本文以 earendil-works/pi-mono 为样本,分析 agent 在已经开始流式输出或正在执行工具 时,如何安全地接受新的用户输入。所引用的代码位于
packages/agent/src/agent.ts、packages/agent/src/agent-loop.ts与packages/coding-agent/src/core/agent-session.ts。
问题背景
一个 agent 在长任务中跑十几分钟非常常见:读代码 → 修代码 → 跑测试 → 改文档。期间用户经常会想:
- "等等,方向走偏了,先看下别的";
- "做完之后顺便把这个也做了";
- "下一次我让你做 X 时,记得带上 Y 这份资料"。
朴素的解法只有两种:
- 要么不给打断 ------用户只能等到 agent 自己
agent_end,再发新消息,已经做的工作可能跟新意图相反; - 要么粗暴 abort + 重发------丢掉了已经积累的 context,相当于从零开始。
pi 选了第三条路:在 turn 边界精确注入用户消息,对应三个公开 API:
| API | 触发时机 | 典型用途 |
|---|---|---|
steer(msg) |
当前 turn 的所有 toolCall 全部结算后、下一次 LLM 调用前 | 打断"它正在按错思路工作" |
followUp(msg) |
模型本来要 agent_end 时 |
"顺便再做这件事" |
nextTurn(msg) |
下一次用户新 prompt 之前注入 | 用户级 context 注入 |
三者共享一套底层机制------消息队列 + agentLoop 在固定切点 poll,但语义有微妙区别。本文逐个拆解。
一、steer:在 turn 边界打断
1.1 接口
typescript
// packages/agent/src/agent.ts
class Agent {
steer(message: AgentMessage): void {
this.steeringQueue.enqueue(message);
}
set steeringMode(mode: QueueMode) {
this.steeringQueue.mode = mode;
}
}
注意 steer 是同步方法------它只是把消息塞进队列就返回,不会等待 agent 处理完。这是对的:调用者通常在事件回调或 UI 交互里调它,必须立刻返回。
1.2 队列实现
队列本身是个非常薄的封装:
typescript
// packages/agent/src/agent.ts
class PendingMessageQueue {
private messages: AgentMessage[] = [];
public mode: QueueMode; // "all" | "one-at-a-time"
constructor(mode: QueueMode) {
this.mode = mode;
}
enqueue(message: AgentMessage): void {
this.messages.push(message);
}
drain(): AgentMessage[] {
if (this.mode === "all") {
const drained = this.messages.slice();
this.messages = [];
return drained;
}
const drained = this.messages.length ? [this.messages.shift()!] : [];
return drained;
}
}
两种模式的差别只在 drain 时一次取多少:
one-at-a-time(默认):每个切点只取一条,余下的留在队列里等下个切点;all:一次清空,全部一起注入下个 turn。
绝大多数场景应该用默认的 one-at-a-time。all 模式只在"用户连点了三下打断"这种边缘场景里才需要------把所有打断意图打包成一次干预,避免连续三次干预浪费 LLM 调用。
1.3 切点:agent-loop 在哪 poll
Agent 把队列 drain 接成 agentLoop 的钩子:
typescript
// packages/agent/src/agent.ts
{
getSteeringMessages: async () => {
// 跳过本次 prompt 启动时的第一次 poll,避免把刚 enqueue 的 prompt 又取出来
if (skipInitialSteeringPoll) {
skipInitialSteeringPoll = false;
return [];
}
return this.steeringQueue.drain();
},
getFollowUpMessages: async () => this.followUpQueue.drain(),
}
落到 agentLoop 的主循环里,steering 在两个位置被消费:
typescript
// packages/agent/src/agent-loop.ts
// 启动前先 poll 一次(用户在 prompt 之前就 enqueue 的消息会走这条)
let pendingMessages = (await config.getSteeringMessages?.()) || [];
while (true) {
let hasMoreToolCalls = true;
while (hasMoreToolCalls || pendingMessages.length > 0) {
await emit({ type: "turn_start" });
// ① turn 开始前:把 pending 注入为新消息
if (pendingMessages.length > 0) {
for (const message of pendingMessages) {
await emit({ type: "message_start", message });
await emit({ type: "message_end", message });
currentContext.messages.push(message);
newMessages.push(message);
}
pendingMessages = [];
}
// ② LLM 调用 + 工具执行
const message = await streamAssistantResponse(...);
const toolCalls = message.content.filter((c) => c.type === "toolCall");
const toolResults = [];
if (toolCalls.length > 0) {
const batch = await executeToolCalls(currentContext, message, config, signal, emit);
toolResults.push(...batch.messages);
...
}
await emit({ type: "turn_end", message, toolResults });
// ③ turn_end 之后:再 poll 一次 steer 队列
pendingMessages = (await config.getSteeringMessages?.()) || [];
}
// ④ inner 循环退出,agent 本要 end:poll follow-up 队列
const followUps = (await config.getFollowUpMessages?.()) || [];
if (followUps.length > 0) {
pendingMessages = followUps;
continue;
}
break;
}
await emit({ type: "agent_end", messages: newMessages });
关键时序:
turn_start ─ message_start/end (user) ─ message_start/update/end (assistant) ─
├── 若有 toolCall:tool_execution_* + message_start/end (toolResult)
└── 重复直到 assistant 不再要求工具
turn_end ← steer 在这里被 poll
│
├── 队列非空 → 把消息当作 user message 写入 context → 进入下一 turn
└── 队列空:
├── follow-up 队列非空 → 进入下一 turn(带 follow-up 消息)
└── follow-up 也空 → agent_end
最重要的不变量 :steer 注入永远发生在 turn_end 之后、turn_start 之前。这意味着:
- 当前 assistant message 已经完整 stop;
- 当前 turn 触发的所有工具调用已经全部结算(结果也已经回到 context);
- 没有任何"半个工具调用"或"半个 LLM 流"被打断。
这是 pi steer 与"abort + 重发"的本质差别------abort 会丢掉部分 toolResult,steer 不会。
1.4 实战示例
假设用户让 agent 重构一个文件,agent 已经 read 了文件、正在生成 edit 工具调用。这时用户意识到方向错了:
typescript
// UI 监听到用户点了"打断"
agent.steer({
role: "user",
content: "等等,先不要直接改这个文件。它有 100 行测试覆盖,我想先看一下当前测试是否通过。",
timestamp: Date.now(),
});
接下来发生的事:
- 当前 edit 工具调用完成(不被 abort);
tool_execution_end后产生toolResult加入 context;turn_endemit;getSteeringMessages返回[steerMsg];- 下一个
turn_start; - steerMsg 作为 user message 进入 context(emit
message_start+message_end); - LLM 看到的对话历史:
...(原 user) (原 assistant) (toolResult) (steer 用户消息); - assistant 在新 turn 里响应 steer。
工作不丢、上下文连续、模型能基于已经做完的工具结果讨论下一步。
二、followUp:模型本要停时追加
2.1 接口
typescript
// packages/agent/src/agent.ts
class Agent {
followUp(message: AgentMessage): void {
this.followUpQueue.enqueue(message);
}
set followUpMode(mode: QueueMode) {
this.followUpQueue.mode = mode;
}
}
队列实现完全和 steer 共享 PendingMessageQueue,只是用第二个实例。
2.2 切点:inner loop 退出时
回看主循环的最后部分:
typescript
// packages/agent/src/agent-loop.ts
while (true) {
while (hasMoreToolCalls || pendingMessages.length > 0) {
// ...一个 turn...
}
// 内层退出 → agent 本要 end
const followUps = (await config.getFollowUpMessages?.()) || [];
if (followUps.length > 0) {
pendingMessages = followUps;
continue; // 回到内层,把 follow-up 当作下个 turn 的输入
}
break;
}
await emit({ type: "agent_end", messages: newMessages });
hasMoreToolCalls 为 false 且 pendingMessages 为空时,agent 本来要 agent_end 退出。这一刻 pi 多做了一步:再去 follow-up 队列看一眼,有就把它当作新一轮 turn 的输入。
2.3 与 steer 的关键差别
很多人初看会觉得"既然 steer 也能在 turn_end 后注入消息,那 follow-up 是不是多余?"------不是。两者切点不同:
| 维度 | steer |
followUp |
|---|---|---|
| 切点 | 任意 turn_end 之后,包括还要继续工具调用的 turn | 只在 inner loop 准备退出(agent 本要 end)时 |
| 消费优先级 | 高(每个 turn_end 都 poll) | 低(agent_end 之前最后一道关) |
| 用途 | 打断、改方向 | 顺手再做一件事 |
具体说:
[场景 1:assistant 还要继续调工具]
turn_end (有 toolResults,hasMoreToolCalls = true)
→ poll steer:有 → 注入并进入下一 turn(steer 优先于继续工具循环)
→ poll steer:空 → 直接进入下一 turn 继续处理 toolResult
[场景 2:assistant 说完了,不再调工具]
turn_end (无 toolResults,hasMoreToolCalls = false)
→ poll steer:有 → 注入并进入下一 turn
→ poll steer:空 → inner 循环退出
→ poll follow-up:有 → 注入并进入下一 turn
→ poll follow-up:空 → agent_end
如果用 steer 写"任务完成后再做这件事",会出问题------steer 会在中间任何一个 turn_end 被取走,把"补充任务"插到工作中间,干扰当前任务。followUp 把消费时机推到"agent 本要停"的最后一刻,避开了这个问题。
2.4 实战示例
agent 跑完了"修复 bug → 跑测试通过"的完整流程,用户在中途按下了一个"再帮我写 changelog"按钮:
typescript
agent.followUp({
role: "user",
content: "测试通过后,把这次修改简要写进 CHANGELOG.md 的 Unreleased 段",
timestamp: Date.now(),
});
agent 不会被打断,会按原计划修完 bug、跑完测试,到了本要 agent_end 的那一刻才看到这条消息,于是新起一个 turn 写 changelog。这正是用户想要的"做完正事再顺手"。
三、nextTurn:跨 prompt 边界注入
nextTurn 在 Agent 这一层没有,是 coding-agent 的 AgentSession 加上去的能力。
3.1 接口与存储
typescript
// packages/coding-agent/src/core/agent-session.ts
class AgentSession {
/** Messages queued to be included with the next user prompt as context ("asides"). */
private _pendingNextTurnMessages: CustomMessage[] = [];
async sendCustomMessage<T = unknown>(
message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details">,
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
): Promise<void> {
const appMessage = {
role: "custom",
...message,
timestamp: Date.now(),
} satisfies CustomMessage<T>;
if (options?.deliverAs === "nextTurn") {
this._pendingNextTurnMessages.push(appMessage);
} else if (this.isStreaming) {
if (options?.deliverAs === "followUp") {
// 入 follow-up 队列
} else {
// 入 steer 队列(默认)
}
} else {
// ...
}
}
}
3.2 切点:下一次用户 prompt 之前
不在 agentLoop 里 poll,而是在 AgentSession.prompt() 启动的入口处一次性 flush:
typescript
// packages/coding-agent/src/core/agent-session.ts (节选概念示意)
async prompt(text: string, options?: PromptOptions) {
// ... 各种校验、扩展命令处理 ...
const messages: AgentMessage[] = [];
// 把 nextTurn 队列里的所有消息插到新 user message 之前
for (const msg of this._pendingNextTurnMessages) {
messages.push(msg);
}
this._pendingNextTurnMessages = [];
// 再追加用户本次的 prompt
messages.push(/* user message */);
// 喂给 Agent 跑一轮
await this.agent.prompt(messages);
}
所以 nextTurn 消息永远不会自己触发 agent 跑。它是被动的:等到下一次用户 prompt 时,作为"附带 context"挂在用户消息前面进入对话历史。
3.3 与 steer / followUp 的差别
| 维度 | steer |
followUp |
nextTurn |
|---|---|---|---|
| 自己触发 agent 跑? | 是(如果 agent 已在跑) | 是(如果 agent 已在跑) | 否,必须等下次 prompt |
| 注入时机 | 当前 turn_end 之后 | inner loop 退出时 | 下一次用户 prompt 之前 |
| 用户感知 | 立刻打断 | 任务结束追加 | 静默累积 |
| 典型用途 | "改方向" | "顺手再做一件事" | "把这份资料带在身边" |
举两个 nextTurn 的实际用法:
用法 1:自动注入资料
agent 在工作时发现有几个文件用户没提到但很相关。它不需要立刻打断当前任务去汇报,可以发一条 nextTurn 消息:
typescript
session.sendCustomMessage({
customType: "context_aside",
content: "我注意到 src/utils/auth.ts 也涉及这个改动,下次你提相关问题时我会带上它。",
display: true,
details: { relatedFile: "src/utils/auth.ts" },
}, { deliverAs: "nextTurn" });
下次用户 prompt 时,这条消息出现在 user message 之前,模型能看到。
用法 2:异步通知/审计
某个 webhook 触发了一个 customMessage,需要让 agent "知道但不打扰":
typescript
session.sendCustomMessage({
customType: "ci_status",
content: "CI: 上一次 PR 检查已通过",
display: true,
}, { deliverAs: "nextTurn" });
四、整体时序对照
把三种打断方式画在一张时序图上:
┌─ user ─────────► agent.prompt(text)
│
├─ Agent 跑:
│ turn_start
│ message_start/end (user)
│ message_start/update/end (assistant) ── toolCall? ──┐
│ tool_execution_* │
│ message_start/end (toolResult) │
│ turn_end ◄──────────────────────────────────────────── │
│ │
│ ├─ poll steeringQueue: │
│ │ 有 → 注入下一 turn (打断) │
│ │ 空 → 看是否还有 toolCall 要继续 ────────────────┘
│ │
│ └─ inner loop 退出(assistant 不再要工具):
│ poll followUpQueue:
│ 有 → 注入下一 turn ("顺手再做一件事")
│ 空 → agent_end
│
├─ user 在 agent 已经 end 之后再发 prompt:
│ AgentSession.prompt() flushes _pendingNextTurnMessages
│ → 插在新 user 消息前面
│ → agent.prompt(...)
四个不变量串起整套机制:
- steer / followUp 入队是同步的,调用者拿到的是 enqueue 已完成;
- agent_loop 只在 turn 边界 poll,永远不会打断"半个工具"或"半个 LLM 流";
- 入队消息会作为 user message 写进 context,next turn 后 LLM 看到的就是完整的多轮对话历史;
- nextTurn 不会自己触发 agent,它是被动等 prompt 携带过来。
五、和"abort + 重发"对比
回到一开始的对比:
| abort + 重发 | steer / followUp / nextTurn | |
|---|---|---|
| 已经做的工作 | 丢失 | 保留 |
| context 连续性 | 断裂 | 完整 |
| LLM 重新理解 | 是 | 否 |
| 实现复杂度 | 低 | 中(需要 turn 边界设计) |
| 适用 | 真的要重来 | 改方向 / 追加 / 静默注入 |
abort 仍然是必要的------比如用户彻底想关掉 agent,或当前 turn 卡死要强制结束。但绝大多数"中途想说点什么"的场景,应该走 steer/followUp/nextTurn。
六、自己实现的工程要点
如果想在自己的 agent 框架里加上类似机制,关键不是队列实现(队列写起来很简单),而是 turn 边界的清晰程度。下面几条是从 pi 这套设计里抽出来的工程要点:
-
agent 主循环必须有显式的 turn 概念。每个 turn = 一次 LLM 调用 + 这次调用产生的所有工具调用结算。turn_end 之后才是注入新消息的安全点。
-
入队是同步的,drain 是异步钩子。enqueue 必须不阻塞,drain 必须能 await(因为可能要做异步检查,比如 lazy load)。
-
turn_end 后两道 poll:一道处理 turn 中事件(steer),一道处理 inner loop 退出前的最后机会(followUp)。它们是不同语义的关卡。
-
入队消息要走和用户消息一样的事件序列 。pi 在内层注入时会 emit
message_start+message_end,这样 UI 能像渲染普通用户消息一样渲染它,订阅者不用做特殊判断。 -
drain 模式(all / one-at-a-time)要可配置。默认 one-at-a-time 是因为它最不容易出错;高级用户在特定场景里需要 all 模式打包消费。
-
对 abort 的语义保持纯净。abort 就是 abort,不要让它清空 steer 队列------队列里的消息可能是用户在 abort 之前就 enqueue 好的合法意图。
写在最后
把"中途打断"做成"turn 边界注入"而不是"信号 + 重发",是 pi 给同类项目最有教育意义的一条经验。它的代价非常小------一个队列、两次 poll、几个事件------但收益是用户能在长任务中连续表达意图,agent 既不丢工作也不丢上下文。
steer / followUp / nextTurn 这三层切点的差别看似细微,落到产品上是三种完全不同的用户行为:
- "我不同意你正在做的事";
- "你做完了,我顺便再补一句";
- "下次你工作时记得带上这个"。
把它们映射到三个 turn 边界的固定切点,是这套设计的优雅之处。
仓库地址:https://github.com/earendil-works/pi-mono
关键文件:
packages/agent/src/agent.ts、packages/agent/src/agent-loop.ts、packages/coding-agent/src/core/agent-session.ts