你已经知道Agent循环是什么了,但是它真的有这么简单吗?

70 阅读5分钟

前言

上一篇文章我们知道了,Agent想要干活,主要是 Agent Loop 和 ToolSystem 在干活,那么本文我们来聊聊在Agent Loop中,我们会遇到哪些问题。

从零实现 Agent Loop:一个 while(true) 里能踩多少坑

你跟 ClaudeCode 在终端说一句:「把项目里所有 console.log 换成 logger.info」。

然后你看到它开始读文件、改文件、再读、再改。整个过程背后就是一个循环。

你的第一版 Agent Loop 大概长这样:

const messages = [{ role: 'user', content: '把所有 console.log 换成 logger.info' }]

while (true) {
  const res = await chat(messages)
  if (res.stopReason === 'end_turn') break

  for (const tool of res.toolCalls) {
    const result = await executeTool(tool.name, tool.arguments)
    messages.push({ role: 'tool', content: result })
  }
}

核心就 5 行。但真的跑起来你会发现,这个循环之所以叫 Loop,是因为它真的会一直 Loop 下去。

坑一:它自己停不下来

Agent 失控的常见方式有三种:死循环——反复调用同一个工具做同样的事;Token 烧穿——无限续写;输出截断——输出到一半被截断,它自己不知道。

所以 Agent Loop 的第一件事不是能力,是保险丝。

保险丝一:给工具调用打指纹。 把「工具名 + 参数」做一次 SHA256,得到一个指纹。同样的调用、同样的参数、同样的结果,就说明这一轮没有产生任何进展。

function fingerprint(name, params) {
  const stable = JSON.stringify(params, Object.keys(params).sort())
  return createHash('sha256').update(name + stable).digest('hex').slice(0, 12)
}

有了指纹就能分级处理:重复 5 次注入警告——「你正在反复调用工具且没有进展,请换一种方式」;重复 10 次直接阻断。

只看重复还不够,还要看结果。read_file → write_file 来回切这种 ping-pong,参数每次都不同,指纹对不上,但状态没变化。所以第二种检测器要同时对比结果指纹,结果一样才算一次无进展。最后加一道全局熔断:累计 30 次无进展,强制停止,没有例外。

保险丝二:Token 预算。 给每个任务预先定预算,跑到 90% 时注入一条提示:「已完成 Token 目标的 87%(26170/30000),继续工作,不要总结」。

为什么要特意写「不要总结」?因为模型感知到上下文快满了会偷懒——跳过步骤、简化回答、不调用工具,靠省着用来维持空间。

还要检测递减回报:续写第一次 +3000 token,第二次 +2000,第三次 +400,第四次 +200,连续两次递减就是在原地打转,停。

保险丝三:输出截断。 每个模型都有 max_output_tokens,Claude 默认 8192。超过这个数就被截断,而它并不知道,它会认为自己已经说完了。

ClaudeCode 的处理是:把上限提到 64k;真被截断了就注入一条恢复消息——「输出 Token 限制被触发,不要道歉,不要回顾你在做什么,直接从断点接着说,把剩下的工作拆成更小的块」。三次恢复都不行就认栽,把不完整的结果标记截断后返回。硬撑只会烧更多 token。

坑二:流式和 Tool Call 撞在一起

早期是白屏几秒、出一段内容、工具调用、又白屏几秒。现在模型边输出边调用工具。

为什么是 SSE 而不是 WebSocket?WebSocket 是全双工的,客户端可以随时说话。但 LLM 的流式输出本质上是服务端单向往外推 token,客户端不需要说话。SSE 是 HTTP 的特殊模式,长连接加单向推送,还自带重试重连。杀鸡不用牛刀。

真正的麻烦在解析。模型输出的 tool_use 不是一次给你的,是一小片一小片蹦出来的:

'{"file_'
'path:" "'
'src/uti'
'ls.ts"}'

过早解析——拿着半截 JSON 去执行,调用错工具或者直接崩;过晚解析——等全部输出完再动手,流式的意义就没了。你只能在「看起来像完整 JSON」的那一刻才接管。

还有两个细节。并发安全:Read、Glob、Grep 只读,随便并发;Edit 写入,必须串行;Bash 更麻烦,ls 可以并发,rm 必须串行。结果顺序:按工具调用的顺序返回,不是按完成顺序——牺牲一点效率,换可读性和可调试性。

坑三:错了怎么办

先分类,再谈策略。可重试的:429 限流、529/503 过载、408 超时、ECONNRESET 连接重置。不可重试的:400 参数错误、401/403 认证失败、402 余额不足。需要降级的:连续多次 529、流式连接反复断开。

单次失败怎么重试?指数退避加随机抖动。

能不能固定时间重试?不能。固定时间会引发重试风暴——所有请求同时重试、同时到达服务端,服务端继续拒绝,然后它们再同时重试。指数退避:第一次等 500ms,第二次 1000ms,第三次 2000ms,每次翻倍;再叠一个 0-100ms 随机抖动把请求打散。

流式还有一个更隐蔽的坑:TCP 半开。用户切 WiFi、切移动数据时,客户端的 TCP 可能已经断了,但 SSE 维护的是 HTTP 长连接,浏览器层不知道,页面就一直挂在那里等数据,永远不会超时。解法是两头都加东西:服务端定期发心跳(SSE 可以发注释,不会触发客户端逻辑);客户端加看门狗,记录最后一次收到数据的时间,超阈值就主动重连。

如果重试也不管用,就降级:流式降为非流式减轻服务端压力,再不行换模型。OpenClaw 把故障分得更细——临时故障用指数退避冷却,持久故障走单独禁用通道,退避基数拉到 5 小时,最长 24 小时。前提是提前备好兄弟模型。

最后

一个 Agent Loop 的全部内容是:调模型、执行工具、把结果塞回去、再来一轮。

5 行就能写完。真正难的是那些「不写就会出事」的部分——循环检测、预算控制、截断恢复、重试退避、连接保活。

模型负责聪明,Loop 负责活着。