← 返回写作
12 分钟阅读

Agent CLI 的事件循环与中断模型:当用户中途按下回车

Agent CLI 的事件循环与中断模型:当用户中途按下回车

Agent CLI 的事件循环与中断模型:当用户中途按下回车

引言:你按下了 Enter,agent 还没说完

你给 Claude Code 一个任务:「重构鉴权模块」。它读了几个文件、改了一段代码、正在跑单测的第 3 秒,你突然想起来一件事:

“等一下,先用 Vite 而不是 Webpack,这点先告诉它”

你噼里啪啦敲完这句话按下回车 —— agent 还在跑,屏幕在不停滚动,你的输入怎么被它”看到”?

如果你试过这个动作,你会发现 Claude Code 和 Codex 都有一个有意思的行为:

  • 你输入的内容不会立刻打断 agent
  • 但底部会出现一个 “Queued” 之类的提示(Codex 里这叫 steer
  • agent 当前 step 跑完会”看到”你的新消息,作为下一轮 input 处理
  • 你想立刻打断,按 Esc 就好;想退出,双击 Ctrl+C

整个体验非常自然。但这套自然背后是一个精心设计的事件循环 + 多层消息队列 + 中断状态机。这篇文章把它全部拆开。

读完你会理解:

  • 为什么 LLM stream 在”哲学上”不可中断
  • 为什么这套系统至少需要两个独立的 loop + 五种 queue 状态
  • Claude Code 和 Codex 各自怎么实现的(带源码引用)
  • 自己做 AI agent CLI 时这套要怎么搭

1. 难题:LLM 哪里不可中断?

直觉上 “中断”是简单的事 —— Ctrl+C 一下杀掉就行。但 LLM agent 有三个层次的”不可中断点”,理解了它们才能理解后面的设计:

LLM agent 的三个不可中断层次:HTTP stream / tool exec / 已完成的副作用

层次 内容 能不能中断 中断的代价
1. LLM HTTP stream 模型在按 token 流式输出 可以(abort HTTP 连接) 已生成的 token 照样计费
2. Tool call 执行 比如在跑 npm install 可以(SIGTERM 进程) 已经下载的包不会撤回;进程可能留 zombie
3. 已完成的副作用 比如已经写完的 package.json 不能 文件已落盘,git diff 看得到

核心原则:**LLM agent 的”中断”只能是”停止下一步”,不能”撤销已经发生的”**。这件事在做产品设计时必须明示给用户 —— 你按 Esc 中断,已经写的代码、已经 push 的 commit、已经发的请求都不会回滚。

第三层是 OS / 业务问题,agent CLI 管不到。第一、二层 agent CLI 必须管,而管法决定了用户体验的好坏。


2. 通用解法:双 loop + 多级 queue

任何能让用户在 agent 干活时正常输入的 CLI 工具,都长成这样:

Agent CLI 通用架构:UI Loop 与 Agent Loop 通过多级 channel 解耦

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
┌────────────────────────────┐                ┌──────────────────────────────┐
│ UI Loop (主线程) │ │ Agent Loop (worker 线程) │
│ ───────────────── │ │ ─────────────────── │
│ │ │ │
│ ratatui event::read() │ │ loop { │
│ ↓ │ │ ① stream LLM tokens │
│ 键盘事件 │ submit │ (可 abort 但已扣费) │
│ ↓ │ ─────────────→ │ ② call tools │
│ TextArea (composer) │ (channel) │ (可 SIGTERM) │
│ ↓ │ │ ③ check queue ◀───────── │
│ Enter / Esc / Ctrl+C │ │ drain steers │
│ ↓ │ interrupt │ handle interrupts │
│ app_event_sender ─────────│ ─────────────→ │ ④ next step or end │
│ │ (op queue) │ } │
└────────────────────────────┘ └──────────────────────────────┘
↑ 永远不阻塞 ↑ 阻塞但有"检查点"
用户随时能打字 在每个 step 边界 drain queue

三个不可绕开的设计要点:

1. UI 线程和 agent 线程必须分离。不分离的话用户键盘输入会被阻塞 —— agent 在 LLM stream 的 30 秒里你按什么都不响应,体验崩。

2. 通过 channel / queue 解耦。Rust 用 tokio::sync::mpsc,Go 用 chan,Node 用 EventEmitter。本质都是 producer-consumer 模型。

3. drain queue 的时机必须在 step 边界。LLM 流式输出中间不能 drain(会破坏对话连贯性),tool call 执行中也不能(会破坏调用语义)。只能在每个 LLM turn 完成后、或每个 tool call 返回后这两类边界点检查 queue。

但这只是骨架。真正复杂的是中间的 queue —— 它不是一个简单的 FIFO,而是多级状态机


3. Codex 的实现:从源码挖出来的真实细节

OpenAI 的 Codex(codex-rs/)在这块做得最复杂、最完整。我把它的设计完整拆开,你能看到工业级实现长什么样。

3.1 一个关键术语:Steer

源码里有个专门的词叫 steer(”引导”),定义是:

Steer = 用户在 agent 还在跑当前 turn 的时候输入的新消息

为什么不叫 “interrupt”?因为 interrupt 是要”中断”,而 steer 是要”引导” —— 用户不想终止 agent 的工作,只是想给它新的方向。命名透露了设计意图。

源码里 grep pending_steers 能看到一连串相关概念(codex-rs/tui/src/chatwidget.rs):

1
2
3
4
5
6
7
8
9
10
11
12
// 用户输入但 turn 还没开始的(已 queue,未 send)
queued_user_messages: VecDeque<QueuedUserMessage>,

// User messages that tried to steer a non-regular turn and must be retried first.
rejected_steers_queue: VecDeque<UserMessage>,

// Steers already submitted to core but not yet committed into history.
pending_steers: VecDeque<PendingSteer>,

// 中断后的标志:要立刻把 pending steers 作为新 turn 重提,
// 还是回到 composer 让用户继续编辑
submit_pending_steers_after_interrupt: bool,

Codex 的 steer 至少有 3 个状态,加上正常的 queued / committed,组成一个 5 状态的状态机:

Codex Steer 的 5 状态生命周期:queued → pending → committed / rejected

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
                          [Composer 输入]

不在 turn 中 │ 在 turn 中
┌────┴────┐
▼ ▼
┌─────────────┐ ┌─────────────────┐
状态 ① │ queued_user │ │ pending_steers │ 状态 ③
│ _messages │ │ │
│ (等 turn) │ │ (已发 core, │
└──────┬──────┘ │ 未 commit) │
│ └────┬────────────┘
turn 开始 │ │
▼ │ turn complete
┌─────────────┐ ▼
状态 ② │ in flight │ ┌─────────────────┐
│ (正在执行) │ │ committed │ 状态 ④
└─────────────┘ │ history.append() │
└─────────────────┘

非 regular turn 拒绝 ↘
┌─────────────────┐
│ rejected_steers │ 状态 ⑤
│ _queue │
│ (等下一个 regular
│ turn 重试) │
└─────────────────┘

rejected_steers_queue 是一个非常聪明的设计 —— 不是所有 turn 都接受 steer。比如 review turn / plan turn 这种”严肃模式”下用户的 steer 会被暂存,等下一个普通 turn 才重提。这避免了 steer 污染严肃任务的上下文

3.2 InterruptManager:另一类 queue

Codex 还有一个独立的 queue 叫 InterruptManagercodex-rs/tui/src/chatwidget/interrupts.rs):

