Skip to content

8.3 Agent Loop 与工具执行

本页分析版本earendil-works/pi@c13ffe12026-07-30

本章解决什么问题8.2 结束时,我们手上是一个会流式打字的聊天程序:用户说一句,模型回一句,进程等下一句。本章补上把它变成 Agent 的那一层——一次用户输入之后,程序自己决定要调几次模型、调哪些工具、什么时候停。对应实验的两步:labs/mini-agent-harness/step04-agent-loop(循环怎么转)与 labs/mini-agent-harness/step05-tools(工具怎么做正经)。

前置知识8.2 消息历史与流式事件2.6 异步迭代器与 for await3.4 工具调用(Tool Calling)3.5 Agent 与 Agent Loop。第 5、6 部分读过的话会更省力,但不是必须。

学习目标:读完本章后你能

  • 逐行讲出 runAgentLoop 这个异步生成器(Async Generator)的每一句在做什么,包括每一处 return 的退出理由;
  • 说清工具结果(Tool Result)为什么必须是第三种 role、为什么必须 pushcontext.messages
  • 手写一个约 30 行的 validate,并说出它兑现了 2.13.4 埋下的哪个伏笔;
  • 说出「校验失败回填给模型」和「校验失败抛异常」这两种设计各自导致什么后果;
  • 把我们的循环与 Pi 的 runLoop 对齐,指出 Pi 在同一根主干上多挂了哪几类工程能力。

建立直觉:让 step03 不够用

先把上一步的形状摆出来。step03 的主循环核心是这一段(labs/mini-agent-harness/step03-streaming/src/main.ts):

ts
context.messages.push(userText(line));

const message = await renderStream(model(context));
// 只有正常结束的回复才进上下文;出错的半截回复丢弃。
if (message) {
  context.messages.push(message);
}

这是一问一答:模型调用次数 = 用户输入次数。它做不了下面这件事——

用户:帮我算 12*8 助手:这个我算一下。 (程序自己跑了一次计算器,得到 96) 助手:算好了:12*8 = 96。

用户只说了一句,助手却说了两次话。中间那次计算不是模型做的(3.4 反复强调过:模型只会「说」,不会「做」),是我们的程序替它做的。做完之后必须再问一次模型,模型才能基于计算结果说出最终那句话。

step04 的主循环变成两行(labs/mini-agent-harness/step04-agent-loop/src/main.ts):

ts
context.messages.push(userText(line));
await renderEvents(runAgentLoop({ streamFn, context, tools }));

runAgentLoop 里面是一个循环,它自己决定转几圈。这个循环就是 Agent Loop(Agent 循环);它是整个 Agent Harness(Agent 运行框架)里唯一「让 Agent 成为 Agent」的部件。

请注意这个 diff 里消失了什么:step03 那句 context.messages.push(message) 不见了。原因不是省略,而是职责搬了家——循环转到第二圈时必须已经能看到第一圈的助手消息和工具结果,所以「把消息追加进历史」这件事只能发生在循环内部,不可能等到循环结束后由 main.ts 来做。渲染函数也因此从 renderStream(返回 AssistantMessage | undefined)改名成 renderEvents(返回 Promise<void>):它不再需要把消息交回给谁,纯粹只负责显示。

📘 概念Agent Loop(Agent Loop)
一次用户输入之后反复执行的四步:调模型 → 检查模型有没有请求工具 → 有就执行工具并把结果追加进上下文 → 回到第一步。退出条件只有一个:模型这一轮不再请求任何工具。聊天机器人没有这个循环,所以模型说完就结束;Agent 有了它,才能「先看一眼文件,再改一处代码,再报告改完了」。

最小示例一:step04 把循环转起来

先跑一遍

bash
cd labs/mini-agent-harness/step04-agent-loop
npm install
npm run demo

npm run demo 的真实输出(完整内容见该目录的 expected-output.txt,本书作者实测逐字一致):

text
Mini Agent Harness · step04 agent-loop(demo)

你> 你好
[第 1 轮]
助手> 你好!我现在会用工具了,试试问我「帮我算 12*8」。
[第 1 轮结束]

你> 帮我算 12*8
[第 1 轮]
助手> 这个我算一下。
[工具调用] calc {"expression":"12*8"}
[工具结果] calc → 96
[第 1 轮结束]
[第 2 轮]
助手> 算好了:12*8 = 96。
[第 2 轮结束]

你> 帮我算 1 除以零
[第 1 轮]
助手> 我试试。
[工具调用] calc {"expression":"1/0"}
[工具结果] calc → 除数不能为 0
[第 1 轮结束]
[第 2 轮]
助手> 这个算不了,除数不能是 0。
[第 2 轮结束]

你> /exit
再见。(本次对话共 10 条消息)

请先只看第二段。三行用户输入里,只有「帮我算 12*8」这一句触发了两轮:第 1 轮模型请求工具,第 2 轮模型看着工具结果说话。第一句「你好」只转了一圈,因为模型没请求工具,循环第一轮就退出了。

最后那个「共 10 条消息」可以自己数一遍,数清楚了就说明你已经理解这一步:3 条用户消息 + 5 条助手消息 + 2 条工具结果消息。助手为什么是 5 条而不是 3 条?因为「12*8」和「1 除以零」各产生了两条助手消息(每轮一条)。

类型上新增的四样东西

src/types.ts 相比 step03 的结构性新增有四处(另有三处小改动:StopReason 多了一个成员 "toolUse";文件末尾多了一个取出全部 Tool Call 的辅助函数 toolCallsOfmessageText 的函数体改了一行,原因见本节末尾的说明)。第一处,助手消息的内容块(Content Block)多了一种:

ts
// labs/mini-agent-harness/step04-agent-loop/src/types.ts
export interface ToolCallContent {
  type: "toolCall";
  id: string;
  name: string;
  arguments: Record<string, unknown>;
}

export type ContentBlock = TextContent | ToolCallContent;

