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 与自研检查点对照:轮子与现成品边界 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。