主题
异步任务编排与状态机
一句话:Agent 任务从分钟到小时级,远超 HTTP 超时,必须把任务状态外置为持久化状态机;编排层用 DAG 声明依赖、用检查点实现恢复、用幂等键防止重复——异步编排是调度层,Agent Loop 是执行单元,两者通过 Task State 连接。
适用:2026 年生产级 Agent 任务编排系统;具体引擎(Temporal、Inngest、Restate 等)能力以官方文档为准。
为什么需要
Agent 任务的典型特征是长跑:一次 Coding Agent 任务可能涉及数十次文件读写、多次测试执行、跨模型的代码审查,耗时从几分钟到几小时。HTTP 请求的默认超时是 30–60 秒,即使拉长到 5 分钟也远远不够。更关键的是,长任务必然遇到进程崩溃、节点重启、网络中断——如果任务状态只存在于进程内存中,一次崩溃就丢失全部进展。
第二个问题是步骤间有依赖关系:代码生成必须在文件检索之后、测试执行必须在代码生成之后、代码审查必须在测试通过之后。这些依赖需要显式声明和自动调度,而不是靠人工串联或隐式顺序。临时脚本(for 循环 + sleep + try/catch)能跑通 demo,但无法处理崩溃恢复、并发控制、超时管理、审计追踪——这些是生产级编排的核心诉求。
核心机制
任务状态机:10 态模型
下图给出任务生命周期的完整状态集合与转移;读法是看每个出口由什么事件触发,而不是按状态名的书写顺序理解:
| 状态 | 含义 | 转换条件 | 持久化要求 |
|---|---|---|---|
| created | 任务已创建,尚未入队 | 幂等键检查通过 | 写入数据库 |
| queued | 已入队等待调度 | 调度器拉取 | 更新状态+时间戳 |
| running | 正在执行 | 执行器领取 | 心跳上报 |
| waiting | 等待外部事件 | 需要人工确认或外部回调 | 保存完整上下文 |
| succeeded | 成功完成 | 任务结果有证据 | 记录结果+证据 |
| failed | 失败不可恢复 | 重试耗尽或不可重试错误 | 记录失败原因 |
| cancelled | 主动取消 | 用户或系统取消 | 记录取消原因 |
| timed_out | 超时 | 超过时间预算 | 记录超时点 |
| expired | 过期 | 超过生存期(含排队) | 记录过期原因 |
| paused | 暂停 | 人工暂停 | 保存完整上下文+断点 |
关键原则:每个状态转换都必须持久化。进程崩溃后,新执行器从数据库读取最后状态继续执行,而不是从内存恢复。幂等键(idempotency key)防止重复创建——相同幂等键的请求只创建一个任务。
DAG 型编排:声明依赖,自动调度
图里先找两个汇聚点:C 必须等 A 与 B 都完成,F 必须等 D 与 E 都完成;D 与 E 之间没有边,是这条链上唯一可并肩推进的一段。
三种依赖类型:
| 依赖类型 | 含义 | 示例 |
|---|---|---|
| 数据依赖 | 下游任务需要上游的输出作为输入 | 代码生成需要文件检索的结果 |
| 时序依赖 | 下游必须在上游完成后才能开始 | 测试必须在代码生成之后 |
| 条件依赖 | 下游是否执行取决于上游的结果 | 代码审查只在测试通过后执行 |
调度器按拓扑排序执行,支持并行分支(上例中 D 和 E 可并行)。失败处理:可重试错误按指数退避重试(最多 N 次)、非关键步骤跳过继续、关键步骤失败则整分支取消。
持久化工作流引擎:与临时脚本的本质区别
| 特性 | 临时脚本 | 持久化工作流引擎 |
|---|---|---|
| 状态存储 | 进程内存 | 外部数据库 |
| 崩溃恢复 | 从头开始或丢失 | 从最后检查点恢复 |
| 审计追踪 | 日志文件 | 结构化状态转换记录 |
| 超时管理 | 手动 try/catch | 引擎内置,按状态粒度 |
| 并发控制 | 自行实现 | 引擎调度,支持并行分支 |
| 暂停/恢复 | 不支持 | 保存完整上下文,任意状态暂停 |
检查点机制:每个步骤完成后,引擎将当前状态(包括中间结果、上下文、预算消耗)持久化为检查点。崩溃恢复时,从最近的检查点继续,而不是从头执行。
恢复语义:以任务为单位恢复,不是以进程为单位。进程崩溃后,新进程从数据库读取任务状态继续执行;已发出的副作用(已写入的文件、已发送的请求)不会自动回滚。
已发副作用的处理:
- 幂等重试:副作用操作设计为幂等的(相同请求多次执行结果相同),重试不会造成重复影响
- 显式补偿:非幂等操作需要补偿逻辑(如已创建的资源需要显式删除)
长任务工程要点
| 要点 | 说明 | 不做的后果 |
|---|---|---|
| Task ID 生命周期 ≠ HTTP 连接 | 任务 ID 独立于任何连接,HTTP 断开任务继续 | 客户端超时导致任务丢失 |
| 心跳与孤儿检测 | 执行器定期上报心跳,超时未上报视为孤儿任务 | 进程崩溃后任务永远卡在 running |
| 预算按子任务派生 | 总预算分解到每个子任务,子任务独立计量 | 单个子任务耗尽全部预算 |
| 暂停/恢复保存完整上下文 | 包括中间结果、计划、预算剩余、工具状态 | 恢复后上下文丢失,任务行为不一致 |
| 超时分层 | 连接超时 / 首包超时 / 首 Token 超时 / 流式空闲超时 / 总时长 | 只设一个超时,要么误杀长任务,要么漏检卡死 |
与 Agent Loop 的关系
关键区分:Agent Loop 是执行单元(负责单步推理与工具调用),异步编排是调度层(负责任务间依赖与恢复),两者通过 Task State 连接。顺着箭头走一圈就能看出这个分工:调度器只把子任务交给 Loop,Loop 不直接写检查点,状态经 Task State 落库后才由检查点管理持久化,恢复点再回到调度器。两层之间只有这四条边,没有共享内存,状态交接面只有 Task State——所以两者可以分别升级、分别排查,也能解释为什么“任务卡在 running 却查不到进行到哪一步”通常出在状态写回这一跳。
实战场景
案例:长任务在请求线程里同步等待——一次 504 造出三个重复任务
现象:Coding Agent 平台把“跑一次完整任务”做成同步 HTTP 接口,任务实际耗时从几分钟到几小时,网关连接超时按 60 秒配。客户端在 60 秒统一拿到 504,上游按可重试错误重试三次,每次重试都新建一个任务。上线首周这类“客户端已断开、服务端还在跑”的任务 1200 余次(经验值,需校准),队列被自家重试占满,团队按模型故障排查了两天。
排查过程:按层给判据而不给结论。网关层——504 全部落在同一超时点,无模型侧限流码,网关只是按配置断连,并没有错杀任务。执行层——抽查 Task ID,Agent Loop 在正常推进,工具调用成功、Token 按预期增长,模型层排除。编排层——把 Task ID 与请求线程存活期做对照,发现 Task ID 由请求线程创建并随线程一起回收,接口一超时任务就永久停在 running,既不推进也不失败;同时命中正文“客户端超时导致任务丢失”和“进程崩溃后任务永远卡在 running”两行。判据:HTTP 断开后任务是否还活着,看数据库里该 Task ID 的下一步转换有没有发生,不看客户端有没有收到结果。
对症做法:创建与执行拆成两次交互,请求线程只做校验、幂等键检查与入队。
yaml
submit: {handler_timeout_ms: 2000, response: {task_id: required, status: queued}} # 请求线程不执行任何步骤
result: {mode: [poll, event_stream], resume_by: task_id} # 客户端轮询或订阅;总时长与生存期在任务侧计量复盘要点:正文说只设一个超时会“误杀长任务或漏检卡死”,这次是第三种形态——连接超时替任务做了终止决定,做完还不写状态。修复不在调大超时数值,而在把执行搬离请求线程,并给 running 配上 30 秒无心跳的孤儿检测。
案例:滚动发布丢中间态——检查点写在成功时刻而不是每步
现象:节点滚动发布,一次跑到 38 分钟的长任务被杀,新执行器把它当新任务从头执行。当天 9 个任务重复消耗约 3.2 倍预算(经验值,需校准),2 个因超出生存期被判 expired;复盘时没人能回答“它当时进行到哪一步”。
排查过程:先查编排层有没有该任务的结构化状态转换记录——库里只有 created→queued→running 三条,没有任何步骤级记录。再查执行器:中间结果、已消耗预算、计划、工具状态全部挂在进程内存的 TaskContext 上;配置写的是 strategy: after_each_step,代码实现是任务成功时写一次。归因落在 Runtime 的实现缺口,不在模型不稳定,也不是网关重试放大。判据:崩溃恢复的验证方式是杀掉执行器、看新执行器从哪里读——读得到最近检查点才算状态外置,读不到就是临时脚本换了个名字。
对症做法:检查点落到每一步,并按完整上下文写全,与 paused、waiting 的持久化要求同一口径。
python
def run_step(task, step):
if step.id in task.state.completed_steps:
return task.state.output(step.id) # 恢复时跳过,直接取持久化输出
result = execute(task, step)
task.state.transition("running", "running", # 每次转换都落库
checkpoint={"step": step.id, "outputs": result.refs,
"budget_used": task.budget.used(),
"plan": task.plan.digest(),
"tools": task.tools.snapshot()})复盘要点:正文对比表里“状态存储:进程内存”和“审计追踪:日志文件”是同一件事的两面。“恢复以任务为单位”只有在检查点字段完整时才成立,否则它退化成以进程为单位从头重跑。
案例:恢复重放已提交副作用——同一笔退款出账两次
现象:退款任务在 waiting 落库后人工确认到达、恢复执行,执行器在出账之后、写结果之前崩溃。恢复时该步骤没有完成记录,整步被重放,同一笔退款出账两次,涉及 37 单(经验值,需校准);4 小时后对账报警才捞出,中间无人发现。
排查过程:先排除编排层重复创建——同一幂等键在库里只查到一个 Task ID,不是重复任务。再比执行层的工具调用记录:同一 step_id 有两次外呼,请求体一致、时间戳相差 11 秒(经验值,需校准),是恢复重放而不是并发。归因落在“已发出的副作用不会自动回滚”:状态机回滚了,钱没有退回来。判据:副作用是否已提交只能靠记录证明,不能靠状态推断——任务停在 running 不告诉外界账有没有出。
对症做法:按正文两条出路(幂等重试或显式补偿)先分类,再决定恢复动作。
| 副作用类型 | 恢复语义 | 落地要求 |
|---|---|---|
| 幂等(读、带幂等键的可重入写入) | 直接重试 | 下游按幂等键去重;调用侧单声明无效 |
| 非幂等、可补偿(创建资源、发消息) | 先补偿再重试 | 补偿动作显式注册,能取到已创建资源引用 |
| 非幂等、不可补偿(出账、对外发送) | 不自动重放 | 缺提交记录时转 waiting,人工裁决 |
python
def call_side_effect(task, op):
key = f"{task.idempotency_key}:{task.step_id}:{op.signature}"
if store.put_if_absent("intent", key, op.args): # 先写意图,再外呼
resp = op.invoke(idempotency_key=key)
store.mark_committed(key, ref=resp.external_id) # 提交证据绑定外部引用
return resp
prior = store.get("intent", key)
return reconcile(prior) if not prior.committed else EffectRef(prior.committed)复盘要点:坑不在缺重试逻辑,在于把恢复语义理解成“回到某个状态”而不是“回到某个状态加上已知已发生的事实”。检查点记步骤完成,意图记录记副作用已提交及其外部引用;缺后者,恢复只能二选一:重放或者卡住。
最小上手
yaml
task_orchestration:
state_machine:
states: [created, queued, running, waiting, succeeded, failed,
cancelled, timed_out, expired, paused]
persistence: every_transition
idempotency_key: required_for_creation
dag:
tasks:
- { id: file_retrieval, deps: [], timeout_s: 60, retry: 3 }
- { id: code_gen, deps: [file_retrieval, spec_load], timeout_s: 300, budget_tokens: 50000 }
- { id: test_exec, deps: [code_gen], timeout_s: 180, on_fail: cancel_downstream }
- { id: review, deps: [test_exec, static_analysis], condition: test_exec.status == succeeded }
checkpoint: { strategy: after_each_step, storage: database }
recovery:
unit: task # 不是进程
side_effects: idempotent_retry_or_compensate
orphan_detection: heartbeat_timeout_s: 30验收标准:状态机 10 态完整、幂等键防重复、DAG 并行调度、崩溃检查点恢复、心跳孤儿检测。
生产动作
- [ ] 实现 10 态状态机,每个转换持久化到数据库
- [ ] 幂等键机制上线,防止重复创建任务
- [ ] DAG 编排器支持三种依赖(数据/时序/条件)
- [ ] 检查点机制:每步完成后持久化完整上下文
- [ ] 心跳与孤儿检测:30 秒无心跳标记为孤儿
- [ ] 预算按子任务派生,子任务独立计量
- [ ] 超时分层:连接/首包/首 Token/流式空闲/总时长
- [ ] 已发副作用处理:幂等重试或显式补偿
- [ ] 编排层与 Agent Loop 通过 Task State 解耦
边界与误区
| 适用场景 | 不适用场景 |
|---|---|
| 任务耗时超过 60 秒的 Agent 应用 | 毫秒级响应的实时推理服务 |
| 多步骤有依赖关系的复杂任务 | 单步无状态的简单问答 |
| 需要崩溃恢复和审计追踪的生产环境 | 一次性实验或 demo |
常见误区:
- "临时脚本能跑就行" —— 临时脚本无法处理崩溃恢复、并发控制、审计追踪
- "恢复是以进程为单位" —— 恢复以任务为单位,进程崩溃后新进程从检查点继续
- "已发副作用会自动回滚" —— 不会,必须设计幂等重试或显式补偿
- "Task ID 绑定 HTTP 连接" —— Task ID 独立于连接,HTTP 断开任务继续
- "只设一个总超时" —— 超时必须分层,否则长任务被误杀或卡死任务漏检
讲清楚
异步任务编排解决 Agent 长跑问题:任务从分钟到小时级,远超 HTTP 超时,必须把状态外置为持久化状态机(10 态模型)。每个状态转换持久化,幂等键防止重复创建。DAG 编排声明任务间依赖(数据/时序/条件),调度器按拓扑排序执行并行分支。持久化工作流引擎与临时脚本的本质区别是状态外置、可恢复、可审计。检查点机制每步完成后持久化,崩溃恢复以任务为单位从检查点继续。已发副作用不会自动回滚,必须幂等重试或显式补偿。长任务工程要点包括 Task ID 独立于 HTTP 连接、心跳孤儿检测、预算按子任务派生、超时分层。编排层是调度层,Agent Loop 是执行单元,两者通过 Task State 连接。
适用:2026 年长任务编排与持久化工作流选型;状态数与超时阈值需按任务形态校准 | 初稿 2026-10