Agent 不是更聪明的 ChatGPT:一次请求背后的循环机制
你让 ChatGPT 把项目里的 moment 换成 dayjs,它通常会回你一整段修改建议。
你让 Claude Code、Codex 这类编码 Agent 做同一件事,它会自己打开 package.json,先看 dayjs 装没装;再搜出所有引用点,一个文件一个文件地改;最后跑一遍测试,确认没炸。
表面看,只是"多调了几个工具"。但两者的分野不在工具数量,而在谁握着那个循环。
Chatbot 是你说一句、它回一句。Copilot 是你在干活、它给建议,接不接由你定。Agent 则把"想一步、做一步、看一步"的循环整个拿到了自己手里------人只负责给目标,Agent 负责一轮一轮推进,直到做完、失败,或被喊停。
本文就顺着一次真实请求,把这个循环拆开给你看。
一、ReAct 不难,难在每一轮都要收得住
Agent 的基础模式通常被概括成 ReAct:Reasoning、Acting、Observation。推理、行动、观察,然后进入下一轮。
简化成代码大概是这样:
ts
while (!shouldStop) {
const context = prepareContext();
const response = await callModel(context);
if (!response.toolCalls.length) {
stop("模型认为任务已经完成");
}
const results = await executeTools(response.toolCalls);
state.add(response, results);
}
真正投入生产的 Agent,循环里面塞的东西远不止这几行。
第一,准备工作不是把历史消息原样带上。它要检查上下文有没有接近上限,决定是否清理旧的工具结果、裁剪消息或者生成摘要。上下文处理得太晚,下一轮 API 可能直接拒绝请求。
第二,不是等模型把整段回答生成完才看工具调用。模型在流式输出时,工具名和参数会逐步出现:
json
{"file_
path": "
src/uti
ls.ts"}
这里存在一个时机问题。解析太早,可能调用错误的工具,甚至因为 JSON 不完整直接崩溃;解析太晚,用户会盯着界面等,并发和反馈都跟不上。工程实现通常要一边接收 token,一边判断工具调用块是否已经完整,参数通过校验后再执行。
第三,执行工具时不能简单串行,也不能全部并发。读文件、Glob、Grep 这类只读操作可以并行;Edit 这类写操作必须串行;Bash 更麻烦,同为命令,ls 和 rm 的风险完全不同,需要单独判断。
第四,工具结果回到上下文之后,这一轮才算结束,然后再准备下一轮。Agent 不是在执行一条超长指令,而是在反复完成"组装上下文、调用模型、执行工具、更新状态"。
二、流式输出为什么常用 SSE,而不是 WebSocket
LLM 的输出天然适合流式传输。模型本来就是一个 token 一个 token 地生成,等到完整回答生成完再返回,用户只会看到长时间白屏。
这里的通信方向和普通聊天不完全一样。虽然用户在界面上可以随时按停止、补充消息或者审批工具,但在一次生成过程中,主要数据流是服务端持续向客户端推送 token。WebSocket 提供全双工能力,当然能做;SSE 更贴近这种单向推送场景。
SSE 本质上是基于 HTTP 的长连接,协议本身带重连机制。客户端发一个请求,服务端持续通过事件流返回数据。
不过,SSE 是单向的,不代表用户不能批准工具执行。
一个常见的交互过程是:
- 模型流式输出,明确要调用某个需要授权的工具;
- 这一轮流结束,界面弹出审批;
- 用户点击允许,客户端发送一个普通的 HTTP 请求;
- 服务端执行工具,再开启一条新的 SSE 流,把后续结果推给客户端。
也就是说,审批走的是另一条请求,不需要硬塞进当前 SSE 连接里。
SSE 还有一个容易被忽略的问题:TCP 的半开状态。用户从 Wi-Fi 切到移动网络时,底层连接可能已经不可用,但浏览器还在等数据。HTTP 长连接不会自动知道对端已经失联。
解决办法通常有两层:
- 服务端定期发送 SSE 注释作为心跳,保持连接可感知;
- 客户端维护看门狗,记录最后一次收到数据的时间,超过阈值就主动断开并重连。
只做服务端心跳不够,客户端也得能发现"很久没消息了"。
三、工具可以边说边调用,但执行顺序要有确定性
模型在输出工具调用 JSON 时,有些实现会允许工具提前进入执行队列。这里最容易踩的坑是:把"可以并行"理解成"结果可以乱序返回"。
读文件和搜索可以同时跑,但写文件必须等前置读取和判断完成。Claude Code 一类实现的并发策略通常遵循一个朴素原则:只读操作能并发,写入操作串行,Shell 命令按具体动作判断。ls 可以并发,rm 最好单独串行处理。
即使多个只读工具并行执行,结果回填到上下文时通常也会按工具调用顺序排列。原因不复杂:
- 下一轮模型看到的结果稳定;
- 用户看到的过程可读;
- 出现问题时方便对照调用和结果;
- 测试更容易复现,不会因为返回顺序不同得到不同结果。
并发解决的是等待时间,顺序解决的是可控性。这两件事不能混在一起。
四、状态追踪决定 Agent 什么时候停
一个没有状态管理的 Agent,最典型的表现就是该停时不停,不该重试时继续重试。
循环至少要回答下面几个问题:
- 现在跑到第几轮了?
- 上一轮为什么继续?是正常推理,还是错误恢复、压缩重试?
- 上下文压缩执行到哪里?压缩后释放了多少空间?
- 输出被截断了几次?
- 有没有挂起任务或者等待用户确认的操作?
这些问题看似琐碎,却决定了整个系统能不能刹车。
以 Claude Code 这类编码 Agent 的退出条件为例,常见停止原因不只一种:
- 模型没有返回工具调用;
- 流式连接被中断;
- 工具执行被中断;
- Hook 阻止继续执行;
- 达到最大轮数;
- 上下文过长,API 拒绝请求;
- 压缩之后上下文仍然过长,无法恢复。
这里有个很重要的设计思路:"模型说完成了"只是退出条件之一,不是唯一条件。
如果只相信模型自己的判断,那么连接异常、工具阻塞、上下文溢出、Hook 拒绝都会变成未知状态。一个可靠的循环需要同时理解模型状态、协议状态、工具状态和用户状态。
五、实时反馈不是 UI 装饰
Agent 跑一轮可能需要几十秒。如果界面只在全部完成后一次性给结果,用户不知道它是在读文件、执行命令,还是已经卡死。
更合理的方式是持续把中间状态展示出来:当前调用了什么工具、参数是什么、工具返回了什么、为什么进入下一轮。实现上通常会使用生成器或者事件流,把模型输出和工具事件不断推给上层。
这不仅是为了"看起来在动"。当 Agent 做错了,用户越早看到中间过程,越容易中断并纠正。等它改了二十个文件再发现方向错了,回滚成本会高得多。
反馈也不能把模型内部所有推理都原样暴露。应该展示可验证的动作:调用了什么工具、访问了哪个文件、执行结果如何,以及当前进入了哪个恢复分支。用户看到的是运行证据,不是装饰性的进度条。
六、Agent Loop 的真正复杂度在 Harness
模型负责推理和决定下一步,Harness 负责让这件事在真实环境里安全地发生。
它要处理上下文压缩、SSE 重连、工具并发、权限审批、错误恢复、状态记录、退出条件,以及用户按下 Ctrl+C 之后怎么收尾。模型换一个,这些工程问题仍然存在;Harness 写得太薄,换个更强的模型也救不回来。
回到开头那个"替换 moment 为 dayjs"的例子。用户只看到一句话,背后可能发生了十几轮循环:读配置、装依赖、全局搜索、修改文件、跑测试、根据报错回退、再次修改。
这就是 Agent 和 Chatbot 的分界。前者不是回答得更长,而是能够在环境里持续行动,并且知道什么时候应该停下来。
如果要验证一个 Agent 的 Loop 做得好不好,不要只看它能不能完成一次演示。更值得看的是:它遇到连接中断、工具报错、上下文溢出和用户拒绝时,还能不能把事情讲清楚,然后干净地退出。