注意 arguments 的类型是 Record<string, unknown>——已经解析好的对象,不是 JSON 字符串。「谁负责把模型吐的文本解析成对象、谁负责校验这个对象」是 step05 的主题,这里先记住它此刻是不可信的。

第二处,多了第三种 role

ts
export interface ToolResultMessage {
  role: "toolResult";
  toolCallId: string;
  toolName: string;
  content: TextContent[];
  isError?: boolean;
}

export type Message = UserMessage | AssistantMessage | ToolResultMessage;
⚠️ 常见误解把工具结果塞回助手消息里
既然工具是「助手要求调用的」,直觉上会想把结果拼进那条助手消息的文本里。不要这样做,两个理由:一是模型 API 的协议就是把工具结果定义成独立的一条消息([3.4](/agent-basics/tool-calling) 讲过);二是语义上它根本不是模型说的话——工具结果是**外部世界的输入**,混进助手消息等于告诉模型「这段是你自己说的」,模型会因此产生混乱的自我认知。`toolCallId` 则解决配对问题:一条助手消息里可以有多个 Tool Call(一次工具调用请求),模型靠这个 id 知道哪个结果对应哪次请求。

第三处,StreamEvent8.2 的 4 种变成 5 种,多出来的那种是模型宣布它要调用工具:

ts
export type StreamEvent =
  | { type: "start" }
  | { type: "text_delta"; delta: string }
  /** 模型决定调用一个工具。arguments 此时已经是对象。 */
  | { type: "toolcall"; toolCall: ToolCallContent }
  | { type: "done"; message: AssistantMessage }
  | { type: "error"; error: string };

第四处,循环需要一个比 StreamEvent 更大的事件联合:

ts
export type HarnessEvent =
  | StreamEvent
  | { type: "turn_start"; turn: number }
  /** 循环准备执行某次工具调用(执行前)。 */
  | { type: "tool_call"; toolCall: ToolCallContent }
  /** 某次工具调用执行完毕(执行后)。 */
  | { type: "tool_result"; toolCallId: string; toolName: string; content: string; isError: boolean }
  | { type: "turn_end"; turn: number };

HarnessEventStreamEvent 的超集:模型自己的事件start / text_delta / toolcall / done / error)加上循环自己的事件turn_start / tool_call / tool_result / turn_end)。合成一个联合的好处是渲染层只需要一个 for await 加一个 switch;坏处是订阅者必须能区分 toolcall(模型说它要调用)与 tool_call(循环真的开始执行了)这两个长得很像的事件——它们确实是两件事,中间隔着一次真实的函数调用。

🌱 初学者提示为什么 messageText 里要先写一句 const blocks: ContentBlock[] = message.content
三种 role 的 content 数组元素类型不一样:UserMessageTextContent[]AssistantMessageContentBlock[]ToolResultMessage 又是 TextContent[]。直接在这个联合数组上调用 filter,TypeScript 合不出一个可用的调用签名(它要同时满足三种数组的重载)。先赋值给一个 ContentBlock[] 变量把类型归一,问题就消失了。这不是本书编的教学例子,是写这一步时真实撞到的编译错误。

假模型怎么用一份剧本驱动两轮

FakeModel 要能演完整个循环,关键在它拿什么去匹配规则:

ts
// labs/mini-agent-harness/step04-agent-loop/src/fake-model.ts
function matchKey(context: Context): string {
  const last = context.messages[context.messages.length - 1];
  if (!last) return "";
  if (last.role === "toolResult") {
    return `toolResult:${last.toolName} ${messageText(last)}`;
  }
  return messageText(last);
}

第 1 轮时最后一条消息是用户消息,匹配串是「帮我算 12*8」;工具执行完之后,最后一条变成了工具结果,匹配串变成 toolResult:calc 96。于是 rules.ts 里成对出现的两条规则,正好就是循环的两轮:

ts
// labs/mini-agent-harness/step04-agent-loop/src/rules.ts
{
  match: "12*8",
  reply: "这个我算一下。",
  toolCall: { name: "calc", arguments: { expression: "12*8" } },
},
{ match: "toolResult:calc 96", reply: "算好了:12*8 = 96。" },

命中带 toolCall 的规则时,FakeModel 先把 reply 逐 token 流式吐出,再 yield 一个 toolcall 事件,最后那条 done 事件里的 stopReason"toolUse"——「我停下来是因为要用工具」,而不是「我说完了」。toolCallId 由一个进程内递增的 callSeq 生成(call_1call_2……),保证唯一且在 demo 里可预测。

主角:agent-loop.ts 逐行

下面是 runAgentLoop 的全部循环主体,一行没删(labs/mini-agent-harness/step04-agent-loop/src/agent-loop.ts):

ts
export async function* runAgentLoop(options: AgentLoopOptions): AsyncGenerator<HarnessEvent> {
  const { streamFn, context, tools, streamOptions } = options;
  const maxTurns = options.maxTurns ?? 8;

  for (let turn = 1; turn <= maxTurns; turn++) {
    yield { type: "turn_start", turn };

    // 第一步:调模型,把它的事件原样转发出去。
    let assistant: AssistantMessage | undefined;
    for await (const event of streamFn(context, streamOptions)) {
      yield event;
      if (event.type === "done") {
        assistant = event.message;
      } else if (event.type === "error") {
        // 模型这一轮失败了:不追加任何消息,整个循环结束。
        return;
      }
    }
    if (!assistant) return;

    context.messages.push(assistant);

    // 第二步:看模型有没有要求用工具。没有就说明它说完了。
    const toolCalls = toolCallsOf(assistant);
    if (toolCalls.length === 0) {
      yield { type: "turn_end", turn };
      return;
    }

    // 第三步:逐个执行工具,结果按顺序追加回上下文。
    // (Pi 默认是并行执行的,我们为了输出可预测采用串行。)
    for (const toolCall of toolCalls) {
      yield { type: "tool_call", toolCall };
      const result = await executeToolCall(toolCall, tools);
      context.messages.push(result);
      yield {
        type: "tool_result",
        toolCallId: result.toolCallId,
        toolName: result.toolName,
        content: result.content.map((block) => block.text).join(""),
        isError: result.isError === true,
      };
    }

    yield { type: "turn_end", turn };
    // 第四步:回到循环开头,带着新的工具结果再问一次模型。
  }

  yield { type: "error", error: `Agent Loop 达到最大轮数 ${maxTurns},已强制停止。` };
}