1
2
3
4
5
6
7
8
9
10
11
12
13
pub(crate) enum QueuedInterrupt {
ExecApproval(ExecApprovalRequestEvent),
ApplyPatchApproval(ApplyPatchApprovalRequestEvent),
Elicitation { request_id, params },
RequestPermissions(RequestPermissionsEvent),
RequestUserInput(ToolRequestUserInputParams),
ItemStarted(ThreadItem),
ItemCompleted(ThreadItem),
}

pub(crate) struct InterruptManager {
queue: VecDeque<QueuedInterrupt>,
}

这个 queue 缓存的不是用户输入,而是**”agent 想打断用户却需要暂存的事件”** —— 比如执行命令需要批准、apply patch 需要批准、MCP 需要 elicit 输入等等。

为什么要单独排队?文件开头注释写得明明白白:

Queue prompt overlays and deferred tool activity while another interrupt is visible.

意思是:如果当前已经有一个弹窗(比如另一个 approval 正在等用户点)了,新的 approval 不能直接弹出来盖住它,而是排队等前一个处理完。这避免了 UI 上多个对话框互相覆盖、用户搞不清在同意什么的混乱。

3.3 Op::Interrupt:硬中断走另一条路

用户按 Esc / Ctrl+C 走的是完全不同的通道。这是协议级别的一个独立 Op:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// codex-rs/protocol/src/protocol.rs
pub enum Op {
/// Abort current task without terminating background terminal processes.
/// This server sends [`EventMsg::TurnAborted`] in response.
Interrupt,

/// Terminate all running background terminal processes for this thread.
/// Use this when callers intentionally want to stop long-lived background shells.
CleanBackgroundTerminals,

UserInput { items: Vec<UserInput>, ... },
UserTurn { ... },
...
}

