一个 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

相关推荐
JJJennie77710 小时前
当AI开始学会欺骗
网络·人工智能
糖果店的幽灵10 小时前
大模型测评DeepEval快速入门-安全与通用指标详解
人工智能·安全·langgraph·大模型测评·deepeval
江畔柳前堤16 小时前
roLabelImg 详细安装教程
开发语言·人工智能·后端·云原生
阿里云大数据AI技术16 小时前
分链路差异化设计的DSP准实时数仓|钛动科技基于阿里云实时计算 Flink 版 + DLF Paimon + EMR Serverless StarRocks 的实践
人工智能·flink
陕西企来客16 小时前
2026年7月AI智能搜索曝光趋势研判
大数据·人工智能·机器学习·ai智能搜索曝光
阿里云大数据AI技术17 小时前
从算力到智能体,面向 Agentic AI 的基础设施演进
人工智能·agent
hangyuekejiGEO17 小时前
GEO技术服务选型指南
大数据·人工智能·python
阿里云大数据AI技术18 小时前
EMR Serverless Spark AI Function 的双维降本实践
人工智能·sql·spark
维基框架18 小时前
GitHub源码处理提速 一趟扫描反而更慢
人工智能·github
冬奇Lab18 小时前
代码库知识库系列(05):向量检索 vs 知识图谱——加了调用图并没有变更好
人工智能