首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >Agent 的状态与持久化:断点续跑、幂等与副作用补偿

Agent 的状态与持久化:断点续跑、幂等与副作用补偿

原创
作者头像
Archive
发布于 2026-09-28 10:37:44
发布于 2026-09-28 10:37:44
1190
举报
文章被收录于专栏:随笔随笔

先从一种很常见的事故形态说起

一个跑对账的 Agent,任务是"找出差异 → 生成调整单 → 提交审批 → 通知相关人",一共七个步骤。跑到第五步,提交审批的接口超时了。

重试逻辑很自然地触发。重试跑通,调整单被创建了两张,审批流起了两条。

这类问题不是模型不可靠造成的,模型那几步其实都答对了。出问题的是包裹在模型外面的执行逻辑——它按"HTTP 请求"的假设来重试,而这一步实际上是"写"。

传统服务里重试默认是安全的,因为绝大多数接口是幂等的读。Agent 不一样:它的每一步都可能是副作用,而且副作用往往落在系统之外——发消息、建工单、扣款、改库存。这些动作没有撤销按钮,只有补偿。

所以做 Agent 的持久化,难点不是"怎么把状态存下来",而是怎么定义一次重试的安全性。

会话历史不是状态

先讲一个最容易被忽略的点。

不少实现把"做到哪一步了"编码在对话上下文里,让模型自己读历史再决定下一步。这在短任务里跑得挺好,但一旦需要断点续跑就崩了,原因有三个。

上下文是有损的。它保存的是"说过的话",不是"发生过的事实"。模型看到"我已经提交了调整单",但它不知道那次提交到底成功了没有。

上下文会被压缩。为了控制 token,长任务早晚要裁历史,一裁,"我做过什么"就丢了。

上下文不能原子写。而状态必须能。崩溃发生时,你要么处于"步骤 N 已完成",要么处于"步骤 N 未完成",不能是中间态。

结论很直接:状态要落在外部存储里,按步骤结构化记录,而不是留在对话里让模型回忆。

用事件日志,而不是覆盖式快照

存状态有两种做法:每次覆盖写一份当前状态,或者只追加事件、状态由事件重放得出。

Agent 场景我倾向于以事件日志为主:

代码语言:bash
复制
task_id, step_seq, step_name, status, input_hash,
output_ref, started_at, finished_at, effect_class, idempotency_key

几个字段值得单独说:

step_seq 是步骤序号,单调递增、不重用,恢复时靠它判断"我停在哪"。

effect_class 是这一步的副作用等级。这是整套设计里最重要的一个声明,后面展开。

output_ref 存引用而不是内容。一次检索回来几万字是很常见的,直接进库会让日志很快膨胀到没法查询,所以长输出放对象存储,日志里只留指针。

idempotency_key 是重试时的去重依据。

为什么不直接存快照?因为快照只能回答"现在是什么",事件日志能回答"怎么变成这样的"。排查线上问题时后者才有用——尤其是你要判断"某一步到底执行了几次"。

代价也得说清楚:事件日志多了重放成本。所以实践里一般是事件日志加定期快照,每 N 步落一次快照,重放时从最近的快照开始,不用从头算。

给每一步标副作用等级

这是整套设计的地基。我会把步骤分三类:

  • read:纯读,重试永远安全。
  • idem_write:幂等写,带幂等键的创建或更新,重试安全(前提是下游真的认幂等键)。
  • non_idem_write:非幂等写,发消息、发起审批、触发付款这类,重试会出问题。

前两类的执行策略很简单,超时就重试。第三类必须走另一条路:先确认真实状态,再决定要不要做。

这里有个很实用的判断标准:如果一个接口你没有"查询"能力,就不要把它标成可重试的。

举两个具体的。支付网关通常提供按商户订单号查单的接口,所以付款这一步可以设计成超时后先查单,查到了就当成功,查不到再决定重试还是转人工,这样它接近幂等。但"发企业微信消息"这类接口很多不支持回查,那就只能靠自己的去重表兜底。

副作用等级不是给模型看的,是给执行器看的。模型可以选步骤,但不能决定重试策略。

幂等键怎么构造

幂等键是"这一步的这件事"的唯一标识,构造方式直接决定去重的正确性。

比较稳的构造是:

代码语言:bash
复制
idempotency_key = hash(task_id + step_seq + canonical(params))

三个部分各自有作用。

task_id 区分不同任务,避免跨任务撞键。

step_seq 区分同一任务里的不同步骤。这里用序号而不是步骤名——同一个步骤名在一次任务里可能合法地执行多次,比如循环里反复查同一类数据,用名字会误判成重复。

