Agent 工作流重放与断点续传:失败任务不必从头跑(续篇)

📅 2026/7/30 3:17:29 👁️ 阅读次数 📝 编程学习
Agent 工作流重放与断点续传:失败任务不必从头跑(续篇)

Agent 工作流重放与断点续传:失败任务不必从头跑(续篇)

场景痛点

Agent执行一个10步工作流。第8步调用外部API超时失败。Agent从第1步重新执行。前7步的结果全部丢弃。

第1步查询数据库耗时30秒。第3步调用AI模型生成报告耗时2分钟。第5步执行数据清洗耗时1分钟。前7步合计约4分钟。第8步超时后重跑4分钟的前置步骤,浪费时间和token。

更糟的情况:第7步是"发送邮件通知"。重跑时又发了一封重复邮件。第2步是"删除临时文件"。重跑时文件已经不存在了——操作不可重复执行。

核心矛盾:步骤间的数据依赖与执行副作用。重跑不只是时间浪费,还可能产生重复副作用(发邮件、写数据库)或失败副作用(删除已删除的文件)。

底层机制与原理剖析

断点续传的本质是执行状态的持久化与恢复。每个步骤完成后,其输出数据和执行状态被保存。失败时从最后一个成功步骤继续,而非从起点重新开始。

关键机制:

  1. 步骤状态机。每个步骤有明确的状态:pending → running → completed | failed | skipped。步骤完成后状态和输出被持久化到CheckpointStore。恢复时从completed状态的最晚步骤开始向后执行。

  2. 副作用分类。步骤分为两类:可重放(纯计算,无外部副作用)和不可重放(有外部副作用如发邮件、写数据库)。可重放步骤可以安全重新执行。不可重放步骤必须用幂等性保证——即使重跑也不会产生重复效果。

  3. Checkpoint原子性。步骤状态持久化必须是原子操作。步骤执行了一半就崩溃——输出数据可能不完整。需要"全部写完才标记completed"的原子保证。

  4. 步骤间数据依赖图。步骤4依赖步骤3的输出。恢复时必须确保步骤3的输出仍在CheckpointStore中——不是简单地从步骤4重新执行,而是先验证前置依赖的完整性。

生产级代码实现

WorkflowCheckpointManager:断点续传核心