注意两个细节:

  • Op::Interrupt 不杀后台 terminal 进程。如果你之前让 agent 起了个 npm run dev 跑后台,Esc 中断当前 turn 不会杀掉它。要清理需要显式 Op::CleanBackgroundTerminals
  • **响应是 EventMsg::TurnAborted**。这是一个事件,TUI 收到后调 on_interrupted_turn(reason),根据 submit_pending_steers_after_interrupt 标志位决定下一步:
    • 如果是 true(用户先 steer 后 Esc),把 pending steers 作为一个新 turn 自动 submit,外加一条 history note:”Model interrupted to submit steer instructions.”
    • 如果是 false(用户直接 Esc),保留 pending steers 在 composer 里让用户继续编辑

3.4 ChatComposer:底部输入框

底部那个输入框叫 ChatComposercodex-rs/tui/src/bottom_pane/chat_composer.rs),核心是一个 ratatui TextArea

1
2
3
4
5
6
7
8
pub(crate) struct ChatComposer {
textarea: TextArea,
textarea_state: RefCell<TextAreaState>,
is_bash_mode: bool,
active_popup: ActivePopup,
app_event_tx: AppEventSender,
...
}

它的职责注释也很明确:

  • Editing the input buffer (a [TextArea])
  • Routing keys to the active popup (slash commands, file search, skill mentions)
  • Promoting typed slash commands into atomic elements
  • Handling submit vs newline on Enter

最后一条很关键:Enter 键的歧义性 —— 是提交还是换行?大部分 CLI 工具的策略:单 Enter 提交,Shift+Enter 换行(或反过来,可配置)。Codex 用 keymap 系统(tui/src/keymap.rs)让用户自定义。


4. Claude Code 的实现:相对轻量

Claude Code 没开源到这种程度,但从用户视角和文档能推断出它的实现框架。

Claude Code(轻量)vs Codex(精细)的中断模型对比

4.1 输入排队的可见信号

你在 Claude Code agent 跑的时候输入并回车,底部会出现类似这样的提示:

1
2
> 等一下,先用 Vite 不要 Webpack
(queued — will be sent after current turn)

这告诉你:消息已经入队,会在当前 turn 结束后作为新 turn 发送。

如果你在排队期间继续输入,新消息会追加到 queue(不会替换上一条),这点和 Codex 一致。

4.2 Esc / Ctrl+C 的语义

按键 行为
Esc(一次) 中断当前 LLM stream,turn 标 aborted,对话保留。等价于 Codex 的 Op::Interrupt
Ctrl+C(一次) 同上 + 提示 “Press Ctrl+C again to exit”
Ctrl+C(再一次) 退出整个进程

这种”双击退出”的设计是 vim、less、tmux 这些经典 CLI 的标准做法,避免误操作丢失对话状态。

4.3 后台任务的”礼貌打断”

Claude Code 还有一个细节:当后台 subagent 完成 / 后台 bash 命令产生输出时,不会立刻打断主 agent,而是用 <task-notification> 信封把通知留到主 agent 下一个自然的 step 边界:

1
2
3
4
5
<task-notification>
<task-id>byfhr211c</task-id>
<status>completed</status>
<summary>Background command "..." completed (exit code 0)</summary>
</task-notification>

主 agent 在自己的 LLM 一段说完后(不是 stream 中间)才会读到这个事件。这就是”礼貌打断” —— 不打断 agent 当前的思维链,等它自然停下来再插话。

4.4 一个 Claude Code 没暴露但有的机制:Cancellation Token

虽然源码看不到,但行为可以推断:当用户 Esc 后,Claude Code 内部一定有一个 cancellation token / abort signal,被多处尊重:

  • LLM HTTP stream 检查到 token cancelled → abort 连接
  • Tool call 还在跑的话 → SIGTERM 子进程
  • 后续 step 不再执行

这是 Rust tokio::select! + CancellationToken 或 Node AbortController 的标准用法,每个做这类系统的人都得自己搭一遍。