逐段读:

async function*yield。它是异步生成器:调用它不会执行任何代码,只会拿到一个异步迭代器(Async Iterator);调用方每 for await 一次,函数体才往前推进到下一个 yield(机制见 2.6)。这带来一个重要性质——这个文件一行 console.log 都没有。谁想看进度就订阅事件,渲染成终端界面(TUI,Terminal User Interface)还是网页,agent-loop.ts 都不用改。

for (let turn = 1; turn <= maxTurns; turn++)。循环变量是「第几轮」,一轮 = 一次模型调用 + 它请求的全部工具执行。

for await (const event of streamFn(...)) { yield event; ... }。模型的每个事件被原样转发,同时顺手把 done 事件里的完整助手消息接住。为什么不用 streamFn 的返回值?因为 8.2 定下的契约是 StreamFn 返回一个事件流而不是一条消息,完整消息只在流的末尾以 done 事件的形式出现。

两处 return(模型侧)event.type === "error" 时直接 return:模型这一轮失败了,不往 context.messages 里追加任何东西,整个循环终止。紧随其后的 if (!assistant) return; 是兜底——流正常结束了却没给过 done,说明 StreamFn 的实现违反了契约,我们不猜它想干什么,直接停。

context.messages.push(assistant)。循环就地修改传进来的 context。这是一个有意的设计:main.ts 持有同一个 context 对象,所以循环结束后对话历史已经是完整的,不需要任何返回值的搬运。代价是这个函数有副作用,测试时要注意每次传新对象。

第三处 return(正常结束)toolCalls.length === 0 就是循环的唯一正常退出条件:模型这一轮只说了话、没请求工具,说明它说完了。注意退出前先 yieldturn_end,事件流才是配对的。

执行工具的三行核心yield tool_call(执行前)→ await executeToolCall(...)(执行)→ context.messages.push(result)(回填)→ yield tool_result(执行后)。push 那一行是整个 step04 最关键的一行:工具确实执行了、结果确实通过 tool_result 事件打到了屏幕上,但如果它没有进 context.messages下一圈模型看到的上下文和「工具从没执行过」完全一样。本章的实践任务会让你把这一行注释掉,亲眼看一次。

循环外那一行 yield { type: "error", ... }for 正常跑完(不是从中间 return)只有一种可能:转满了 maxTurns 圈模型还在要工具。这时必须报错停下。

工具异常为什么不许冲出循环

executeToolCall 是本步骤第二个值得逐行看的函数:

ts
async function executeToolCall(
  toolCall: ToolCallContent,
  tools: Record<string, ToolHandler>,
): Promise<ToolResultMessage> {
  const handler = tools[toolCall.name];
  let text: string;
  let isError = false;

  if (!handler) {
    text = `未注册的工具:${toolCall.name}`;
    isError = true;
  } else {
    try {
      text = await handler(toolCall.arguments);
    } catch (error) {
      // 工具是外部代码,允许它抛异常;但异常不能冲出循环,
      // 否则模型永远不知道自己那次调用失败了。
      text = `工具执行失败:${error instanceof Error ? error.message : String(error)}`;
      isError = true;
    }
  }

  return {
    role: "toolResult",
    toolCallId: toolCall.id,
    toolName: toolCall.name,
    content: [{ type: "text", text }],
    isError,
  };
}

它的签名说明了一切:入参是一次 Tool Call,出参一定是一条工具结果消息,没有第三种可能。工具找不到、工具抛异常,都被转换成带 isError 的结果消息喂回模型。理由很直接:如果异常冲出循环,用户会看到一个栈回溯,而模型永远不知道自己那次调用失败了,也就没有机会换个方式重试。「让模型有机会自我纠错」是这个 try/catch 的全部意义,step05 会把这一点推到极致。

顺带解释 demo 里一个容易误读的细节:「除数不能为 0」那一行没有标 (出错)。因为 step04 的 calc返回了这段文字,而不是抛异常——isError 为 false。「工具认为这是一次正常的业务答复」和「工具崩了」是两回事。什么样的失败该标 isError,取决于你希望模型如何反应;step05 的 calc 就把除零改成了 isError: true

maxTurns:一个必须存在的安全阀

maxTurns 默认 8。为什么非要有它?因为模型和工具完全可能互相喂招停不下来:工具永远返回「参数不对」,模型永远换个写法重试。没有上限的话,一次用户输入就能把 API 额度烧光,而且没有任何东西会打断它。

代价是这个数字很难定。真实场景里循环转 4、5 圈非常常见(读文件 → 搜索 → 改文件 → 报告),设小了会误伤。Pi 的做法不是设一个硬上限,而是把「要不要继续」交给上层钩子决定,本章末尾会看到。

图解:step04 的一圈

图加载中…

图 8.3-1 step04 runAgentLoop 的控制流
阅读顺序:从上到下;从 turn_end 回到轮数判断的那条边就是循环本身。

这张图说明的是退出路径比主干更值得记:图里一共三个终点、四种退出理由,只有 C -- 否 那条是正常结束(模型不再要工具),其余分别是模型报了 error、流结束却没给过 done(契约被违反,与前者共用 RET 这个终点)、以及转满 maxTurns。请特别注意那条从 turn_end 回到轮数判断的边——它对应源码里 for 循环的下一次迭代,也是「第 2 轮」得以出现的原因。图中每个方框都能在 agent-loop.ts 里找到对应语句:EX 是那个内层 for (const toolCall of toolCalls)STOP 是循环体外最后那行 yield

最小示例二:step05 把工具做成正经抽象

step04 的工具是刻意粗糙的——src/tools.ts 只有一张「名字 → 函数」的表,args.expression 直接 String() 一下就用,没有任何校验。step05 补上三件事:工具有了要发给模型的「说明书」、执行前有一道运行时校验、文件类工具处理了越界与截断。