// workflow/checkpoint-manager.ts import { Redis } from 'ioredis'; import { createHash } from 'crypto'; type StepStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped'; interface StepCheckpoint { stepId: string; stepName: string; status: StepStatus; output: any; // 步骤输出数据 startedAt: string; completedAt: string | null; retryCount: number; sideEffectType: 'replayable' | 'idempotent' | 'non_replayable'; // 副作用分类 outputHash: string; // 输出数据的hash,用于完整性校验 } interface WorkflowCheckpoint { workflowId: string; taskId: string; steps: StepCheckpoint[]; currentStepIndex: number; createdAt: string; updatedAt: string; globalRetryCount: number; maxGlobalRetries: number; } class WorkflowCheckpointManager { private redis: Redis; private readonly CHECKPOINT_TTL = 7 * 24 * 3600; // checkpoint保留7天 constructor(redisUrl: string) { this.redis = new Redis(redisUrl); } // 创建工作流checkpoint async createCheckpoint( workflowId: string, taskId: string, stepDefinitions: StepDefinition[] ): Promise<WorkflowCheckpoint> { const checkpoint: WorkflowCheckpoint = { workflowId, taskId, steps: stepDefinitions.map(def => ({ stepId: def.id, stepName: def.name, status: 'pending', output: null, startedAt: '', completedAt: null, retryCount: 0, sideEffectType: def.sideEffectType, outputHash: '' })), currentStepIndex: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), globalRetryCount: 0, maxGlobalRetries: 3 }; const key = `checkpoint:${workflowId}:${taskId}`; await this.redis.setex(key, this.CHECKPOINT_TTL, JSON.stringify(checkpoint)); return checkpoint; } // 标记步骤开始执行 async markStepRunning(workflowId: string, taskId: string, stepIndex: number): Promise<void> { const checkpoint = await this.loadCheckpoint(workflowId, taskId); if (!checkpoint) throw new Error('Checkpoint不存在'); checkpoint.steps[stepIndex].status = 'running'; checkpoint.steps[stepIndex].startedAt = new Date().toISOString(); checkpoint.currentStepIndex = stepIndex; checkpoint.updatedAt = new Date().toISOString(); await this.saveCheckpoint(workflowId, taskId, checkpoint); } // 标记步骤完成(原子操作:输出+hash+状态一起写) // 为什么要求原子性:步骤输出写了一半就崩溃,后续恢复读到不完整数据会引发错误 async markStepCompleted( workflowId: string, taskId: string, stepIndex: number, output: any ): Promise<void> { const checkpoint = await this.loadCheckpoint(workflowId, taskId); if (!checkpoint) throw new Error('Checkpoint不存在'); // 计算输出hash用于完整性校验 const outputHash = createHash('sha256') .update(JSON.stringify(output)) .digest('hex'); checkpoint.steps[stepIndex].status = 'completed'; checkpoint.steps[stepIndex].output = output; checkpoint.steps[stepIndex].completedAt = new Date().toISOString(); checkpoint.steps[stepIndex].outputHash = outputHash; checkpoint.updatedAt = new Date().toISOString(); await this.saveCheckpoint(workflowId, taskId, checkpoint); } // 标记步骤失败 async markStepFailed( workflowId: string, taskId: string, stepIndex: number, error: string ): Promise<void> { const checkpoint = await this.loadCheckpoint(workflowId, taskId); if (!checkpoint) throw new Error('Checkpoint不存在'); checkpoint.steps[stepIndex].status = 'failed'; checkpoint.steps[stepIndex].retryCount++; checkpoint.globalRetryCount++; checkpoint.updatedAt = new Date().toISOString(); // 将错误信息存入步骤输出(用于调试) checkpoint.steps[stepIndex].output = { error }; await this.saveCheckpoint(workflowId, taskId, checkpoint); } // 从断点恢复:找到最后一个completed步骤,验证前置依赖完整性 async resumeFromCheckpoint( workflowId: string, taskId: string ): Promise<ResumeResult> { const checkpoint = await this.loadCheckpoint(workflowId, taskId); if (!checkpoint) throw new Error('Checkpoint不存在'); // 超过全局重试上限,不再恢复 if (checkpoint.globalRetryCount >= checkpoint.maxGlobalRetries) { return { action: 'abort', reason: `全局重试次数已达上限${checkpoint.maxGlobalRetries}`, checkpoint }; } // 找到恢复起点:最后一个completed步骤的下一个步骤 let resumeFromIndex = 0; for (let i = checkpoint.steps.length - 1; i >= 0; i--) { if (checkpoint.steps[i].status === 'completed') { // 验证输出完整性:hash校验 const currentHash = createHash('sha256') .update(JSON.stringify(checkpoint.steps[i].output)) .digest('hex'); if (currentHash !== checkpoint.steps[i].outputHash) { // 输出数据被篡改或不完整——此步骤不可信,从更早步骤恢复 // 为什么不直接报错退出:数据不完整可能是因为Redis写入未完成, // 回退到更早的完整步骤是安全的恢复策略 checkpoint.steps[i].status = 'failed'; checkpoint.steps[i].output = null; continue; } resumeFromIndex = i + 1; break; } } // 检查恢复起点的副作用类型 const resumeStep = checkpoint.steps[resumeFromIndex]; if (resumeStep?.sideEffectType === 'non_replayable') { // 不可重放步骤不能安全重试——转为人工介入模式 // 为什么不允许重试non_replayable步骤:发邮件重试会重复发送, // 写数据库重试可能违反业务约束 return { action: 'human_intervention', reason: `步骤${resumeStep.stepName}为non_replayable类型,不能安全重试`, failedStepIndex: resumeFromIndex, checkpoint }; } // 清理后续步骤的状态——它们可能在上次执行中处于中间状态 for (let i = resumeFromIndex; i < checkpoint.steps.length; i++) { if (checkpoint.steps[i].status !== 'pending') { checkpoint.steps[i].status = 'pending'; checkpoint.steps[i].output = null; checkpoint.steps[i].outputHash = ''; } } checkpoint.currentStepIndex = resumeFromIndex; checkpoint.updatedAt = new Date().toISOString(); await this.saveCheckpoint(workflowId, taskId, checkpoint); // 构建恢复上下文:提供前置步骤的输出数据 const context: Record<string, any> = {}; for (let i = 0; i < resumeFromIndex; i++) { if (checkpoint.steps[i].status === 'completed') { context[checkpoint.steps[i].stepId] = checkpoint.steps[i].output; } } return { action: 'resume', resumeFromIndex, context, // 前置步骤输出数据 checkpoint }; } // 获取指定步骤的缓存输出(跳过该步骤的重新执行) async getCachedOutput( workflowId: string, taskId: string, stepIndex: number ): Promise<any> { const checkpoint = await this.loadCheckpoint(workflowId, taskId); if (!checkpoint) return null; const step = checkpoint.steps[stepIndex]; if (step.status !== 'completed') return null; // hash校验确保数据完整性 const currentHash = createHash('sha256') .update(JSON.stringify(step.output)) .digest('hex'); if (currentHash !== step.outputHash) { return null; // 数据不完整,不能使用缓存 } return step.output; } private async loadCheckpoint( workflowId: string, taskId: string ): Promise<WorkflowCheckpoint | null> { const key = `checkpoint:${workflowId}:${taskId}`; const data = await this.redis.get(key); return data ? JSON.parse(data) : null; } private async saveCheckpoint( workflowId: string, taskId: string, checkpoint: WorkflowCheckpoint ): Promise<void> { const key = `checkpoint:${workflowId}:${taskId}`; // 使用Redis的SET+EXPIRE原子操作确保TTL不丢失 await this.redis.setex(key, this.CHECKPOINT_TTL, JSON.stringify(checkpoint)); } } interface ResumeResult { action: 'resume' | 'abort' | 'human_intervention'; resumeFromIndex?: number; context?: Record<string, any>; reason?: string; failedStepIndex?: number; checkpoint: WorkflowCheckpoint; } interface StepDefinition { id: string; name: string; sideEffectType: 'replayable' | 'idempotent' | 'non_replayable'; }

