生产级 Agent 的“停止”到底怎么做:取消、工具收尾与中途插话
生产级 Agent 的“停止”到底怎么做:取消、工具收尾与中途插话
很多 Agent 的“停止”按钮,实际只是把前端 SSE 连接断开。后台模型还在跑、工具还在执行、下一轮对话还可能因为缺少
tool_result直接报错。本文把“取消”拆成四层:取消信号、工具终止、对话历史修复、用户中途补充(steering),并给出一套可落地的工程做法。
1. 为什么“断开前端”不等于取消
用户点停止时,最常见的错误实现是:
abortController.abort(); // 只断了浏览器到服务端的流
这会带来四个问题:
- 模型请求可能还在跑:流断了,服务端上游请求不一定立刻停。
- 工具可能已经执行了一半:写文件、发请求、扣库存,不会因为前端断开而回滚。
- 对话历史损坏:模型发起了
tool_call,但没等到tool_result,下一轮 API 会拒绝这种消息序列。 - 计费不会退:很多模型供应商对“客户端中途断开”的请求照常计费。
所以“停止”不是 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 完全停下后再执行
实现方式:
- 用户中途输入 → 入队,不打断当前工具;
- 工具批次结束;
- 在下一轮模型调用前,把队列里的消息作为新的
HumanMessage注入; - 模型基于新要求继续,而不是从头再来。
伪代码:
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. 最小落地清单
- 每次 run 一个
AbortController,通过config.signal传给模型和工具; - 服务端维护 active run 注册表,提供
/api/chat/stop; - 前端停止按钮先调 stop 接口,再断本地流;
- 取消后给所有未完成的
tool_call补 syntheticToolMessage; - 增加
repairInterruptedToolCalls中间件,下一轮模型调用前兜底修复; - 增加 steer 队列,在工具批次结束后、下一次模型调用前注入;
- 记录 cancel / steer / orphan 指标;
- 写测试:取消时工具是否真的停、下一轮是否报错、用户补充是否被正确注入。
一句话:
取消不是“断流”,而是“信号传递 + 工具终止 + 历史修复 + 状态可恢复”。
更多推荐
所有评论(0)