5.3 一次 Tool Call 的完整循环
本页分析版本earendil-works/pi@c13ffe12026-07-30本章解决什么问题:5.2 走完了「问一句、答一句」的路径。现在把工具加进来——模型说「我要读文件」之后,到底是谁去读的文件、读到的东西怎么回到模型面前、循环什么时候才停。 前置知识:3.4 工具调用(Tool Calling)、3.5 Agent 与 Agent Loop、5.2 一次普通请求的完整路径。 学习目标:读完后你能 ① 说清「模型从不执行任何东西,执行者永远是 harness」这句话在源码里对应哪几行;② 完整背出
toolCall内容块 → 检测 → 分派 → 校验 → 执行 → 回填 → 再请求 这条链路的函数与行号;③ 逐段讲出 read 工具从参数到截断输出的全过程;④ 说出真实实现比教科书伪代码多做了哪几类工程工作。
建立直觉:模型只会「说」,不会「做」
先澄清一个几乎人人都会踩的误解。
path=a.txt 的那个工具」。真正打开文件的是运行在你机器上的 Pi 进程。这个「替模型跑腿」的角色,就是 Agent Harness(Agent 运行框架)。 所以一次工具调用(Tool Calling)在物理上被拆成了两次模型请求:
- 第一次请求:harness 把「系统提示词 + 对话历史 + 工具声明清单」发给模型;模型回一条 assistant 消息,其中含有一个或多个 Tool Call(一次工具调用请求)。
- harness 执行工具,得到工具结果(Tool Result),把它当成一条新消息追加到上下文(Context)里。
- 第二次请求:harness 带着「原历史 + assistant 消息 + 工具结果」再问一次;模型这时才能基于文件内容说话。
如果模型第二次又要了一个工具,就重复 2–3。什么时候停?模型这一轮不再请求任何工具的时候。这正是 Pi 内层循环的退出条件。
最小示例:三十几行的工具回环
下面这个脚本不依赖任何库、不需要 API Key,用一个「假模型」把上面三步演一遍。新建 mini-tool-loop.mjs:
// mini-tool-loop.mjs —— 用假模型演示「谁在执行工具」
const lines = ["第一行", "第二行", "第三行", "第四行"];
const tools = {
read: async ({ limit = 2 }) => {
const shown = lines.slice(0, limit).join("\n");
return limit < lines.length ? `${shown}\n[还有 ${lines.length - limit} 行,用 offset=${limit + 1} 继续]` : shown;
},
};
let round = 0; // 假模型:第一轮请求工具,第二轮根据工具结果收尾
async function fakeModel(messages) {
round++;
if (round === 1) {
return { role: "assistant", content: [{ type: "toolCall", id: "c1", name: "read", arguments: { path: "a.txt", limit: 2 } }] };
}
const last = messages[messages.length - 1];
return { role: "assistant", content: [{ type: "text", text: `文件开头是:${last.content.split("\n")[0]}` }] };
}
const messages = [{ role: "user", content: "看看 a.txt 开头写了什么" }];
let hasMoreToolCalls = true;
while (hasMoreToolCalls) {
const message = await fakeModel(messages);
messages.push(message);
const toolCalls = message.content.filter((c) => c.type === "toolCall");
hasMoreToolCalls = toolCalls.length > 0;
for (const call of toolCalls) {
console.log(`[harness] 执行工具 ${call.name}`, call.arguments);
const content = await tools[call.name](call.arguments);
messages.push({ role: "toolResult", toolCallId: call.id, content });
}
}
console.log("[模型最终回答]", messages[messages.length - 1].content[0].text);运行 node mini-tool-loop.mjs,本机(Node v26.5.0)真实输出为:
[harness] 执行工具 read { path: 'a.txt', limit: 2 }
[模型最终回答] 文件开头是:第一行请注意三件事,它们和 Pi 的真实实现一一对应:
hasMoreToolCalls这个变量名不是我编的,Pi 的循环里就叫这个名字。- 工具结果是一条新消息(
role: "toolResult"),带着toolCallId与原请求配对——模型靠这个 id 知道哪条结果对应哪次请求。 - 工具输出里那句「还有 N 行,用 offset 继续」不是装饰。上下文窗口有限,工具必须把「我截断了、你可以这样继续」讲给模型听。Pi 在真实的 read 工具里做的就是这件事。
回到 Pi 源码
第一步:模型返回了什么
流式响应结束后,streamAssistantResponse 会返回一条完整的 AssistantMessage(5.2 讲过这条路径,packages/agent/src/agent-loop.ts:193)。它的 content 是一个数组,元素可能是文本、思考内容,也可能是 Tool Call(源码事实):
ToolCallarguments 是 Record<string, any>——注意这个 any。模型生成的是 JSON 文本,流式解析出来的对象在这一刻还没有经过任何校验,它是不可信输入。这一点决定了后面一整段校验代码的存在理由。
第二步:循环如何检测工具调用
回到 runLoop 的内层循环。5.2 里我们只看了「拿到消息就结束」,现在看它后面那一段(源码事实):
// packages/agent/src/agent-loop.ts:203-224(有省略)
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); // ← 工具结果回填上下文
newMessages.push(result);
}
}
await emit({ type: "turn_end", message, toolResults });
// …(省略:prepareNextTurn 快照、shouldStopAfterTurn、steering 轮询)hasMoreToolCalls内层循环的条件是 while (hasMoreToolCalls || pendingMessages.length > 0)(packages/agent/src/agent-loop.ts:174)。所以「模型这一轮只回文本」= toolCalls.length === 0 = hasMoreToolCalls 留在 false = 循环退出。纯文本收尾在源码里就是这么朴素的一件事。
hasMoreToolCalls = !executedToolBatch.terminate 这一行还藏着第二个出口:工具可以在结果里设 terminate: true 请求提前停机。但判定很严格——只有整批工具结果都设了才算数(源码事实,packages/agent/src/agent-loop.ts:582-584 的 shouldTerminateToolBatch)。
截断保护:不是所有 toolCall 都值得执行
stopReason === "length" 那个三元分支值得单独说。它的意思是:模型的输出被 token 上限截断了。
failToolCallsFromTruncatedMessage为什么不执行?函数上方的注释给了理由(源码事实,agent-loop.ts:374-380):流式的工具参数是用「尽力而为的 JSON 抢救解析器」拼出来的,一条被截断的消息完全可能产出能通过 schema 校验、但语义上残缺的参数。想象 bash 工具收到半截命令,或者 write 工具收到半截文件内容——执行它们的代价远大于让模型重发一次。
这条分支有专门的测试固化:packages/agent/test/agent-loop.test.ts 中的 "should not execute tool calls from a length-truncated assistant message"(第 371 行)。
第三步:串行还是并行
executeToolCalls「一个串行工具拖累整批」是刻意写成这样的:判断条件用的是 toolCalls.some(...),而不是逐个工具分组。至于为什么这样设计,源码与注释都没有说明;据此推断(尚未在源码中直接证实),是为了避免「模型同时请求 edit 改文件和 read 读同一个文件」这类竞态——与其让每个工具自己加锁,不如让声明了 sequential 的工具把整批拉回顺序执行。一种看法是这个策略过于保守:只要有一个慢工具声明串行,整批的并发收益就没了;代价换来的是「不用推理任意两个工具之间是否互相干扰」这份确定性。
两条分支分别是 executeToolCallsSequential(packages/agent/src/agent-loop.ts:433-487)与 executeToolCallsParallel(packages/agent/src/agent-loop.ts:489-554)。并行版有一个容易看漏的细节:它先顺序地对每个 tool call 做准备(查找 + 校验 + 前置钩子),把真正的执行包成闭包塞进数组,最后用 Promise.all 一起跑(agent-loop.ts:540-542)。因此并发的只是 execute 本身,准备阶段仍是有序的。
第四步:准备一次调用——查找、垫片、校验、钩子
prepareToolCall// packages/agent/src/agent-loop.ts:607-618(有省略)
const tool = currentContext.tools?.find((t) => t.name === toolCall.name);
if (!tool) {
return { kind: "immediate", result: createErrorToolResult(`Tool ${toolCall.name} not found`), isError: true };
}
try {
const preparedToolCall = prepareToolCallArguments(tool, toolCall);
const validatedArgs = validateToolArguments(tool, preparedToolCall);
// …(省略:beforeToolCall 钩子可 block;abort 检查;619-650 行)
} catch (error) {
// …(省略:657-663 行,把异常转成错误工具结果)
}这里最值得体会的是错误的去向:工具名写错、参数类型不对、钩子拦截——全都不会让循环崩溃,而是变成一条内容为错误文本的工具结果,回填给模型。模型读到的是一段形如 Validation failed for tool "read": 加逐条错误路径的文本(拼装模板见 packages/ai/src/utils/validation.ts:301-307),据此通常能自己改正重发。把错误当作对话内容,是 Agent Harness 相对普通 RPC(远程过程调用)框架最特别的错误处理观。
校验本身在 pi-ai 包里:
validateToolArguments注意最后那句 Received arguments: 加原始 JSON——错误消息是写给模型看的,所以要把它当时发了什么原样还给它。
第五步:真正执行
executePreparedToolCall// packages/agent/src/agent-loop.ts:675-696(有省略)
const result = await prepared.tool.execute(
prepared.toolCall.id,
prepared.args as never,
signal,
(partialResult) => {
if (!acceptingUpdates) return;
updateEvents.push(Promise.resolve(emit({ type: "tool_execution_update", /* …(省略字段) */ partialResult })));
},
);
acceptingUpdates = false;
await Promise.all(updateEvents);
return { result, isError: false };acceptingUpdates 这个开关是防御性的:工具 Promise 结算之后再调 onUpdate(比如某个没清理干净的定时器)会被静默忽略,避免结算后的事件污染界面。这条语义在 AgentToolUpdateCallback 的注释里写明了(packages/agent/src/types.ts:371-377)。
以 read 工具为例:从参数到截断输出
Pi 的命令行界面(CLI)内置七个工具,pi --help 的真实采集输出(research/cli-captures/pi-help.txt:170-177)列出了它们:
Built-in Tool Names:
read - Read file contents
bash - Execute bash commands
edit - Edit files with find/replace
write - Write files (creates/overwrites)
grep - Search file contents (read-only, off by default)
find - Find files by glob pattern (read-only, off by default)
ls - List directory contents (read-only, off by default)grep / find / ls 后面标的「off by default」,对应源码里那份默认激活清单 ["read", "bash", "edit", "write"](源码事实,packages/coding-agent/src/core/agent-session.ts:2592-2594)——七个工具都在注册表里,但只有四个会进 context.tools 发给模型。
我们挑最简单、无副作用的 read 走一遍。它的定义是一个 ToolDefinition:
createReadToolDefinitionexecute 的文本分支(packages/coding-agent/src/core/tools/read.ts:264-315)大致是这样:
// packages/coding-agent/src/core/tools/read.ts:266-305(有省略)
const buffer = await ops.readFile(absolutePath);
const textContent = buffer.toString("utf-8");
const allLines = textContent.split("\n");
const startLine = offset ? Math.max(0, offset - 1) : 0;
const startLineDisplay = startLine + 1;
if (startLine >= allLines.length) {
throw new Error(`Offset ${offset} is beyond end of file (${allLines.length} lines total)`);
}
// …(省略:277-286 行,用户显式给了 limit 时先按 limit 切片)
const truncation = truncateHead(selectedContent);
// …(省略:290-294 行,首行本身就超字节上限时,提示改用 bash + sed 兜底)
if (truncation.truncated) {
const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
const nextOffset = endLineDisplay + 1;
outputText = truncation.content;
if (truncation.truncatedBy === "lines") {
outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`;
} // …(省略:302-304 行 else 分支:按字节上限截断时的另一种措辞)
details = { truncation };
}几个设计点:
- 参数已经是可信的。
path、offset、limit到这里时已过 typebox 校验,工具内部不再做类型判断,只做业务判断(比如 offset 越界)。 - 失败就抛。
AgentTool.execute的注释明确要求「失败时抛异常,而不是把错误编码进 content」(源码事实,packages/agent/src/types.ts:388);统一转错误结果的活由循环干。 - 截断必须自带续读说明。这是全书反复出现的主题:上下文窗口是稀缺资源。
truncateHeadread 用 truncateHead(要开头),bash 用 truncateTail(要结尾的报错信息),grep 用 truncateLine(防单行爆炸)——同一个文件里三种策略,对应三种「读者最想看哪一段」的判断(源码事实,packages/coding-agent/src/core/tools/truncate.ts:78、:168、:268)。
ToolDefinition(coding-agent 层,比 agent 层多了 promptSnippet 与终端界面渲染函数,execute 有第 5 个参数 ctx);agent 包的循环只认识 4 个参数的 AgentTool。中间的适配器是 wrapToolDefinition(packages/coding-agent/src/core/tools/tool-definition-wrapper.ts:5-20),它把第 5 个参数用一个工厂函数补上。工具类型的三层结构在 6.4 工具系统细讲。 第六步:收尾、包装、回填
执行完还有两步。finalizeExecutedToolCall(packages/agent/src/agent-loop.ts:709-754)调用 afterToolCall 钩子,允许扩展(Extension)按字段覆盖 content / details / usage / terminate / isError。然后:
createToolResultMessage这条 ToolResultMessage 随即被 push 进 currentContext.messages(前面那段 agent-loop.ts:218-221),于是它成为下一次请求上下文的一部分。coding-agent 的 convertToLlm 对 toolResult 角色原样透传给 Provider(模型服务提供方)(源码事实,packages/coding-agent/src/core/messages.ts:186-187);同时 AgentSession 在 message_end 事件里把它写进会话(Session)文件(packages/coding-agent/src/core/agent-session.ts:625-642)。
第七步:内层循环再来一遍
hasMoreToolCalls 现在是 true,内层 while 条件成立,于是又走一次 streamAssistantResponse——这一次上下文里多了 assistant 消息和工具结果。模型看到文件内容,可能直接回答(纯文本,循环结束),也可能再要一个工具(继续绕圈)。
至于工具声明是怎么发给模型的:streamAssistantResponse 把 context.tools 原样放进 LLM 上下文(packages/agent/src/agent-loop.ts:298-302),Anthropic 适配器再把每个工具的 typebox parameters 序列化成 input_schema 下发(packages/ai/src/api/anthropic-messages.ts:1313-1318)。同一份 schema 有三个用途:发给模型当声明、运行时当校验规则、编译期当 TypeScript 类型。
图解
图 5.3-1 把 5.2 的时序图扩展出工具回环
读法:从上往下是时间。和 5.2 相比多出来的是中间那一段——L 与 T 之间的往返,以及第二次发往 P 的请求。请重点看两处 Note:第一处对应 agent-loop.ts:203 与 :206,第二处对应 :773 与 :218-221。整张图里 P 从头到尾只做了两件事:收文本、吐文本。
图 5.3-2 单次 tool call 的生命周期:四个失败出口都通向同一个终点
读法:从上往下是正常路径 A→C→D→F→G→H→I→J,每个方框都标了对应的源码行。请重点看汇聚到 E 的那四支箭头——查不到工具、校验失败、被钩子拦截、执行抛异常,最后都变成同一种东西:一条 isError 的工具结果消息,照样走 I 和 J 回填进上下文。没有任何一条路径会让循环崩溃。这正是「把错误当对话内容」的设计在图上的样子。
图 5.3-3 并行执行时,事件顺序与消息顺序被刻意分开
关注 E 之后的分叉:界面事件按「谁先跑完谁先播报」发出,而写进消息历史的工具结果严格保持模型请求时的顺序。前者服务于终端界面的实时感,后者保证上下文可复现。这条行为由测试 "should emit tool_execution_end in completion order but persist tool results in source order" 固化(packages/agent/test/agent-loop.test.ts:586)。
真实实现比伪代码多了什么
概念层面的工具循环(3.4 与 3.5 讨论的那一层,本章开头的最小示例就是它的样子)在骨架上是对的:过滤出 toolCall、逐个执行、结果塞回历史、没有 toolCall 就停。差距全在工程细节上:
| 伪代码里没有的 | Pi 里对应的实现 | 为什么必须有 |
|---|---|---|
| 参数校验 | validateToolArguments(validation.ts:278-310)+ prepareArguments 垫片(agent-loop.ts:586-598) | 模型输出是不可信输入;类型错了要能让模型自己修 |
| 输出截断 | truncateHead / truncateTail / truncateLine(truncate.ts:78、:168、:268) | 一个 10MB 的文件能一次撑爆上下文窗口 |
| 并发与顺序 | executeToolCalls 分派(agent-loop.ts:411-426)、并行版的顺序保持(:540-548) | 多工具要快,但写文件类工具不能乱序 |
| 错误包装 | prepareToolCall 的 try/catch(:657-663)、executePreparedToolCall 的 catch(:697-703) | 一次工具失败不该终止整个会话 |
| 截断消息保护 | failToolCallsFromTruncatedMessage(:381-406) | 半截参数比没有参数更危险 |
| 取消 | 贯穿全链的 AbortSignal(:629-635、:675-678、:478-480) | 用户按下 Esc 时,正在跑的 bash 也要停 |
| 钩子 | beforeToolCall / afterToolCall(:619-643、:709-754) | 扩展要能拦截危险命令、改写结果 |
取消这条线索单独在 5.6 取消与错误处理 展开;钩子与扩展的关系见 7.1 Extension 系统。
实践任务
目标:不发一次真实模型请求,验证本章讲的三条行为:工具结果会回填、length 截断不执行工具、整批 terminate 才提前停。
前提:不需要任何 API Key。这些测试用的是假的 StreamFn,不联网。
步骤 1:在 Pi 仓库根目录运行整个文件的测试。
npm test --workspace=@earendil-works/pi-agent-core -- test/agent-loop.test.ts预期现象:Test Files 1 passed (1)、Tests 21 passed (21),耗时不到一秒(本机真实运行结果)。
步骤 2:只跑与工具相关的用例,并打开逐条列表。
npm test --workspace=@earendil-works/pi-agent-core -- test/agent-loop.test.ts -t "tool" --reporter=verbose预期现象:11 个用例通过、10 个被跳过;通过的列表里能看到 should handle tool calls and results、should not execute tool calls from a length-truncated assistant message、should stop after a tool batch when every tool result sets terminate=true(本机真实运行结果)。
步骤 3:用编辑器打开 packages/agent/test/agent-loop.test.ts,跳到第 274 行的 "should handle tool calls and results",找出三样东西:假模型第一轮返回的 toolCall 块长什么样、测试里的 execute 往数组里记了什么、断言检查了哪几个事件。
如何判断成功:你能指着测试代码说出「这一行就是 agent-loop.ts:203 过滤出来的东西」,并解释为什么假模型必须在第二轮返回纯文本,否则测试会一直循环下去。
常见错误:① 漏掉 --,参数会被 npm 自己吃掉而不是传给 vitest;② 在 packages/agent 目录里跑 --workspace 会报找不到工作区,请回到仓库根目录;③ -t 是按用例名过滤,被跳过的用例显示为 ↓,那是正常的,不是失败。
对应源码位置:packages/agent/src/agent-loop.ts:203-224(检测与回填)、:381-406(截断保护)、:582-584(terminate 判定)。想看工具自己的行为,另一个文件是 packages/coding-agent/test/tools.test.ts,其中 "should truncate files exceeding line limit"(第 88 行)正是本章 read 截断那一段的测试。
本章小结
- 模型不执行任何东西。它只在 assistant 消息的 content 里放一个
toolCall块(packages/ai/src/types.ts:360-366);执行者永远是 harness,唯一的tool.execute调用点在packages/agent/src/agent-loop.ts:675。 - 完整链路:
streamAssistantResponse(:193)→ 过滤 toolCall(:203)→executeToolCalls分派(:411-426)→prepareToolCall查找与校验(:600-664)→executePreparedToolCall执行(:666-707)→finalizeExecutedToolCall钩子(:709-754)→createToolResultMessage(:773-787)→ 回填currentContext(:218-221)→ 内层 while 再转一圈(:174)。 - 循环的终止条件在本章只用到一个:这一轮没有 toolCall,
hasMoreToolCalls保持false。另外三个(error/aborted、整批 terminate、shouldStopAfterTurn)在 5.6 与 6.3 展开。 - 所有失败都变成一条
isError的工具结果回给模型,而不是异常上抛;错误消息是写给模型看的。 - read 工具的价值不在读文件,在于「截断之后告诉模型怎么继续」:
truncateHead+Use offset=N to continue.。 - 关键术语:Tool Call(一次工具调用请求)、工具结果(Tool Result)、内层循环、截断(truncation)、执行模式(executionMode)、terminate。
- 关键源码索引:
packages/agent/src/agent-loop.ts:203-224 / 381-406 / 411-426 / 600-664 / 666-707 / 773-792;packages/ai/src/utils/validation.ts:278-310;packages/ai/src/types.ts:360-366;packages/coding-agent/src/core/tools/read.ts:203-347;packages/coding-agent/src/core/tools/truncate.ts:11-13 / 78-160;测试packages/agent/test/agent-loop.test.ts、packages/coding-agent/test/tools.test.ts。 - 自测问题:① 模型一次返回了 3 个 toolCall,其中一个工具声明了
executionMode: "sequential",实际会怎么执行?为什么?② 工具execute里throw new Error("文件不存在"),最终模型会看到什么?③ 为什么stopReason === "length"时不能执行工具,哪怕参数通过了校验?④ 并行执行时,tool_execution_end事件顺序和写进历史的工具结果顺序为什么不一样? - 下一章:5.4 流式事件如何传播到界面——本章一路
emit出去的tool_execution_start/update/end到底被谁接住、怎么变成终端里滚动的那几行。 - 尚未展开:工具的注册与激活流程(哪些工具进了
context.tools)、扩展自定义工具、beforeToolCall的权限模型,分别在 6.4 工具系统 与 7.3 自定义工具与斜杠命令;把这套循环自己写一遍见 8.3 Agent Loop 与工具执行。