ResumableWorkflowExecutor:可恢复的工作流执行器

// workflow/resumable-executor.ts import { WorkflowCheckpointManager, StepDefinition } from './checkpoint-manager'; class ResumableWorkflowExecutor { private stepHandlers: Map<string, StepHandler> = new Map(); constructor(private checkpointManager: WorkflowCheckpointManager) {} // 注册步骤处理器 registerHandler(stepId: string, handler: StepHandler): void { this.stepHandlers.set(stepId, handler); } // 执行工作流(新建或恢复) async execute( workflowId: string, taskId: string, steps: StepDefinition[], resumeFrom?: ResumeResult ): Promise<WorkflowResult> { // 如果没有恢复上下文,创建新checkpoint if (!resumeFrom) { await this.checkpointManager.createCheckpoint(workflowId, taskId, steps); } const startIndex = resumeFrom?.resumeFromIndex ?? 0; const context = resumeFrom?.context ?? {}; for (let i = startIndex; i < steps.length; i++) { const step = steps[i]; // 标记步骤running await this.checkpointManager.markStepRunning(workflowId, taskId, i); const handler = this.stepHandlers.get(step.id); if (!handler) { await this.checkpointManager.markStepFailed(workflowId, taskId, i, `未注册步骤处理器: ${step.id}`); break; } try { // 执行步骤,传入前置步骤的输出作为上下文 // 为什么传入context而非让步骤自己去checkpoint取:减少步骤对checkpoint系统的依赖, // 步骤只需要关心输入数据,不需要关心持久化机制 const output = await handler.execute(context, { timeoutMs: handler.timeoutMs ?? 30000, retryOnTimeout: step.sideEffectType !== 'non_replayable' }); // 标记完成并缓存输出 await this.checkpointManager.markStepCompleted(workflowId, taskId, i, output); // 将输出加入上下文,供后续步骤使用 context[step.id] = output; } catch (error: any) { await this.checkpointManager.markStepFailed(workflowId, taskId, i, error.message); // 尝试从断点恢复 const resumeResult = await this.checkpointManager.resumeFromCheckpoint( workflowId, taskId ); if (resumeResult.action === 'resume') { // 自动重试:从断点恢复后重新执行 // 为什么递归调用而非循环继续:恢复可能需要重新初始化上下文, // 递归调用确保状态干净 return this.execute(workflowId, taskId, steps, resumeResult); } else if (resumeResult.action === 'human_intervention') { // 不可自动恢复,返回人工介入请求 return { status: 'needs_human_intervention', failedStep: resumeResult.failedStepIndex!, reason: resumeResult.reason, checkpoint: resumeResult.checkpoint }; } else { // 全局重试上限,任务失败 return { status: 'failed', reason: resumeResult.reason, checkpoint: resumeResult.checkpoint }; } } } return { status: 'completed', context }; } } interface StepHandler { timeoutMs?: number; execute(context: Record<string, any>, options: { timeoutMs: number; retryOnTimeout: boolean }): Promise<any>; } interface WorkflowResult { status: 'completed' | 'failed' | 'needs_human_intervention'; context?: Record<string, any>; reason?: string; failedStep?: number; checkpoint?: WorkflowCheckpoint; }

