取消与超时
取消不是即死——LLM 由 SDK 立刻掐断,但工具是协作式的:不响应 signal 的工具要等到 stepMs 超时才停,而超时和用户 Stop 走的是同一条 AbortSignal。
所有触发都收敛到一个 AbortController
一次任务运行期间,随时可能被打断。触发来源有三个,但它们最终都发火同一个 session.abortController(apps/server/src/services/task-runner.ts),再作为 control.abortSignal 流进 Agent 引擎。理解取消,第一步就是理解这个收敛点:无论谁按下停止,下游只认一个信号。
| 触发来源 | 机制 | 覆盖场景 |
|---|---|---|
| 同进程 abort | abortManager.abort(taskId)(apps/server/src/lib/abort-manager.ts) | inline 模式,运行就在当前 API 进程 |
| 跨进程 abort | abort-bus 的 Redis pub/sub → subscribeAbort 回调 | 运行在独立 BullMQ worker 进程 |
| 订阅前窗口的取漏 | isAbortRequested(taskId) 复查 abort-bus 的持久 flag | Stop 落在 run 装上订阅之前的空档 |
前两者是运行时推送,第三者是拉取式复查。之所以需要第三者:任务先入队,worker 要过一会儿才装上 subscribeAbort。如果用户在这个空档里按了 Stop,纯 pub/sub 是 fire-and-forget 的——没人在听,消息直接丢。所以 publishAbort 除了 PUBLISH,还会写一个持久 flag(zapvol:aborted:{taskId},EX 120s),run 装上订阅后立刻用 isAbortRequested 复查它,把这个空档补上。相对地,每一轮起手都会 clearAbortRequest,避免上一轮的旧 Stop 在 TTL 内误杀新一轮的 run。
一次 Stop 的完整链路
taskOrchestrator.abort(userId, taskId) 被 POST /api/tasks/:id/abort(HTTP)和 task:stream:abort(WS)两条路径调用,顺序执行:先 taskService.abort 校验归属并落 isActive=false(未授权调用在这一步抛错,永远到不了后面的信号派发),再同时触发同进程的 abortManager.abort 和跨进程的 publishAbort(两者都幂等,双发无害),最后立刻推一条 WS aborted 事件让前端不必等 agent 收尾。
LLM 由 SDK 掐断,工具是协作式
信号进入引擎后,agent.stream({ abortSignal, onToolExecutionEnd, timeout })(packages/backend/src/agent/agent-loop.ts)把它分发到两条截然不同的路径:
LLM 流由 SDK 确保停止。 signal 一立,正在进行的模型生成 HTTP 请求会在下一个 await 点被取消。这一层无需业务代码介入——Vercel AI SDK 自己处理,最可靠。
工具是协作式的——停不停取决于工具自己。 SDK 把 abortSignal 透传进每个工具的 execute(args, { abortSignal }),但它只是一个信号,工具必须主动响应才会真停:
| 工具 | 如何响应 signal |
|---|---|
shell(execute) | signal: abortSignal 传给 sandbox exec + withDeadline |
browser | 转发给 BrowserBridge.request(action, signal),pending 请求以 internal_error "aborted" 解决 |
| 搜索类(tavily / exa) | 传给底层 fetch |
不响应 signal 的工具不会立刻停——它会一直跑到超时才被强制掐断。这正是下一节要讲的:超时是取消的兜底,而且用的是同一套机制。
超时和用户取消是同一条信号
buildStreamTimeouts(agent-loop.ts)给 agent.stream 配了三级墙钟,每一级到点后,SDK 都把它转成 AbortSignal.timeout——和用户按 Stop 走的是同一条 AbortSignal 路径,下游不区分二者。
| 层级 | 默认值 | 约束对象 | 常量 |
|---|---|---|---|
chunkMs | 120s | 相邻两个 chunk 之间的最大静默 | DEFAULT_CHUNK_TIMEOUT_MS |
stepMs | 300s + 压缩津贴 | 单步内“模型生成 + 该步所有工具执行”的总耗时 | DEFAULT_STEP_WORK_TIMEOUT_MS |
toolMs | 120s | 单个工具 execute() 的墙钟(从工具开始算) | DEFAULT_TOOL_TIMEOUT_MS |
stepMs 不是工具粒度,它罩住整步。而 stepMs 下发给 SDK 的值是工作预算 + 压缩津贴(COMPACTION_SUMMARIZE_TIMEOUT_MS):压缩跑在 prepareStep 里、在 stepMs arm 之后且不可重置地吃这个时钟,不留津贴就会挤占工具本应拿到的整段工作预算。
execute 与 task 是长时工具,豁免 toolMs。 shell 自管 240s+grace、task 的嵌套 subagent 循环常达数分钟,用全局 toolMs 夹断它们会误杀。豁免的实现是把它们的 {toolName}Ms 设成 stepMs——即“回落到步级约束、不叠更紧的工具级上限”。这带来一个必须记住的推论:想让 subagent 跑超过 stepMs,得连 stepMs 一起抬,单抬 taskMs 无效——整步会先被 stepMs abort。
setup 阶段也能被打断
取消不只覆盖 LLM 生成和工具执行。在调 LLM 之前,buildAgentInputs 并行构建 instructions + tools,其中两路都会 await Skill 元数据(SkillStorage 的 R2/FS 读取),并非纯内存操作。这段被 withDeadline([...], { ms: AGENT_SETUP_TIMEOUT_MS, signal: control?.abortSignal }) 罩住(默认 30s):R2 卡死时这一轮的 setup 有兜底时钟,同时也响应用户 Stop / 父级 abort。所以即便任务还没进入推理,Stop 也能让它退出。
中止是协调式收卷,不是即死
signal 发火后流会结束,createUIMessageStream 的 onEnd({ messages, isAborted }) 触发 finalizeExecution(task-runner.ts)。取消不是把进程一刀切掉,而是走一段协调式收卷:
- 收编孤儿工具 part——中断判定
signal.aborted || isAborted成立时,terminatePendingToolParts(parts, "Cancelled")把停在input-available悬空的“执行中工具 part”收成终态。不做这步,前端会永久 shimmer(转圈)。 - 落库部分结果——
finalizeTurn(taskId, aiMessage, wasAborted, …)把已产出的 assistant message、累积 usage、task metadata 写入,并派生事件类型(优先级aborted > errored > hitl > completed)。 - 释放进程级资源——
finally块无条件执行:unsubscribeAbort()→abortManager.remove()→ MCP 断开 →stopLockHeartbeat()→releaseTaskLock()。
也就是说,被取消的这一轮仍会留下可见的部分产物,并且必定归还锁、心跳、MCP 连接后才结束——后续各轮不会因为一次 Stop 而卡在僵尸态。
调优点
以下是这套机制上可以针对性调整的旋钮,改动前先对照本页的约束理解各自的连带影响。
| 旋钮 | 位置 | 当前值 | 调优时注意 |
|---|---|---|---|
| 持久 abort flag TTL | abort-bus.ts FLAG_TTL_SEC | 120s | 需覆盖“入队到 worker 装上订阅”的最长空档;太短会漏、太长会让旧 Stop 误杀新 run 的窗口变宽(clearAbortRequest 已在每一轮起手兜底) |
| chunk 静默上限 | agent-loop.ts DEFAULT_CHUNK_TIMEOUT_MS | 120s | 长时无输出工具需配心跳,否则会被误判静默 |
| 步级工作预算 | agent-loop.ts DEFAULT_STEP_WORK_TIMEOUT_MS | 300s | 抬它才能让 subagent / execute 跑更久;单抬 toolMs 无效 |
| 单工具墙钟 | agent-loop.ts DEFAULT_TOOL_TIMEOUT_MS | 120s | 只作用于非豁免工具;execute / task 已回落到 stepMs |
| setup 兜底时钟 | agent-loop.ts AGENT_SETUP_TIMEOUT_MS | 30s | 覆盖 Skill 元数据的 R2 读取;R2 慢时可上调 |
一个实现上的固定注意点:收编孤儿 part 只在 finalizeExecution 一侧做。所以新增不响应 signal 的自研工具时,要按“到 stepMs 才停”来预期它的最坏中止时延。
相关文档
- 任务编排——execute 的五阶段生命周期,abort 是其中一环
- Agent Engine——ReAct 循环与
agent.stream的分层超时 - 后台任务队列——worker 进程模型,跨进程 abort 的由来
- Streaming Architecture——流的构建与恢复,中止如何终结流