5. 三种”打断”语义对比

把上面所有概念归纳成一张表,你能看清”打断”在 agent CLI 里到底有几种含义:

Steer / Hard Interrupt / Exit 三种打断语义的层级对比

用户操作 通用术语 Codex 术语 UI 表现 内部机制 副作用
回车提交新消息 软中断 / 排队 steer “Queued” 提示 pending_steers queue,下个 step 边界 drain 0(不打断当前 turn)
Esc / 单 Ctrl+C 硬中断 Op::Interrupt “Interrupting…” TurnAborted 事件 + cancellation token + LLM stream abort 已生成 token 计费,已写文件保留
双 Ctrl+C 退出 SIGINT 进程退出 OS-level signal 整个会话需要重新加载
/clear / /exit 等命令 显式重置 / 关闭 slash command 命令执行 走 composer 的特殊路径,不进 LLM 看具体命令

5.1 一个微妙的区别:steer vs interrupt-then-input

用户的两种操作流程结果可以完全不同

流程 A(pure steer):

1
agent 在跑 → 用户输入 "用 Vite" → 回车 → 等 agent 自然结束当前 turn → 用户输入作为新 turn 处理

流程 B(interrupt + input):

1
agent 在跑 → 用户输入 "用 Vite" → 按 Esc → agent 立刻 abort → 用户输入作为新 turn 处理

A 等了几秒到几分钟;B 立刻打断。哪个更好?取决于场景:

  • 如果你只是补充信息(agent 当前思路没问题),用 A
  • 如果你想换方向(agent 当前思路完全错),用 B

Codex 的 submit_pending_steers_after_interrupt 标志位就是为了让 B 这种”先输入再 Esc”的连贯操作更顺 —— 用户按 Esc 时如果 pending_steers 不空,自动把它们打包提交,不需要再按一次回车。这是一个非常贴心的细节。


6. 三个有意思的边界 case

6.1 用户连发 3 条怎么办

用户着急时会按多次回车。处理策略主要有三种:

策略 谁这么做 优缺点
全部排队,按顺序处理 Claude Code、Codex 不丢消息,但用户多次按回车会让 agent 多跑几个 turn
合并成一条提交 部分轻量 CLI 节省 token,但合并语义可能错乱
只取最后一条 一些聊天产品 简单但前面消息丢了,用户困惑

Codex 选了第一个,看 pending_steers: VecDeque<PendingSteer> 的实现就知道是顺序队列。Claude Code 也是第一个。这是更”诚实”的做法 —— 用户输入了什么都给 agent 看,让 agent 自己判断。

但 Codex 在中断后做了合并优化submit_pending_steers_after_interrupt 触发时,多个 pending steer 是作为一个新 turn一起提交,不是 N 个 turn。这避免了用户因连发而被多收 N 倍 token 费。

6.2 “补充信息” vs “取消任务”——agent 怎么判断?

这是个困难的问题。用户说 “用 Vite” 可能是:

  • 补充:”还在做迁移,但请用 Vite” → 继续上个任务
  • 取消并改方向:”不要做 Webpack 了,用 Vite 重新来” → 抛弃之前的工作

Agent CLI 工具本身判断不了,只能依赖 LLM 自己理解。两家工具的策略:

  • Claude Code:在系统 prompt 里训练 / 引导,让 LLM 看到新 user message 时优先判断意图。如果 ambiguous,会问一句澄清。
  • Codex:类似。但 collaboration mode 不同表现也不同 —— Plan mode 下 LLM 倾向把新输入当”补充计划要求”,Execute mode 下倾向”调整执行方向”。

这部分是 LLM 工程的范畴,不是 CLI 工程能解决的。好的设计是让用户能看到 agent 的判断并随时纠正(Codex 的 InterruptedTurnNoticeMode 就是干这个,给一行提示告诉用户”我在执行你新的引导消息”)。

6.3 后台 subagent 完成时如何”礼貌”打断主 agent

这是 multi-agent 场景下的特有问题(参考 24 篇 Multi-Agent 拆解)。

主 agent 在 LLM stream 中间,后台 subagent 完成了任务想报告结果。两种处理:

粗暴打断:subagent 完成立刻给主 agent 发新消息 → 中断主 agent stream → 让它去看 subagent 结果。问题:主 agent 思路被打断,可能写出来的东西支离破碎。

