长任务 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 才真正从一次性的演示,变成可以被托付任务的系统。