说明书与实现的切分

ts
// labs/mini-agent-harness/step05-tools/src/tools/types.ts
export interface ToolSpec {
  name: string;
  /** 给模型看的描述。写得越准,模型越少调错。 */
  description: string;
  parameters: ParametersSchema;
}

export interface ToolDefinition extends ToolSpec {
  execute(args: Record<string, unknown>, context: ToolContext): Promise<ToolResult>;
}

这个切分不是洁癖,是被物理约束逼出来的:发给模型的部分必须能序列化成 JSON 放进请求体,而函数没法序列化。所以 ToolSpec 是纯数据、要出网,execute 只能留在本地。ToolRegistry.specs() 做的就是把说明书部分摘出来:

ts
specs(): ToolSpec[] {
  return [...this.tools.values()].map(({ name, description, parameters }) => ({
    name,
    description,
    parameters,
  }));
}

main.ts 把它挂进上下文:tools: tools.specs()Context 也因此多了一个 tools?: ToolSpec[] 字段——模型必须先知道有哪些工具可用,才可能发起 Tool Call

需要说清楚的一点:step05 的 FakeModel 与 step04 完全一致(两个文件 diff 无输出),它靠剧本回复,根本不读 context.tools。所以这一步的 tools 字段暂时是「摆着的」,它真正开始起作用要等到 8.6 接上真实模型。这里先把数据放到正确的位置,是为了那时候不用改上层。

ToolRegistry.register 在名字重复时直接抛异常,和「工具执行失败回填给模型」形成对照:工具名是模型寻址的唯一依据,重名意味着模型的调用会落到不确定的实现上——这是程序员写错了,越早炸越好;而模型给错参数是运行时的正常现象,要回填给它。两类错误的处置方式必须分开。

手写 validate:兑现 2.1 与 3.4 的伏笔

2.1 的结论是「类型只存在于编译期,运行时的数据它管不着」;3.4 指出工具参数正是这类数据的典型代表。step05 就是这两处伏笔的落地:

ts
// labs/mini-agent-harness/step05-tools/src/tools/validate.ts
export type ValidateResult =
  | { ok: true; value: Record<string, unknown> }
  | { ok: false; errors: string[] };

export function validate(schema: ParametersSchema, input: unknown): ValidateResult {
  if (typeof input !== "object" || input === null || Array.isArray(input)) {
    return { ok: false, errors: ["参数必须是一个对象"] };
  }

  const value = input as Record<string, unknown>;
  const errors: string[] = [];

  for (const name of schema.required ?? []) {
    if (value[name] === undefined) {
      errors.push(`缺少必填参数 ${name}`);
    }
  }

  for (const [name, property] of Object.entries(schema.properties)) {
    const item = value[name];
    if (item === undefined) continue;
    if (typeof item !== property.type) {
      errors.push(`参数 ${name} 应为 ${property.type},实际是 ${typeof item}`);
    }
  }

  for (const name of Object.keys(value)) {
    if (schema.properties[name] === undefined) {
      errors.push(`不认识的参数 ${name}`);
    }
  }

  return errors.length > 0 ? { ok: false, errors } : { ok: true, value };
}

它检查四类问题:输入本身是不是对象、必填字段在不在、在场字段的类型对不对、有没有多余字段。返回值是可辨识联合(Discriminated Union),调用方必须先看 ok 才能拿到 value——这样就不可能忘记处理失败分支,编译器会拦住你。

请注意 validate 的位置:它是整条链路上唯一的关卡,把「模型生成的一段不可信数据」变成「可以放心断言类型的数据」。所以 calc.ts 里那句 args.expression as string 才是安全的:

