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

一、一个 tool call 失败,整个工作流白跑

Agent 做复杂任务时,通常拆成多步骤:搜索 → 分析 → 生成 → 审核,每步可能涉及多次 LLM 调用和工具调用。问题在于:如果第三步的工具调用抛了 500,整个工作流全部作废——没有重放能力,只能从头再跑。

这不仅浪费算力(前面走过的步骤白跑了),更糟糕的是非确定性:重新执行时 LLM 可能生成不同的中间结果,导致最终输出与重试前不一致。这对需要可复现性的场景是致命的——比如数据分析 Agent,两次运行得出不同的结论就完全不可信了。

实际损失数据:一个 5 步工作流,每步平均耗时 10 秒,LLM 调用成本约 $0.02/步。如果第 4 步失败,前 3 步的 $0.06 和 30 秒全部浪费。如果一天跑 1000 个工作流、失败率 5%,每天浪费 $3 和 500 秒算力——累积下来不是小数目。更关键的是时间浪费:一个 5 步工作流耗时 50 秒,失败后从头跑又是 50 秒,加上重试可能 100 秒。如果用断点续传,只重跑第 4 步,恢复时间约 10 秒——节省 40 秒。

断点续传(Checkpoint & Resume)的核心价值:在每一步成功的节点保存状态快照,失败时从最近的检查点恢复,而不是从头开始。这把"从头重跑"变成"从断点续跑",时间和成本节省与失败位置成正比。

sequenceDiagram
    participant WF as 工作流引擎
    participant CK as 检查点存储
    participant LLM as LLM 服务
    participant TOOL as 工具服务
    
    WF->>WF: Step 1: 需求分析
    WF->>LLM: 分析请求
    LLM-->>WF: 分析结果
    WF->>CK: 写入检查点 checkpoint_1
    
    WF->>WF: Step 2: 信息检索
    WF->>TOOL: search(query)
    TOOL-->>WF: 搜索结果
    
    Note over WF,TOOL: Step 3 失败
    WF->>TOOL: fetch_detail(url)
    TOOL--xWF: 500 错误
    
    WF->>CK: 读取最近检查点 checkpoint_1
    CK-->>WF: 恢复状态
    WF->>TOOL: search(query) — 复用缓存
    TOOL-->>WF: 缓存命中, 跳过
    WF->>TOOL: fetch_detail(url) — 重试
    TOOL-->>WF: 成功
    WF->>CK: 写入检查点 checkpoint_2

二、检查点的设计原则

原则一:最小快照

检查点不是把整个内存 dump 到磁盘。只保存导致状态变化的决策结果,不保存中间推理过程。一个典型检查点的内容:已完成的步骤 ID、每步的输入输出、当前 LLM 对话摘要。

为什么只存结果不存过程?两个原因。一是存储成本:一个 LLM 推理的中间链可能包含数千 token,存下来检查点体积膨胀 10 倍。二是恢复效率:恢复时只需要结果就能继续下一步,中间推理链对后续步骤没有直接价值。如果把推理链也存下来,检查点从 1KB 膨胀到 10KB,Redis 写入延迟增加,但恢复后的执行效果完全一样——得不偿失。

最小快照的另一个优势:跨实例恢复。如果原实例挂了,另一个实例可以加载检查点继续执行——因为检查点只包含决策结果而非内存状态,没有实例绑定的数据。

原则二:幂等性保证

恢复时可能重新执行某些步骤。设计上必须保证:同一步骤以相同输入重复执行,结果等价。LLM 调用没有天然幂等性——同一个 prompt 两次调用可能返回不同内容。所以需要对 LLM 输出做确定性缓存

具体做法:为每个 LLM 调用生成一个缓存 key = hash(prompt + model_id + temperature)。temperature > 0 时缓存无效(因为 LLM 输出不确定),temperature = 0 时可以缓存。但实际工程中 temperature = 0 也不保证完全确定性(受 GPU 并行计算浮点误差影响),所以缓存只是"近似幂等"——对于大多数场景够用了。

真正的幂等性要求:对于关键决策步骤(如"选择哪个数据源"),不应该依赖 LLM 的非确定性输出。应该在检查点中保存决策结果,恢复时直接使用保存的决策——而非重新让 LLM 做决策。这保证两次运行的决策路径完全一致。

原则三:回滚边界

不是所有步骤都值得回滚。副作用步骤(发送邮件、创建工单、写入数据库)一旦执行就不能撤销。这类步骤之前必须写检查点,且恢复时标记为"已完成"跳过重放。