幂等性保障装饰器

// workflow/idempotent-decorator.ts import { Redis } from 'ioredis'; // 为non_replayable步骤添加幂等性保障 // 为什么用装饰器而非侵入式修改:步骤处理器不应该知道幂等性机制的存在, // 保持业务逻辑与工程机制的分离 class IdempotentDecorator implements StepHandler { private redis: Redis; constructor( private innerHandler: StepHandler, redisUrl: string ) { this.redis = new Redis(redisUrl); } async execute( context: Record<string, any>, options: { timeoutMs: number; retryOnTimeout: boolean } ): Promise<any> { // 生成幂等性key:基于步骤输入的hash // 为什么基于输入hash而非步骤ID:同一步骤可能用不同参数调用, // 幂等性保证的是"相同输入只执行一次" const idempotencyKey = this.generateIdempotencyKey(context); // 检查是否已执行 const existingResult = await this.redis.get(`idempotent:${idempotencyKey}`); if (existingResult) { // 已经执行过,直接返回缓存结果 // 为什么不重新执行:发邮件、写数据库这类操作重复执行会产生业务副作用 return JSON.parse(existingResult); } // 执行步骤 const result = await this.innerHandler.execute(context, options); // 缓存结果(保留24小时) // 为什么24小时而非永久:幂等性保证的是"同一次工作流执行中不重复", // 跨执行不需要保证(下次工作流是新的业务场景) await this.redis.setex(`idempotent:${idempotencyKey}`, 86400, JSON.stringify(result)); return result; } private generateIdempotencyKey(context: Record<string, any>): string { // 只使用步骤直接相关的输入参数生成key,而非全部上下文 // 为什么不全用context:上下文包含所有前置步骤输出,hash变化频繁, // 导致幂等性key不稳定,缓存命中率低 const relevantInputs = Object.entries(context) .filter(([key]) => key.startsWith('step_') || key === 'input') .sort(([a], [b]) => a.localeCompare(b)); return createHash('sha256') .update(JSON.stringify(relevantInputs)) .digest('hex'); } }

边界分析与架构权衡

Checkpoint存储选型

Redis:读写快(<1ms),但数据有TTL,7天后checkpoint丢失。适合短期任务。

PostgreSQL:持久化可靠,但读写慢(5~10ms)。适合长期任务(跨天执行的工作流)。

混合策略:步骤执行期间用Redis暂存(快),工作流完成后迁移到PostgreSQL归档(持久)。归档数据用于事后审计和调试。

大输出数据的Checkpoint成本

步骤3(AI生成报告)的输出可能是10KB的文本。步骤6(图片处理)的输出可能是5MB的二进制数据。Checkpoint存储5MB数据在Redis中代价高。

