恢复不变量重放重建“程序已经决定到哪里”,而不是重做外部世界;任何可能产生外部效果或非确定结果的操作都必须离开确定性重放路径。

为什么持久化一份 JSON 还不够

在内存循环中保存 {step: 4},无法回答崩溃发生在 API 请求发送前、发送后但响应前,还是响应后但状态落盘前。恢复时“从第 4 步再跑一次”可能安全,也可能重复创建工单、发信或扣款。

长期 Run 还跨越发布、权限变化、数据修改和人工等待。旧 JSON 只描述某一时刻的投影,不保留每个决定、输入、外部回执和因果顺序,也无法解释新代码为何要走同一分支。

快照能回答与不能回答的问题
问题只有快照事件历史 + 快照
当前显示状态可以可以
该状态如何形成通常不可以按事件重建因果
外部动作是否完成容易与落盘窗口混淆用稳定命令 ID 与完成事件关联
新代码能否恢复旧 Run依赖隐式兼容可重放测试和版本分支

用历史区分“决定过”与“执行过”

事件历史记录不可变的事实,例如 RunStarted、ModelRequested、ToolScheduled、ToolCompleted、ApprovalRequested、ApprovalResolved 和 RunCompleted。Command 表示 Workflow 决定要做什么;完成事件表示外部执行器报告了什么。稳定的 run、step、command 和 attempt ID 把二者关联起来。

快照仍然有价值,它加速读取与查询;但快照必须由历史投影得到,并带上最后消费的事件版本。历史是审计与恢复真源,不能为了“修数据”原地改写。修复应追加纠正事件或执行受审计迁移。

type RunEvent =
  | { type: 'tool_scheduled'; commandId: string; tool: string; inputHash: string }
  | { type: 'tool_completed'; commandId: string; receiptRef: string }
  | { type: 'tool_failed'; commandId: string; code: string; retryable: boolean }
  | { type: 'approval_requested'; approvalId: string; actionHash: string }
  | { type: 'approval_resolved'; approvalId: string; decision: 'allow' | 'deny' };

function hasCompleted(history: RunEvent[], commandId: string): boolean {
  return history.some(event =>
    event.type === 'tool_completed' && event.commandId === commandId
  );
}

事件中不要无边界保存模型输入和敏感工具结果。大对象进入 Artifact Store,历史只保留内容哈希、引用、权限标签和必要摘要;否则事件历史会同时成为成本、隐私与删除合规问题。

重放恢复控制流,而不是重做外部世界

Temporal 官方文档要求 Workflow 代码在相同输入和历史下产生同序列 Command;LLM/API/数据库等非确定操作应放入 Activity。重放时,Workflow 重新执行控制逻辑,但已经存在的 Activity 完成事件会提供原结果,而不是再次调用外部服务。

本地时间、随机数、模型响应和配置读取都会使分支不确定,必须由运行时提供可重放值或记录为事件。否则新 Worker 可能在旧历史同一位置发出不同 Command,形成 non-determinism。

确定性控制与非确定外部活动的边界
历史重放恢复状态并重建已作出的控制决定。
匹配 Command已有完成事件则复用历史结果。
调度 Activity仅未完成的外部工作进入执行器。
追加结果成功、失败或心跳形成新历史。
Durable 不等于 exactly-onceActivity Worker 可能在外部系统成功后、完成事件提交前崩溃,运行时只能再次尝试。业务效果仍需幂等键、查询确认或对账;无法幂等的动作要降低自动重试并进入未知结果处理。

检查点位置是成本与恢复粒度的取舍

模型调用、Tool 完成、审批请求/决定、并行 Join 与关键预算变化后通常值得固化。检查点过密会增加存储和写延迟;过疏会扩大崩溃后的重算与状态不确定窗口。对长 Activity,可用心跳携带进度,但心跳进度不能冒充外部业务提交。

检查点至少包含有效配置版本、预算消耗、计划版本、待处理 Command、Artifact 引用和事件游标。不要直接序列化进程对象、闭包或 SDK 内部类型,这会把语言实现细节变成永久数据契约。

代码与状态共同版本化

长期 Run 会被多个部署版本处理。Temporal 文档明确指出改变产生 Command 的调用顺序可能破坏重放,并提供 Worker Versioning 与 Patching。通用设计也应承认这一约束:运行定义、Prompt、Tool Schema 和状态 Schema 都是恢复契约。

固定运行依赖

Run 记录 Workflow build、Prompt、模型策略和 Tool Schema 版本;恢复优先路由到兼容 Worker。优点是行为稳定,代价是旧 Worker 与依赖需要保留到最长 Run 生命周期结束。

兼容旧历史

通过版本分支让新代码解释旧事件;已记录的决定不能被新分支重新解释。要用真实历史做 replay test,而不是只对新 Run 做单元测试。

显式迁移

迁移前检查状态不变量与等待中的外部关系,生成新快照并追加迁移事件;迁移工具本身需要幂等、审计和失败恢复。不能用数据库脚本静默改掉历史含义。

安全退役

统计在途 Run、最长等待、旧版本事件与回滚窗口。只有旧 Run 完成、迁移或被有依据地终止后,才能下线 Worker、Prompt 和 Tool 版本。

恢复前重新验证现实世界

历史证明过去发生过什么,不证明现在仍可继续。人工批准后恢复、长时间等待后重启或系统发布后接管,都要重新检查主体权限、租户、审批有效期、业务对象版本、资源可用性、Deadline、预算和政策版本。

重验证失败不应改写历史:可以进入重新规划、请求新审批、补偿或终止。特别是审批不是资源锁;等待期间订单、代码或配置可能被他人修改,提交前必须用版本号或条件写入防止覆盖。

长期 Run 需要运营能力

运维界面要能按租户、状态、等待原因、版本和风险搜索 Run;支持暂停、取消、重试单个 Activity、附加人工修复 Artifact,并显示每次操作的权限与审计。直接“改数据库让它继续”不是运行能力。

  • 告警:无进展、心跳丢失、重试风暴、历史膨胀、等待超期和不兼容 Worker;
  • 指标:恢复成功率、从恢复到首次进展时间、人工处置时长、旧版本在途量;
  • Runbook:未知结果确认、非确定性故障、事件/Artifact 损坏、旧 Worker 回滚;
  • 治理:历史加密、字段脱敏、Retention、合法删除与审计完整性的取舍。

验收不能只做“重启后还能跑”。应在 Tool 请求前、请求后响应前、完成事件前分别杀进程;在人工等待期间发布不兼容变更;让旧 Worker 暂时不可用,再验证结果没有重复副作用、丢失预算或跳过权限复核。

主动提取:为什么事件历史已经记录 ToolScheduled 仍不能证明外部动作完成?
Scheduled 只证明 Workflow 决定并调度了动作;网络、Worker 或外部系统可能尚未完成。必须找到 ToolCompleted 与外部回执,或进入未知结果查询。
恢复判断:审批已允许,但 Run 等待两天后恢复,能直接提交吗?
不能默认提交。要复核审批是否过期、参数与对象版本是否仍匹配、主体权限和政策是否变化,再决定继续、重批或取消。

资料与事实边界

以下以官方文档、标准、论文和原始工程资料为主。厂商 API、价格、限额与模型能力会变化,落地时仍需按目标版本做能力探测和回归。

  1. Temporal:Workflow Definition — 确定性重放、Command/Event 匹配、非确定操作与版本策略
  2. Temporal:Activities — Activity 外部执行、结果进入历史、幂等与心跳检查点
  3. Temporal:Event History — Workflow Event History 的官方语义与恢复基础