Claude Code Agent Loop 研究系列(08)—— Interrupt:从 Ctrl-C 到合成 tool_result

💡 原文中文,约6200字,阅读约需15分钟。
📝

内容提要

本文介绍Claude Code Agent Loop的中断机制。用户按Ctrl-C时,通过AbortController贯穿全loop,在HTTP请求、streaming到tool执行间隙、tool batch间隙三个检查点响应中断。中断后合成缺失的tool_result保持消息结构完整,用signal.reason区分纯打断和提交新消息两种意图,streaming中断保留部分输出,MCP清理仅主线程执行。

🔎

延伸解读

为什么用单个 AbortController 而不是多个

Claude Code 在每次 loop 启动时创建一个 AbortController,并让所有并行动作共享同一个 signal。这样用户按一次 Ctrl-C,所有正在进行的操作(如 HTTP 请求、多个工具执行、权限批准 Promise 等)会同时收到中断信号。如果为每个动作单独创建 controller,就需要逐个 abort,容易遗漏,导致某些动作继续运行,破坏消息结构。这种设计简化了中断传播,确保一致性。

中断后如何保持消息结构完整

中断发生时,如果某些工具还在执行,它们的 tool_use 块可能已经存在于消息数组中,但对应的 tool_result 尚未生成,这会破坏配对不变量,导致下次调用 LLM 时返回 400 错误。Claude Code 通过扫描所有 in-flight 的 tool_use,为它们合成假的 tool_result(内容为 'Interrupted by user',is_error 为 true),从而在结构上补全消息数组,避免后续请求失败。这种合成机制与之前提到的 ensureToolResultPairing

两种中断语义:纯打断与提交新消息

用户按 Ctrl-C 可能有两种意图:一是单纯想停止 loop,观察当前进度;二是想中断后立即提交新消息,补充信息。Claude Code 通过 signal.reason 来区分这两种情况:普通中断(reason 为 'ctrl-c')会合成 'Interrupted by user' 消息;而提交中断(reason 为 'interrupt')则不合成,因为用户即将提交的新消息本身就是上下文补充,再添加冗余消息反而多余。这种设计让中断后的行为更符合用户预期。

Streaming 中断保留部分输出的设计取舍

在流式返回阶段中断时,已经收到的 SSE 事件会被保留,即使消息不完整(没有 tool_use 或 stop_reason),也会追加到消息数组中。这样做的原因是用户按 Ctrl-C 往往是因为看到 LLM 说了错误的内容,保留这些部分输出可以让用户在后续对话中引用(例如“你刚才说的 X 是错的”)。如果丢弃,用户就失去了这个上下文。虽然保留不完整消息可能让历史看起来不整洁,但 Claude Code 选择了保留,以支持用户的中断意图。

Q&A

Claude Code 中用户按 Ctrl-C 中断 loop 时,系统是如何保证消息数组结构完整的?

中断触发后,loop 会扫描所有 in-flight 的 tool_use,并为缺失 tool_result 的工具合成一个假的 tool_result,内容为 'Interrupted by user',并标记 is_error: true。这样保证了 tool_use 和 tool_result 的配对不变量,避免下次调用 LLM 时因结构不完整而报 400 错误。

Claude Code 的 AbortController 是如何贯穿整个 loop 的?为什么只用一个 controller?

Claude Code 在每次 loop 启动时创建一个新的 AbortController,并放在 QueryEngine 上,整个 loop 共享。用户按 Ctrl-C 时,UI 层调用 interrupt() 触发 abort(),所有并行动作(如 HTTP 请求、工具执行、权限批准等)都通过同一个 signal 同时收到中断信号。只用一个 controller 可以确保一次 abort 就能中断所有并行动作,避免使用多个 controller 时遗漏某些操作。

Claude Code 在 loop 的哪些位置检查中断信号?为什么选择这些位置?

Claude Code 在三个关键位置检查 signal.aborted:1) HTTP 请求层,通过将 signal 传给 fetch,使请求立即中断;2) Streaming 结束到 tool 执行之间,如果已中断则不启动工具;3) Tool batch 之间,如果已中断则不启动下一批。选择这些位置是因为 signal 是协作式的,IO 操作能立即响应,但非 IO 操作必须主动检查。这些检查点位于每个“要开始新工作”的位置,确保在启动新任务前能响应中断。

Claude Code 如何区分用户按 Ctrl-C 是单纯打断还是打断后提交新消息?

Claude Code 通过 signal.reason 来区分两种中断意图。普通 abort 时 signal.reason 为 'ctrl-c',表示单纯打断,会合成 'Interrupted by user' 消息;提交 abort 时 signal.reason 为 'interrupt',表示打断后要提交新消息,此时不合成 'Interrupted by user',因为用户的新消息本身就是上下文补充,避免冗余。

在 streaming 过程中中断,Claude Code 如何处理已经收到的部分输出?

在 streaming 过程中中断,Claude Code 会保留已经收到的部分输出。例如,如果已经收到 3 个 delta,这 3 段 text 会累积到 assistant 消息中,并追加到 messages 数组,即使没有 tool_use 和 stop_reason。这样用户可以看到 loop 走到哪里,并在下次对话中引用之前的内容。

为什么 Chicago MCP 清理只在主线程中断时执行,而 sub-agent 中断时不执行?

因为 sub-agent 是主线程 loop 内部启动的,它与主线程共享同一个 MCP server 连接。如果 sub-agent 中断时清理连接,可能会影响主线程后续对 MCP 的使用。因此,只有主线程 loop 被中断时才执行清理,sub-agent 中断不触发,以保持连接的可用性。

Claude Code 的中断机制与权限批准、maxTurns 机制是如何互补的?

权限批准是 loop 主动等待用户在危险操作时拍板;interrupt 是用户主动打断 loop,想停就停;maxTurns 是硬保险,达到上限自动停止,无需用户参与。三者共同保证了 loop 自动运行不会失控。

🏷️

标签

➡️

继续阅读