Pi 的任务规划与推理模式
本文发布于公众号:stringwu的工程笔记 Pi 的任务规划与推理模式
系列第三篇,源码:github.com/earendil-works/pi
前面的文章:
1 总览
在真实工程场景中,Agent 面临的环境是高度动态且信息不对称的。试图在任务开始的第一秒就生成“完整且绝对正确”的计划,往往会导致系统僵化。
Pi 把任务规划拆成三层各司其职的稳定底座:
-
agent-loop负责逐轮推进任务;(ReAct循环) -
thinkingLevel统一推理强度档位, -
extension负责实现具体规划工作流。
Planner 不写死在内核里,而是作为可插拔的脚手架叠在循环之上
这种设计带来了三大关键优势:
- 自适应演进:通过“观察-行动-看结果-再判断”的逐轮推进(Turn),天然适应不确定性任务。
- 高可扩展性:计划格式、审批机制与进度看板均交由 Extension 自定义,Core 无需关注具体工作流。
- 运行时解耦:模型与推理强度可在 Turn 边界动态切换,适配复杂任务的不同阶段。
2 问题层:为什么 Agent 不能靠"一次性计划"
2.1 真实任务的信息是不完整的,计划必须在执行中生长
很多任务从外面看很简单——"分析这个模块""修复这个 bug"。但 agent 真正开始时,并不知道:
- 哪个测试失败、堆栈是什么;
- 相关代码在哪里;
- 是业务逻辑错了,还是测试预期错了;
- 改完会不会影响别的模块。
所以它不可能在第一秒就写出绝对正确的完整计划。合理的推进方式是:
先看错误 → 再找代码 → 再判断原因 → 再小范围修改 → 再验证
2.2 推论:内置强 Planner 会把 workflow 写死
如果 core 内置一个 Planner:
Planner 决定计划 → Executor 按计划走 → 中途发现新情况还要改计划状态机 → 换模型还要迁移 Planner 内部状态
demo 里看起来规整,真实工程里反而僵硬。因为不同用户对"计划"的要求完全不同:
| 用户想要的"计划"形态 |
|---|
| 回答里列几步即可 |
| 写进 TODO.md |
| 必须先审批、不能直接改文件 |
| 变成任务看板 |
| 多 subagent 分工:探索、规划、实现、复查 |
core 内置一种 Planner,其他所有模式都变成绕路。
2.3 core 应该只管"循环是否可靠",而不是"计划长什么样"
Pi 的判断是:只要 模型输出 → 工具调用 → 工具结果 → 下一轮模型输出 这个循环稳定,上层就能用 extension 做出任意 planning 风格。
3 机制层:core 靠什么把复杂任务跑起来
core 不替你规划路线,它保证每一轮都能做到"收到任务 → 派人执行 → 收集结果 → 决定下一轮"。调度台不管施工流程,只管调度永远可靠。
core 的支撑点有四个:
- agent-loop(循环)
- turn/run(任务组织)
- 上下文转换(模型无关)
- thinkingLevel + adapter(能力抽象与翻译)。
它们共同把"观察、行动、修正"做扎实。
3.1 agent-loop:循环本身就是 ReAct
Pi 的核心推进在 packages/agent/src/agent-loop.ts。每一轮只做六件事:
prompt / continue
-> streamAssistantResponse() // 1. 准备上下文 2. 调模型
-> assistant message // 3. 收集输出
-> extract tool calls // 4. 执行工具
-> append tool results // 5. 结果写回上下文
-> prepareNextTurn() / shouldStopAfterTurn() // 6. 判断是否下一轮
ReAct 听起来像论文术语,大白话就是"想一下 → 做一下 → 看结果 → 再想一下"。在 Pi 里它有直接的对应物:
| ReAct 概念 | Pi 中的对应物 |
|---|---|
| Reasoning | assistant 文本或 thinking block |
| Action | toolCall |
| Observation | toolResult |
| Next reasoning | 下一轮模型请求 |
所以 Pi 不需要一个叫 ReActPlanner 的类——loop 本身就把这个模式跑起来了。
3.2 turn 与 run:把长任务拆成多轮
模型不用在第一轮假装什么都知道,它可以在每次工具返回后基于新证据重新判断。
| 概念 | 含义 |
|---|---|
turn |
一次模型响应,以及它触发的一批工具调用 |
run |
用户发起的一次整体运行,可含多个 turn |
用户说"分析这个 bug 并给出修复方案",一次 run 可能是:
turn 1 读错误日志 → 拿到日志
turn 2 搜索相关代码 → 拿到文件位置
turn 3 阅读实现 → 输出修复方案
turn 4(继续修复时)调用 edit 改代码
turn 5 运行检查命令 → 输出总结
3.3 上下文转换:让模型看到该看的,且与厂商无关
Pi 保存中立的对话历史,而不是某个厂商专用的请求 JSONL——这是"运行中切换模型"的前提。
请求前经过两步:
AgentMessage[](Pi 自己的工作记录本)
-> transformContext() // 像整理会议材料:保留、裁剪、补充
-> convertToLlm() // 像翻译员:转成当前 provider 的格式
-> provider payload
session 里保存的是"用户说了什么、助手回答了什么、工具调用了什么",而不是 OpenAI / Anthropic / Google 各自的原始 payload。切换模型时,只需在下一次请求前重新走一遍 convertToLlm()——这就是"无缝切换"的关键。
3.4 thinkingLevel:先抽象稳定语义,再适配外部差异
上层只表达意图,底层负责翻译,能力差异由降级机制诚实处理。
厂商对"推理"的叫法和参数各不相同:reasoning effort、thinking、budget tokens、自定义字段……Pi 定义统一档位:
type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
可以把它想成汽车档位:off 是空挡,low/medium 是日常行驶,high/xhigh/max 是爬坡。驾驶员只需换挡,不必知道变速箱里齿轮怎么咬合。
但统一抽象不等于抹平现实:packages/ai/src/models.ts 会根据模型能力判断可用档位,模型不支持就降级。这比"所有模型都假装支持 high reasoning"更诚实,也更适合生产系统。
3.5 provider adapter:把统一档位翻译成厂商语言
| Provider / API | 大致映射 |
|---|---|
| OpenAI Responses | reasoningEffort |
| Anthropic Messages | 新模型 effort,旧模型 thinking token budget |
| Google Generative AI | thinking level 或 budget |
| OpenAI-compatible | 按兼容配置转 reasoning/thinking 字段 |
就像寄快递只说"加急"或"普通",至于顺丰、FedEx 内部怎么调度,不该让寄件人管。
4 扩展层:plan mode 如何叠在循环之上
planning 不是一条单独管线,而是叠在基础循环上的一组可组合扩展点。
Pi 的 plan mode(packages/coding-agent/examples/extensions/plan-mode/)不是内核功能,而是一个 extension,像"施工前审批":
计划阶段:只读工具 + 注入计划提示 + 模型输出 Plan
-> extension 解析计划 -> 用户确认
执行阶段:恢复写工具 -> 注入计划步骤 -> 模型执行 -> [DONE:n] 标记进度
它用到的扩展点,每一个都是通用能力:
| 扩展点 | 在 plan mode 里的作用 |
|---|---|
| command | 注册 /plan、/todos |
| active tools | 计划阶段切到只读工具集 |
| tool hook | 拦截不安全 bash |
| before agent start | 注入隐藏提示,要求输出计划 |
| agent end | 从输出中解析计划 |
| turn end | 根据 [DONE:n] 更新进度 |
| custom state | 持久化计划与进度 |
| widget | 界面展示计划状态 |
这意味着:今天可以用编号列表做计划,明天可以换成 JSON、YAML、看板、审批流、多 agent 分工、外部项目管理系统——core 不需要知道这些形态。
5 贯穿机制:为什么所有切换都发生在 turn 边界
模型、推理档位、工具集、system prompt 的变化都放在 turn 边界刷新,而不是在 stream 中途硬改——这是整套设计能稳定运行的地基。
流程(以 thinking level 为例,模型切换同构):
用户选择新档位
-> AgentSession.setThinkingLevel()
-> 按当前模型能力 clamp
-> 写入 agent.state.thinkingLevel
-> 记录 thinking_level_change,通知 UI 和 extension
-> 下一轮请求使用新配置
为什么"下一轮生效"而不是立刻打断当前 stream?因为流式请求发出后,provider 已按旧参数开始生成,中途强改会让状态无法解释。如同换挡发生在下一次动力输出,而不是齿轮咬住时硬掰。
这个不起眼的边界机制支撑了所有高级能力:中途切模型、调档位、extension 临时禁用工具、plan mode 从只读切到执行、用户追加 follow-up、插入 steering message。
6 Trade-off:放弃了什么,换来了什么
Pi 放弃了内置强 Planner"看起来完整"的确定性,换来了组合灵活性。
| 取舍 | 收益 | 代价 |
|---|---|---|
| planning 外置 | core 稳定,支持多种计划形态 | core 不保证计划被严格执行 |
| turn loop 推进 | 工具结果实时修正下一步 | 长任务需多轮上下文管理 |
| thinkingLevel 抽象 | UI 和上层逻辑统一 | 不同模型真实效果不一致 |
| 下一轮刷新配置 | 模型/工具切换稳定 | 当前 stream 不中途变更 |
| provider-neutral transcript | 切模型自然 | 每次请求前需做上下文转换 |
这些代价符合 coding agent 的真实处境:agent 不是批处理脚本,它在不断变化的信息环境里工作。稳定的循环边界,比一个看起来完美但难以调整的 Planner 更重要。
7 复杂性被放在了正确的位置
回看全文,这套设计最值得借鉴的不是某个类写得多巧,而是分层:
| 复杂性 | 所在层 |
|---|---|
| 工具循环 | agent-loop |
| 模型能力差异 | Models + provider adapter |
| 推理强度选择 | thinkingLevel |
| 计划工作流 | extension |
| 会话状态 | AgentSession + transcript |
| 上下文裁剪/转换 | transformContext() / convertToLlm() |
给架构设计者的四条启发:
- 先抽象稳定语义,再适配外部差异——
thinkingLevel不暴露厂商参数,先定义自己的档位。 - 策略做成 extension,而不是塞进 core——plan mode 可以很强,但不该绑死所有任务。
- 可变配置放到清晰边界更新——切换都发生在 turn 边界,系统易理解、易恢复。
- 让工具结果参与下一轮判断——代码环境是动态的,agent 必须根据现场信息更新判断。
8 总结
agent-loop 负责推进任务
thinkingLevel 负责统一推理档位
provider adapter 负责翻译模型差异
extension 负责定义计划工作流
如果说传统 Planner 像开工前就定死的施工图,Pi 更像一个调度系统:每一步都看现场反馈,再决定下一步怎么走。
coding agent 更现实的组织方式,不是幻想一开始就知道所有答案,而是把"观察、行动、修正"这件事做扎实。
9附:核心组件索引
| 组件 | 位置 | 职责 |
|---|---|---|
Agent |
packages/agent/src/agent.ts |
保存模型、thinking level、工具、消息和运行状态 |
agent-loop |
packages/agent/src/agent-loop.ts |
执行模型响应、工具调用、写回结果、进入下一轮 |
AgentLoopConfig / ThinkingLevel |
packages/agent/src/types.ts |
loop 配置与 hook;统一推理档位 |
Models |
packages/ai/src/models.ts |
判断模型支持的 thinking level 并降级 |
| provider adapter | packages/ai/src/api/ |
统一档位翻译成厂商参数 |
AgentSession |
packages/coding-agent/src/core/agent-session.ts |
会话、模型/档位切换、extension 事件 |
plan-mode extension |
packages/coding-agent/examples/extensions/plan-mode/ |
"先规划、再执行"的受控流程示例 |

浙公网安备 33010602011771号