跳到主内容
创建时间:最后更新:作者:白川

输入队列与继续执行

追踪引导队列与后续队列的轮询时点,说明消息为什么既不会重复消费,也不会丢失。

输入队列与继续执行

运行中的输入进了队列之后,有一个问题经常让人困惑:队列里的消息,究竟在什么时刻被取走?

这不是细节问题。取走的时刻决定了三件事:用户的话什么时候生效、为什么后续消息有时"卡住"不动、以及 abort() 之后那些排队的话去哪了。

更关键的是一个反直觉的事实:引导队列非空会压住后续队列。 你明明排了两条消息,一条也没丢,但模型只回应了第一条——不是 bug,是调度规则。

这篇追踪队列的四个轮询时点、两种投递模式的实现、以及 continue() 的五种分支。读完你能预测一条排队消息会在哪一轮被消费,以及为什么不会重复也不会丢。

需要的前置只有一条:知道 Pi 在会话忙碌时提供 steer()(引导)和 followUp()(后续)两种排队方式,分别进两个队列。

一、两个队列,两次轮询

队列有两个,取走的时机也不同:

引导队列(steering)后续队列(follow-up)
注入位置
当前助手轮次及其工具调用全部结束之后
智能体把待办都做完之后
官方描述
"所有工具调用都已经结束 → 引导消息被注入 → LLM 在下一轮响应"
"只在没有更多工具调用、也没有引导消息时才被检查"
典型用途
纠正方向、补充约束
追加任务、要求总结

"没有更多工具调用、也没有引导消息"这个条件是整篇的核心。 两个条件都满足才轮到后续队列,缺一个都不检查。

二、轮询时点表

源码里事件循环的主循环结构是这样的:

text
# 事件循环的主循环结构:四个轮询点决定排队消息在第几轮被取走
外层循环
├─ 取一次引导消息(启动时的初始轮询)
└─ 内层循环(还有工具调用 或 还有待处理消息)
   ├─ 准备消息(prepareNextTurn)
   ├─ 若待处理消息为空,再取一次引导消息
   ├─ 发出 turn_start,注入准备好的和取到的消息
   ├─ provider 请求 → 工具执行
   ├─ finishTurn → turn_end
   └─ 取一次引导消息 → 若有工具调用或取到消息,继续内层循环
└─ 取一次后续消息 → 若取到,继续外层循环

把轮询点列成表:

序号位置取哪个队列触发条件
1
循环刚启动
引导
无条件。这是"用户在等待期间输入了话"的兜底
2
每轮准备阶段(prepareNextTurn 之后)
引导
仅当当前没有待处理消息时
3
每轮 turn_end 之后
引导
无条件
4
内层循环结束(智能体本来要停)
后续
无条件

第 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.

翻译成流程:

text
# 引导队列压住后续队列:第 3 点取到引导消息就回到内层循环,根本走不到第 4 点
内层循环结束
  ↓
取引导消息(第 3 点)
  ├─ 取到了 → 回到内层循环,继续跑
  │            (后续队列根本没被看)
  └─ 没取到 → 取后续消息(第 4 点)
               ├─ 取到了 → 回到外层循环,再跑一轮
               └─ 没取到 → 运行结束

引导队列非空,后续队列就被压住。 注意这里的两层检查:第 3 点取到的引导消息会让内层循环继续,根本走不到第 4 点。

有一个细节容易忽略:第 3 点的取用是排空式的。取出来的消息立刻作为 user 消息注入当前上下文,然后待处理消息被清空。所以"压住"不是永久的——引导消息被消费掉之后,后续消息就在下一次外层循环被检查。

但如果引导队列里还有更多消息(one-at-a-time 模式下一次只取一条),下一轮内层循环结束时还会再取一条。后续队列要等引导队列彻底清空。

四、队列模式

两个队列各自有一个投递模式:

模式行为
"one-at-a-time"(默认)
每次只取队首一条,等模型回应完再取下一条
"all"
一次把所有排队消息都取出来,一起发给模型

源码里队列对象就是这个语义:

typescript
// 取出和移除是同一个操作的两个半步,中间没有别的路径能改数组
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() 选中的那几条,然后从数组里切掉——所以取走的一定是"被选中的那几条",不多不少。这就是"不重复消费也不丢失"的第二层保障:取出和移除是同一个操作的两个半步,中间没有别的路径能改数组。

三个地方能改这个模式:

位置写法
settings.json
"steeringMode": "all" / "followUpMode": "one-at-a-time"
Agent 构造选项
steeringMode、followUpMode
AgentSession
session.setSteeringMode(mode) / session.setFollowUpMode(mode)

AgentSession 创建时会从设置同步这两个模式(syncQueueModesFromSettings())。会话层的 setter 会同时写回设置。

