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 await、3.4 工具调用(Tool Calling)、3.5 Agent 与 Agent Loop。第 5、6 部分读过的话会更省力,但不是必须。
学习目标:读完本章后你能
建立直觉:让 step03 不够用
先把上一步的形状摆出来。step03 的主循环核心是这一段(labs/mini-agent-harness/step03-streaming/src/main.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):
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>):它不再需要把消息交回给谁,纯粹只负责显示。
最小示例一:step04 把循环转起来
先跑一遍
cd labs/mini-agent-harness/step04-agent-loop
npm install
npm run demonpm run demo 的真实输出(完整内容见该目录的 expected-output.txt,本书作者实测逐字一致):
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 的辅助函数 toolCallsOf;messageText 的函数体改了一行,原因见本节末尾的说明)。第一处,助手消息的内容块(Content Block)多了一种:
// 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:
export interface ToolResultMessage {
role: "toolResult";
toolCallId: string;
toolName: string;
content: TextContent[];
isError?: boolean;
}
export type Message = UserMessage | AssistantMessage | ToolResultMessage;第三处,StreamEvent 从 8.2 的 4 种变成 5 种,多出来的那种是模型宣布它要调用工具:
export type StreamEvent =
| { type: "start" }
| { type: "text_delta"; delta: string }
/** 模型决定调用一个工具。arguments 此时已经是对象。 */
| { type: "toolcall"; toolCall: ToolCallContent }
| { type: "done"; message: AssistantMessage }
| { type: "error"; error: string };第四处,循环需要一个比 StreamEvent 更大的事件联合:
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 };HarnessEvent 是 StreamEvent 的超集:模型自己的事件(start / text_delta / toolcall / done / error)加上循环自己的事件(turn_start / tool_call / tool_result / turn_end)。合成一个联合的好处是渲染层只需要一个 for await 加一个 switch;坏处是订阅者必须能区分 toolcall(模型说它要调用)与 tool_call(循环真的开始执行了)这两个长得很像的事件——它们确实是两件事,中间隔着一次真实的函数调用。
content 数组元素类型不一样:UserMessage 是 TextContent[],AssistantMessage 是 ContentBlock[],ToolResultMessage 又是 TextContent[]。直接在这个联合数组上调用 filter,TypeScript 合不出一个可用的调用签名(它要同时满足三种数组的重载)。先赋值给一个 ContentBlock[] 变量把类型归一,问题就消失了。这不是本书编的教学例子,是写这一步时真实撞到的编译错误。 假模型怎么用一份剧本驱动两轮
FakeModel 要能演完整个循环,关键在它拿什么去匹配规则:
// 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 里成对出现的两条规则,正好就是循环的两轮:
// 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_1、call_2……),保证唯一且在 demo 里可预测。
主角:agent-loop.ts 逐行
下面是 runAgentLoop 的全部循环主体,一行没删(labs/mini-agent-harness/step04-agent-loop/src/agent-loop.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 就是循环的唯一正常退出条件:模型这一轮只说了话、没请求工具,说明它说完了。注意退出前先 yield 了 turn_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 是本步骤第二个值得逐行看的函数:
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 补上三件事:工具有了要发给模型的「说明书」、执行前有一道运行时校验、文件类工具处理了越界与截断。
说明书与实现的切分
// 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() 做的就是把说明书部分摘出来:
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 就是这两处伏笔的落地:
// 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 才是安全的:
async execute(args) {
// args 已经通过 validate,断言是安全的。
const expression = args.expression 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 之前插入校验:
// 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 的真实片段:
你> 故意写错参数试试
[第 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 这类安全阀配套使用,两者是一对。
rules.ts 里写死了一条规则 { match: "toolResult:calc 参数校验失败", reply: "参数名不对,改成 expression 重来。", toolCall: { name: "calc", arguments: { expression: "1+1" } } },靠子串匹配命中。这是剧本,不是智能。但循环这一侧的代码是真的:把 FakeModel 换成真模型,这条链路一行都不用改([8.6](/mini-harness/real-provider) 会验证这一点)。真实模型拿到同样的错误文本会不会同样改对参数名,本实验没有条件验证,属于据此推断(尚未在本书中直接证实);能确定的只是我们把改正所需的信息放到了它看得见的地方——没有这一步,再强的模型也无从改起。 文件工具绕不开的两件事
read_file 演示的两个问题,任何读文件的工具都必须处理:
// 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 的第三个场景就是它拦下的(真实输出):
[工具调用] 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,超出就截断并注明:
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 没有任何一条路径是「抛异常给用户」或「终止循环」。图中的 B、V、X 三个判断分别对应 agent-loop.ts 里 runTool 的三段代码(tools.get、validate、try/catch),W 对应外层 executeToolCall 的返回值与 runAgentLoop 里那句 context.messages.push(result)。
回到 Pi 源码:runLoop 与我们的差距
Pi 的等价物叫 runLoop,是一个内部函数,被 agentLoop(低层流式 API)和 Agent 类共用(源码事实):
runLoop循环骨架如下(源码事实。为聚焦主干省略了四段并逐处标注,另外贯穿全函数的 newMessages 记账也一并略去;控制流未改动):
// 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_end与agent_end,保证订阅者收到的事件成对;我们的循环在这条路径上直接return,turn_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 的处理是全部直接判失败,一个都不执行:
failToolCallsFromTruncatedMessage4. 默认并行执行工具。 我们串行是为了 demo 输出可预测;Pi 默认并行,并允许单个工具用 executionMode: "sequential" 声明自己必须独占(agent-loop.ts:411-426,默认值 "parallel" 在 packages/agent/src/agent.ts:230)。会修改同一个文件的工具就该这么声明。
5. terminate:工具可以要求停下循环。 hasMoreToolCalls = !executedToolBatch.terminate 这一行的来源是:
shouldTerminateToolBatch这是我们完全没有的能力——某些工具(例如「结束本次任务」类工具)执行完就该让循环停下,而不是再问一次模型。
工具执行:校验发生在哪一行
我们把校验写在 runTool 里;Pi 把它放在 prepareToolCall,位置和职责完全对应(源码事实):
prepareToolCall顺序是:查不到工具就直接返回 Tool ${name} not found 的错误结果(agent-loop.ts:607-614,对应我们的「未注册的工具」)→ validateToolArguments(agent-loop.ts:618)→ 可选的 beforeToolCall 钩子,它可以 block 掉这次调用(agent-loop.ts:619-643,我们没有)。整段的 catch(agent-loop.ts:657-663)把校验抛出的错误转成 isError 的工具结果——和我们「校验失败回填给模型」是同一个决策,只是 Pi 用 throw + catch 表达,我们用返回值表达。
校验的实现在 pi-ai 包里,用的是 TypeBox:
validateToolArguments它比我们的 30 行多做了三件事:Value.Convert 会尝试把 "128" 这类字符串强转成数字(模型很爱把数字写成字符串);validator 是 Compile 出来并用缓存复用的,不是每次现算;错误消息带字段路径,还会把收到的原始参数一并打印给模型看。但它的位置和作用与我们的 validate 完全一致——都是那条链路上唯一的关卡。
工具类型的分层也和我们的切分对应:
ToolAgentToolTool.parameters 的类型是 TParameters extends TSchema,而 TSchema 是从 typebox 包 import 进来的(源码事实,packages/ai/src/types.ts:456)。TypeBox 的设计目标就是让 schema 对象本身同时是一份合法的 JSON Schema,因此它可以直接序列化进请求体发给模型服务——这正好解释了我们那句「说明书必须是纯数据」为什么成立。多出来的那个 constrainedSampling 字段我们没有对应物:源码注释把它写成「provider 侧的约束采样配置」,用来要求模型服务端在生成参数时就强制符合 schema,而不是等生成完再校验。
事件:HarnessEvent 与 AgentEvent
AgentEvent对照我们的 HarnessEvent:turn_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目标:确认自己理解了「回填」这一步的必要性,而不只是读过。
步骤:
cd labs/mini-agent-harness/step04-agent-loop && npm install && npm run demo,与expected-output.txt逐字比对。- 打开
src/agent-loop.ts,把context.messages.push(result);那一行注释掉,再跑npm run demo。 - 恢复该行。改在
rules.ts里加一对新规则(一条带toolCall,一条match以toolResult:calc开头),让「帮我算 100-1」也能触发完整的两轮循环,并把demo.ts的script数组加上这句输入。 - 把
runAgentLoop的maxTurns显式传成2,构造一个模型永远要工具的规则,观察最后那条error事件。
预期现象:第 2 步注释掉 push 之后(本书作者实测),「帮我算 12*8」那一段变成这样——
[第 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 会同时命中结果 96 和 99 两轮,写成 toolResult:calc 99 才是安全的。
源码位置:src/agent-loop.ts(循环主体)、src/fake-model.ts 的 matchKey、src/rules.ts。
labs/mini-agent-harness/step05-tools目标:把「运行时校验」从一句口号变成一次亲眼所见的失效。
步骤:
cd labs/mini-agent-harness/step05-tools && npm install && npm run demo,确认最后一行是「本次对话共 18 条消息」。- 打开
src/agent-loop.ts,把runTool里那段校验(const checked = validate(...)与随后的if (!checked.ok)分支)删掉,把tool.execute(checked.value, toolContext)改成tool.execute(toolCall.arguments, toolContext),重新跑 demo。 - 恢复。给
validate增加一条规则:ParametersSchema支持可选的enum?: string[],值不在枚举里就报错;给calc加一个op参数试一试。 - 新增第三个工具
list_files(用node:fs/promises的readdir),注册进createBuiltinTools,并在rules.ts里加规则让它被调用。注意复用read-file.ts里的越界检查。
预期现象:第 2 步之后(本书作者实测),「故意写错参数试试」那一段变成——
[工具调用] calc {"expr":"1+1"}
[工具结果] calc → 无法解析表达式:undefined(出错)
[第 1 轮结束]
[第 2 轮]
助手> 这句话没有命中任何规则,我只能用兜底回复了。args.expression 是 undefined,正则拿它去匹配当然不成立,于是返回「无法解析表达式:undefined」。程序不会崩溃——但模型收到的是一条毫无信息量的错误,它无从知道问题出在参数名上,自我纠错的链条就此断掉(这里表现为落到兜底规则)。第 4 步做完,npm start 启动时打印的「可用工具」一行会多出 list_files。
如何判断成功:你能说出第 2 步的结果为什么比「参数校验失败:缺少必填参数 expression;不认识的参数 expr」更糟——不是因为它更危险,而是因为它更不可诊断:错误信息里没有任何能让模型改对的线索。
常见错误:第 3 步只改了 validate 却忘了 PropertySchema 的类型定义,编译不过;第 4 步忘了越界检查,list_files 成了绕过 read_file 防护的后门。
源码位置:src/tools/validate.ts、src/tools/types.ts、src/agent-loop.ts 的 runTool、src/tools/read-file.ts。
本章小结
- Agent Loop 就是四步:调模型 → 检查 Tool Call → 执行工具并把结果追加进上下文 → 回到第一步。唯一的正常退出条件是「模型这一轮不再请求工具」。写成异步生成器之后,循环只
yield事件、不打印任何东西,渲染层可以随便换。 - 工具结果是第三种 role,靠
toolCallId与发起它的 Tool Call 配对;必须push回context.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)、toolCallId、stopReason: "toolUse"、isError、运行时校验、ToolSpec / ToolDefinition、maxTurns 安全阀、异步生成器(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.ts的runTool(两道关卡)。 - Pi:
packages/agent/src/agent-loop.ts:155-275(runLoop)、:381-406(截断特判)、:411-426(并行/串行分派)、:582-584(terminate)、:600-664(prepareToolCall);packages/ai/src/utils/validation.ts:278-310(validateToolArguments);packages/ai/src/types.ts:480-485(Tool);packages/agent/src/types.ts:380-403(AgentTool)、:422-437(AgentEvent)。 - 相关章节:5.3 一次 Tool Call 的完整循环、6.3 pi-agent-core:Agent 与循环、6.4 工具系统:定义、校验与执行。
自测问题:
runAgentLoop里一共有几处return?分别在什么情况下触发?哪一处才是「正常结束」?- 把
context.messages.push(result)删掉,屏幕上还看得见工具结果吗?模型呢?两者为什么会不一致? - step05 的
calc里写args.expression as string是安全的;把同一句话原样搬进 step04 的tools.ts就不安全。请只用一句话说清差别在哪里。 - 模型第一次把参数名写成
expr、第二次改对了。这个「改正」发生在哪一侧?程序为它做了什么? - 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 一个字符都不用改。