用户点了"停止",界面上的打字机效果立刻不动了。两分钟后,客服收到投诉:"我明明取消了,为什么还是发了三封邮件"。日志里那三封邮件的时间戳全在"取消"之后——因为取消只停掉了前端的流,后端的工具调用队列还在跑。
同一个系统上还有另一类现场:用户点停止、改了个说法重新提问,Agent 回了一句"我刚才已经帮你下单了,还需要再下吗"。中断的真实语义不是"停止输出",是"在哪个检查点停下来",而检查点在哪、哪些动作不可回滚,决定了这个功能能不能做对。
1. 中断发生在三层,只有一层是你能控制的
| 层 | 用户看到 | 实际发生 | 可控性 |
|---|---|---|---|
| 客户端 | 文字停止增长 | 断开流 / 不再渲染 | 全可控 |
| 服务端生成 | 无感知 | 模型继续生成到结束(除非取消) | 需显式传递取消 |
| 工具执行 | 无感知 | 已发出的请求继续执行 | 通常不可撤销 |
第二行是最常见的资源浪费:一次 3,000 token 的生成,用户在第 200 个 token 时点了停止,后端仍在跑完剩下的 2,800 个——账单照付,只是没人看。
第三行是最常见的业务事故:send_email、charge_card、create_order 这类工具一旦发出,取消不了。而用户点"停止"的意图通常是"别继续了",不是"把我已经做的事也撤了"——系统必须区分这两件事,并向用户说清楚。
// 一次点"停止"后,实际仍在继续的三件事
t+0.0s 用户点停止 → 浏览器断开 SSE
t+0.1s 服务端收到 write 失败 → 若未处理,循环继续
t+0.4s 模型生成完剩余 2800 token → 计费完成
t+1.2s 工具队列里的 send_email 执行 → 邮件发出(不可回滚)
// 结论:只有"客户端停止渲染"是 100% 生效的
2. 断连不等于取消
HTTP/SSE 断开只表示"对端不再接收",它不携带任何"请停止"的语义。服务端要么主动检测写失败并中止,要么把取消当成一个显式的协议动作。
| 做法 | 能否真正停止 | 代价 |
|---|---|---|
| 只关前端流 | 不能 | 后端跑完、费用照付 |
| 检测写失败后中止 | 能(生成层) | 需要捕获 IO 异常 |
| 显式 cancel 接口(另发请求) | 能(全链路) | 需要任务 id 与取消令牌 |
| 超时兜底 | 部分 | 停止时间不可控 |
第三行是唯一可靠的方案:给每个任务一个可寻址的 id,取消是一次独立的、可重放的请求。这样即使用户网络抖动、流已经断了,取消动作仍然能送达。
// 取消的协议形状(三步,缺一不可)
POST /runs/{run_id}/cancel → 202,返回当前阶段
GET /runs/{run_id} → {status: cancelled|cancelling|finished}
// 关键:cancel 是幂等的,重复点"停止"不应产生多个取消事件
3. 检查点决定"停在哪一步"
中断的颗粒度不是"立刻",而是"最近的一个安全检查点"。检查点之间做的事,都会被完整执行。
| 检查点位置 | 停下时已发生 | 适合 |
|---|---|---|
| 每次模型调用前 | 上一轮工具已执行完 | 通用 |
| 每次工具调用前 | 模型输出已产生(浪费) | 副作用敏感 |
| 每个"不可逆操作"前 | 需要用户确认 | 支付、发送、删除 |
| 无检查点(只在末尾) | 全部做完 | 不可中断任务 |
第三行是关键设计:把不可逆操作做成"先确认后执行",而不是"执行后补偿"。因为补偿往往做不到(邮件发出去收不回,订单取消要退款流程)。
// 检查点 + 不可逆操作前确认:两层拦截
for step in plan:
if cancel_token.is_set(): // 1. 每个步骤前检查
return Stopped(at=step.name, partial=state)
if step.irreversible:
await ask_user_confirm(step) // 2. 不可逆操作前强制确认
if cancel_token.is_set():
return Stopped(at=step.name, partial=state)
state = await step.run()
// 注意:step 内部若已发出外部请求,这里拦不住——只能在设计上让 step 尽量原子
4. 半截输出必须被标记,不能当成品
用户中断时,Agent 往往已经产出半段文本。这段文本的处理决定了后续很多问题。
| 处理方式 | 后果 | 建议 |
|---|---|---|
| 当完整答案入库 | 后续检索命中半截内容 | 禁止 |
| 丢弃 | 用户看着内容消失,体验差 | 可接受(前端保留) |
保存但标记 incomplete |
可恢复、可续写 | 推荐 |
| 保存原始事件流 | 可回放、可审计 | 成本高但最完整 |
第三行是平衡点:库里存 status=interrupted,下次同一会话可以"继续"而不是"重来"。第四行在需要审计的场景才有必要。
// 污染链:中断产物怎么跑到下一次回答里
1 用户中断 → 库里存 status=interrupted, content="…已查到 3 份制度…"
2 索引/语义缓存未区分状态 → 当作正常答案收下
3 下一次相似提问检索到它 → 回答引用半截结论
// 修法:入库必带 status,检索侧过滤 status=complete
这里有个容易忽略的连锁:如果半截答案进了语义缓存或 RAG 索引,下一次相似问题可能检索到这段不完整内容——中断的产物污染了知识库。所以"入库"这一动作必须带状态字段。
5. 中断之后的对话:状态必须说清楚
用户停止之后再说话,Agent 最容易犯的错是"装作什么都没发生",继续按原计划往下走。
| 场景 | 错误回应 | 正确做法 |
|---|---|---|
| 用户停止后问"你刚才做了什么" | 继续下一步 | 汇报已完成/未完成清单 |
| 用户停止后改需求 | 沿用旧计划 | 明确"旧计划已废除" |
| 用户停止后什么也没说 | 沉默等待 | 保留可续写状态 |
| 用户停止后又点停止 | 重复取消 | 幂等,返回同一状态 |
第一、二行合起来是一个原则:中断必须留下一个显式的"计划状态"(进行中 / 已停止 / 部分完成),Agent 的下一步动作由这个状态决定,而不是由对话文本推断。
// 一个够用的中断快照(存在会话状态里,而不是拼进 prompt)
{"run_id": "r-8821", "status": "interrupted",
"plan": ["search_docs", "summarize", "send_email"],
"done": ["search_docs", "summarize"],
"pending": ["send_email"],
"irreversible_done": [],
"partial_output": "…已查到 3 份制度…"}
// 下次对话先读这个快照,再决定"续写 / 重来 / 只汇报"
6. 排错:五种"停了没停住"
| 现象 | 八成原因 | 处理方向 |
|---|---|---|
| 点了停止,副作用仍发生 | 取消了流,没取消任务 | 显式 cancel + 检查点 |
| 停止后费用没降 | 生成层继续跑完 | 检测写失败或取消令牌 |
| 重新提问后 Agent 接着做旧任务 | 无计划状态快照 | 落 status 并读它 |
| 停止按钮点两次报错 | 取消不幂等 | 幂等 cancel 接口 |
| 续写时上下文错乱 | 用了半截输出当输入 | 标记 incomplete 并显式拼接 |
第二行值得单独量化:一次长回答 3,000 token,用户平均在第 300 token 停止——如果没有生成层取消,成本只降 10 %,而不是降到 10 %。这类浪费在成本报表里表现为"用户活跃度下降但成本不降"。
7. 边界:什么情况不该给"停止"
| 场景 | 为什么不该 | 做法 |
|---|---|---|
| 支付/转账的第二步 | 中断会产生不确定状态 | 只允许"完成"或"不开始" |
| 已提交的批量任务 | 中途停在半批更难收拾 | 提供"暂停"而非"终止" |
| 数据库迁移/DDL | 中断可能留下中间态 | 走事务或可回滚的批处理 |
| 长事务的写入阶段 | 回滚成本高 | 先完成当前原子单元 |
判断口径一句话:"停止"只应该存在到"下一个不可逆点"为止——所以设计顺序是"先标出不可逆点,再决定在哪里放停止按钮",而不是先做按钮再想它能不能停住。反过来做,就会出现开头那三封邮件。
8. 自测
- 中断发生在哪三层?哪一层是用户点"停止"实际只影响到的?
- 为什么"断开流"不等于"取消任务"?可靠的取消需要什么前提?
- 检查点放在哪里,决定了什么?为什么"不可逆操作前确认"优于"事后补偿"?
- 半截输出为什么不能直接入库?它可能污染什么?
- 用户中断后重新提问,Agent 需要读什么才能不"接着做旧任务"?
- 什么场景不该提供"停止"?判据是什么?
9. 一句话总结
"停止"的语义是"在下一个检查点停下",不是"立刻什么都不做":客户端断流只停渲染,生成层要继续取消(否则成本只降 10 %),而已执行的工具不可回滚——所以不可逆操作必须做成"先确认后执行"。三件必备:可寻址的 run_id + 幂等 cancel 接口、带状态的中断快照(interrupted / 部分完成)、半截输出标 incomplete 再入库。判据:停止按钮只能存在到下一个不可逆点之前。