Appearance
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" |
| 你执行完交回的结果 | ToolResultMessage(role: "toolResult",带回 toolCallId) |
| 你介绍给模型的工具清单 | Context.tools |
| 真正执行工具的函数 | AgentTool.execute(见 2.2) |
注意:pi 循环做的"执行工具",就是上面那段"你读懂提议、替模型执行"的自动化。模型永远只负责"提议",执行的活全在循环里(见下文)。
双层循环结构
runLoop(agent-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 已挂载:
| 步骤 | 循环在做什么 | 产生的事件/消息 |
|---|---|---|
| 0 | Agent.prompt("帮我算 3 + 4") | agent_start、message_start/end(user) |
| 1 | 内层循环开始,调 StreamFn | turn_start |
| 2 | 模型回复 toolCall(calculator,{3,4}),stopReason=toolUse | message_start → message_update×N → message_end |
| 3 | 发现 content 里有 toolCall → hasMoreToolCalls=true | — |
| 4 | 执行 calculator.execute → 返回 7 | tool_execution_start → tool_execution_end |
| 5 | 把结果包成 toolResult 回填上下文 | message_start/end(toolResult) |
| 6 | 内层循环继续,再次调 StreamFn | turn_end、turn_start |
| 7 | 模型这次回复 "3 + 4 = 7",stopReason=stop | message_start→…→message_end |
| 8 | content 无 toolCall → hasMoreToolCalls=false,内层退出 | — |
| 9 | 拉 followUp(无)→ 外层退出 | turn_end、agent_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 | |
|---|---|---|
| 时机 | 一开始就把消息放进去 | 在循环中途、特定排空点才放 |
| 谁发起 | prompt 时 | steer / 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 结束后才执行的(这里 message 是 streamAssistantResponse 返回的完整消息)。为什么不在 for await 里检查?
因为 for await 消费的是流式增量事件(text_delta、toolcall_delta…),此时工具调用可能还没生成完(toolcall_delta 还在累积参数),检查结果是不完整、不可靠的。所以循环分两步:
for await (...) { ... } ← 阶段一:只做增量累积,不做工具检查
// 流结束,拿到完整 message(含完整 toolCall)
filter( c => c.type === "toolCall" ) ← 阶段二:现在才检查for await 内部只负责:把增量拼成 partial,在 done/error 时取出最终消息并返回。真正判断"模型要不要调工具、调哪个",要等流结束、拿到完整的 toolCall 才可靠。
只要模型还在发 toolCall,hasMoreToolCalls 就为真,内层循环继续,用新鲜的工具结果再次调用模型。
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 是什么
config 是 AgentLoopConfig —— 一次 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 里的配置大致分几类:
| 类别 | 字段 | 作用 |
|---|---|---|
| 模型 | model、reasoning、sessionId、thinkingBudgets、transport | 决定"怎么调模型" |
| 消息转换 | convertToLlm、transformContext、getApiKey | 决定"发什么给模型" |
| 工具钩子 | beforeToolCall、afterToolCall、toolExecution | 决定"怎么执行工具" |
| 回合控制 | shouldStopAfterTurn、prepareNextTurn | 决定"回合后干嘛" |
| 队列 | getSteeringMessages、getFollowUpMessages | 决定"从哪取新消息" |
为什么设计成对象? 因为 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[] |
工具执行的两种模式
executeToolCalls(agent-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.ts 的 Agent 类把它包装成有状态、可订阅、可队列的对象:
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 消息,而不是"直接传给模型"? 因为模型理解的是"一段对话历史",不是"程序内部的返回值"。把工具结果编码成一条带 toolCallId 的 toolResult 消息追加进上下文,模型才能把"我上次调的那个工具"和"它的结果"对应起来,从而决定下一步。这也保证了整个 transcript 可持久化、可恢复。
Q3:模型"什么时候该停"由谁决定?如何避免死循环? 由 stopReason 决定:stop(正常给答案)或 error/aborted 就停,toolUse 才继续。死循环的防护有:hasMoreToolCalls 只在有 toolCall 时为真、shouldStopAfterTurn 钩子可强制提前停、length(触顶)会失败所有工具调用、以及外层循环在 followUp 队列空时退出。
Q4:为什么 Agent 要把"纯函数循环"包装成有状态对象?runAgentLoop 是无状态纯函数(不持有状态),而产品需要"一个能记住当前 transcript、能被订阅、能入队"的对象。Agent 类负责:持有 _state、转发事件、维护 steer/followUp 队列、管理 abort。纯函数保证可测可复用,类保证好用 —— 两者职责分离。
下一步:2.2 工具系统。