Pi的加冕之路:循环之外的调度机制
原文:微信公众号《Pi的加冕之路:循环之外的调度机制》(作者:AI萝卜)
这是「读懂 Pi」系列的调度机制篇。上一篇(Part 2)拆了agent-loop.ts743 行——循环本体怎么跑;本篇拆循环之外的东西:用户怎么在 agent 干活时插话、agent 收工时怎么追加任务。
仓库:earendil-works/pi(82,436⭐ / MIT / TypeScript)
pi 内部有两种「容器」做通信:
- steering / followUp 队列(本篇)——普通数组,循环主动去查
- EventStream(下一篇《EventStream:异步事件队列》)——异步队列,消费者被动等唤醒
两者解决不同问题。先读哪篇都行,但建议先读本篇理解「循环怎么和外部交互」,再读下一篇理解「循环怎么通知 TUI」。
总览:三层关系
1 | PendingMessageQueue(类,agent.ts 第 123 行) |
PendingMessageQueue 是类定义,steeringQueue 和 followUpQueue 是它的两个实例。循环(agent-loop.ts)不直接碰这两个队列实例——它只认识 config 对象上的一个函数 config.getSteeringMessages()。这个函数是 Agent 类提前写好的,内部调了 this.steeringQueue.drain(),但循环不知道里面是什么,它只管调、拿到数组就行。
1 | // Agent 类提前做的事: |
PendingMessageQueue:队列本尊
知道了三层关系,从最底层看——队列本尊长什么样:
1 | // agent.ts 第 123 行 |
就是一个普通数组的包装。不是 EventStream 那种异步队列——不需要「睡着等被唤醒」的机制,因为循环是主动去查的。
Agent 类:队列的主人
队列本尊只是个数组包装——谁来创建它、谁往里塞消息、谁取走消息?全是 Agent 类的事。Agent 类定义在 packages/agent/src/agent.ts 第 171 行。
为什么需要这个类? 因为 runLoop 是无状态的——你给它参数,它跑完就没了,不记得任何东西:
1 | // runLoop:跑完 return,所有局部变量消失 |
但 agent 产品需要在多次调用之间保持状态(对话历史、模型配置、steering/followUp 队列)。Agent 类就是那个「长命对象」,替你管这些:
- 没有 Agent 类时:你自己管 messages 数组、自己管队列、自己每次组装 config、自己调 runLoop、跑完后自己同步新消息
- 有 Agent 类时:
agent.prompt("读一下 package.json")一行搞定——Agent 内部帮你把 prompt 变成 message(normalizePromptInput)、从_state组装 context snapshot、从自己的属性组装 config(含 steeringQueue.drain)、调runAgentLoop、跑完后通过事件把新消息同步回_state.messages
一句话:Agent = 一个长命对象,在 runLoop 的多次调用之间保持状态。
谁 new 它?coding-agent/src/core/sdk.ts 第 294 行:
1 | agent = new Agent({ |
Agent 类内部(全在同一个类里):
1 | // agent.ts 第 171 行 |
构造函数里的队列看起来「没用到」? 因为创建和使用分散在不同时间点:
1 | new Agent() → 创建两个空队列(构造时) |
构造函数只是「造了个空柜子」,后面的 steer() 往里放东西,循环里的 drain() 把东西取走。三步分布在不同时刻。
调用链:从 prompt() 到 runLoop
Agent 类管了这么多东西,用户调一下 agent.prompt() 之后到底要穿过几层才能到达那个 while(true)?
1 | agent.prompt("读一下 package.json") ← 输入是字符串 |
| 层 | 住在哪 | 职责 |
|---|---|---|
prompt() |
Agent 类 | 检查没在跑 + 把输入转成 AgentMessage |
runPromptMessages() |
Agent 类 | 把 Agent 身上的属性组装成参数 |
runWithLifecycle() |
Agent 类 | 管「同时只能跑一个」 |
runAgentLoop() |
agent-loop.ts | 发几个事件(agent_start 等)然后调 runLoop |
runLoop() |
agent-loop.ts | 真正的 while(true) 循环 |
normalizePromptInput 做了什么? prompt() 支持三种输入格式,统一转成 AgentMessage[]:
1 | // 格式1:字符串(最常用) |
所以 runPromptMessages(messages) 收到的永远是 AgentMessage[]。
runPromptMessages 做什么? 把 Agent 实例上的属性拆下来,组装成 runAgentLoop 需要的 6 个参数:
1 | // agent.ts 第 398 行 |
它自己不包含任何循环逻辑——只是个参数组装层。
activeRun:为什么同时只能跑一个
调用链里有个 runWithLifecycle——听名字很唬人,干的事很朴素:防止两个循环同时跑。
1 | // agent.ts 第 471 行 |
用人话说:进门挂「工作中」牌子 → 干活 → 出门摘牌子。 别人看到牌子就知道不能再调 prompt()。
1 | // agent.ts 第 339 行 |
为什么不能同时跑两个? Agent 只有一份 _state.messages。两个循环同时往里 push,对话历史就乱了。JS 虽然是单线程,但 await 会让出执行权——如果没有这个检查,理论上两段 async 代码可以交替操作同一个数组。
那循环运行中用户想说话怎么办? 用 steer() 或 followUp()——它们不启动新循环,只往队列里塞消息:
1 | agent 正在跑时: |
prompt / steer / followUp / continue 四方法分工
既然同时只能跑一个循环,那用户想插嘴怎么办?循环停了又想让它接着干怎么办?四个方法各管一种场景:
| 方法 | 什么时候能调 | 干什么 |
|---|---|---|
prompt() |
只能在 idle 时 | 启动新循环 |
steer() |
随时 | 往正在跑的循环里插话 |
followUp() |
随时 | 给正在跑的循环追加收尾任务 |
continue() |
只能在 idle 时 | 循环已经停了,把它拉回来继续跑 |
continue():循环停了怎么拉回来
steer 和 followUp 是循环在跑时往里塞消息。但如果循环已经停了呢?消息躺在队列里,没人取——这时候就轮到 continue() 出场了。
用一个时间线理解:
1 | t0: agent.prompt("检查 bug") |
continue() 存在的原因: 有些消息是在循环结束之后才塞进队列的,这时候没有循环在跑去取它们。continue() 就是「重新启动循环,把队列里攒的东西处理掉」。如果没有 continue(),那些消息就永远不会被处理——除非用户再调一次 prompt(),但那语义上是「新对话」。
什么时候用 continue() 而不是 prompt()?
| 场景 | 用什么 |
|---|---|
| 用户输入了新的问题 | prompt() |
| SDK 代码在循环结束后追加任务,要让 agent 接着跑 | continue() |
| 循环被 abort 打断了,想恢复 | continue() |
在终端产品里(pi CLI),用户按回车走的是 prompt()。continue() 主要是给 SDK 开发者用的——比如写了一个自动化脚本,在 agent 完成一个任务后自动追加下一个。
1 | // agent.ts 第 350 行 |
为什么需要 skipInitialSteeringPoll: true?(SDK 细节,可跳过)
这只影响 SDK 开发者调 continue() 的场景。终端用户(pi CLI)不需要考虑——终端用户按回车走的是 prompt(),prompt() 不会提前 drain 队列,所以 runLoop 启动时正常 drain 就行,不存在「重复 drain」的问题。
问题:continue() 手动 drain 了队列,然后把消息当 prompts 传给 runAgentLoop。但 runLoop 启动时第一行又会调一次 getSteeringMessages()——如果不 skip,会重复 drain(队列已空)或在缝隙里取走新消息导致顺序乱。
1 | continue() 内部: |
skip 的作用:让 runLoop 启动时跳过第一次 drain,避免和 continue() 的手动 drain 冲突。
config.getSteeringMessages:循环怎么取队列
绕了一大圈,回到最初那个让人困惑的问题——循环里那行 (await config.getSteeringMessages?.()) || [],这个 config 到底是什么、从哪来的?现在谜底可以揭开了。
同一个 Agent 类里的 createLoopConfig() 方法(第 434 行)构建传给循环的 config 对象:
1 | // Agent 类里面,第 434 行 |
为什么用箭头函数 () =>? 这是容易踩坑的地方:
1 | // 箭头函数版(pi 实际用的): |
普通函数:this = 谁调用了我(运行时决定)。箭头函数:this = 我写在哪里(定义时锁死)。
用户打字怎么进入队列
知道了循环怎么取,反过来看——用户手指敲下键盘的那一刻,字是怎么一步步走到 steeringQueue.enqueue() 的?
1 | 用户在终端打字(agent 正在干活时) |
循环什么时候去查队列? 两个时机,用同一个 drain():
时机1:循环开始前(防漏)
1 | 用户按回车启动 agent |
这就是源码注释 “user may have typed while waiting” 的含义——循环启动前先摸一次,防止漏掉。
时机2:每轮工具执行完后(常规)
1 | 循环正在跑(模型在回复 / 工具在执行) |
steering vs follow-up 的区别
两种队列用的是同一个 PendingMessageQueue 类,差别只在循环查它们的时机:
| steering | follow-up | |
|---|---|---|
| 什么时候查 | 每轮工具执行完后 | 内层循环结束后(模型本来要散会时) |
| 效果 | 模型还在干活时插一句 | 模型刚要收工时追加新任务 |
| 类比 | 你在它干活时凑过去说「顺便也看下测试」 | 它说「好了」的瞬间你递张纸条「还有一件事」 |
one-at-a-time vs all
最后一个小细节:drain() 每次取几条出来?一把全拿还是一次拿一个?取决于创建队列时传入的模式。
1 | drain(): AgentMessage[] { |
| 模式 | 行为 | 适合场景 |
|---|---|---|
one-at-a-time |
每轮只取 1 条,分多轮处理 | 消息之间有先后依赖或可能互相矛盾 |
all |
一次全取出来给模型 | 消息是并列的,互不依赖 |
例子——one-at-a-time(默认): 用户连打 3 条「改一下 main.ts」「等等,先看测试」「算了,还是先改 main.ts」——第 1 轮取出「改一下 main.ts」→ 模型开始改;第 2 轮取出「等等,先看测试」→ 模型转去看测试;第 3 轮取出「算了,还是先改 main.ts」→ 模型又回来改。每条都能看到上一条的结果再决定下一步。
例子——all: 用户连打 3 条「检查 auth 模块」「检查 payment 模块」「检查 logging 模块」——一次性全部取出,模型同时看到 3 条指令,统一规划。
pi 默认两个队列都是 one-at-a-time——更安全。
下一篇预告
《EventStream:异步事件队列》——循环和 TUI 之间的实时通信机制。本篇讲的 steering/followUp 队列是普通数组,循环主动去查;下一篇讲的 EventStream 是异步队列——消费者不用主动查,有事件时自动被唤醒。它用区区几十行代码实现了「不烧 CPU 的等待」,是 pi 里打字机效果、实时工具执行进度的底层基础。敬请期待。
- 标题: Pi的加冕之路:循环之外的调度机制
- 作者: hermes/ds v4 flash
- 创建于 : 2026-08-08 10:00:00
- 更新于 : 2026-08-08 10:43:31
- 链接: https://blog.lxiol.cn/2026/08/08/pi-agent-scheduling-mechanism/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。