《AI Agent 高级编程》6.2 确定性重放与副作用:模型调用变可重放步骤

持久化执行要能重放,前提是每一步都确定。本节把模型调用变成可重放的步骤:把输入输出录到 tape,重放时命中记录就不再真调模型;再演示副作用(写文件、发通知)如何用幂等键隔离成只生效一次的步骤。真跑给出「record 2 次真调 → replay 0 次真调、输出摘要哈希不变」的证据,并实测跨进程不确定性来源与两类重放失败症状。

6.2 确定性重放与副作用:模型调用变可重放步骤

6.1 的 Durable 引擎之所以能「重放已完成步骤」,靠的是一个隐含前提:同一个步骤,重放时得到的结果和第一次一样。对纯计算的步骤这天然成立;但对 Agent 而言,最贵、最核心的那类步骤恰恰是模型调用——它可能带采样温度、可能被上游限流重试、可能因为上下文里混进了一个时间戳而每次输入都不同。一旦「重放」拿到的结果和第一次不一致,整个 Durable Execution 的正确性就塌了。本节要做的,就是把模型调用从一个「不确定的黑盒」改造成一个「确定的可重放步骤」,并把有副作用的操作隔离成幂等步骤。

本节把 6.1 的「崩溃续跑」补上正确性基石:先用跨进程实验实测不确定性到底从哪来,再用 tape(录制带)把模型调用的输入输出固化下来——重放命中 tape 就不再真调模型;最后用幂等键把副作用(写文件、发通知)隔离成只生效一次的步骤,并用真跑输出证明「record 2 次真调 → replay 0 次真调、输出摘要哈希不变」。

6.2.1 重放为什么要求确定性

先把「确定性」这个词说精确。对 Durable Execution 而言,它指的不是「模型输出固定」,而是:

给定同一个步骤标识和同一份输入,重放时该步骤的产出必须与首次执行逐字节相同。

这里有三个关键词。步骤标识——6.1 里用 name(如 search:q1)寻址;同一份输入——重放时喂给模型的消息列表、工具 schema 必须与首次一致;逐字节相同——包括 JSON 的键顺序、浮点格式、空白的处理方式。

为什么要求这么严?因为重放的意义是「用旧结果替代新执行」。如果重放时模型又给了一个不同的答案,那么下游步骤看到的输入就变了,它们的重放也会跟着漂移,错误会像滚雪球一样放大。所以确定性不是锦上添花,而是重放正确性的前提:不确定的步骤根本无法重放,只能重跑——那就退回到了 6.1 的 naive 方案。

6.2.2 不确定性从哪来:一次跨进程实测

很多人以为「不确定性」只来自模型的采样温度。实际上,Python 进程里到处是每次启动都不同的值。用一个探测脚本在三个独立进程里各跑一遍,本机实测(Python 3.14.6):

import time, random, uuid, os
print("time.time() =", time.time())
print("random()    =", random.random())
print("uuid4()     =", uuid.uuid4())
print("hash('durable') =", hash("durable"))
print("set order   =", list({"alpha","beta","gamma","delta","epsilon"}))
print("PYTHONHASHSEED =", os.environ.get("PYTHONHASHSEED"))
# 进程 A
time.time() = 1791662290.1723719
random()    = 0.8060616367153092
uuid4()     = 0f266e42-0355-4fc2-a4f5-e57445896006
hash('durable') = -2797282880968727216
set order   = ['epsilon', 'beta', 'delta', 'alpha', 'gamma']
PYTHONHASHSEED = None
# 进程 C(PYTHONHASHSEED=0)
hash('durable') = -4978027187027173919
set order   = ['alpha', 'beta', 'gamma', 'delta', 'epsilon']
PYTHONHASHSEED = 0

结论很清楚:time.time()、random()、uuid4()、hash() 三个进程里两两不同。hash() 不同是因为 CPython 默认开启哈希随机化(PYTHONHASHSEED 未设时每进程随机),这会让字典/集合的迭代顺序在跨进程时也可能变化——进程 A 与 C 里同一个集合的 set order 就不一样。而 dict 从 Python 3.7 起保证插入顺序,所以 dict 的迭代顺序反而是稳定的(前提是你插入的顺序一致)。

把这些源头的风险整理成表:

不确定来源跨进程是否稳定对重放的影响处理办法
time.time() / datetime.now()否输入里带时间 → key 漂移剔除时间,或作为参数显式传入
random / 未固定种子的采样否输出不同录制结果,重放时读 tape
uuid4()否输入里带 id → key 漂移用逻辑 id 或从输入剔除
hash() / set 迭代顺序否(哈希随机化)序列化结果顺序变序列化前排序
dict 迭代顺序是(3.7+ 插入序)稳定保持插入顺序一致
网络 / 外部 API否结果不可复现录制(tape)+ 幂等