为什么不能重放副作用步骤?因为外部系统的状态已经被改变了——重放意味着再发一封邮件、再创建一个工单。这不仅冗余,还可能造成业务错误(比如用户收到两封相同的邮件)。所以副作用步骤在恢复时必须跳过,而非重放——这是检查点系统中最关键的区分。

一个常见的错误:把所有步骤都标记为 IDEMPOTENT。这会导致副作用步骤被重放——发送两次邮件。正确的做法是在定义工作流时显式标注每个步骤的类型:IDEMPOTENT(可安全重放)、SIDE_EFFECT(不可重放)、DETERMINISTIC(结果确定可缓存)。

三、带检查点的工作流引擎

import json
import hashlib
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable, Optional
import redis.asyncio as redis


class StepType(Enum):
    IDEMPOTENT = "idempotent"        # 可安全重放
    SIDE_EFFECT = "side_effect"      # 有副作用,不可重放
    DETERMINISTIC = "deterministic"  # 相同输入保证相同输出


@dataclass
class StepResult:
    step_id: str
    step_type: StepType
    input_hash: str
    output: Any
    success: bool
    error: Optional[str] = None
    duration_ms: float = 0.0
    timestamp: float = field(default_factory=time.time)


@dataclass
class WorkflowState:
    workflow_id: str
    current_step: int
    completed_steps: list[StepResult] = field(default_factory=list)
    # conversation_summary 用于恢复 LLM 上下文
    conversation_summary: str = ""
    metadata: dict[str, Any] = field(default_factory=dict)


class CheckpointWorkflowEngine:
    """支持断点续传的工作流引擎。
    
    架构决策:
    - 使用 Redis 存检查点而非本地文件,支持多实例共享
    - 检查点带 TTL(24h),过期自动清理,防止无限积累
    - 每步执行前检查是否存在有效检查点,优先恢复
    """

    def __init__(self, redis_url: str, checkpoint_ttl: int = 86400):
        self._redis = redis.from_url(redis_url)
        self._checkpoint_ttl = checkpoint_ttl
        self._llm_cache: dict[str, Any] = {}

    async def execute(
        self,
        workflow_id: str,
        steps: list[Callable],
        step_types: list[StepType],
        fallback: Optional[Callable] = None,
    ) -> WorkflowState:
        """执行工作流并自动处理断点续传。"""
        # 尝试加载已有状态
        state = await self._load_state(workflow_id)

        if state is None:
            state = WorkflowState(workflow_id=workflow_id, current_step=0)

        # 从断点开始执行
        while state.current_step < len(steps):
            step_fn = steps[state.current_step]
            step_type = step_types[state.current_step] if state.current_step < len(step_types) else StepType.IDEMPOTENT
            step_id = f"{workflow_id}:step_{state.current_step}"

            # 检查是否已经成功执行过(用于恢复场景)
            # 副作用步骤在恢复时跳过重放——只检查是否已完成
            if self._is_step_completed(state, step_id):
                state.current_step += 1
                continue

            start = time.monotonic()
            try:
                result = await step_fn()
                step_result = StepResult(
                    step_id=step_id,
                    step_type=step_type,
                    input_hash=self._hash_input(state),
                    output=result,
                    success=True,
                    duration_ms=(time.monotonic() - start) * 1000,
                )
            except Exception as e:
                step_result = StepResult(
                    step_id=step_id,
                    step_type=step_type,
                    input_hash=self._hash_input(state),
                    output=None,
                    success=False,
                    error=str(e),
                    duration_ms=(time.monotonic() - start) * 1000,
                )

                if fallback and state.current_step > 0:
                    # 有回退策略且之前有成功步骤: 回到上一个检查点
                    state.current_step -= 1
                    await self._save_state(state)
                    continue
                
                state.completed_steps.append(step_result)
                await self._save_state(state)
                # 不吞异常,让上层感知失败
                raise RuntimeError(f"Step {step_id} failed: {e}") from e

            state.completed_steps.append(step_result)
            state.current_step += 1

            # 每步完成后保存检查点
            await self._save_state(state)

        return state

    async def _save_state(self, state: WorkflowState) -> None:
        """持久化工作流状态到 Redis。
        
        使用 SETEX 自动过期,避免僵尸检查点积累。
        为什么不用 Redis 事务?因为单个 SETEX 命令已经原子性足够,
        多字段写入用 pipeline 即可,不需要 MULTI/EXEC 的隔离保证。
        """
        key = f"workflow:checkpoint:{state.workflow_id}"
        data = json.dumps(self._serialize_state(state))
        await self._redis.setex(key, self._checkpoint_ttl, data)

    async def _load_state(self, workflow_id: str) -> Optional[WorkflowState]:
        key = f"workflow:checkpoint:{workflow_id}"
        data = await self._redis.get(key)
        if data is None:
            return None
        return self._deserialize_state(json.loads(data))

    def _is_step_completed(self, state: WorkflowState, step_id: str) -> bool:
        return any(s.step_id == step_id and s.success for s in state.completed_steps)

    def _hash_input(self, state: WorkflowState) -> str:
        # 包含 conversation_summary 确保上下文变化时哈希也变化
        context = f"{state.workflow_id}:{state.current_step}:{state.conversation_summary}"
        return hashlib.sha256(context.encode()).hexdigest()[:16]

    def _serialize_state(self, state: WorkflowState) -> dict:
        return {
            "workflow_id": state.workflow_id,
            "current_step": state.current_step,
            "steps": [
                {
                    "step_id": s.step_id,
                    "step_type": s.step_type.value,
                    "input_hash": s.input_hash,
                    "success": s.success,
                    "error": s.error,
                }
                for s in state.completed_steps
            ],
            "summary": state.conversation_summary,
        }

    def _deserialize_state(self, data: dict) -> WorkflowState:
        state = WorkflowState(
            workflow_id=data["workflow_id"],
            current_step=data["current_step"],
            conversation_summary=data.get("summary", ""),
        )
        for s in data.get("steps", []):
            state.completed_steps.append(StepResult(
                step_id=s["step_id"],
                step_type=StepType(s["step_type"]),
                input_hash=s["input_hash"],
                output=None,
                success=s["success"],
                error=s.get("error"),
            ))
        return state

