输入队列与继续执行
追踪引导队列与后续队列的轮询时点,说明消息为什么既不会重复消费,也不会丢失。
输入队列与继续执行
运行中的输入进了队列之后,有一个问题经常让人困惑:队列里的消息,究竟在什么时刻被取走?
这不是细节问题。取走的时刻决定了三件事:用户的话什么时候生效、为什么后续消息有时"卡住"不动、以及 abort() 之后那些排队的话去哪了。
更关键的是一个反直觉的事实:引导队列非空会压住后续队列。 你明明排了两条消息,一条也没丢,但模型只回应了第一条——不是 bug,是调度规则。
这篇追踪队列的四个轮询时点、两种投递模式的实现、以及 continue() 的五种分支。读完你能预测一条排队消息会在哪一轮被消费,以及为什么不会重复也不会丢。
需要的前置只有一条:知道 Pi 在会话忙碌时提供 steer()(引导)和 followUp()(后续)两种排队方式,分别进两个队列。
一、两个队列,两次轮询
队列有两个,取走的时机也不同:
"没有更多工具调用、也没有引导消息"这个条件是整篇的核心。 两个条件都满足才轮到后续队列,缺一个都不检查。
二、轮询时点表
源码里事件循环的主循环结构是这样的:
# 事件循环的主循环结构:四个轮询点决定排队消息在第几轮被取走
外层循环
├─ 取一次引导消息(启动时的初始轮询)
└─ 内层循环(还有工具调用 或 还有待处理消息)
├─ 准备消息(prepareNextTurn)
├─ 若待处理消息为空,再取一次引导消息
├─ 发出 turn_start,注入准备好的和取到的消息
├─ provider 请求 → 工具执行
├─ finishTurn → turn_end
└─ 取一次引导消息 → 若有工具调用或取到消息,继续内层循环
└─ 取一次后续消息 → 若取到,继续外层循环把轮询点列成表:
第 2 点的"仅当当前没有待处理消息"值得单独说。源码注释解释了原因:准备阶段可能很长(比如压缩),期间排队的引导消息要在这里被捡起来;但如果前面已经取到消息,就不要再取一次——否则 one-at-a-time 模式下这一轮会送进两条消息。
这就是"不会重复消费"的第一层保障。
三、引导压住后续
官方 README 的原话:
Follow-up messages are checked only when there are no more tool calls and no steering messages. If any are queued, they are injected and another turn runs.
翻译成流程:
# 引导队列压住后续队列:第 3 点取到引导消息就回到内层循环,根本走不到第 4 点
内层循环结束
↓
取引导消息(第 3 点)
├─ 取到了 → 回到内层循环,继续跑
│ (后续队列根本没被看)
└─ 没取到 → 取后续消息(第 4 点)
├─ 取到了 → 回到外层循环,再跑一轮
└─ 没取到 → 运行结束引导队列非空,后续队列就被压住。 注意这里的两层检查:第 3 点取到的引导消息会让内层循环继续,根本走不到第 4 点。
有一个细节容易忽略:第 3 点的取用是排空式的。取出来的消息立刻作为 user 消息注入当前上下文,然后待处理消息被清空。所以"压住"不是永久的——引导消息被消费掉之后,后续消息就在下一次外层循环被检查。
但如果引导队列里还有更多消息(one-at-a-time 模式下一次只取一条),下一轮内层循环结束时还会再取一条。后续队列要等引导队列彻底清空。
四、队列模式
两个队列各自有一个投递模式:
源码里队列对象就是这个语义:
// 取出和移除是同一个操作的两个半步,中间没有别的路径能改数组
class PendingMessageQueue {
private messages: AgentMessage[] = [];
public mode: QueueMode;
enqueue(message: AgentMessage): void {
this.messages.push(message);
}
hasItems(): boolean {
return this.messages.length > 0;
}
// 按模式决定这次能看到几条:"all" 全部,"one-at-a-time" 只看队首一条
peek(): AgentMessage[] {
if (this.mode === "all") return this.messages.slice();
const first = this.messages[0];
return first ? [first] : [];
}
// drain = peek + 移除
drain(): AgentMessage[] {
const drained = this.peek();
this.messages = this.messages.slice(drained.length);
return drained;
}
clear(): void {
this.messages = [];
}
}peek() 不消费,drain() 才消费。 drain() 取走的是 peek() 选中的那几条,然后从数组里切掉——所以取走的一定是"被选中的那几条",不多不少。这就是"不重复消费也不丢失"的第二层保障:取出和移除是同一个操作的两个半步,中间没有别的路径能改数组。
三个地方能改这个模式:
AgentSession 创建时会从设置同步这两个模式(syncQueueModesFromSettings())。会话层的 setter 会同时写回设置。
五、查看队列而不消费它
peekQueuedMessages() 的实现有一个值得记住的细节:
// 预览下一轮会被取走的消息:优先看引导队列
// peek 不消费,可以安全地反复调用
peekQueuedMessages(): AgentMessage[] {
const steering = this.steeringQueue.peek();
return steering.length > 0 ? steering : this.followUpQueue.peek();
}它优先看引导队列。 引导队列非空时,返回的就是引导队列的预览,后续队列即使有消息也不会被返回。这和实际调度顺序一致——所以它能准确回答"下一轮模型会看到什么"。
session.clearQueue() 是宿主最常用的一个:
// 返回被清掉的内容——SDK 的 abort() 不会退回消息,宿主要自己接住
clearQueue(): { steering: string[]; followUp: string[] } {
// 先拷贝再清空:直接返回原数组的话,调用方拿到的会是已经被清空的那个
const steering = [...this._steeringMessages];
const followUp = [...this._followUpMessages];
this._steeringMessages = [];
this._followUpMessages = [];
// agent 层也要清:会话层和 agent 层各存一份
this.agent.clearAllQueues();
// 发出 queue_update,界面上的"N 条待处理"提示才会更新
this._emitQueueUpdate();
return { steering, followUp };
}它同时清会话层的两个文本数组和 agent 层的两个队列,并发出 queue_update 事件。
六、continue():从已有上下文继续
agent.continue() 的行为分三种情况,官方 README 描述得很精确:
Empty and system-only transcripts reject without consuming queues. A non-assistant tail continues from existing context: steering is polled at startup, while follow-up input waits until the response naturally stops.
对应源码:
// continue() 从已有上下文继续
async continue(): Promise<void> {
if (this.activeRun) {
throw new Error("Agent is already processing. Wait for completion before continuing.");
}
const lastMessage = this._state.messages[this._state.messages.length - 1];
// 空或全是 system 消息:抛错且不碰队列
if (!lastMessage || this._state.messages.every((message) => message.role === "system")) {
throw new Error("No messages to continue from");
}
if (lastMessage.role === "assistant") {
// 尾部是 assistant:必须靠队列里的消息启动,先看引导队列
const queuedSteering = this.steeringQueue.drain();
if (queuedSteering.length > 0) {
// skipInitialSteeringPoll 很关键:消息已经被 drain 出来了,
// 循环启动时再轮询一次引导队列会造成重复消费
await this.runPromptMessages(queuedSteering, { skipInitialSteeringPoll: true });
return;
}
const queuedFollowUps = this.followUpQueue.drain();
if (queuedFollowUps.length > 0) {
await this.runPromptMessages(queuedFollowUps);
return;
}
// 两个队列都空:抛错
throw new Error("Cannot continue from message role: assistant");
}
// 非 assistant 尾部(user 或 toolResult):从现有上下文直接继续
await this.runContinuation();
}整理成表:
skipInitialSteeringPoll: true 是这里的关键。 走引导分支时,消息已经被 drain() 取出来了,如果循环启动时再轮询一次引导队列,会造成重复——所以要跳过初始轮询。
"一批"这个词受队列模式控制:one-at-a-time 下是一批一条,all 下是全部。
七、abort() 不清队列
这是本篇最容易被搞错的一点。
agent.abort() 只做一件事:中止当前运行的 AbortController。它不清任何队列。
交互式 TUI 里的做法是:先 session.clearQueue() 拿到内容,把文本拼回编辑器,再调 session.abort()。
SDK 宿主没有编辑器,退回编辑器是你要自己实现的:
// 先清队列拿到内容,再中止运行
const { steering, followUp } = session.clearQueue();
await session.abort();
// 把 steering 和 followUp 的文本交还给用户界面顺序很重要。 abort() 会等待会话空闲,这期间当前运行可能把队列里的消息消费掉。如果先 abort(),clearQueue() 拿到的可能已经是空数组,用户排队的输入就丢了。
所以:队列不会丢,但也不会自动回到用户手上。 宿主不接住,它就留在队列里等下一轮。
八、完整示例
import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
});
const unsubscribe = session.subscribe((event) => {
// 队列变化:宿主用它更新"有 N 条待处理消息"的提示
if (event.type === "queue_update") {
const total = event.steering.length + event.followUp.length;
console.error(`[queue] steering=${event.steering.length} followUp=${event.followUp.length}`);
if (total > 0) {
// peek 不会消费,可以安全地反复调用
const next = session.agent.peekQueuedMessages();
console.error(`[queue] 下一轮会看到 ${next.length} 条`);
}
}
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
process.stdout.write(event.assistantMessageEvent.delta);
}
});
try {
const run = session.prompt("读一下 package.json 并总结依赖");
// 运行中排两条:一条引导,一条后续
await new Promise((r) => setTimeout(r, 200));
console.error("steer ->", await session.steer("先看 pnpm-lock.yaml"));
await new Promise((r) => setTimeout(r, 200));
console.error("followUp ->", await session.followUp("最后再总结一下两个文件"));
await run;
// 用户改主意:取回还没送出的内容
const leftover = session.clearQueue();
if (leftover.steering.length || leftover.followUp.length) {
console.error("退回编辑器:", leftover);
}
} finally {
unsubscribe();
session.dispose();
}要观察"引导压住后续",改成一个可复现的实验:
// 引导和后续同时排入,观察模型先回应哪一条
await session.steer("这是引导:先改 README");
await session.followUp("这是后续:再改 CHANGELOG");
// 在下一次 provider 请求前打印队列预览
const next = session.agent.peekQueuedMessages();
console.error("下一轮先看到:", next.map((m) => m.content));
// 只会看到引导那条:peek 优先看引导队列你现在能做什么
- 对着轮询时点表说出一条引导消息会在哪一轮被取走,并说出第 2 点为什么要加"仅当没有待处理消息"这个条件。
- 同时排入一条引导和一条后续,用
peekQueuedMessages()确认下一轮只会看到引导那条,并解释原因。 - 连续调两次
peekQueuedMessages(),确认结果相同且队列长度不变,证明预览不消费。 - 用
session.clearQueue()取回排队内容,确认返回的两个数组有内容且pendingMessageCount归零。 - 排入消息后调用
agent.abort(),确认队列内容仍在,证明中止本身不清队列。 - 会话停在 assistant 消息且两个队列都空时调用
continue(),确认它抛出 "Cannot continue from message role: assistant",并且没有消耗任何队列。
排障速查
最后一行值得展开:session.agent 是 AgentSession 上的公开只读属性,所以 session.agent.peekQueuedMessages() 是可用的;但如果你不想下沉到 agent 层,会话层有 getSteeringMessages() 和 getFollowUpMessages() 两个只读方法,以及 pendingMessageCount。