Skip to content

2.1 核心 Agent 循环

这是 pi 的心脏@pi/agent-core 的核心文件 packages/agent/src/agent-loop.ts 实现了一个经典的 ReAct 式循环:重复"思考 → 行动 → 观察",直到给出最终答案。这一节把它彻底讲透。

什么是"Agent 循环"

Agent 与传统"一问一答"的唯一区别是:它能用工具。因此循环必须能处理工具调用:

用户提问


模型回复 ──话─────► 最终答案(stop),结束

带 toolCall(stopReason=toolUse)


执行工具 ──工具结果──► 回填为 toolResult 消息

   └──────────► 再次调用模型(带着工具结果)

循环的退出条件:模型给出 stopReason = "stop"(或出错/中止)。

先补一课:模型到底怎么"请求调工具"(Function Calling)

上面反复出现"模型请求调工具",小白可能会困惑:模型又没有手,它怎么"请求"? 这一节把 function calling 的原理讲清楚。

核心:模型"不会真的执行工具",它只是"输出一段特殊文字"

模型的本质是接话茬(见预备知识)。它做不了任何实际动作,它只会输出文字。所谓"请求调工具",其实是模型输出了一段有固定格式的文字,你(程序)读懂了这段文字,然后替它去执行。

模型输出的文字(不是真的执行,只是"提议"):
{
  "type": "toolCall",
  "id": "tc1",
  "name": "calculator",        ← "我想调用 calculator 这个工具"
  "arguments": { "a": 3, "b": 4 }  ← "参数是 3 和 4"
}

为什么模型知道"可以调用 calculator"?

因为你在发给模型的请求里"告诉"了它。这正是 Context 里的 tools 字段的作用(见 1.1):

ts
const context = {
	systemPrompt: "...",
	messages: [ /* 对话历史 */ ],
	tools: [
		{ name: "calculator", description: "计算两数之和", parameters: {...} },
		//          ↑ 你把工具"介绍"给模型
	],
};

模型读到 tools,就知道"哦,我可以调用 calculator"。如果某个工具没写进 tools,模型就根本不知道它存在,也就不会去调用。

完整闭环(一次工具调用)

你                                   模型
 │ 发请求(含 tools 列表 + 对话历史)──►
 │                                     │ 模型"决定":我要算 3+4
 │ ◄── 回复:toolCall(calculator,{3,4})│ (输出一段"提议"文字)
 │                                     │
 │ 你读懂这段文字,执行 calculator(3,4) 
 │ 得到 7
 │ 把"7"作为 toolResult 回填给模型 ────►
 │                                     │ 模型看到结果,接着回答
 │ ◄── 回复:"3 + 4 = 7"(stop)────────│

一句话模型负责"提议"(用规定的格式说想调哪个工具、用什么参数),你负责"执行"(真正跑代码),再把结果交回给模型继续思考。 这就是 function calling。

Function calling 在 pi 里的具体体现