四、断点续传的代价

存储成本

Redis 中每个检查点约 1-5KB。日均 10 万次工作流执行,存储开销约 500MB——可接受。但不要用关系型数据库存检查点,写入频率高会导致主从延迟。MySQL 的 WAL 写入在高频场景下会产生 binlog 延迟,而 Redis 的纯内存操作延迟 < 1ms。

缓存一致性问题

LLM 输出的缓存需要区分模型版本。GPT-4 和 GPT-4o 对同一 prompt 的输出可能不同——这不是理论上的可能性,而是实践中确实会发生的。缓存 key 必须包含模型标识和版本号,否则跨版本恢复会导致结果不一致。

更深层的缓存问题:temperature 参数。temperature = 0 时 LLM 输出相对确定(但非绝对),可以缓存。temperature > 0 时输出不确定,缓存无效。如果缓存策略把 temperature = 0.01 的输出也缓存了,恢复后可能得到与原始执行不同的结果——因为 0.01 仍然有微小随机性。

不适用场景

  • 单步工作流(只有一步,失败就失败,不需要检查点)
  • 实时性要求 < 100ms(写 Redis 增加 2-5ms 延迟)
  • 对不可重现性有要求的场景(A/B 测试等,需要每次运行结果不同)

Trade-off 讨论

  • 检查点频率 vs 存储成本:每步写检查点是最安全的,但写入次数最多。可以改为"只在副作用步骤前写检查点"——减少写入,但失败时可能多回退一步。推荐:关键步骤(副作用前、决策点)写检查点,普通步骤可以省略。
  • 缓存精度 vs 性能:temperature = 0 时缓存是安全的,但禁用随机性可能降低回答质量。temperature > 0 时不缓存,但恢复后可能得到不同结果。这是一个无法完美解决的 trade-off。
  • TTL 长短 vs 恢复窗口:24h TTL 适合大多数场景,但长时间运行的批处理任务可能需要更长 TTL。TTL 过短会导致任务还没恢复就过期了。推荐:按工作流类型设置不同 TTL——实时对话 1h、批处理任务 72h。

五、总结

断点续传的本质是把"无状态执行"变成"有状态恢复"。这个转换的代价是每次执行都要写一次 Redis,但换来的是失败瞬间的秒级恢复——对于多步骤 Agent 工作流,这个取舍是值得的。检查点设计上遵循最小快照原则,只存结果不存过程,才能在存储开销和恢复速度之间找到平衡。副作用步骤不可重放是系统中最需要小心处理的边界——搞错这一点会导致业务错误而非技术错误。最终的收益:5 步工作流第 4 步失败,恢复时间从 50 秒降到 10 秒,成本从 $0.10 降到 $0.02。

更多推荐