Pi Agent Loop 架构拆解:从 while 循环到生产级 Agent 引擎
Pi Agent Loop 架构拆解:从 while 循环到生产级 Agent 引擎
我第一次写 Agent 的时候,Agent Loop 就是一个 while 循环:
while True:
response = llm.call(messages)
if not response.has_tool_calls:
break
for tool_call in response.tool_calls:
result = execute(tool_call)
messages.append(result)
简单、直观、能跑。然后就开始翻车了。
第一次翻车:LLM 连续调了27次同一个工具,while 循环死活不退出,token 费用直接炸了。第二次翻车:LLM 脑补了一个不存在的工具名,程序抛异常崩了。第三次翻车:跑了30多轮之后,上下文 token 超限,API 返回 400 错误。第四次翻车:用户想中途改个指令,发现根本没办法——Agent 跑起来就像一列没有紧急制动的火车。
后来我看了 Pi 的源码。Pi 是一个 74K+ Star 的 TypeScript Agent 框架,它的 Agent Loop 实现让我意识到:Agent Loop 不是一个 while 循环,而是一个有状态的事件流。
这篇文章就从我踩过的四个坑出发,拆解 Pi 是怎么解决这些问题的。
朴素 Agent Loop 的四个坑
① 无限循环
最直观的问题。LLM 的输出是概率性的——它可能连续返回工具调用,永远不停。朴素 while 循环没有退出条件,或者退出条件太弱(只检查「是否还有工具调用」),一旦 LLM 陷入工具调用的循环,程序就卡死了。
② 工具选错
LLM 返回的工具调用可能有三种错误:工具名不存在(bashh 而不是 bash)、参数格式错误(应该传 JSON 对象传了字符串)、参数值不合理(文件路径包含非法字符)。朴素实现通常不做校验,直接把错误的调用扔给工具执行,然后报异常。
③ 上下文爆窗
多轮对话后,messages 数组越来越长,token 数超过模型的上下文窗口限制。API 直接返回 400 错误,整个对话报废。朴素实现没有压缩机制,只能眼睁睁看着 token 数涨到上限。
④ 用户无法干预
Agent 跑起来之后,用户只能干等。想中途修改指令?没门。想取消当前操作?没门。想说「别改那个文件了」?等 Agent 跑完再说吧。
这四个坑不是「边界情况」,而是 Agent 在真实场景中一定会遇到的问题。Pi 的 Agent Loop 就是为了解决这四个问题而设计的。
Pi 的整体分层架构
+---------------------------------------------------------------+
| AgentSession (应用层) |
| - 自动重试 (auto-retry) |
| - 自动压缩 (auto-compaction) |
| - 扩展系统 (extensions) |
| - 会话持久化 |
+---------------------------------------------------------------+
| Agent (状态管理层) |
| - 拥有对话记录 (transcript) |
| - 管理 steering/follow-up 消息队列 |
| - 生命周期事件订阅 |
+---------------------------------------------------------------+
| agentLoop / runLoop (核心循环层) |
| - 双层嵌套循环 |
| - 工具调用执行 (并行/串行) |
| - 流式响应处理 |
+---------------------------------------------------------------+
| StreamFn / pi-ai (LLM 传输层) |
| - 各厂商 API 适配 (25+ 提供商) |
| - 流式 HTTP 请求 |
| - OAuth / API Key 管理 |
+---------------------------------------------------------------+
四层各司其职:应用层负责重试、压缩、会话管理;状态管理层维护对话记录和消息队列;核心循环层实现双层嵌套循环和工具执行;传输层对接各家 LLM API。
核心设计哲学是:把 Agent Loop 当作「有状态的事件流」来管理,而不是简单的 while 循环。 通过将循环分解为多个正交的关注点——循环控制、工具执行、消息队列、状态管理、错误处理——每一层都可以独立测试和扩展。
双层嵌套循环——Agent Loop 的心脏
① 为什么需要双层循环
单层循环有一个根本缺陷:它只处理一轮交互。 LLM 停止调用工具后,循环退出,整个 Agent 就「死」了。如果用户想继续对话(比如「继续」「换个方案」),必须重新启动一个新的循环,之前的上下文全丢了。
Pi 的解决方案是双层嵌套循环:
- 外层循环:处理 follow-up 消息——Agent 停止后用户发的新消息
- 内层循环:处理工具调用和 steering 消息——Agent 运行中的实时干预
② 内层循环的 8 个步骤
Pi 的内层循环(runLoop 函数)每一轮执行 8 个步骤:
内层循环单轮:
1. 注入 pending 的 steering 消息到上下文
2. 调用 streamAssistantResponse() 发起 LLM 请求
3. 从 LLM 响应中提取 tool calls
4. 执行工具调用 (executeToolCalls)
5. 将工具结果推入上下文
6. 检查 shouldStopAfterTurn 是否应该停止
7. 轮询 steering 消息队列
8. 如果还有 tool calls 或 steering 消息 -> 继续内层循环
步骤 6 是关键的终止检查点。如果 shouldStopAfterTurn 返回 true,Agent 直接结束;如果返回 false,内层循环继续。这让终止逻辑不是简单的「LLM 不调工具就停」,而是可以根据业务需求灵活控制。
③ Steering 和 Follow-up 消息队列
这是 Pi 最精巧的设计之一。它把用户输入分成了两种:
Steering 消息:Agent 运行过程中用户输入的新指令。比如 Agent 正在改文件 A,用户突然说「别改 A 了,改 B」。Steering 消息会在下一轮内层循环开始前注入到上下文中,Agent 能「听到」用户的实时指令。
Follow-up 消息:Agent 停止后用户发的新消息。比如 Agent 完成任务后,用户说「继续优化一下」。Follow-up 消息会触发外层循环继续,Agent 从上次停止的地方继续工作。
两种消息的生命周期:
Steering 消息(运行中干预):
用户输入 -> steeringQueue -> 内层循环轮询 -> 注入上下文 -> 影响下一轮 LLM 调用
Follow-up 消息(停止后继续):
用户输入 -> followUpQueue -> 外层循环检查 -> 触发新的内层循环
两个队列的设计让 Agent 从「发一次等结果」变成了「实时交互」。用户不再是被动等待的旁观者,而是可以随时介入的参与者。
怎么让 Agent 精准选择工具
① 工具描述是第一道防线
LLM 选择工具的依据是系统提示词中的工具列表。如果工具描述不清楚,LLM 就会脑补。
Pi 的 buildSystemPrompt 函数会动态生成工具列表。关键设计:只在提示词中列出实际可用的工具。 如果用户禁用了某个工具,它就不会出现在提示词中。
每个工具配一行摘要(toolSnippets),比如 read: Read file contents。这比完整描述更高效。
Pi 还有一个 Skills 系统:从指定目录扫描 .md 文件,解析 YAML frontmatter,格式化为提示词中的 Skills 列表。name 限制 64 字符、description 限制 1024 字符。
② 参数校验是第二道防线
即使 LLM 选对了工具,参数也可能有问题。Pi 在执行前做了两层校验:
validateToolArguments:校验 LLM 返回的参数是否符合工具定义的 JSON Schema。
prepareToolArguments:工具级别的参数预处理。比如文件路径规范化、参数默认值填充。
还有一个容易忽略的设计:截断检测。 如果 LLM 的输出被 token 限制截断,Pi 会把这批工具调用全部标记为失败。原因是截断的工具参数可能是不完整的 JSON。
③ beforeToolCall 钩子是第三道防线
Pi 在工具执行前提供了一个 beforeToolCall 钩子,可以插入自定义逻辑:阻止工具执行、修正参数、权限检查、审计日志。
④ 工具执行模式:并行 vs 串行
Pi 默认并行执行所有工具调用(Promise.all),但允许特定工具标记为 sequential。如果这一批工具调用中有任何一个标记为 sequential,整批降级为串行执行。
循环怎么终止——三个终止机制
① shouldStopAfterTurn:基于业务逻辑的终止
每轮工具执行完后,Pi 会调用 shouldStopAfterTurn 函数,返回 true 就停止。这个机制把终止逻辑从循环本身解耦出来。
② shouldTerminateToolBatch:基于工具返回的终止
每个工具的返回结果可以包含 terminate: true 标志。如果这批工具调用中所有工具都返回 terminate: true,整个批次终止。注意是所有,不是任何一个。
③ 截断检测:基于输出限制的终止
LLM 输出被 token 限制截断时,所有工具调用直接标记为失败。这既是安全机制,也是终止机制。
三个终止机制形成了一套完整的安全网:
终止机制的三个层次:
shouldStopAfterTurn -- 业务层:根据业务逻辑判断是否停止
shouldTerminateToolBatch -- 工具层:工具自己决定是否终止
截断检测 -- 系统层:输出被截断时强制终止
系统提示词怎么构建
① buildSystemPrompt 的拼装逻辑
Pi 的系统提示词不是写死的,而是动态拼装的。buildSystemPrompt 函数接收一个 options 对象,根据当前环境组装提示词:
基础 prompt 定义了 Agent 的角色:「You are an expert coding assistant operating inside pi, a coding agent harness.」
② 工具列表的动态生成
只在提示词中列出实际可用的工具。每个工具配一行摘要,让 LLM 快速理解用途。根据可用工具动态调整指南——比如有 bash 工具就加文件操作指南,有 read 工具就加文件阅读指南。
③ Skills 的自动发现
从指定目录扫描 .md 文件,解析 YAML frontmatter 中的 name 和 description。格式化为提示词中的 Skills 列表。name 限制 64 字符、description 限制 1024 字符——防止注入过长的 skill 描述撑爆上下文。
项目上下文用 XML 标签包裹(<project_context>),这是提示词工程中的常见技巧——用标签让 LLM 明确区分「系统指令」和「项目指令」。
上下文压缩——token 爆窗的解法
① 为什么需要压缩
多轮对话后,messages 数组越来越长,token 数超过模型的上下文窗口限制。不能简单删除旧消息——可能丢失关键上下文(比如之前读过的文件内容、做过的修改记录)。
② Pi 的压缩策略
Pi 的压缩不是简单的截断,而是用 LLM 总结旧消息。compact 函数把旧消息发送给 LLM,让它生成一段摘要,用摘要替换原始消息。
压缩时保留文件操作的元数据:哪些文件被读过(readFiles)、哪些文件被修改过(modifiedFiles)。这些信息在压缩后仍然可用,后续 Agent 可以知道「我之前看过这个文件」。
压缩后允许一次重试(_overflowRecoveryAttempted 标志)。如果压缩后 token 仍然超限,直接失败——防止无限压缩循环。
其他设计亮点
事件流架构:整个 Agent Loop 通过 EventStream 发出事件——agent_start、turn_start、message_start、message_update、tool_execution_start、tool_execution_end、turn_end、agent_end。UI 层订阅事件做渲染,业务层订阅事件做日志和监控。事件流让核心循环和 UI 解耦。
会话持久化:支持 JSONL、SQLite 多种存储后端。会话可以序列化到磁盘,下次启动时恢复。对于长时间运行的任务(比如一个复杂的重构),这个特性很关键。
自动重试:LLM 调用失败后,指数退避重试。不是简单的「失败就报错」,而是「失败了再试几次」。在生产环境中,LLM API 的临时故障很常见,自动重试能显著提升成功率。
prepareNextTurn:每轮结束后可以动态切换模型、调整 thinking level。比如前几轮用便宜的模型快速探索,最后一轮用强模型做最终决策。
我的学习收获
看 Pi 源码之前,我以为 Agent Loop 就是一个 while 循环加上工具调用。看完之后才发现,一个生产级的 Agent Loop 需要解决的问题远比我想象的多:
工具选择不是靠 LLM 自己判断就行的。 需要三层防线:描述让 LLM 知道有哪些工具、校验确保参数正确、钩子让外部逻辑可以拦截。这三层防线缺一不可。
循环终止不是一个 break 就能搞定的。 需要三个层次的终止机制:业务层(shouldStopAfterTurn)、工具层(shouldTerminateToolBatch)、系统层(截断检测)。每个层次处理不同类型的终止场景。
系统提示词是「活的」。 它不是写死的模板,而是根据当前可用工具、项目上下文、Skills 动态拼装的。这让同一个 Agent 在不同项目中可以有不同的行为。
上下文压缩是必要的。 不压缩就等着 token 爆窗。但压缩不是简单删除,而是用 LLM 总结旧消息,保留关键元数据。
这套设计的复杂度是「必要的复杂度」——每个功能(重试、压缩、并行工具执行、实时交互)都是生产级 Agent 不可或缺的。Pi 的价值在于把这些复杂性封装在了清晰的分层架构中。
写在最后
Agent Loop 不是一个 while 循环。它是一个有状态的事件流,需要处理工具选择、参数校验、循环终止、上下文压缩、用户干预、会话持久化等一系列问题。
Pi 的设计哲学是:把 Agent Loop 分解为多个正交的关注点,每一层都可以独立测试和扩展。这种分层不是过度设计,而是生产级系统的必然选择。
如果你也在做 Agent 开发,推荐看看 Pi 的源码。不管用什么语言,它解决的问题和设计思路都是通用的。
项目地址:https://github.com/earendil-works/pi
更多推荐



所有评论(0)