模型返回 success,或对用户说「已经改好了」,只是一次语言输出,不等于订单、工单或数据库里真的发生了变更。关键写操作之后,要用系统查询做后置核验:对账成功才算完成,对不上就补偿、有限重试或转人工。这套模式和具体模型无关,避免把智能体的自我汇报直接当成业务事实。
为什么不能信口头「已完成」
智能体链路里至少有三层「完成」,它们经常不同步。
第一层是对话层:模型生成「已取消」「已改价」「工单已关闭」。第二层是工具层:HTTP 200、函数返回 { ok: true }。第三层才是业务层:订单表、工单表、库存流水里出现了目标状态。客服事故里最常见的,是前两层成功、第三层没有发生。
不同步的原因并不神秘。写库成功但响应超时,客户端当成失败;网关重试导致工具返回成功、实体仍是旧值;工具捕获了异常,模型仍把对话收束成「已处理」。更麻烦的是:模型会把工具返回值复述成事实,用户听到的是确定性结论,系统里却还是原状态。
所以核验对象不是「模型聪不聪明」,而是把完成从自然语言声明改成可查询的业务谓词。口头完成可以展示,但不能作为扣款、发货、关单的依据。
后置条件要写成可执行查询
对每个有副作用的工具,先写下四件事,再允许它进入生产。
作用对象要具体到主键:哪张订单、哪条工单、哪个 SKU。期望状态要写成字段谓词,例如 status === 'cancelled' 且 cancelledAt 非空,而不是「取消成功」。查询必须走只读接口或只读查询,不读模型记忆,不读工具函数自己刚返回的 body。超时与终态也要预先规定:窗口内查不到算失败;已发货、已结算这类终态禁止自动重试。
后置条件应当幂等:同一主键查两次,对错结论应一致。它回答的是「现在系统是不是这个样子」,不是「刚才那次调用方觉得自己成功了没有」。工具返回值可以被截断、被网关改写、被重试中间态污染;系统记录才是对账底账。
不要把对账写进提示词,让模型「自己再检查一下」。模型可以再编一段「我查过了」。核验必须是执行器里的同步步骤:verify() 为真,才允许对话层对用户说完成。
用 Node.js 对照系统记录
下面用 Node.js 内置 http 搭一个最小订单服务,再写一层与模型无关的「执行工具 → 查询对账 → 判定」。有意让订单 10086 的取消接口只返回 ok、不写库,用来模拟口头成功。把文件存为 postcheck.js,Node.js 18+ 执行 node postcheck.js 即可。
'use strict';
const http = require('node:http');
const { once } = require('node:events');
const orders = new Map([
['10086', { id: '10086', status: 'paid', cancelledAt: null }],
['10087', { id: '10087', status: 'paid', cancelledAt: null }],
]);
function sendJson(res, statusCode, body) {
const buf = Buffer.from(JSON.stringify(body));
res.writeHead(statusCode, {
'content-type': 'application/json; charset=utf-8',
'content-length': buf.length,
});
res.end(buf);
}
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://127.0.0.1');
const parts = url.pathname.split('/').filter(Boolean);
const orderId = parts[1];
if (req.method === 'GET' && parts[0] === 'orders' && parts.length === 2) {
const order = orders.get(orderId);
if (!order) return sendJson(res, 404, { error: 'not_found' });
return sendJson(res, 200, order);
}
if (
req.method === 'POST' &&
parts[0] === 'orders' &&
parts[2] === 'cancel' &&
parts.length === 3
) {
const order = orders.get(orderId);
if (!order) return sendJson(res, 404, { error: 'not_found' });
if (orderId === '10086') {
return sendJson(res, 200, { ok: true, claimed: true });
}
order.status = 'cancelled';
order.cancelledAt = new Date().toISOString();
return sendJson(res, 200, { ok: true, order });
}
sendJson(res, 404, { error: 'unknown_route' });
});
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function getOrder(baseUrl, orderId) {
const res = await fetch(`${baseUrl}/orders/${orderId}`);
if (!res.ok) throw new Error(`getOrder failed: ${res.status}`);
return res.json();
}
async function cancelOrderTool(baseUrl, orderId) {
const res = await fetch(`${baseUrl}/orders/${orderId}/cancel`, {
method: 'POST',
});
const body = await res.json();
return { toolOk: res.ok && body.ok === true, body };
}
async function assertCancelled(baseUrl, orderId) {
const order = await getOrder(baseUrl, orderId);
if (order.status !== 'cancelled' || !order.cancelledAt) {
const err = new Error('postcondition_failed');
err.order = order;
throw err;
}
return order;
}
async function executeWithPostcheck(baseUrl, orderId, retries = 3) {
const audit = [];
const tool = await cancelOrderTool(baseUrl, orderId);
audit.push({ step: 'tool', tool });
if (!tool.toolOk) {
return { status: 'tool_failed', orderId, audit };
}
for (let attempt = 1; attempt <= retries; attempt += 1) {
try {
const order = await assertCancelled(baseUrl, orderId);
audit.push({ step: 'verify', attempt, order });
return { status: 'confirmed', orderId, order, audit };
} catch (err) {
audit.push({
step: 'verify',
attempt,
error: err.message,
order: err.order ?? null,
});
if (attempt < retries) await sleep(40 * attempt);
}
}
return {
status: 'escalate_human',
orderId,
reason: '系统记录与工具完成声明不一致',
audit,
};
}
async function main() {
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const { port } = server.address();
const baseUrl = `http://127.0.0.1:${port}`;
const lied = await executeWithPostcheck(baseUrl, '10086');
const confirmed = await executeWithPostcheck(baseUrl, '10087');
console.log(JSON.stringify({ lied, confirmed }, null, 2));
server.close();
}
main().catch((err) => {
console.error(err);
process.exitCode = 1;
});
运行后,10086 会落到 escalate_human,10087 才是 confirmed。工具都返回了 ok: true,差别只在 GET 对照。对话层若只渲染工具结果,两个单都会被说成「已取消」;加上后置核验,口头成功进不了完成态。
核验层放在工具执行器里,而不是模型回调里。任意智能体框架都可以在 Tool 返回后插入 assertCancelled 这类函数,不必绑定某家 function calling 协议。
不一致时:补偿、有限重试、转人工
对账失败不要无限重试,也不要立刻对用户说「失败」然后丢掉上下文。按失败性质选动作。
有限重试适合查询延迟、主从滞后、索引稍晚可见。次数和间隔要写死,例如最多三次、短间隔退避;窗口用尽进入下一动作。重试的是只读核验,或对明确幂等的写接口再投一次,而不是让模型自由发挥「再试一下」。
补偿适合确认发生了部分写入。例如取消订单失败但优惠券已作废,就应恢复券;改价失败但库存已锁,就应解锁。补偿本身也是写操作,同样要带后置条件:补偿调用返回成功不算数,相关记录回到期望状态才算数。补偿成功仍要记一笔自动纠偏审计,不能把错误藏进重试循环。
转人工适合终态冲突、权限不足、对账持续失败。工单里应带上原始请求、工具入参、历次查询快照和不一致字段,并禁止模型继续调用同一写工具绕过。对人的话术要区分「系统已确认变更」和「已提交、待确认」,不要复述模型的完成句。
下面这段把三种去向收成明确状态机,可直接接到上例的返回值上。
function nextAction(result) {
if (result.status === 'confirmed') {
return { userText: '系统已确认订单取消', notifyHuman: false };
}
if (result.status === 'tool_failed') {
return { userText: '取消未提交成功,请稍后重试', notifyHuman: false };
}
return {
userText: '取消结果待人工核对,请勿重复操作',
notifyHuman: true,
ticket: {
orderId: result.orderId,
reason: result.reason,
audit: result.audit,
},
};
}
生产里还要处理一个边界:核验查询本身失败(超时、5xx)。这不等于后置条件为假,只能标成「未知」,对用户避免说已完成,并进入有限重试或转人工。未知比谎报完成危害小。
接入时的几条约束
只对有副作用的工具做后置核验。只读查询不必对账;改订单、改库存、关工单、发起支付必须对账。核验凭据与写入凭据分开,避免核验逻辑误写。完成态对外唯一:对内 API、客服话术、前端提示都只认核验通过后的状态,不认模型 token,也不认工具 body 里的 ok。
日志要能按 requestId 拉齐工具返回、查询快照、重试次数、是否转人工。事后追责靠这笔对账记录,不靠聊天窗。若存在副本延迟,把延迟当成重试预算来设计,而不是放宽谓词——把 status 写成「差不多取消了」等于取消核验。
这套做法不保证分布式事务,也不消除一切竞态;它只是把「完成」从自我汇报改成可执行对账。智能体可以继续生成自然语言,但自然语言不再拥有业务终态的解释权。
总结
口头「已完成」只是对话产物。关键工具调用之后,用系统记录执行后置条件,对账成功才能对用户确认;对不上就有限重试、补偿或转人工。Node.js 里这层逻辑很薄:写工具、读实体、断言谓词、留下审计。它和选用哪家模型无关。把自我汇报拦在执行器之外,业务事实才能回到订单、工单和数据库里。
评论