ts
async execute(args) {
  // args 已经通过 validate,断言是安全的。
  const expression = args.expression as string;
⚠️ 常见误解以为 as string 提供了运行时保证
as 什么都不检查,它只是让编译器闭嘴([2.1](/foundations/types-basics))。这句断言之所以安全,全部理由都在它前面那道 validate,与 as 本身无关。把 validate 删掉,这行代码一个字符都不用改,照样编译通过,然后在运行时把 undefined 传进正则里。判断一处断言安不安全,永远是看它上游有没有真正执行的检查代码。

真实项目当然不会手写这 30 行,会用 TypeBox 或 Zod 这类库。手写一遍的目的只有一个:看清这道关卡到底在做什么。Pi 用的是 TypeBox,本章最后一节会给出源码位置。

循环里多出来的两道关卡

step05 的 agent-loop.ts 相比 step04 只改了工具执行那一段——把 Record<string, ToolHandler> 换成 ToolRegistry,并在 execute 之前插入校验:

ts
// labs/mini-agent-harness/step05-tools/src/agent-loop.ts
async function runTool(
  toolCall: ToolCallContent,
  tools: ToolRegistry,
  toolContext: ToolContext,
): Promise<{ content: string; isError?: boolean }> {
  const tool = tools.get(toolCall.name);
  if (!tool) {
    return { content: `未注册的工具:${toolCall.name}`, isError: true };
  }

  // 关卡一:运行时参数校验。不通过就不执行。
  const checked = validate(tool.parameters, toolCall.arguments);
  if (!checked.ok) {
    return { content: `参数校验失败:${checked.errors.join(";")}`, isError: true };
  }

  // 关卡二:执行本身也可能抛异常,异常不许冲出循环。
  try {
    return await tool.execute(checked.value, toolContext);
  } catch (error) {
    return {
      content: `工具执行失败:${error instanceof Error ? error.message : String(error)}`,
      isError: true,
    };
  }
}

三个分支(工具没找到、校验没过、执行抛异常)都走向同一个终点:一条带 isError 的工具结果,回填给模型。没有一条路径是抛异常或终止循环。

runAgentLoop 的循环主体控制流一个分支都没动——还是那个 for 加四步。把两个文件的这段函数逐行 diff 一遍,除了 step04 里那几行「第一步 / 第二步 / ……」的教学注释被删掉之外,只剩两处真实差异:解构那行多了一个 toolContext,以及 executeToolCall(toolCall, tools) 变成了 executeToolCall(toolCall, tools, toolContext)——两处都只是为了把工具执行环境传下去。这一点值得停下来看一眼:变的是「怎么执行一次工具」,不变的是「循环怎么转」。这条缝在 step08 换真实模型时同样成立。

校验失败为什么要回填给模型

价值在 demo 的第二个场景里,下面是 expected-output.txt 的真实片段:

text
你> 故意写错参数试试
[第 1 轮]
助手> 我把参数名写成 expr 试试。
[工具调用] calc {"expr":"1+1"}
[工具结果] calc → 参数校验失败:缺少必填参数 expression;不认识的参数 expr(出错)
[第 1 轮结束]
[第 2 轮]
助手> 参数名不对,改成 expression 重来。
[工具调用] calc {"expression":"1+1"}
[工具结果] calc → 2
[第 2 轮结束]
[第 3 轮]
助手> 这次通过了:1+1 = 2。
[第 3 轮结束]

模型第一次把参数名写成了 expr,被 validate 拦下;错误信息作为工具结果回到它面前,它下一圈自己改对了。这就是 Agent 能自我纠错的机制来源——不是模型「聪明」,是我们把错误信息放回了它看得见的地方。

那两条错误信息的顺序也可以对着 validate 的代码验证一遍:先跑 required 循环得到「缺少必填参数 expression」,再跑属性类型循环(expression 不在场,continue 跳过),最后跑多余字段循环得到「不认识的参数 expr」,用 拼接。逐字对得上。

对照另一种设计:如果校验失败直接抛异常给用户看,那么每次模型猜错参数名都需要人来干预。一种看法是「回填」几乎总是更好,代价是模型可能反复试同一个错误参数——所以它必须和 maxTurns 这类安全阀配套使用,两者是一对。

🌱 初学者提示FakeModel 是怎么「学会」改正的
它当然没有学会。rules.ts 里写死了一条规则 { match: "toolResult:calc 参数校验失败", reply: "参数名不对,改成 expression 重来。", toolCall: { name: "calc", arguments: { expression: "1+1" } } },靠子串匹配命中。这是剧本,不是智能。但循环这一侧的代码是真的:把 FakeModel 换成真模型,这条链路一行都不用改([8.6](/mini-harness/real-provider) 会验证这一点)。真实模型拿到同样的错误文本会不会同样改对参数名,本实验没有条件验证,属于据此推断(尚未在本书中直接证实);能确定的只是我们把改正所需的信息放到了它看得见的地方——没有这一步,再强的模型也无从改起。

文件工具绕不开的两件事

read_file 演示的两个问题,任何读文件的工具都必须处理:

ts
// labs/mini-agent-harness/step05-tools/src/tools/read-file.ts
const target = path.resolve(context.cwd, relative);

// 越界检查:解析后的绝对路径必须以 cwd 开头。
const root = path.resolve(context.cwd);
if (target !== root && !target.startsWith(root + path.sep)) {
  return { content: `拒绝访问工作目录之外的路径:${relative}`, isError: true };
}

demo 的第三个场景就是它拦下的(真实输出):

text
[工具调用] read_file {"path":"../../../etc/hosts"}
[工具结果] read_file → 拒绝访问工作目录之外的路径:../../../etc/hosts(出错)

两个细节:其一,模型给出 ../../../etc/hosts 不一定是恶意的,它可能只是猜错了路径——但工具的实现必须假设参数可能是恶意的。其二,比较写的是 startsWith(root + path.sep) 而不是 startsWith(root),因为 /work/workspace-secret 的前缀,不加分隔符会把 /workspace-secret/x 误判成在 /work 之内。这类边界是安全检查里最常见的漏洞形态。

同样出于「参数不可信」的理由,calc 刻意不用 eval:把模型生成的文本交给解释器执行,等于把整台机器交出去。正则解析功能弱,但攻击面为零。

第二件事是截断。MAX_LINES = 40,超出就截断并注明:

ts
const kept = lines.slice(0, MAX_LINES).join("\n");
return { content: `${kept}\n…(共 ${lines.length} 行,只显示前 ${MAX_LINES} 行)` };

为什么必须截断:工具结果是要进上下文的,一个几万行的文件会挤掉别的内容、烧掉 token。为什么必须注明:模型需要知道自己看到的是残缺的,否则它会拿前 40 行当全文来推理。Pi 有专门的模块做这件事(packages/coding-agent/src/core/tools/truncate.ts)。

图解:一次工具调用要过几道关

图加载中…

图 8.3-2 step05 一次工具调用经过的关卡与四条汇合路径
阅读顺序:从左到右。四条路径最终汇合到同一个终点。

这张图要读的是汇合点而不是分叉点。三条失败路径与一条成功路径最后都进入同一个方框:包成工具结果消息、追加进上下文。整个 step05 没有任何一条路径是「抛异常给用户」或「终止循环」。图中的 BVX 三个判断分别对应 agent-loop.tsrunTool 的三段代码(tools.getvalidatetry/catch),W 对应外层 executeToolCall 的返回值与 runAgentLoop 里那句 context.messages.push(result)

回到 Pi 源码:runLoop 与我们的差距

Pi 的等价物叫 runLoop,是一个内部函数,被 agentLoop(低层流式 API)和 Agent 类共用(源码事实):

earendil-works/pi@c13ffe1第 155–275 行在 GitHub 查看 ↗
Pi 的 Agent Loop 主体。外层 while 处理 follow-up 续跑,内层 while 处理「模型请求 → 工具执行 → 回填 → 再请求」。

循环骨架如下(源码事实。为聚焦主干省略了四段并逐处标注,另外贯穿全函数的 newMessages 记账也一并略去;控制流未改动):

ts
// packages/agent/src/agent-loop.ts:170-272(有省略)
while (true) {
	let hasMoreToolCalls = true;
	// Inner loop: process tool calls and steering messages
	while (hasMoreToolCalls || pendingMessages.length > 0) {
		// …(省略:firstTurn 判定后 emit turn_start;注入 pendingMessages)
		const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFunction);
		// …(省略:stopReason 为 error 或 aborted 时 emit turn_end 与 agent_end 后 return)
		const toolCalls = message.content.filter((c) => c.type === "toolCall");
		const toolResults: ToolResultMessage[] = [];
		hasMoreToolCalls = false;
		if (toolCalls.length > 0) {
			const executedToolBatch =
				message.stopReason === "length"
					? await failToolCallsFromTruncatedMessage(toolCalls, emit)
					: await executeToolCalls(currentContext, message, config, signal, emit);
			toolResults.push(...executedToolBatch.messages);
			hasMoreToolCalls = !executedToolBatch.terminate;
			for (const result of toolResults) {
				currentContext.messages.push(result);   // ← 和我们那句 push 是同一件事
			}
		}
		await emit({ type: "turn_end", message, toolResults });
		// …(省略:prepareNextTurn 快照、shouldStopAfterTurn 判定、steering 队列轮询)
	}
	// …(省略:follow-up 队列检查,有内容则回到外层循环开头)
	break;
}

