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

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

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

先看最小内核:6 行

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

ts 复制代码
while (true) {
  const response = await llm.chat(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.chat(messages)------把完整历史喂给模型。 注意是 messages 而不是只传最后一条。这是 Agent 比 chatBot 贵得多的根本原因:上下文随轮数线性增长。

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 里到底该发生什么

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

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

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

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

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

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

第一档:snipping------直接删。

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

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

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

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

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

ts 复制代码
// 允许被压缩的工具
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------让模型自己摘要。

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

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

shell 复制代码
## 用户意图
(用户在这次对话中想要完成什么)

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

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

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

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

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

还有两个实现细节:

ts 复制代码
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 里解决掉。具体实现放到「工具系统」那一篇展开。

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

如果像这样写

ts 复制代码
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,模型永远不知道发生了什么;而返回一段可读的错误文本,模型下一轮就能看到「哦,这个文件不存在」,然后自己换个路径重试。

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

bash 复制代码
✗  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,给它一个终止条件,它就会自动完成「调用模型 → 执行工具 → 再调用模型」的循环。

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

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

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

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

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

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

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

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

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

ts 复制代码
/**
 * 一个最小但「能进生产」的 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('\n\n----------------')
  console.log(`退出原因:${finalState.exitReason}`)
  console.log(
    `状态:${finalState.turn} 轮 / ${finalState.toolCallCount} 次工具调用 / 截断 ${finalState.truncated} 次`
  )
}

main()

跑起来的输出:

scss 复制代码
我先看一下这个文件。
  [工具] 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 循环里对各种场景的处理怎样,抗逆性如何。

相关推荐
张忠琳3 小时前
【deepseek-harness】DeepSeek Harness Agent Loop 模块深度架构分析之二
ai·agent·deepseek·harness·dsh
Albart5753 小时前
大模型无限循环输出、重复生成文本:参数层面规避幻觉输出实战
大模型·llm·vllm·大模型推理·幻觉·重复输出
用户8082598666873 小时前
给 Agent 装上记忆:多轮对话的历史管理——token 预算、按轮裁剪与滚雪球摘要
agent
用户8082598666873 小时前
给 Agent 接上知识库:RAG 检索链路——分块策略、混合检索与重排
agent
玉宇夕落3 小时前
llm模块二 结构化输出 LangChain 结构化输出完全指南:从 JSON 解析到 withStructuredOutput
langchain·llm
山间小僧3 小时前
「AI学习笔记」Agent Memory(一)会话内记忆
aigc·agent·vibecoding
染指11106 小时前
113.Agent-LangChain核心组件-大模型Short-term_memory短期记忆和PostgreSQL记忆存储
人工智能·langchain·agent·agents
用户283209679376 小时前
Function Calling 只是开始:Agent 工具系统到底难在哪
agent
Eric_见嘉6 小时前
打开这个 VSC 设置「组件引用位置」和数量就都清楚了
前端·typescript·visual studio code