一聚教程网:一个值得你收藏的教程网站

最新下载

热门教程

Agent Loop进阶:从简单死循环走向生产级实现

时间:2026-09-13 16:28:01 编辑:袖梨 来源:一聚教程网

Agent 的核心循环并不复杂:把消息交给模型,根据工具调用执行任务,再将结果写回上下文。真正困难的是,当循环持续几十轮并进入真实业务环境后,如何处理上下文溢出、重复调用、接口限流、输出截断和用户中断。要让它从示例代码变成可靠系统,需要重新审视每一轮中的状态、边界与恢复机制。

Agent Loop - agent的心脏。从6行代码的while循环说起,跟你聊聊写一个agent最底层、必不可少的部分。

AgentLoop:从 while(true) 到生产级循环

先看最小内核:6 行

一个 Agent 最核心的逻辑,可以压缩到这么几行:

while (true) {
  const response = await llm.ch@t(messages)

  // 没有工具调用,说明任务完成,结束循环
  if (response.toolCalls.length === 0) break

  for (const toolCall of response.toolCalls) {
    const result = await executeTool(toolCall)
    messages.push(result) // 把工具结果告诉 LLM
  }
}

我们不妨逐行拆一下,这里每一行都不是随便写的:

messages——loop 的记忆。 它是这轮循环唯一的持久化状态。模型本身是无状态的,它之所以能「记得」前三轮做了什么,全靠我们每轮把完整历史重新喂给它。

llm.ch@t(messages)——把完整历史喂给模型。 注意是 messages 而不是只传最后一条。这是 Agent 比 ch@tBot 贵得多的根本原因:上下文随轮数线性增长。

response.toolCalls.length === 0——唯一的退出条件。 模型说「我不用工具了」,就意味着它认为任务完成,可以给最终答案了。

messages.push(result)——把结果塞回去。 这一步最容易漏。工具执行完如果不把结果写回 messages,下一轮模型看不到,就会一直重复调用同一个工具。

while (true)——不自设轮数上限。 循环的终止权交给模型自己。听起来很优雅,但这也正是后面所有麻烦的来源。

但这 6 行,会在哪些地方崩掉

把上面这段代码直接放进生产环境,你会依次遇到这些问题:

  1. 上下文爆了。 跑到第 30 轮,messages 撑爆了模型的上下文窗口,API 直接报错。
  2. 死循环了。 模型反复调用同一个工具、同样的参数,你拦不住,因为循环里没有任何检测逻辑。
  3. API 挂了。 一个 429 限流,整个任务当场中断,前面 20 轮白跑。
  4. 用户以为卡死了。 一轮循环可能几十秒,中间没有任何输出,用户等不及直接 ctrl+c。
  5. Token 烧穿了。 一觉醒来发现多了一位数字。
  6. 输出被截断了。 模型说到一半撞上 max_output_tokens,它自己不知道,你也以为它说完了。

发现了吗?这六个问题,没有一个出在「循环」这个结构本身

所以「能跑的 loop」和「生产级的 loop」之间的差距,不在于要不要写 while,而在于——在每一轮循环里,你还额外做了什么。

一轮 loop 里到底该发生什么

把这六类问题归位,一轮循环里其实有五个阶段:

┌─────────────────────────────────────────────────────┐
│                  while (true)                       │
│                                                     │
│  ① 准备上下文 ── 快爆了吗?压缩、裁剪、注入预算警告     │
│         │                                           │
│         ▼                                           │
│  ② 调用模型 ──── 流式接收;识别到工具就立刻开始执行     │
│         │         (不冲突的才能并行)                │
│         ▼                                           │
│  ③ 决定是否继续 ─ 不只是「有没有工具调用」             │
│         │                                           │
│         ▼                                           │
│  ④ 执行工具 ──── 报错信息要写给模型看,不是给人看       │
│         │                                           │
│         ▼                                           │
│  ⑤ 构建下一轮状态 ─ 记录轮数、token、压缩点、截断次数   │
│         │                                           │
│         └──────────► 回到 ①                         │
└─────────────────────────────────────────────────────┘

下面就按这五个阶段,逐个说清楚它们各自在解决什么问题。

准备上下文——要在爆掉之前动手

