AI Agent 运行数小时不崩盘:聊聊正在升温的 Durable Execution
如果你也关注 Go、后端开发、AI Agent 与工程实践,欢迎点个关注。这里不只分享结论,也会持续记录踩坑过程、架构思路和真实落地经验,希望能与你一起进步。 过去我们构建 AI Agent,通常从一个简单循环开始:
text
调用模型 -> 判断是否需要工具 -> 执行工具 -> 把结果交给模型 -> 继续
这个循环用几十行代码就能跑起来,演示效果也很好。
但当 Agent 开始执行真正的生产任务,问题会突然变得复杂:
- 一次任务运行数小时,中间 Worker 重启;
- 模型已经调用成功,本地还没保存结果就发生宕机;
- 邮件已经发出,但调用方因为超时再次执行;
- Agent 等待人工审批两天,原来的进程早已不存在;
- 用户关闭页面后重新连接,希望继续看到之前的输出;
- 发布新版本后,旧任务仍然要按照旧逻辑完成;
- 同一个任务需要在不同执行路径之间进行回放和评测。
这些问题都指向一个近期快速升温的技术方向:Durable Execution,持久化执行。
Google 在 2026 年发布 Agent Executor,将持久化执行、快照恢复、单写者一致性和连接恢复作为长时 Agent Runtime 的基础能力;Dapr 最新文档将工作流支持的 DurableAgent 作为推荐执行模式;Temporal 也把 AI Agent 视为持久化执行的重要应用场景。Google:Agent Executor、Dapr:Durable Agents、Temporal:Durable Execution
这篇文章不讨论某个具体框架的 API,而是从工程原理出发,解释为什么长时 Agent 需要持久化执行,以及如何用 Go 构建一套可恢复、可重放、不会重复产生副作用的执行模型。
一、先看一个非常真实的故障窗口
假设 Agent 正在处理采购任务:
text
1. 查询库存
2. 比较三家供应商
3. 生成采购方案
4. 等待负责人审批
5. 调用供应商接口下单
6. 发送采购结果
第五步中可能出现这样的执行顺序:
text
Agent 调用供应商 API
-> 供应商创建订单成功
-> Agent Worker 在保存结果前崩溃
-> 任务被重新投递
-> Agent 再次调用供应商 API
-> 创建第二笔订单
如果我们只依赖消息队列,通常只有两个选择:
- ACK 消息:可能丢失未保存的执行结果;
- 不 ACK 消息:任务会重跑,外部副作用可能重复发生。
这正是长流程的核心难题:
计算过程可以重放,但已经发生的真实副作用不能随意重放。
普通任务队列保存的是"还有一项任务要执行",持久化执行系统保存的是"这项任务已经执行到哪里,每一步得到了什么结果,接下来应该继续哪一步"。
二、Durable Execution 到底是什么?
持久化执行可以概括为:
将程序执行过程中的关键决策和外部结果写入持久化历史,使任务在进程崩溃、机器重启或网络中断后,可以从已确认的位置继续执行,而不是从头再来。
它通常包含四个核心概念。
1. Workflow
Workflow 描述长期业务流程,例如:
text
创建研究任务
-> 搜索资料
-> 分析资料
-> 等待人工确认
-> 生成报告
-> 发布报告
Workflow 负责控制流程,不直接执行不确定的网络和数据库操作。
2. Activity
Activity 是可能失败、超时或产生副作用的操作:
- 调用大模型;
- 查询搜索引擎;
- 发送邮件;
- 写数据库;
- 调用支付或订单接口;
- 执行代码。
Activity 可以独立设置超时、重试和幂等策略。
3. Event History
每个 Workflow 都有一份只追加的执行历史:
text
WorkflowStarted
ActivityScheduled(model_call_1)
ActivityCompleted(model_call_1, result_ref)
ActivityScheduled(search_1)
ActivityCompleted(search_1, result_ref)
ApprovalRequested(approval_1)
ApprovalReceived(approval_1, approved=true)
...
恢复任务时,系统读取历史并重新构建 Workflow 状态。
4. Checkpoint / Snapshot
当历史很长时,每次从第一条事件开始恢复会越来越慢。系统可以周期性保存快照:
text
Snapshot at sequence 200
+ Events 201...230
= Current State
快照用于加速恢复,事件历史仍然是解释任务如何到达当前状态的重要证据。
三、为什么"保存聊天记录"还不够?
很多 Agent 系统已经把消息写入数据库:
text
user: 帮我调研新能源汽车
assistant: 我准备先搜索行业数据
tool: ...
assistant: ...
这只能保存对话内容,不能完整保存执行语义。
系统还需要知道:
- 这次工具调用是否已经提交;
- 当前步骤由哪个 Worker 持有;
- 工具结果是否可以安全复用;
- 失败属于可重试还是永久失败;
- 是否正在等待审批或外部事件;
- 预算还剩多少;
- 使用的是哪个 Agent、Prompt 和工具版本;
- 恢复时应该继续执行,还是重新做模型决策。
因此,Conversation Memory 与 Execution State 是两个不同概念:
| 类型 | 解决的问题 |
|---|---|
| 对话记忆 | Agent 知道用户和模型之前说过什么 |
| 执行状态 | 系统知道哪些步骤已经可靠完成 |
| 业务状态 | 订单、支付、工单等领域对象当前是什么状态 |
三者可能互相关联,但不应该混成一份不可解释的 JSON。
四、持久化执行依赖"确定性重放"
很多工作流引擎恢复状态时,不是直接反序列化整个进程内存,而是重新执行 Workflow 代码,并从历史中读取已经发生过的结果。
例如,第一次执行时:
text
代码请求调用 LLM
-> 引擎调度 Activity
-> Activity 返回结果 A
-> 结果 A 写入历史
Worker 重启后:
text
Workflow 代码重新运行
-> 再次走到"调用 LLM"
-> 引擎发现历史中已有结果 A
-> 直接返回 A,不再调用 LLM
这就是 Replay。
它带来一条非常重要的规则:
Workflow 代码必须是确定性的;不确定操作必须放进 Activity。
下面的写法不适合直接出现在可重放的 Workflow 中:
go
// 错误示意:重放时可能得到不同结果。
now := time.Now()
id := uuid.NewString()
value := rand.Intn(100)
response, _ := http.Get("https://example.com")
这些结果在每次重放时可能不同,会导致代码路径与历史不一致。
正确思路是由执行引擎提供可重放的时间、随机数和版本信息,或者把外部操作放入 Activity:
go
type ResearchWorkflow struct {
Activities Activities
}
func (w *ResearchWorkflow) Run(
ctx WorkflowContext,
input ResearchInput,
) (ResearchReport, error) {
sources, err := ctx.ExecuteActivity(
"search_sources",
input.Query,
ActivityOptions{
Timeout: 2 * time.Minute,
MaxAttempts: 3,
},
)
if err != nil {
return ResearchReport{}, err
}
report, err := ctx.ExecuteActivity(
"generate_report",
sources,
ActivityOptions{
Timeout: 5 * time.Minute,
MaxAttempts: 2,
},
)
if err != nil {
return ResearchReport{}, err
}
return report.(ResearchReport), nil
}
这里的 WorkflowContext 是框架无关的示意接口。重点不是具体语法,而是:
- Workflow 只描述控制流;
- 搜索和模型调用由 Activity 执行;
- Activity 结果写入历史;
- 重放时直接复用已完成结果。
五、重放不等于 Exactly Once
持久化执行经常被误解为"所有 Activity 都只会执行一次"。
实际上,在分布式系统中仍然可能出现:
text
Activity 调用外部接口成功
-> Worker 在上报结果前崩溃
-> 引擎无法确认是否成功
-> Activity 被重新调度
所以常见保证仍然是 At Least Once。持久化执行负责可靠调度和状态恢复,外部副作用仍然需要业务幂等。
1. 为每个副作用生成稳定业务键
text
tool_call_id = workflow_id + step_id + operation
例如:
text
purchase_123 + step_5 + CREATE_ORDER
重试时继续使用同一个键。
2. 服务端使用唯一约束兜底
sql
CREATE UNIQUE INDEX uk_supplier_order_request
ON supplier_orders (tenant_id, request_id);
重复请求返回第一次创建的订单,而不是再次执行。
3. 区分结果未知与业务失败
外部调用超时,不应立即记为失败:
text
SUCCEEDED:确认成功
FAILED:确认失败
UNKNOWN:调用结果无法确认
进入 UNKNOWN 后,应使用原请求号查询远端、等待回调或者执行对账,而不是直接生成新请求号重试。
4. Activity 自身也要保存尝试记录
go
type ActivityAttempt struct {
WorkflowID string
ActivityID string
Attempt int
IdempotencyKey string
Status string
StartedAt time.Time
FinishedAt *time.Time
ResultRef string
ErrorCode string
}
这样可以区分:
- Workflow 逻辑重放;
- Activity 因失败重试;
- Worker 崩溃后重新领取;
- 用户显式要求重新执行。
六、一个最小可理解的 Go 执行模型
生产环境通常会选择成熟工作流引擎,但理解最小模型有助于做正确的架构决策。
1. 事件结构
go
type EventType string
const (
EventWorkflowStarted EventType = "WORKFLOW_STARTED"
EventActivityScheduled EventType = "ACTIVITY_SCHEDULED"
EventActivityCompleted EventType = "ACTIVITY_COMPLETED"
EventActivityFailed EventType = "ACTIVITY_FAILED"
EventApprovalRequested EventType = "APPROVAL_REQUESTED"
EventApprovalReceived EventType = "APPROVAL_RECEIVED"
EventWorkflowCompleted EventType = "WORKFLOW_COMPLETED"
)
type WorkflowEvent struct {
WorkflowID string
Sequence int64
Type EventType
StepID string
Payload json.RawMessage
CreatedAt time.Time
}
数据库对 (workflow_id, sequence) 建立唯一约束,事件只允许追加,不允许原地修改。
2. 当前状态由历史归约得到
go
type WorkflowState struct {
Status string
CompletedActivities map[string]json.RawMessage
PendingApproval string
LastSequence int64
}
func Reduce(events []WorkflowEvent) WorkflowState {
state := WorkflowState{
Status: "NEW",
CompletedActivities: make(map[string]json.RawMessage),
}
for _, event := range events {
state.LastSequence = event.Sequence
switch event.Type {
case EventWorkflowStarted:
state.Status = "RUNNING"
case EventActivityCompleted:
state.CompletedActivities[event.StepID] = event.Payload
case EventApprovalRequested:
state.Status = "WAITING_APPROVAL"
state.PendingApproval = event.StepID
case EventApprovalReceived:
state.Status = "RUNNING"
state.PendingApproval = ""
case EventWorkflowCompleted:
state.Status = "SUCCEEDED"
}
}
return state
}
这段代码展示了事件溯源的核心:当前状态不是唯一事实,事件历史才是;当前状态可以由历史重新计算,也可以用 Snapshot 加速。
3. 追加事件时处理并发
多个 Worker 可能同时尝试推进同一 Workflow,因此必须使用乐观并发控制:
sql
INSERT INTO workflow_events (
workflow_id,
sequence,
event_type,
step_id,
payload,
created_at
)
VALUES (
:workflow_id,
:expected_sequence + 1,
:event_type,
:step_id,
:payload,
NOW()
);
唯一键冲突表示其他 Worker 已经推进了状态,当前 Worker 必须重新加载历史,不能覆盖新状态。
生产系统还会使用租约、分区单写者或 Actor 模型降低竞争。Google Agent Executor 也将单写者架构作为分布式会话一致性的关键设计。Google:Agent Executor
七、Human-in-the-loop 为什么特别适合持久化工作流?
人工审批可能几秒完成,也可能等待几天。如果使用普通 HTTP 请求:
- 连接无法保持几天;
- Worker 不能一直占用;
- 服务发布后原进程会消失;
- 审批消息可能重复到达;
- 用户可能撤回或超时。
持久化工作流可以在等待时完全释放计算资源:
审批事件需要:
- 稳定的
approval_id; - 明确的审批人和权限;
- 到期时间;
- 同意、拒绝和撤回状态;
- 重复回调幂等;
- 完整审计记录。
Workflow 收到事件后从等待点继续,不需要重新让模型回顾全部历史并猜测自己应该做什么。
八、断线恢复:不要把连接生命周期等同于任务生命周期
长时 Agent 通常通过 SSE 或 WebSocket 输出进度。客户端断线是常态,不应该触发任务重跑。
每个输出事件都应具有递增序号:
text
run_id: run_123
sequence: 42
event: tool_completed
data: ...
客户端保存最后收到的序号,重连时携带:
http
GET /v1/runs/run_123/events
Last-Event-ID: 42
服务端先补发 43...current,再继续发送实时事件。
Google Agent Executor 将这种能力称为 Connection Recovery:客户端按最后看到的序号重新连接并补齐缺失响应。Google:Agent Executor
这里需要区分三种状态:
text
Workflow State:任务执行到了哪里
Stream State:客户端收到了哪里
Conversation State:模型上下文包含什么
客户端重连只改变 Stream State,不能创建新 Workflow。
九、版本升级是持久化执行最容易忽略的坑
一个 Workflow 可能运行几天,而应用每天都在发布。
假设旧代码是:
text
搜索 -> 写报告
新代码变成:
text
搜索 -> 事实核查 -> 写报告
旧 Workflow 恢复时,如果直接执行新代码,历史中没有"事实核查"事件,重放就可能发生不一致。
常见处理方式有三种。
1. Workflow 版本标记
创建任务时记录:
text
workflow_type
workflow_version
agent_version
prompt_version
tool_schema_version
旧任务继续使用兼容实现,新任务使用新版本。
2. 版本化分支
工作流引擎可以在历史中记录版本决策:
go
version := ctx.GetVersion("add_fact_check", 1, 2)
if version >= 2 {
if err := runFactCheck(ctx); err != nil {
return err
}
}
重放旧任务时读取历史版本 1,新任务记录并使用版本 2。
3. Continue-as-new
历史过长或逻辑需要大幅升级时,可以创建一个继承必要状态的新 Workflow,让旧历史归档。
无论采用哪种方式,都不应该假设所有运行中的任务会在下一次发布前结束。
十、工作流、任务队列和 Actor 应该怎么选?
它们不是互斥技术,而是解决不同问题。
| 技术 | 更适合解决 |
|---|---|
| 任务队列 | 分发相对独立、可以短时间完成的任务 |
| 持久化工作流 | 多步骤、长时间、需要等待和恢复的业务流程 |
| Actor | 有稳定身份和封装状态的并发实体 |
| 状态机 | 限制业务对象的合法状态迁移 |
| 事件溯源 | 保存完整变化历史并重建状态 |
一个 Agent 平台可能同时使用:
text
Workflow 管理 Run 生命周期
-> Queue 分发 Activity
-> Actor 管理 Session 单写状态
-> 状态机约束业务阶段
-> Event History 支持恢复与审计
什么情况下普通队列已经足够?
- 任务几秒内完成;
- 无须等待外部事件;
- 失败后可以安全从头执行;
- 没有不可重复的副作用;
- 不需要保存中间决策轨迹。
什么情况下应该考虑持久化执行?
- 任务运行分钟、小时或天;
- 包含多个模型与工具调用;
- 需要人工审批;
- 需要暂停、恢复或取消;
- 失败后不能从头重做;
- 工具调用成本高或有真实副作用;
- 需要完整审计与轨迹评测;
- 多 Agent 之间存在依赖和交接。
十一、使用成熟引擎,还是自己实现?
一个最小事件循环并不难写,难的是后面的边界:
- 历史一致性;
- 定时器和长时间等待;
- Worker 租约与故障接管;
- Activity 重试和心跳;
- 取消传播;
- Workflow 版本兼容;
- 历史压缩;
- 多租户隔离;
- 跨区域容灾;
- 可视化和故障运维。
因此,生产项目通常更适合评估成熟方案:
- Temporal:以 Workflow 和 Activity 为核心,提供多语言 SDK 和持久化执行;
- Dapr Workflows / Durable Agents:与 Dapr 的状态、Pub/Sub、服务调用和可观测能力结合;
- Google Agent Executor:面向分布式 Agent 的开放 Runtime 标准,强调恢复、隔离和会话一致性;
- 云厂商托管工作流:适合已经深度使用相应云生态的团队。
选择时重点比较:
- 是否支持当前主要语言,尤其是 Go;
- Workflow 的确定性与版本升级模型;
- Activity 的超时、重试、心跳和取消;
- 外部事件与人工审批支持;
- 历史和 Payload 的大小限制;
- 多租户、安全与数据驻留;
- 自托管和托管成本;
- 可观测性与运维工具;
- 与现有 MQ、数据库和 Kubernetes 的整合成本;
- 是否会把 Agent 框架与执行引擎强耦合。
不要只看"能不能跑通 Demo",而要测试 Kill Worker、断网、重复事件、长时间等待和版本升级。
十二、我们的落地顺序
如果现有 Agent 仍运行在一个同步 HTTP Handler 中,可以按以下顺序演进。
第一阶段:任务异步化
- 创建稳定的
run_id; - HTTP 只负责提交任务;
- 客户端通过查询或事件流获取进度;
- Run 使用显式状态机。
第二阶段:步骤持久化
- 拆分模型调用和工具 Activity;
- 保存 Step、Attempt 和结果引用;
- 写操作增加幂等键;
- 记录
UNKNOWN状态并提供对账。
第三阶段:故障恢复
- 引入 Workflow History;
- 增加 Checkpoint;
- Worker 使用租约和心跳;
- 支持从已完成步骤恢复;
- 增加取消传播。
第四阶段:等待与协作
- 外部事件唤醒;
- Human-in-the-loop;
- 多 Agent 子工作流;
- 连接断开后的事件补发。
第五阶段:生产治理
- Workflow 和 Agent 版本管理;
- Replay 测试;
- 历史归档;
- 跨租户隔离;
- 成本、质量和恢复能力指标;
- 故障注入与灾难恢复演练。
每个阶段都能独立产生价值,不必为了使用某个热门框架一次性重构整个平台。
结语
AI Agent 越来越像一种长期运行、会等待、会分支、会调用外部系统的动态程序。传统的"请求进来、执行完成、返回结果"模型很难承载它。
这也是 Durable Execution 重新受到关注的原因。
它不是让机器永不故障,而是让故障不再意味着任务丢失或从头开始:
进程可以消失,Worker 可以更换,连接可以断开,但任务的执行历史必须留下,已经完成的步骤不能凭空丢失,真实副作用不能因为重放而重复发生。
对于生产 Agent,模型决定它能做多聪明的事,持久化执行决定它能否把这些事可靠地做完。
未来我们可能会把 Agent 看作一种新的分布式工作负载,而 Durable Execution,就是这类工作负载正在补上的执行底座。