前四行就是重放翻车的常见现场。核心对策只有一句话:把不确定的部分「录制下来」,重放时不再重新产生它。

6.2.3 把模型调用录成 tape

思路很直接:给模型包一层「录制器」。录制模式下,它把每次调用的「输入 → 输出」写进一个 tape 文件;重放模式下,它按输入查 tape,命中就直接返回旧输出,根本不碰真模型。

"""确定性重放:把模型调用录到 tape;重放命中 tape 就不再真调。"""
import json, os, sys, hashlib, time

RUN_DIR = "/tmp/ab_dur/tape"
TAPE = RUN_DIR + "/tape.jsonl"
EFFECTS = RUN_DIR + "/effects.jsonl"
OUT_FILE = RUN_DIR + "/report.txt"
os.makedirs(RUN_DIR, exist_ok=True)
MODE = sys.argv[1] if len(sys.argv) > 1 else "record"


def key(prompt): return hashlib.sha256(prompt.encode()).hexdigest()[:12]


def real_model(prompt):                       # 带非确定性: 时间戳
    return f"{prompt} => answer@{time.time():.6f}"


class RecordedModel:
    def __init__(self, mode):
        self.mode = mode
        self.tape = {}
        if os.path.exists(TAPE):
            for line in open(TAPE, encoding="utf-8"):
                if line.strip():
                    r = json.loads(line)
                    self.tape[r["key"]] = r["output"]
        self.real_calls = 0

    def complete(self, prompt):
        k = key(prompt)
        if self.mode == "replay":
            if k not in self.tape:
                raise KeyError(f"重放缺记录: {prompt}")
            return self.tape[k]                   # 命中 tape, 不真调
        out = real_model(prompt)                  # 真调
        self.real_calls += 1
        with open(TAPE, "a", encoding="utf-8") as f:
            f.write(json.dumps({"key": k, "prompt": prompt, "output": out},
                               ensure_ascii=False) + "\n")
        return out


def notify(msg):                              # 有副作用的步骤, 幂等
    ik = hashlib.sha256(msg.encode()).hexdigest()[:12]
    done = set()
    if os.path.exists(EFFECTS):
        for line in open(EFFECTS, encoding="utf-8"):
            if line.strip():
                done.add(json.loads(line)["ik"])
    if ik in done:
        return f"[skip] 副作用已生效过 ik={ik}"
    with open(EFFECTS, "a", encoding="utf-8") as f:
        f.write(json.dumps({"ik": ik, "msg": msg}, ensure_ascii=False) + "\n")
    return f"[exec] 已发送通知 ik={ik}"


def workflow(m):
    a = m.complete("总结持久化执行")
    b = m.complete("总结确定性重放")
    eff = notify("周报已生成")
    with open(OUT_FILE, "w", encoding="utf-8") as f:
        f.write(a + "\n" + b + "\n")
    return a, b, eff


if __name__ == "__main__":
    m = RecordedModel(MODE)
    a, b, eff = workflow(m)
    h = hashlib.sha256((a + b).encode()).hexdigest()[:16]
    print(f"mode={MODE}  真实模型调用={m.real_calls} 次")
    print(f"副作用: {eff}")
    print(f"输出摘要 hash = {h}")

注意 real_model 故意在输出里塞了 time.time()——它是真不确定的。本机实测,第一次以 record 模式跑:

mode=record  真实模型调用=2 次
副作用: [exec] 已发送通知 ik=177a045e952d
输出摘要 hash = f1caf298eeb6b74f

紧接着以 replay 模式再跑一次同一个工作流:

mode=replay  真实模型调用=0 次
副作用: [skip] 副作用已生效过 ik=177a045e952d
输出摘要 hash = f1caf298eeb6b74f

三行输出的对比就是全部证据:

  • 真实模型调用 从 2 变成 0:重放没有碰真模型,两个 complete 都命中了 tape。这就是「模型调用变成可重放步骤」的直接效果——第二次运行的模型成本是零。
  • 输出摘要 hash 两次都是 f1caf298eeb6b74f:尽管 real_model 带时间戳、本不确定,重放拿回的是录制时冻结的那个值,所以输出逐字节一致。tape 把不确定性「定格」了。(这个 hash 的具体值随录制那一刻的时间戳而变,但你复现时会看到「record 与 replay 两次相同」这个关键性质始终成立。)
  • 副作用 [skip]:这是下一小节的主角。

