14.3 幂等与副作用
14.1 让 Agent 会重试,14.2 让它会超时止损。这两件事都提高了「把活干成」的概率,却同时埋了一个新风险:同一个动作可能被执行两次。如果这个动作是「查一下天气」,重复无害;如果是「给客户退款」,重复就是事故。可靠性的最后一块拼图,是让重复执行不产生重复影响。
本节把 minagent 推进到
minagent/resilience/idempotency.py:为工具调用生成幂等键,用一个注册表记住已执行的副作用并返回缓存结果,让重试、恢复、重复调用都不会真的「再做一遍」。
14.3.1 Agent 为什么会重复执行同一个工具
在普通程序里,「同一个函数被调用两次」通常是 bug。但在 Agent 里,重复调用是正常路径上的常客,至少有四个来源:
| 来源 | 场景 | 是否可避免 |
|---|---|---|
| 重试 | 14.1 的 with_retry 在超时/5xx 后重发 | 不可避免(正是为容错) |
| 检查点恢复 | 第 7.3 节的续跑,从存档点重放未完成步骤 | 不可避免 |
| 模型重复调用 | 模型在同一轮或相邻轮发两次一样的 tool_call | 难完全避免 |
| 并发竞态 | 两个 worker 同时决定执行同一动作 | 需加锁 |
前两个是我们自己引入的:为了让 Agent 更可靠,我们主动重发请求。这就形成一个悖论——容错机制本身制造了重复。所以「幂等」不是可选优化,而是「启用重试」的前置条件:只有当重试是幂等的,重试才是安全的。
第三个来源最能说明问题:模型并不「记得」自己上一轮调用过什么。给它一个能查订单的工具,它完全可能在推理中重复发出同一个查询——它不是在「重试」,只是「又想到了」。对这类调用,防护手段和重试一样:识别出「这是同一个操作」,第二次直接返回第一次的结果。
第四个来源是分布式特有的:当第 13 章的多 Agent 跑起来,两个 worker 可能同时决定「给这个订单发货」。这时不仅需要幂等,还需要在存储层加锁——本节先解决单进程的重复,多进程的一致性留到卷二。
换个角度看,这是**「至少一次」投递语义的必然结果。为了让 Agent「不漏事」,重试与恢复都保证「至少执行一次」;而「至少一次」的另一面就是「可能不止一次」。要把它变成「恰好一次」,唯一的办法不是「保证只发一次」(分布式下做不到),而是「让重复发的那次不产生额外影响」——这就是幂等。「恰好一次」=「至少一次」+「幂等」**,这是分布式系统的经典等式。
14.3.2 只读与写入:天然幂等 vs 必须防护
不是所有工具都需要防护。区分标准只有一条:这次调用有没有改变外部世界。
| 工具类型 | 例子 | 幂等性 | 重复调用的后果 |
|---|---|---|---|
| 只读 | search / read / get_weather | 天然幂等 | 结果一样,只是浪费一次调用 |
| 写入 | send_email / create_order / refund | 不幂等 | 副作用叠加:多封邮件、重复下单 |
| 有状态 | increment_counter / append_log | 不幂等 | 计数被多加、日志被多写 |
只读工具天然幂等——查两次天气和查一次,世界没有任何变化,无非多花点时间。对它们做幂等缓存意义不大(反而可能返回过期数据)。真正要防护的是写入类工具:它们的调用会改变外部状态,而外部状态不会因为「这是重试」就自动回滚。
所以工程上的做法是给工具打标签:在注册时声明它是只读还是写入,幂等层只对写入类生效。这比「对所有工具一律缓存」更精确——后者会让只读工具读到陈旧结果,得不偿失。在 minagent 里,这层声明可以挂在 Tool 上,也可以像本节一样在注册表里用 mark_write 显式登记。
一个容易被忽略的边界是**「读」也可能不幂等**:如果工具背后是一次「读取并删除」的队列消费(pop),那它表面是读、实际是写。判断幂等性不能看工具的名字,要看它是否改变了任何持久状态。凡是有「消耗」语义的操作——出队、取号、领券——都要归到写入类,接受幂等防护。
14.3.3 idempotency_key:由工具名 + 规范化参数生成
幂等的核心是一个问题:怎么判断「这次调用」和「上次调用」是同一个操作? 答案是给每次调用算一个指纹——幂等键。两次调用的键相同,就认定它们是同一个操作。
键由两部分拼成:工具名 + 参数。工具名区分「发邮件」和「查邮件」,参数区分「发给 A」和「发给 B」。但参数直接拼字符串有个坑:{"to": "a", "subject": "Hi"} 和 {"subject": "Hi", "to": "a"} 在语义上是同一个调用,字典键序不同而已。所以要先规范化:
import json, hashlib
def normalize_args(args: dict) -> str:
# 排序键 + 紧凑分隔符,消除「键序/空格不同」带来的假差异
return json.dumps(args, sort_keys=True, ensure_ascii=False,
separators=(",", ":"))
def idempotency_key(tool_name: str, args: dict) -> str:
payload = f"{tool_name}:{normalize_args(args)}"
return hashlib.sha256(payload.encode()).hexdigest()[:16]
sort_keys=True 保证键序无关,separators=(",", ":") 去掉多余空格,ensure_ascii=False 让中文参数原样参与哈希。最后用 sha256 压成一个定长短串——用哈希而不是原文,是因为键要能当字典键、能落盘、能跨进程传递,原文可能很长。取前 16 位十六进制,碰撞概率对单机场景已经足够低。
规范化这一步是幂等能否生效的关键。如果键的算法对「键序」敏感,那么同一个操作换一种参数写法就会被算成两个不同的键,防护直接失效——模型发的参数顺序是不可控的,规范化必须抹平它。同一份参数、两种键序,真跑算出来是同一个键:
{"to": "a@x.com", "subject": "Hi"} -> 16f92182bcd5cfc3
{"subject": "Hi", "to": "a@x.com"} -> 16f92182bcd5cfc3
14.3.4 IdempotentRegistry:记录已执行的副作用
有了键,注册表就是一个「键 → 首次结果」的字典。第一次调用真正执行并把结果记下;后续同键调用直接返回记录,不再碰真实副作用:
class IdempotentRegistry:
def __init__(self):
self._results: dict[str, str] = {} # key -> 首次结果
self._write_tools: set[str] = set()
def mark_write(self, name: str) -> None:
self._write_tools.add(name) # 声明哪些工具是写入类
def call(self, name: str, func, args: dict) -> str:
if name not in self._write_tools:
return func(**args) # 只读:天然幂等,直接跑
key = idempotency_key(name, args)
if key in self._results:
print(f" [幂等命中] {name} key={key} 返回缓存结果")
return self._results[key]
print(f" [首次执行] {name} key={key}")
result = func(**args) # 只有首次才碰副作用
self._results[key] = result
return result
两个细节值得说。其一,只读工具走的是「短路分支」——不进字典、不做哈希,直接执行,这样既不承担哈希开销,也不会读到陈旧结果。其二,缓存的是结果而非「已执行」标记:重试的调用方期望拿到一个结果,如果第二次只回一个「已执行」的空壳,模型会以为工具没返回内容。返回与首次完全一致的结果,才能让重试对调用方完全透明。
注册表还有一个工程上要留意的点:_results 会随运行单调增长。一个长跑的服务如果永不清理,这个字典会越攒越大——虽然每个条目只是一个短键和一段结果文本,但成千上万次工具调用之后仍会占内存。所以生产版会配一个淘汰策略(LRU 或 TTL),只保留最近一段时间内的键。本节的内存版故意不做淘汰,是为了让「命中 / 未命中」的行为完全确定、便于测试。
14.3.5 真跑:假模型重复调用同一个写操作
用一个「发邮件」工具做验证——它内部有一个副作用计数器,每真正执行一次就 +1。再让假模型连续两轮发出语义相同的写调用:
from dataclasses import dataclass
from typing import Any
@dataclass
class ToolCall: # minagent.types 里的类型
id: str
name: str
arguments: dict[str, Any]
side = {"count": 0}
def send_email(to: str, subject: str) -> str:
side["count"] += 1
return f'{{"sent": "{to}", "n": {side["count"]}}}'
reg = IdempotentRegistry()
reg.mark_write("send_email")
# 假模型脚本:连续两轮发出同一个写操作
script = [
[ToolCall("c1", "send_email", {"to": "a@x.com", "subject": "Hi"})],
[ToolCall("c2", "send_email", {"subject": "Hi", "to": "a@x.com"})], # 键序不同
"已通知客户。",
]
for turn, item in enumerate(script):
if isinstance(item, str):
print(f"turn {turn}: final -> {item}")
break
for tc in item:
out = reg.call(tc.name, send_email, tc.arguments)
print(f"turn {turn}: {tc.id} -> {out}")
print("实际副作用次数:", side["count"])
c1 是首次,c2 参数键序不同但语义相同。真跑(Python 3.14.6):
[首次执行] send_email key=16f92182bcd5cfc3
turn 0: c1 -> {"sent": "a@x.com", "n": 1}
[幂等命中] send_email key=16f92182bcd5cfc3 返回缓存结果
turn 1: c2 -> {"sent": "a@x.com", "n": 1}
turn 2: final -> 已通知客户。
实际副作用次数: 1
结果正是我们要的:c1 首次执行,计数器到 1;c2 命中了同一个键(尽管它的参数写成 {"subject": "Hi", "to": "a@x.com"},键序和 c1 相反)——这证明规范化抹平了键序差异;到最后一轮模型给出 final 回答时,实际副作用次数 停在 1。两次调用、一次真实副作用,重复的那一次被彻底挡住了。
14.3.6 对照:不防护会怎样
为了看清防护的价值,把同一组两次调用在不加幂等的情况下跑一遍,直接执行:
== 不防护:直接执行 ==
{"sent": "a@x.com", "n": 1}
{"sent": "a@x.com", "n": 2}
副作用次数: 2
== 加幂等:同键命中缓存 ==
首次 key=16f92182bcd5cfc3 -> {"sent": "a@x.com", "n": 1}
命中 key=16f92182bcd5cfc3 -> {"sent": "a@x.com", "n": 1}
副作用次数: 1
不防护时,同一个客户收到了两封邮件(n 涨到 2);加幂等后只发了一封(停在 1)。这就是「重试安全」与「重试危险」的分界。再想一步:如果这个工具换成 refund,副作用次数: 2 就是退了两次款——一个幂等键,挡住的就是这种事故。如果去掉 sort_keys=True,c2 会算出不同的键、被当成新操作,计数器同样会涨到 2,一封邮件真的被发了两遍——这正是规范化那一步的分量。
这也解释了为什么「幂等」是 Agent 能安全使用重试的前提:14.1 的重试器越激进(重试次数越多),重复的风险越高;只有先有了幂等,才敢把重试次数调大。没有幂等的重试,是在赌运气;有了幂等的重试,才是工程。
14.3.7 幂等键必须由框架生成,不能交给模型
一个很自然的诱惑是:让模型在调用工具时自己带一个 idempotency_key 参数。这是错的,而且错得很危险:
| 理由 | 说明 |
|---|---|
| 模型不可靠 | 它可能每次生成不同的键,防护直接失效 |
| 键必须确定 | 同一个操作必须映射到同一个键,模型做不到 |
| 职责错位 | 幂等是框架的横切关注点,不该混进业务参数 |
正确的分工是:模型负责「想做什么」,框架负责「这是不是重复」。模型只管发出语义清晰的工具调用,框架在分发前用工具名 + 规范化参数算出键。这样无论模型换一个措辞、改一个键序,框架依然能识别出重复。让模型提供幂等键,等于把最需要确定性的一环交给了最不确定的组件——幂等键是「算」出来的,不是「问」出来的。
14.3.8 边界:缓存该活多久、该放哪
幂等注册表也有它的边界。第一是生命周期:进程内存里的字典,重启就没了。如果重试发生在重启之后(比如检查点恢复跨了进程),内存缓存救不了——要跨进程幂等,键和结果得落盘,第 7.3 节的检查点文件正好是天然的落点。第二是过期:无限期缓存会让「用户真的想再发一次同一封邮件」被误判成重复,所以生产里通常给键设一个 TTL(比如 24 小时),过期后允许重发。第三是范围:键只该在「一次任务」的范围内有效,跨任务复用会张冠李戴。
| 维度 | 内存版(本节) | 持久化版(生产) |
|---|---|---|
| 存储 | 进程内 dict | SQLite / Redis |
| 存活 | 进程生命周期 | 跨重启 |
| 过期 | 无 | TTL |
| 适用 | 单进程、短期重试 | 检查点恢复、多 worker |
卷一先立内存版,因为它零依赖、可断言,足以覆盖「重试」和「模型重复调用」两个主要来源;跨进程的那一半,用第 7.3 节的检查点思路就能扩展。
选择哪种存储,取决于「重复可能发生在多远的距离上」:如果只是单进程内的重试,内存够用;如果涉及重启,就得落盘;如果涉及多 worker,就得用共享存储(Redis)并加锁。存储的选择跟着「重复的距离」走,这是分布式幂等设计的通用原则。
14.3.9 与检查点恢复合流
幂等注册表最有价值的时刻,是它和第 7.3 节的检查点恢复合流时。想象一个任务跑到一半崩了,从检查点重启:它会把「存档点之后」的步骤重新执行一遍——包括那些已经成功、但结果没来得及写进存档点的写操作。没有幂等,这些写操作会真的再发生一次;有了幂等,第二次命中缓存、直接返回,任务安全续跑。
# 从检查点恢复时,注册表要和检查点一起持久化
checkpoint = {
"messages": [...],
"done_keys": list(reg._results.keys()),
}
# 恢复时把键灌回注册表,重放的写操作就会命中缓存
reg._results = {k: load_result(k) for k in checkpoint["done_keys"]}
注意这里把 _results 的键也存进了检查点——键是「这个操作做过没」的权威记录。这正是 14.3.8 说的「要跨进程幂等,键和结果得落盘」的落点:检查点文件就是那份持久化的注册表。第 7 章的恢复机制和第 14 章的幂等防护,在这里合成了一件事。
14.3.10 幂等键也会「误伤」
幂等键不是越细越好。如果键太粗(比如只用了工具名),两个不同的操作会被算成同一个,第二个被错误地返回缓存——用户想给 A 和 B 各发一封邮件,结果 B 拿到的是 A 的结果。如果键太细(比如把时间戳、随机数也算进参数),同一个操作每次都会算出不同的键,防护形同虚设。
| 键的粒度 | 例子 | 后果 |
|---|---|---|
| 太粗 | 只用 send_email | 不同收件人被误判为重复 |
| 太细 | 参数里含时间戳 / 随机数 | 每次都是新键,防护失效 |
| 正好 | 工具名 + 业务参数 | 同操作同键、异操作异键 |
判据是:键应该恰好包含「决定这个操作是什么」的全部参数,不多不少。这正是「工具名 + 规范化参数」的用意——工具名定「哪类操作」,参数定「哪一次操作」。多加一个时间戳,防护就废了;少加一个收件人,就会误伤。
14.3.11 小结:让重试安全的三个条件
回看整章,一个「敢重试」的 Agent 需要同时满足三个条件,缺一不可:
| 条件 | 由谁提供 | 缺了会怎样 |
|---|---|---|
| 错误可分类 | 14.1 的 classify | 对确定错误反复重试 |
| 等待有上限 | 14.2 的 timeout + TaskGroup | 被慢调用拖死、留孤儿 |
| 重复无副作用 | 14.3 的幂等键 | 写操作被重复执行 |
三者合起来,才是「可靠」。单独做任何一条,都只是把失败挪了个地方。
至此第 14 章闭环:14.1 分类错误、只重试能救的;14.2 超时止损、结构化取消不留孤儿;14.3 让重试不产生重复影响。三节合起来,minagent 从「能跑」变成「敢重试」——这是它迈向生产的第一步,也是最容易被跳过、却最容易出事故的一步。
真实模型下会怎样:本节用的是脚本化的重复调用,键是确定性算出来的。真实 Claude 的 tool_call.id 每次调用都不同——它标识的是「这一次请求」,不是「这一个操作」,所以不能用它当幂等键,必须像本节一样从工具名和参数重新算。更麻烦的是,真实模型可能把「同一个操作」用不同的参数措辞表达出来(比如把 subject 从 "Hi" 改成 "Hi!"),这时键会不同,幂等层也拦不住——它只能保证「参数完全一致」的重复被挡住。要挡住「语义相同但写法不同」的重复,需要模型层面配合:在系统提示里要求它在重发前先确认这个操作是否已经做过,或者让工具自己返回「该操作已存在」的业务错误。卷一把确定性的那一半(完全相同的调用)做扎实,语义级的去重留到卷三。
阅读导航:上一节:14.2 超时、取消与结构化并发 · 下一节:15.1 运行追踪 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。