# 08 · 中断与转向：在流式、并发、欠着一堆 tool_result 时干净地叫停

> 用户想中途喊停，或改主意换个方向。难点在于：此时可能已经发出了 tool_use 却还没产出 tool_result，而历史里每个 tool_use 都必须配对。讲清中断如何合成“已取消”结果、如何把 AbortSignal 贯穿到底，以及“硬中断”与“转向”的区别。

- 作者：David（道雾轩）
- 专栏：从 LLM 到 Coding Agent（https://daiw.org/manual/llm-to-agent.md）
- 最后更新：2026-08-03
- 原文：https://daiw.org/manual/llm-to-agent/interrupt
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

# 08 · 中断与转向：干净地叫停

权限管的是“该不该让它做”；这一篇管的是一个正交问题：**它正做着，用户突然想喊停，或者改主意了，怎么办？** 在一个流式、并发、还欠着一堆 `tool_result` 的局面下，“干净地停下”比想象中难。

## 一、背景：停下来，不能停出一地烂摊子

用户按下 Ctrl+C 的那一刻，Agent 可能正处于任意状态：

- 模型的响应**流到一半**。
- 三个工具**并行跑着**，一个刚写完文件，一个还在 `npm install`，一个在队列里没轮到。

如果粗暴地一停了之，你会撞上[02 篇](https://daiw.org/manual/llm-to-agent/tool-use)那条铁律的反面：

<Callout type="warn">
  **历史里每个 `tool_use` 都必须有配对的 `tool_result`。** 中断时，你已经把带 `tool_use` 的助手消息放进历史了(模型“说”它要调这些工具)，但这些工具**还没产出结果**。如果就这么停下、下次接着聊，这段历史就是**非法的**——API 会直接拒绝你的下一个请求。所以中断的核心工作不是“停”，而是“**善后**”：给每一个悬空的 `tool_use`，补一个(内容为“已取消”的)`tool_result`，把历史重新变合法。
</Callout>

## 二、解决方案：AbortSignal 贯穿 + 合成结果

### 一根信号，贯穿到底

中断的机制核心是一个 `AbortSignal`。它必须从最外层**一路穿透**到每一个可能长时间阻塞的地方：

```ts title="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`:

```ts title="工具执行入口先看信号:已中断就直接产出取消结果"
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`**。历史保持合法，下次可以无缝继续。

<Callout type="info">
  这里能看出[06 篇](https://daiw.org/manual/llm-to-agent/streaming-tool-execution)那个流式执行器的设计价值：因为它统一管理着“已派发/在跑/排队中”的所有工具，中断时它能一次性地为这些工具产出善后结果(在跑的等它们因 signal 自然结束，排队的直接标记取消)。**把工具执行收拢到一个执行器里，中断善后才有地方统一处理**——否则你得满世界追那些散落的 Promise。
</Callout>

## 三、两种“打断”：硬中断 vs 转向

用户打断 Agent，其实有两种意图，处理方式不同：

| | 硬中断(Interrupt) | 转向(Steering) |
| --- | --- | --- |
| 用户意图 | “停！别干了” | “等等，其实我想让你换个方向” |
| 触发 | Ctrl+C、停止按钮 | 在它跑着时又输入了一句话 |
| 处理 | 善后 → 停在当前回合，等新指令 | 善后 → 把新消息作为**下一轮的用户输入**接上去 |

<Callout type="info">
  **转向是“打断 + 追加”，而不是“打断 + 丢弃”。** 当用户在 Agent 跑着时敲入新指令，成熟的做法不是丢掉当前进度重来，而是：干净地结束当前回合(合成善后结果)，然后**把用户这句新话，连同已完成的部分工作，一起作为下一轮的输入**。模型于是能“带着上文改方向”——它看得到自己刚才做到哪了，也看得到用户的新要求。这种“边跑边插话调整方向”的能力，是交互式 Agent 体验丝滑的关键。实现上，常配一个**消息队列**：跑的时候用户输入先入队，当前回合一结束就出队接上。
</Callout>

## 四、实现细节：中断路径的几个当口

把中断逻辑接进[03 篇](https://daiw.org/manual/llm-to-agent/agent-loop)的循环，要照顾几个当口：

1. **流式途中被中断**：模型流因 signal 抛错/中止，捕获它，别让它冒泡成“真错误”(它是用户主动行为，不是故障)。
2. **工具执行途中被中断**：执行器为每个工具产出善后结果(取消/错误)，保证配对完整。
3. **回合收尾**：产出一条“用户中断”的标记消息(让 UI 和后续逻辑知道这是主动中断)，然后**停在这个回合**——不再自动进入下一轮，把控制权交回用户。

```ts title="循环里的中断处理(简化)"
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                                       // 其它错误,继续往上抛(见下一阶段)
}
```

<Callout type="warn">
  一个容易搞错的地方：**用户主动中断，不是“错误”。** 别把它和模型故障、网络超时混在一条错误路径里处理——那会让日志里满是假报警，也会让 UI 弹出误导性的“出错了”。中断是一条**独立的、正常的**控制流分支，要和真正的错误恢复([下一阶段](https://daiw.org/manual/llm-to-agent)的主题)分开对待。
</Callout>

## 五、小结

中断看似是个 UI 小功能，背后却牵动着 Agent 最核心的不变式——**历史的合法性**。做好它，靠的是三件事：一根贯穿到底的 `AbortSignal`、一个能统一善后的工具执行器、以及把“用户主动停”和“系统出故障”清清楚楚分开。

到这里，**第四阶段“安全与控制”就完整了**：权限管住了“能做什么”，中断管住了“何时停下”。我们的 Agent 现在既自主又可控。

但还有一个扩展点没碰：如果用户想在**工具执行前后**插入自己的逻辑呢？——比如“每次写文件前自动跑一遍格式化”“每次提交前拦一道检查”。下一篇[Hooks](https://daiw.org/manual/llm-to-agent/hooks)，我们给 Agent 开一组“可编程的插槽”。

---

**本篇对应代码**：配套仓库让 `executeTool` 感知中断信号并合成取消结果，CLI 用 Ctrl+C 触发本回合中断。`git checkout article-08`。