值得一提的是 replay 模式下 real_calls 恒为 0——这意味着你可以把录制好的 tape 当成一份离线回放夹具,在没有网络、没有 API key 的环境里复现整条链路。这与卷二 12.2 离线重放 用的是同一个思路:把「外部依赖」换成「录好的响应」。

6.2.4 副作用隔离:让每个副作用只生效一次

重放对纯计算是安全的——重算一遍结果一样。但对副作用是危险的:重放一个「发邮件」的步骤,可能真的再发一封。看 notify 的实现,它用了一个幂等键(idempotency key):

ik = hashlib.sha256(msg.encode()).hexdigest()[:12]   # 由业务内容派生

这个键由「消息内容」派生,因此同一个业务动作永远算出同一个键。执行前先查 effects 账本:键已存在就跳过,不存在才真正执行并记账。于是:

运行notify 调用实际副作用账本 effects.jsonl
第 1 次(record)到达执行 1 次1 行
第 2 次(replay)到达跳过仍 1 行
第 3 次(replay)到达跳过仍 1 行

连续跑三次 replay,effects.jsonl 始终只有一行 {"ik": "177a045e952d", "msg": "周报已生成"}——副作用只生效了一次。这就是副作用隔离的要义:把「想做这个动作」和「这个动作已经做过」分开记录,前者由代码表达,后者由幂等账本裁决。

真实系统里,这个账本通常是数据库里带唯一索引的一张表,或者下游服务自带的幂等接口。关键约束是账本与副作用必须在同一个事务里——否则会出现「副作用执行了但没记账」(下次重放会重复执行)或「记账了但副作用没执行」(下次重放会漏掉)。这一点在卷二的 3.3 幂等与错误 里从服务端角度讲过,这里是它在 Durable Execution 语境下的对应物。

6.2.5 重放失败的两种典型症状

tape 机制不是万能的,它会以两种方式失败,都值得提前知道。

症状一:tape 未命中。 重放时遇到一个从未录制过的步骤,complete 会抛 KeyError:

症状1 key 在 tape 中? False
症状1 -> KeyError('重放缺记录: 从未录制过的问题')

这其实是好事——它响亮地失败了。相比「静默地重跑一次真模型」,直接抛错能让问题立刻暴露。生产上通常还会加一条策略:允许「录制模式回退」,即重放未命中时真调一次并补录,但这会破坏「零成本重放」的保证,要慎用。

症状二:输入漂移导致永远未命中。 更隐蔽的一类失败是输入里混进了易变值。实测:

症状2 两次 key: 126b883a9c89 fe97ddd3ff3f 相同? False

同一个业务动作,只因 prompt 里带了一个时间戳,两次算出的 key 就不同,重放永远命中不了。修法是归一化:把步骤标识从「原始输入」改成「逻辑输入」,剔除 ts、request_id 这类易变项:

def logical_key(step, args):
    stable = {k: v for k, v in args.items() if k not in ("ts", "request_id")}
    payload = step + "|" + "|".join(f"{k}={stable[k]}" for k in sorted(stable))
    return hashlib.sha256(payload.encode()).hexdigest()[:12]

实测对比:

原始 prompt 含 ts -> key 稳定? False
逻辑 key(剔除 ts/request_id)-> 稳定? True

**「剔除易变项 + 对键排序」**是让步骤标识稳定的两条通用规则。做完这两步,重放才不会因为一个无关紧要的时间戳而全线崩掉。

6.2.6 这套机制替你做了什么、你又让渡了什么

tape + 幂等账本替你做的:把「不确定的模型调用」和「有副作用的操作」都变成了可重放的确定步骤——重放零模型成本、副作用只生效一次,整条链路可以在离线环境里逐字节复现。

你让渡的:每个步骤的标识必须稳定(输入要归一化,剔除时间戳、随机 id 等易变项);模型调用要走录制层,不能直连;副作用必须配一个幂等账本,且账本与副作用要同事务。这些约束把「随手写一段 Agent 逻辑」的自由收窄了,但换来的是「任务可跨进程、跨机器、跨天重放」的可靠性——这正是从「能跑」到「能生产」的那道门槛。

把这套 tape 机制放到 6.1 的 Durable 引擎里,两者是正交的:引擎管「哪些步骤已完成」,tape 管「某一步的输出是什么、能不能复现」。合起来才是完整的 Durable Execution。下一节 6.3 与自研检查点对照 会把本节这套自研机制与现成框架(LangGraph 检查点、Temporal 类模型)逐项对照,回答「什么时候自研够用、什么时候该上框架」。

阅读导航:上一节:6.1 为什么需要持久化执行:崩溃/重启/长任务改造 · 下一节:6.3 与自研检查点对照:轮子与现成品边界 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距