跳到正文

异步任务编排与状态机 ​

一句话: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

常见误区:

  1. "临时脚本能跑就行" —— 临时脚本无法处理崩溃恢复、并发控制、审计追踪
  2. "恢复是以进程为单位" —— 恢复以任务为单位,进程崩溃后新进程从检查点继续
  3. "已发副作用会自动回滚" —— 不会,必须设计幂等重试或显式补偿
  4. "Task ID 绑定 HTTP 连接" —— Task ID 独立于连接,HTTP 断开任务继续
  5. "只设一个总超时" —— 超时必须分层,否则长任务被误杀或卡死任务漏检

讲清楚 ​

异步任务编排解决 Agent 长跑问题:任务从分钟到小时级,远超 HTTP 超时,必须把状态外置为持久化状态机(10 态模型)。每个状态转换持久化,幂等键防止重复创建。DAG 编排声明任务间依赖(数据/时序/条件),调度器按拓扑排序执行并行分支。持久化工作流引擎与临时脚本的本质区别是状态外置、可恢复、可审计。检查点机制每步完成后持久化,崩溃恢复以任务为单位从检查点继续。已发副作用不会自动回滚,必须幂等重试或显式补偿。长任务工程要点包括 Task ID 独立于 HTTP 连接、心跳孤儿检测、预算按子任务派生、超时分层。编排层是调度层,Agent Loop 是执行单元,两者通过 Task State 连接。

适用:2026 年长任务编排与持久化工作流选型;状态数与超时阈值需按任务形态校准 | 初稿 2026-10

把 AI 工程讲到既懂也会用