这是最容易被忽略的阶段。大多数人的做法是「等 API 报 context length exceeded 再处理」,但那时候已经晚了:报错就意味着这一轮已经浪费掉了。

正确的做法是在进入模型调用之前评估,而压缩有轻重三档:

第一档:snipping——直接删。

把最老的消息整条丢掉。代价最小(零 token 开销,不需要调模型),但信息真的丢了。适合处理那些已经确认不再需要的中间结果。

第二档:microcompact——局部替换。

不破坏对话结构,只把旧工具调用的结果替换成占位符。

这个思路的关键是:工具结果往往是上下文里最占地方、又最快过期的内容。第 3 轮 read_file 读到的文件内容,到第 20 轮几乎不可能再被引用。但它的字符数,可能比所有用户消息加起来还多。

实际实现时有两个细节值得注意:

// 允许被压缩的工具
const CLEARABLE_TOOLS = new Set([
  'read_file', 'bash', 'grep', 'glob', 'list_directory', 'edit_file', 'write_file'
])
const KEEP_RECENT_TOOL_RESULT = 3   // 保留最近 3 个工具调用结果

export function microcompact(messages: ModelMessage[]) {
  // 找出所有工具结果的位置
  const toolResultIndices = messages
    .map((m, i) => (m.role === 'tool' ? i : -1))
    .filter(i => i !== -1)

  // 只清理「不包括最近 3 个」的那些
  const toClear = toolResultIndices.slice(
    0, Math.max(0, toolResultIndices.length - KEEP_RECENT_TOOL_RESULT)
  )

  let cleared = 0
  const result = messages.map((msg, idx) => {
    if (!toClear.includes(idx)) return msg
    if (msg.role !== 'tool' || !Array.isArray(msg.content)) return msg

    // 白名单之外的工具不清理
    const toolName = (msg.content[0] as any)?.toolName || 'unknown'
    if (!CLEARABLE_TOOLS.has(toolName)) return msg

    cleared++
    return {
      ...msg,
      content: msg.content.map((part: any) => ({
        ...part,
        output: textToolResultOutput(`[tool result cleared]`),
      })),
    }
  })

  return { messages: result, cleared }
}

两个细节:一是留最近 3 个——模型正在处理的那批工具结果不能动,否则它会突然「忘了」自己刚读到什么;二是白名单——像 memory 写入、知识库检索这类结果,往往是任务的关键依据,不适合按「新旧」一刀切。

第三档:summarize——让模型自己摘要。

前两档都是「丢信息换空间」,这一档是用一次额外的模型调用,把旧对话变成一份结构化摘要。代价最贵,但信息保留得最好。

关键在于摘要提示词的质量。如果只是让它「总结一下」,它会给你一段笼统的话;真正有用的是结构化模板

## 用户意图
(用户在这次对话中想要完成什么)

## 已完成的操作
(Agent 执行了哪些工具调用、产生了什么结果)

## 关键发现
(读取的文件内容要点、搜索结果中的关键信息)

## 当前状态
(对话进行到哪一步了、还有什么没做完)

## 需要保留的细节
(文件路径、变量名、配置值、错误信息等不能丢失的具体内容)

最后那一条是灵魂。摘要最容易出的问题就是「把 src/utils/format.ts:42 概括成『某个工具文件』」,模型拿着这份摘要根本没法继续干活。所以要明确要求:文件路径、UUID、版本号原样保留

还有两个实现细节:

const CONTEXT_TOKEN_THRESHOLD = 300   // 消息开销小于 300 token 就不摘要
const KEEP_RECENT_MESSAGES = 6        // 保留最近 6 条原始消息
  • 阈值太小的话,为了省 200 token 花掉一次完整的模型调用,纯亏。
  • 保留最近 6 条之后,还要往前回退到最近一条 user 消息再切分,否则可能把一次工具调用和它的结果切在两边,模型会看到「调用了工具但没有结果」的残缺结构。

三个阶段的关系是递进的:先 snipping,不行再 microcompact,实在不行才 summarize。每次能用便宜的手段解决,就不要动用模型。

调用模型——边说边执行

这个阶段有两个反直觉的设计。

第一:工具不用等模型说完

流式返回时,模型是一个字一个字往外吐的。如果等它完整说完再解析工具调用,那几十秒的输出时间就白等了。

