一、整体框架图
1.1、Agent Loop:外层与内层的双循环
PI 的核心是 runLoop() 里的双层 while(true):内层负责单个回合(turn)内的工具调用循环,外层负责回合之间的准备与 followUp 接续。
flowchart TB
start([用户提交消息])
subgraph OUTER["外层循环 runLoop · while(true)"]
direction TB
prep["① 回合准备 prepareNextTurn<br/>上下文压缩 / 模型切换 / 收集 followUp"]
followup{"⑥ 取 followUp 队列<br/>getFollowUpMessages"}
subgraph INNER["内层循环 turn 循环 · while 还有工具调用 或 有插话"]
direction TB
s1["② 注入 steering 插话<br/>+ 组装上下文(context 事件)"]
s2["③ 流式采样<br/>streamAssistantResponse"]
s3{"④ stopReason 判定<br/>有无工具调用?"}
s4["⑤a 校验 prepareToolCall<br/>beforeToolCall 钩子可拦截 / 改参"]
s5["⑤b 执行工具 executeToolCalls<br/>串行或并发"]
s6["⑤c 结果回填上下文<br/>afterToolCall → turn_end"]
s1 --> s2 --> s3
s3 -->|toolUse 有工具| s4 --> s5 --> s6 --> s1
end
prep --> s1
s3 -->|"stop / length 无工具"| followup
followup -->|有 followUp| prep
end
start --> prep
followup -->|无| fin([agent_end<br/>本次运行结束])
s3 -->|error / aborted| abort([硬停<br/>跳过工具执行<br/>排队消息退回编辑器])
style INNER stroke:#4a90d9,stroke-width:2px,stroke-dasharray:6 4,fill:#f0f6fc
style OUTER stroke:#3aa76d,stroke-width:2.5px,fill:#f7fbf8两个循环的分工:
- 内层(小循环):一个回合的完整生命周期。流式采样拿到模型响应后,若
stopReason是toolUse就进入"校验 → 执行 → 回填"流程,回填完成后回到采样;用户中途插话(steering)也会让内层续命——它在每个 turn 开始前被注入 - 外层(大循环):内层自然结束(模型不再要工具)后,先做回合准备(上下文压缩、换模型),再取 followUp 队列;有 followUp 就开启新回合,没有则整个运行结束
- 插话 vs followUp:回车发送的是 steering(插队,当前工具执行完立即生效);Alt+Enter 发送的是 followUp(等本轮工作全部做完后作为新回合的开始)
为什么这么设计?
双层循环主要是做一个语义分割,插话便于客户在流式输出中“转向”,排活则便于自动继续下一个任务
这是一种节省成本同时兼顾正确性与交互性的设计,用户如果发现agent出现问题,手动输入后在这一轮工具结束以后就会转变思考方向,如果是单循环的话就会变成停止任务,从头再来,哪怕有历史记录也会有重新生成的部分。
二、回合准备
主要做四个事情
2.1上下文压缩
通过一系列方式进行上下文估算,防止上下文溢出导致模型报错
- 阈值触发(threshold):回合结束后检查不等式:当前上下文 token 数 > 模型窗口大小 − 16384,也就是说在快满的时候就压缩,会有16384的余量
- 溢出恢复(overflow):如果非常不幸,运行过程中上下文溢出了,比如读了一个超大的工具集,那么把失败那条消息从上下文里摘掉(历史还在,只是不再喂给模型)→ 立即压缩 → 自动重试一次。第二次还失败就不再折腾,提示你换更大窗口的模型。还有个细节:从小窗口模型切到大窗口后,旧模型的溢出错误不会触发压缩
- 手动触发(manual):/compact
2.2 模型切换
聊到一半的时候使用/model等指令切换模型,不会马上切换,记录在回合准备这里,下一次走到这一步的时候进行切换
2.3 思考等级置换
同上
2.4 收集插话
中途发送消息的话不会打断,而是先排队,回合结束的时候检查队列,如果有插话的话就会放入peddingMessages(待注入消息队列),下回合开始的时候模型就能看到了 这里要注意,插话为steering,属于内层循环,而另一个概念followUp属于外层循环,后面会详细说明
三、流式采样
3.1 流式采样状态机(partial即消息)
flowchart TD
A["streamFunction()<br/>供应商差异止步于此"] --> B["start<br/>partial push 进历史"]
B --> C["text_start / delta / end"]
B --> D["thinking_start / delta / end"]
B --> E["toolcall_start / delta / end<br/>参数 JSON 流式生长"]
C --> F["done / error<br/>定稿替换 partial,同一条出口"]
D --> F
E --> F
F --> G{"stopReason?"}
G -->|normal| H["进工具阶段"]
G -->|length| I["半截工具调用全判废<br/>模型重新发起"]
G -->|aborted| J["也是正常 turn<br/>finishTurn 照常收尾"]
I -.->|重新采样| B
style A fill:#EEEDFE,stroke:#534AB7
style B fill:#CECBF6,stroke:#534AB7
style F fill:#E6F1FB,stroke:#185FA5
style G fill:#E6F1FB,stroke:#185FA5一次LLM采样的产物是一条消息,但是在物理上这个消息是在几秒或者几分钟内逐token到达的,所以会出现几个矛盾点:
3.2 历史是“消息的列表”,可是这条消息在到达过程中并不存在,存入历史的时机是什么?
直觉上我们考虑全部到完再放,但是这会存在一个问题,采样中途ESC那条消息就消失了,不留痕迹了,如果插话(steering)想要知道模型看到哪里了就断档,压缩和调试都是看到的残缺边界,所以PI考虑使用一边到一边放,放入历史的不是一个已完成消息,而是正在生成的消息的活表示:partial。也就是说历史列表的语义从“已完成消息的集合”悄悄变成了“包含生成中消息的世界状态”,所以如果中断了也不会消失,只是变成了aborted标记的字段
3.3 partial从何而来,如何组织成整体?
delta是增量碎片,如何组织成一个整体呢,如果用循环凭借:buffer+=delta,那么每一个消费者(UI、steering、调试器)都需要拼接一次,逻辑扩散到各处,拼出来的东西不一致,如果用ai拼接的话,就只能在距离供应商最近的位置拼接
所以PI这里考虑的是事件词汇表:12 种事件里,9 种带 partial 字段——每种 delta 事件都自带“到目前为止的完整消息”
这里注意,不同于cc的模式,cc是先打标记最后拼接,做了一个透传,如何凭借看调用方的想法,
而PI是发生在事件的生产端,供应商每吐一个增量,协议适配层就会积到partial上,然后才发出这个事件,所以agent循环拿到的时候已经是一个完整消息了,只是未达到历史消息的标准,此时称之为活对象。
3.4 活对象有多个观察者,那么每个人应该看到什么
活对象在出口的时候做一个浅拷贝:观察者拿到的副本是发出那一刻的真相,历史持有的是持续生长的本体。一个对象,两种视角,各取所需。
为什么这么设计?
流式采样可以和之前的插话联动,流式采样可以让用户感知到目前的agent思考进度,观察思考大致方向是否有问题,这是一种强而有力的交互方式,而partial这是一种防止上下文边界缺失的措施,如果没有partial,上下文中未完成的内容不会存在历史里,下一次启动任务的时候可能丢失关键信息
四、并发调用
4.1 并发调用流程图
flowchart TD
A["一条回复带 N 个工具调用"] --> B{"有工具声明 sequential?<br/>或宿主全局配了 sequential?"}
B -->|是| C["整批串行:for 循环逐个跑"]
B -->|否| D["阶段② 串行准备(按调用序)<br/>TypeBox 校验 → beforeToolCall 钩子"]
D -->|拒绝 / 找不到工具| E["immediate:错误结果当场定型"]
D -->|放行| F["阶段③ 打包成闭包<br/>循环不 await,继续准备下一个"]
F --> G["阶段④ Promise.all 同时点火<br/>真并发只在这一行"]
G --> H["阶段⑤ 保序回填<br/>结果严格按调用序进历史"]
H --> I{"terminate 全票通过?"}
I -->|每一个都是 true| J["循环终止"]
I -->|有否决| K["continue 内层 while"]
F -.->|signal.aborted| L["Operation aborted<br/>不执行,不留半截副作用"]
style G fill:#EAF3DE,stroke:#3B6D11
style D fill:#CECBF6,stroke:#534AB7
style F fill:#CECBF6,stroke:#534AB74.2 N个调用之间是什么关系
首先要解决的问题就是:调用之间,谁是独立的,谁必须排队
通用层:默认人人独立,读写不同文件的调用时互相不阻塞的,全部并发,因为95%的时间在等I/O,所以会考虑单线程的并发模型,类似epoll的就绪通知 + 事件驱动,等待不需要算力。
例外层:具有一票否决的特性,任何一个工具声明了executionMode:"sequential"这一整批调用全部退化为串行,因为乱序执行结果不稳定
4.3 五个阶段
- 分流:宿主全局配置是否 sequential、本批是否有工具声明 sequential
- 串行准备:TypeBox校验参数,beforeToolCall 钩子作准备
- 打包闭包:每个调用都打包成async塞进数组,循环在这时候不会await它,准备循环继续跑下一个调用的校验和钩子,而已放行的调用还没有开始执行,只是“蓄势”。每个闭包开头有一道
signal.aborted检查——中断的调用直接返回「Operation aborted」错误结果,不真跑。 - 并发点火:准备循环全部跑完,所有闭包同时开跑
- 保序回填:结果数组严格按输入顺序排列——谁先完成无所谓。循环按这个顺序创建
toolResultMessage、广播、push 进历史。然后shouldTerminateToolBatch做“全票检查”:这批结果每一个都terminate: true循环才终止——一票即否决,防止单个工具的单方面退出吞掉兄弟调用的结果。
注意**“看到的顺序”和“回填的顺序”是两回事。** 执行过程中的事件(tool_execution_start、流式 partial 更新)按完成序实时广播;而结果消息按调用序回填。广播是给 UI 看的(越快越好),回填是给历史用的(toolCall/toolResult 配对必须确定)。
为什么这么设计?
因为agent并发任务多为I/O阻塞的,比如大模型API,外部工具,MCP等,这种情况下采用epoll的方式会好很多
五、结果回填
5.1 回填解决了什么问题
并发结束的时候会收到N个执行纪录,而模型的协议中有一条铁律:下一条采样请求里,assistant 的每个 toolCall 块必须紧跟一个 toolResult 块。缺了错了,供应商直接 400。
所以回填解决的问题就是如何吧这个执行记录配对完整并合理的落入历史上下文中
5.2 四步机制
包装:利用配对键进行配对,同时也要做规范化,防止脏数据返回,比如JS扩张的Null等
1 2 3 4 5 6 7{ role: "toolResult", toolCallId: finalized.toolCall.id, // 配对键 toolName: finalized.toolCall.name, content: finalized.result.content ?? [], // 规范化:null 不许进历史 isError, durationMs?, timestamp: Date.now() }广播:只有
message_start+message_end,没有 update 阶段,这里可以和前面的流式采样那种start,delta,end做对比,这里没有中间态,消息需要完整性。双账本落笔:有两个账本,一个是世界状态(供下一轮采样),另一个是本次run的增量(供宿主/回放)
打包交换,触发下一站:打包完成后立刻轮询steering队列,检查是否有插话,有插话就进入插话的逻辑调整agent大致方向
为什么这么设计?
这么设计我理解是为了保证顺序性和完整性,首先工具是并发的,所以需要利用配对键找到合适的位置,进行包装,也需要保证每个调用都有结果而不是返回很奇怪的东西影响结果
六、工具系统详解
Pi框架的工具系统也考虑了如何轻量化,下面是他的简单流程图
flowchart TD
A["ToolDefinition<br/>扩展面向:renderers + 上下文"] -->|wrapToolDefinition| B["AgentTool<br/>运行时面向"]
B --> C["串行准备<br/>TypeBox 校验 + prepareArguments 兼容垫片"]
C --> D["execute 四参数<br/>toolCallId · params · signal · onUpdate"]
D --> E{"工具内部"}
E -->|edit / write| F["withFileMutationQueue<br/>同文件串行 · 异文件并行"]
E -->|bash / read| G["直接并发"]
F --> H["AgentToolResult 三通道<br/>content→模型 · details→UI<br/>structuredContent→程序"]
G --> H
H --> I["回填:toolCallId 配对<br/>isError 也是合法结果"]
style B fill:#CECBF6,stroke:#534AB7
style H fill:#EAF3DE,stroke:#3B6D11
style F fill:#FAEEDA,stroke:#BA75176.1 一个工具=2个接口+一条包装边界
接口一:ToolDefinition —— 定义/扩展面向
| |
是环境性质的,是写工具的人面对的接口
接口二:AgentTool —— 内核运行时面向
这个是内核认识的最小执行单元,只有执行必需的七八个字段,没有 renderers,execute 也没有 ctx 参数。内核对工具的全部认知到此为止。
包装边界:wrapToolDefinition,两个接口之间的翻译层
| |
正向包装的重点在于 ctxFactory(toolCallId, signal),也就是说环境不是包装时注入的,是每次执行时现场创建的——同一次 run 里多次调用同一工具,每次拿到的 ctx 都是针对本次调用的(带正确的 abort signal)。反向合成则保证“有人塞了个裸 AgentTool 进来”注册表也不崩。
6.2 三个通道结果:一个结果,三种受众
AgentToolResult的一次返回会做三路分发
| |
这样子给模型看的和给人看的就会强制分离,三着就不会相互污染,大多数自研工具系统把这三样糊在一个字符串里,模型看到 UI 专属的转义垃圾,就是这个通道没分的后果。
6.4 文件突变队列
解决了一个并发中一个重要的问题:两个并发的 edit/write 打同一个文件怎么办?
Pi的解决方案是每个文件一把锁(按真实路径识别,符号链接指向同一文件也算同一个)。edit 和 write 工具内部自动把自己的写操作挂进这个队列——同一个文件的操作排队一个个来,不同文件照常并发。框架内置,写工具自动获得,模型和扩展都不用操心。