概念pi 里的形式
模型输出的"调用提议"AssistantMessage 里的 ToolCall 内容块(type: "toolCall"
模型"想调工具"的信号stopReason = "toolUse"
你执行完交回的结果ToolResultMessagerole: "toolResult",带回 toolCallId
你介绍给模型的工具清单Context.tools
真正执行工具的函数AgentTool.execute(见 2.2

注意:pi 循环做的"执行工具",就是上面那段"你读懂提议、替模型执行"的自动化。模型永远只负责"提议",执行的活全在循环里(见下文)。

双层循环结构

runLoopagent-loop.ts:155)用 外层循环 + 内层循环 组织:

ts
while (true) {                       // 外层:处理 follow-up 队列
	let hasMoreToolCalls = true;
	while (hasMoreToolCalls || pendingMessages.length > 0) {  // 内层:工具调用 + steering
		// 1. 注入 pendingMessages(steering)
		// 2. 调用模型,流式拿 assistant 回复
		// 3. 若带 toolCall:执行工具,回填 toolResult
		// 4. 决定是否继续(hasMoreToolCalls)
	}
	// 内层结束 = 本"回合"结束。检查 follow-up 队列。
	if (followUpMessages.length > 0) { pendingMessages = followUpMessages; continue; }
	break;
}
  • 内层循环处理"一次用户输入引发的连续工具调用"(模型可能连调好几个工具)。
  • 外层循环处理"Agent 本来要停了,但又来了 follow-up 消息"的情况。

手把手走一遍:从"用户提问"到"最终回答"

1.1 那个"算 3+4"的例子,把循环的每一步对应到真实事件。假设工具 calculator 已挂载:

步骤循环在做什么产生的事件/消息
0Agent.prompt("帮我算 3 + 4")agent_startmessage_start/end(user)
1内层循环开始,调 StreamFnturn_start
2模型回复 toolCall(calculator,{3,4})stopReason=toolUsemessage_startmessage_update×N → message_end
3发现 content 里有 toolCallhasMoreToolCalls=true
4执行 calculator.execute → 返回 7tool_execution_starttool_execution_end
5把结果包成 toolResult 回填上下文message_start/end(toolResult)
6内层循环继续,再次调 StreamFnturn_endturn_start
7模型这次回复 "3 + 4 = 7"stopReason=stopmessage_start→…→message_end
8contenttoolCallhasMoreToolCalls=false,内层退出
9拉 followUp(无)→ 外层退出turn_endagent_end

要点:第 2 步模型"请求调工具",第 4 步我们真正执行,第 5 步把结果交回,第 6 步带着工具结果再问一次。循环的所有浪漫,都在这 2→6 之间。

逐步拆解内层循环

1. 注入 pending 消息

ts
// agent-loop.ts:182
for (const message of pendingMessages) {
	await emit({ type: "message_start", message });
	await emit({ type: "message_end", message });
	currentContext.messages.push(message);
	newMessages.push(message);
}
pendingMessages = [];

"注入 pending 消息"是在干什么? 就是把队列里攒着的用户消息,塞进 Agent 的对话上下文,让模型在下一轮里看到它们。核心就一行:currentContext.messages.push(message)

为什么需要这一步?因为用户的消息不是"调用模型时一次性给的",而是在 Agent 干活途中、异步攒起来的pending 队列就是"等 Agent 有空时再喂给模型"的暂存区:

用户输入


pending 队列(攒着,等 Agent 排空点)
   │          ↓ 某个排空点到了

注入:把队列里的消息 push 进 context.messages


下一轮调用模型时,模型就能看到这些消息

举例:Agent 正在处理"帮我改 README",模型调了 read 还没回。这时你插话 steer("别改那个,改这个")。这条消息不会立刻发给模型(模型在忙),而是先进 pending;等 Agent 处理完当前回合、来到排空点,才被注入context.messages;下一轮调模型时,模型就看到了这句插话,从而调整行为。

直接传给模型注入 pending
时机一开始就把消息放进去在循环中途、特定排空点才放
谁发起promptsteer / followUp 异步攒来的
影响模型一开始就知道模型做到一半才看到

一句话:注入 pending = 把异步攒起来的用户消息,推到 context.messages,让模型在下一轮看到。 这是"用户中途插话"能生效的机制(更多见 2.4 队列)。

2. 调用模型(streamAssistantResponse)

agent-loop.ts:281。它把 AgentMessage[] 转成 LLM 兼容的 Message[],构造 Context,调用 StreamFn,然后消费事件流:

ts
const llmMessages = await config.convertToLlm(messages);
const llmContext: Context = { systemPrompt, messages: llmMessages, tools };
const response = await streamFunction(config.model, llmContext, { ... });
for await (const event of response) {
	// start → 压入 partial,发 message_start
	// text_delta 等 → 更新 partial,发 message_update
	// done/error → 拿 finalMessage,回填,发 message_end
}

3. 检查工具调用

ts
// agent-loop.ts:203(与真实源码一致)
const toolCalls = message.content.filter((c) => c.type === "toolCall");

const toolResults: ToolResultMessage[] = [];
hasMoreToolCalls = false;                                  // ① 先复位:假定"这回合没有工具调用"
if (toolCalls.length > 0) {
	const executedToolBatch = await executeToolCalls(currentContext, message, config, signal, emit);
	toolResults.push(...executedToolBatch.messages);
	hasMoreToolCalls = !executedToolBatch.terminate;       // ② 有工具调用 → true;工具要求终止则仍 false
	for (const result of toolResults) {
		currentContext.messages.push(result);
		newMessages.push(result);
	}
}

为什么有工具调用就要置为 true

hasMoreToolCalls 是内层循环的"继续开关":

ts
while (hasMoreToolCalls || pendingMessages.length > 0) {  // 为 true 就再跑一圈
	...
}
  • 模型这回合发了工具调用toolCalls.length > 0)→ 说明它"还没答完,要先执行工具"。执行完必须把工具结果喂回去、再调一次模型让它继续思考,所以 hasMoreToolCalls = true,内层循环继续
  • 模型这回合没发工具调用 → 说明它"直接给最终答案了",hasMoreToolCalls 保持本次复位的 false,内层循环退出

