持久化执行的艺术:Temporal 与 AI Agent 编排
从 Redis 状态机到 Event Sourcing:分布式任务编排的演进之路
Temporal 让你像写单机程序一样写分布式工作流。
🎯 问题背景:分布式任务编排的困境
一个典型场景
想象你正在构建一个 AI Agent 系统,需要完成以下流程:
┌─────────────────────────────────────────────────────────────┐
│ AI Agent 执行流程 │
│ │
│ [1. 分析任务] → [2. 等待用户确认] → [3. 执行操作] │
│ ↓ ↓ ↓ │
│ 调用 LLM 可能等几小时 调用外部 API │
│ ↓ │
│ [4. 汇总结果] │
└─────────────────────────────────────────────────────────────┘
看起来简单,但魔鬼藏在细节里:
- 步骤 2 等待确认时:进程在干什么?占着内存等?
- 等待过程中服务重启了:怎么恢复到"等待确认"状态?
- 步骤 3 失败了:重试?回滚?通知用户?
- 如果有子任务:子任务失败如何影响主任务?
传统方案的痛点
| 方案 | 实现思路 | 问题 |
|---|---|---|
| 数据库轮询 | 定时扫描 pending 任务 | 延迟高、数据库压力大 |
| 消息队列 | 每步发消息触发下一步 | 状态分散、恢复困难 |
| 状态机 + Redis | 存储当前状态,代码判断 | 恢复逻辑复杂、状态可能丢失 |
| 自研调度系统 | 从头造轮子 | 维护成本高、容易有 bug |
核心难题可以归结为四个:
┌─────────────────────────────────────────────────────────────┐
│ 四大难题 │
│ │
│ 1. 状态持久化 2. 长时间等待 │
│ 进程崩溃后 等待人工审批时 │
│ 如何恢复? 资源如何释放? │
│ │
│ 3. 嵌套编排 4. 分布式协调 │
│ 子任务、孙任务 多个 Worker │
│ 如何管理? 如何协同? │
│ │
└─────────────────────────────────────────────────────────────┘
🏛️ Temporal 架构概览
Temporal 是一个开源的持久化执行平台,最初由 Uber 开发(前身是 Cadence),现在是独立公司维护的项目。
整体架构
┌─────────────────────────────────────────────────────────────┐
│ 客户端应用 │
│ (启动工作流、发送信号、查询状态) │
└─────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Temporal Server │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ Frontend │ │ Matching │ │ History │ │
│ │ (API 网关) │ │ (任务分发) │ │ (事件存储/重放) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
│ │ │
│ ┌──────┴──────┐ │
│ │ 持久化存储 │ │
│ │ (MySQL/PG/ │ │
│ │ Cassandra) │ │
│ └─────────────┘ │
└─────────────────────────┬───────────────────────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Worker 1 │ │ Worker 2 │ │ Worker N │
│ (无状态) │ │ (无状态) │ │ (无状态) │
└──────────┘ └──────────┘ └──────────┘
核心概念
| 概念 | 类比 | 作用 |
|---|---|---|
| Workflow | 状态机/流程图 | 定义编排逻辑,决定"做什么" |
| Activity | 函数调用 | 执行实际业务代码(调 API、写数据库) |
| Worker | 工人 | 从队列拉取任务并执行,完全无状态 |
| Signal | 消息/事件 | 外部向工作流发送输入(如用户确认) |
| Query | 查询接口 | 获取工作流当前状态 |
| Timer | 定时器 | 等待一段时间后继续 |
关键设计原则
Worker 无状态,Server 有状态。
这意味着:
- Worker 可以随时重启、扩缩容
- 所有状态都在 Temporal Server(通过数据库持久化)
- 任何 Worker 都可以执行任何任务
🔮 核心原理:Event Sourcing 与确定性重放
这是理解 Temporal "魔法"的关键。
传统状态存储 vs Event Sourcing
传统方式:存储快照
数据库/Redis 中存储:
{
"task_id": "abc-123",
"current_step": 3,
"status": "waiting_approval",
"data": { ... }
}
问题:
- 只知道"现在在哪",不知道"怎么来的"
- 恢复时需要大量 if/else 判断
- 历史不可追溯
Event Sourcing:存储事件
Event History:
┌─────────────────────────────────────────────────────────────┐
│ [1] WorkflowExecutionStarted │
│ { input: { goal: "分析市场报告" } } │
│ │
│ [2] ActivityTaskScheduled │
│ { activityType: "AnalyzeTask" } │
│ │
│ [3] ActivityTaskCompleted │
│ { result: "分析完成,建议..." } │
│ │
│ [4] TimerStarted │
│ { duration: "24h", reason: "等待用户确认" } │
│ │
│ [5] SignalReceived │
│ { signalName: "userConfirm", data: { approved: true }} │
│ │
│ [6] ActivityTaskScheduled │
│ { activityType: "ExecuteTask" } │
│ │
│ ... │
└─────────────────────────────────────────────────────────────┘
核心思想:不存储状态,存储所有发生过的事件。状态可以通过重放事件推导出来。
重放机制详解
假设工作流执行到第 4 步时,Worker 进程崩溃了:
┌─────────────────────────────────────────────────────────────┐
│ 崩溃前的执行 │
│ │
│ Worker A 执行: │
│ ───────────── │
│ [1] 启动工作流 → 写入事件 [1] │
│ [2] 调用 Activity → 写入事件 [2] │
│ [3] Activity 完成 → 写入事件 [3] │
│ [4] 启动 Timer → 写入事件 [4] │
│ 💥 Worker A 崩溃! │
│ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 恢复后的重放 │
│ │
│ Worker B 接手(可以是任何 Worker): │
│ ───────────────────────────────── │
│ 重放事件 [1] → 跳过(已完成) │
│ 重放事件 [2] → 跳过(已完成) │
│ 重放事件 [3] → 跳过(已完成) │
│ 重放事件 [4] → 发现 Timer 还在等待 │
│ │
│ 继续等待 Timer... │
│ (或者发现 Timer 已到期 / Signal 已收到,继续执行) │
│ │
└─────────────────────────────────────────────────────────────┘
关键点:
- 事件先写入数据库,才算完成——不会丢失
- 重放时,已完成的步骤直接跳过——不会重复执行
- 任何 Worker 都可以重放——无需特定进程
确定性要求
为什么 Temporal 要求工作流代码必须是确定性的?
┌─────────────────────────────────────────────────────────────┐
│ 确定性 vs 非确定性 │
│ │
│ ✅ 允许 ❌ 不允许 │
│ ───────── ─────────── │
│ • 调用 Activity • 直接调用外部 API │
│ • 使用 Workflow Timer • 使用 time.Now() │
│ • 使用 Workflow Random • 使用 math/rand │
│ • 读取 Workflow 输入 • 读取环境变量 │
│ • 条件分支(基于输入/结果) • 条件分支(基于当前时间) │
│ │
└─────────────────────────────────────────────────────────────┘
原因:重放时必须产生相同的执行路径。
想象一下,如果工作流代码是:
如果当前时间是早上 → 执行路径 A
否则 → 执行路径 B
首次执行时是早上,走了路径 A。 崩溃后重放时是下午,走了路径 B。
这就完全乱了!Temporal 无法正确恢复状态。
所以:所有"不确定"的操作(时间、随机数、外部调用)都必须通过 Activity 或 Workflow API 来做,这样 Temporal 可以记录结果,重放时直接使用记录的结果。
与 Redis 快照方案对比
| 维度 | Redis 快照 | Temporal Event Sourcing |
|---|---|---|
| 存什么 | 当前状态(JSON) | 所有事件(Event History) |
| 空间占用 | 小(只有最新状态) | 大(但可以归档/清理) |
| 恢复速度 | 快(直接读取) | 需要重放(但有优化) |
| 历史追溯 | ❌ 无法追溯 | ✅ 完整审计日志 |
| 数据一致性 | 可能丢失(Redis 持久化策略) | 强一致(数据库事务) |
| 调试能力 | 弱(只能看当前状态) | 强(可以看每一步) |
| 恢复逻辑 | 需要手写 if/else | 自动(重放机制) |
⏸️ 中断与恢复机制深度解析
传统方案的中断恢复为什么难?
问题一:状态保存时机
┌─────────────────────────────────────────────────────────────┐
│ 保存太频繁? 保存太少? │
│ ────────── ────────── │
│ • 每一步都存 Redis • 只在关键点存 │
│ • 性能差 • 中间步骤可能丢失 │
│ • 网络开销大 • 恢复时状态不完整 │
└─────────────────────────────────────────────────────────────┘
问题二:恢复逻辑复杂
恢复时需要判断:
├── 状态是 "分析中"?→ 重新调用 LLM
├── 状态是 "等待确认"?→ 继续等待
├── 状态是 "执行中"?→
│ ├── 子任务 A 完成了吗?
│ ├── 子任务 B 完成了吗?
│ └── 需要回滚吗?
└── ...
状态越多,恢复逻辑越像意大利面条 🍝
问题三:等待时的资源占用
等待用户确认(可能几小时/几天):
方案 A: 进程阻塞等待
→ 一个任务占用一个线程/协程
→ 100 个等待中的任务 = 100 个闲置资源
方案 B: 轮询数据库
→ 定时查询 "有没有用户确认"
→ 延迟 = 轮询间隔
→ 数据库压力大
Temporal 的优雅解决方案
等待时发生了什么?
┌─────────────────────────────────────────────────────────────┐
│ 时间线 │
│ ─────── │
│ │
│ T1: 工作流执行到 "等待信号" │
│ │ │
│ ├─→ 写入事件: WorkflowTaskCompleted │
│ │ (记录 "我在等待信号") │
│ │ │
│ └─→ Worker 完成当前任务,释放资源 ✓ │
│ (没有任何进程在"等待") │
│ │
│ ────────── 可能过了几小时/几天 ────────── │
│ │
│ T2: 用户发送确认信号 │
│ │ │
│ ├─→ Server 收到信号 │
│ │ │
│ ├─→ 写入事件: SignalReceived │
│ │ │
│ └─→ Server 创建新的 Workflow Task │
│ 放入 Task Queue │
│ │
│ T3: 某个 Worker 拉取任务 │
│ │ │
│ ├─→ 重放 Event History │
│ │ │
│ ├─→ 发现 SignalReceived 事件 │
│ │ │
│ └─→ 继续执行后续逻辑 ✓ │
│ │
└─────────────────────────────────────────────────────────────┘
关键洞察:
- 等待期间没有任何进程/线程在占用资源
- 状态完全由 Event History 保持
- 任何 Worker 都可以恢复执行
- 等待 1 秒和等待 1 年,对系统来说没有区别
Signal 机制 vs 传统轮询
| 维度 | 传统轮询 | Temporal Signal |
|---|---|---|
| 实现 | 定时查询数据库 | 事件驱动 |
| 延迟 | 取决于轮询间隔(秒级) | 毫秒级 |
| 等待时资源 | 消耗(轮询进程) | 零消耗 |
| 扩展性 | 任务越多,数据库压力越大 | 线性扩展 |
| 代码复杂度 | 需要管理轮询逻辑 | 直接 "等待信号" |
超时与重试策略
Temporal 内置了完善的超时和重试机制:
超时类型:
| 超时类型 | 含义 | 场景 |
|---|---|---|
| Start-to-Close | 单次执行最长时间 | Activity 调用超时 |
| Schedule-to-Start | 排队等待最长时间 | Worker 繁忙时 |
| Schedule-to-Close | 从排队到完成最长时间 | 端到端超时 |
| Heartbeat | 心跳间隔 | 长时间运行的 Activity |
重试策略:
┌─────────────────────────────────────────────────────────────┐
│ 指数退避重试 │
│ │
│ 第 1 次失败 → 等待 1 秒 → 重试 │
│ 第 2 次失败 → 等待 2 秒 → 重试 │
│ 第 3 次失败 → 等待 4 秒 → 重试 │
│ 第 4 次失败 → 等待 8 秒 → 重试 │
│ ... │
│ 达到最大次数 → 标记失败,可触发补偿逻辑 │
│ │
│ 配置项: │
│ • 初始间隔(InitialInterval) │
│ • 退避系数(BackoffCoefficient) │
│ • 最大间隔(MaximumInterval) │
│ • 最大重试次数(MaximumAttempts) │
│ • 不重试的错误类型(NonRetryableErrors) │
│ │
└─────────────────────────────────────────────────────────────┘
🌳 递归式子孙任务编排
为什么需要嵌套工作流?
复杂任务往往需要分解:
┌─────────────────────────────────────────────────────────────┐
│ 场景:批量数据处理 │
│ │
│ 主任务:处理 1000 个用户的年度报告 │
│ │ │
│ ├─ 子任务 1:处理用户 A │
│ │ ├─ 孙任务:拉取交易记录 │
│ │ ├─ 孙任务:计算统计数据 │
│ │ └─ 孙任务:生成 PDF │
│ │ │
│ ├─ 子任务 2:处理用户 B │
│ │ └─ ... │
│ │ │
│ └─ 子任务 N:... │
│ │
└─────────────────────────────────────────────────────────────┘
如果用传统方式:
- 需要手动管理父子关系
- 需要自己实现 ID 生成和关联
- 子任务失败时,父任务怎么知道?
- 要取消整个任务树,怎么遍历?
Temporal 的 Child Workflow
┌─────────────────────────────────────────────────────────────┐
│ Parent Workflow │
│ WorkflowID: parent-123 │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 独立的 Event History │ │
│ │ [1] WorkflowStarted │ │
│ │ [2] ChildWorkflowExecutionStarted { id: child-A } │ │
│ │ [3] ChildWorkflowExecutionStarted { id: child-B } │ │
│ │ [4] ChildWorkflowExecutionCompleted { id: child-A } │ │
│ │ [5] ChildWorkflowExecutionCompleted { id: child-B } │ │
│ │ [6] WorkflowCompleted │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────┴─────────────┐ │
│ ▼ ▼ │
│ ┌────────────────┐ ┌────────────────┐ │
│ │ Child A │ │ Child B │ │
│ │ id: child-A │ │ id: child-B │ │
│ │ │ │ │ │
│ │ 独立 History │ │ 独立 History │ │
│ │ 独立 Signal │ │ 独立 Signal │ │
│ └───────┬────────┘ └────────────────┘ │
│ │ │
│ ┌───────┴───────┐ │
│ ▼ ▼ │
│ ┌───────┐ ┌───────┐ │
│ │Grand 1│ │Grand 2│ ← 孙任务 │
│ │独立 WF│ │独立 WF│ │
│ └───────┘ └───────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
子任务的独立性
每个子工作流都是完全独立的:
| 特性 | 说明 |
|---|---|
| 独立 WorkflowID | 每个子任务有唯一标识,可单独查询 |
| 独立 Event History | 状态完全隔离,互不影响 |
| 独立 Signal 通道 | 可以单独给子任务发送信号 |
| 独立超时/重试 | 子任务有自己的超时和重试策略 |
| 独立 Query | 可以单独查询子任务状态 |
子任务中断如何影响父任务?
场景:孙任务等待人工审批
┌─────────────────────────────────────────────────────────────┐
│ 时间线 │
│ ─────── │
│ │
│ T1: 主任务启动,创建子任务 A │
│ │
│ T2: 子任务 A 启动,创建孙任务 X │
│ │
│ T3: 孙任务 X 执行到 "等待审批" │
│ ├─→ 孙任务 X:暂停,等待 Signal │
│ ├─→ 子任务 A:暂停,等待孙任务 X 完成 │
│ └─→ 主任务:暂停,等待子任务 A 完成 │
│ │
│ ⚡ 此时:没有任何 Worker 进程在等待! │
│ 所有状态都在各自的 Event History 中 │
│ │
│ ────────── 一段时间后 ────────── │
│ │
│ T4: 用户发送 Signal 给孙任务 X │
│ (注意:是发给孙任务,不是主任务) │
│ │
│ T5: 孙任务 X 收到 Signal,继续执行,完成 │
│ │
│ T6: 子任务 A 发现孙任务 X 完成,继续执行,完成 │
│ │
│ T7: 主任务发现子任务 A 完成,继续执行 │
│ │
└─────────────────────────────────────────────────────────────┘
父任务的控制策略
| 策略 | 行为 | 适用场景 |
|---|---|---|
| 等待完成 | 父任务阻塞直到子任务完成 | 子任务结果影响后续逻辑 |
| 分离执行 | 父任务不等待,子任务独立运行 | Fire-and-forget |
| 取消传播 | 父任务取消时,自动取消所有子任务 | 整体任务取消 |
| 错误处理 | 子任务失败时,父任务可选择:忽略/重试/失败/补偿 | 容错设计 |
与传统方案对比
| 维度 | 传统方案(手动实现) | Temporal Child Workflow |
|---|---|---|
| 状态管理 | 需要手动维护父子关系表 | 框架自动管理 |
| ID 生成 | 需要自己设计命名规则 | 自动生成 / 可自定义 |
| 信号路由 | 需要自己实现分发逻辑 | 每个工作流独立通道 |
| 取消传播 | 需要手动遍历取消 | 配置 ParentClosePolicy |
| 嵌套深度 | 深度越深越难管理 | 理论上无限嵌套 |
| 可观测性 | 需要自建监控系统 | Temporal UI 原生支持 |
| 恢复复杂度 | 指数级增长 | 各自独立恢复 |
🔐 并发控制:为什么不需要分布式锁?
这是一个常被问到的问题:多个 Worker 同时运行,状态怎么保证一致?不需要加锁吗?
Temporal 的并发控制机制
┌─────────────────────────────────────────────────────────────┐
│ Temporal 如何避免竞态条件 │
│ │
│ 1. 工作流执行是"单线程"的 │
│ ──────────────────────── │
│ 同一个 WorkflowID 的工作流,在任意时刻 │
│ 只有一个 Workflow Task 在执行 │
│ Server 保证任务不会被重复分发 │
│ │
│ 2. Server 通过数据库保证互斥 │
│ ────────────────────────── │
│ • 乐观锁(版本号校验) │
│ • 数据库事务(原子写入) │
│ • Shard 分片(减少锁竞争) │
│ │
│ 3. 多个 Signal 同时到达? │
│ ───────────────────── │
│ Server 会排队处理 │
│ 工作流代码看到的是顺序的事件流 │
│ 不会乱序,不会丢失 │
│ │
└─────────────────────────────────────────────────────────────┘
与传统分布式锁对比
| 场景 | 传统方案(Redis 分布式锁) | Temporal |
|---|---|---|
| 同一任务被多个 Worker 抢 | 需要 SETNX 抢锁 | Server 只分发给一个 |
| 状态更新冲突 | 需要乐观锁/悲观锁 | Event 是 append-only |
| 多个请求同时修改 | 需要手动加锁 | 工作流单线程执行 |
| Signal 并发 | 需要队列或锁 | Server 自动排队 |
| 锁过期问题 | 可能导致重复执行 | 版本号机制避免 |
传统分布式锁的经典问题
┌─────────────────────────────────────────────────────────────┐
│ Redis 分布式锁的隐患 │
│ │
│ 问题 1:锁过期但任务还没完成 │
│ ───────────────────────────── │
│ Worker A 持锁执行任务 │
│ 发生 GC 或网络延迟,执行变慢 │
│ 锁过期,Worker B 获得锁 │
│ Worker A 恢复继续执行... │
│ 💥 两个 Worker 同时在执行同一个任务! │
│ │
│ 问题 2:时钟漂移 │
│ ──────────────── │
│ Worker A 的系统时钟比 Redis 快 5 秒 │
│ Worker A 认为锁还有效 │
│ 实际上 Redis 那边已经过期了... │
│ 💥 数据不一致! │
│ │
│ 问题 3:网络分区 │
│ ──────────────── │
│ Worker A 获得锁后,与 Redis 网络断开 │
│ 无法续期,锁被 Worker B 获得 │
│ 网络恢复后,Worker A 认为自己还持有锁... │
│ 💥 脑裂问题! │
│ │
└─────────────────────────────────────────────────────────────┘
Temporal 如何避免这些问题
┌─────────────────────────────────────────────────────────────┐
│ Temporal 的解决方案 │
│ │
│ 1. 不是"锁",是"租约 + 版本号" │
│ ───────────────────────── │
│ Worker 拿到的是带版本号的任务 │
│ 提交结果时校验版本号 │
│ 版本不匹配?提交失败,Server 会重新调度 │
│ 失败了也没关系,重放机制保证正确性 │
│ │
│ 2. Server 是唯一的协调者 │
│ ──────────────────────── │
│ 不依赖 Worker 的本地时钟 │
│ 所有决策由 Server 做出 │
│ Worker 只是"执行者" │
│ │
│ 3. 幂等性 + 重放 = 最终一致 │
│ ───────────────────────── │
│ 即使出现异常情况 │
│ 重放机制保证:已完成的步骤不会重复执行 │
│ 最终状态一定是正确的 │
│ │
└─────────────────────────────────────────────────────────────┘
Signal 的顺序保证
当多个 Signal 同时发送时会发生什么?
10:00:00.001 - 用户 A 发送 Signal "confirm"
10:00:00.002 - 用户 B 发送 Signal "cancel"
Server 处理:
┌─────────────────────────────────────┐
│ Signal 队列(按到达顺序) │
│ [1] confirm (10:00:00.001) │
│ [2] cancel (10:00:00.002) │
└─────────────────────────────────────┘
工作流看到的事件:
[N] SignalReceived { name: "confirm" }
[N+1] SignalReceived { name: "cancel" }
✅ 顺序确定,不会乱序
✅ 不会丢失任何 Signal
✅ 工作流代码可以按顺序处理
小结
Temporal 的哲学:把并发控制的复杂性交给基础设施,业务代码不需要关心锁。
| 你不需要关心 | Temporal 帮你处理 |
|---|---|
| 任务会不会被重复执行? | Server 保证只分发一次 |
| 状态更新会不会冲突? | 版本号 + 重放机制 |
| Signal 会不会乱序? | Server 排队处理 |
| Worker 崩溃了怎么办? | 自动重新调度 |
⚖️ 与 Redis PubSub 方案的全面对比
在实际项目中,我们也实现过基于 Redis 的编排系统。这里做一个详细对比:
架构对比
┌─────────────────────────────────────────────────────────────┐
│ Redis PubSub 方案 │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────────────────────┐ │
│ │ API 1 │ │ API 2 │ │ Redis │ │
│ │(进程1) │ │(进程2) │ │ • State (JSON 快照) │ │
│ └────┬────┘ └────┬────┘ │ • PubSub 频道 │ │
│ │ │ │ • running 标志 │ │
│ └──────────────┴─────────┤ • subscribers 集合 │ │
│ └─────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Temporal 方案 │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────────────────────┐ │
│ │ Worker1 │ │ Worker2 │ │ Temporal Server │ │
│ │(无状态) │ │(无状态) │ │ • Event History (DB) │ │
│ └────┬────┘ └────┬────┘ │ • Task Queue │ │
│ │ │ │ • Signal Queue │ │
│ └──────────────┴─────────┤ • Timer Service │ │
│ └─────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
核心差异
| 维度 | Redis PubSub | Temporal |
|---|---|---|
| 状态存储 | JSON 快照(当前状态) | Event History(所有事件) |
| 恢复方式 | 读取 JSON + if/else 判断 | 自动重放 |
| 子任务支持 | 需要手动实现 | 原生 Child Workflow |
| 实时推送 | ✅ PubSub 原生支持 | 需要额外实现 |
| 历史追溯 | ❌ 只有最新状态 | ✅ 完整历史 |
| 故障恢复 | 可能丢失状态 | 强一致保证 |
| 运维复杂度 | 低(只需 Redis) | 中(需要 Temporal Server) |
| 学习曲线 | 低 | 中 |
实时推送能力对比
Redis PubSub 的优势:
┌─────────────────────────────────────────────────────────────┐
│ 真正的实时推送 │
│ │
│ Worker 执行 Activity 时: │
│ ───────────────────────── │
│ 1. 调用 LLM,收到一个 chunk │
│ 2. 立即 Publish 到 Redis │
│ 3. 所有订阅者(包括其他进程)立即收到 │
│ 4. 前端实时显示 │
│ │
│ 延迟:毫秒级 │
│ │
└─────────────────────────────────────────────────────────────┘
Temporal 需要额外实现:
Temporal 本身是为持久化设计的,不是为实时推送设计的。
要实现实时推送,可以:
- 在 Activity 里调用 Redis PubSub 推送
- 使用 Query 轮询(有延迟)
- 使用 Temporal UI(仅限调试)
混合架构建议:
┌─────────────────────────────────────────────────────────────┐
│ 混合架构 │
│ │
│ Temporal Redis PubSub │
│ ┌─────────────┐ ┌─────────────────┐ │
│ │ 编排逻辑 │ │ 实时推送 │ │
│ │ 状态持久化 │──Activity───▶│ 流式输出 │ │
│ │ 中断恢复 │ 内调用 │ 前端更新 │ │
│ │ 子任务管理 │ │ │ │
│ └─────────────┘ └─────────────────┘ │
│ │
│ 各取所长: │
│ • Temporal 负责"可靠性" │
│ • Redis 负责"实时性" │
│ │
└─────────────────────────────────────────────────────────────┘
🚀 Temporal 的更多能力
除了上述核心功能,Temporal 还提供:
工作流版本管理
当你需要修改工作流逻辑时,已经在运行的工作流怎么办?
Temporal 支持版本标记:旧版本的工作流继续用旧逻辑执行,新启动的工作流用新逻辑。
Saga 模式(分布式事务补偿)
┌─────────────────────────────────────────────────────────────┐
│ 订单处理 Saga │
│ │
│ 正向流程: │
│ [扣减库存] → [创建订单] → [扣款] → [发送通知] │
│ │
│ 如果"扣款"失败: │
│ [恢复库存] ← [取消订单] ← [失败] │
│ │
│ Temporal 自动管理补偿逻辑 │
│ │
└─────────────────────────────────────────────────────────────┘
定时任务(Schedule)
比 Cron 更强大:
- 支持 Cron 表达式
- 支持间隔触发
- 支持暂停/恢复
- 有完整的执行历史
搜索属性(Search Attributes)
给工作流打标签,支持复杂查询:
查询:Status = "processing" AND CustomerId = "cust-123" AND OrderTotal > 100
长时间运行(Continue-as-New)
当 Event History 太大时,可以"重启"工作流,保留状态但清空历史。
📊 何时选择 Temporal?
适合的场景
| 场景 | 原因 |
|---|---|
| AI Agent 编排 | 多步骤、需要人工介入、可能失败需重试 |
| 订单处理流程 | 长时间运行、需要补偿逻辑 |
| 审批工作流 | 等待时间不确定、需要状态持久化 |
| 数据管道 | 多阶段处理、需要重试和监控 |
| 微服务编排 | 跨服务调用、需要事务一致性 |
| 定时任务 | 比 Cron 更可靠、有执行历史 |
不太适合的场景
| 场景 | 原因 | 替代方案 |
|---|---|---|
| 超低延迟(<10ms) | 有调度开销 | 直接调用 |
| 简单任务队列 | 过于复杂 | Redis Queue / SQS |
| 纯实时流式输出 | 不是设计目标 | WebSocket / SSE |
| 短生命周期任务 | 不需要持久化 | 普通函数调用 |
部署选择
| 选项 | 适合场景 | 注意事项 |
|---|---|---|
| Docker Compose | 开发/测试 | 不适合生产 |
| Kubernetes | 生产环境 | 需要运维能力 |
| Temporal Cloud | 不想管运维 | 按量付费 |
🎯 总结
Temporal 的核心价值
- 持久化执行:进程崩溃?自动恢复,一行代码都不用改
- 长时间等待:等待用户审批时,零资源消耗
- 复杂编排:子孙任务嵌套,框架自动管理
- 可观测性:完整的执行历史,调试神器
设计哲学
把困难的事情(状态管理、分布式协调、故障恢复)交给基础设施,让业务代码保持简单直接。
一句话总结
Temporal 让你像写单机程序一样写分布式工作流,像调试单进程一样调试跨服务流程。
对于 AI Agent 这种需要多步骤执行、人工介入、长时间运行的场景,Temporal 提供了一个优雅且可靠的解决方案。