logo

长任务 Agent:检查点、恢复与幂等执行

Published on

长任务 Agent:检查点、恢复与幂等执行

短对话里的 Agent 很容易让人产生一种错觉:模型想一想,调用一个工具,返回结果,任务就结束了。真正的业务任务往往不是这样。整理一批合同、分析一个代码库、同步多个系统、生成一份报告,可能要运行几分钟甚至几小时,中间还会遇到网络超时、服务限流、进程重启和用户关闭页面。

我见过一个很尴尬的线上问题:Agent 已经成功创建了一个远程任务,但本地在保存结果前崩溃了。恢复后它不知道上一步是否成功,于是又创建了一次。用户最终得到两个订单、两封邮件或两份报告,系统却只认为自己执行了一次。

长任务 Agent 的核心不是让模型“记住更多”,而是把任务变成一个可以持久化、暂停、恢复、重试和审计的工作流。模型负责决定下一步,系统负责记住已经发生过什么,以及哪些动作绝对不能重复。

一、先把任务从对话里拿出来

长任务不能绑定在一次 HTTP 请求或一次 WebSocket 连接上。用户刷新页面,任务仍然应该继续;浏览器断线,服务端也不应该丢掉状态。

type AgentTask = {
  taskId: string
  tenantId: string
  goal: string
  status: 'queued' | 'running' | 'paused' | 'succeeded' | 'failed' | 'cancelled'
  currentStep: number
  totalSteps?: number
  checkpointId?: string
  createdAt: string
  updatedAt: string
}

创建任务时返回 taskId,后续通过查询接口、事件流或通知获取进度。不要让前端一直等待原始模型请求完成,长请求很容易被网关、负载均衡器或浏览器超时切断。

二、用状态机表达任务生命周期

任务状态不能靠几个布尔字段拼出来。建议明确规定哪些状态可以互相转换:

queued → running → succeeded
             ├──→ paused → running
             ├──→ failed → running
             └──→ cancelled

状态转换必须是受控的。例如已完成的任务不能因为一个迟到的重试消息重新变成 running;已经取消的任务不能悄悄继续执行新的外部动作。

function transition(task: AgentTask, next: AgentTask['status']) {
  const allowed = transitions[task.status] ?? []
  if (!allowed.includes(next)) {
    throw new Error(`非法状态转换: ${task.status} -> ${next}`)
  }
  return { ...task, status: next, updatedAt: new Date().toISOString() }
}

状态机的价值在故障时最明显。所有人都知道成功路径,真正需要设计的是“工具调用成功但响应丢失”“用户取消时动作正在执行”“恢复消息比旧消息先到”这些不舒服的情况。

三、检查点要保存什么

检查点不是简单保存一段模型文本,而是保存足够恢复任务的事实:

type Checkpoint = {
  checkpointId: string
  taskId: string
  stepIndex: number
  plan: Step[]
  completedSteps: CompletedStep[]
  pendingStep?: Step
  toolResults: Record<string, unknown>
  modelVersion: string
  stateVersion: number
  createdAt: string
}

至少要知道:计划是什么,哪些步骤已完成,工具返回了什么,当前正在执行哪一步,使用了哪个模型和 Prompt 版本。不要只保存“模型的思考过程”,因为自然语言不是可靠的事务日志。

检查点应该在有意义的边界创建:工具调用成功后、一个子任务完成后、状态发生不可逆变化前。保存太频繁会增加存储和写入成本,保存太少则恢复时需要重做大量工作。

四、最危险的是“成功了,但我没收到成功响应”

分布式系统里,调用结果通常有三种:成功并收到响应,失败并收到错误,已经执行但响应在网络里丢了。第三种最难处理。

因此每个可能产生副作用的工具调用都要有业务幂等键:

type ToolCommand = {
  taskId: string
  stepId: string
  operationId: string
  tool: string
  payload: unknown
}

const idempotencyKey = `${taskId}:${stepId}:${operationId}`

服务端或第三方 API 收到相同幂等键时,应该返回第一次执行的结果,而不是再执行一次。对于不支持幂等键的外部服务,可以在本地建立操作记录,执行前先检查状态;但这只能降低风险,无法凭空让一个不可查询的外部动作变得完全可靠。

创建订单、扣款、发邮件、删除文件、修改权限都属于高风险副作用。对这类动作,最好把“准备”和“提交”分开:Agent 先生成待执行计划和参数,经过用户确认后再提交,提交阶段使用幂等键并持久化结果。