五、查看队列而不消费它

方法作用
agent.peekQueuedMessages()
预览下一轮会被取走的消息,不消费
agent.hasQueuedMessages()
两个队列里是否还有消息
agent.clearSteeringQueue()
只清引导队列
agent.clearFollowUpQueue()
只清后续队列
agent.clearAllQueues()
两个都清
session.clearQueue()
清两个队列,并返回被清掉的文本
session.getSteeringMessages() / session.getFollowUpMessages()
只读地看会话层记录的排队文本
session.pendingMessageCount
排队消息总数

peekQueuedMessages() 的实现有一个值得记住的细节:

typescript
// 预览下一轮会被取走的消息:优先看引导队列
// peek 不消费,可以安全地反复调用
peekQueuedMessages(): AgentMessage[] {
	const steering = this.steeringQueue.peek();
	return steering.length > 0 ? steering : this.followUpQueue.peek();
}

它优先看引导队列。 引导队列非空时,返回的就是引导队列的预览,后续队列即使有消息也不会被返回。这和实际调度顺序一致——所以它能准确回答"下一轮模型会看到什么"。

session.clearQueue() 是宿主最常用的一个:

typescript
// 返回被清掉的内容——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.

对应源码:

typescript
// 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();
}

整理成表:

上下文尾部行为队列消耗
空,或全是 system 消息
抛错 "No messages to continue from"
不消耗队列
非 assistant 尾部(user 或 toolResult)
从现有上下文继续,引导在启动时轮询,后续等响应自然停下
正常轮询
assistant 尾部 + 引导队列非空
用取出的引导消息继续
消耗一批引导
assistant 尾部 + 引导队列空、后续队列非空
用取出的后续消息继续
消耗一批后续
assistant 尾部 + 两个队列都空
抛错 "Cannot continue from message role: assistant"
不消耗

skipInitialSteeringPoll: true 是这里的关键。 走引导分支时,消息已经被 drain() 取出来了,如果循环启动时再轮询一次引导队列,会造成重复——所以要跳过初始轮询。

"一批"这个词受队列模式控制:one-at-a-time 下是一批一条,all 下是全部。

七、abort() 不清队列

这是本篇最容易被搞错的一点。

agent.abort() 只做一件事:中止当前运行的 AbortController。它不清任何队列。

交互式 TUI 里的做法是:先 session.clearQueue() 拿到内容,把文本拼回编辑器,再调 session.abort()。

SDK 宿主没有编辑器,退回编辑器是你要自己实现的:

typescript
// 先清队列拿到内容,再中止运行
const { steering, followUp } = session.clearQueue();
await session.abort();
// 把 steering 和 followUp 的文本交还给用户界面

顺序很重要。 abort() 会等待会话空闲,这期间当前运行可能把队列里的消息消费掉。如果先 abort(),clearQueue() 拿到的可能已经是空数组,用户排队的输入就丢了。

所以:队列不会丢,但也不会自动回到用户手上。 宿主不接住,它就留在队列里等下一轮。

八、完整示例

typescript
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();
}

要观察"引导压住后续",改成一个可复现的实验:

typescript
// 引导和后续同时排入,观察模型先回应哪一条
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",并且没有消耗任何队列。

排障速查

现象检查点处理
后续消息一直没被处理
引导队列非空
引导优先被检查;等引导清空,或清掉引导队列
同一条消息被处理两次
队列模式
one-at-a-time 每次一条;检查是否有扩展同时调了 steer() 和 followUp()
peek 看到的和实际不符
预览优先级
peekQueuedMessages() 优先看引导队列;它反映的是真实调度顺序
continue() 抛 "No messages to continue from"
上下文状态
上下文为空或全是 system 消息;此时不消耗队列,可以重试
continue() 抛 "Cannot continue from message role: assistant"
尾部角色
assistant 尾部要靠队列里的消息启动;先排一条再 continue()
abort() 后排队消息不见了
宿主没接住
SDK 的 abort() 不退回内容;先 clearQueue() 拿回来
排队内容想改但拿不回来
顺序
先 clearQueue() 再 abort();反过来会丢消息
队列提示不更新
没订阅事件
订阅 session.subscribe() 并处理 queue_update
session.agent 访问不到
SDK 层 API
peekQueuedMessages() 在 agent 层;会话层用 getSteeringMessages() / getFollowUpMessages()

最后一行值得展开:session.agent 是 AgentSession 上的公开只读属性,所以 session.agent.peekQueuedMessages() 是可用的;但如果你不想下沉到 agent 层,会话层有 getSteeringMessages() 和 getFollowUpMessages() 两个只读方法,以及 pendingMessageCount。

参考资料