取消与超时

取消不是即死——LLM 由 SDK 立刻掐断,但工具是协作式的:不响应 signal 的工具要等到 stepMs 超时才停,而超时和用户 Stop 走的是同一条 AbortSignal。

所有触发都收敛到一个 AbortController

一次任务运行期间,随时可能被打断。触发来源有三个,但它们最终都发火同一个 session.abortControllerapps/server/src/services/task-runner.ts),再作为 control.abortSignal 流进 Agent 引擎。理解取消,第一步就是理解这个收敛点:无论谁按下停止,下游只认一个信号。

触发来源机制覆盖场景
同进程 abortabortManager.abort(taskId)apps/server/src/lib/abort-manager.tsinline 模式,运行就在当前 API 进程
跨进程 abortabort-bus 的 Redis pub/sub → subscribeAbort 回调运行在独立 BullMQ worker 进程
订阅前窗口的取漏isAbortRequested(taskId) 复查 abort-bus 的持久 flagStop 落在 run 装上订阅之前的空档

前两者是运行时推送,第三者是拉取式复查。之所以需要第三者:任务先入队,worker 要过一会儿才装上 subscribeAbort。如果用户在这个空档里按了 Stop,纯 pub/sub 是 fire-and-forget 的——没人在听,消息直接丢。所以 publishAbort 除了 PUBLISH,还会写一个持久 flag(zapvol:aborted:{taskId},EX 120s),run 装上订阅后立刻用 isAbortRequested 复查它,把这个空档补上。相对地,每一轮起手都会 clearAbortRequest,避免上一轮的旧 Stop 在 TTL 内误杀新一轮的 run。

一次 Stop 的完整链路

sequenceDiagram actor User participant Orch as Task Orchestrator participant Bus as abort-bus · Redis participant Run as Agent Run · abortController participant SDK as ToolLoopAgent · stream participant Tool as Running tool rect rgba(244, 114, 182, 0.16) Note over User,Bus: ① Stop 请求 User->>Orch: POST /tasks/:id/abort Orch->>Orch: taskService.abort (权限 + isActive=false) Orch->>Run: abortManager.abort (同进程) Orch->>Bus: publishAbort (持久 flag + PUBLISH) Orch-->>User: WS task:event aborted end rect rgba(251, 191, 36, 0.16) Note over Bus,Run: ② 信号收敛 Bus-->>Run: pub/sub 命中 subscribeAbort (跨进程) Run->>Run: isAbortRequested 复查 flag (订阅前窗口) Run->>Run: session.abortController.abort(remote_stop) end rect rgba(96, 165, 250, 0.16) Note over Run,Tool: ③ 传播与停止 Run->>SDK: control.abortSignal SDK->>SDK: LLM 生成在下一个 await 点取消 SDK->>Tool: options.abortSignal (协作式) Tool-->>SDK: honor → 提前返回 / tool-error end rect rgba(52, 211, 153, 0.16) Note over SDK,Orch: ④ 协调式收尾 SDK-->>Run: 流结束 Run->>Run: terminatePendingToolParts (Cancelled) Run->>Run: finalizeTurn (aborted) + 释放锁/心跳/MCP end

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 的工具不会立刻停——它会一直跑到超时才被强制掐断。这正是下一节要讲的:超时是取消的兜底,而且用的是同一套机制。

超时和用户取消是同一条信号

buildStreamTimeoutsagent-loop.ts)给 agent.stream 配了三级墙钟,每一级到点后,SDK 都把它转成 AbortSignal.timeout——和用户按 Stop 走的是同一条 AbortSignal 路径,下游不区分二者。

层级默认值约束对象常量
chunkMs120s相邻两个 chunk 之间的最大静默DEFAULT_CHUNK_TIMEOUT_MS
stepMs300s + 压缩津贴单步内“模型生成 + 该步所有工具执行”的总耗时DEFAULT_STEP_WORK_TIMEOUT_MS
toolMs120s单个工具 execute() 的墙钟(从工具开始算)DEFAULT_TOOL_TIMEOUT_MS

stepMs 不是工具粒度,它罩住整步。而 stepMs 下发给 SDK 的值是工作预算 + 压缩津贴COMPACTION_SUMMARIZE_TIMEOUT_MS):压缩跑在 prepareStep 里、在 stepMs arm 之后且不可重置地吃这个时钟,不留津贴就会挤占工具本应拿到的整段工作预算。

executetask 是长时工具,豁免 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 发火后流会结束,createUIMessageStreamonEnd({ messages, isAborted }) 触发 finalizeExecutiontask-runner.ts)。取消不是把进程一刀切掉,而是走一段协调式收卷:

  1. 收编孤儿工具 part——中断判定 signal.aborted || isAborted 成立时,terminatePendingToolParts(parts, "Cancelled") 把停在 input-available 悬空的“执行中工具 part”收成终态。不做这步,前端会永久 shimmer(转圈)。
  2. 落库部分结果——finalizeTurn(taskId, aiMessage, wasAborted, …) 把已产出的 assistant message、累积 usage、task metadata 写入,并派生事件类型(优先级 aborted > errored > hitl > completed)。
  3. 释放进程级资源——finally 块无条件执行:unsubscribeAbort()abortManager.remove() → MCP 断开 → stopLockHeartbeat()releaseTaskLock()

也就是说,被取消的这一轮仍会留下可见的部分产物,并且必定归还锁、心跳、MCP 连接后才结束——后续各轮不会因为一次 Stop 而卡在僵尸态。

调优点

以下是这套机制上可以针对性调整的旋钮,改动前先对照本页的约束理解各自的连带影响。

旋钮位置当前值调优时注意
持久 abort flag TTLabort-bus.ts FLAG_TTL_SEC120s需覆盖“入队到 worker 装上订阅”的最长空档;太短会漏、太长会让旧 Stop 误杀新 run 的窗口变宽(clearAbortRequest 已在每一轮起手兜底)
chunk 静默上限agent-loop.ts DEFAULT_CHUNK_TIMEOUT_MS120s长时无输出工具需配心跳,否则会被误判静默
步级工作预算agent-loop.ts DEFAULT_STEP_WORK_TIMEOUT_MS300s抬它才能让 subagent / execute 跑更久;单抬 toolMs 无效
单工具墙钟agent-loop.ts DEFAULT_TOOL_TIMEOUT_MS120s只作用于非豁免工具;execute / task 已回落到 stepMs
setup 兜底时钟agent-loop.ts AGENT_SETUP_TIMEOUT_MS30s覆盖 Skill 元数据的 R2 读取;R2 慢时可上调

一个实现上的固定注意点:收编孤儿 part 只在 finalizeExecution 一侧做。所以新增不响应 signal 的自研工具时,要按“到 stepMs 才停”来预期它的最坏中止时延。

相关文档

这页有帮助吗?