先看一模一样的部分,这是本章最想让你确认的事:

  • 检测工具调用的写法:message.content.filter((c) => c.type === "toolCall"),和我们的 toolCallsOf 里那句 filter 是同一件事。
  • 工具结果就地追加进 currentContext.messages,逐句对应我们那行 context.messages.push(result)——连「就地修改传进来的上下文」这个副作用式做法都一样。
  • 循环的推进条件仍然是「模型这轮还要不要工具」,只是 Pi 把它存进了一个显式变量 hasMoreToolCalls,而我们直接写在 if (toolCalls.length === 0) return; 里。
  • 助手消息 stopReason"error""aborted" 时立即结束,和我们收到 error 事件就 return 是同一个决策。差别只在收尾:Pi 退出前补发了 turn_endagent_end,保证订阅者收到的事件成对;我们的循环在这条路径上直接 returnturn_start 没有配对的 turn_end——这是简化版留下的一处粗糙,render.ts 恰好不依赖配对才没暴露出来。

再看Pi 多做的事,逐条对应到源码:

1. 两层循环。 内层是我们已经理解的那层;外层 while (true)agent-loop.ts:170)用于 follow-up:内层觉得该停了,外层去问一次「有没有排队的新消息」,有就继续跑,没有才 break。这是我们的单层 for 没有的。

2. 用钩子代替 maxTurns。 Pi 没有硬编码的轮数上限,而是在每个 turn_end 之后调用 config.shouldStopAfterTurn?.(...),返回 true 就发 agent_end 并退出(agent-loop.ts:247-257)。一种看法是这更灵活:上限该由业务定,而不是由循环定;代价是每个使用方都得自己想清楚停止策略,忘了写就真的没有上限。

3. 截断响应的特判。 stopReason === "length" 意味着模型输出被 token 上限切断,这条消息里的每个 Tool Call 参数都可能是残缺的——参数残缺但仍能解析通过是最危险的情况。Pi 的处理是全部直接判失败,一个都不执行:

packages/agent/src/agent-loop.ts · failToolCallsFromTruncatedMessage
earendil-works/pi@c13ffe1第 381–406 行在 GitHub 查看 ↗
输出被 token 上限切断时,这一批 tool call 全部报错返回,不执行任何一个,让模型重新发起。

4. 默认并行执行工具。 我们串行是为了 demo 输出可预测;Pi 默认并行,并允许单个工具用 executionMode: "sequential" 声明自己必须独占(agent-loop.ts:411-426,默认值 "parallel"packages/agent/src/agent.ts:230)。会修改同一个文件的工具就该这么声明。

5. terminate:工具可以要求停下循环。 hasMoreToolCalls = !executedToolBatch.terminate 这一行的来源是:

packages/agent/src/agent-loop.ts · shouldTerminateToolBatch
earendil-works/pi@c13ffe1第 582–584 行在 GitHub 查看 ↗
仅当本批**每个** tool result 都带 terminate 为 true 时,才提前结束循环。

这是我们完全没有的能力——某些工具(例如「结束本次任务」类工具)执行完就该让循环停下,而不是再问一次模型。

工具执行:校验发生在哪一行

我们把校验写在 runTool 里;Pi 把它放在 prepareToolCall,位置和职责完全对应(源码事实):

earendil-works/pi@c13ffe1第 600–664 行在 GitHub 查看 ↗
按名字查工具 → prepareToolCallArguments → validateToolArguments → beforeToolCall 钩子;整段包在 try/catch 里,任何抛出都转成 isError 的工具结果。

顺序是:查不到工具就直接返回 Tool ${name} not found 的错误结果(agent-loop.ts:607-614,对应我们的「未注册的工具」)→ validateToolArgumentsagent-loop.ts:618)→ 可选的 beforeToolCall 钩子,它可以 block 掉这次调用(agent-loop.ts:619-643,我们没有)。整段的 catchagent-loop.ts:657-663)把校验抛出的错误转成 isError 的工具结果——和我们「校验失败回填给模型」是同一个决策,只是 Pi 用 throw + catch 表达,我们用返回值表达。

校验的实现在 pi-ai 包里,用的是 TypeBox:

packages/ai/src/utils/validation.ts · validateToolArguments
earendil-works/pi@c13ffe1第 278–310 行在 GitHub 查看 ↗
先 structuredClone 参数并做类型强转,再用编译好的 validator 检查;失败时汇总带路径的错误消息并 throw。

它比我们的 30 行多做了三件事:Value.Convert 会尝试把 "128" 这类字符串强转成数字(模型很爱把数字写成字符串);validator 是 Compile 出来并用缓存复用的,不是每次现算;错误消息带字段路径,还会把收到的原始参数一并打印给模型看。但它的位置和作用与我们的 validate 完全一致——都是那条链路上唯一的关卡。

