模型返回 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 里这层逻辑很薄:写工具、读实体、断言谓词、留下审计。它和选用哪家模型无关。把自我汇报拦在执行器之外,业务事实才能回到订单、工单和数据库里。