为什么持久化一份 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。
检查点位置是成本与恢复粒度的取舍
模型调用、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 仍不能证明外部动作完成?
恢复判断:审批已允许,但 Run 等待两天后恢复,能直接提交吗?
资料与事实边界
以下以官方文档、标准、论文和原始工程资料为主。厂商 API、价格、限额与模型能力会变化,落地时仍需按目标版本做能力探测和回归。
- Temporal:Workflow Definition — 确定性重放、Command/Event 匹配、非确定操作与版本策略
- Temporal:Activities — Activity 外部执行、结果进入历史、幂等与心跳检查点
- Temporal:Event History — Workflow Event History 的官方语义与恢复基础