实际的做法是:边输出边识别。一旦流里出现了完整的 tool-call 块,立刻开始执行,不必等结束事件。用户看到的效果就是「模型还在说话,工具已经跑完了」。

第二:只有不冲突的操作才能并行

模型一次回复里可能说要调用多个工具,比如「读 A 文件、读 B 文件、写 C 文件」。这三个能并发吗?

不能全并发。读文件可以并行,写文件必须串行——否则两个工具同时写同一个文件,结果不可预期。

这个约束的实现手段是一把读写锁:

  • 只读工具获取共享锁,可以和其它只读工具同时持有
  • 读写工具获取独占锁,必须等所有其它工具都执行完才能开始

模型的输出是并发的,但工具的语义是有冲突的,这个矛盾必须在 loop 里解决掉。具体实现放到「工具系统」那一篇展开。

决定是否继续——最被低估的地方

如果像这样写

if (response.toolCalls.length === 0) break

然后就说「这样 Agent 就会在任务完成时停下了」。这是远远不够的。

真实的退出场景,光 Claude Code 里就有 7 种(实际是 10 种):

  1. LLM 没有工具调用 —— 正常完成
  2. 流式传输过程中被中断 —— 比如人为手动打断
  3. 工具执行被中断 —— 用户中途取消
  4. hook 阻止了继续执行 —— 权限或安全策略拦截
  5. 超过了最大轮数 —— 硬性保鲜丝
  6. 上下文过长,API 拒绝 —— 压缩没救回来
  7. 压缩后上下文还是过长,无法恢复 —— 彻底放弃

这份清单值得反复看。它说明一件事:「循环结束」不等于「任务完成」

一个生产级 Agent 必须能区分这些情况,因为它们的后续动作完全不同:

  • 情况 1 是成功,可以给用户最终答案
  • 情况 5 是没做完但被迫中断,要告诉用户「我跑了 50 轮还没搞定,可能需要你介入」
  • 情况 6、7 是失败,要提示用户「上下文超限,建议开新会话或缩小任务范围」

如果这七种情况都走进同一个 break,用户看到的就只有「Agent 突然停了」,完全不知道发生了什么。

执行工具——错误信息是写给模型看的

工具执行失败时,是抛异常还是返回错误字符串?

答案是返回字符串。因为工具结果的接收方不是人,是模型。

抛异常会直接中断整个 loop,模型永远不知道发生了什么;而返回一段可读的错误文本,模型下一轮就能看到「哦,这个文件不存在」,然后自己换个路径重试。

所以错误信息要写得足够优雅:

✗  Error: ENOENT
✓  错误:文件 src/utils.ts 不存在。当前目录下的文件有:
    src/utils/format.ts、src/utils/date.ts,请确认路径。

后者不只是报告错误,还给模型提供了下一步的线索。这个差别在实际任务里非常明显——它决定了 Agent 是能自己爬起来,还是就此卡死。

构建下一轮状态——看一步

进入下一轮之前,还有一些零碎但必要的工作:

  • 检查当前有哪些 skill 可用,需不需要注入新的行为规范
  • 记录这一轮消费掉了哪些命令、读了哪些文件(避免重复劳动)
  • 清理已经废弃的临时状态

这些事都不复杂,但漏掉任何一件,都会在后面某一轮以奇怪的方式表现出来。

状态追踪:loop 的仪表盘

上面五个阶段能顺利运转,靠的是一个东西:状态

一个生产级 loop 至少要能随时回答这五个问题:

1. 现在到第几轮了? 判断是不是该停下。这是最基础的保鲜丝。

2. 上一轮为什么选择继续? 是正常执行完了继续?还是遇到了错误在恢复?还是在重试压缩?同样一个「继续」,背后的含义完全不同。

3. 压缩执行到哪了? 是不是已经触发过紧急压缩?压缩之后 token 降了多少?如果压缩完还是超限,说明该放弃了。

4. 输出被截断了几次? 模型输出撞上 max_output_tokens 被截断,可以尝试注入恢复消息让它接着说。但要有次数上限:第一次恢复、第二次恢复、第三次就认栽,把不完整的结果返回给用户并标记「输出被截断」。

5. 有没有被挂起的任务? 比如等待用户确认的危险操作。

