一、引言:agent loop 的作用和地位
什么是 agent
chatbot、workflow、agent 是三种不同的自动化模式,本质区别在于谁主导控制流。
chatbot 是"一问一答":用户输入 → LLM 回复 → 结束。每轮交互独立,LLM 没有跨轮目标,也不会主动采取行动。你问它"帮我整理这个目录下的文件",它只能告诉你"你可以用 mv 命令...",然后等你手动执行。
workflow 是"预先编排":开发者提前定义好步骤和分支------第一步读目录、第二步过滤、第三步移动。每一步做什么、什么时候做,都是代码写死的。LLM 只在某个节点里被调用,负责"理解"或"生成",但不决定流程走向。
agent 把控制流交给模型:同样是"整理文件",agent 自己决定先调工具读目录、再调工具移动文件、检查结果、继续整理------直到它认为任务完成。干活的过程由模型主导,而不是由 workflow 主导。这就是 agent loop 和 chatbot、workflow 最大的区别:不仅可以对话,还可以真正干活,并且干活的过程是由模型主导,而不是由 workflow 主导。
驱动这一切的就是 agent loop------它决定 agent 什么时候调 LLM、什么时候执行工具、什么时候停下来。
二、pi 的实现方式
初始上下文
agent loop 启动前,context 已经持有三样东西:
yaml
context = {
systemPrompt: <见下方>,
messages: [
{ role: "user", content: "请用 echo 工具回显 hello", timestamp: ... }
],
tools: [echoTool, bashTool, readTool, ...]
}
- systemPrompt 是 system 消息,每轮调用 LLM 时都会带上。它定义 agent 的人格、能力边界、可用工具的使用约定。
- messages 是对话历史。初始只有用户的一条 prompt,之后每轮都会往里追加------assistant 回复、toolCall、toolResult 都累积在这里。
- tools 是工具定义列表。LLM 调用工具时看到的就是这个列表(被转成 OpenAI 的 function schema 格式)。
pi 的默认 system prompt(coding-agent/src/core/system-prompt.ts:130-147):
sql
You are an expert coding assistant operating inside pi, a coding agent harness.
You help users by reading files, executing commands, editing code, and writing new files.
Available tools:
- read: <一行描述>
- bash: <一行描述>
- edit: <一行描述>
- write: <一行描述>
In addition to the tools above, you may have access to other custom tools
depending on the project.
Guidelines:
- <动态生成的行为约束,如 "Be concise in your responses">
- <如 "Use bash for file operations like ls, rg, find">
Pi documentation (read only when the user asks about pi itself...):
- Main documentation: <readmePath>
- Additional docs: <docsPath>
- Examples: <examplesPath>
- When reading pi docs or examples, resolve docs/... under Additional docs
and examples/... under Examples, not the current working directory
- ...
Current date: 2026-08-25
Current working directory: /Users/ivan.bao/lab/...
它的结构分五块:
- 角色定义:告诉模型"你是 pi 里的 coding assistant"
- 可用工具:列出工具名 + 一行描述
- 行为约束:动态生成的 guidelines(根据启用的工具组合不同)
- 文档索引:pi 自己的文档路径(模型被问到 pi 本身时才读)
- 环境信息:当前日期 + 工作目录(放最后)
两层循环
pi 的 agent loop 用双层 while 循环驱动 agent 运转:
- 外层循环处理"后续消息"------当 agent 本来要停下来时,检查是否有用户排队的后续消息(比如用户在 agent 执行过程中又发了一条"顺便也改一下配置文件"),有就继续,没有就退出。
- 内层循环处理"工具调用 + 插话"------只要 LLM 还在请求工具调用,或者有用户中途插话的消息没处理完,就继续转。每一圈调一次 LLM、检查有没有工具调用、有就执行并回填结果,然后判断下一圈怎么转。
两个循环嵌套在一起:外层决定"agent 整体什么时候停",内层决定"每一轮什么时候转到下一轮"。退出条件、转向控制由四个可选钩子配合完成------它们在第四章展开。
概览图
单轮细节图
循环各阶段的 context 变化
以"请用 echo 工具回显 hello"为例,展示 context.messages 在每个阶段怎么累积:
arduino
初始状态
┌─────────────────────────────────────┐
│ messages: │
│ [0] user: "请用 echo 工具回显..." │
└─────────────────────────────────────┘
stream 后(turn 1 第一步)
┌─────────────────────────────────────┐
│ messages: │
│ [0] user: "请用 echo 工具回显..." │
│ [1] assistant: │
│ content: [ │
│ { type:"text", text:"我来..." },│
│ { type:"toolCall", │
│ name:"echo", │
│ arguments:{text:"hello"} } │
│ ] │
└─────────────────────────────────────┘
execute 后(turn 1 第二步)
┌─────────────────────────────────────┐
│ messages: │
│ [0] user: "请用 echo 工具回显..." │
│ [1] assistant: [text + toolCall] │
│ [2] toolResult: │
│ toolCallId: "call_xxx", │
│ toolName: "echo", │
│ content: [ │
│ { type:"text", text:"Echo: hello" }
│ ], │
│ isError: false │
└─────────────────────────────────────┘
stream 后(turn 2)
┌─────────────────────────────────────┐
│ messages: │
│ [0] user: "请用 echo 工具回显..." │
│ [1] assistant: [text + toolCall] │
│ [2] toolResult: "Echo: hello" │
│ [3] assistant: │
│ content: [ │
│ { type:"text", text:"已成功回显 hello" }
│ ] │
│ stopReason: "stop" │
└─────────────────────────────────────┘
无工具调用 → 循环停止 → agent_end
context.messages 是 agent 的记忆------每一轮往里追加,LLM 下一轮调用时看到的就是完整的累积历史。这就是为什么 LLM 能在 turn 2 知道"已成功回显"------因为 turn 1 的 toolResult 已经在 context 里了。
三、最小循环骨架
第 2 章的流程图展示了完整的 agent loop,但 pi 的真实 runLoop 代码里还埋着四个钩子(第 4 章展开)。先把钩子全部摘掉,看最小循环长什么样。
以下是 pi runLoop(packages/agent/src/agent-loop.ts:155-274)的最小骨架,用伪代码省略非核心逻辑:
typescript
async function runLoop(context, newMessages, config, signal, emit):
hasMoreToolCalls = true
while hasMoreToolCalls: # 内层循环
if not firstTurn:
emit(turn_start)
# 1. 调 LLM,拿到 assistant 消息
message = streamAssistantResponse(context, config, signal, emit)
# ↑ 文章 2 已讲:消费 AssistantMessageEventStream,
# 翻译成 AgentEvent,把最终 AssistantMessage 写回 context.messages
newMessages.push(message)
# 2. 异常退出
if message.stopReason in (error, aborted):
emit(turn_end, message, toolResults=[])
emit(agent_end, newMessages)
return
# 3. 检查有没有工具调用
toolCalls = message.content.filter(type == "toolCall")
toolResults = []
hasMoreToolCalls = false
# 4. 有工具调用 → 执行 → 回填结果
if toolCalls.length > 0:
batch = executeToolCalls(context, message, toolCalls, signal, emit)
# ↑ 文章 4 展开:查工具 → 校验参数 → execute → 构造 ToolResultMessage
toolResults = batch.messages
hasMoreToolCalls = not batch.terminate
for result in toolResults:
context.messages.push(result) # 回填到对话历史
newMessages.push(result)
emit(turn_end, message, toolResults)
emit(agent_end, newMessages)
循环的核心只有四步:
- 调 LLM ------
streamAssistantResponse拿到 assistant 消息(文章 2 已讲,这里当黑盒,签名是(context, config, signal, emit) → AssistantMessage) - 异常检查 ------如果
stopReason是error或aborted,直接退出 - 检查工具调用 ------从
message.content里筛出type: "toolCall"的块 - 执行工具 + 回填 ------
executeToolCalls执行工具拿到ToolResultMessage[],push 回context.messages
hasMoreToolCalls 是循环的驱动条件:LLM 请求了工具调用就继续转,没有就停。注意第 4 步回填后,下一轮 streamAssistantResponse 调用时,LLM 能在 context.messages 里看到完整的 toolResult------这就是 agent 能"基于工具结果继续推理"的机制。
入口封装在上面一层,pi 提供了 agentLoop 和 runAgentLoop 两种调用形态:
php
function agentLoop(prompts, context, config, signal): # 返回 EventStream
stream = createAgentStream()
runAgentLoop(prompts, context, config,
event => stream.push(event), # emit 回调转发到 stream
signal
).then(messages => stream.end(messages))
return stream
async function runAgentLoop(prompts, context, config, emit, signal):
newMessages = [...prompts]
context.messages.push(...prompts)
emit(agent_start)
emit(turn_start)
for prompt in prompts:
emit(message_start, prompt)
emit(message_end, prompt)
await runLoop(context, newMessages, config, signal, emit)
return newMessages
agentLoop 返回 EventStream<AgentEvent, AgentMessage[]>------调用方可以 for await 消费事件,也可以 await .result() 拿最终消息列表。两种调用形态对应两种使用场景:交互式 UI 用 EventStream 实时渲染,脚本批处理用 async 直接 await。
四、钩子
第 3 章的最小循环骨架能跑通"调 LLM → 执行工具 → 回填 → 继续",但缺少真实场景需要的能力:用户中途插话、每轮后判断是否该停、动态切换模型。pi 用四个可选钩子解决这些需求------它们都在 AgentLoopConfig 上,不传就不生效,传了就在循环的特定位置被调用。
1. prepareNextTurn------每轮后调整下一轮的状态
调用位置:turn_end 之后、下一轮 turn_start 之前。
ini
# 在 runLoop 的内层循环末尾(伪代码接第 3 章)
emit(turn_end, message, toolResults)
nextTurn = config.prepareNextTurn?({
message, # 本轮 assistant 消息
toolResults, # 本轮工具结果
context, # 当前 context
newMessages, # 本次运行累积的所有新消息
})
if nextTurn:
context = nextTurn.context ?? context # 换对话上下文
config.model = nextTurn.model ?? config.model # 换模型
config.reasoning = nextTurn.thinkingLevel # 换思考等级
返回值影响控制流:如果返回了 AgentLoopTurnUpdate,下一轮用新的 context / model / thinkingLevel 调 LLM。典型用途是 context window 管理------对话太长时裁剪旧消息,或者切换到更大 context 的模型。
2. shouldStopAfterTurn------每轮后判断是否提前终止
调用位置:prepareNextTurn 之后。
bash
if config.shouldStopAfterTurn?({
message, # 本轮 assistant 消息
toolResults, # 本轮工具结果
context, # 当前 context
newMessages, # 累积消息
}):
emit(agent_end, newMessages)
return # 退出 runLoop
返回 true 就直接 agent_end 退出,不再检查 steering / follow-up。典型用途是 token 预算耗尽时优雅停止------让当前轮的工具调用完成,但不再启动新一轮。
3. getSteeringMessages------用户中途插话
调用位置:内层循环每一圈开始时,以及 shouldStopAfterTurn 之后。
ini
# 内层循环开头
pendingMessages = config.getSteeringMessages?() ?? []
while hasMoreToolCalls or pendingMessages.length > 0:
# 先处理插话消息
for msg in pendingMessages:
emit(message_start, msg)
emit(message_end, msg)
context.messages.push(msg)
newMessages.push(msg)
pendingMessages = []
# 然后正常调 LLM
message = streamAssistantResponse(...)
...
# shouldStopAfterTurn 检查后,再查一次 steering
pendingMessages = config.getSteeringMessages?() ?? []
返回的消息会注入到 context.messages,LLM 下一轮调用时就能看到。典型用途是用户在 agent 执行过程中追加指令------"顺便也改一下配置文件"------agent 不用等当前任务完成就能收到新指令。
4. getFollowUpMessages------本来要停了但有后续消息
调用位置:内层循环退出后、外层循环判断是否继续时。
bash
# 内层循环退出(hasMoreToolCalls == false 且无 steering)
while true: # 外层循环
while hasMoreToolCalls or pendingMessages: # 内层循环
...
# 内层停了,检查有没有后续消息
followUpMessages = config.getFollowUpMessages?() ?? []
if followUpMessages.length > 0:
pendingMessages = followUpMessages
continue # 回到内层循环
else:
break # 真的停了
和 getSteeringMessages 的区别:steering 是中途插话 (内层循环还在转),follow-up 是追加任务(内层循环已经停了,用户又给了新任务)。典型用途是用户在 agent 完成回答后说"很好,现在帮我做另一件事"------agent 继续跑,不用重新启动。
钩子调用时序
四个钩子在每一轮的调用顺序:
markdown
turn_start
→ streamAssistantResponse
→ executeToolCalls
→ turn_end
→ prepareNextTurn ← 调整下一轮状态
→ shouldStopAfterTurn ← 判断是否提前终止
↗ 是 → agent_end
→ getSteeringMessages ← 检查插话
↗ 有 → 回到 turn_start
→ (内层循环退出)
→ getFollowUpMessages ← 检查后续任务
↗ 有 → 回到 turn_start
↗ 无 → agent_end
四个钩子都是可选的------不传就不调用,循环行为退化为第 3 章的最小骨架。AgentHarness 实现了全部四个,用于支撑 session 管理和消息队列;第三方 agent 可以按需选择。
五、Q&A
Q1:Agent 在满足什么条件后退出循环,完成与用户的交互?
四个退出条件,分两类。
正常退出(agent 认为任务完成):
- 无工具调用 ------
streamAssistantResponse返回的AssistantMessage.content里没有toolCall块。LLM 自己决定"我不需要再调工具了,直接回复用户",hasMoreToolCalls置为false,内层循环退出。 - 无后续消息 ------内层循环退出后,
getFollowUpMessages返回空数组。没有用户排队的追加任务,外层循环也退出,发agent_end。
异常/提前退出(agent 被迫停止):
- error / aborted ------
streamAssistantResponse返回的stopReason是error(LLM 调用失败)或aborted(用户 Ctrl-C)。立即发turn_end+agent_end,不执行工具、不检查钩子。 - shouldStopAfterTurn 返回 true ------某轮结束后,钩子判断"该停了"(如 token 预算耗尽、用户主动要求停止)。发
agent_end,不检查 steering / follow-up。
关键区分:条件 1 是"LLM 自己决定不调工具了"------这是 agent 自主性的体现,模型判断任务完成。条件 2 是"用户没有追加任务"------这是外部的确认。两者都满足才正常退出。条件 3、4 是外部强制中断,不是 agent 自主决策。
还有第五个边缘情况:terminate 标记 ------如果工具执行后所有 ToolResult 的 terminate 字段都是 true,hasMoreToolCalls 也会置 false。这是工具主动告诉 agent"别再转了"(比如某个工具发现了致命错误需要立即停止)。
Q2:几个 hook 的作用分别是什么?
| 钩子 | 什么时候调 | 干什么 | 返回值怎么影响循环 |
|---|---|---|---|
prepareNextTurn |
每轮 turn_end 之后 |
调整下一轮的状态------裁剪过长的对话历史、切换到更大 context 的模型、调整思考等级 | 返回 AgentLoopTurnUpdate,下一轮用新的 context / model / thinkingLevel |
shouldStopAfterTurn |
prepareNextTurn 之后 |
判断"该不该停"------token 预算耗尽、用户主动要求停止、上下文窗口快满了 | 返回 true → 直接 agent_end,跳过所有后续检查 |
getSteeringMessages |
内层循环每一圈开头 + shouldStopAfterTurn 之后 |
取用户中途插话的消息------agent 在执行任务时用户追加了"顺便也改一下配置文件" | 返回的消息注入 context.messages,LLM 下一轮就能看到 |
getFollowUpMessages |
内层循环退出后 | 取用户排队的后续任务------agent 完成回答后用户说"很好,现在做另一件事" | 返回非空 → 外层循环继续,消息变 pendingMessages 重新进入内层 |
两两配对的设计:
prepareNextTurn+shouldStopAfterTurn:管"下一轮要不要继续、怎么继续"。前者调整状态,后者决定终止。getSteeringMessages+getFollowUpMessages:管"用户的新消息什么时候进来"。前者是中途插话 (内层还在转),后者是追加任务(内层已经停了)。
一句话:prepareNextTurn 管"怎么转下一圈",shouldStopAfterTurn 管"还转不转",getSteeringMessages 管"中途加料",getFollowUpMessages 管"结束后续杯"。
Q3:是否可以在基础的四个工具的基础上增加其他工具?
可以,pi 的工具体系设计来就是可扩展的。
pi 内置 7 个工具(coding-agent/src/core/tools/index.ts:83):
| 集合 | 工具 | 用途 |
|---|---|---|
| coding tools(4 个) | read / bash / edit / write |
核心写代码能力 |
| read-only tools(4 个,read 重叠) | read / grep / find / ls |
只读检索能力 |
但工具不止这 7 个------pi 有三条扩展路径:
1. 用 pi 提供的工具工厂自定义
createCodingTools / createReadOnlyTools / createAllTools 接受 ToolsOptions,可以配置每个工具的行为(如 BashToolOptions 可以加 spawn hook 限制命令)。
2. 自己实现 AgentTool 接口
typescript
const myTool: AgentTool = {
name: "search_web",
label: "Search Web",
description: "Search the web and return results",
parameters: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
},
executionMode: "sequential",
async execute(toolCallId, params) {
const results = await fetch(...);
return {
content: [{ type: "text", text: JSON.stringify(results) }],
details: { query: params.query },
};
},
};
把它加到 context.tools 数组里,agent loop 自动把它传给 LLM,LLM 就能调用它。文章 4 会展开 AgentTool 接口的设计和工具注册机制。
3. 通过 Extension API 注入
pi 的 Extension API 允许不改源码、在 .pi/extensions/ 目录下声明扩展,扩展可以注册自定义工具。这是 pi "扩展无需 fork" 理念的体现。
关键约束:工具定义里的 name 必须全局唯一(agent-harness.ts:212 用 Map<string, AgentTool> 持有,重名会覆盖)。parameters 必须是合法 JSON Schema------LLM 根据它生成参数。
六、下一章预告
下一篇文章将进入 pi 的工具体系------从 AgentTool 接口定义、工具注册机制、到 executeToolCalls 的完整执行链(sequential / parallel / beforeToolCall / afterToolCall 钩子),并以 read 工具为例,展示一个真实工具从定义到被 agent loop 调用的完整路径。其余 6 个工具(bash / edit / write / grep / find / ls)将逐个介绍。