最小 Agent Loop

最小 Agent Loop

第 06 章我们完成了一轮工具调用的全流程:声明 tools → 模型返回 tool_calls → 我们执行工具 → 把结果作为 tool 消息回填。做完的那一刻你可能会问:这不就是一个 Agent 吗?——还差得远。那一轮流程只发生一次就结束了:模型要了天气,你查了天气,然后呢?如果你想把结果拿给模型、让它接着判断「适不适合出门」,你得再手动发一轮。而真实的任务往往需要连续好几轮行动。

把「一轮工具调用」升级成「多轮循环」,就是本章要做的:最小 Agent Loop,大约 50 行的 while 循环,这也是后续所有章节(工具系统、上下文管理、流式渲染、多智能体……)的地基。Claude Code 那个 1700 多行的主循环,内核就是这个循环,其余全是保护机制。

本章目标

读完本章并做完配套练习后,你应该能够:

开场反例:一次调用只能回答,不能行动

假设你要做一个天气助手,用户的问题是:

上海今天适合出门吗?帮我查一下天气再回答。

把这个问题发给一个只会回答、不会行动的模型,你会得到什么?两种可能:

  1. 模型直接编一个天气,它没有实时数据,只能瞎猜(这正是第 06 章之前的状态);
  2. 模型「举手」说:我要调用 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 即最终答案"]

逐环拆开:

循环的意义在于:每一轮 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),再回到循环顶。注意几个刻意的设计:

看一次完整运行,用上面那个天气问题,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_callscontent 为空);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 的价值:

  1. 模型会「幻觉 Observation」,它在 Action: 之后自己编一个假结果接下去。Dummy Agent 的解法是生成时传 stop=["Observation:"],让模型在 Observation: 前截断,由 harness 注入真实的工具输出。用「截断生成」来防幻觉,本质是在跟模型的输出纪律搏斗。
  2. 解析脆弱,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-scratchrun_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 的测试:

小结

下一章(ch08)给工具穿上盔甲:工具注册表、从 TS 类型生成 JSON Schema、统一错误协议、超时/取消/幂等。本章的循环先「裸奔」地跑着,下一章开始往循环体里叠加工程化,但无论叠加多少,while (iterations < maxIterations) 这个骨架不会变。

延伸阅读

完成阅读,去做练习 →