生产级 Agent 的“停止”到底怎么做:取消、工具收尾与中途插话

很多 Agent 的“停止”按钮,实际只是把前端 SSE 连接断开。后台模型还在跑、工具还在执行、下一轮对话还可能因为缺少 tool_result 直接报错。本文把“取消”拆成四层:取消信号、工具终止、对话历史修复、用户中途补充(steering),并给出一套可落地的工程做法。

1. 为什么“断开前端”不等于取消

用户点停止时,最常见的错误实现是:

abortController.abort(); // 只断了浏览器到服务端的流

这会带来四个问题:

  1. 模型请求可能还在跑:流断了,服务端上游请求不一定立刻停。
  2. 工具可能已经执行了一半:写文件、发请求、扣库存,不会因为前端断开而回滚。
  3. 对话历史损坏:模型发起了 tool_call,但没等到 tool_result,下一轮 API 会拒绝这种消息序列。
  4. 计费不会退:很多模型供应商对“客户端中途断开”的请求照常计费。

所以“停止”不是 UI 行为,而是一个贯穿前后端的生命周期事件。

2. 第一层:取消信号要层层派生

正确做法是每个运行中的任务都有自己的 AbortController,并且从父级派生:

// 一次用户请求
const root = new AbortController();

// 模型流
fetch(modelUrl, { signal: root.signal });

// 每个工具
const toolSignal = AbortSignal.any([root.signal, AbortSignal.timeout(30_000)]);
await tool.invoke(args, { signal: toolSignal });

原则:

  • 父信号取消,所有子信号同步取消;
  • 子信号超时,不影响父任务;
  • 不要用全局变量做取消标记,多会话/多子 Agent 会互相污染;
  • 每个长循环在“调用模型前、启动工具前”主动检查信号,不能只依赖底层抛异常。

服务端最好维护一个运行注册表:

const activeRuns = new Map<string, { controller: AbortController; startedAt: number }>();

按 ownerKey + conversationId 找到正在跑的 run,提供一个显式的停止接口:

POST /api/chat/stop
{ "conversationId": "...", "patientId": "..." }

前端停止按钮先调这个接口,再断本地流。这样即使前端断线,服务端也能主动收尾。

3. 第二层:工具要能真正停,shell 要杀进程组

对于 HTTP/数据库工具,传入取消信号即可。

对于 shell 命令,只杀父进程不够:

npm run dev
  └─ node vite
       └─ esbuild

只杀 shell,子进程还在占端口。正确做法是终止整个进程组:

  • Unix:process.kill(-pid, "SIGTERM"),超时后再 SIGKILL;
  • Windows:taskkill /PID <pid> /T /F;
  • 温和退出用 SIGTERM,允许工具清理临时文件;
  • 超时未退出再 SIGKILL。

不同产品策略不同:

  • Codex:先 SIGTERM,等几十毫秒再 SIGKILL;
  • Claude Code:直接 SIGKILL 整个进程组。

对于没有 shell 的 Agent,仍然要检查:HTTP 请求、MCP 调用、文件写入是否支持取消。

4. 第三层:对话历史必须修复

模型 API 对消息序列有硬约束:

assistant(tool_calls) → tool(tool_result) → assistant(...)

每个 tool_call 后面必须跟一个对应的 tool_result。如果用户在工具执行中取消,历史里就会留下一个没有结果的 tool_call,下一轮直接报错。

两种修复方式:

方式 A:补一条“已取消”的结果

new ToolMessage({
  tool_call_id: call.id,
  name: call.name,
  status: "error",
  content: "用户主动取消了本次操作,工具没有返回结果。不要假定它成功;如需继续,请重新调用。",
})

优点:保留完整历史,模型知道发生过什么。
缺点:历史会变长。

方式 B:删掉被打断的半轮回答

把没有结果的 tool_call 及其半截回答一起移除,让模型从最后一个完整状态重启。
优点:干净。
缺点:丢失上下文,用户可能刚看到一半内容就没了。

工程上常用组合:

  • 普通工具:补 tool_result;
  • 写操作/高风险工具:补“已取消,未执行”的明确结果;
  • 前端保留用户已经看到的内容,但标注“已停止”。

5. 第四层:Steering——用户不是要取消,只是想补充

用户在执行中说:

等一下,我下周三不行,改成下周四。

这不是取消,而是中途改方向。不能立刻把这条消息插进历史,因为会破坏 tool_call / tool_result 的配对。

正确做法是两条队列:

steer 队列:当前这批工具执行完,下一次模型调用前注入
follow-up 队列:Agent 完全停下后再执行

实现方式:

  1. 用户中途输入 → 入队,不打断当前工具;
  2. 工具批次结束;
  3. 在下一轮模型调用前,把队列里的消息作为新的 HumanMessage 注入;
  4. 模型基于新要求继续,而不是从头再来。

伪代码:

beforeModel: async (state, runtime) => {
  const pending = steeringQueue.drain(runtime.context.conversationId);
  if (pending.length === 0) return;
  return {
    messages: pending.map(text => new HumanMessage({
      content: `【用户中途补充】${text}`,
      additional_kwargs: { lc_source: "steering" },
    })),
  };
}

注意:

  • 不要在工具调用和工具结果之间插消息;
  • 写操作已经进入确认卡时,steering 只应影响后续步骤,不能偷偷改已提交的参数;
  • 高风险操作收到 steering 时,应重新确认,而不是直接改单。

6. 前端应该怎么表现

停止按钮至少要有三种状态:

running → stopping → canceled

UI 行为:

  • 点击停止:按钮进入“正在停止”,不要立刻清空内容;
  • 服务端确认停止:保留已生成的部分,标注“已停止”;
  • 如果正在等待 HITL 确认:停止应取消这次待确认操作,并明确告知“未提交”;
  • 如果用户只是补充要求:输入框保持可用,显示“已加入队列”,不要伪装成新一轮。

7. 可观测性

至少记录:

  • cancel_requested:用户点停止
  • cancel_ack:服务端确认停止
  • cancel_latency_ms:从请求到真正停止
  • orphan_tool_calls:取消后缺少结果的工具调用数
  • steer_enqueued / steer_injected:中途补充的入队和注入次数
  • canceled_turns:被取消的轮次
  • post_cancel_errors:取消后下一轮是否报错

如果 orphan_tool_calls 长期大于 0,说明历史修复没做好。
如果 post_cancel_errors 上升,说明 tool_call/tool_result 配对仍然被破坏。

8. 最小落地清单

  1. 每次 run 一个 AbortController,通过 config.signal 传给模型和工具;
  2. 服务端维护 active run 注册表,提供 /api/chat/stop;
  3. 前端停止按钮先调 stop 接口,再断本地流;
  4. 取消后给所有未完成的 tool_call 补 synthetic ToolMessage;
  5. 增加 repairInterruptedToolCalls 中间件,下一轮模型调用前兜底修复;
  6. 增加 steer 队列,在工具批次结束后、下一次模型调用前注入;
  7. 记录 cancel / steer / orphan 指标;
  8. 写测试:取消时工具是否真的停、下一轮是否报错、用户补充是否被正确注入。

一句话:

取消不是“断流”,而是“信号传递 + 工具终止 + 历史修复 + 状态可恢复”。

Logo

更多推荐