canonical(params) 是参数规范化后的哈希。这一步很容易踩坑:参数里如果混进时间戳、随机数、请求 ID,同一次逻辑写在重试时会算出不同的键,去重直接失效。所以构造键之前要把易变字段剔掉,只留真正决定业务语义的那几个。

还有一个容易忽略的细节:幂等键要在调用前生成并落库,不是返回后才记录。 先落库再调用,中间崩溃的话重试时能查到键从而跳过或回查;反过来先调用再落库,崩溃窗口里的那次调用就成了幽灵。

补偿是注册下来的动作,不是"再调一次反向接口"

非幂等写没法重试,但可以做补偿。补偿和反向操作不是一回事,差别在于补偿本身也会失败。

举个例子:提交审批单的补偿是撤回审批单。撤回可能因为审批已经被人处理而失败,这时不能假装没事,也不能无脑重试到天荒地老。

所以我倾向于把补偿做成显式注册:

代码语言:python
复制
STEPS = {
    "create_adjustment": {
        "effect": "non_idem_write",
        "call": create_adjustment,
        "query": query_adjustment,          # 不确定态回查
        "compensate": withdraw_adjustment,  # 显式注册,可以为 None
        "compensate_deadline": "PT10M",     # 超过这个时长不再自动补偿
    },
    "match_orders": {
        "effect": "read",
        "call": match_orders,
        "query": None,
        "compensate": None,
    },
}

两个字段值得展开。

query 是处理"不确定态"的唯一手段。超时之后你不知道写成功没有,只能查。没有回查能力的步骤就不要标成可自动处理,它会变成长期的不确定态,最后总是要人来看。

compensate_deadline 是补偿的止损位。过了这个点,上游状态可能已经被别人改过,自动补偿没意义了,应该转人工而不是继续自动折腾。

执行器骨架大致是这样:

代码语言:python
复制
def run_step(task, step_def, params):
    key = idem_key(task.id, step_def.seq, params)
    prev = ledger.find(key)

    if prev and prev.status == "done":
        return prev.output_ref                     # 做过,直接复用

    if prev and prev.status == "uncertain":
        seen = step_def.query(params)              # 不确定态先回查
        if seen is not None:
            ledger.mark_done(key, ref_of(seen))
            return ref_of(seen)
        # 查不到,走补偿或转人工

    if step_def.effect == "non_idem_write":
        ledger.mark_uncertain(key)                 # 先落库,再调用

    try:
        out = step_def.call(params)
    except Timeout:
        if step_def.effect != "non_idem_write":
            return run_step(task, step_def, params)    # 读和幂等写可以放心重试
        raise NeedHuman(task, key)                     # 非幂等写转人工

    ledger.mark_done(key, ref_of(out))
    return ref_of(out)

这是骨架,但两个顺序不能改:先落 uncertain 再调用,以及不确定态必须先查。

代价:checkpoint 频率是一笔账

每步落一次状态,成本不高但也不是零,多一次写库、多一次序列化。步数多、并发高的时候要算一下。

这里的取舍和传统系统不太一样:我倾向于宁可多写,也不要少写。原因是 Agent 的步骤比普通 RPC 少得多,一个任务几十步,不是几百万次调用,存储压力不在一个量级;而少写一次 checkpoint 的代价,是恢复时无法判断某一步做过没有,也就是前面说的长期不确定态。

真正需要控制的是输出体量,不是记录条数。所以 output_ref 这个设计比"压 checkpoint 频率"有用得多。

落地顺序

从零做的话,按这个顺序,大概一周能有可用版本:

  1. 先把步骤表定义出来,每一步标上 effect_class。这一步能让很多隐藏的坑提前暴露。
  2. 落事件日志,只记不改。先不管幂等,先让"发生了什么"有据可查。
  3. 加幂等键和去重表,从 non_idem_write 的步骤开始。
  4. 给所有非幂等写补 query;查不了的,明确标成需要人工确认。
  5. 最后再做补偿。补偿是兜底,不是主路径。

容易做反的正是第 5 条:一上来就设计一套漂亮的补偿流程,结果发现八成的"失败"其实靠回查就能确认成功,根本不需要补偿。

收尾

Agent 的持久化,难点从来不是存状态,而是把"重试安全"从一句口号变成每一步都可判定的属性。这件事想清楚了,状态存哪、存多细,反而是顺带的结果。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 先从一种很常见的事故形态说起
  • 会话历史不是状态
  • 用事件日志,而不是覆盖式快照
  • 给每一步标副作用等级
  • 幂等键怎么构造
  • 补偿是注册下来的动作,不是"再调一次反向接口"
  • 代价:checkpoint 频率是一笔账
  • 落地顺序
  • 收尾
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档