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 运行中的实时干预

外层循环: while true

有 follow-up 消息?

设置为 pending

内层循环

退出

注入 steering 消息

调用 LLM

提取 tool calls

执行工具

结果推入上下文

shouldStop?

有 steering 消息?

检查 follow-up

② 内层循环的 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

角色定义 + 指南

工具列表 + 摘要

项目上下文

Skills 列表

当前工作目录

完整系统提示词

基础 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

Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