主题
02 · 手写工具循环(Agent 核心)
这一站解决 Agent 的根本问题:LLM 只会「说」不会「做」。学完你将拥有一个 200 行、无框架、每步可观测、带五道工程防御的工具循环——以及理解所有 Agent 框架剥到最里层后的那台发动机。
前置:01 · LLM API 与结构化输出(JSON 模式 vs 工具调用的可靠性差异是本站的地基) 配套跑起来:examples/minimal-agent(40 行最小实现)+ pnpm chat --trace(全功能版) 深读:参考库 · 工具循环深读(逐行代码走读与设计取舍表)
一、为什么:一问一答的天花板
裸 LLM 是「一问一答」的文本函数:你问一句,它答一句,会话结束。但真实任务是多步的:
text
用户:「订单 A-1024 到哪了?」
模型内心:我需要先查订单状态,拿到结果才能回答。
↑ 「查」这个动作,模型做不到——它没有手。解法的三块拼图:
- 工具清单——随请求附上一份「你能做什么」的说明(名称 + 用途 + 参数图纸);
- 点名机制——模型不直接回答,而是说「我要调
getOrderStatus,参数{orderId: "A-1024"}」(这就是 function calling,模型训练时专门学过的输出模式,比「求它输出 JSON」可靠得多——原因见上一站的可靠性排序); - 回灌循环——我们的代码替它执行工具,把结果塞回对话,让模型看着结果继续。
三者合体就是 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 一次计费)。什么时候该停手用框架?——当你发现自己在循环里重复造「消息裁剪 / 工具缓存 / 并行调度」这三个轮子时。在那之前,手写版的可控性完胜。
六、动手任务(做完才算通关)
- 跑通
pnpm examples:minimal,回答:为什么「北京和上海哪个热」是 3 步而不是 1 步? pnpm chat --trace里故意问一个不存在的订单号,观察 ✗ 失败回灌后模型的行为。- 改造:给 examples 加一个你自己的工具(比如查某城市汇率),要求 description 写清「适合/不适合」,然后验证模型会正确选它。
自测题(先凭记忆答,再展开)
- 循环的出口条件是什么?「步数用完」时为什么不硬挤一个回答?
- 工具结果的「回灌」为什么不省略?
- 五道防御分别针对什么故障?哪两道是「壳」,哪三道在循环内?
答案
- 模型某步不再发起工具调用(只返回文本)即最终回答;步数用完抛明确错误——硬挤回答=编造半成品结论,宁要明确失败。
- 模型无状态,每次调用只看
messages;不回灌=下一轮失忆,查询结果丢失。 - maxSteps→死循环;校验闸+回灌→参数幻觉;幂等壳→写操作重复;审批壳→高危无人类确认;signal→断连烧钱。后两道(幂等/审批)是壳,前三道在循环内。
延伸
- 参考库 · 工具循环深读 ——
src/agent-loop.ts逐行走读 + 设计取舍表 - 参考库 · 工具箱 —— 工具注册、幂等层、壳组合器的完整语义
- 归档周教程(内容完整):week11 · LLM API 周 · 循环深入补篇
- 下一站:03 · 记忆与上下文压缩 —— 循环之外的另一半:让 Agent 记得说过什么