工具类型的分层也和我们的切分对应:

earendil-works/pi@c13ffe1第 480–485 行在 GitHub 查看 ↗
pi-ai 层的 Tool:name / description / parameters 与可选的 constrainedSampling,没有 execute——这就是要发给模型的那部分,对应我们的 ToolSpec。
earendil-works/pi@c13ffe1第 380–403 行在 GitHub 查看 ↗
agent 层的 AgentTool extends Tool,加上 label、可选的 prepareArguments、execute 与 executionMode——对应我们的 ToolDefinition。

Tool.parameters 的类型是 TParameters extends TSchema,而 TSchema 是从 typebox 包 import 进来的(源码事实,packages/ai/src/types.ts:456)。TypeBox 的设计目标就是让 schema 对象本身同时是一份合法的 JSON Schema,因此它可以直接序列化进请求体发给模型服务——这正好解释了我们那句「说明书必须是纯数据」为什么成立。多出来的那个 constrainedSampling 字段我们没有对应物:源码注释把它写成「provider 侧的约束采样配置」,用来要求模型服务端在生成参数时就强制符合 schema,而不是等生成完再校验。

事件:HarnessEvent 与 AgentEvent

earendil-works/pi@c13ffe1第 422–437 行在 GitHub 查看 ↗
Pi 的 10 种 AgentEvent:Agent 生命周期、turn 生命周期、消息生命周期、工具执行生命周期,四组事件放进同一个联合。

对照我们的 HarnessEventturn_start / turn_end 两边同名同义;我们的 tool_call / tool_result 对应 Pi 的 tool_execution_start / tool_execution_end,Pi 中间还多一个 tool_execution_update(工具执行到一半推送部分结果,例如 bash 的实时输出)。我们把模型的 StreamEvent 直接并进联合,Pi 则把它包在 message_update 事件的 assistantMessageEvent 字段里——设计思路一致,分层更清楚:Pi 的订阅者可以只关心「消息变了」而不必理解模型协议的每一种事件。

图加载中…

图 8.3-3 Pi runLoop 的双层循环
阅读顺序:从上到下;两条回边分别是内层与外层循环。

把这张图和图 8.3-1 叠在一起看:中间那一竖列(调模型 → 判错 → 检测 toolCall → 执行并回填 → turn_end)就是我们写的那个 for 循环,一一对应。图 8.3-3 多出来的是左右两侧:HOOK 那个方框(agent-loop.ts:226-259)是我们没有的钩子层,FU 与外层回边(agent-loop.ts:262-271)是我们没有的 follow-up 层。换句话说,我们实现的是这张图的主干,Pi 在主干两侧挂了「谁能打断它」和「它停下来之后还能不能被叫醒」。

实践任务

🛠 实践任务把循环转起来,并亲手拆掉它labs/mini-agent-harness/step04-agent-loop

目标:确认自己理解了「回填」这一步的必要性,而不只是读过。

步骤

  1. cd labs/mini-agent-harness/step04-agent-loop && npm install && npm run demo,与 expected-output.txt 逐字比对。
  2. 打开 src/agent-loop.ts,把 context.messages.push(result); 那一行注释掉,再跑 npm run demo
  3. 恢复该行。改在 rules.ts 里加一对新规则(一条带 toolCall,一条 matchtoolResult:calc 开头),让「帮我算 100-1」也能触发完整的两轮循环,并把 demo.tsscript 数组加上这句输入。
  4. runAgentLoopmaxTurns 显式传成 2,构造一个模型永远要工具的规则,观察最后那条 error 事件。

预期现象:第 2 步注释掉 push 之后(本书作者实测),「帮我算 12*8」那一段变成这样——

text
[第 1 轮]
助手> 这个我算一下。
[工具调用] calc {"expression":"12*8"}
[工具结果] calc → 96
[第 1 轮结束]
[第 2 轮]
助手> 这句话没有命中任何规则,我只能用兜底回复了。
[第 2 轮结束]

工具真的执行了[工具结果] calc → 96 就打在屏幕上),但第 2 轮模型完全不知道这件事:它的上下文里最后一条仍是自己那条「这个我算一下。」,matchKey 拿这句话去找规则,一条都没命中,于是落到兜底规则。最后一行也从「共 10 条消息」变成「共 8 条消息」——少掉的正是那 2 条工具结果。第 3 步新规则生效后,输出里会多出一段「第 1 轮 / 第 2 轮」。

如何判断成功:你能说出为什么屏幕上看得见 96、模型却看不见——事件流(给人看的)与消息历史(给模型看的)是两条独立的通道,tool_result 事件走的是前者,push 那一行才是后者。

再想一步:本例里模型走了兜底回复,是因为 FakeModel 的 matchKey 只看上下文最后一条消息。换成真实模型会怎样?它看到的是「一条含 Tool Call 的助手消息后面什么都没有」,据此推断(尚未在本实验中直接验证):多数模型服务会直接拒绝这种不配对的请求体,或者让模型重发同一次调用;哪一种都比 FakeModel 的兜底回复更难诊断。这也是为什么本步骤要用一个行为完全可预测的假模型来演示它。

常见错误:新规则加在了兜底规则(没有 match 字段的那条)后面——规则按顺序取第一条命中的,兜底必须永远在最后。另外匹配用的是 includes 而不是相等:match 写得越短越容易误命中,例如 toolResult:calc 9 会同时命中结果 9699 两轮,写成 toolResult:calc 99 才是安全的。

源码位置src/agent-loop.ts(循环主体)、src/fake-model.tsmatchKeysrc/rules.ts

🛠 实践任务拆掉校验,看它挡住了什么labs/mini-agent-harness/step05-tools

目标:把「运行时校验」从一句口号变成一次亲眼所见的失效。

