AI Agent 运行数小时不崩盘:聊聊正在升温的 Durable Execution

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 ExecutorDapr:Durable AgentsTemporal: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 不能一直占用;
  • 服务发布后原进程会消失;
  • 审批消息可能重复到达;
  • 用户可能撤回或超时。

持久化工作流可以在等待时完全释放计算资源:

sequenceDiagram participant A as Agent participant W as Workflow Engine participant H as Human participant T as Tool A->>W: 请求执行高风险操作 W->>W: 写入 ApprovalRequested W-->>A: 挂起,不占用 Worker W->>H: 发送审批通知 H->>W: Approved W->>W: 写入 ApprovalReceived W->>A: 从 Checkpoint 恢复 A->>T: 执行工具 T-->>A: 返回结果

审批事件需要:

  • 稳定的 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 标准,强调恢复、隔离和会话一致性;
  • 云厂商托管工作流:适合已经深度使用相应云生态的团队。

选择时重点比较:

  1. 是否支持当前主要语言,尤其是 Go;
  2. Workflow 的确定性与版本升级模型;
  3. Activity 的超时、重试、心跳和取消;
  4. 外部事件与人工审批支持;
  5. 历史和 Payload 的大小限制;
  6. 多租户、安全与数据驻留;
  7. 自托管和托管成本;
  8. 可观测性与运维工具;
  9. 与现有 MQ、数据库和 Kubernetes 的整合成本;
  10. 是否会把 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,就是这类工作负载正在补上的执行底座。

相关推荐
Oneslide1 天前
ES 7.17 APM 致命坑:@timestamp 被识别为 text,彻底解释为什么必须升级 8.x
后端
SimonKing1 天前
Spring Boot 集成 OnlyOffice,5 分钟搞定 Word/Excel 在线编辑
java·后端·程序员
明月_清风1 天前
🌐 多链生态对比:EVM vs Solana vs Sui,开发者怎么选?
后端·web3
swipe1 天前
14|(前端转全栈)商品详情高频访问怎么扛?Redis Cache Aside 实战
前端·后端·面试
swipe1 天前
15|(前端转全栈)从一个副标题字段看懂后端完整交付链路
前端·后端·面试
Apifox1 天前
Apifox 7 月更新|审计日志、密钥扫描防护、Postman/OpenAPI / Swagger 导入体验优化
前端·后端·测试
不才不才不不才1 天前
Spring 源码系列(12): @EnableAspectJAutoProxy 到底注册了什么
java·后端·spring
用户8181870627461 天前
第5章 ClassCastException:类型擦除与桥接方法引发的陷阱
后端
爱勇宝1 天前
《道德经》第 9 章:别把系统和自己都推到过载
前端·后端·程序员
shengjk11 天前
未来最好的代码,可能是人类完全看不懂的
后端