08 · 中断与转向:在流式、并发、欠着一堆 tool_result 时干净地叫停
用户想中途喊停,或改主意换个方向。难点在于:此时可能已经发出了 tool_use 却还没产出 tool_result,而历史里每个 tool_use 都必须配对。讲清中断如何合成“已取消”结果、如何把 AbortSignal 贯穿到底,以及“硬中断”与“转向”的区别。
08 · 中断与转向:干净地叫停
权限管的是“该不该让它做”;这一篇管的是一个正交问题:它正做着,用户突然想喊停,或者改主意了,怎么办? 在一个流式、并发、还欠着一堆 tool_result 的局面下,“干净地停下”比想象中难。
一、背景:停下来,不能停出一地烂摊子
用户按下 Ctrl+C 的那一刻,Agent 可能正处于任意状态:
- 模型的响应流到一半。
- 三个工具并行跑着,一个刚写完文件,一个还在
npm install,一个在队列里没轮到。
如果粗暴地一停了之,你会撞上02 篇那条铁律的反面:
历史里每个 tool_use 都必须有配对的 tool_result。 中断时,你已经把带 tool_use 的助手消息放进历史了(模型“说”它要调这些工具),但这些工具还没产出结果。如果就这么停下、下次接着聊,这段历史就是非法的——API 会直接拒绝你的下一个请求。所以中断的核心工作不是“停”,而是“善后”:给每一个悬空的 tool_use,补一个(内容为“已取消”的)tool_result,把历史重新变合法。
二、解决方案:AbortSignal 贯穿 + 合成结果
一根信号,贯穿到底
中断的机制核心是一个 AbortSignal。它必须从最外层一路穿透到每一个可能长时间阻塞的地方:
const controller = new AbortController()
// 1) 传给模型流式调用——abort 时,流会中止
const stream = client.messages.stream({ ... }, { signal: controller.signal })
// 2) 传给每个工具——比如 bash 把它交给子进程,abort 时命令被杀
await execAsync(command, { signal: controller.signal })用户一喊停,controller.abort() 一调,这根信号同时到达“正在生成的模型流”和“正在跑的工具”,两边一起停。
给悬空的工具合成“已取消”结果
停下之后,遍历这一轮所有已发出的 tool_use,给每个还没结果的,合成一个“已取消”的 tool_result:
async function executeTool(tu, ctx): Promise<Outcome> {
// 排在队列里、还没轮到就被中断的工具:直接给一个取消结果,不真正执行
if (ctx.signal.aborted) {
return {
block: { type: 'tool_result', tool_use_id: tu.id, content: '操作已被用户中断', is_error: true },
// ...
}
}
// ...正常执行...
}这样,无论某个工具是“跑到一半被杀”(它自己因 signal 抛错,产出错误结果)还是“还没轮到就被取消”(入口处直接返回取消结果),每个 tool_use 最终都拿到了一个配对的 tool_result。历史保持合法,下次可以无缝继续。
这里能看出06 篇那个流式执行器的设计价值:因为它统一管理着“已派发/在跑/排队中”的所有工具,中断时它能一次性地为这些工具产出善后结果(在跑的等它们因 signal 自然结束,排队的直接标记取消)。把工具执行收拢到一个执行器里,中断善后才有地方统一处理——否则你得满世界追那些散落的 Promise。
三、两种“打断”:硬中断 vs 转向
用户打断 Agent,其实有两种意图,处理方式不同:
| 硬中断(Interrupt) | 转向(Steering) | |
|---|---|---|
| 用户意图 | “停!别干了” | “等等,其实我想让你换个方向” |
| 触发 | Ctrl+C、停止按钮 | 在它跑着时又输入了一句话 |
| 处理 | 善后 → 停在当前回合,等新指令 | 善后 → 把新消息作为下一轮的用户输入接上去 |
转向是“打断 + 追加”,而不是“打断 + 丢弃”。 当用户在 Agent 跑着时敲入新指令,成熟的做法不是丢掉当前进度重来,而是:干净地结束当前回合(合成善后结果),然后把用户这句新话,连同已完成的部分工作,一起作为下一轮的输入。模型于是能“带着上文改方向”——它看得到自己刚才做到哪了,也看得到用户的新要求。这种“边跑边插话调整方向”的能力,是交互式 Agent 体验丝滑的关键。实现上,常配一个消息队列:跑的时候用户输入先入队,当前回合一结束就出队接上。
四、实现细节:中断路径的几个当口
把中断逻辑接进03 篇的循环,要照顾几个当口:
- 流式途中被中断:模型流因 signal 抛错/中止,捕获它,别让它冒泡成“真错误”(它是用户主动行为,不是故障)。
- 工具执行途中被中断:执行器为每个工具产出善后结果(取消/错误),保证配对完整。
- 回合收尾:产出一条“用户中断”的标记消息(让 UI 和后续逻辑知道这是主动中断),然后停在这个回合——不再自动进入下一轮,把控制权交回用户。
try {
// ...流式调用、边流边派发工具...
} catch (e) {
if (signal.aborted) {
const outcomes = await executor.drain() // 收齐所有善后结果
for (const o of outcomes) yield toolResultEvent(o)
yield { type: 'done', reason: 'interrupted' }
return // 停在这一回合
}
throw e // 其它错误,继续往上抛(见下一阶段)
}一个容易搞错的地方:用户主动中断,不是“错误”。 别把它和模型故障、网络超时混在一条错误路径里处理——那会让日志里满是假报警,也会让 UI 弹出误导性的“出错了”。中断是一条独立的、正常的控制流分支,要和真正的错误恢复(下一阶段的主题)分开对待。
五、小结
中断看似是个 UI 小功能,背后却牵动着 Agent 最核心的不变式——历史的合法性。做好它,靠的是三件事:一根贯穿到底的 AbortSignal、一个能统一善后的工具执行器、以及把“用户主动停”和“系统出故障”清清楚楚分开。
到这里,第四阶段“安全与控制”就完整了:权限管住了“能做什么”,中断管住了“何时停下”。我们的 Agent 现在既自主又可控。
但还有一个扩展点没碰:如果用户想在工具执行前后插入自己的逻辑呢?——比如“每次写文件前自动跑一遍格式化”“每次提交前拦一道检查”。下一篇Hooks,我们给 Agent 开一组“可编程的插槽”。
本篇对应代码:配套仓库让 executeTool 感知中断信号并合成取消结果,CLI 用 Ctrl+C 触发本回合中断。git checkout article-08。