最小 Agent Loop
最小 Agent Loop
第 06 章我们完成了一轮工具调用的全流程:声明 tools → 模型返回 tool_calls → 我们执行工具 → 把结果作为 tool 消息回填。做完的那一刻你可能会问:这不就是一个 Agent 吗?——还差得远。那一轮流程只发生一次就结束了:模型要了天气,你查了天气,然后呢?如果你想把结果拿给模型、让它接着判断「适不适合出门」,你得再手动发一轮。而真实的任务往往需要连续好几轮行动。
把「一轮工具调用」升级成「多轮循环」,就是本章要做的:最小 Agent Loop,大约 50 行的 while 循环,这也是后续所有章节(工具系统、上下文管理、流式渲染、多智能体……)的地基。Claude Code 那个 1700 多行的主循环,内核就是这个循环,其余全是保护机制。
本章目标
读完本章并做完配套练习后,你应该能够:
- 说清「单次问答」与「agent」的分界线:一次调用只能回答,agent 能为了目标连续行动;
- 画出 think→act→observe 三环节,并能指出代码里每一环的位置与分工(模型决策、harness 执行);
- 手写一个 50 行左右的
while循环:把messages + tools发给模型 → 若返回tool_calls则逐个执行并回填后继续 → 否则把文本作为最终答案返回; - 说出循环的两类 stop 条件:模型不再要求工具(正常结束)、达到
maxIterations(保险丝); - 对比 ReAct 提示式(文本协议)与原生 tool calling(结构化协议)两种「让模型要工具」的方式,说出各自的适用场景与取舍;
- 识别并修掉死循环失败模式,理解为什么
maxIterations是必不可少的保护。
开场反例:一次调用只能回答,不能行动
假设你要做一个天气助手,用户的问题是:
上海今天适合出门吗?帮我查一下天气再回答。
把这个问题发给一个只会回答、不会行动的模型,你会得到什么?两种可能:
- 模型直接编一个天气,它没有实时数据,只能瞎猜(这正是第 06 章之前的状态);
- 模型「举手」说:我要调用
get_weather工具(返回tool_calls)。然后就没有然后了。调用没人执行,因为你只给了它一次机会。
你当然可以再发一轮请求把工具结果喂回去,于是你开始手工操作:把第 06 章那一轮流程重复执行:
// 第 06 章练过的「单轮工具调用」,手工重复三轮:
let messages = [
{ role: 'user', content: '上海今天适合出门吗?帮我查一下天气再回答。' },
];
// —— 第一轮 ——
let reply = await model(messages); // 模型:我要调 get_weather
messages.push(reply); // 存下模型的话(含 tool_calls)
let weather = await runTool(reply.tool_calls); // 你执行工具
messages.push({ role: 'tool', tool_call_id: reply.tool_calls[0].id, content: weather });
// —— 第二轮 ——
reply = await model(messages); // 模型看到天气,接着判断
messages.push(reply);
// 模型说:27°C、晴,适合出门 → 不再要工具,结束
三轮还能忍,二十轮呢?你要在循环外一遍遍复制「调用模型 → 判断要不要工具 → 执行 → 回填」这几行。更糟的是,「该不该继续」的判断其实是一条固定规则(模型还有没有 tool_calls),不是每次都需要人重新想一遍的新逻辑。
把这段固定规则自动化,就是 Agent Loop,让程序自己决定「要不要再来一轮、再来该做什么」。shareAI-lab/learn-claude-code 的第一章(s01)用一句话概括了它:One loop & Bash is all you need,一个循环加一个工具执行器,就是一个 Agent 的最小内核。
循环的三环:think → act → observe
把上面的手工流程抽象出来,就是三个反复出现的环节:
flowchart TD
start(["用户消息进入 messages"]) --> think["think · 模型决策<br/>返回 content(可能带 tool_calls)"]
think --> hasTools{"有 tool_calls?"}
hasTools -- "有" --> act["act · harness 执行工具<br/>模型只决定,不执行"]
act --> observe["observe · 工具结果作为 tool 消息<br/>回填进 messages"]
observe --> think
hasTools -- "没有" --> stop["stop · content 即最终答案"]
逐环拆开:
- think(想):把当前完整消息历史(含此前的所有工具结果)和工具清单发给模型,模型决定下一步,是再要一个工具,还是直接给出最终答案。决定权在模型。
- act(做):模型说「要调用
get_weather」,由谁去跑?——不是模型,是 harness(我们写的循环代码)。模型只负责说「要什么、参数是什么」,执行是 harness 的职责。这个分工是 Agent 架构的关键:模型是决策者,harness 是执行者。 - observe(看):把工具的执行结果作为一条
tool消息回填进 messages,让模型在下一轮能「看到」它做了什么、得到了什么。这也是 ReAct(Reason + Act)这个名字的由来,思考与行动交替进行,行动的结果成为下一次思考的输入。
循环的意义在于:每一轮 messages 都在变长,模型看到的上下文越来越完整。它不是同一个问题问三遍,而是「带上前一轮的行动结果,继续推进」。pguso/agents-from-scratch 第 06 课对这一点有个很准的总结:agent = 循环 + 状态,循环让行为可以多步,状态(这里的 messages)让步骤之间能衔接(lesson 06: The Agent Loop)。
手写最小 Agent Loop
先定消息与工具的形状(沿用第 06 章的协议):
export interface ToolCall {
id: string; // 本轮调用的唯一 id,回填时靠它配对
name: string; // 要调用的工具名
arguments: Record<string, unknown>; // 已解析好的参数对象
}
export interface Message {
role: 'system' | 'user' | 'assistant' | 'tool';
content: string;
tool_calls?: ToolCall[]; // 仅 assistant 消息携带
tool_call_id?: string; // 仅 tool 消息携带,与 tool_calls[].id 配对
}
export interface Tool {
name: string;
description: string;
execute(args: Record<string, unknown>): Promise<string> | string;
}
// callLlm 是注入的:循环不认识 HTTP、不认识厂商。
// 第 04 章的 Provider 就是它的一个实现;判题与测试时注入脚本化 mock。
export type CallLlm = (messages: Message[], tools: Tool[]) => Promise<{
content: string; // 本轮输出文本;没有工具调用时它就是最终答案
toolCalls: ToolCall[]; // 模型要求的工具调用;空数组 = 结束信号
}>;
然后就是主角——约 50 行的 while 循环:
const DEFAULT_MAX_ITERATIONS = 5;
export async function runAgent(
initialMessages: Message[],
opts: { callLlm: CallLlm; tools: Tool[]; maxIterations?: number },
): Promise<{ messages: Message[]; finalText: string; iterations: number }> {
const { callLlm, tools, maxIterations = DEFAULT_MAX_ITERATIONS } = opts;
const messages = [...initialMessages]; // 循环只在自己的副本上追加
let iterations = 0;
while (iterations < maxIterations) {
// think:把完整历史(含此前的工具结果)和工具清单交给模型
const response = await callLlm(messages, tools);
// 模型的输出永远先落进历史(文本 + 可能带的 tool_calls)
const assistant: Message = { role: 'assistant', content: response.content };
if (response.toolCalls.length > 0) assistant.tool_calls = response.toolCalls;
messages.push(assistant);
// stop 条件一(正常结束):模型没有要求任何工具 → 它就是最终答案
if (response.toolCalls.length === 0) {
return { messages, finalText: response.content, iterations: iterations + 1 };
}
// act + observe:逐个执行模型要的工具,结果作为 tool 消息回填
for (const call of response.toolCalls) {
const tool = tools.find((t) => t.name === call.name);
if (!tool) throw new Error(`Unknown tool: ${call.name}`);
const output = await tool.execute(call.arguments);
messages.push({ role: 'tool', tool_call_id: call.id, content: output });
}
iterations += 1;
}
// stop 条件二(保险丝):达到最大轮数,拿最后一轮文本兜底
const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant');
return { messages, finalText: lastAssistant?.content ?? '', iterations };
}
对照上一节的图,逐段读一遍:while 循环体第一件事是 callLlm(think),然后判断 toolCalls 是否为空,为空直接返回(stop),不为空就 find 工具、execute、push tool 消息(act + observe),再回到循环顶。注意几个刻意的设计:
messages是循环唯一的「状态」,只追加、不修改。模型看到的永远是从头到尾的完整历史。这个「追加式事件史」不是巧合,第 09 章的上下文压缩、第 10 章的持久化与回放,都建立在「消息历史可以原样重放」这个性质上。callLlm是注入的。循环本身不关心模型在哪个厂商、走什么协议,第 04 章的 Provider 恰好是CallLlm的一个实现,判题时注入的是脚本化 mock(下一节会看到)。这是依赖倒置在最小形态下的样子。tools.find(...)找不到就抛错。最小循环不做容错,工具没注册是程序员的 bug,让它在第一轮就爆炸,比静默吞掉好。工具系统的工程化兜底(统一错误协议、超时、重试)是第 08 章的事。- 返回值三件套:
finalText给用户看,messages是完整演变记录(调试、测试、审计都用得上),iterations是实际轮数。
看一次完整运行,用上面那个天气问题,callLlm 走两轮:
sequenceDiagram
participant U as 用户
participant H as harness(runAgent)
participant M as LLM
participant T as get_weather
U->>H: 「上海今天适合出门吗?帮我查天气」
H->>M: messages + tools
M-->>H: assistant<br/>tool_calls: get_weather(location=上海)
H->>T: execute(location=上海)
T-->>H: 「上海晴,27°C,微风」
H->>M: messages + tool 消息
M-->>H: assistant「27°C、晴,适合出门,记得防晒。」
H-->>U: finalText
第一轮模型只给出 tool_calls(content 为空);harness 执行后把结果回填;第二轮模型看到真实天气,不再要工具,输出最终文本,循环在 iterations === 2 时正常退出。整个多步行为,你一行”如果…那么…”的硬编码都没有写:下一步做什么,从头到尾是模型根据不断增长的上下文自主决定的。这就是 Agent 与普通程序的分水岭(第 01 章讲过)。
停止条件:循环怎么知道「做完了」
任何循环都必须回答一个问题:什么时候停? 最小 Agent Loop 有两类停止条件,缺一不可:
| 停止条件 | 触发时机 | 性质 |
|---|---|---|
| 模型不再要求工具 | 某一轮 toolCalls 为空 | 正常结束:模型认为目标已达成 |
达到 maxIterations | 轮数达到上限 | 保险丝:模型失控时的兜底 |
第一类条件值得多说一句:判断「要不要继续」时,我们只看有没有 tool_calls,不看厂商的 finish_reason / stop_reason(第 04 章讲过这两个字段)。原因有二:一是两个厂商的枚举语义本就不同('tool_calls' vs 'tool_use'),统一判断多一层翻译;二是流式响应里 stop_reason 可能还没更新、但内容里已经带工具调用了,shareAI-lab/learn-claude-code 在剖析 Claude Code 源码时专门指出过这一点:Claude Code 的 1729 行主查询循环不用 stop_reason 决定是否继续,靠的是检测内容里有没有 tool_use 块(s01 深入 CC 源码)。「有没有工具调用」比「厂商说它为什么停」更可靠、更可移植。
第二类条件(maxIterations)是本章最重要的安全网,下一节单独讲它的必要性。
ReAct 提示式 vs 原生 tool calling
这一节回答一个你迟早会问的问题:「让模型要工具」只能靠原生 tool calling 吗? 不是。历史上还有一条更朴素的路线,ReAct 提示式(也叫文本协议),Hugging Face Agents Course 的 Dummy Agent 是它的教科书级示例(The Dummy Agent Library)。
它的思路是:不声明任何 schema,把工具的使用方法写进 system prompt,并要求模型按固定格式输出:
Thought: I need the weather in Shanghai to decide.
Action: {"action": "get_weather", "action_input": {"location": "Shanghai"}}
模型输出的是一段文本,harness 用正则/JSON 解析把 Action 后面的 JSON blob 抠出来执行,然后手工拼接:
Observation: 上海晴,27°C,微风
再把它拼回 prompt 让模型继续。这条路线的两个经典坑,正好反衬出原生 tool calling 的价值:
- 模型会「幻觉 Observation」,它在
Action:之后自己编一个假结果接下去。Dummy Agent 的解法是生成时传stop=["Observation:"],让模型在Observation:前截断,由 harness 注入真实的工具输出。用「截断生成」来防幻觉,本质是在跟模型的输出纪律搏斗。 - 解析脆弱,JSON blob 可能混在 markdown 代码块里、可能带多余字符;解析失败怎么办、参数校验谁来做,全都得自己兜。
原生 tool calling(第 06 章 + 本章用的)把这两件事结构化了:模型返回的是结构化 tool_calls(名字、参数分开),harness 不需要跟文本搏斗;参数按 JSON Schema 声明、模型按格式生成;tool 消息是协议里的一等角色,与 assistant 的输出天然配对。
对比放在一张表里:
| 维度 | ReAct 提示式(文本协议) | 原生 tool calling(结构化协议) |
|---|---|---|
| 工具如何声明 | 写进 system prompt 的自然语言 | 请求体里的 tools 数组(JSON Schema) |
| 模型如何要工具 | 输出带 Action: 的文本 + JSON blob | 返回结构化 tool_calls |
| 结果如何回填 | 手工拼接 Observation: 字符串 | tool 消息 + tool_call_id 配对 |
| 防幻觉 | 靠 stop=["Observation:"] 截断 hack | 协议保证,无需 hack |
| 解析健壮性 | 弱(JSON 混在文本里) | 强(字段分离) |
| 模型要求 | 任意能读指令的文本模型 | 需厂商支持 tool calling(近两年主流模型均支持) |
| 适合场景 | 本地小模型、无原生支持的模型、教学理解 | 生产默认、多工具、并行调用 |
取舍一句话:原生 tool calling 是今天的主流默认,可靠、标准、省心;ReAct 提示式是它出现之前(以及没有它的模型上)的通用后备,也是理解「agent 内核不依赖具体协议」的教学透镜。两者都值得手写一遍,本章练的是协议无关的循环骨架,换协议只动 callLlm 那一层,循环一行不改。顺便说一句:上一节 callLlm(messages, tools) 里那个 tools 参数,在原生路线下会翻译成请求体的 tools 数组,在 ReAct 路线下会翻译成 system prompt 里的工具描述,这个「翻译」正是 callLlm 的实现细节,循环对此一无所知。
常见坑与失败模式
坑一:死循环,模型每一轮都要求工具。 最经典的失败模式。一个没有任何停止条件的循环:
// 死循环版:没有任何退出路径
async function brokenLoop(messages) {
while (true) {
const r = await callLlm(messages);
const out = await runTool(r.toolCalls); // 假设工具总成功
messages.push(r.message);
messages.push(out); // 结果回填 → 下一轮模型又想要工具 → 永续
}
}
真实世界里这种「模型持续要工具」并不罕见:工具结果触发了新的行动需求(查完 A 想查 B),或者模型在某个失败结果上反复重试同一个工具。while (true) 一旦遇到这种模型,就是 token 烧穿、请求打到厂商限流。修复就是 maxIterations:把无条件的 while (true) 换成 while (iterations < maxIterations),让「循环体必须能走到尽头」成为硬约束。这是所有生产 Agent 的标配(pguso/agents-from-scratch 的 run_loop 同样用 max_steps 兜底)。
坑二:工具结果没回填进 messages。 模型要求工具 → 你执行了 → 但忘了把 tool 消息 push 回去 → 下一轮模型看到的还是上一轮的上下文 → 它再次要求同一个工具 → 和坑一一样陷入死循环,而且看起来「模型好笨」。先检查「结果到底进没进上下文」,再怪模型。
坑三:回填的消息形状不对。 忘带 role: 'tool'、忘带 tool_call_id、或者 tool_call_id 对不上 tool_calls[].id,第 06 章的单轮里这只是一个 400;放进循环后它会每轮放大,错误消息越滚越多,排查成本翻倍。写循环时把「回填格式」当作循环不变量来保证:每一轮结束后,历史里 assistant 的每个 tool_calls[i].id 都恰好对应一条 tool_call_id 相同的 tool 消息。
坑四:maxIterations 截断后没有兜底文本。 循环被保险丝掐断时,用户必须拿到点什么。最小循环的兜底是「最后一轮 assistant 文本」;更成熟的方案(第 20 章的成本与观测)会在截断时返回一个明确的错误信号。无论如何,不要让用户面对一个空的 finalText。
坑五:把决策和执行混在一起。 循环里既调模型又跑工具是应该的,但职责要分清:模型决定「要不要、要哪个」,harness 决定「怎么执行、执行结果怎么回去」。新手常犯的是让工具内部再去「问模型」,或者在循环里写死「下一轮做什么」的业务逻辑,前者把 harness 变成瑞士军刀,后者把 agent 降级回 workflow(第 01 章的分水岭)。
坑六:工具执行是 IO。 execute 可能慢、可能失败、可能有副作用。最小循环先朴素地 await;超时、重试、幂等、取消这些工程化手段全部留给第 08 章,现在把它们堆进 50 行的循环,只会让「最小」荡然无存。
动手练习
理论到此为止。配套练习要求你亲手实现 runAgent,练习会给你一个注入式的 callLlm(脚本化的 mock 模型,无需 API key、结果确定可复现),你要补全循环逻辑,并通过四个 stage 的测试:
- 单轮无工具:模型直接回答,循环恰好跑一轮;
- 多轮工具循环:查完天气再回答,工具结果必须被模型看到;
- 停止条件:模型不要求工具时循环立刻结束,不多跑;
- 死循环保护:模型每轮都要工具时,
maxIterations能把它截断。
小结
- 单次问答不是 agent:一次调用只能回答;agent 是「循环 + 状态」,能为了目标连续行动(think → act → observe);
- 三环分工:think = 模型决策,act = harness 执行工具(模型只决定、不执行),observe = 结果回填上下文;
- 50 行
while循环:messages + tools发给模型 → 有tool_calls就逐个执行并回填后继续 → 没有就返回最终文本;messages是只追加的状态,callLlm是注入的依赖; - 两类 stop 条件:模型不再要工具(正常)、
maxIterations(保险丝);判断继续与否只看有没有tool_calls,不依赖厂商的stop_reason; - ReAct 提示式 vs 原生 tool calling:结构化协议是生产默认,文本协议是后备与教学透镜;循环骨架与协议无关;
- 死循环是最大失败模式,
maxIterations是它的解药;截断后必须有兜底文本。
下一章(ch08)给工具穿上盔甲:工具注册表、从 TS 类型生成 JSON Schema、统一错误协议、超时/取消/幂等。本章的循环先「裸奔」地跑着,下一章开始往循环体里叠加工程化,但无论叠加多少,while (iterations < maxIterations) 这个骨架不会变。
延伸阅读
- shareAI-lab/learn-claude-code · s01: Agent Loop:本章「一个循环就够了」的直接参照;内含对 Claude Code 主循环(
while True检测工具调用)的源码剖析。 - pguso/agents-from-scratch · Lesson 06: The Agent Loop:agent = 循环 + 状态;
max_steps终止条件的理由。 - Hugging Face Agents Course · The Dummy Agent Library:ReAct 文本协议的手写最小 agent,含
stop=["Observation:"]防幻觉技巧。 - TheSeydiCharyyev/build-your-own-agent:Agent 技术栈 10 组件的 from-scratch 资源索引;「1. The agent loop」一节汇总了 canonical 循环实现(read-input → call-model → detect tool_use → execute → feed-back)。
- Anthropic: Tool use(开发者文档):原生 tool calling 的官方机制说明,用于对照 ReAct 文本协议。