# 打断与防失控 · 两种「叫停」的哲学

> agent 正忙时，你怎么插话？模型自己陷入复读机怎么办？这一章看 grok 的两套「叫停」机制——协作式打断（buffer 合并，不抢占）与硬取消，以及服务端 doom-loop 检测如何中途掐断失控生成、在独立预算上重采样。

- 作者：David（道雾轩）
- 专栏：Grok Build 源码解读（https://daiw.org/manual/grok-build.md）
- 最后更新：2026-09-15
- 原文：https://daiw.org/manual/grok-build/interjection-doom-loop
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

# 打断与防失控 · 两种「叫停」的哲学

Part 1 最后一章，讲几种「让正在跑的 turn 停下或转向」的机制。它们的设计哲学差异很值得玩味。

## 打断 vs 取消：协作 vs 抢占

grok 把「用户想插话」分成两种，机制完全不同：

- **硬取消**(TUI 里是 <kbd>Ctrl+C</kbd>；turn 运行中按 Esc 只会提示你改按 Ctrl+C)：你要它**立刻停**。`cancel_running_task`(`acp_session_impl/cancel.rs`)先杀掉前台终端命令，再 `abort` 掉 turn 任务、取消在途的采样请求。如果这一刀截断了助手正在输出的文字、又没留下别的信号(没有悬空的工具调用)，下一条真实用户消息会被套上 `INTERRUPT_NOTE`(「The user interrupted the previous turn:」)信封，让模型知道上一轮是被打断的——原先那条 `INTERRUPT_REMINDER` 系统提醒已被这套信封取代。「send now」(默认 <kbd>Ctrl+Enter</kbd>)也属于这一类：取消当前 turn，立刻把你的话作为下一轮发出。这是**抢占**。
- **软打断**(边跑边补一句)：你想**引导**它而不打断。走一个完全不同的原语——`xai-interjection-core` 的 `EventQueue`。

## 软打断：一个共享队列，协作式合并

`EventQueue<E>` 就是个 `Arc<Mutex<Vec<E>>>`(`interjection-core/events.rs`)，shell 用的 `InterjectionBuffer` 只是它装 `PendingInterjection` 的别名。插话由客户端经 ACP 扩展请求送进会话(`SessionCommand::Interject`)；在 TUI 里把 `[ui].follow_up_behavior` 设成 `"steer"` 后，turn 进行中按 Enter 排队的话也会在安全点被提升成插话(默认的 `"queue"` 则等本轮结束再跑)。

插的话被 push 进队列，**不取消任何东西**；循环在**安全点**(圈顶、每批工具执行之后、turn 收尾前)调 `drain_interjections_at_safe_point()` → `drain_pending_interjections()` 把它排空，套上 `<user_query>…</user_query>` 信封(前面一句「The user sent a message while you were working:」，后面提醒它别丢下手头没做完的事)，作为一条独立的合成用户消息注入历史，循环 `continue`，模型下一圈就看到了。

```mermaid
flowchart LR
  U[你补一句] -->|push| Q["EventQueue<br/>Arc&lt;Mutex&lt;Vec&gt;&gt;"]
  L[turn 循环] -->|安全点| D[drain_pending_interjections]
  Q --> D
  D -->|合成 User 消息| H[写入 chat-state]
  H --> L2[下一圈模型看到]
```

关键词是**协作(cooperative merge)，不是抢占(preemption)**：打断从不取消 turn，而是让模型在下一步把它和手头工作一起权衡。注意它和硬取消的对比——同样是「叫停」，一个直接 `abort` 任务、掐断在途请求(真抢占)，一个用共享队列(只是排队等它自己看)。这个「什么时候该抢占、什么时候该协作」的取舍，是交互式 agent 的核心设计题。

<Callout type="info">
  一个鲁棒性细节：若打断在 turn 已经跑完最后一次排空之后才到，它会「搁浅」。`flush_stranded_interjections` 兜底把它转成排在队首的 fallback prompt，不会石沉大海。
</Callout>

## 防失控：doom-loop 检测

模型有时会陷入**复读机式失控**——同一段话反复吐、或低置信度地空转。grok 在采样器里接了服务端的 **doom-loop 检测**(`doom_loop.rs`)，这是纯客户端安全网之外的另一道防线。

机制(仅 Responses API。协议上是 opt-in——请求带上 `x-grok-doom-loop-check` 头服务端才报；grok 默认就带，`GROK_DOOM_LOOP_RECOVERY`、`[doom_loop_recovery]` 或远端设置任一层设为 false 即可关掉)：

- 服务端在流里插一种特殊 SSE 帧 `response.doom_loop_check`，携带累积的**触发信号**，语法如 `tail_repetition:{阈值}@{channel}` 或 `low_logprob@{channel}`；一并发送的 `x-grok-exact-repetition-check` 头还会带来 `exact_repetition:{tokens}x{copies}@{channel}` 这类标签。
- `DoomLoopSignalCollector` 逐帧 `absorb` 这些信号(并**吞掉**这种帧，免得污染正常反序列化)。
- **置信判定**很保守：只有 `thinking` 通道、阈值不高于 `max_threshold`(默认 64)的 `tail_repetition` 才算「确信失控」；其它一律只告警不动作。
- 一旦确信，`stream_responses` **中途先 yield 一个 `DoomLoopSignals`、再 yield `Failed{DoomLoopDetected}`，直接断开 SSE**——与其把那段正在烧的复读继续流完，不如立刻重采样。

重采样走的是[流式事件那章](https://daiw.org/manual/grok-build/streaming-events)提过的**独立 doom 预算**(默认 2 次，近乎立即重试，因为失控是随机的)，和传输预算互不消耗。它也不是盲目重来：重试请求会追加一条「输出被判定为循环」的提醒(`RECOVERY_REMINDER`)，失败那轮若没调工具，还会把截断到 8 KB / 4 KB 的推理与正文回放给模型(`doom_loop_recovery.rs`)。预算耗尽后 `disarm_abort()`——不再中断，接受最后一次结果，避免无限重采样。

## 客户端这一侧：动作停滞

服务端盯的是「生成在复读」，shell 的 turn 循环还自己盯着「动作在复读」(`turn.rs` 的 `IdenticalToolCallRun`)：同一组工具调用(不看顺序)参数逐字相同地连发，到第 8 次先注入一条提醒，nudge 它换条路，到第 12 次就以 `TurnOutcome::StationarityEnded` 静默结束这一轮；读文件、写 todo 这类最容易空转的工具门槛更紧(4 次提醒、8 次收尾)，反复执行 `true` 这种空命令的保活轮询连发 4 次也会收尾。

## 和《从 LLM 到 Coding Agent》对照

对照 [interrupt](https://daiw.org/manual/llm-to-agent/interrupt)：最小实现里，打断往往就是一个取消信号。grok 把它拆成了**抢占(硬取消 / send now)+ 协作(软打断)**两套，再加上**服务端 doom-loop** 与**客户端动作停滞**两道自动防线。这几样合起来回答的是同一个产品问题：一个会自己动手、还可能跑飞的 agent，怎么既让用户随时能拽住缰绳，又能自己从失控里爬出来。

至此 Part 1「Agent 运行时内核」完。下一部分进[工具系统](https://daiw.org/manual/grok-build/tool-contract)——grok 那近 15 万行的重头。

---

上一篇：[记忆](https://daiw.org/manual/grok-build/memory)