解决方案:大输出存对象存储(S3/OSS),Checkpoint只存引用URL

// 大输出存储策略 async markStepCompleted(stepIndex: number, output: any): Promise<void> { const outputSize = Buffer.byteLength(JSON.stringify(output)); if (outputSize > 100_000) { // 超过100KB // 存入对象存储,Checkpoint只保留URL const url = await this.objectStore.upload( `checkpoint-output/${this.workflowId}/${stepIndex}`, JSON.stringify(output) ); // hash仍然基于完整数据计算,完整性校验不受影响 const outputHash = createHash('sha256') .update(JSON.stringify(output)) .digest('hex'); checkpoint.steps[stepIndex].output = { __ref: url, __hash: outputHash }; } else { checkpoint.steps[stepIndex].output = output; } }

并行步骤的Checkpoint问题

某些工作流有并行步骤(步骤3a和3b同时执行)。两个并行步骤同时写Checkpoint——并发冲突。

Redis的SET操作是原子的,但两个步骤同时更新同一个checkpoint JSON会互相覆盖。

解决方案:每个步骤的checkpoint独立存储(key =checkpoint:${workflowId}:${taskId}:step:${stepIndex}),而非所有步骤共享一个JSON。恢复时聚合所有步骤的状态。代价是恢复操作变慢(需要读取N个key),但消除了并发冲突。

断点续传与版本兼容

工作流定义在v1版本有10个步骤。执行到第5步时失败。开发者在v2版本修改了步骤定义(合并了步骤3和4)。此时v1的checkpoint无法映射到v2的步骤定义。

解决方案:checkpoint中保存步骤定义的版本号。恢复时如果版本不匹配,触发完整重跑而非断点续传。保守但安全——映射错误的后果比重新执行更严重。

超时与无限挂起的步骤

步骤4调用外部API,API没有响应也没有超时。步骤永远停在running状态。checkpoint永远等不到这个步骤的完成或失败标记。

解决:全局超时守护线程

// 超时守护 async watchdog(workflowId: string, taskId: string, maxStepDurationMs: number): void { const checkpoint = await this.loadCheckpoint(workflowId, taskId); for (const step of checkpoint.steps) { if (step.status === 'running') { const elapsed = Date.now() - new Date(step.startedAt).getTime(); if (elapsed > maxStepDurationMs) { // 超时步骤标记为failed await this.markStepFailed(workflowId, taskId, checkpoint.steps.indexOf(step), `步骤超时:${elapsed}ms > ${maxStepDurationMs}ms` ); } } } }

总结

断点续传把工作流从"全量重跑"变成"增量恢复"。核心设计:

  1. 每个步骤完成后状态和输出持久化到CheckpointStore。恢复时从最后一个completed步骤向后继续。
  2. 步骤分三类副作用:replayable(可重放,纯计算)、idempotent(幂等,有副作用但重复安全)、non_replayable(不可重放,重复执行有业务风险)。non_replayable步骤必须用幂等性装饰器保障。
  3. Checkpoint原子性保证:输出数据和hash一起写入,完整性校验防止读到半写数据。
  4. 步骤间数据通过context传递,步骤处理器不需要关心Checkpoint机制——业务逻辑与工程机制分离。
  5. 大输出数据存对象存储,Checkpoint只存引用URL。Redis存10KB以内的小数据。
  6. 并行步骤独立存储Checkpoint,避免并发冲突。
  7. 全局重试上限防止无限循环。超时守护防止步骤永远挂在running状态。

断点续传不是锦上添花——是生产级Agent工作流的基本要求。没有断点续传的工作流,每次失败都从头跑,时间和token的浪费随步骤数线性增长。10步工作流节省4分钟,50步工作流节省20分钟。这是工程效率的底线要求。

资料说明

本文中的协议、版本、性能、成本和行业趋势应以可核验的一手资料为准。未标注统计口径的比例、时间表和预测仅作工程讨论,不应视为行业事实。可参考 0730 资料来源索引,并在发布前将具体来源贴到对应断言之后。