Skip to content

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 这个数字从头到尾没人算过。这不是模型不够聪明,而是程序少写了几行。缺的三个动作是:

  1. 执行:按名字找到 calculate 函数,把参数传进去,拿到 391
  2. 回填:把 391 变成一条新消息,追加到历史里;
  3. 再问一次:带着这份更长的历史,重新请求模型。

关键的一步在第 3 步之后:模型看到 391,这一次它可能给出最终答案,但也可能又要求调用一次工具(比如它还想验算一下)。既然「再问一次」之后可能再次回到「执行」,这三个动作就不是一条直线,而是一个。这个圈就是本章的主角。

Agent 的工作定义

📘 概念Agent(Agent)
一个由四样东西组成的程序:大语言模型(负责决定下一步做什么)、工具(让决策能作用于外部世界)、循环(让「决策—行动—观察」重复进行直到任务完成)、状态(一份不断增长的消息历史,让每一轮都看得见之前发生过什么)。四者缺一,程序就不再是 Agent。

「Agent」在业界是个用得很松的词,有人把一个精心写的提示词也叫 agent。本书统一采用上面这个工程定义:它可以直接对应到代码,方便我们逐块拆解 Pi 的源码。四个要素缺一个会怎样:

要素它负责什么缺了它,程序退化成
大语言模型(LLM)每一轮决定「下一步做什么」只能按固定脚本走的普通程序
工具把决策变成对外部世界的实际操作只会聊天的聊天机器人
循环让「决策 → 行动 → 观察结果」反复进行只能行动一次就停住——上一节那个洞
状态(消息历史)让模型看得见之前发生过什么每轮失忆,模型会永远重复第一步

最后一行值得多说一句。3.1 章讲过,模型是无状态的:它不记得上一次你问过什么,每次请求你都得把全部历史重新发过去。所以「状态」在这里不是什么高深机制,就是一个不断变长的数组。但如果忘了把工具结果追加进去,模型下一轮看到的历史和上一轮一模一样,于是它会做出一模一样的决定——再次要求调用同一个工具,永远循环下去。

还有一个比例关系值得记住:四个要素里只有第一个需要「智能」。剩下三个是彻头彻尾的普通程序——一个 while、一个数组、几个函数调用。你在本书后面读到的 Pi 源码,绝大部分都是在把后三样做扎实(怎么保存状态、怎么取消、怎么校验参数、怎么把事件送到界面)。模型本身是一个 HTTPS 请求,反而是最短的那一段。

Agent Loop 的六步骨架

📘 概念Agent Loop(Agent 循环)(Agent Loop)
Agent 的主循环:把用户输入放进历史,请求模型;模型若要求调用工具就执行、把结果写回历史、再次请求模型;如此往复,直到模型返回不含任何 Tool Call 的纯文本回复为止。

用伪代码写出来是这样(这是完整的骨架,不是简化版——真实框架多出来的东西都是在这几行上加装的):

ts
// 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 在事件类型的注释里写得很明确:

earendil-works/pi@c13ffe1第 422–428 行在 GitHub 查看 ↗
Pi 的 Agent 事件类型;第 426 行的注释给出了 turn 的官方定义:一个 turn 就是一次 assistant 回复加上它引发的工具调用与结果。
ts
export 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 讲。

循环什么时候停

一个会自己转圈、每转一圈都要花钱和时间的程序,最要紧的问题是:它凭什么会停下来? 上面的骨架里有三个出口,真实框架也不外乎这几类:

  1. 正常出口:模型返回不含 Tool Call 的纯文本 —— 它认为任务做完了。这是绝大多数情况。
  2. 保护出口:轮数达到上限 —— 由你的代码强行中断。
  3. 异常出口:请求模型时出错、用户按下取消 —— 这一类下面单说。

最大轮数保护为什么是必需品。设想一个模型每轮都要求「再验算一次」,永不给出最终答案。没有上限,这个 while 就是死循环:每一轮都在往历史里堆消息、都在调用一次真实的模型 API(真金白银)、都在执行一次工具(可能改动文件)。这不是假想,本章实验的第 3 部分就用一个「坏掉的模型」把它演出来了:maxTurns 设成 3,循环转满 3 轮被拦下,历史里堆了 7 条消息。上限设多少取决于任务(编码类 Agent 常见几十到上百轮),但「必须有一个上限」没有例外。

⚠️ 常见误解以为工具报错应该让循环崩溃
工具会失败:文件不存在、命令返回非零、模型生成了非法参数。直觉上应该把异常抛出去,让程序停下报错——但那样用户只会看到一个堆栈,而 Agent 本来有机会自己修好。正确做法是把错误当成一种结果:捕获异常,把错误信息包装成一条 toolResult 消息(标上「这是错误」),照常回填进历史。模型下一轮读到「除数不能为 0」,就可能改用正确的参数重试。本章实验第 4 部分演示了完整的自我纠正过程:第 1 轮除以 0 出错、第 2 轮改成除以 4、第 3 轮给出答案。真正该让循环停下的是「请求模型」这一步失败,不是工具失败。
🌱 初学者提示还有哪些终止条件
真实框架的出口比三个多一些:用户中途按下取消键(对应 2.7 章的 AbortSignal);模型输出被 token 上限截断,导致 Tool Call 的参数可能是残缺的;某个工具明确要求「到此为止」(比如一个「结束会话」工具)。Pi 这几种都有,都在 agent-loop.ts 的同一段代码里,6.3 章会逐个对照源码讲。本章先记住三类主出口就够。

