流超时与工具心跳
chunkMs 是三个流超时里最紧的一个,且工具执行期间它不会自动重置——一条静默运行超过 120s 的 shell 会中止整条流,即便 execute/task 豁免已就位。剖析成因,以及唯一有效的 preliminary-yield 心跳
一眼看懂
agent.stream({ timeout }) 暴露三级超时。其中两级可按工具覆盖,第三级——chunkMs——是全局的,而它恰恰是任何长时工具
的真正上限。这页逐层追清成因,并指明唯一合法的心跳。
| 关键数字 | 值 |
|---|---|
chunkMs 默认值 | 120s(DEFAULT_CHUNK_TIMEOUT_MS) |
| 步内工作预算 | 300s(DEFAULT_STEP_WORK_TIMEOUT_MS) |
| 压缩 summarize soft-timeout | 60s(COMPACTION_SUMMARIZE_TIMEOUT_MS) |
实际下发 SDK 的 stepMs | 360s(工作预算 + 压缩津贴) |
toolMs 默认值 | 120s(DEFAULT_TOOL_TIMEOUT_MS) |
execute / task 的 per-tool 覆盖 | stepMs(360s)——只抬 toolMs,动不了 chunkMs |
shell timeout 参数 | 默认 30s,上限 240s(SHELL_MAX_TIMEOUT_SEC) |
| 静默 shell 的实际上限 | 120s——chunkMs 先撞线 |
什么能重置 chunkMs | 只有 streamText 管道上的 chunk(preliminary 结果) |
chunk 会重置 stepMs 吗 | 不会——stepMs 每步 arm 一次,与 chunk 无关 |
| 锁定的 SDK 版本 | [email protected] |
三个超时,一个合并的中止信号
buildStreamTimeouts(packages/backend/src/agent/agent-loop.ts:442)组装出传给 agent.stream({ timeout }) 的对象。
SDK 读三级超时外加 totalMs:
| 级别 | 默认 | 约束的是什么 | 重置行为 |
|---|---|---|---|
chunkMs | 120s | 两个 stream chunk 之间的最长静默(仅限流式) | 每收到一个 chunk 重置一次(resetChunkTimeout) |
stepMs | 360s | prepareStep(含压缩)加上模型生成加上该步所有工具执行 | 每步 arm 一次,步内从不重置 |
toolMs | 120s | 单个工具 execute() 的墙钟——给无自管超时的工具兜底 | 工具开始执行时 arm |
totalMs | 未设 | 整个 stream() 调用 | 从不重置 |
chunkMs 重置不会重置 stepMs。 两者是独立计时器:resetChunkTimeout()(stream-text.ts:1750)只碰 chunk 的
abort controller;stepTimeoutId 在 streamStep 顶部 arm 一次、只会被清除、从不重新 arm。一条持续吐 chunk 的流会把
chunkMs 无限重置,但仍会在从步起点算起的 stepMs 处死掉。chunkMs = “chunk 间最长静默”;stepMs = “从步起点起对
整步的硬墙”。
决定性的细节:四个中止来源被折进同一个信号(stream-text.ts:732——mergeAbortSignals(abortSignal, totalTimeoutMs, stepAbortController?.signal, chunkAbortController?.signal))。任意一个触发都会中止整条流,而这个合并信号还会被交给正在
执行的工具(execute-tool-call.ts:120)。所以 chunkMs 超时不只是停止读流——它会主动中止那个正在执行的工具。
每个计时器都以带级别标签的 TimeoutError DOMException 中止(util/set-abort-timeout.ts):chunkMs 越界表现为
Chunk timeout of 120000ms exceeded。
豁免抬的是 toolMs,漏掉了真正生效的那个
tools: {
[`${TOOL_NAME_EXECUTE}Ms`]: stepMs, // 300s
[`${TOOL_NAME_TASK}Ms`]: stepMs, // 300s
}
tools[{name}Ms] 只能覆盖 per-tool 的 toolMs(request-options.ts——getToolTimeoutMs 返回
timeout.tools?.[name+'Ms'] ?? timeout.toolMs)。它正确地把 shell 与子 agent 的工具级上限从 120s 抬到 300s,让它们回落
到步级约束,而不是被更紧的工具级上限夹断。
但 chunkMs 是单一全局值,没有 per-tool 形式——豁免碰不到它。于是三个约束里最紧的那个,对 execute 和 task 依然
停在 120s。豁免的本意——长时工具不该被提前剪断——被一个它从未处理的计时器悄悄架空。
chunkMs 在工具执行期间不会停
resetChunkTimeout()(stream-text.ts:1750)在处理合并流的 transform 顶部被调用——模型 chunk 与 tool-result chunk
一视同仁(stream-text.ts:2051)。工具执行期间的问题很简单:有没有任何 chunk 穿过那个 transform。
对一个 execute 是普通 async 函数的工具,下面的时序说明:没有。
逐步看:模型吐出 model-call-end;executeToolsFromStream 立即把它转发下游(execute-tools-from-stream.ts:88),到达
外层 transform,在工具执行的最起点 arm 起 chunk 计时器。随后就在这同一个 model-call-end 上,它 await 所有工具执行
(execute-tools-from-stream.ts:199)。这个 await 挂起期间,除非工具流式吐出结果,否则什么都不会被 enqueue。
clearChunkTimeout() 只在该步的 flush(stream-text.ts:2274)里执行,那已是工具全部结束之后。所以这 120s 会在整段静默
执行里持续倒数。
只有 streamText 管道上的 chunk 能重置 chunkMs
存在两条互相独立的 stream 平面。chunkMs 只盯其中一条。
| 平面 A——streamText chunk 管道 | 平面 B——UI message stream | |
|---|---|---|
| 谁在喂它 | agent.stream 内部的模型 chunk + tool-result chunk | ctx.writer,经 writeTransient(context.ts:258) |
chunkMs 是否监听 | Yes | No |
| 工具怎么塞进去 | 只能靠 execute 返回 async iterable(每个 yield 变成 preliminary/final 结果) | ctx.writeTransient(event, data) |
| wire 上的 chunk 形状 | tool-result → 按 toolCallId 键控的 tool-output-available | data-{event},transient: true |
工具能推上平面 A 的只有它 yield 出去的值。没有任何 API 能让工具从内部往 streamText 发一个裸 data chunk——writeTransient
是 Zapvol 在平面 B 上开的旁路,而平面 B 对 chunkMs 不可见。
现状的洞——shell 在流 stdout,却仍会超时
shell.tool.ts:88 把 execute 定义为返回 Promise 的普通 async 函数。它确实在流输出——但走的是 onStdout / onStderr
回调里的 ctx.writeTransient(TOOL_STREAM, …)(shell.tool.ts:100),那是平面 B。无论一次构建打印多少行,streamText 的
chunk 计时器都喂不到。
于是一条 shell 命令的实际上限是:
min(chunkMs 120s, per-tool 300s, stepMs 300s, shell 自管 hardWall) = 120s
具体地说:operator 可以设 timeout: 240,工具也被特意在工具级豁免到 300s,然而任何墙钟超过 120s 的命令都会在 120s 被
中止,与输出量无关。task 工具是完全相同的形状——一个跑数分钟却不向父流冒泡任何东西的子 agent 步骤,会触发父流的 120s
chunkMs。
解法——空载荷心跳,保留 append ticker
工具的心跳不可避免是一个 preliminary 结果——那是平面 A 唯一承载的信号。那就让它什么都不承载:yield 一个最小的、合法的
快照,唯一职责是重新 arm chunkMs。
// execute 改成 async generator
execute: async function* ({ command, timeout }, { abortSignal, toolCallId, context }) {
// 展示仍在平面 B——细粒度、append 式、transient(不入库)
// onStdout: ctx.writeTransient(TOOL_STREAM, { toolCallId, chunk: { … } })
// 心跳在平面 A——一个空的 ShellOutput 快照,间隔 < chunkMs
yield { exitCode: -1, result: "", executionTime: elapsed }; // preliminary——重新 arm chunkMs(exitCode 非空,-1 表示“未退出”)
// …命令运行期间重复…
yield { ...final, result: capShellOutput(final.result) }; // 最后一个 yield = 真结果
}
让这一切安全的契约在 execute-tool.ts(provider-utils):当 execute 返回 async iterable 时,每个 yield 出的值都
以 { type: 'preliminary' } 发出,而 generator 返回后,最后一个值被以 { type: 'final' } 再发一次。下游
(execute-tool-call.ts:153)只有 final 输出进入模型上下文;每个 preliminary 纯粹用于心跳与 UI 预览,随后丢弃。两条
推论:
- 最后一个 yield 就是工具结果。心跳必须是中间的 yield;generator 必须最后 yield 完整的
ShellOutput,之后才返回。把半成品 快照当作最终值 yield,会把一个截断的结果交给模型。 - 心跳载荷永远到不了模型。空快照是安全的。
为什么用空载荷而不是累积的 stdout:把丰富的 stdout 留在 writeTransient 的 append(平面 B)、心跳上什么都不发,就避免了把
输出送两遍。见下方冗余说明。
preliminary 不是 data-part——两者无法同样处理
一个 preliminary 结果会变成按 toolCallId 键控的 tool-output-available chunk(to-ui-message-chunk.ts:274),所以在
客户端,readUIMessageStream 会把它合并进那次工具调用的 part,更新其 output 并标记 preliminary: true。而一个
writeTransient 事件变成游离的 data-{event} part,由 data 通道处理。两者落在消息树的不同位置,由不同的 renderer 处理。
这正是为什么朴素的双写——既保留 writeTransient 流 stdout,又把 stdout 放进心跳——是冗余的:客户端会通过两条结构不同的
通道收到同一份输出的两份拷贝,且无法用统一逻辑处理它们。空载荷心跳绕开了这一点:preliminary 不承载内容,就不与任何东西
重复。客户端对 shell 工具的 preliminary: true 输出直接忽略即可(一行 guard;空输出本来也渲染不出什么)。
另一条路,以及为什么不选
SDK 原生的做法是彻底砍掉 writeTransient,在每个 stdout tick 上流一份完整的累积快照——单通道、无冗余,preliminary 同时
兼作展示与心跳。代价是实打实的:overwrite(快照)语义而非 append,以及对话痨输出的 O(n²) 带宽(每个 tick 重发目前全量)
对比 append ticker 的 O(n)。对构建与测试输出而言,这个权衡倾向于保留 append ticker、用最小心跳——它同时也是更小的改动,且
复用现成的 TOOL_STREAM renderer。
压缩跑在 prepareStep 里——另一套计时器剧本
上下文压缩跑在 prepareStep 里(agent-loop.ts → stepCompactor.apply),在该步模型调用之前。它与两个计时器的关系
和工具正好相反,根因在于 streamStep(stream-text.ts)里每个计时器 arm 的位置:
setAbortTimeout({ label: "Step" })——在streamStep顶部 armstepMs。await prepareStep(...)——压缩在这里跑。- 第一次
resetChunkTimeout()——只在处理模型 chunk 的 transform 里触发,即模型开始产出之后。
两个推论:
- 压缩期间
chunkMs不会触发。 chunk 计时器要等第一个模型 chunk 穿过 transform 才 arm,所以再慢的压缩也不会撞chunkMs。(这与工具场景正好镜像——工具执行前model-call-end已经把 chunk 计时器 arm 起来了。)那段静默期的 SSE 连接由传输层的withSseKeepalive注释帧兜住(apps/server/src/lib/sse-keepalive.ts),不靠chunkMs。 stepMs会为压缩耗时买单。 因为步计时器在prepareStep之前 arm,压缩墙钟算进stepMs且无法重置。若不处理,一次 接近上限的压缩会吃掉execute/task豁免本该交给工具的预算。故实际stepMs=工作预算(300s)+ 压缩津贴(60s)= 360s,津贴等于压缩自身的 soft-timeout(见下),保住工具的工作预算。
永远不要中止压缩——用 soft-timeout 兜底
压缩是前置刚需,不是可选工作:若某个超时把它中途 abort,上下文照样超长、这一步照样会失败(ContextLengthError,或
请求因超窗被拒)。中止只是把失败往后挪。而且 prepareStep 没有 abortSignal 入参,所以压缩 hang 住时 stepMs 触发也
中断不了它——执行 parked 在 await prepareStep 上,一次卡死的 summarizer LLM 调用会无限拖死整整一轮。
解法是降级式 soft-timeout,而非硬中止。FLOOR 的 summarize LLM 调用(summarizer.ts)拿到自己的
AbortSignal.timeout(COMPACTION_SUMMARIZE_TIMEOUT_MS)(60s)。超时即回落到 summarizer 现成的确定性静态 checkpoint
(最近的用户诉求 + 动作计数)——它仍然把上下文压下来,使这一步依旧可行。于是压缩永远完成、永远缩小上下文,只是在模型
卡死时降级成一份更薄的摘要。已 offload 的工具输出仍可经 view_tool_call 恢复。
在这段(可能很慢的)压缩期间,prepareStep 发出一个瞬态 AGENT_STATE: "compacting" 进度事件(gate 在
predictedTokens >= trigger,与引擎压缩门槛同源),让 operator 看到“正在压缩上下文”而不是以为这一轮卡死。下一步的
start-step → executing 状态会自然覆盖它。
每条事实的出处
| 事实 | 出处 |
|---|---|
| 超时默认值 + 豁免 | packages/backend/src/agent/agent-loop.ts(buildStreamTimeouts) |
stepMs 在 prepareStep 之前 arm | [email protected] stream-text.ts(streamStep:setAbortTimeout → await prepareStep) |
| 压缩 soft-timeout → 静态兜底 | packages/backend/src/context/compaction/summarizer.ts;config.ts(COMPACTION_SUMMARIZE_TIMEOUT_MS) |
compacting 进度事件 | packages/backend/src/agent/agent-loop.ts(createPrepareStep) |
| 合并的中止信号 | [email protected] stream-text.ts:732 |
resetChunkTimeout / clear 点 | [email protected] stream-text.ts:1750, 2051, 2274 |
model-call-end 上 await 工具执行 | [email protected] execute-tools-from-stream.ts:88, 199 |
| per-tool 超时解析 | [email protected] request-options.ts(getToolTimeoutMs) |
| preliminary vs final 契约 | [email protected] @ai-sdk/provider-utils types/execute-tool.ts |
| 只有 final 进模型上下文 | [email protected] execute-tool-call.ts:144-155 |
preliminary → tool-output-available | [email protected] to-ui-message-chunk.ts:274 |
shell stdout 经 writeTransient(平面 B) | packages/backend/src/tools/tools/shell.tool.ts:88-114;context.ts:258 |