智能体可以在超时后重试,但发邮件、创建工单和扣款等副作用不能被无条件重放。本文用 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 表模拟支持幂等键和结果查询的外部提供方账本;接入真实邮件、工单或支付平台时,应将 providerExecute 和 providerQuery 替换为对应 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,不会重新创建第二笔结果。
真实系统中,提供方能力决定了安全边界,优先级通常如下:
- 提供方原生支持幂等键,并能按键查询结果;
- 提供方支持业务单号唯一约束,可按业务单号查询;
- 只能查询近期列表,由对账任务按金额、收件人等字段谨慎匹配;
- 既不支持幂等也不能查询时,不能承诺自动安全重试,应转人工确认。
生产环境还应加入带抖动的指数退避、最大尝试次数、死信队列和监控告警。支付类调用需要保存提供方流水号并进行日终账单对账;邮件可记录 message ID;工单应保存外部 ticket ID。幂等键要包含租户边界,数据库中的敏感 payload 应加密或最小化保存,并设置符合业务追溯周期的保留时间。
事务发件箱只解决本地业务记录与投递任务的一致性,不能让不支持幂等的远端接口自动获得“恰好一次”语义。多 worker 部署时,还需使用数据库行锁、租约或原子状态更新领取任务;本文的 SQLite worker 是便于验证协议的单进程实现,不应直接当作高并发队列。
总结
智能体的重试策略必须和工具的副作用语义一起设计,而不能只在模型调用外层套一个重试循环。要点包括:
- 用稳定的幂等键标识业务意图,并校验请求指纹;
- 在同一事务内写入工具调用记录与 outbox;
- 将超时视为“结果未知”,优先查询而非盲目重放;
- 保存提供方凭证,通过结果接口让智能体获取终态;
- 用周期对账修复网络中断、worker 异常和进程重启留下的中间状态;
- 当远端既无幂等能力也不可查询时,明确降级到人工确认。
这套机制不能消除所有分布式故障,但能把重复副作用从不可控的偶发事故,转化为可查询、可恢复、可审计的工程流程。
评论