Agent 工程体系 · 第 71/98 篇。内容以 2026 年 9 月可验证的公开规范和稳定接口为基线;框架版本敏感能力会明确标注,不把实验行为写成通用保证。

Agent 幂等设计:请求键、工具调用、写入去重、回执和重放

Agent 并不是一次函数调用,而是一个会反复推理、调用工具、接收工具结果、更新状态并继续执行的运行时。模型可能因为超时、网络断开、进程重启、Checkpoint 恢复或上游重试而再次生成相同工具调用;多 Agent 编排还可能让不同执行器在同一个业务任务上并发工作。

因此,Agent 的可靠执行不能建立在“每个步骤只会执行一次”这个假设上。更准确的基础假设是:

业务请求、Agent 运行、工具调用和外部写入,都可能以至少一次语义到达。

幂等设计的目标,不是让系统永远不重复发送请求,而是让重复请求不会重复产生不可接受的业务副作用,并且能够通过回执判断“之前到底发生了什么”。

OpenAI 将 Agent 描述为能够规划、调用工具、协作完成多步工作的应用;其 Agents SDK 也把运行循环、工具、编排、状态和可恢复结果作为不同的运行时概念。Anthropic 对 Agent 系统的说明同样强调了工作流、工具调用和多步自主执行之间的关系。幂等设计需要覆盖的,正是这些步骤之间的故障边界,而不只是某一个 HTTP 接口。(developers.openai.com)


1. 先区分四个容易混淆的对象

“重复”并不是单一概念。至少要区分:

  1. 请求重复:同一个用户请求或上游任务被提交多次。
  2. 运行重复:同一个请求因为恢复或重试,启动了多个 Agent Run。
  3. 工具调用重复:同一个工具调用被执行器提交多次。
  4. 业务写入重复:工具调用已经产生副作用,但调用方没有收到结果,于是再次写入。

如果不区分这四层,通常会出现一种错误实现:

收到请求
  -> 生成一个 request_id
  -> 每次重试都重新生成 request_id
  -> 认为所有请求都是新请求

这实际上破坏了幂等性。请求键必须跨越重试、进程重启和恢复流程保持不变,否则系统无法判断两个请求是否代表同一个业务意图。

1.1 幂等的形式化定义

设某个操作为:

F(x,S)(S,y)F(x, S) \rightarrow (S', y)

其中:

  • xx 是输入;
  • SS 是操作前状态;
  • SS' 是操作后状态;
  • yy 是返回结果。

如果操作可重复执行,则要求:

F(x,F(x,S).S).S=F(x,S).SF(x, F(x, S).S).S = F(x, S).S

也就是第一次执行后,再执行同一个操作,最终状态不再变化。

但这个定义还不够,因为 Agent 工具往往有外部副作用。例如发送邮件:

第一次调用:邮件已发送,但响应丢失
第二次调用:又发送一封邮件

如果第二次调用返回“发送成功”,数据库状态可能看起来正常,但用户实际收到了两封邮件。因此,Agent 幂等必须同时约束:

  1. 状态幂等:重复调用不重复修改业务状态;
  2. 副作用幂等:重复调用不重复发送、扣款、下单或删除;
  3. 结果幂等:重复调用返回同一业务结果,或返回明确的已完成回执;
  4. 审计幂等:重复调用不会制造无法解释的多条业务事实。

2. “Exactly-once”通常只是局部实现,不是端到端事实

分布式系统经常使用以下三种投递语义:

至少一次:At-least-once

发送方在没有收到确认时会重试:

发送请求
  -> 等待超时
  -> 再次发送请求

优点是尽量不丢请求,缺点是可能重复。

至多一次:At-most-once

发送方只发送一次,失败后不重试。

优点是不重复,缺点是可能丢失执行。

恰好一次:Exactly-once

从业务视角看,副作用只发生一次。

真正的端到端恰好一次非常难。即使 Agent 执行器只向支付服务发送一次请求,支付服务到银行、银行到清算系统之间仍可能存在重试。因此工程上更常见的做法是:

传输层采用至少一次,副作用层使用幂等键、唯一约束、状态机和回执把重复执行折叠成一次业务效果。

这可以写成:

At-least-once delivery+Idempotent effect=Effectively-once business result\text{At-least-once delivery} + \text{Idempotent effect} = \text{Effectively-once business result}

这里的“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. 请求键的正确生成规则

请求键需要满足四个条件:

稳定

同一个业务意图在重试和恢复期间保持不变。

唯一

不同业务意图不能共享同一个键。

有作用域

不同租户、不同工具或不同业务操作之间最好隔离作用域。

可验证

服务端不能只接受客户端传来的键,还要绑定请求摘要,防止同一个键被用于不同参数。

可以定义:

K=scoperequest_idK = \text{scope} \parallel \text{request\_id}

H=Hash(Canonicalize(arguments))H = \text{Hash}(\text{Canonicalize}(arguments))

服务端保存:

(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: 重新获得租约

状态机的关键约束是:

UNKNOWN⇏FAILED\text{UNKNOWN} \not\Rightarrow \text{FAILED}

因为“没有收到响应”和“外部操作失败”不是同一个事实。


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 应先写为 ACCEPTEDRUNNING,外部调用成功后再写入回执。


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
结果:重复扣款

正确的重放规则是:

Replay(tool call)={return stored receipt,if effect already succeededquery external state,if effect is unknownexecute with same effect key,if not started\text{Replay(tool call)} = \begin{cases} \text{return stored receipt}, & \text{if effect already succeeded}\\ \text{query external state}, & \text{if effect is unknown}\\ \text{execute with same effect key}, & \text{if not started} \end{cases}

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 幂等的核心不是给请求加一个随机字符串,而是建立一条稳定的业务因果链:

业务请求
  -> 逻辑步骤
  -> 效果键
  -> 持久化执行状态
  -> 外部副作用
  -> 回执
  -> 恢复或重放

可靠执行的关键规则可以压缩为四条:

  1. 请求键标识业务意图,不能随着重试变化。
  2. 效果键标识具体副作用,不能由模型随意生成。
  3. 未知状态必须与失败状态分离,超时后先查询再重试。
  4. 重放应优先返回已持久化回执,而不是再次提交外部副作用。

当 Agent 被视为一个可能重复运行的分布式状态机,而不是一次性的模型调用,幂等、回执、Checkpoint 和重放才能形成一个闭环。


系列导航与关联阅读

官方资料

本文依据 Agent、模型、协议与框架官方资料重新梳理;正文、示例与生产清单由 WR BLOG 编写。