为什么每回合要先复位为 false(步骤①)?

因为 hasMoreToolCalls 是跨迭代共享的变量。如果不先复位,上一轮留下的 true 会被带到下一轮,内层循环就永远退不出去(死循环)。所以每处理完一条助手消息,先假定"这回合没有工具调用",再按实际结果决定是否置回 true

什么时候置为 false

情形hasMoreToolCalls内层循环结果
每回合处理完助手消息时先复位为 false——
模型没发工具调用(给最终答案)保持 false退出
模型发了工具调用,且工具要求终止(terminate=true仍为 false退出
模型发了工具调用,且工具未要求终止置回 true继续

补充:hasMoreToolCalls = !executedToolBatch.terminate 里的 terminate 来自工具结果(AgentToolResult.terminate,见 2.2)。若该批次所有工具结果都设 terminate: true,即使有工具调用也停止 —— 这是"工具主动要求收工"的机制。

工具调用的检查时机:注意到 filter 是在 for await 结束后才执行的(这里 messagestreamAssistantResponse 返回的完整消息)。为什么不在 for await 里检查?

因为 for await 消费的是流式增量事件text_deltatoolcall_delta…),此时工具调用可能还没生成完toolcall_delta 还在累积参数),检查结果是不完整、不可靠的。所以循环分两步:

for await (...) { ... }   ← 阶段一:只做增量累积,不做工具检查
                           // 流结束,拿到完整 message(含完整 toolCall)
filter( c => c.type === "toolCall" )   ← 阶段二:现在才检查

for await 内部只负责:把增量拼成 partial,在 done/error 时取出最终消息并返回。真正判断"模型要不要调工具、调哪个",要等流结束、拿到完整toolCall 才可靠。

只要模型还在发 toolCallhasMoreToolCalls 就为真,内层循环继续,用新鲜的工具结果再次调用模型。

4. 回合结束与做准备

ts
await emit({ type: "turn_end", message, toolResults });
// 可选:prepareNextTurn 允许替换下一次的 model / context / thinkingLevel
const nextTurn = await config.prepareNextTurn?.({ message, toolResults, ... });
// 可选:shouldStopAfterTurn 允许主动提前停止
if (await config.shouldStopAfterTurn?.(...)) { emit agent_end; return; }
pendingMessages = (await config.getSteeringMessages?.()) || [];

先搞清楚这里的 config 是什么

configAgentLoopConfig —— 一次 Agent 循环用到的全部运行时配置。它把"这一个回合怎么跑"的所有参数打包成一个对象,传给无状态的 runLoop

它由 Agent.createLoopConfig()agent.ts:441)在每次运行开始时生成:

ts
private createLoopConfig(options): AgentLoopConfig {
	return {
		model: this._state.model,              // 用什么模型
		reasoning: ...,                        // 思考档位
		sessionId, onPayload, onResponse, transport, thinkingBudgets,
		beforeToolCall, afterToolCall,         // 工具前后钩子
		shouldStopAfterTurn, prepareNextTurn,  // 回合控制
		convertToLlm, transformContext, getApiKey,  // 消息转换
		getSteeringMessages, getFollowUpMessages,   // 队列取数
	};
}

AgentLoopConfig 里的配置大致分几类:

类别字段作用
模型modelreasoningsessionIdthinkingBudgetstransport决定"怎么调模型"
消息转换convertToLlmtransformContextgetApiKey决定"发什么给模型"
工具钩子beforeToolCallafterToolCalltoolExecution决定"怎么执行工具"
回合控制shouldStopAfterTurnprepareNextTurn决定"回合后干嘛"
队列getSteeringMessagesgetFollowUpMessages决定"从哪取新消息"