五、重试不是遇到错误就再来一次

错误应该先分类:

  • 网络超时:可能已经执行,必须依赖幂等查询。
  • 429 限流:等待后重试,不能立即打满服务。
  • 5xx:可以指数退避,但要限制次数。
  • 参数错误:重试同一个请求没有意义,应修正或转人工。
  • 权限错误:不能靠重试绕过权限。
async function retryable<T>(run: () => Promise<T>, maxAttempts = 3) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await run()
    } catch (error) {
      if (!isTransient(error) || attempt === maxAttempts) throw error
      await sleep(backoff(attempt))
    }
  }
  throw new Error('unreachable')
}

指数退避还要加随机抖动,避免一批任务同时重试形成新的流量尖峰。每次重试都记录尝试次数和原因,后续才能判断是外部服务不稳定,还是 Agent 产生了错误参数。

六、恢复时不要盲目重跑整个计划

进程崩溃后,恢复器应该读取最近一个检查点,重新确认外部操作状态,再决定下一步。已经成功的步骤直接跳过;状态未知的步骤先查询;只有明确失败且可重试的步骤才重新执行。

读取 checkpoint
核对已完成步骤
查询状态未知的外部操作
重建当前上下文
继续下一个安全步骤

如果每次恢复都把完整对话重新塞给模型,成本会越来越高,也可能因为模型重新规划而偏离原路线。应该把持久化的结构化状态作为事实,把模型当作当前步骤的决策器,而不是让它重新发明整个任务历史。

七、进度要让用户看得懂

“Agent 正在思考”不是进度。用户需要知道完成了什么、正在做什么、还剩什么,以及是否需要自己确认。

type ProgressEvent = {
  taskId: string
  stepId: string
  status: 'started' | 'completed' | 'waiting' | 'failed'
  message: string
  timestamp: string
}

进度消息应尽量描述事实,例如“已读取 12/20 份文件”“等待你确认发送邮件”,不要把模型内部推理原文全部展示给用户。事件要有顺序号,客户端断线重连时可以从上一个序号继续拉取,避免进度跳来跳去。

用户取消任务也要定义语义:取消排队中的任务很简单,取消正在运行的工具则可能只能阻止后续步骤,无法撤回已经发生的外部动作。界面和状态都要诚实表达“已停止后续执行”,不要笼统写成“全部撤销”。

八、检查点本身也要版本化和保护

任务会运行很久,代码、模型和工具 Schema 可能在中途更新。恢复旧任务时,不能假设新版本完全兼容旧状态。检查点应保存 schema 版本,恢复器负责迁移或暂停请求人工处理。

同时,检查点可能包含用户文档、工具参数和第三方返回结果,要按照租户和权限隔离,不能为了方便让所有后台任务共享一张表。敏感字段要脱敏或加密,过期任务要有清理策略。

九、可靠性评测要模拟真实故障

不要只测试任务顺利完成。至少模拟:

  • LLM 在第 3 步超时;
  • 工具执行成功但响应丢失;
  • Worker 在写检查点前崩溃;
  • 同一消息重复投递;
  • 用户在高风险动作前取消;
  • 任务恢复时模型版本已经变化;
  • 外部服务长时间限流;
  • 两个 Worker 同时抢到同一个任务。

每个场景都要回答:会不会重复副作用?状态是否最终一致?用户能否看到真实结果?任务是否可以人工接管?这些问题比“正常路径跑通了”更能说明系统是否可靠。

总结:长任务 Agent 的记忆应该属于系统

我现在不会把一个运行几小时的 Agent 称为“一个很长的 Prompt”。Prompt 再长,也不能替代数据库里的状态、检查点和操作记录。模型可以帮助规划和决策,但任务已经完成了哪一步、外部动作是否发生过、失败后能不能安全重试,这些必须由系统用结构化事实保存。

检查点让任务能恢复,状态机让恢复有边界,幂等键防止副作用重复,分类重试让系统不至于越错越忙,进度事件让用户知道真实情况,版本和权限控制则保证长时间运行不会悄悄跨过数据边界。

长任务 Agent 的成熟标志,不是它能连续思考多久,而是它在断网、崩溃、重复消息和不确定结果面前,仍然知道自己做过什么、下一步能不能做,以及什么时候应该停下来请人判断。把这些基础设施补上以后,Agent 才真正从一次性的演示,变成可以被托付任务的系统。

🤪 您也可以编辑此页: