第 2 章:百行 Agent Loop——让模型行动、观察并停下来
← 上一章:什么是 Agent · 下一章:记忆与 Skill 进化
开场:一句「我查一下订单」,中间发生了什么
上一章你见到了四类客服,记住了 D 类「模型提议、代码批准、环境反馈」。现在把镜头推进到 D 类客服一次实际操作上。
他对着你说「我查一下订单」,然后低下头,几秒后抬起头说「查到了,您的订单在运输中」。从「说出口」到「报结果」,中间其实发生了四件看不见的事:
- 他把「查订单」写成了一张能交给系统的纸条——工具调用指令;
- 系统给这张纸条盖了一个编号——调用 ID,防止和其他纸条搞混;
- 系统真的去查了,把结果写出来;
- 结果贴回他那张带编号的纸条上,他读完才抬头向你汇报。
这四件事,任何一件断了,D 类客服就会露出马脚:结果没贴回纸条,他会重复查、瞎猜,或者一直低着头说「正在查询」;纸条没有编号,同一批开出去两张条,回来就分不清哪张答哪张;纸条写出去没人执行,他只会念念有词却什么也没办成。
这一章就讲这四件事的代码长什么样。它加起来不过百来行,却是几乎所有 Agent 框架的心脏。本章结束时,你能回答:模型明明只返回了一条「调用工具」的指令,为什么程序还要写一百行代码围着它转? 答案不是「模型不够聪明」,而是这一百行在维持一条不丢的记录,并决定什么时候停。
D 类客服说「我查一下订单」,但执行结果没有贴回他手上那张纸条,他继续往下说,最可能的表现是?
- A:他换了家店查
- B:他记性不好
- C:他重复查、瞎猜,或一直说「正在查询」
- D:他直接把订单取消了
一次模型调用能做的,到此为止
先看最省的一档:一次调用。你把用户问题发给模型,模型要么回一段话,要么回一个「我想调用工具」的意图。对应 Mini Emperor 里的数据就是 ModelReply(content=..., tool_calls=...)。
问题是:光有意图,什么事都没发生。 模型回了一句「我想查一下订单」(tool_calls=[ToolCall(...)]),但程序拿到这行字之后如果不做任何事,用户得到的是一段未完成的结构——他说了想查,但没人真去查。这一步对应教学仓库的 step01_single_call.py:一次 User Message、一次模型调用、一次打印,没了。
所以「模型能返回工具调用」这件事本身不稀奇,稀奇的是程序敢不敢执行它,以及执行后怎么把结果送回去。从这一步到能真正办事,中间隔着一个循环——我们叫它内层 Loop。
模型返回了一个 search_kb 的调用意图,但程序拿到后没有执行它,用户会得到什么?
- A:一段完整的最终回答
- B:一段「想查但没人真去查」的未完成结构
- C:程序直接报错退出
- D:系统自动开了一张工单
把一个动作接回来:Tool Call 与 Tool Result
循环的起点是把模型提议的动作真正接回来执行。教学仓库的 step05_tool_use.py 第一次出现这条完整路径。把它拆成两半:
请求那一半——模型想用哪个工具、传什么参数:
{
"id": "call-1",
"name": "add",
"arguments": {"a": 2, "b": 3}
}结果那一半——工具执行后,结果必须带着同一个编号回去:
{
"tool_call_id": "call-1",
"name": "add",
"content": 5
}那个 id / tool_call_id 不是装饰。同一轮里模型可能同时开两张「纸条」(同时调用 add 和 multiply),结果回来时如果没带编号,代码就分不清哪个是哪个。在并发、重试的场景里,这个编号是因果关系的锚点:它保证了「这条结果,就是那个请求的结果」。
对应的,Mini Emperor 里保存请求时写的是 role: "assistant" 的消息(模型说的话 + 它提议的工具调用),执行完回灌时写的是 role: "tool" 的消息,并用 tool_call_id 指回原来的调用。这一来一回,模型下一轮就能读到「我刚才要的动作,结果是 5」。
同一轮里模型要同时调用 add 和 multiply 两个工具,为什么结果必须带 tool_call_id?
- A:让代码看着更规范
- B:让模型记住它学过什么
- C:好把两条结果分别贴回对应的请求上,不张冠李戴
- D:方便把日志打得更好看
内层循环转起来之后,麻烦才开始
循环接上了,看起来能办事了。但真正的坑不在这段「顺风路」上,而在三种断法里:
断法一:执行了,结果没回灌。 搜索确实发生了,但结果没有写回 messages。模型下一轮什么都看不见,于是它重复搜索、凭空猜测,或者干脆说「还在查询」。这不是模型笨,是它看不到环境反馈。
断法二:没有停止上限。 模型不断调用 noop、反复搜同一个关键词、或在两个工具之间来回切换。没有上限的话,费用和时间一起无限增长。max_iterations 就是给内层循环装的一把闸:转满这么多轮还没结束,就进入失败态。
断法三:没做权限检查。 只要工具被注册进 registry,模型就可能请求它。你也许在系统提示里写了「不要调用危险工具」——但那只是劝,不是锁。CapabilityPolicy 在工具真正执行前再查一次,模型可以提议它看到的任何工具,但能不能落地由代码把关。
把这三处断法补上,就是 Mini Emperor 里 AgentRunner 内层循环的骨架。注意它有一个很关键的细节:失败也要作为结果回灌。 模型请求了一个不被允许的工具,代码不是直接吞掉,而是回灌一条带错误信息的 role: "tool" 消息。模型需要知道「这个动作为什么没成」,才能改参数、换工具、或决定放弃。
工具执行了、结果也回灌了,但模型下一轮还是不断调用同一个工具。第一步最该排查什么?
- A:换个更贵、更强的模型
- B:结果是否真的进了下一轮消息、是不是被截断成了没意义的空
- C:直接调高 max_iterations
- D:把那个工具从注册表里删掉
让每一轮被看见:事件与停止条件
内层循环转起来之后,还有个工程问题:你怎么知道它现在干到哪了? 界面上显示的「正在查询」,应该来自运行记录下来的真实事件,而不是模型自己嘴里说的「正在查询」——否则你分不清它真在干活,还是在表演。
Mini Emperor 的做法是在状态边界发出 AgentEvent。一次真实运行的记录长这样:
turn.started
model.completed(iteration=1, tool_calls=["add"])
tool.started(id=1, name="add")
tool.completed(result=5)
model.completed(iteration=2, tool_calls=[])
turn.completed(answer="结果是 5", iterations=2)事件协议一旦稳定,终端和网页就都能订阅它来画状态,谁产生的(模型 / 代码 / 环境)一目了然。
最后是停止问题。工具循环停下来,不等于任务完成。 模型不再请求工具,只说明它想不出还需要什么动作;业务上「查到了订单」「文件写完了」「测试跑过了」,都得靠外部证据来证明。所以生产里要把「停止循环」和「验证完成」分开:前者看 max_iterations 和「模型不再要工具」,后者看文件、订单、测试或 Goal 证据。
完整的内层循环长这样:
注意图上 W → B → C 这一圈:只要没到上限,就回到「组装 Context、再调模型」。模型每读一次更新后的消息记录,就多知道一点真实世界发生了什么——这正是循环的意义。
模型不再请求工具了,就代表任务完成了吗?
- A:是,模型不再调用就说明它搞定了
- B:还要看外部证据——文件、订单、测试或 Goal 引用
- C:永远不算完成,得无限循环下去
- D:看用户满不满意,满意就算
动手:让两个失败发生,再用一个测试拦住
理论和手感之间隔着一道墙,亲手撞一次才会留下印象。下面两段代码都是故意写错的,请对照真实实现看清楚断在哪里。
失败一:执行了,但结果没回灌。
# 内层循环里被删掉的一行——删掉它,循环就「失聪」了
for call in reply.tool_calls:
result = await tools.invoke(call.name, call.arguments)
yield AgentEvent("tool.completed", {"id": call.id, "result": result})
# 少了这一行:messages.append({"role": "tool",
# "tool_call_id": call.id, "name": call.name, "content": result})interface ToolCall {
id: string;
name: string;
arguments: Record<string, unknown>;
}
interface ModelReply {
toolCalls: ToolCall[];
}
interface ToolRegistry {
invoke(name: string, args: Record<string, unknown>): Promise<unknown>;
}
interface AgentEvent {
type: string;
payload: Record<string, unknown>;
}
interface ToolResultMessage {
role: "tool";
toolCallId: string;
name: string;
content: unknown;
}
async function* runInnerLoop(
reply: ModelReply,
tools: ToolRegistry,
messages: ToolResultMessage[],
): AsyncGenerator<AgentEvent> {
// 内层循环里被删掉的一行——删掉它,循环就「失聪」了
for (const call of reply.toolCalls) {
const result = await tools.invoke(call.name, call.arguments);
yield { type: "tool.completed", payload: { id: call.id, result } };
// 少了这一行:messages.push({ role: "tool",
// toolCallId: call.id, name: call.name, content: result });
}
}对照 mini_emperor/backend/src/mini_emperor/agent.py 里 run_turn 的真实写法:结果必须写回 messages,并且带着 tool_call_id。删掉它,模型下一轮就读不到任何结果——真实模型通常表现为重复调用或凭空猜测。
失败二:模型反复调 noop,没有上限。 现有测试正好构造了这一幕:脚本化模型连续三次要调 noop,Runner 上限 2。你不需要自己搭模型,直接跑测试看最后一个事件:
cd "$(git rev-parse --show-toplevel)"
uv run --python 3.12 --extra dev pytest \
mini-emperor/backend/tests/test_agent.py::test_agent_loop_stops_at_iteration_limit \
-v预期最后事件是:
turn.failed {"reason": "max_iterations"}没有上限的话,第三次 noop 会照常执行、照常回灌、再回去调模型——永远停不下来。有了 max_iterations=2,转满两轮就进入失败态。
如果测试不过,按这个顺序排查是哪一层的问题:
- 事件类型或顺序不对 →
run_turn的 yield 位置; - 结果没写进下一轮 →
role: "tool"回灌那一行; - 该停没停 →
max_iterations检查的位置和计数方式。
门禁·代码:跑通上面这条测试,把输出保存为证据。
本章小结
这一章没有教你背一段框架代码,而是让你看穿内层 Loop 的四件小事:
模型提议,代码执行,结果回灌,循环往复。 模型返回的是「我想调用工具」的意图;程序把它接住、执行、把结果贴回带 tool_call_id 的消息里,再让模型读一次。循环的意义,是让模型每一轮都比上一轮多知道一点真实世界。
三处断法,就是三个治理插入点。 结果没回灌 → 模型重复调用;没有上限 → 停不下来;没有权限检查 → 越权执行。生产里真正需要精读的,就是这条循环上你插了哪些检查。
停下来 ≠ 完成。 max_iterations 只是防止失控的闸,业务完成要靠外部证据(文件、订单、测试、Goal 引用)来证明。
可观察来自事件,不来自台词。 界面上的「正在查询」应该来自 AgentEvent,而不是模型自己说的话。
一句话记住这一章:
循环不是让模型「跑得更久」,而是让它在每一次往返后都拥有更新的真实世界信息,并且有人把关、有闸可停、有证据证明完成。
如果上面这些话你自己也能说得出来(不看资料),就说明这一章的知识已经连成一片,不是散落的碎片。
复述与证据
合上资料,亲手画出一次两轮模型调用的完整消息数组:第一轮模型提议调用 add,第二轮模型给出最终回答。把下面每一步标出来是谁产生的:
- 第一轮:
user消息 → 模型; - 第一轮:模型返回
role: "assistant"的 Tool Call(带id); - 代码执行
add,返回role: "tool"的消息(带tool_call_id); - 第二轮:以上三条全部作为
messages再发给模型; - 模型返回最终回答,
turn.completed。
再追问两个问题:
- 工具执行失败时,为什么也要把错误作为 Result 回灌,而不是吞掉?
- UI 上的「正在搜索」,应该来自模型文字还是
AgentEvent?为什么?
门禁·证据:提交上述消息数组,并标出每一步是谁产生的(模型 / 代码 / 环境)。
24 小时后:不看资料,画出「模型提议 → 代码执行 → 结果回灌 → 再调模型」的循环,并标出 max_iterations 和 CapabilityPolicy 各插在哪。
有兴趣可以继续看
以下资料按优先级排。至少完成一项 P0,并保存完成证据。
教学仓库P045 分钟 · Step 01–05,重点 Step 05
- 预计
- 45 分钟
- 位置
Step 01–05,重点 Step 05- 阅读任务
- 从 step01 一路跑到 step05,打印 stop_reason、History 最后一条类型和 Tool Call ID。
- 完成证据
- 保存一次两轮模型调用的完整脱敏消息数组。
书与实验P055 分钟 · ai-agent-book 第 2 章核心循环 + chapter1/context
- 预计
- 55 分钟
- 位置
ai-agent-book 第 2 章核心循环 + chapter1/context- 阅读任务
- 移除 Tool Result 做消融,记录模型重复调用或凭空猜测的现象。
- 完成证据
- 提交失败 Trace、根因和修复后的 Trace。
论文P050 分钟 · ReAct Figure 1、Section 3
- 预计
- 50 分钟
- 位置
ReAct Figure 1、Section 3- 阅读任务
- 把 Step 05 的往返转写为 Thought / Action / Observation,公开 Trace 不暴露私有推理。
- 完成证据
- 标出模型输出、Harness 动作和环境 Observation。
官方文档P140 分钟 · DeepSeek Chat Completion 的 tools/tool_calls/stream
- 预计
- 40 分钟
- 位置
DeepSeek Chat Completion 的 tools/tool_calls/stream- 阅读任务
- 核对供应商格式怎样适配为 ModelReply,标出不能信任的模型参数。
- 完成证据
- 写一张字段映射表。
本文提到的代码与出处(有兴趣逐条核对时用):
claude-agent-examples@54a18980334541773f13940aa0c0475728d30ee0:build-agent-example/code/step01_single_call.pyclaude-agent-examples@54a18980334541773f13940aa0c0475728d30ee0:build-agent-example/code/step02_loop_no_memory.pyclaude-agent-examples@54a18980334541773f13940aa0c0475728d30ee0:build-agent-example/code/step03_history.pyclaude-agent-examples@54a18980334541773f13940aa0c0475728d30ee0:build-agent-example/code/step04_system_prompt.pyclaude-agent-examples@54a18980334541773f13940aa0c0475728d30ee0:build-agent-example/code/step05_tool_use.pyai-agent-book@1c18370279f8f0457bf2c44dfa585d08d4d5f281:book/chapter2.mdai-agent-book@1c18370279f8f0457bf2c44dfa585d08d4d5f281:chapter1/context/README.mdmini-emperor/backend/src/mini_emperor/agent.pymini-emperor/backend/tests/test_agent.py