Skip to content

02 · 手写工具循环(Agent 核心) ​

这一站解决 Agent 的根本问题:LLM 只会「说」不会「做」。学完你将拥有一个 200 行、无框架、每步可观测、带五道工程防御的工具循环——以及理解所有 Agent 框架剥到最里层后的那台发动机。

前置:01 · LLM API 与结构化输出(JSON 模式 vs 工具调用的可靠性差异是本站的地基) 配套跑起来:examples/minimal-agent(40 行最小实现)+ pnpm chat --trace(全功能版) 深读:参考库 · 工具循环深读(逐行代码走读与设计取舍表)


一、为什么:一问一答的天花板 ​

裸 LLM 是「一问一答」的文本函数:你问一句,它答一句,会话结束。但真实任务是多步的:

text
用户:「订单 A-1024 到哪了?」
模型内心:我需要先查订单状态,拿到结果才能回答。
                ↑ 「查」这个动作,模型做不到——它没有手。

解法的三块拼图:

  1. 工具清单——随请求附上一份「你能做什么」的说明(名称 + 用途 + 参数图纸);
  2. 点名机制——模型不直接回答,而是说「我要调 getOrderStatus,参数 {orderId: "A-1024"}」(这就是 function calling,模型训练时专门学过的输出模式,比「求它输出 JSON」可靠得多——原因见上一站的可靠性排序);
  3. 回灌循环——我们的代码替它执行工具,把结果塞回对话,让模型看着结果继续。

三者合体就是 ReAct 循环(Reason + Act):所有 Agent 框架(LangChain / LangGraph / CrewAI)剥到最里层都是它。


二、循环的最小形态:从零推导 15 行 ​

不要背概念,直接推。循环需要的只有四步:

ts
// agent-loop.ts 的骨架(简化到只剩脉络)
for (let step = 1; step <= maxSteps; step++) {
  // ① 思考:对话历史 + 工具清单发给模型
  const result = await generateText({ model, messages, tools });

  // ② 出口:模型不再要工具 = 它认为办完了,文本即最终回答
  if (result.toolCalls.length === 0) return { text: result.text, ... };

  // ③ 行动:逐个执行模型点名的工具
  for (const call of result.toolCalls) {
    const output = await execute(call);          // 校验闸在这里(见第四节)
    // ④ 回灌:结果包成 role: "tool" 消息塞回历史
    messages.push({ role: "tool", content: [makeToolResult(call, output)] });
  }
  // → 回到 ①,模型看着结果继续……
}
throw new Error(`步数用完(${maxSteps})`);       // 保险丝(见防御 1)

最容易忽略的一步是 ④ 回灌:模型是无状态的,每次调用只看到 messages 数组——不把工具结果塞回去,下一轮它就「失忆」,等于查了白查。

为什么手写而不用框架:框架的价值是省事,代价是黑盒。手写 200 行,每一步都能插桩(日志、审批、缓存、中止)——后面你会看到,五个工程防御全部是在这 200 行上「加壳」实现的,框架里你连壳往哪加都找不到。


三、跑起来(先有体感再读实现) ​

powershell
cd agent-app
pnpm build                    # examples 引用构建产物
pnpm examples:minimal         # 40 行最小版:模型自己决定查几次天气
pnpm chat --trace             # 全功能版:轨迹打开,每一步打到终端

--trace 的输出就是循环的心电图:

text
▶ 思考 step 1 → 调用模型(上下文 1 条消息,可用工具 3 个)
⚙ 行动 step 1 → 要调 1 个工具:getOrderStatus({"orderId":"A-1024"})
✓ 观察 step 1 → getOrderStatus 返回:{...}
▶ 思考 step 2 → ...
◆ 完成 → 模型给出最终回答(25 字,共 2 步)

注意两个概念(容易混):

  • step = 模型被调用一次,不是工具调用次数——一个 step 里模型可以点名多个工具;
  • onStep 钩子是循环对外的「现场直播」出口:API 层靠它把每步推给浏览器渲染思考面板,评测框架靠它收集轨迹打分。循环不认识任何订阅者——这就是解耦。

四、工程化:从玩具到可靠的五道防御 ​

上面的循环是玩具。真实系统里,下面五个故障全部实际发生过(本仓库的开发过程就是证据),每个对应一道防御。这条「故障 → 防御」的清单才是本章的核心知识:

防御 1 · maxSteps 保险丝 —— 防死循环烧钱 ​

故障:模型陷入循环(反复调同一个工具),永远跑下去——每轮烧 token、用户界面无限「思考中…」。 防御:步数上限,用完抛「步数用完」明确失败。刻意不让模型「拿着半截结果硬答」——宁要明确的失败,不要虚假的成功(与「空数据不编零」同一条原则)。 参数:库级默认 5(DEFAULT_MAX_STEPS);合理流程真的需要更多步就调大,代价是失控风险。

