一个 agent 怎么做“中途打断“:steer / followUp / nextTurn

本文以 earendil-works/pi-mono 为样本,分析 agent 在已经开始流式输出或正在执行工具 时,如何安全地接受新的用户输入。所引用的代码位于 packages/agent/src/agent.tspackages/agent/src/agent-loop.tspackages/coding-agent/src/core/agent-session.ts

问题背景

一个 agent 在长任务中跑十几分钟非常常见:读代码 → 修代码 → 跑测试 → 改文档。期间用户经常会想:

  • "等等,方向走偏了,先看下别的";
  • "做完之后顺便把这个也做了";
  • "下一次我让你做 X 时,记得带上 Y 这份资料"。

朴素的解法只有两种:

  1. 要么不给打断 ------用户只能等到 agent 自己 agent_end,再发新消息,已经做的工作可能跟新意图相反;
  2. 要么粗暴 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-timeall 模式只在"用户连点了三下打断"这种边缘场景里才需要------把所有打断意图打包成一次干预,避免连续三次干预浪费 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(),
});

接下来发生的事:

  1. 当前 edit 工具调用完成(不被 abort);
  2. tool_execution_end 后产生 toolResult 加入 context;
  3. turn_end emit;
  4. getSteeringMessages 返回 [steerMsg]
  5. 下一个 turn_start
  6. steerMsg 作为 user message 进入 context(emit message_start + message_end);
  7. LLM 看到的对话历史:...(原 user) (原 assistant) (toolResult) (steer 用户消息)
  8. 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 边界注入

nextTurnAgent 这一层没有,是 coding-agentAgentSession 加上去的能力。

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(...)

四个不变量串起整套机制:

  1. steer / followUp 入队是同步的,调用者拿到的是 enqueue 已完成;
  2. agent_loop 只在 turn 边界 poll,永远不会打断"半个工具"或"半个 LLM 流";
  3. 入队消息会作为 user message 写进 context,next turn 后 LLM 看到的就是完整的多轮对话历史;
  4. nextTurn 不会自己触发 agent,它是被动等 prompt 携带过来。

五、和"abort + 重发"对比

回到一开始的对比:

abort + 重发 steer / followUp / nextTurn
已经做的工作 丢失 保留
context 连续性 断裂 完整
LLM 重新理解
实现复杂度 中(需要 turn 边界设计)
适用 真的要重来 改方向 / 追加 / 静默注入

abort 仍然是必要的------比如用户彻底想关掉 agent,或当前 turn 卡死要强制结束。但绝大多数"中途想说点什么"的场景,应该走 steer/followUp/nextTurn


六、自己实现的工程要点

如果想在自己的 agent 框架里加上类似机制,关键不是队列实现(队列写起来很简单),而是 turn 边界的清晰程度。下面几条是从 pi 这套设计里抽出来的工程要点:

  1. agent 主循环必须有显式的 turn 概念。每个 turn = 一次 LLM 调用 + 这次调用产生的所有工具调用结算。turn_end 之后才是注入新消息的安全点。

  2. 入队是同步的,drain 是异步钩子。enqueue 必须不阻塞,drain 必须能 await(因为可能要做异步检查,比如 lazy load)。

  3. turn_end 后两道 poll:一道处理 turn 中事件(steer),一道处理 inner loop 退出前的最后机会(followUp)。它们是不同语义的关卡。

  4. 入队消息要走和用户消息一样的事件序列 。pi 在内层注入时会 emit message_start + message_end,这样 UI 能像渲染普通用户消息一样渲染它,订阅者不用做特殊判断。

  5. drain 模式(all / one-at-a-time)要可配置。默认 one-at-a-time 是因为它最不容易出错;高级用户在特定场景里需要 all 模式打包消费。

  6. 对 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.tspackages/agent/src/agent-loop.tspackages/coding-agent/src/core/agent-session.ts

相关推荐
zhangfeng11331 小时前
Mamba transformer的颠覆者 论文技术解读与应用实践深度报告,
人工智能·深度学习·transformer
weixin_446260851 小时前
Skill-RM:通过Agent技能统一异构评估标准
人工智能
CriticalThinking1 小时前
在 JetBrains IDE 中通过 ACP 协议集成 Claude Code等外部工具
ide·agent·ai编程
Sss_Ass1 小时前
2026 年 AI 大模型 & AI 编程工具实战全总结
人工智能
IT23101 小时前
RISC-V SoC设计解决方案:从架构优化到验证收敛
人工智能
BlockWay1 小时前
WEEX Labs 周度观察:微软-OpenAI 合作调整与AI 多云趋势
大数据·人工智能·算法·安全·microsoft
掘金一周2 小时前
问卷调查:如果现在收到裁员通知,你手里的现金流能支撑多久? | 沸点周刊6.4
前端·人工智能·后端
Smoothcloud润云2 小时前
5大功能精修,重构AI算力使用体验!
java·人工智能·windows·算法·重构·编辑器·sublime text
andafaAPS2 小时前
安达发|工艺品aps自动排产排程排单软件:告别生产“一团乱麻“
大数据·数据库·人工智能·安达发aps·计划排产软件·自动排单软件