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

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

作者 David更新于 第 15 篇(共 60 篇)

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

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

打断 vs 取消:协作 vs 抢占

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

  • 硬取消(TUI 里是 Ctrl+C;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」(默认 Ctrl+Enter)也属于这一类:取消当前 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,模型下一圈就看到了。

图表加载中…

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

一个鲁棒性细节:若打断在 turn 已经跑完最后一次排空之后才到,它会「搁浅」。flush_stranded_interjections 兜底把它转成排在队首的 fallback prompt,不会石沉大海。

防失控: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——与其把那段正在烧的复读继续流完,不如立刻重采样。

重采样走的是流式事件那章提过的独立 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:最小实现里,打断往往就是一个取消信号。grok 把它拆成了抢占(硬取消 / send now)+ 协作(软打断)两套,再加上服务端 doom-loop 与客户端动作停滞两道自动防线。这几样合起来回答的是同一个产品问题:一个会自己动手、还可能跑飞的 agent,怎么既让用户随时能拽住缰绳,又能自己从失控里爬出来。

至此 Part 1「Agent 运行时内核」完。下一部分进工具系统——grok 那近 15 万行的重头。


上一篇:记忆

本页目录