这五个问题的答案,几乎决定了 loop 里所有的分支决策。没有状态追踪的 loop,只能做出「继续」或「退出」两个选择;有了状态追踪,才能做出「继续 / 告警 / 恢复 / 降级 / 熔断」这五个选择。

实时反馈:Agent 必须边跑边说

这一点经常被工程上的讨论忽略,但它直接决定产品能不能用。

一次 Agent 任务可能跑几十秒到几分钟。如果这期间终端上什么都不显示,用户的第一反应不是「它在努力工作」,而是「它是不是卡死了」,然后直接 ctrl+c。

所以中间过程必须实时暴露:

  • 模型正在说什么(流式输出)
  • 正在调用哪个工具、参数是什么
  • 工具返回了什么(可以截断预览)
  • 当前是第几轮、花了多少 token

实现手段上,用 async generator 会很自然——模型流本身就是一个异步迭代器,一层层 for await 处理下去,天然就实现了「边产出边消费」。

那为什么不直接用 SDK 自带的循环?

说到这里,一个自然的疑问是:现在的 AI SDK 不是已经内置了循环机制吗?

确实有。比如 Vercel AI SDK 的 stopWhen,给它一个终止条件,它就会自动完成「调用模型 → 执行工具 → 再调用模型」的循环。

// SDK 自带的循环:给定终止条件,自动循环
const result = streamText({
  model,
  tools,
  messages,
  stopWhen: stepCountIs(10),   // 最多 10 步
})

但这个便利是有代价的:你没法在循环中间插入自己的逻辑。

而上面五个阶段讲的每一件事——压缩、并发控制、循环检测、状态追踪、预算控制、权限检查——全部都是「循环中间的逻辑」。

用 SDK 自带的循环,你等于把这五个阶段全部放弃了,只剩下一个「能跑通」的 demo。

所以我的选择是:把 SDK 降级为「一次模型调用」,循环自己写。

const result = streamText({ model, tools, messages, system, maxRetries: 0 })
for await (const part of result.fullStream) {
  // 每一次工具调用、每一个文本增量,都从这里过一遍
  // 想在哪插入逻辑,就在哪插入
}

maxRetries: 0 也是必须的——重试要由我们自己控制(指数退避、判断哪些错误值得重试),不能交给 SDK 拍脑袋。

最小示例:一个能跑的 loop 骨架

下面是一个完整可运行的骨架。它没有连接真实模型(用 mock 代替),但五个阶段和状态追踪一个不少,你可以直接跑起来看它的执行过程:

/**
 * 一个最小但「能进生产」的 AgentLoop 骨架
 * 运行:npx tsx agent-loop.ts
 * 无需任何 API Key —— 模型用 mock 模拟,换成真实的 streamText 即可
 */

// ---------- 1. 类型:loop 只认这三种信号 ----------
interface ToolCall { id: string; name: string; args: Record<string, any> }

type Chunk =
  | { type: 'text'; text: string }         // 模型说了几个字
  | { type: 'tool-call'; call: ToolCall }  // 模型要用工具
  | { type: 'finish'; reason: 'end_turn' | 'tool_use' | 'max_tokens' }

// ---------- 2. 工具:模型的手脚 ----------
const tools: Record<string, (args: any) => Promise<string>> = {
  read_file: async ({ path }) =>
    `export function formatDate(d) { return moment(d).format('YYYY-MM-DD') } // << ${path}`,
  edit_file: async ({ path, content }) =>
    `已写入 ${path}(${content.length} 字符)`,
}

// ---------- 3. 模型:这里用 mock,真实项目换成 streamText ----------
async function* mockModel(turn: number): AsyncGenerator<Chunk> {
  const plan: Array<{ text: string; call?: ToolCall }> = [
    { text: '我先看一下这个文件。', call: { id: 'c1', name: 'read_file', args: { path: 'src/utils.ts' } } },
    { text: '找到了 moment 的用法,把它换成 dayjs。', call: { id: 'c2', name: 'edit_file', args: { path: 'src/utils.ts', content: "import dayjs from 'dayjs'" } } },
    { text: '重构完成:moment 已全部替换为 dayjs。' },
  ]
  const step = plan[Math.min(turn, plan.length - 1)]
  for (const ch of step.text) yield { type: 'text', text: ch } // 逐字流式
  if (step.call) yield { type: 'tool-call', call: step.call }
  yield { type: 'finish', reason: step.call ? 'tool_use' : 'end_turn' }
}

