第 12 / 15 章 约 7 分钟
任务调度与容错
处理调度、取消、超时、恢复与容错。
内容同步:2026年10月25日 · 文档版本 6
这个模块在 Agent 里的位置
这个模块决定 Agent 是否可停止、可恢复、可幂等。它影响:
- 任务是否会挂死。
- 用户取消后是否还有副作用。
- worker 重启后是否重复执行。
- 旧 worker 是否会覆盖新 worker。
- 外部动作是否重复发生。
一个不能被可靠停止和恢复的 Agent,不能称为生产系统。
先记住几个概念
| 概念 | 含义 | 为什么重要 |
|---|---|---|
| Model timeout | 单次模型请求超时 | 控制单次调用 |
| Tool timeout | 单次工具执行超时 | 控制外部依赖 |
| Run timeout | 整个逻辑任务超时 | 控制总资源 |
| AbortSignal | 取消传播信号 | 停止模型和工具 |
| Lease | worker 执行租约 | 判断谁有权继续 |
| Generation | 执行代数 | 防止旧 worker 覆盖新 worker |
| Idempotency key | 外部动作幂等键 | 防止重复执行 |
从一个真实问题开始:一个工具卡住,整个 run 挂了 40 分钟
一个 Agent 需要查询 30 亿行事件。
预期:
查聚合表 -> 2 秒
实际:
工具误扫 raw 表 -> 40 分钟
系统没有任何超时:
- 模型请求已结束。
- 工具还在执行。
- run 一直处于 running。
- 用户无法取消。
- worker 被这个任务占用。
- 队列开始堆积。
根因是只有模型超时,没有 tool timeout 和 run timeout。
三层超时必须分开
| 层 | 语义 | 示例 |
|---|---|---|
| model call | 单次模型请求 | 60s |
| tool call | 单次工具执行 | 10s |
| run | 整个逻辑任务,含压缩和子任务 | 10min |
关键点:
- run timeout 不能被 compaction 重置。
- tool timeout 不能被子任务无限叠加。
- model timeout 不代表任务完成。
- 超时后必须有终态,不能永远 running。
取消不是改 UI 状态
用户点取消后,系统至少要做:
runtime 标记 cancelling
-> abort model request
-> abort tool signal
-> stop subagents
-> block pending action
-> write terminal cancelled
如果只改 UI:
- 模型还在生成。
- 工具还在查库。
- 子任务还在跑。
- pending action 可能继续执行。
典型事故:
页面显示已取消,几分钟后用户收到一封任务完成邮件。
AbortSignal 必须传到工具
工具接口不能只有 input。
错误设计:
async function run(input: Input): Promise<Output>;
正确设计:
async function run(
input: Input,
context: {
signal: AbortSignal;
toolCallId: string;
},
): Promise<Output>;
工具内部要:
- 把 signal 传给数据库或 HTTP client。
- 在长循环中检查 signal.aborted。
- 超时后清理临时资源。
- 返回稳定的 TIMEOUT 或 CANCELLED 错误。
worker 恢复与 fencing
场景:
- worker A claim 了 run。
- worker A 崩溃。
- lease 过期。
- worker B 接管。
- worker A 恢复。
如果 A 恢复后继续写结果,会覆盖 B 的状态。
正确做法:
worker A: generation=1
worker B: generation=2
A 恢复后只能写 immutable checkpoint
B 才能写当前结果
数据库字段:
alter table agent_runs
add column timeout_at timestamptz,
add column cancel_requested_at timestamptz,
add column terminal_status text,
add column terminal_at timestamptz;
create index agent_runs_timeout_idx
on agent_runs (status, timeout_at)
where status in ('running', 'compacting', 'cancelling');
恢复前要检查什么
恢复不是简单“继续跑”,至少要检查:
- lease 是否有效。
- generation 是否当前。
- checkpoint 是否完整。
- 外部动作是否已执行。
- run 是否已被用户取消。
- 是否已超过 run timeout。
一个恢复记录:
{
"runId": "run-9f27",
"recovery": {
"generation": 4,
"checkpointRef": "s3://checkpoints/run-9f27/gen-4.json",
"canResume": true,
"externalActionsExecuted": ["send-email-771"]
}
}
如果外部动作已执行,不能简单重试。
外部动作必须有幂等键
动作表:
create table agent_actions (
id bigint primary key,
run_id bigint not null,
action_key text not null,
status text not null,
result jsonb,
created_at timestamptz not null,
unique (run_id, action_key)
);
示例:
notify:run-9f27:owner:9182
只要 action key 相同:
- 网络超时后重试不会重复发邮件。
- worker 恢复后不会重复执行。
- 用户重复点击不会重复写数据。
超时后的重试策略
不能所有超时都自动重试。
| 错误 | 是否重试 | 原因 |
|---|---|---|
| model timeout | 可重试一次 | 可能是瞬时 |
| tool timeout | 谨慎 | 可能是查询设计错误 |
| run timeout | 不自动重试 | 成本会翻倍 |
| permission denied | 不重试 | 权限不会自动恢复 |
| budget exceeded | 不重试 | 预算不会恢复 |
重试前必须确认:
- 幂等。
- 预算允许。
- 生成代数仍当前。
- 用户没有取消。
和传统后端的关系
这套机制和后端任务系统高度同构:
| Agent 里的概念 | 传统后端里的对应物 |
|---|---|
| Run | 异步 job |
| Worker | job worker |
| Lease | 租约 / 心跳 |
| Generation | 版本号 / fencing token |
| Retry policy | 重试队列 |
| Dead letter | 死信队列 |
| Backpressure | 队列限流 |
| Idempotency key | 幂等键 |
如果你做过消息队列、任务调度或分布式 worker,这一章的大部分经验都可以直接复用。
常见工程错误
| 错误 | 实际后果 | 正确做法 |
|---|---|---|
| 只有模型超时 | 工具挂死 | 三层 timeout |
| 取消只改 UI | 后台继续执行 | AbortSignal 传播 |
| 恢复不校验 generation | 旧 worker 覆盖新 worker | fencing |
| 外部动作无幂等 | 重复发通知 / 写数据 | action key |
| 超时自动重试 | 成本翻倍 | retry policy |
| run 永远 running | 任务挂死 | terminal status |
上线前检查
- 三层 timeout 显式。
- cancel 传播到 model / tool / subagent / action。
- run 有 terminal status。
- 恢复有 generation。
- 外部动作幂等。
- 超时重试有策略。
- 取消事件可审计。