为什么设计成对象? 因为 runAgentLoop无状态纯函数,不接受 Agent 实例。把所有配置打包成 config 传进去,循环就能在不依赖 Agent 类的情况下工作 —— 这也是"纯函数循环 + 有状态 Agent"解耦的一部分。

注意:config局部变量,不是全局配置,且可能被 prepareNextTurn 在每个回合后动态改写(换成新对象)。

prepareNextTurn 在做什么

它是回合切换钩子:每个回合结束、开始下一回合之前,给宿主一次"改写下一次模型请求"的机会。它返回:

ts
export interface AgentLoopTurnUpdate {
	context?: AgentContext;          // 替换下一次请求的上下文
	model?: Model<any>;              // 替换下一次请求的模型
	thinkingLevel?: ThinkingLevel;   // 替换下一次的思考档位
}

返回 undefined 就保持原样;返回对象就替换下一次请求的配置:

ts
prepareNextTurn: async ({ context }) => {
	// 例:上下文太长就换个小模型继续
	if (estimateTokens(context) > LIMIT) {
		return { model: smallModel, reasoning: "low" };
	}
	return undefined;   // 否则保持原样
}
返回的内容会被怎么使用?—— 以"换成小模型"为例

循环在进入下一回合、调用模型之前,用返回值替换掉局部 config 的对应字段agent-loop.ts:232-245):

ts
const nextTurnSnapshot = await config.prepareNextTurn?.(nextTurnContext);
if (nextTurnSnapshot) {
	currentContext = nextTurnSnapshot.context ?? currentContext;   // ① 替换上下文
	config = {
		...config,
		model: nextTurnSnapshot.model ?? config.model,            // ② 替换模型
		reasoning: /* 根据 thinkingLevel 替换思考档位 */,         // ③ 替换思考档位
	};
}

循环里调用模型用的是这一行(agent-loop.ts:308):

ts
const response = await streamFunction(config.model, llmContext, { ... });

所以 config.model 就是"这一回合调谁"。替换后,下一回合(内层循环下一次迭代)读到的就是新值

text
第 1 回合(大模型)
  config.model = 大模型
  → 调 streamFunction(config.model=大模型) → 带 toolCall → 执行工具 → 回填
  → 回合结束,prepareNextTurn 返回 { model: 小模型, reasoning: "low" }
  → config = { ...config, model: 小模型, reasoning: "low" }   ← 局部 config 被替换

第 2 回合(小模型)
  config.model = 小模型   ← 已被替换
  → 调 streamFunction(config.model=小模型) → 看到前面的上下文+工具结果 → 继续干活

关键点

  • 替换时机在"回合之间":turn_end 之后、下一回合调 streamFunction 之前。
  • 替换的是局部 config,不是 Agent._state:只影响本次运行的后续回合,不改 agent.state.model 的持久值(那是"下次运行"用的)。
  • 按字段替换:只返回 model 就只换模型,其余字段用 ?? config.xxx 兜底保持原样。
  • context 也可替换:返回了 context 就替换下一次请求的上下文(可注入/裁剪消息)。
与其他钩子的职责对比

它们在"回合结束"这个点附近的职责不同:

钩子时机目的返回
prepareNextTurn回合后、下回合前改写下一次请求(换模型/上下文/思考档位)AgentLoopTurnUpdate | undefined
shouldStopAfterTurn回合后决定是否提前停boolean
getSteeringMessages回合后取新消息喂给下一回合AgentMessage[]
getFollowUpMessages全部回合结束后取 follow-up 让外层继续AgentMessage[]

工具执行的两种模式

executeToolCallsagent-loop.ts:411)根据配置选择并行或串行:

ts
if (config.toolExecution === "sequential" || 有标为 sequential 的工具) {
	return executeToolCallsSequential(...);   // 一个执行完再执行下一个
}
return executeToolCallsParallel(...);        // 都准备好后并发执行
  • 并行 是默认("parallel"),适合多个独立工具调用。
  • 串行 适合有依赖顺序、或工具本身不该并发的场景。

