Agent 工作流重放与断点续传:失败任务不必从头跑(续篇)
Agent 工作流重放与断点续传:失败任务不必从头跑(续篇)
场景痛点
Agent执行一个10步工作流。第8步调用外部API超时失败。Agent从第1步重新执行。前7步的结果全部丢弃。
第1步查询数据库耗时30秒。第3步调用AI模型生成报告耗时2分钟。第5步执行数据清洗耗时1分钟。前7步合计约4分钟。第8步超时后重跑4分钟的前置步骤,浪费时间和token。
更糟的情况:第7步是"发送邮件通知"。重跑时又发了一封重复邮件。第2步是"删除临时文件"。重跑时文件已经不存在了——操作不可重复执行。
核心矛盾:步骤间的数据依赖与执行副作用。重跑不只是时间浪费,还可能产生重复副作用(发邮件、写数据库)或失败副作用(删除已删除的文件)。
底层机制与原理剖析
断点续传的本质是执行状态的持久化与恢复。每个步骤完成后,其输出数据和执行状态被保存。失败时从最后一个成功步骤继续,而非从起点重新开始。
关键机制:
-
步骤状态机。每个步骤有明确的状态:
pending → running → completed | failed | skipped。步骤完成后状态和输出被持久化到CheckpointStore。恢复时从completed状态的最晚步骤开始向后执行。 -
副作用分类。步骤分为两类:可重放(纯计算,无外部副作用)和不可重放(有外部副作用如发邮件、写数据库)。可重放步骤可以安全重新执行。不可重放步骤必须用幂等性保证——即使重跑也不会产生重复效果。
-
Checkpoint原子性。步骤状态持久化必须是原子操作。步骤执行了一半就崩溃——输出数据可能不完整。需要"全部写完才标记completed"的原子保证。
-
步骤间数据依赖图。步骤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`
);
}
}
}
}
总结
断点续传把工作流从"全量重跑"变成"增量恢复"。核心设计:
- 每个步骤完成后状态和输出持久化到CheckpointStore。恢复时从最后一个completed步骤向后继续。
- 步骤分三类副作用:replayable(可重放,纯计算)、idempotent(幂等,有副作用但重复安全)、non_replayable(不可重放,重复执行有业务风险)。non_replayable步骤必须用幂等性装饰器保障。
- Checkpoint原子性保证:输出数据和hash一起写入,完整性校验防止读到半写数据。
- 步骤间数据通过context传递,步骤处理器不需要关心Checkpoint机制——业务逻辑与工程机制分离。
- 大输出数据存对象存储,Checkpoint只存引用URL。Redis存10KB以内的小数据。
- 并行步骤独立存储Checkpoint,避免并发冲突。
- 全局重试上限防止无限循环。超时守护防止步骤永远挂在running状态。
断点续传不是锦上添花——是生产级Agent工作流的基本要求。没有断点续传的工作流,每次失败都从头跑,时间和token的浪费随步骤数线性增长。10步工作流节省4分钟,50步工作流节省20分钟。这是工程效率的底线要求。
资料说明
本文中的协议、版本、性能、成本和行业趋势应以可核验的一手资料为准。未标注统计口径的比例、时间表和预测仅作工程讨论,不应视为行业事实。可参考 0730 资料来源索引,并在发布前将具体来源贴到对应断言之后。
更多推荐


所有评论(0)