// ---------- 4. 状态:loop 的仪表盘 ----------
interface LoopState {
  turn: number              // 现在第几轮
  exitReason: string        // 为什么退出
  toolCallCount: number     // 一共调了几次工具
  lastFinishReason: string  // 上一轮为什么继续
  truncated: number         // 输出被截断了几次(本骨架未实现截断恢复,恒为 0)
}

// ---------- 5. loop 本体 ----------
async function agentLoop(task: string, maxTurns = 12): Promise<LoopState> {
  const messages: Array<{ role: string; content: string }> = [{ role: 'user', content: task }]
  const state: LoopState = {
    turn: 0, exitReason: '', toolCallCount: 0, lastFinishReason: '', truncated: 0,
  }

  while (true) {
    // —— 阶段 1:进入新一轮前,先检查要不要干预(压缩 / 预算 / 熔断)——
    state.turn++
    if (state.turn > maxTurns) {
      state.exitReason = `超过最大轮数 ${maxTurns}`
      break
    }

    // —— 阶段 2:调用模型,流式接收 ——
    let text = ''
    const calls: ToolCall[] = []
    let reason = 'end_turn'

    for await (const chunk of mockModel(state.turn - 1)) {
      switch (chunk.type) {
        case 'text':
          text += chunk.text
          process.stdout.write(chunk.text) // 实时反馈:边跑边说
          break
        case 'tool-call':
          calls.push(chunk.call) // 边输出边收集,不等模型说完
          break
        case 'finish':
          reason = chunk.reason
          break
      }
    }
    state.lastFinishReason = reason

    // —— 阶段 3:退出条件(这只是其中一种)——
    if (calls.length === 0) {
      state.exitReason = '模型没有工具调用,任务结束'
      break
    }

    // —— 阶段 4:执行工具 ——
    for (const call of calls) {
      const fn = tools[call.name]
      const result = fn ? await fn(call.args) : `[错误] 没有名为 ${call.name} 的工具`
      state.toolCallCount++
      console.log(`n  [工具] ${call.name} -> ${result}`)
      messages.push({ role: 'tool', content: result }) // 结果塞回 messages
    }

    // —— 阶段 5:构建下一轮状态 ——
    messages.push({ role: 'assistant', content: text })
    console.log(`n  [继续] 第 ${state.turn} 轮结束,进入下一轮`)
  }

  return state
}

// ---------- 6. 跑起来 ----------
async function main() {
  const finalState = await agentLoop('把 src/utils.ts 里的 moment 替换成 dayjs')

  console.log('nn----------------')
  console.log(`退出原因:${finalState.exitReason}`)
  console.log(
    `状态:${finalState.turn} 轮 / ${finalState.toolCallCount} 次工具调用 / 截断 ${finalState.truncated} 次`
  )
}

main()

跑起来的输出:

我先看一下这个文件。
  [工具] read_file -> export function formatDate(d) { return moment(d).format('YYYY-MM-DD') } // << src/utils.ts

  [继续] 第 1 轮结束,进入下一轮
找到了 moment 的用法,把它换成 dayjs。
  [工具] edit_file -> 已写入 src/utils.ts(25 字符)

  [继续] 第 2 轮结束,进入下一轮
重构完成:moment 已全部替换为 dayjs。

----------------
退出原因:模型没有工具调用,任务结束
状态:3 轮 / 2 次工具调用 / 截断 0 次

注意最后两行——它把退出原因状态都打了出来。这就是前面说的「状态追踪」:同样是结束,你能一眼看出它是正常完成,还是撞了轮数上限,还是被熔断。

总结

回到开头那句话:LLM 和 Agent 之间只差一个 loop。

但是loop之间,亦有高低。

  • 6 行代码就能让它跑起来,可是仅仅只是跑起来而已。
  • 真正核心的部分,全在「每一轮里还应该发生什么」——准备上下文、并发控制、退出判断、状态追踪、实时反馈

所以判断一个 Agent 是不是「生产级」,不要看它能不能跑通一个 demo,去看它的 while 循环里对各种场景的处理怎样,抗逆性如何。

热门栏目