步骤

  1. cd labs/mini-agent-harness/step05-tools && npm install && npm run demo,确认最后一行是「本次对话共 18 条消息」。
  2. 打开 src/agent-loop.ts,把 runTool 里那段校验(const checked = validate(...) 与随后的 if (!checked.ok) 分支)删掉,把 tool.execute(checked.value, toolContext) 改成 tool.execute(toolCall.arguments, toolContext),重新跑 demo。
  3. 恢复。给 validate 增加一条规则:ParametersSchema 支持可选的 enum?: string[],值不在枚举里就报错;给 calc 加一个 op 参数试一试。
  4. 新增第三个工具 list_files(用 node:fs/promisesreaddir),注册进 createBuiltinTools,并在 rules.ts 里加规则让它被调用。注意复用 read-file.ts 里的越界检查。

预期现象:第 2 步之后(本书作者实测),「故意写错参数试试」那一段变成——

text
[工具调用] calc {"expr":"1+1"}
[工具结果] calc → 无法解析表达式:undefined(出错)
[第 1 轮结束]
[第 2 轮]
助手> 这句话没有命中任何规则,我只能用兜底回复了。

args.expressionundefined,正则拿它去匹配当然不成立,于是返回「无法解析表达式:undefined」。程序不会崩溃——但模型收到的是一条毫无信息量的错误,它无从知道问题出在参数名上,自我纠错的链条就此断掉(这里表现为落到兜底规则)。第 4 步做完,npm start 启动时打印的「可用工具」一行会多出 list_files

如何判断成功:你能说出第 2 步的结果为什么比「参数校验失败:缺少必填参数 expression;不认识的参数 expr」更糟——不是因为它更危险,而是因为它更不可诊断:错误信息里没有任何能让模型改对的线索。

常见错误:第 3 步只改了 validate 却忘了 PropertySchema 的类型定义,编译不过;第 4 步忘了越界检查,list_files 成了绕过 read_file 防护的后门。

源码位置src/tools/validate.tssrc/tools/types.tssrc/agent-loop.tsrunToolsrc/tools/read-file.ts

本章小结

  • Agent Loop 就是四步:调模型 → 检查 Tool Call → 执行工具并把结果追加进上下文 → 回到第一步。唯一的正常退出条件是「模型这一轮不再请求工具」。写成异步生成器之后,循环只 yield 事件、不打印任何东西,渲染层可以随便换。
  • 工具结果是第三种 role,靠 toolCallId 与发起它的 Tool Call 配对;必须 pushcontext.messages。事件流是给人看的、消息历史是给模型看的,两条通道各走各的:少了那句 push,屏幕上照样打印结果,模型却像工具从没执行过一样。
  • 所有失败都转成带 isError 的工具结果回填给模型,没有一条路径抛异常或终止循环:工具没找到、参数校验没过、执行抛异常,三条路汇合到同一个终点。这是 Agent 能自我纠错的机制来源,代价是必须配一个 maxTurns 之类的安全阀。
  • validate 是链路上唯一的关卡。TypeScript 类型在运行时已被擦除,args.expression as string 不提供任何保证;这句断言安全的全部理由,是它上游那道真正执行的检查代码。
  • 工具要切成「说明书」与「实现」两半:说明书必须能序列化成 JSON 发给模型,execute 只能留在本地。这不是风格问题,是物理约束。
  • Pi 的 runLoop 主干与我们的一致(检测、执行、回填、再请求,连「就地改上下文」都一样),挂在主干两侧的是我们没有的东西:外层 follow-up 循环、shouldStopAfterTurn 钩子、stopReason === "length" 的截断特判、默认并行执行与 terminate 语义。

关键术语:Agent Loop(Agent 循环)、Tool Call(一次工具调用请求)、工具结果(Tool Result)、toolCallIdstopReason: "toolUse"isError、运行时校验、ToolSpec / ToolDefinitionmaxTurns 安全阀、异步生成器(Async Generator)。

关键源码索引

  • 实验代码:labs/mini-agent-harness/step04-agent-loop/src/agent-loop.ts(循环主体)、labs/mini-agent-harness/step05-tools/src/tools/validate.ts(30 行校验)、labs/mini-agent-harness/step05-tools/src/agent-loop.tsrunTool(两道关卡)。
  • Pi:packages/agent/src/agent-loop.ts:155-275runLoop)、:381-406(截断特判)、:411-426(并行/串行分派)、:582-584terminate)、:600-664prepareToolCall);packages/ai/src/utils/validation.ts:278-310validateToolArguments);packages/ai/src/types.ts:480-485Tool);packages/agent/src/types.ts:380-403AgentTool)、:422-437AgentEvent)。
  • 相关章节:5.3 一次 Tool Call 的完整循环6.3 pi-agent-core:Agent 与循环6.4 工具系统:定义、校验与执行

自测问题

  1. runAgentLoop 里一共有几处 return?分别在什么情况下触发?哪一处才是「正常结束」?
  2. context.messages.push(result) 删掉,屏幕上还看得见工具结果吗?模型呢?两者为什么会不一致?
  3. step05 的 calc 里写 args.expression as string 是安全的;把同一句话原样搬进 step04 的 tools.ts 就不安全。请只用一句话说清差别在哪里。
  4. 模型第一次把参数名写成 expr、第二次改对了。这个「改正」发生在哪一侧?程序为它做了什么?
  5. Pi 为什么在 stopReason === "length" 时把整批 Tool Call 直接判失败,而不是挑能解析的执行?

下一章预告 / 尚未展开的内容:到这里为止,程序退出之后一切归零——对话历史只活在内存里。8.4 Session 保存与恢复 会给它加上一个 JSONL 会话文件,用 id + parentId 记录条目,并支持 --continue 恢复上次的对话(对应 labs/mini-agent-harness/step06-sessions)。本章刻意没碰的三件事也各有归宿:取消(用户按 Ctrl+C 时,正在 await 的工具怎么停)和扩展钩子(外部文件如何往循环里注册工具、订阅事件)在 8.5;**把 FakeModel 换成真实 Provider(模型服务提供方)**在 8.6——届时你会验证本章的一个说法:换模型时 agent-loop.ts 一个字符都不用改。

本书分析的 Pi 版本:earendil-works/pi@c13ffe1(2026-07-30)