智能体可以在超时后重试,但发邮件、创建工单和扣款等副作用不能被无条件重放。本文用 Node.js 与 SQLite 实现一套可运行的幂等工具调用协议,并通过事务发件箱、提供方结果查询和异常对账处理网络抖动与进程重启。

超时为什么会变成重复副作用

假设智能体调用支付工具,服务端已经完成扣款,但响应在返回途中丢失。智能体看到的是“超时”,无法判断支付究竟失败,还是成功但响应丢失。如果它直接重试,就可能产生第二笔支付。

同类问题也存在于其他工具中:邮件服务已经接收邮件,调用方却没有收到 message ID;工单系统已经创建工单,连接却在响应前断开。根因不是“重试次数太多”,而是调用方把未知结果误当成失败。

一次工具调用至少可能处于以下状态:

本地观察远端事实能否直接重放
明确失败且未执行未产生副作用可以
返回成功已产生副作用不需要
超时或进程退出未知不可以直接判断

所谓“恰好一次”通常不能仅靠本地锁实现。更可行的目标是:同一个业务意图拥有稳定的幂等键;本地状态与待执行任务原子写入;重试时复用该键;结果未知时先向提供方查询,再决定补偿或重试。

幂等键应描述业务意图,而不是某一次 HTTP 请求。例如支付可以使用 tenant:order-123:pay-v1,创建工单可以使用 tenant:alert-456:ticket-v1。智能体重试时必须沿用原键,不能每次生成新的 UUID。

协议设计:请求、发件箱与结果

工具入口接收 Idempotency-Key,并为请求正文计算指纹。相同键和相同指纹返回已有记录;相同键却携带不同参数时返回冲突,避免调用方误复用键。

数据库事务同时写入调用记录和 outbox。这样不会出现“调用已登记但任务丢失”,也不会出现“任务已投递但调用记录不存在”。后台 worker 再消费 outbox,并把同一个幂等键传给邮件、工单或支付提供方。

核心状态可以简化为:

状态含义后续动作
pending已受理,结果尚未确认worker 执行或对账
succeeded已取得可确认结果返回保存的结果
failed明确且不可重试的失败人工处理或新建业务意图

调用接口返回 202 Accepted 不代表副作用已经完成。智能体应通过结果查询接口轮询,直到得到终态,而不是因首次响应超时就换一个键重新调用。

可运行的 Node.js 实现

下面的示例使用真实存在的 better-sqlite3 API。为便于本地运行,provider_receipts 表模拟支持幂等键和结果查询的外部提供方账本;接入真实邮件、工单或支付平台时,应将 providerExecuteproviderQuery 替换为对应 SDK 或 HTTP API,同时保留协议结构。

准备项目:

mkdir agent-idempotency && cd agent-idempotency
npm init -y
npm install better-sqlite3

保存为 server.js

const http = require('node:http');
const crypto = require('node:crypto');
const Database = require('better-sqlite3');