礼貌打断:subagent 完成时把通知入 queue,主 agent 当前 step 自然结束后才看到。优点:主 agent 思维连贯,subagent 结果作为下一轮输入处理。两家工具都选了这个

Codex 的 InterruptManager 队列里有 ItemStarted / ItemCompleted 这两种事件,就是为这个场景准备的 —— 不是马上插入 UI,等当前写入周期结束再批量处理。


7. 设计原则总结

把上面所有细节抽象出来,做 agent CLI 的人能学到这些原则:

1. UI 和 agent 必须双 loop。同一个 loop 里跑 LLM 调用 + 处理键盘事件,必崩。Rust 用 tokio task / Go 用 goroutine / Node 用 worker thread / Python 用 asyncio task 都行,关键是分开。

2. Queue 不是一层,而是多层状态机。至少要分清 “user 提交了但 turn 没起” / “已发到 core 但未 commit” / “已 reject 等重试” 这几个状态。简单的 VecDeque<Message> 在生产场景下会出 bug。

3. Drain 时机必须在自然边界。LLM stream 中间不 drain、tool call 中间不 drain,只在 step 边界 drain。这是保证对话连贯的核心约束。

4. Hard interrupt 是独立通道。不要用”特殊 user message”模拟中断,要用专门的 Op::Interrupt + cancellation token。两条通道语义不一样,混在一起一定混乱。

5. UI 中断也要排队。不仅用户输入要排队,agent 想弹给用户的 approval / permission 请求也要排队。同时只能展示一个对话框给用户看。

6. 起一个有意义的术语。Codex 给 “用户中途插话” 起名 steer(引导)而不是 interrupt(中断),是一个非常成熟的产品决定 —— 命名定义了行为。在你做 agent CLI 时,这种概念清晰度非常重要。

7. 礼貌优于及时。后台事件(subagent 完成、文件变化、网络回调)默认应该等到主 agent 自然边界再插入,不要每次有事就打断。打断成本高于延迟成本。

8. 副作用不可逆,要明示用户。Esc 不会撤销已写的代码、已发的请求、已计费的 token。这个事实必须在 UI 上让用户感知到,否则用户会按 Esc 然后期待”什么都没发生”。


8. 自己做 agent CLI 时的 checklist

如果你打算做一个 agent CLI 工具(哪怕是内部工具),这套机制不是 nice-to-have,是 must-have。从最小到完整的清单:

阶段 必须实现
MVP 双 loop(UI + agent)+ 单 channel + Ctrl+C 退出
Beta 软中断(steer queue)+ 硬中断(cancellation token)+ “queued” UI 提示
生产 多状态 queue(queued / pending / committed / rejected)+ InterruptManager(UI 事件排队)+ 双击退出 + steer 后 Esc 自动 submit
企业级 后台事件礼貌打断 + 跨 turn 类型的 steer 路由(regular / plan / review)+ slash command 独立通道 + 完整 audit log

少一层都会让用户体验崩在某个具体场景里 —— 而那种”用 5 分钟就出 bug”的体验,会让你的工具被用户立刻弃用。


结语:好的事件循环让用户感觉不到事件循环

回到文章开头那个场景:你在 agent 干活时按下回车输入”用 Vite 不要 Webpack”。

如果工具做对了,你的体验是:输入立刻显示在底部、agent 没有被你打断继续干当前的事、几秒后 agent 自然停下来 acknowledge 你的引导、按你新意图继续。整个过程你不需要思考”要不要按 Esc / 我的输入会不会丢 / agent 会不会乱”。

如果工具做错了,你会遇到:输入卡住等 agent 结束才显示 / agent 写到一半被你打断输出错乱 / 你按 Ctrl+C 整个会话没了 / 多次输入只生效最后一条。每个 bug 单独看都不致命,加起来用户就走了。

事件循环和中断模型是 agent CLI 的”地基” —— 用户感受不到它的存在,但它崩了一切都崩。Claude Code 和 Codex 这两家把它做到接近零摩擦的水平,背后是大量的状态机设计、队列分级、边界处理。理解了这些,你才能做出同样不让用户感到”啊好烦”的工具。


参考资料

Ask Leslie

从本站公开文章中寻找答案。当前版本在浏览器本地检索,不上传问题,也不会编造不存在的经历。

输入一个问题,我会把你带到 Leslie 写过的相关内容。

微信联系

Leslie Zhang 的微信二维码

扫码添加 Leslie,建议备注你的名字与来意。