每个工具调用的完整生命周期包含:参数校验 →(可选 beforeToolCall 拦截)→ 执行 →(可选 afterToolCall 改写)→ 发送执行结束事件(详见 2.2 工具)。

Agent 类:循环的有状态封装

agent-loop.ts 提供的是无状态纯函数runAgentLoop),而 packages/agent/src/agent.tsAgent 类把它包装成有状态、可订阅、可队列的对象:

ts
class Agent {
	state;                    // 当前状态(消息、工具、模型、thinkingLevel)
	subscribe(listener);      // 监听生命周期事件
	async prompt(input);      // 发起一次新对话
	async continue();         // 继续上一轮
	steer(message);           // 队列一条"插队"消息
	followUp(message);        // 队列一条"完成后"消息
	abort();                  // 中止当前运行
	waitForIdle();            // 等待所有事件处理完
}

Agent 做的事情:

  • 维护 _state(transcript、tools、model、isStreaming 等)。
  • 把内部的 AgentEvent 通过 processEvents 转发给订阅者。
  • 维护 steering / followUp 两个队列,供 getSteeringMessages / getFollowUpMessages 消费。

一条 AgentEvent 的旅行

Agent.prompt()runAgentLoop → 内部 emit()processEvents() → 更新 this._state → 通知订阅者。

事件类型(packages/agent/src/types.ts:422):

ts
| { type: "agent_start" } | { type: "agent_end"; messages }
| { type: "turn_start" } | { type: "turn_end"; message; toolResults }
| { type: "message_start"; message } | { type: "message_update"; message } | { type: "message_end"; message }
| { type: "tool_execution_start" | "tool_execution_update" | "tool_execution_end"; ... }

这些事件是 TUI 渲染、日志、扩展系统的数据来源(见 2.3)。

最小心智模型

一句话记住 @pi/agent-core

Context(systemPrompt + messages + tools)交给 StreamFn,拿到助手回复;回复里若有 toolCall,执行工具、把结果回填,再交回去;直到模型说"我答完了"。

小结

  • 核心循环 = ReAct 循环,双层结构(内层处理工具调用,外层处理 follow-up)。
  • stopReason = "toolUse" 是"继续循环"的信号。
  • 工具结果以 toolResult 消息回填,模型才能"看到"工具做了什么。
  • Agent 类把纯函数循环包装成有状态对象,并暴露事件订阅与队列。
真实源码位置
  • 循环实现:packages/agent/src/agent-loop.ts:155
  • 调用模型:packages/agent/src/agent-loop.ts:281
  • 工具调度:packages/agent/src/agent-loop.ts:411
  • Agent 类:packages/agent/src/agent.ts

面试角度:为什么这样设计 Agent 循环

Q1:为什么用双层循环(内层处理工具+steering,外层处理 followUp)? 因为"一次用户输入引发的连续工具调用"和"Agent 本来要停、又来了 followUp"是两种不同节奏。内层循环处理前者(模型可能连调几个工具),外层处理后者(停止后追加)。合并成一个循环会让退出条件极度混乱;拆两层,各自只关心一种"何时继续"的判断。

Q2:为什么工具结果要回填成 toolResult 消息,而不是"直接传给模型"? 因为模型理解的是"一段对话历史",不是"程序内部的返回值"。把工具结果编码成一条带 toolCallIdtoolResult 消息追加进上下文,模型才能把"我上次调的那个工具"和"它的结果"对应起来,从而决定下一步。这也保证了整个 transcript 可持久化、可恢复。

Q3:模型"什么时候该停"由谁决定?如何避免死循环?stopReason 决定:stop(正常给答案)或 error/aborted 就停,toolUse 才继续。死循环的防护有:hasMoreToolCalls 只在有 toolCall 时为真、shouldStopAfterTurn 钩子可强制提前停、length(触顶)会失败所有工具调用、以及外层循环在 followUp 队列空时退出。

Q4:为什么 Agent 要把"纯函数循环"包装成有状态对象?runAgentLoop 是无状态纯函数(不持有状态),而产品需要"一个能记住当前 transcript、能被订阅、能入队"的对象。Agent 类负责:持有 _state、转发事件、维护 steer/followUp 队列、管理 abort。纯函数保证可测可复用,类保证好用 —— 两者职责分离。

下一步:2.2 工具系统