给 Agent 配一个 timeout: 30_000,不代表它真的会在 30 秒内结束。Agent 外层、模型请求、工具、重试退避和持久化只要有一层重新计时,用户看到的 30 秒就可能变成 60 秒甚至更久;更危险的是,前端已经显示超时,后台工具仍在写数据。
2026-08-08 的官方版本核验给出了三个值得组合的信号:Vercel AI SDK 7.0.58 修复 ToolLoopAgent 没有遵守 Agent 超时设置;Pydantic AI 2.26.0 把取消提升为一级 Runtime 事件;Google GenAI Python 2.17.0 增加 TOO_MANY_TOOL_CALLS 终态。本文不比较框架 API,而是用一个通过 7 项测试的 TypeScript 最小实验,拆开一条通用超时契约。
一、最常见的错误,是每一层都从头计时
下面的代码看起来给模型和工具都加了 10 秒超时,实际却允许整轮至少跑 20 秒;再加两次重试,时间会继续膨胀。
ts
await withTimeout(() => callModel(), 10_000)
await withTimeout(() => callTool(), 10_000)
await retry(() => callModel(), { attempts: 2, timeoutMs: 10_000 })
| 失效方式 | 表面配置 | 实际风险 |
|---|---|---|
| 每层重新计时 | Agent、模型、工具各 30 秒 | 总时长叠加 |
| 只中止 HTTP | 请求结束 | 子进程、队列或数据库仍继续 |
| 重试重置预算 | 每次都拿到完整 timeout | 故障时运行时间最长 |
只返回 error |
所有失败一个状态 | 无法判断能否重试或是否有副作用 |
| 前端取消不下传 | UI 显示"已停止" | 后台仍花费 Token 或执行工具 |
真正的入口约束应该是一条绝对截止时间 deadlineAt。后续每一层只能消费剩余预算,不能创建新的完整预算。
二、把相对 timeout 转成绝对 deadline
入口接到用户请求时只计算一次截止时间,同时保留父级取消信号。模型、工具和退避拿到的是同一个上下文。
ts
type RunBudget = {
deadlineAt: number
parentSignal: AbortSignal
perStepCapMs: number
reserveMs: number
}
const budget: RunBudget = {
deadlineAt: Date.now() + 30_000,
parentSignal: request.signal,
perStepCapMs: 8_000,
reserveMs: 1_000,
}
reserveMs 不是给业务继续运行,而是给终态持久化、Trace 收尾和用户响应留空间。没有这段余量,系统可能在最后一毫秒完成工具,却来不及写入"工具是否执行成功"。分布式系统还要处理跨机器时钟;本文实验只在单进程使用 Date.now()。
三、每一步只能拿到剩余预算与单步上限的较小值
实验在启动步骤前计算:min(perStepCap, deadlineAt - now - reserve)。结果小于等于零时,步骤根本不应启动。
ts
const allocatedMs = Math.min(
perStepCapMs,
Math.max(0, deadlineAt - Date.now() - reserveMs),
)
if (allocatedMs <= 0) {
return { terminal: "deadline-exceeded" }
}
单步上限用于防止一个挂起工具吃完全部时间;总截止时间负责限制整个 run。生产实现还要把信号真正接到 Provider SDK、fetch、数据库驱动、子进程和队列消费者。只在外层 Promise.race 一个计时器,通常不会停止底层工作。
四、用户取消和预算耗尽必须是两种终态
"用户点击停止"表达主动意图;"deadline exceeded"表达系统没在承诺时间内完成。二者都要向下传播,但重试、提示和指标不同。
ts
const controller = new AbortController()
const cancel = () => controller.abort(new BudgetAbort("cancelled"))
parentSignal.addEventListener("abort", cancel, { once: true })
const timer = setTimeout(
() => controller.abort(new BudgetAbort("deadline-exceeded")),
allocatedMs,
)
父信号中止时,模型流、工具、并行分支和后续状态提交都应看到取消。Pydantic AI 2.26.0 的一级取消事件说明这不是 UI 细节,而是 Runtime 契约;本文没有安装该框架,只吸收这一通用判断。
五、重试花的是同一笔钱,不能刷新预算
重试前至少检查:错误是否允许重试、是否还有 attempt、剩余预算是否覆盖退避。若连退避都放不下,应立即结束。
ts
const remaining = deadlineAt - Date.now() - reserveMs
if (!failure.retryable || attempt >= maxAttempts) return fail(failure)
if (remaining <= retryDelayMs) return { terminal: "deadline-exceeded" }
await waitFor(retryDelayMs, parentSignal)
// 下一次仍使用原来的 deadlineAt
对写操作还要增加幂等键和结果回查。连接在提交后断开时,"超时"不等于"没有执行";不先查询真实结果就重试发布、支付或写库,可能制造重复副作用。
六、把失败终态建模清楚,恢复逻辑才有依据
实验没有把所有异常压成一个字符串,而是保留六种终态和每一步的审计记录。
| 终态 | 是否可直接重试 | 必须记录 |
|---|---|---|
completed |
否 | 结果、耗时、用量 |
cancelled |
通常否 | 谁取消、已完成步骤 |
deadline-exceeded |
视幂等性决定 | 截止点、剩余工作、副作用 |
too-many-steps |
否 | 实际次数、上限、最后工具 |
provider-rejected |
按状态码与策略 | Provider、模型、原始原因 |
tool-failed |
先查结果 | 工具名、参数摘要、副作用状态 |
ts
type StepRecord = {
name: string
kind: "model" | "tool"
attempt: number
allocatedMs: number
outcome: "completed" | "retrying" | "failed" | "timed-out"
sideEffectStarted: boolean
}
Google GenAI Python 2.17.0 把"工具调用次数耗尽"暴露为 TOO_MANY_TOOL_CALLS,说明步骤上限不是普通完成。Adapter 应保留 Provider 原始 finish reason,再映射到业务终态。
七、最小实验验证了什么
实验运行在 Bun 1.3.1、TypeScript 7.0.2、Biome 2.2.0。它用本地计时器和模拟步骤验证总预算传播,不调用真实模型或外部工具。
text
Biome: Checked 6 files. No fixes applied.
TypeScript --noEmit: passed
7 pass, 0 fail, 13 expect() calls
json
{"operations":3,"retries":1,"terminal":"completed"}
7 项测试覆盖共享 deadline、挂起工具超时、用户取消、瞬时错误重试、预算不足时拒绝退避、Step 上限,以及工具失败后的副作用标记。它不证明任何 SDK 当前版本已经正确实现。
八、接入现有 Agent Runtime 时怎么验收
用慢模型、永不返回的工具、429、数据库提交后断线做故障注入;再覆盖取消与完成同时发生、超时与工具回调同时发生、一个并行分支失败时同级分支是否停止。恢复 Worker 后还要检查旧 deadline、attempt、审批和副作用状态是否仍有效。
ts
expect(trace.terminal).toBe("deadline-exceeded")
expect(trace.lastStep).toBe("publish")
expect(trace.sideEffectStarted).toBe(true)
expect(trace.retryStarted).toBe(false)
观测至少记录 remaining_ms_before_step、allocated_step_ms、取消来源、attempt、step count、原始 finish reason、是否计费和副作用状态,同时避免记录凭据与完整敏感正文。
九、上线前检查表与验证边界
- 入口只生成一次绝对 deadline,后续层不重置;
- 每步预算取剩余时间与单步上限的较小值;
- 为持久化、Trace 和响应保留收尾时间;
- 父级取消传播到模型、工具、并行任务和退避;
- 取消、超时、Step 耗尽和工具失败是不同终态;
- 重试不刷新 deadline,预算不足时不启动退避;
- 写工具有幂等键、结果回查和副作用审计;
- 用挂起、慢响应、取消竞态和提交后断线做故障注入。
官方来源:Vercel AI SDK ai@7.0.58、Pydantic AI v2.26.0、Google GenAI Python v2.17.0。
验证边界:本文基于 2026-08-08 已核验的官方 Release,并完成框架无关的 TypeScript 实验;没有安装上述 SDK,没有调用真实模型、数据库、队列或发布工具,也没有验证分布式时钟、Provider 取消到账或线上延迟改善。