3.5 Agent 与 Agent Loop
本章解决什么问题:3.4 工具调用结束在一个悬念——模型只会请求调用工具,它没有手,什么也执行不了。那么谁去执行?结果怎么送回模型?模型看完结果又怎么接着往下做?本章把这三个问题的答案合成一段十几行的循环代码。这段代码就是「Agent 之所以是 Agent」的全部秘密,也是本书后面所有源码分析的中心。
前置知识:3.1 LLM 与模型 API(模型是无状态的:每次请求都要带上完整历史)、3.2 消息、上下文与 token(user / assistant / toolResult 三种消息)、3.4 工具调用(Tool Calling)(Tool Call 与工具结果的数据形状);TypeScript 方面用到 2.3 可辨识联合与 2.5 Promise 与 async/await。
学习目标:读完本章后你能
- 说出 Agent 的工作定义——大语言模型 + 工具 + 循环 + 状态——并解释缺任何一个会退化成什么;
- 默写 Agent Loop 的六步骨架,并解释为什么第 2 步必须携带完整历史、第 5 步必须把工具结果写回历史;
- 列出循环的全部终止条件,说明为什么最大轮数保护是必需品而不是可选项;
- 准确使用「一轮(turn)」和「一步(step)」这两个词描述循环的运行过程;
- 在 Pi 源码里认出这个循环:
packages/agent/src/agent-loop.ts里那两层while。
建立直觉:3.4 章末尾留下的那个洞
先把 3.4 的结论摆出来:给模型一份工具清单,它就多了一种回复方式——不直接回答,而是回复「请帮我调用 calculate,参数是 {op: "multiply", a: 23, b: 17}」。这条回复叫 Tool Call(一次工具调用请求)。
问题在于,模型是一个函数:文本进、文本出。它能说出「请帮我调用计算器」,但它没有手,碰不到你的文件、你的终端、你的计算器函数。所以只走到这里,程序会是这个样子:
用户:帮我算一下 23 乘以 17 是多少?
模型:「这个我自己算不准,我用一下计算器。」+ Tool Call calculate{"op":"multiply","a":23,"b":17}
(结束)用户等来的是一句「我用一下计算器」,然后就没有然后了——391 这个数字从头到尾没人算过。这不是模型不够聪明,而是程序少写了几行。缺的三个动作是:
- 执行:按名字找到
calculate函数,把参数传进去,拿到391; - 回填:把
391变成一条新消息,追加到历史里; - 再问一次:带着这份更长的历史,重新请求模型。
关键的一步在第 3 步之后:模型看到 391,这一次它可能给出最终答案,但也可能又要求调用一次工具(比如它还想验算一下)。既然「再问一次」之后可能再次回到「执行」,这三个动作就不是一条直线,而是一个圈。这个圈就是本章的主角。
Agent 的工作定义
「Agent」在业界是个用得很松的词,有人把一个精心写的提示词也叫 agent。本书统一采用上面这个工程定义:它可以直接对应到代码,方便我们逐块拆解 Pi 的源码。四个要素缺一个会怎样:
| 要素 | 它负责什么 | 缺了它,程序退化成 |
|---|---|---|
| 大语言模型(LLM) | 每一轮决定「下一步做什么」 | 只能按固定脚本走的普通程序 |
| 工具 | 把决策变成对外部世界的实际操作 | 只会聊天的聊天机器人 |
| 循环 | 让「决策 → 行动 → 观察结果」反复进行 | 只能行动一次就停住——上一节那个洞 |
| 状态(消息历史) | 让模型看得见之前发生过什么 | 每轮失忆,模型会永远重复第一步 |
最后一行值得多说一句。3.1 章讲过,模型是无状态的:它不记得上一次你问过什么,每次请求你都得把全部历史重新发过去。所以「状态」在这里不是什么高深机制,就是一个不断变长的数组。但如果忘了把工具结果追加进去,模型下一轮看到的历史和上一轮一模一样,于是它会做出一模一样的决定——再次要求调用同一个工具,永远循环下去。
还有一个比例关系值得记住:四个要素里只有第一个需要「智能」。剩下三个是彻头彻尾的普通程序——一个 while、一个数组、几个函数调用。你在本书后面读到的 Pi 源码,绝大部分都是在把后三样做扎实(怎么保存状态、怎么取消、怎么校验参数、怎么把事件送到界面)。模型本身是一个 HTTPS 请求,反而是最短的那一段。
Agent Loop 的六步骨架
用伪代码写出来是这样(这是完整的骨架,不是简化版——真实框架多出来的东西都是在这几行上加装的):
// Agent Loop 的完整骨架
历史 = [用户输入] // 第 1 步:用户输入进入历史
轮数 = 0
while (轮数 < 最大轮数) { // 循环边界:最大轮数保护
轮数 = 轮数 + 1
回复 = await 请求模型(历史) // 第 2 步:带上完整历史请求模型
历史.追加(回复)
if (回复里没有 Tool Call) { // 第 3 步:分岔口
return 回复.文本 // → 纯文本:任务结束,交给用户
}
for (每个 Tool Call of 回复) { // 第 4 步:逐个执行模型点名的工具
结果 = await 执行工具(Tool Call) // (工具抛错也要接住,见下文)
历史.追加(结果) // 第 5 步:结果回填进历史
}
} // 第 6 步:回到 while 开头,再问一次模型
return "已达到最大轮数,循环被中断" // 保护性出口六步里有三步容易被轻视,逐个说清楚:
- 第 2 步为什么要带上「完整」历史:模型无状态。它不知道自己上一轮说过什么,也不知道工具返回了什么,这些信息全在你发过去的历史里。少发一条,模型就少知道一件事。(历史越滚越长会撞上上下文窗口的上限,那是 3.7 章上下文压缩要解决的问题。)
- 第 3 步是整个循环唯一的分岔口:判断依据非常朴素——这条回复里有没有 Tool Call。有就继续转,没有就停。不需要模型说「我做完了」,也不需要你去理解它说了什么。
- 第 5 步的「回填」是循环能推进的原因:工具结果必须变成一条
toolResult消息追加进历史。这样下一轮的历史就和这一轮不同了,模型才可能做出不同的决定。忘了这一步,循环会原地打转直到撞上最大轮数。
图 3.5-1 Agent Loop:六步循环
阅读顺序:从上往下走,走到底再沿右边的箭头绕回第 2 步。这是全书最重要的一张图,后面每一次讲「Pi 做了什么」,都是在给这张图的某个方框加细节。请重点看两处:一是第 3 步这个菱形——它是循环唯一的分岔口,判断依据只是「回复里有没有 Tool Call」;二是从第 5 步绕回第 2 步的那条线——第 6 步不是一个新动作,它就是这条回边,Agent 的「自主性」全部来自它。图里的三个出口(正常结束、保护性中断,以及下一节会补上的出错中断)对应真实框架的全部终止条件。
术语约定:「一轮」和「一步」
这两个词后面会反复出现,先把它们钉死,否则读源码时很容易把「转了几圈」和「做了几件事」搅在一起。本书的约定与 Pi 源码一致:
- 一轮(turn) = 一次模型请求 + 这次回复触发的全部工具执行。也就是上图从第 2 步走到第 5 步(或走到出口)的一整圈。上一节那段任务是两轮完成的:第 1 轮请求模型并执行了
calculate,第 2 轮请求模型拿到最终文本。 - 一步(step) = 循环体内的一个具体动作,也就是图上的一个方框:「请求一次模型」是一步,「执行一个工具」也是一步。
一轮通常包含多步,一轮产生的消息条数也不等于轮数:上面那两轮一共产生了 4 条消息(user、assistant、toolResult、assistant)。轮数、步数、消息条数是三个不同的计数,混用会让后面读事件序列时非常痛苦。
这个「轮」的定义不是本书发明的,Pi 在事件类型的注释里写得很明确:
AgentEventexport type AgentEvent =
// Agent lifecycle
| { type: "agent_start" }
| { type: "agent_end"; messages: AgentMessage[] }
// Turn lifecycle - a turn is one assistant response + any tool calls/results
| { type: "turn_start" }
| { type: "turn_end"; message: AgentMessage; toolResults: ToolResultMessage[] }
// …(省略:消息与工具执行相关的其余 6 种事件)Pi 每转一圈就发一对 turn_start / turn_end 事件——终端界面上那一段一段的输出分块,边界就是它。完整的十种事件留到 6.3 pi-agent-core 讲。
循环什么时候停
一个会自己转圈、每转一圈都要花钱和时间的程序,最要紧的问题是:它凭什么会停下来? 上面的骨架里有三个出口,真实框架也不外乎这几类:
- 正常出口:模型返回不含 Tool Call 的纯文本 —— 它认为任务做完了。这是绝大多数情况。
- 保护出口:轮数达到上限 —— 由你的代码强行中断。
- 异常出口:请求模型时出错、用户按下取消 —— 这一类下面单说。
最大轮数保护为什么是必需品。设想一个模型每轮都要求「再验算一次」,永不给出最终答案。没有上限,这个 while 就是死循环:每一轮都在往历史里堆消息、都在调用一次真实的模型 API(真金白银)、都在执行一次工具(可能改动文件)。这不是假想,本章实验的第 3 部分就用一个「坏掉的模型」把它演出来了:maxTurns 设成 3,循环转满 3 轮被拦下,历史里堆了 7 条消息。上限设多少取决于任务(编码类 Agent 常见几十到上百轮),但「必须有一个上限」没有例外。
最小示例:40 行看得见的循环
把上面的骨架写成真能跑的 TypeScript。存成 mini-loop.ts,用 1.2 章的方式 tsx mini-loop.ts 运行。模型是假的(不联网、不需要 API Key):它只看历史的形状——还没有工具结果就要求调用工具,有了就直接报答案。
type ToolCall = { id: string; name: string; arguments: Record<string, unknown> };
type UserMessage = { role: "user"; content: string };
type AssistantMessage = { role: "assistant"; text: string; toolCalls: ToolCall[] };
type ToolResultMessage = { role: "toolResult"; toolCallId: string; output: string };
type Message = UserMessage | AssistantMessage | ToolResultMessage;
/** 假模型:历史里还没有工具结果就要求调用工具,有了就直接报答案。 */
async function model(messages: Message[]): Promise<AssistantMessage> {
const last = messages[messages.length - 1];
if (last.role === "toolResult") {
return { role: "assistant", text: `算出来了:${last.output}`, toolCalls: [] };
}
return {
role: "assistant",
text: "我用一下计算器。",
toolCalls: [{ id: "c1", name: "multiply", arguments: { a: 23, b: 17 } }],
};
}
const tools: Record<string, (args: Record<string, unknown>) => Promise<string>> = {
multiply: async (args) => String(Number(args.a) * Number(args.b)),
};
async function main(): Promise<void> {
const messages: Message[] = [{ role: "user", content: "23 乘以 17 是多少?" }];
for (let turn = 1; turn <= 10; turn++) { // 最大轮数保护:最多 10 轮
const assistant = await model(messages); // 第 2 步
messages.push(assistant);
console.log(`第 ${turn} 轮 · 模型说:${assistant.text}`);
if (assistant.toolCalls.length === 0) break; // 第 3 步:纯文本 → 结束
for (const call of assistant.toolCalls) { // 第 4 步
const output = await tools[call.name](call.arguments);
console.log(`第 ${turn} 轮 · 执行 ${call.name} ⇒ ${output}`);
messages.push({ role: "toolResult", toolCallId: call.id, output }); // 第 5 步
}
}
console.log(`历史里现在有 ${messages.length} 条消息`);
}
main();真实输出:
第 1 轮 · 模型说:我用一下计算器。
第 1 轮 · 执行 multiply ⇒ 391
第 2 轮 · 模型说:算出来了:391
历史里现在有 4 条消息几个可以立刻验证的点:两轮、四条消息,正好印证上一节的术语约定;for (let turn = 1; turn <= 10; ...) 这一行同时干了两件事——它是循环,也是最大轮数保护;整段代码里没有任何一处「理解」了模型说的话,分岔只看 toolCalls.length === 0。
这四十行已经是一个能用工具完成任务的 Agent 了。它离 Pi 差的不是循环本身(循环就是这样),而是循环周围的一切:流式输出、参数校验、并行执行工具、取消、会话保存、事件通知界面。那些是 3.6 章和第五、六部分的内容。
Pi 中哪里用到了它
Pi 把这个循环放在 packages/agent 这个 package 里,函数叫 runLoop。你在上面读到的六步,在源码里一一对应得上——只是它多了两层 while 和一批钩子。
runLoopasync function runLoop(/* …(省略:上下文、配置、取消信号、事件回调、流函数五个参数)*/): Promise<void> {
// …(省略:初始化与开跑前的一次排队消息检查)
// Outer loop: continues when queued follow-up messages arrive after agent would stop
while (true) {
let hasMoreToolCalls = true;
// Inner loop: process tool calls and steering messages
while (hasMoreToolCalls || pendingMessages.length > 0) {
// …(省略:turn_start 事件、把排队消息插进上下文)
// Stream assistant response
const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFunction);
newMessages.push(message);
if (message.stopReason === "error" || message.stopReason === "aborted") {
await emit({ type: "turn_end", message, toolResults: [] });
await emit({ type: "agent_end", messages: newMessages });
return;
}内层那个 while (hasMoreToolCalls || pendingMessages.length > 0) 就是本章的循环:条件的前半段是「上一轮还有工具调用没处理完,得再问一次模型」,也就是图 3.5-1 那条回边。streamAssistantResponse 是第 2 步(名字里的 stream 说明它是 3.3 章的流式请求);紧接着的 stopReason === "error" || "aborted" 判断,就是上一节说的异常出口:请求出错或用户取消时,发完事件直接 return,循环立刻结束。
再往下几行是第 3、4、5 步:
executeToolCallsstopReason === "length" 的分支处理「模型输出被 token 上限截断」的情况。 // Check for tool calls
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); // ← 第 5 步:结果回填
newMessages.push(result);
}
}
await emit({ type: "turn_end", message, toolResults });对照着看:toolCalls.length > 0 是图 3.5-1 的菱形(第 3 步);executeToolCalls 是第 4 步;currentContext.messages.push(result) 是第 5 步的回填,一个字都不多。中间那个三元分支是上一节 BeginnerNote 提到的截断保护:stopReason === "length" 表示模型的输出被 token 上限切断了,此时 Tool Call 的参数可能只写了一半,Pi 不冒险执行,而是把整批调用直接判为失败。末尾的 hasMoreToolCalls = !executedToolBatch.terminate 则顺便回答了「有没有别的停法」:如果本批工具集体表示「到此为止」,这一位会被置为 false,内层 while 的条件不再成立,循环停下。
从源码结构看,Pi 的循环比本章骨架多出来的,主要是三类东西:流式输出(第 2 步是流式的,边收边发事件)、事件通知(每一步都 emit 一个事件给界面)、插队消息(pendingMessages:用户在模型思考时又输入了一句话)。这些都不改变循环的骨架,只是在骨架上挂东西。它们各自的机制,以及外层那个 while (true) 到底在续跑什么,留到 6.3 pi-agent-core:Agent 与循环。想先看一次真实请求怎么走完这个循环,可以直接跳 5.3 一次 Tool Call 的完整循环。
实践任务
labs/agent-concepts/04-agent-loop目标:把假模型、工具和循环装成一个能自主完成任务的最小 Agent,亲眼看到四件事:没有循环时程序缺了什么、循环转两轮如何完成任务、最大轮数保护如何拦住失控的模型、工具报错如何让模型自我纠正。这是基础篇的压轴实验。实验目录:labs/agent-concepts/04-agent-loop(全书实验索引见实践任务索引)。
步骤:
进入实验目录,安装依赖并运行:
shcd labs/agent-concepts/04-agent-loop npm install npm start先只看第 1、2 部分的输出,用一句话说出两者的差别到底是什么(提示:模型的代码一个字都没变)。
打开
src/loop.ts,在runAgentLoop里逐行标出图 3.5-1 的六个方框分别在第几行;再指出两个终止出口分别是哪一行。数一数第 4 部分:转了几轮?最终历史有几条消息?为什么这两个数字不相等?
把第 3 部分的
maxTurns: 3改成maxTurns: 1,预测输出后再运行验证;然后把while (turn < maxTurns)改成while (true),准备好按 Ctrl+C,亲眼看一次失控的循环,再改回去。进阶(README 里的练习任务 3):改造
calculatorModel,让它先算23 × 17、再把结果加 100,第三轮才给出最终答案。做完你会发现loop.ts一行都不用改。
预期现象:npm start 的输出与实验目录下 expected-output.txt 逐字一致(本实验没有随机数、没有网络、没有计时,每次运行结果相同)。其中第 2 部分应当出现:
循环结束:转了 2 轮,停止原因 completed
用户看到的答案:23 乘以 17 等于 391。第 3 部分应当出现 停止原因 max_turns,第 4 部分应当出现 执行工具 calculate ⇒ 除数不能为 0(出错) 以及随后模型改正参数的那一轮。
如何判断成功:输出逐字一致;你能不看代码说出六步骨架;你能解释第 1 部分和第 2 部分的差别不是「模型更聪明了」;完成第 5、6 步的改造并预测正确。
常见错误:
- 报
Cannot find module './types.ts':本实验的import带.ts后缀,必须用npm start(即tsx src/main.ts)运行,不要先编译再node; - 改造模型后循环停不下来:多半是新分支忘了返回「不带 Tool Call 的纯文本」,模型永远在要求调用工具——这正是最大轮数保护存在的意义;
- 改造模型后模型看不到上一次的结果:检查是不是漏了第 5 步的回填,或者读历史时取错了那条
toolResult。
对应源码位置:packages/agent/src/agent-loop.ts 的 runLoop(上一节的两处 SourceRef)——读懂了实验里的 runAgentLoop,那段源码就只是「同一个循环 + 流式 + 事件 + 插队消息」。
本章小结
- Agent = 大语言模型 + 工具 + 循环 + 状态。模型负责决策,工具负责行动,循环负责重复,状态(消息历史)负责让模型看得见之前发生过什么。四者缺一都会退化:没有循环就只能行动一次,没有状态就会永远重复第一步。
- 四个要素里只有模型需要「智能」,其余三样是普通程序。Agent Harness(Agent 运行框架)的绝大部分代码都花在后三样上。
- Agent Loop 的六步骨架:用户输入进历史 → 带完整历史请求模型 → 判断回复里有没有 Tool Call → 有就执行工具 → 结果回填历史 → 回到第二步。第三步是唯一的分岔口,判断依据只是
toolCalls.length === 0。 - 终止条件有三类:模型返回纯文本(正常)、达到最大轮数(保护)、请求出错或用户取消(异常)。最大轮数保护不是可选项——没有它,一个不肯收尾的模型会让循环永远转下去并持续花钱。
- 工具失败不该终止循环:把错误包装成 toolResult 消息回填给模型,Agent 才有自我纠正的机会。
- 术语:一轮(turn)= 一次模型请求 + 它触发的全部工具执行;一步(step)= 循环体内的一个动作。轮数、步数、消息条数是三个不同的计数。
- Pi 里就是这个循环:
packages/agent/src/agent-loop.ts的runLoop,内层while对应本章的六步,外加流式输出、事件通知与插队消息三类扩充。
关键术语:Agent、Agent Loop(Agent 循环)、一轮(turn)、一步(step)、消息历史(状态)、终止条件、最大轮数保护、Tool Call、工具结果(Tool Result)
关键源码索引:packages/agent/src/agent-loop.ts 的 runLoop(内外双层 while)与 executeToolCalls;packages/agent/src/types.ts 的 AgentEvent(turn 的官方定义在第 426 行注释)
自测问题:
- 一个程序有大语言模型、有工具、也把工具结果回填进了历史,但没有循环,它能完成「查一下今天的日期再算出还有几天到月底」这类任务吗?为什么?
- 为什么第 2 步必须把完整历史发过去?如果只发最后一条消息会发生什么?
- 「转了 3 轮」和「历史里有 3 条消息」是同一件事吗?用本章最小示例的输出解释你的答案。
- 某个 Agent 跑起来后一直在重复调用同一个工具、参数都一样,最可能是哪一步写错了?(提示:想想模型每一轮看到的历史有没有变化。)
下一章预告:3.6 Agent Harness、Session 与状态——本章那个循环只有一件事做得很好:转圈。但它转完就没了:历史存在内存里,关掉程序就消失;用户想中途喊停没有开关;界面想显示进度拿不到消息。把循环包进一整圈工程设施——会话(Session)的保存与恢复、取消、状态管理、与界面解耦——得到的东西就叫 Agent Harness(Agent 运行框架),也就是本书书名里的那个词。