const db = new Database('tools.db');
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS tool_calls (
  idem_key TEXT PRIMARY KEY,
  kind TEXT NOT NULL,
  fingerprint TEXT NOT NULL,
  payload TEXT NOT NULL,
  status TEXT NOT NULL,
  result TEXT,
  created_at INTEGER NOT NULL,
  updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS outbox (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  call_key TEXT NOT NULL UNIQUE,
  status TEXT NOT NULL,
  attempts INTEGER NOT NULL DEFAULT 0,
  next_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS provider_receipts (
  idem_key TEXT PRIMARY KEY,
  result TEXT NOT NULL
);
`);

function canonical(value) {
  if (Array.isArray(value)) return value.map(canonical);
  if (value && typeof value === 'object') {
    return Object.fromEntries(Object.keys(value).sort().map(k => [k, canonical(value[k])]));
  }
  return value;
}

function fingerprint(kind, payload) {
  return crypto.createHash('sha256')
    .update(JSON.stringify(canonical({ kind, payload })))
    .digest('hex');
}

const createCall = db.transaction((key, kind, payload) => {
  const fp = fingerprint(kind, payload);
  const old = db.prepare('SELECT * FROM tool_calls WHERE idem_key = ?').get(key);
  if (old) {
    if (old.fingerprint !== fp) throw Object.assign(new Error('idempotency key conflict'), { code: 409 });
    return old;
  }

  const now = Date.now();
  db.prepare(`INSERT INTO tool_calls
    (idem_key, kind, fingerprint, payload, status, created_at, updated_at)
    VALUES (?, ?, ?, ?, 'pending', ?, ?)`)
    .run(key, kind, fp, JSON.stringify(payload), now, now);
  db.prepare(`INSERT INTO outbox (call_key, status, next_at)
    VALUES (?, 'ready', ?)`)
    .run(key, now);
  return db.prepare('SELECT * FROM tool_calls WHERE idem_key = ?').get(key);
});

function providerQuery(key) {
  const row = db.prepare('SELECT result FROM provider_receipts WHERE idem_key = ?').get(key);
  return row ? JSON.parse(row.result) : null;
}

function providerExecute(call) {
  const existing = providerQuery(call.idem_key);
  if (existing) return existing;

  const prefixes = { email: 'msg', ticket: 'ticket', payment: 'pay' };
  if (!prefixes[call.kind]) throw new Error('unsupported tool kind');
  const result = {
    id: `${prefixes[call.kind]}_${crypto.randomUUID()}`,
    kind: call.kind,
    acceptedAt: new Date().toISOString()
  };
  db.prepare('INSERT INTO provider_receipts (idem_key, result) VALUES (?, ?)')
    .run(call.idem_key, JSON.stringify(result));
  return result;
}

function complete(key, result) {
  db.transaction(() => {
    db.prepare(`UPDATE tool_calls SET status = 'succeeded', result = ?, updated_at = ?
      WHERE idem_key = ?`).run(JSON.stringify(result), Date.now(), key);
    db.prepare(`UPDATE outbox SET status = 'sent' WHERE call_key = ?`).run(key);
  })();
}

function work() {
  const job = db.prepare(`SELECT * FROM outbox
    WHERE status = 'ready' AND next_at <= ? ORDER BY id LIMIT 1`).get(Date.now());
  if (!job) return;

  db.prepare(`UPDATE outbox SET status = 'processing', attempts = attempts + 1
    WHERE id = ?`).run(job.id);
  try {
    const call = db.prepare('SELECT * FROM tool_calls WHERE idem_key = ?').get(job.call_key);
    const result = providerExecute(call);

    // 用 CRASH_AFTER_PROVIDER=1 启动时,模拟远端成功后本地进程退出。
    if (process.env.CRASH_AFTER_PROVIDER === '1') process.exit(1);
    complete(call.idem_key, result);
  } catch (error) {
    db.prepare(`UPDATE outbox SET status = 'ready', next_at = ? WHERE id = ?`)
      .run(Date.now() + 2000, job.id);
  }
}

function reconcile() {
  const calls = db.prepare(`SELECT * FROM tool_calls WHERE status = 'pending'`).all();
  for (const call of calls) {
    const result = providerQuery(call.idem_key);
    if (result) complete(call.idem_key, result);
    else db.prepare(`UPDATE outbox SET status = 'ready', next_at = ?
      WHERE call_key = ? AND status = 'processing'`).run(Date.now(), call.idem_key);
  }
}

function send(res, status, value) {
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
  res.end(JSON.stringify(value));
}

const server = http.createServer((req, res) => {
  const url = new URL(req.url, 'http://localhost');
  if (req.method === 'POST' && url.pathname === '/tool-calls') {
    let raw = '';
    req.on('data', chunk => { raw += chunk; });
    req.on('end', () => {
      try {
        const key = req.headers['idempotency-key'];
        if (!key || Array.isArray(key)) return send(res, 400, { error: 'missing Idempotency-Key' });
        const body = JSON.parse(raw);
        if (!body.kind || !body.payload) return send(res, 400, { error: 'kind and payload are required' });
        const call = createCall(key, body.kind, body.payload);
        send(res, call.status === 'succeeded' ? 200 : 202, {
          key: call.idem_key,
          status: call.status,
          result: call.result ? JSON.parse(call.result) : null
        });
      } catch (error) {
        send(res, error.code || 400, { error: error.message });
      }
    });
    return;
  }

  if (req.method === 'GET' && url.pathname.startsWith('/tool-calls/')) {
    const key = decodeURIComponent(url.pathname.slice('/tool-calls/'.length));
    const call = db.prepare('SELECT * FROM tool_calls WHERE idem_key = ?').get(key);
    if (!call) return send(res, 404, { error: 'not found' });
    return send(res, 200, {
      key, status: call.status,
      result: call.result ? JSON.parse(call.result) : null
    });
  }

  send(res, 404, { error: 'not found' });
});

setInterval(work, 200).unref();
setInterval(reconcile, 5000).unref();
server.listen(3000, () => console.log('listening on http://localhost:3000'));

启动并创建支付调用:

node server.js
curl -i -X POST http://localhost:3000/tool-calls \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: shop-1:order-123:pay-v1' \
  -d '{"kind":"payment","payload":{"orderId":"123","amount":9900,"currency":"CNY"}}'

curl http://localhost:3000/tool-calls/shop-1%3Aorder-123%3Apay-v1

重复执行 POST 会返回同一条调用记录。若保持键不变却修改金额,接口会返回 409,而不是悄悄执行另一笔支付。

崩溃恢复与生产对账

可以先删除 tools.db,再用 CRASH_AFTER_PROVIDER=1 node server.js 启动。提交调用后,模拟提供方会记录结果,进程则在更新本地成功状态前退出。随后不带该环境变量重启,定时对账会通过原幂等键查到提供方凭证,并将调用修正为 succeeded,不会重新创建第二笔结果。

真实系统中,提供方能力决定了安全边界,优先级通常如下:

  1. 提供方原生支持幂等键,并能按键查询结果;
  2. 提供方支持业务单号唯一约束,可按业务单号查询;
  3. 只能查询近期列表,由对账任务按金额、收件人等字段谨慎匹配;
  4. 既不支持幂等也不能查询时,不能承诺自动安全重试,应转人工确认。

生产环境还应加入带抖动的指数退避、最大尝试次数、死信队列和监控告警。支付类调用需要保存提供方流水号并进行日终账单对账;邮件可记录 message ID;工单应保存外部 ticket ID。幂等键要包含租户边界,数据库中的敏感 payload 应加密或最小化保存,并设置符合业务追溯周期的保留时间。

事务发件箱只解决本地业务记录与投递任务的一致性,不能让不支持幂等的远端接口自动获得“恰好一次”语义。多 worker 部署时,还需使用数据库行锁、租约或原子状态更新领取任务;本文的 SQLite worker 是便于验证协议的单进程实现,不应直接当作高并发队列。

总结

智能体的重试策略必须和工具的副作用语义一起设计,而不能只在模型调用外层套一个重试循环。要点包括:

  • 用稳定的幂等键标识业务意图,并校验请求指纹;
  • 在同一事务内写入工具调用记录与 outbox;
  • 将超时视为“结果未知”,优先查询而非盲目重放;
  • 保存提供方凭证,通过结果接口让智能体获取终态;
  • 用周期对账修复网络中断、worker 异常和进程重启留下的中间状态;
  • 当远端既无幂等能力也不可查询时,明确降级到人工确认。

这套机制不能消除所有分布式故障,但能把重复副作用从不可控的偶发事故,转化为可查询、可恢复、可审计的工程流程。