Agent工具调用:幂等重试
Agent 调用创建笔记一类写入工具时,超时只说明没有按时拿到结果,不说明服务端没有执行。可靠的重试需要复用同一个业务操作键,由服务端核对参数、记录结果并阻止重复副作用;只在提示词里要求“不要重复”不够。
文章目录
最容易重复的,是已经成功的那次调用
设想一个本地演示业务:助手根据用户请求创建一条笔记。工具服务已经插入记录,但返回结果的连接断开。助手看到超时,又发起一次创建;如果服务端把它当成新操作,就出现两条相同笔记。这不是模型理解能力的问题,而是调用方与执行方对完成状态的认知不一致。
这种“结果未知”应当在状态机里有自己的位置。不能直接从超时跳到失败,再从失败生成一个全新的操作。要先保留原操作身份,查询或重放同一操作的结果。失败提示越笼统,越需要把确定未执行与可能已执行分开。
这里使用笔记作为可撤销、无外部影响的例子。幂等性不替代权限检查和参数校验;工具执行前仍应确认当前调用者可以做这件事。它解决的是同一件事重复到达,不负责判断这件事是否获得授权。
幂等不是“返回内容一模一样”
RFC 9110第9.2.2节把幂等描述为多次相同请求与一次请求具有相同的预期服务端效果。响应不必逐字一致,日志也可以多一条。因此,给工具套一个 HTTP 方法名,并不会自动获得业务层去重。
例如,创建资源的 POST 能否安全重放,需要看服务端提供的操作契约;不能看到网络错误就一概自动重试。反过来,一个宣称幂等的接口若实际每次都追加一条业务记录,也不能由客户端单方面补救。
对 Agent 的工具定义,最好明确写出操作身份、可查询结果、参数冲突与未知状态如何处理。模型生成的某次 tool call 标识未必跨重试或工作流恢复保持不变。业务执行层应保存稳定身份,而不是依赖模型每次恰好生成相同文本。
同一内容,不一定是同一意图
用户可能确实想创建两条同名笔记。若只对 title 做哈希来去重,第二个合法请求会被错误吞掉。内容相同只能说明参数相同,不能证明业务意图相同。一个新的用户动作通常需要新的操作键;同一动作的传输重试则继续使用原键。
反过来,同一个键带着不同标题再次到达,也不能默默返回旧结果,让调用方以为新标题被保存。服务端应识别参数不匹配并明确报告冲突。AWS Builders’ Library 的工程说明讨论了客户端请求标识、参数匹配与原子记录的关系;这里把这些原则落到一个小型教学实现。
键还需要作用域。不同租户恰好使用相同字符串,不应该互相读到结果;同一租户里的不同工具操作也不能意外混用。本例把租户与操作键组成唯一约束,并在参数摘要中包含操作版本。生产系统的租户身份必须来自认证上下文,不能直接信任请求里自报的字符串。
先把业务效果和去重记录放进同一事务
若先插入笔记,提交后再写去重记录,两个步骤之间崩溃,会留下“效果存在、身份记录不存在”的窗口。若先标记已完成再插入笔记,则可能留下相反问题。对能够位于同一数据库里的两类记录,可以用一个事务一起提交。
SQLite 的原子提交说明介绍了其事务原子性的机制。下面示例仅演示单连接、顺序调用,把笔记与已完成结果放在一个本地内存数据库中;它没有实现分布式锁、并发冲突恢复、过期清理或网络传输。
import hashlib
import json
import sqlite3
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE notes (
id INTEGER PRIMARY KEY, tenant TEXT NOT NULL, title TEXT NOT NULL
);
CREATE TABLE operations (
tenant TEXT NOT NULL, op_key TEXT NOT NULL,
fingerprint TEXT NOT NULL, response TEXT NOT NULL,
PRIMARY KEY (tenant, op_key)
);
""")
def create_note(tenant, op_key, title, fail_before_record=False):
payload = json.dumps({"action": "create-note-v1", "title": title},
sort_keys=True, separators=(",", ":"))
fingerprint = hashlib.sha256(payload.encode()).hexdigest()
with db:
previous = db.execute(
"SELECT fingerprint, response FROM operations "
"WHERE tenant=? AND op_key=?", (tenant, op_key)
).fetchone()
if previous:
if previous[0] != fingerprint:
raise ValueError("Key reused with different parameters")
return json.loads(previous[1])
cursor = db.execute("INSERT INTO notes(tenant,title) VALUES (?,?)",
(tenant, title))
result = {"id": cursor.lastrowid, "title": title}
if fail_before_record:
raise RuntimeError("Injected failure before operation record")
db.execute("INSERT INTO operations VALUES (?,?,?,?)",
(tenant, op_key, fingerprint, json.dumps(result)))
return result
# Local simulation: commit succeeded, but the caller lost the response.
first = create_note("demo-a", "operation-1", "Example note")
retry = create_note("demo-a", "operation-1", "Example note")
assert first == retry
assert db.execute("SELECT count(*) FROM notes").fetchone()[0] == 1
try:
create_note("demo-a", "operation-1", "Changed title")
except ValueError:
pass
else:
raise AssertionError("Conflicting parameters accepted")
try:
create_note("demo-a", "operation-fail", "Rollback", True)
except RuntimeError:
pass
assert db.execute("SELECT count(*) FROM notes").fetchone()[0] == 1
assert db.execute("SELECT count(*) FROM operations").fetchone()[0] == 1
create_note("demo-a", "operation-2", "Example note")
create_note("demo-b", "operation-1", "Example note")
assert db.execute("SELECT count(*) FROM notes").fetchone()[0] == 3
assert db.execute("SELECT count(*) FROM operations").fetchone()[0] == 3
print("Replay kept one note; conflict rejected; rollback passed; total notes: 3")
db.close()
原创 Python 示例已运行。第一次调用后重放相同键,笔记数仍为1;同键改参数被拒绝;插入效果后、写操作记录前注入异常,两个表都回到原状态。新操作键允许同名笔记,另一个演示租户也可使用相同键,最终总数为3。
这组验证覆盖的是本地事务边界,不是“所有服务永远只执行一次”的证明。参数摘要也不是加密授权凭据;示例参数都是普通字符串。生产参数规范化需明确数值、默认值与版本语义,不能临时换一套序列化方式,导致本来相同的请求被判断为冲突。
并发到达与进程重启还要单独设计
两次相同操作可能同时到达,而不是像示例一样先后到达。先查询“没有记录”,再各自执行,不能单靠这个查询保证去重。需要让唯一约束、事务或明确的状态竞争决定谁取得执行权,并规定另一个请求是等待、查询状态还是得到可重试的进行中结果。
“进行中”也不能冒充“已完成”。如果把一个占位记录当成功结果返回,调用方就可能拿着不存在的资源继续工作。另一方面,仅因等待太久就把占位删掉并重新执行,也可能与仍在工作的旧请求重叠。状态迁移和故障恢复要有明确规则,不要让超时隐式决定业务事实。
本例的内存数据库会在进程结束后消失,只适合演示原子性,不能承担真正的重启恢复。部署时需要持久化记录,并验证备份、恢复与保留期限下的行为。工作流恢复后也要带回原操作键,而不是只恢复提示词历史,再让模型重新规划一个相同动作。
跨服务副作用不在本地事务里面
如果创建笔记后还要让另一个服务处理附件,本地回滚不能撤销远端已经完成的动作。把远端调用放在数据库事务里,也不会因此把网络变成同一个原子提交域;反而可能长时间占着锁等待。
一种常见工程方向,是本地保存业务记录与待发送事件,再由独立发送流程处理。接收方仍需要自己的去重或幂等契约,重放也仍需保留事件身份。这样的设计能缩小不一致窗口,但不能仅凭“用了消息队列”就宣称端到端 exactly-once。
对于结果未知的写入,优先按稳定身份查询状态;接口支持幂等重试时,再按其契约重放。没有查证手段、也没有幂等保证的副作用,应把不确定状态保留给业务处理,不能让 Agent 猜一个结果后继续执行后续动作。
键的生命周期也是接口的一部分
去重记录不是永久免费的缓存。保存多久、什么时候允许清理、过期请求再次到达会怎样,都要与工作流最长恢复时间相匹配。清理掉记录后再收到迟到重试,服务端可能已经失去识别旧操作的依据。
因此要记录操作键、参数版本、完成状态和可供恢复的结果引用,并明确过期后的行为。若结果包含私有数据,查询与重放仍须重新校验调用者身份;知道键值不应等同于拥有访问权。日志也不必复制整份敏感输入,可记录受控的关联信息与错误分类。
幂等性也不意味着可以无限重试。无界重试会制造额外负载和长等待。重试次数、总截止时间与退避属于另一组资源约束;先确认“可安全重试”,再决定“还值不值得重试”。
让故障演练覆盖看不见的那一半
测试至少包含:提交前失败、提交后响应丢失、同键同参、同键改参、同内容新键、不同作用域相同键,以及服务恢复后的重放。并发环境还要单独验证唯一约束、事务隔离与冲突处理;本例的顺序成功不能替代它。
观察指标应区分请求次数、业务效果次数和重复结果重放次数。否则“工具调用变多”可能只是网络重试,而不是用户多创建了内容。评测时也应保留未知结果,和置信区间文章里的失败分母一样,不把难解释的记录直接删掉。
真正有用的验收问题是:同一业务动作在连接中断、重启与重放后,能否找到可解释的最终状态。做到这一步,Agent 才能在不确定的网络上恢复工作,而不是靠重复执行来碰运气。
参考资料
- RFC 9110 — Idempotent Methods
- AWS Builders’ Library — Making retries safe with idempotent APIs
- SQLite — Atomic Commit
本文于2026年10月14日晚补发,文章日期保留原计划北京时间14:00。示例为本地教学实现,不是真实服务压测结果。
支持
如果文中的解释或示例帮你解决了问题、节省了时间,欢迎自愿打赏支持本站。
二维码可点击放大。更多支持方式见支持页面。