防御 2 · 入参校验闸 + 错误回灌 —— 防参数幻觉 ​

故障(实测):用户问「查 9 月份订单」(没说年份),glm-4-flash 幻觉出 2022-09;用户说「广东深圳这个代理商」,模型把类型词一起塞进名字参数。 防御:工具的 zod schema 在执行前跑 safeParse(业务函数永远只见到合法参数);失败不炸循环,而是把带修正方向的错误说明包成 error-json 回灌——模型读到「月份超出合理范围……按当前年份重试」后,下一轮自己改对。 精髓:错误消息是写给模型看的指令,zod 的 message 字段身兼「报错」与「重试指南」两职。

防御 3 · 幂等壳 —— 防写操作重复执行 ​

故障:模型重试、用户重复提问,都会让「创建工单」这类写操作执行多次——生产库里多出没人要的脏数据。 防御:wrapToolsWithIdempotency 给工具表套壳:会话 + 工具名 + 参数指纹在 TTL 窗口内只真执行一次,重放直接返回首次结果(实测:同会话重复建单拿到同一个工单号)。三条纪律——成功才缓存、在途共享、会话隔离——见工具箱深读。

防御 4 · 审批壳 —— 高危操作先问人 ​

故障:模型「好心」地帮用户建单/删数据——有些操作的代价不该由模型拍板。 防御:AGENT_CONFIRM_TOOLS 名单内的工具执行前挂起,发审批事件等人工裁决(允许/拒绝/60s 超时自动拒绝);拒绝时模型收到结构化拒绝值,能礼貌收尾。 叠加顺序:审批壳在外、幂等壳在内——被拒绝的调用不污染幂等缓存。

防御 5 · signal 中止 —— 防断连烧钱 ​

故障:用户关掉浏览器页面,服务端还在慢悠悠调模型——token 白烧。 防御:AbortSignal 三层贯穿(HTTP 断开 → 循环每步前置检查 → 模型调用层),断开后停止一切生成。循环中止抛类型化错误(ToolLoopAbortedError),调用方按 instanceof 静默收场。

五道防御的共同模式:全部以「壳」或「闸」的形式叠加在循环周边,循环本体一行不改。这就是为什么循环值得手写——壳往哪加,你说了算。


五、三个深入岔路(真实案例,每一 case 都值一课) ​

岔路 1 · Thought 透传。模型伴随工具调用的「思考文本」(text 字段)常为 undefined——function-calling 模型的常态是「只点名不说话」。诚实展示(有就显示,没有就明说)比编一段「让我想想」的假话强。实测 glm-4-flash 还会「说而不做」:输出「请稍等,我正在查询…」却没有工具调用——防御是提示词硬规则(严禁说而不做)+ 结构化闸。

岔路 2 · 多轮对话的实体继承。用户先查「广东深圳的订单」,再追问「买断订单数是多少」——模型第二轮手里没有第一轮的工具结果(会话历史只存问答文本),只能反问或查全量。解法:工具结果扫描出的「实体」(广东深圳 = agentId 42)注入下一轮的 system 动态段。教训:会话记忆的颗粒度(存什么、丢什么)是 Agent 工程的隐形深水区。

岔路 3 · 成本与停手。手写循环的每一步都是真金白银(一次 generateText 一次计费)。什么时候该停手用框架?——当你发现自己在循环里重复造「消息裁剪 / 工具缓存 / 并行调度」这三个轮子时。在那之前,手写版的可控性完胜。


六、动手任务(做完才算通关) ​

  1. 跑通 pnpm examples:minimal,回答:为什么「北京和上海哪个热」是 3 步而不是 1 步?
  2. pnpm chat --trace 里故意问一个不存在的订单号,观察 ✗ 失败回灌后模型的行为。
  3. 改造:给 examples 加一个你自己的工具(比如查某城市汇率),要求 description 写清「适合/不适合」,然后验证模型会正确选它。

自测题(先凭记忆答,再展开) ​

  1. 循环的出口条件是什么?「步数用完」时为什么不硬挤一个回答?
  2. 工具结果的「回灌」为什么不省略?
  3. 五道防御分别针对什么故障?哪两道是「壳」,哪三道在循环内?
答案
  1. 模型某步不再发起工具调用(只返回文本)即最终回答;步数用完抛明确错误——硬挤回答=编造半成品结论,宁要明确失败。
  2. 模型无状态,每次调用只看 messages;不回灌=下一轮失忆,查询结果丢失。
  3. maxSteps→死循环;校验闸+回灌→参数幻觉;幂等壳→写操作重复;审批壳→高危无人类确认;signal→断连烧钱。后两道(幂等/审批)是壳,前三道在循环内。

延伸 ​