2617 字
约 8 分钟
14
S21-A27 · 中断:用户点了"停止",Agent 该停在哪一步

用户点了"停止",界面上的打字机效果立刻不动了。两分钟后,客服收到投诉:"我明明取消了,为什么还是发了三封邮件"。日志里那三封邮件的时间戳全在"取消"之后——因为取消只停掉了前端的流,后端的工具调用队列还在跑。

同一个系统上还有另一类现场:用户点停止、改了个说法重新提问,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. 自测

  1. 中断发生在哪三层?哪一层是用户点"停止"实际只影响到的?
  2. 为什么"断开流"不等于"取消任务"?可靠的取消需要什么前提?
  3. 检查点放在哪里,决定了什么?为什么"不可逆操作前确认"优于"事后补偿"?
  4. 半截输出为什么不能直接入库?它可能污染什么?
  5. 用户中断后重新提问,Agent 需要读什么才能不"接着做旧任务"?
  6. 什么场景不该提供"停止"?判据是什么?

9. 一句话总结

"停止"的语义是"在下一个检查点停下",不是"立刻什么都不做":客户端断流只停渲染,生成层要继续取消(否则成本只降 10 %),而已执行的工具不可回滚——所以不可逆操作必须做成"先确认后执行"。三件必备:可寻址的 run_id + 幂等 cancel 接口、带状态的中断快照(interrupted / 部分完成)、半截输出标 incomplete 再入库。判据:停止按钮只能存在到下一个不可逆点之前。

S21-A27 · 中断:用户点了"停止",Agent 该停在哪一步
https://www.clxhxhhr.top/posts/6737/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。