最小示例:40 行看得见的循环

把上面的骨架写成真能跑的 TypeScript。存成 mini-loop.ts,用 1.2 章的方式 tsx mini-loop.ts 运行。模型是假的(不联网、不需要 API Key):它只看历史的形状——还没有工具结果就要求调用工具,有了就直接报答案。

ts
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 和一批钩子。

earendil-works/pi@c13ffe1第 155–200 行在 GitHub 查看 ↗
Pi 的 Agent Loop 主体:外层 while 处理「本来要停了但又有新消息进来」的续跑,内层 while 才是本章那个六步循环;第 193 行请求模型,第 196 行是出错与取消的中断出口。
ts
async 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 步:

earendil-works/pi@c13ffe1第 203–224 行在 GitHub 查看 ↗
同一个循环里的第 3 到第 5 步:按 toolCall 类型过滤出工具调用、执行它们、把结果回填进上下文,最后发出 turn_end 事件宣告本轮结束;stopReason === "length" 的分支处理「模型输出被 token 上限截断」的情况。
ts
			// 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 的完整循环

实践任务

🛠 实践任务亲手组装一个会自己转的 Agentlabs/agent-concepts/04-agent-loop

目标:把假模型、工具和循环装成一个能自主完成任务的最小 Agent,亲眼看到四件事:没有循环时程序缺了什么、循环转两轮如何完成任务、最大轮数保护如何拦住失控的模型、工具报错如何让模型自我纠正。这是基础篇的压轴实验。实验目录:labs/agent-concepts/04-agent-loop(全书实验索引见实践任务索引)。

步骤

  1. 进入实验目录,安装依赖并运行:

    sh
    cd labs/agent-concepts/04-agent-loop
    npm install
    npm start
  2. 先只看第 1、2 部分的输出,用一句话说出两者的差别到底是什么(提示:模型的代码一个字都没变)。

  3. 打开 src/loop.ts,在 runAgentLoop 里逐行标出图 3.5-1 的六个方框分别在第几行;再指出两个终止出口分别是哪一行。

  4. 数一数第 4 部分:转了几轮?最终历史有几条消息?为什么这两个数字不相等?

  5. 把第 3 部分的 maxTurns: 3 改成 maxTurns: 1,预测输出后再运行验证;然后把 while (turn < maxTurns) 改成 while (true)准备好按 Ctrl+C,亲眼看一次失控的循环,再改回去。

  6. 进阶(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.tsrunLoop(上一节的两处 SourceRef)——读懂了实验里的 runAgentLoop,那段源码就只是「同一个循环 + 流式 + 事件 + 插队消息」。

本章小结

  • Agent = 大语言模型 + 工具 + 循环 + 状态。模型负责决策,工具负责行动,循环负责重复,状态(消息历史)负责让模型看得见之前发生过什么。四者缺一都会退化:没有循环就只能行动一次,没有状态就会永远重复第一步。
  • 四个要素里只有模型需要「智能」,其余三样是普通程序。Agent Harness(Agent 运行框架)的绝大部分代码都花在后三样上。
  • Agent Loop 的六步骨架:用户输入进历史 → 带完整历史请求模型 → 判断回复里有没有 Tool Call → 有就执行工具 → 结果回填历史 → 回到第二步。第三步是唯一的分岔口,判断依据只是 toolCalls.length === 0
  • 终止条件有三类:模型返回纯文本(正常)、达到最大轮数(保护)、请求出错或用户取消(异常)。最大轮数保护不是可选项——没有它,一个不肯收尾的模型会让循环永远转下去并持续花钱。
  • 工具失败不该终止循环:把错误包装成 toolResult 消息回填给模型,Agent 才有自我纠正的机会。
  • 术语:一轮(turn)= 一次模型请求 + 它触发的全部工具执行;一步(step)= 循环体内的一个动作。轮数、步数、消息条数是三个不同的计数。
  • Pi 里就是这个循环packages/agent/src/agent-loop.tsrunLoop,内层 while 对应本章的六步,外加流式输出、事件通知与插队消息三类扩充。

关键术语:Agent、Agent Loop(Agent 循环)、一轮(turn)、一步(step)、消息历史(状态)、终止条件、最大轮数保护、Tool Call、工具结果(Tool Result)

关键源码索引packages/agent/src/agent-loop.tsrunLoop(内外双层 while)与 executeToolCallspackages/agent/src/types.tsAgentEvent(turn 的官方定义在第 426 行注释)

自测问题

  1. 一个程序有大语言模型、有工具、也把工具结果回填进了历史,但没有循环,它能完成「查一下今天的日期再算出还有几天到月底」这类任务吗?为什么?
  2. 为什么第 2 步必须把完整历史发过去?如果只发最后一条消息会发生什么?
  3. 「转了 3 轮」和「历史里有 3 条消息」是同一件事吗?用本章最小示例的输出解释你的答案。
  4. 某个 Agent 跑起来后一直在重复调用同一个工具、参数都一样,最可能是哪一步写错了?(提示:想想模型每一轮看到的历史有没有变化。)

下一章预告3.6 Agent Harness、Session 与状态——本章那个循环只有一件事做得很好:转圈。但它转完就没了:历史存在内存里,关掉程序就消失;用户想中途喊停没有开关;界面想显示进度拿不到消息。把循环包进一整圈工程设施——会话(Session)的保存与恢复、取消、状态管理、与界面解耦——得到的东西就叫 Agent Harness(Agent 运行框架),也就是本书书名里的那个词。

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