Agent 工程体系 · 第 71/98 篇。内容以 2026 年 9 月可验证的公开规范和稳定接口为基线;框架版本敏感能力会明确标注,不把实验行为写成通用保证。
Agent 幂等设计:请求键、工具调用、写入去重、回执和重放
Agent 并不是一次函数调用,而是一个会反复推理、调用工具、接收工具结果、更新状态并继续执行的运行时。模型可能因为超时、网络断开、进程重启、Checkpoint 恢复或上游重试而再次生成相同工具调用;多 Agent 编排还可能让不同执行器在同一个业务任务上并发工作。
因此,Agent 的可靠执行不能建立在“每个步骤只会执行一次”这个假设上。更准确的基础假设是:
业务请求、Agent 运行、工具调用和外部写入,都可能以至少一次语义到达。
幂等设计的目标,不是让系统永远不重复发送请求,而是让重复请求不会重复产生不可接受的业务副作用,并且能够通过回执判断“之前到底发生了什么”。
OpenAI 将 Agent 描述为能够规划、调用工具、协作完成多步工作的应用;其 Agents SDK 也把运行循环、工具、编排、状态和可恢复结果作为不同的运行时概念。Anthropic 对 Agent 系统的说明同样强调了工作流、工具调用和多步自主执行之间的关系。幂等设计需要覆盖的,正是这些步骤之间的故障边界,而不只是某一个 HTTP 接口。(developers.openai.com)
1. 先区分四个容易混淆的对象
“重复”并不是单一概念。至少要区分:
- 请求重复:同一个用户请求或上游任务被提交多次。
- 运行重复:同一个请求因为恢复或重试,启动了多个 Agent Run。
- 工具调用重复:同一个工具调用被执行器提交多次。
- 业务写入重复:工具调用已经产生副作用,但调用方没有收到结果,于是再次写入。
如果不区分这四层,通常会出现一种错误实现:
收到请求
-> 生成一个 request_id
-> 每次重试都重新生成 request_id
-> 认为所有请求都是新请求
这实际上破坏了幂等性。请求键必须跨越重试、进程重启和恢复流程保持不变,否则系统无法判断两个请求是否代表同一个业务意图。
1.1 幂等的形式化定义
设某个操作为:
其中:
- 是输入;
- 是操作前状态;
- 是操作后状态;
- 是返回结果。
如果操作可重复执行,则要求:
也就是第一次执行后,再执行同一个操作,最终状态不再变化。
但这个定义还不够,因为 Agent 工具往往有外部副作用。例如发送邮件:
第一次调用:邮件已发送,但响应丢失
第二次调用:又发送一封邮件
如果第二次调用返回“发送成功”,数据库状态可能看起来正常,但用户实际收到了两封邮件。因此,Agent 幂等必须同时约束:
- 状态幂等:重复调用不重复修改业务状态;
- 副作用幂等:重复调用不重复发送、扣款、下单或删除;
- 结果幂等:重复调用返回同一业务结果,或返回明确的已完成回执;
- 审计幂等:重复调用不会制造无法解释的多条业务事实。
2. “Exactly-once”通常只是局部实现,不是端到端事实
分布式系统经常使用以下三种投递语义:
至少一次:At-least-once
发送方在没有收到确认时会重试:
发送请求
-> 等待超时
-> 再次发送请求
优点是尽量不丢请求,缺点是可能重复。
至多一次:At-most-once
发送方只发送一次,失败后不重试。
优点是不重复,缺点是可能丢失执行。
恰好一次:Exactly-once
从业务视角看,副作用只发生一次。
真正的端到端恰好一次非常难。即使 Agent 执行器只向支付服务发送一次请求,支付服务到银行、银行到清算系统之间仍可能存在重试。因此工程上更常见的做法是:
传输层采用至少一次,副作用层使用幂等键、唯一约束、状态机和回执把重复执行折叠成一次业务效果。
这可以写成:
这里的“effectively-once”不是说网络请求只发了一次,而是说同一业务意图最终只产生一次有效业务效果。
2.1 一个反例:仅依靠重试控制
下面的代码无法保证支付不重复:
def pay(order_id, amount):
try:
return payment_gateway.charge(order_id, amount, timeout=3)
except TimeoutError:
return payment_gateway.charge(order_id, amount, timeout=3)
问题在于第一次调用可能已经扣款,只是响应丢失。第二次调用没有任何证据表明第一次是否成功。
正确的问题不是:
超时后要不要再调用?
而是:
超时后,如何用同一个幂等键查询或折叠之前的操作?
3. 请求键:幂等链路的根
请求键是用来标识同一个业务意图的稳定标识。它不能只是一次 HTTP 请求的随机 ID,也不能由模型自由生成后直接信任。
一个完整的请求身份通常包含多层键:
business_request_id
└── agent_run_id
└── step_id
└── tool_call_id
└── effect_key
它们的作用不同。
3.1 business_request_id:业务请求键
它标识用户或上游系统提交的业务意图。
例如:
req_01JABCD8Z7M6P4K2
同一个用户点击“再次尝试”、网关重试、任务队列重新投递,都必须继续使用这个键。
适合放入:
- HTTP
Idempotency-Key; - 消息队列消息头;
- Agent Run 的业务上下文;
- 审计日志;
- Checkpoint 元数据。
3.2 agent_run_id:一次运行键
同一个业务请求可能有多个运行尝试:
business_request_id = req_123
agent_run_id = run_001
agent_run_id = run_002
run_001 可能因进程崩溃中断,run_002 从 Checkpoint 恢复。两个 Run 不同,但它们处理的是同一个业务请求。
因此不能把 agent_run_id 直接当作业务幂等键,否则恢复会被错误地当成新任务。
3.3 step_id:逻辑步骤键
一个 Agent 任务可能包含:
1. 查询库存
2. 创建订单
3. 扣款
4. 发送通知
每个逻辑步骤应有稳定的 step_id:
inventory.reserve
payment.charge
notification.send
如果 Agent 在恢复后重新计算步骤,不应该因为模型输出了新的随机名称而产生新的逻辑步骤。
3.4 tool_call_id:模型调用实例键
模型输出的一次工具调用通常带有工具名和参数。tool_call_id 用于标识该次模型决策,例如:
{
"tool_call_id": "call_abc123",
"tool_name": "charge_payment",
"arguments": {
"order_id": "order_1001",
"amount": 9900
}
}
它适合做追踪和执行器内部去重,但不应单独承担业务幂等责任。原因是:
- 模型重试可能生成新的
tool_call_id; - 不同 Agent 可能为同一个业务步骤生成不同调用 ID;
- 恢复逻辑可能重新构造工具调用;
- 恶意或错误客户端可能伪造调用 ID。
3.5 effect_key:副作用键
effect_key 是真正用于外部写入去重的键。它应由业务上下文和逻辑动作组成,而不是由模型任意决定。
例如:
effect_key = hash(
tenant_id,
business_request_id,
order_id,
operation="payment.charge",
operation_version="v2"
)
如果同一个订单允许多次合法支付,则不能简单使用 order_id,而应使用支付意图 ID:
effect_key = hash(order_id, payment_intent_id, "charge")
否则第二次真实支付也会被误判为重复。
4. 请求键的正确生成规则
请求键需要满足四个条件:
稳定
同一个业务意图在重试和恢复期间保持不变。
唯一
不同业务意图不能共享同一个键。
有作用域
不同租户、不同工具或不同业务操作之间最好隔离作用域。
可验证
服务端不能只接受客户端传来的键,还要绑定请求摘要,防止同一个键被用于不同参数。
可以定义:
服务端保存:
(scope, idempotency_key, arguments_hash, status, result)
当相同键再次到达时:
arguments_hash相同:视为重试,返回已有结果;arguments_hash不同:返回冲突错误;- 键不存在:创建执行记录。
伪代码如下:
def check_idempotency(scope, key, arguments):
digest = sha256(canonical_json(arguments).encode()).hexdigest()
record = store.find(scope=scope, key=key)
if record is None:
return "new", digest
if record.arguments_hash != digest:
return "conflict", record
if record.status == "SUCCEEDED":
return "replay", record.result
if record.status == "RUNNING":
return "in_progress", record
if record.status == "FAILED_RETRYABLE":
return "retry", record
return "inspect", record
关键点是:同一个键不能静默接受不同参数。
否则会出现下面的危险情况:
第一次:charge(order=1001, amount=99)
第二次:charge(order=1001, amount=199)
如果服务端只按键去重而不校验参数,第二次请求可能错误地复用第一次结果,或者覆盖第一次请求的业务含义。
5. 工具调用不是天然幂等的
Agent 工具可以按副作用分为四类。
5.1 纯读取工具
例如:
get_order(order_id)
search_products(query)
read_document(document_id)
这类工具通常没有业务写入,重复调用主要带来成本和延迟问题。它们仍然需要追踪键,但一般不需要持久化副作用回执。
5.2 确定性写入工具
例如:
set_order_status(order_id, status="PAID")
如果写入使用明确目标值,重复执行通常容易做到幂等:
UPDATE orders
SET status = 'PAID'
WHERE order_id = 'order_1001'
AND status <> 'PAID';
但要注意:状态更新本身不一定等价于业务完成。如果“设置为 PAID”同时还需要写支付流水、发票和库存扣减,就不能只靠这一条 SQL。
5.3 非确定性创建工具
例如:
create_ticket(title, description)
create_payment_intent(order_id, amount)
如果每次调用都生成新的数据库 ID,重复调用会创建多条记录。它需要显式的幂等键:
CREATE UNIQUE INDEX uniq_payment_intent
ON payment_intents(merchant_id, effect_key);
5.4 外部副作用工具
例如:
send_email
send_sms
charge_card
issue_refund
delete_cloud_file
publish_message
这类工具的困难在于:副作用可能发生在本地数据库之外,而且调用结果可能丢失。
工具契约必须声明其幂等属性:
{
"name": "charge_payment",
"side_effect": "external_write",
"idempotency": "required",
"replay_policy": "return_original_receipt",
"timeout_policy": "query_then_retry",
"compensation": "refund_or_manual_review"
}
这里的字段是应用层契约示例,不是某个特定 Agent SDK 的标准 API。SDK 可以负责工具调用循环和状态传递,但业务系统必须自行定义外部写入的幂等语义。OpenAI 的 Agent 文档将工具、运行状态和编排分别作为运行时能力说明;这不等于具体业务工具自动获得幂等保证。(developers.openai.com)
6. 工具执行器的核心状态机
一个可靠的工具执行器不能只有:
调用工具 -> 返回结果
至少应有以下状态:
NEW
-> ACCEPTED
-> RUNNING
-> SUCCEEDED
-> FAILED_RETRYABLE
-> FAILED_FINAL
-> UNKNOWN
6.1 状态含义
NEW:尚未创建执行记录;ACCEPTED:已接受调用,已完成参数校验和授权;RUNNING:执行器已开始调用外部系统;SUCCEEDED:确认副作用成功,并持久化回执;FAILED_RETRYABLE:确定未成功,或业务允许稍后重试;FAILED_FINAL:确定不可重试,例如参数非法、权限不足;UNKNOWN:无法确认外部副作用是否发生。
UNKNOWN 是最重要也最容易被忽略的状态。
例如:
1. 执行器调用支付网关
2. 网关完成扣款
3. 响应返回前连接断开
4. 执行器只知道“超时”
此时不能把状态直接写成 FAILED,因为失败并未被证实。正确状态是:
UNKNOWN
然后由恢复流程执行:
使用同一个 effect_key 查询支付网关
-> 查到成功:写入 SUCCEEDED
-> 查到失败:写入 FAILED_RETRYABLE 或 FAILED_FINAL
-> 仍无法确认:保留 UNKNOWN,进入人工或延迟检查
6.2 状态转换图
stateDiagram-v2
[*] --> NEW
NEW --> ACCEPTED: 参数/授权通过
NEW --> FAILED_FINAL: 参数或授权失败
ACCEPTED --> RUNNING: 获得执行租约
RUNNING --> SUCCEEDED: 确认副作用成功
RUNNING --> FAILED_FINAL: 明确业务失败
RUNNING --> FAILED_RETRYABLE: 明确未执行且可重试
RUNNING --> UNKNOWN: 超时/连接断开
UNKNOWN --> SUCCEEDED: 查询确认成功
UNKNOWN --> FAILED_RETRYABLE: 查询确认未执行
UNKNOWN --> UNKNOWN: 仍无法确认
FAILED_RETRYABLE --> RUNNING: 重新获得租约
状态机的关键约束是:
因为“没有收到响应”和“外部操作失败”不是同一个事实。
7. 写入去重:数据库唯一约束优于应用层判断
最常见但不可靠的写法是:
if not db.exists(effect_key):
db.insert(effect_key, payload)
两个并发执行器可能同时读到“不存在”,然后都插入:
执行器 A:exists = false
执行器 B:exists = false
执行器 A:insert
执行器 B:insert
正确做法是把去重条件交给数据库的唯一约束:
CREATE TABLE effect_records (
effect_key TEXT PRIMARY KEY,
tool_name TEXT NOT NULL,
arguments_hash TEXT NOT NULL,
status TEXT NOT NULL,
result_json TEXT,
receipt_json TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
插入时使用原子冲突处理:
INSERT INTO effect_records (
effect_key,
tool_name,
arguments_hash,
status,
created_at,
updated_at
)
VALUES (?, ?, ?, 'ACCEPTED', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT(effect_key) DO NOTHING;
然后读取记录:
SELECT effect_key, tool_name, arguments_hash, status,
result_json, receipt_json
FROM effect_records
WHERE effect_key = ?;
如果插入成功,当前执行器获得执行权;如果插入失败,说明已有其他执行器处理过或正在处理,应读取既有记录,而不是再次创建副作用。
7.1 SQLite 可运行示例
下面的示例展示“同一个效果键只产生一条业务记录”。它只演示本地数据库去重,不代表可以自动保证第三方支付网关幂等。
import hashlib
import json
import sqlite3
from datetime import datetime, timezone
def canonical_json(value):
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def digest_args(arguments):
return hashlib.sha256(
canonical_json(arguments).encode("utf-8")
).hexdigest()
def now():
return datetime.now(timezone.utc).isoformat()
db = sqlite3.connect(":memory:")
db.execute("""
CREATE TABLE effect_records (
effect_key TEXT PRIMARY KEY,
tool_name TEXT NOT NULL,
arguments_hash TEXT NOT NULL,
status TEXT NOT NULL,
result_json TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
""")
effect_key = "tenant_a:req_1001:payment.charge:v1"
arguments = {
"order_id": "order_1001",
"amount": 9900,
"currency": "CNY",
}
args_hash = digest_args(arguments)
cursor = db.execute("""
INSERT INTO effect_records (
effect_key, tool_name, arguments_hash, status, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(effect_key) DO NOTHING
""", (
effect_key,
"charge_payment",
args_hash,
"SUCCEEDED",
now(),
now(),
))
db.commit()
print("first insert rowcount =", cursor.rowcount)
cursor = db.execute("""
INSERT INTO effect_records (
effect_key, tool_name, arguments_hash, status, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(effect_key) DO NOTHING
""", (
effect_key,
"charge_payment",
args_hash,
"SUCCEEDED",
now(),
now(),
))
db.commit()
print("second insert rowcount =", cursor.rowcount)
row = db.execute("""
SELECT effect_key, status, arguments_hash
FROM effect_records
WHERE effect_key = ?
""", (effect_key,)).fetchone()
print(row)
预期输出类似:
first insert rowcount = 1
second insert rowcount = 0
('tenant_a:req_1001:payment.charge:v1', 'SUCCEEDED', '...')
这里成立的原因不是 Python 的 if 判断,而是 effect_key TEXT PRIMARY KEY 由数据库在并发下提供原子约束。
但这个示例仍有一个边界:它把记录直接写成 SUCCEEDED,没有模拟外部支付。真实流程中,effect_records 应先写为 ACCEPTED 或 RUNNING,外部调用成功后再写入回执。
8. 回执:判断“已经发生什么”的唯一依据
回执是工具执行后持久化的、可用于重试、查询、审计和重放的结果证明。
回执不应只保存:
{
"success": true
}
因为这个结果无法回答:
- 哪个业务对象被修改?
- 外部系统返回了什么 ID?
- 是否已经产生扣款?
- 这个结果对应哪个参数版本?
- 后续恢复时能否安全重用?
一个较完整的回执可以是:
{
"receipt_version": 1,
"effect_key": "tenant_a:req_1001:payment.charge:v1",
"tool_name": "charge_payment",
"arguments_hash": "sha256:...",
"provider": "payment_gateway",
"provider_request_id": "pg_req_7788",
"provider_object_id": "charge_5566",
"status": "SUCCEEDED",
"amount": 9900,
"currency": "CNY",
"occurred_at": "2026-09-01T10:00:00Z"
}
回执具有三种用途:
执行回执
告诉当前 Agent 步骤是否完成。
恢复回执
进程重启后,执行器根据回执决定是跳过、查询还是重试。
对外业务回执
向上游返回稳定结果,例如订单号、支付流水号或通知发送状态。
这三者可以来自同一条记录,但不一定应该暴露同样的信息。内部回执可能包含供应商请求 ID,外部回执可能只包含业务订单号。
8.1 回执必须和参数绑定
假设服务端保存:
effect_key = payment:order_1001
receipt = amount 99 元支付成功
之后客户端用同一个键请求:
amount = 199 元
如果服务端直接返回旧回执,就会造成语义错乱。因而回执重放必须先通过:
effect_key 相同
AND arguments_hash 相同
AND tool_name 相同
AND operation_version 相同
否则应返回冲突:
{
"error": {
"type": "idempotency_conflict",
"message": "same idempotency key was used with different arguments",
"retryable": false
}
}
9. 并发执行:租约、抢占和“谁拥有执行权”
幂等键只能防止重复业务记录,不能自动解决两个执行器同时执行外部副作用的问题。
需要为执行记录增加执行租约:
effect_key
status = ACCEPTED
lease_owner = worker-a
lease_until = 2026-09-01T10:01:00Z
抢占流程可以是:
UPDATE effect_records
SET status = 'RUNNING',
lease_owner = ?,
lease_until = ?
WHERE effect_key = ?
AND (
status = 'ACCEPTED'
OR (status = 'RUNNING' AND lease_until < CURRENT_TIMESTAMP)
);
只有更新行数为 1 的执行器获得执行权。
如果两个 Worker 并发抢占:
Worker A:UPDATE 影响 1 行,获得租约
Worker B:UPDATE 影响 0 行,读取已有状态
Worker B 不能因为没有抢到租约就再次调用外部工具,而应:
- 等待 Worker A 完成;
- 读取最终回执;
- 如果租约过期,则重新抢占;
- 如果状态为
UNKNOWN,进入查询流程。
9.1 租约不是锁的替代品
租约只能表达“当前由谁负责”,不能证明前一个 Worker 已经停止。
例如:
1. Worker A 获得租约
2. A 长时间暂停
3. 租约过期
4. Worker B 获得新租约
5. A 恢复运行并继续调用外部系统
这就是“僵尸执行器”问题。
解决方式包括:
- 外部系统本身使用相同的
effect_key去重; - 每次提交前检查租约;
- 使用 fencing token,让旧 Worker 的写入被拒绝;
- 将外部调用设计为查询加确认,而不是盲目重做。
因此,数据库租约只能控制本地执行权,不能替代外部副作用的幂等协议。
10. 多 Agent 场景:逻辑所有权比调用来源更重要
在多 Agent 系统中,可能有:
规划 Agent -> 支付 Agent -> 通知 Agent
或者多个 Specialist 同时处理同一任务。问题是:如果每个 Agent 都生成自己的请求键,就可能产生多个副作用。
错误设计:
planner: payment_key = planner_run_001
executor: payment_key = payment_agent_run_009
retry: payment_key = retry_run_003
这三个键都不同,支付网关会认为它们是三笔独立支付。
正确设计是将业务效果的所有权绑定到业务步骤:
business_request_id = req_1001
logical_step_id = payment.charge
effect_key = hash(req_1001, order_1001, payment.charge, v1)
Agent 可以更换、重试或恢复,但逻辑步骤的 effect_key 不变。
10.1 Handoff 不应复制副作用权
多 Agent handoff 可以转移推理责任,但不应无条件转移写入权限。
例如:
规划 Agent:可以提出“扣款”计划
支付 Agent:可以执行扣款
通知 Agent:只能读取支付回执并发送通知
如果通知 Agent 发现没有支付结果,不应自行重新扣款,而应查询:
payment.effect_key
这体现了两个概念:
- 推理所有权:哪个 Agent 负责决定下一步;
- 副作用所有权:哪个执行器有权提交该业务效果。
两者必须分离,否则一个恢复后的 Agent 可能把“缺少上下文”误判为“尚未执行”。
11. 重放:不是重新调用,而是重新解释已持久化事实
重放有两种完全不同的含义。
11.1 结果重放
如果某个幂等键已经成功,重复请求直接返回原始回执:
请求 effect_key
-> 查到 SUCCEEDED
-> 返回原始 receipt
-> 不再调用外部系统
这是最安全的重放方式。
11.2 事件重放
从事件日志或 Checkpoint 重新驱动 Agent 状态:
读取历史事件
-> 恢复上下文
-> 重建决策状态
-> 对已完成副作用使用历史回执
-> 只执行尚未完成的效果
重放不能简单地把历史工具调用逐条再发一遍。否则:
历史事件:charge_payment succeeded
重放程序:再次调用 charge_payment
结果:重复扣款
正确的重放规则是:
11.3 时间和随机数也必须可重放
如果 Agent 工具参数依赖:
uuid.uuid4()
datetime.now()
random.randint()
那么恢复后重新计算可能得到不同结果。对于需要确定性重放的步骤,应把这些值写入执行记录:
{
"step_id": "create_invoice",
"derived_inputs": {
"invoice_number": "INV-20260901-1001",
"effective_at": "2026-09-01T10:00:00Z"
}
}
恢复时读取 derived_inputs,而不是重新生成。
12. Checkpoint 与副作用重放的交界
Checkpoint 保存的是 Agent 运行状态,例如:
messages
tool calls
tool results
current step
model metadata
但它不一定等于外部业务事实。一个 Checkpoint 可能在不同时间点生成:
A. 工具调用尚未提交
B. 工具已提交,但回执尚未写回
C. 工具回执已写入,但 Checkpoint 尚未更新
因此恢复时必须分别检查:
| Checkpoint 状态 | 副作用记录 | 恢复动作 |
|---|---|---|
| 未调用 | 不存在 | 允许执行 |
| 已调用 | ACCEPTED |
继续或抢占租约 |
| 已调用 | RUNNING 且租约有效 |
等待 |
| 已调用 | RUNNING 且租约过期 |
抢占后处理 |
| 已调用 | SUCCEEDED |
重放回执,不再调用 |
| 已调用 | UNKNOWN |
查询外部系统 |
| 已调用 | FAILED_FINAL |
返回失败 |
| 已调用 | FAILED_RETRYABLE |
使用相同键重试 |
最危险的情况是:
Checkpoint 看起来“工具结果为空”
但 effect_records 已经是 SUCCEEDED
这时不能因为上下文为空就重新执行。副作用记录属于更高优先级的事实源。
可以把恢复顺序写成:
1. 读取业务请求
2. 读取逻辑步骤
3. 读取 effect_record
4. 读取历史 receipt
5. 根据 effect_record 决定是否执行
6. 仅将 receipt 注入 Agent 上下文
7. 继续推理
不是:
1. 读取 Checkpoint
2. 发现没有工具结果
3. 再次调用工具
13. 一个完整的支付工具流程
假设 Agent 需要完成订单支付。
输入:
{
"request_id": "req_1001",
"order_id": "order_1001",
"payment_intent_id": "pi_1001",
"amount": 9900,
"currency": "CNY"
}
第一步:生成逻辑效果键
effect_key =
hash(
tenant_id="tenant_a",
request_id="req_1001",
order_id="order_1001",
payment_intent_id="pi_1001",
operation="payment.charge",
version="v1"
)
payment_intent_id 很重要,因为同一个订单可能允许退款后重新支付,或者允许分阶段支付。只使用 order_id 可能错误地把合法的新支付当成旧请求。
第二步:原子创建执行记录
不存在:
创建 ACCEPTED 记录
已存在且参数相同:
根据状态处理
已存在但参数不同:
返回 idempotency_conflict
第三步:执行器获取租约
ACCEPTED -> RUNNING
租约中应记录:
lease_owner
lease_until
fencing_token
第四步:向支付网关提交同一个幂等键
POST /charges
Idempotency-Key: tenant_a:req_1001:payment.charge:v1
如果支付网关支持幂等键,应直接透传经过作用域处理的键。如果不支持,则只能依靠:
- 先查询是否已存在同一订单或支付意图的扣款;
- 使用商户侧唯一流水号;
- 记录供应商请求 ID;
- 必要时进入人工核验。
不能因为供应商不支持幂等键,就假设本地数据库能保护外部扣款。
第五步:持久化回执
成功后在同一数据库事务内写入:
status = SUCCEEDED
receipt_json = {...}
并更新业务订单:
UPDATE orders
SET payment_status = 'PAID',
payment_receipt_id = ?
WHERE order_id = ?
AND payment_status IN ('UNPAID', 'PAYMENT_PENDING');
如果订单已经是 PAID,仍然应该检查回执是否属于同一个 payment_intent_id,不能仅凭状态相同就认定成功。
第六步:向 Agent 返回稳定工具结果
{
"ok": true,
"order_id": "order_1001",
"payment_status": "PAID",
"receipt_id": "charge_5566",
"replayed": false
}
重复调用时:
{
"ok": true,
"order_id": "order_1001",
"payment_status": "PAID",
"receipt_id": "charge_5566",
"replayed": true
}
replayed 只用于诊断和观测,不应让 Agent 把“重放结果”误解成“又完成了一次支付”。
14. 错误信封必须表达“是否可以安全重试”
Agent 工具错误不能只有字符串:
{
"error": "timeout"
}
执行器至少需要区分错误类别和副作用确定性:
{
"error": {
"type": "external_timeout",
"message": "provider response was not received",
"retryable": false,
"effect_status": "UNKNOWN",
"effect_key": "tenant_a:req_1001:payment.charge:v1",
"next_action": "QUERY_BEFORE_RETRY"
}
}
这里 retryable: false 的含义不是“永远不能处理”,而是“不能直接再次提交”。应先查询外部系统。
一个实用的错误分类如下:
| 错误 | 是否重试 | 副作用状态 | 下一步 |
|---|---|---|---|
| 参数解析失败 | 否 | 未开始 | 修正参数 |
| 授权失败 | 否 | 未开始 | 返回权限错误 |
| 限流 | 是 | 未知或未开始 | 退避重试 |
| 连接建立失败 | 通常是 | 未开始 | 可重试 |
| 请求发送后超时 | 不能盲重试 | UNKNOWN |
先查询 |
| 外部明确拒绝 | 视业务而定 | 未成功 | 读取错误码 |
| 本地写回执失败 | 不能直接重做 | 可能已成功 | 通过键查询或恢复 |
| 结果已存在 | 否 | 已成功 | 返回原回执 |
关键字段是:
effect_status
它比 retryable 更接近事实。因为“能不能重试”取决于“副作用有没有发生”,而不是取决于网络库抛出了什么异常。
15. 常见错误实现与失败表现
15.1 每次重试都生成新键
失败表现:
一个用户请求对应多个支付流水
一个 Agent Run 对应多条订单
同一通知发送多次
诊断方式:
按 business_request_id 聚合 effect_key
如果一个逻辑步骤出现多个不同效果键,说明键生成位置错误。
15.2 只在 Agent 层去重
例如在内存中保存:
seen_tool_calls = set()
失败原因:
- 进程重启后集合丢失;
- 多副本之间不共享;
- 新的
tool_call_id无法匹配旧调用; - 外部服务仍可能接收到重复请求。
去重记录必须持久化,且应该位于副作用提交边界附近。
15.3 只根据工具名去重
错误键:
effect_key = "send_email"
这会把所有邮件都当成同一个操作。正确键至少要包含业务对象和动作实例:
effect_key = hash(
tenant_id,
order_id,
notification_type="payment_success",
recipient,
template_version
)
15.4 将超时写成失败
失败表现:
支付已经成功
订单仍显示支付失败
恢复任务再次扣款
超时只能说明当前执行器没有得到结果,不说明外部副作用不存在。
15.5 回执写入失败后直接重新执行
流程可能是:
外部支付成功
本地写回执失败
任务重试
再次支付
解决方案不是简单扩大数据库事务,而是:
- 外部调用使用幂等键;
- 本地回执支持通过效果键重建;
- 恢复时先查询外部状态;
- 将
UNKNOWN作为一等状态。
15.6 把“工具结果重放”当成“工具重新执行”
Checkpoint 恢复时,历史工具调用可能会再次进入 Agent 循环。执行器必须在工具入口处检查效果记录,而不是信任上层是否已经去重。
16. 生产诊断:从一条请求还原完整因果链
幂等问题的诊断核心是沿着键回溯:
business_request_id
-> agent_run_id
-> step_id
-> tool_call_id
-> effect_key
-> provider_request_id
-> provider_object_id
每次工具执行至少记录:
{
"business_request_id": "req_1001",
"agent_run_id": "run_002",
"step_id": "payment.charge",
"tool_call_id": "call_abc123",
"effect_key": "tenant_a:req_1001:payment.charge:v1",
"arguments_hash": "sha256:...",
"status_before": "UNKNOWN",
"status_after": "SUCCEEDED",
"replayed": false,
"lease_owner": "worker-b",
"provider_request_id": "pg_req_7788"
}
重点指标不是单纯的“工具调用次数”,而是:
duplicate_request_count
replayed_receipt_count
unknown_effect_count
idempotency_conflict_count
lease_expired_count
provider_query_recovery_count
这些指标能区分:
- 上游重复提交;
- Agent 恢复造成的重复尝试;
- 执行器并发竞争;
- 外部服务超时;
- 参数键复用错误;
- 回执恢复成功或失败。
如果只统计 HTTP 200 数量,无法发现“重复调用但返回同一个结果”的情况。
17. 数据保留、键过期和版本迁移
幂等记录不能无限期保留,但删除记录会改变语义。
例如:
第 1 天:effect_key = k,支付成功
第 30 天:删除了 k 的记录
第 31 天:旧消息重投,系统认为 k 是新请求
因此,键的保留期必须覆盖:
上游最大重试时间
消息队列最大保留时间
人工重放时间窗口
对账和退款关联时间
如果业务要求长期防重,不能只依赖短期幂等表,还要在业务表中保留稳定的业务唯一键,例如:
payment_intent_id
invoice_id
shipment_id
notification_id
17.1 操作版本必须进入效果键
工具逻辑变化时:
v1:支付金额以分为单位
v2:支付金额需要经过税费计算
如果仍使用相同键但参数解释发生变化,旧回执可能被错误复用。因此应明确:
operation_version = v1
effect_key = hash(..., operation_version)
版本迁移时不能简单把旧记录删除。应定义:
- 旧版本回执是否仍可重放;
- 新版本是否允许读取旧版本结果;
- 参数摘要如何兼容;
- 旧状态如何迁移;
- 外部供应商对象如何关联。
18. 哪些工具天然适合幂等,哪些工具只能降低风险
以下操作通常容易实现幂等:
set_status(object_id, target_status)
upsert_profile(user_id, full_document)
put_object(bucket, object_key, content_hash)
create_or_get(resource_key)
它们的共同点是有稳定目标,重复提交会收敛到同一个状态。
以下操作天然不幂等:
increment_balance(+100)
append_message(text)
send_email(...)
charge_card(...)
delete_next_item()
它们必须改造成带业务唯一键的形式。例如把:
increment_balance(+100)
改成:
apply_ledger_entry(
account_id,
entry_id,
amount=100
)
余额可以根据唯一的 entry_id 计算:
CREATE UNIQUE INDEX uniq_ledger_entry
ON ledger_entries(account_id, entry_id);
“增加一次”不是稳定操作,“应用编号为 entry_id 的账务事实”才是可去重的操作。
19. 幂等与补偿不是同一件事
当外部副作用无法做到幂等时,系统可能使用补偿:
扣款成功
创建订单失败
-> 发起退款
但退款不是回滚,而是新的业务副作用。它可能再次超时、失败或进入人工审核。因此补偿动作也必须有自己的效果键:
original_effect_key = payment:charge:req_1001
compensation_key = payment:refund:req_1001:order_creation_failed
不能把“补偿成功”当成“原操作从未发生”。审计上应保留完整事实:
charge succeeded
order creation failed
refund initiated
refund succeeded
如果只覆盖最终状态,后续对账无法解释资金流。
20. 一个可执行的实现边界
在工程上,可以把工具执行器拆成五个明确层次:
1. Decode
严格解析工具名和参数
2. Authorize
校验 Agent、用户、租户和资源权限
3. Idempotency
计算 effect_key,校验参数摘要,读取或创建效果记录
4. Execute
在租约保护下调用外部系统
5. Receipt
持久化状态和回执,并返回稳定结果
对应的数据流如下:
flowchart LR
A[Agent Tool Call] --> B[严格解码]
B --> C[授权检查]
C --> D[计算 effect_key]
D --> E{效果记录}
E -->|SUCCEEDED| F[重放原回执]
E -->|UNKNOWN| G[查询外部系统]
E -->|ACCEPTED/RUNNING| H[检查租约]
E -->|不存在| I[原子创建记录]
I --> J[执行外部副作用]
J --> K[持久化回执]
G --> K
K --> L[返回工具结果]
这里的顺序不能随意调换。
如果先执行再授权,可能产生未授权副作用;如果先执行再写去重记录,并发时会重复;如果遇到 UNKNOWN 直接重试,可能重复扣款;如果成功后不持久化回执,恢复时无法安全重放。
21. 最小验收条件
一个 Agent 工具要达到可接受的幂等基线,至少应能回答以下问题:
关于键
- 同一个业务请求重试时,键是否保持不变?
- 多 Agent handoff 后,逻辑步骤是否仍使用同一个效果键?
- 同一个键被不同参数复用时,是否明确报冲突?
关于执行
- 两个 Worker 并发处理同一效果键时,是否只有一个获得执行权?
- 租约过期后,旧 Worker 是否可能继续提交?
- 外部调用超时后,系统是否进入
UNKNOWN,而不是直接FAILED?
关于回执
- 成功回执是否持久化?
- 重复请求是否返回原始业务结果?
- 回执是否绑定工具名、参数摘要和操作版本?
关于恢复
- Checkpoint 缺少工具结果时,是否仍会检查效果记录?
SUCCEEDED是否只重放回执而不重做副作用?UNKNOWN是否有查询、延迟重试或人工处理路径?
关于审计
- 能否从请求键找到 Agent Run、工具调用和供应商对象?
- 能否区分真实执行与回执重放?
- 能否解释一次扣款、退款或通知的完整因果链?
结语
Agent 幂等的核心不是给请求加一个随机字符串,而是建立一条稳定的业务因果链:
业务请求
-> 逻辑步骤
-> 效果键
-> 持久化执行状态
-> 外部副作用
-> 回执
-> 恢复或重放
可靠执行的关键规则可以压缩为四条:
- 请求键标识业务意图,不能随着重试变化。
- 效果键标识具体副作用,不能由模型随意生成。
- 未知状态必须与失败状态分离,超时后先查询再重试。
- 重放应优先返回已持久化回执,而不是再次提交外部副作用。
当 Agent 被视为一个可能重复运行的分布式状态机,而不是一次性的模型调用,幂等、回执、Checkpoint 和重放才能形成一个闭环。
系列导航与关联阅读
- 系列入口:Agent 工程完整路线:从运行循环、记忆与协议到安全、评测和生产交付
- 上一篇:Agent Checkpoint 与恢复:快照、增量、版本迁移和副作用重放
- 下一篇:Agent 重试、超时与取消:责任层、退避、部分输出和资源清理
- 延伸:Agent 工具执行器:严格解码、授权、超时、幂等和错误信封
官方资料
本文依据 Agent、模型、协议与框架官方资料重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论