Agent Loop 是 Pi 的引擎——它决定了模型如何被循环调用、什么时候停下来。
全部逻辑在agent-loop.ts一个文件中,约 800 行代码。
LLM 本身是一问一答的:你给它一段输入,它返回一段输出。但 Agent 需要多轮自主执行:
- 用户说"帮我改 bug"
- LLM 回复"我需要先读代码" → 调用 read 工具
- 读到代码后,LLM 说"找到问题了" → 调用 edit 工具
- 修改完后,LLM 说"运行测试看看" → 调用 bash 工具
- 测试通过,LLM 说"修好了"→ 停止
这个"调用 LLM → 执行工具 → 把结果喂回去 → 再调用 LLM"的过程,就是 Agent Loop。
Pi 的 Agent Loop 由内循环和外循环组成:
@startuml
skinparam backgroundColor transparent
skinparam ActivityBackgroundColor #f8f9fa
skinparam ActivityBorderColor #dee2e6
start
:检查 Steering 消息队列;
note right: 用户可能在等待时输入了新指令
partition "外循环 (Outer Loop)" {
repeat
partition "内循环 (Inner Loop)" {
repeat
:注入 Steering 消息(如有);
:调用 LLM,获取 AssistantMessage;
if (stopReason == error/aborted?) then (yes)
:发射 agent_end;
stop
endif
if (有 toolCall?) then (yes)
:执行工具调用;
:工具结果 → ToolResultMessage;
:加入上下文;
else (no)
:hasMoreToolCalls = false;
endif
:检查 shouldStopAfterTurn;
:检查 Steering 消息队列;
repeat while (有工具调用 OR 有 Steering 消息?) is (yes)
}
:检查 Follow-up 消息队列;
repeat while (有 Follow-up 消息?) is (yes)
}
:发射 agent_end;
stop
@enduml只要 LLM 还在调用工具,或者有 Steering 消息等待处理,内循环就继续运转。
核心代码(agent-loop.ts:155):
// 内循环条件:有工具调用 OR 有 Steering 消息
while (hasMoreToolCalls || pendingMessages.length > 0) {
// 1. 注入 pending messages
if (pendingMessages.length > 0) {
for (const message of pendingMessages) {
currentContext.messages.push(message);
}
pendingMessages = [];
}
// 2. 调用 LLM,获取 assistant 回复
const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFunction);
// 3. 检查是否有工具调用
const toolCalls = message.content.filter(c => c.type === "toolCall");
hasMoreToolCalls = false;
if (toolCalls.length > 0) {
// 4. 执行工具
const result = await executeToolCalls(currentContext, message, config, signal, emit);
hasMoreToolCalls = !result.terminate;
// 5. 工具结果加入上下文
for (const r of result.messages) {
currentContext.messages.push(r);
}
}
// 6. 检查 Steering 消息
pendingMessages = (await config.getSteeringMessages?.()) || [];
}当内循环结束(LLM 不再调用工具)后,检查是否有 Follow-up 消息。如果有,重新进入内循环。
// 外循环:检查 Follow-up 消息
const followUpMessages = (await config.getFollowUpMessages?.()) || [];
if (followUpMessages.length > 0) {
pendingMessages = followUpMessages;
continue; // 重新进入内循环
}
break; // 没有更多消息,退出Steering(转向)是 Pi 的独特设计——允许在 Agent 运行过程中注入消息改变 Agent 的行为。
@startuml
skinparam backgroundColor transparent
actor User
participant "Agent" as A
participant "Agent Loop" as Loop
participant "LLM" as LLM
User -> A: agent.prompt("分析代码")
A -> Loop: 启动循环
Loop -> LLM: 调用模型
LLM --> Loop: 调用 read 工具
Loop -> Loop: 执行 read 工具
User -> A: agent.steer("停下来,先看 tests/")
note right: 用户在 Agent 运行时\n注入 Steering 消息
Loop -> Loop: 检查 Steering 队列
Loop -> Loop: 注入 Steering 消息到上下文
Loop -> LLM: 再次调用模型(包含新消息)
LLM --> Loop: "好的,我来看 tests/"
@endumlSteering 消息的投递模式:
| 模式 | 行为 |
|---|---|
one-at-a-time |
每次循环只取一条(默认) |
all |
一次性取出所有待处理消息 |
streamAssistantResponse() 是 Agent Loop 调用 LLM 的唯一入口。它做了一件关键的事:最晚转换(Late Conversion)。
async function streamAssistantResponse(context, config, signal, emit, streamFunction) {
// 1. 可选:转换上下文(如压缩)
let messages = context.messages;
if (config.transformContext) {
messages = await config.transformContext(messages, signal);
}
// 2. 关键:AgentMessage[] → Message[]
// 只有在即将调 LLM 的这一刻才做转换
const llmMessages = await config.convertToLlm(messages);
// 3. 构建 LLM 上下文
const llmContext = {
systemPrompt: context.systemPrompt,
messages: llmMessages,
tools: context.tools,
};
// 4. 调用 LLM
const response = await streamFunction(config.model, llmContext, { ...config, signal });
// 5. 流式处理响应事件...
}为什么要"最晚转换"?→ 详见第五章 · 消息系统。
当 LLM 在一次回复中返回多个工具调用时,Pi 支持两种执行策略:
// 所有工具调用同时启动
const results = await Promise.all(
toolCalls.map(tc => executePreparedToolCall(tc, signal, emit))
);// 逐个执行,前一个完成后再执行下一个
for (const toolCall of toolCalls) {
const result = await executePreparedToolCall(toolCall, signal, emit);
results.push(result);
}单个工具也可以声明自己必须顺序执行:
const bashTool: AgentTool = {
name: "bash",
executionMode: "sequential", // 强制顺序
// ...
};每个工具调用都经历五个阶段:
@startuml
skinparam backgroundColor transparent
rectangle "1. prepareToolCall" as P1 #E8F5E9 {
}
rectangle "2. beforeToolCall\n(hook)" as P2 #FFF3E0 {
}
rectangle "3. executePreparedToolCall" as P3 #E3F2FD {
}
rectangle "4. afterToolCall\n(hook)" as P4 #FFF3E0 {
}
rectangle "5. createToolResultMessage" as P5 #F3E5F5 {
}
P1 --> P2 : 验证参数
P2 --> P3 : 未被拦截
P3 --> P4 : 获得执行结果
P4 --> P5 : 可能修改结果
note bottom of P1: 查找工具、校验参数
note bottom of P2: 扩展可在此拦截/修改
note bottom of P3: 实际执行工具逻辑
note bottom of P4: 扩展可修改结果
note bottom of P5: 封装为 ToolResultMessage
@enduml代码体现(简化):
// Step 1: 准备
const preparation = await prepareToolCall(context, message, toolCall, config, signal);
// Step 2: beforeToolCall hook(可拦截)
if (config.beforeToolCall) {
const result = await config.beforeToolCall({ toolCall, args }, signal);
if (result?.block) return errorResult(result.reason);
}
// Step 3: 执行
const executed = await executePreparedToolCall(preparation, signal, emit);
// Step 4: afterToolCall hook(可修改结果)
if (config.afterToolCall) {
const patch = await config.afterToolCall({ toolCall, result: executed.result }, signal);
if (patch) { /* 合并修改 */ }
}
// Step 5: 封装为消息
const toolResultMessage = createToolResultMessage(finalized);如果 LLM 的输出被 token 限制截断(stopReason === "length"),工具调用的参数可能不完整。Pi 不会冒险执行:
if (message.stopReason === "length") {
// 所有工具调用都标记为错误,要求 LLM 重新发送
return failToolCallsFromTruncatedMessage(toolCalls, emit);
}错误消息会提示 LLM:
Tool call "read" was not executed: the response hit the output token limit, so its arguments may be truncated. Re-issue the tool call with complete arguments.
Agent Loop 在运行过程中发射一系列事件,外部(如 UI)通过订阅事件来响应:
agent_start
turn_start
message_start (user message)
message_end
message_start (assistant response - streaming)
message_update (text_delta / toolcall_delta)
message_end
tool_execution_start
tool_execution_update (partial results)
tool_execution_end
message_start (tool result)
message_end
turn_end
turn_start
... (下一轮)
turn_end
agent_end
- 能说出内循环和外循环各自的退出条件
- 理解 Steering 消息的作用和投递时机
- 知道工具调用的五步生命周期
- 理解截断保护机制存在的原因
- 能列出 Agent Loop 发射的主要事件类型
上一章:第二章 · LLM 抽象层
下一章:第四章 · 工具系统 — Agent 的"手"