LLM 的回答不只取决于用户问题,模型别名、提示词、工具 Schema、检索结果和参数都可能发生隐性漂移。本文用 Node.js 为每次调用生成版本指纹,并保存脱敏后的最小证据包,用于回归定位与配置比较。

无法复现的往往不是“随机性”

线上出现异常回答时,我们常先怀疑 temperature。但即使将 temperature 设为 0,也不能保证跨时间得到完全相同的输出:服务端实现可能更新,模型别名可能指向新版本,检索索引也可能已经重建。

一次回答至少依赖以下几层输入:

维度常见漂移建议记录
模型别名切换、供应商升级供应商、模型 ID、接口类型
提示词模板改字、变量顺序变化渲染后的消息及模板版本
工具参数增删、描述调整完整工具 Schema
检索排序变化、文档更新实际送入模型的片段与顺序
参数temperature、输出限制变化最终请求参数,而非默认配置
运行环境SDK 或接口版本变化应用版本、证据格式版本

因此,“代码版本没变”并不等于“模型看到的请求没变”。排查时需要的不是一条零散日志,而是一份能说明当时输入边界的证据包。

指纹与证据包如何划分

我会将记录分成两个哈希:

  • version.fingerprint:模型、系统提示词、工具、检索快照和参数的稳定哈希,用来判断配置是否变化。
  • caseFingerprint:脱敏后完整请求的哈希,用来标识某个具体案例。

哈希前必须对对象键名排序,否则语义相同但属性顺序不同的 JSON 会产生不同结果。证据包本身则保存以下最小信息:

{
  "schemaVersion": 1,
  "createdAt": "ISO 时间",
  "version": {
    "fingerprint": "sha256",
    "material": "参与版本计算的配置"
  },
  "caseFingerprint": "sha256",
  "request": "脱敏后的实际请求",
  "response": {
    "answer": "脱敏后的回答",
    "finishReason": "stop",
    "usage": {},
    "requestId": "供应商请求 ID"
  }
}

版本指纹不是“确定性证明”。它只能证明我们保存的输入配置相同,无法覆盖供应商未公开的服务端状态。它的价值在于快速排除可观测的配置漂移。

用 Node.js 记录一次调用

下面示例使用 Node.js 20 内置的 fetch 和 OpenAI Chat Completions API,不依赖第三方包。模型 ID由环境变量传入,避免在示例里绑定可能变化的模型别名。

新建 llm-evidence.mjs

import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";

const API = "https://api.openai.com/v1/chat/completions";
const KEY = process.env.OPENAI_API_KEY;
const MODEL = process.env.OPENAI_MODEL;

if (!KEY || !MODEL) {
  throw new Error("请设置 OPENAI_API_KEY 和 OPENAI_MODEL");
}

const SYSTEM = "你是客服助手。只根据给定资料回答;资料不足时明确说明。";
const RETRIEVAL = [
  { id: "refund-v3#1", text: "未发货订单可原路退款;已发货订单需先申请退货。" },
  { id: "refund-v3#2", text: "退款到账时间取决于支付渠道,系统不承诺固定天数。" }
];
const TOOLS = [{
  type: "function",
  function: {
    name: "lookup_order",
    description: "根据订单号查询订单状态",
    parameters: {
      type: "object",
      properties: { orderId: { type: "string" } },
      required: ["orderId"],
      additionalProperties: false
    },
    strict: true
  }
}];

function stable(value) {
  if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`;
  if (value && typeof value === "object") {
    return `{${Object.keys(value).sort().map(
      key => `${JSON.stringify(key)}:${stable(value[key])}`
    ).join(",")}}`;
  }
  return JSON.stringify(value);
}

function sha256(value) {
  return createHash("sha256").update(stable(value)).digest("hex");
}

function maskText(text) {
  return text
    .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[EMAIL]")
    .replace(/\b1[3-9]\d{9}\b/g, "[PHONE]")
    .replace(/Bearer\s+[A-Za-z0-9._-]+/gi, "Bearer [TOKEN]");
}

function redact(value) {
  if (typeof value === "string") return maskText(value);
  if (Array.isArray(value)) return value.map(redact);
  if (value && typeof value === "object") {
    return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redact(v)]));
  }
  return value;
}

async function call(request) {
  const response = await fetch(API, {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${KEY}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify(request)
  });
  const data = await response.json();
  if (!response.ok) throw new Error(JSON.stringify(data));
  return { data, requestId: response.headers.get("x-request-id") };
}

async function record(question) {
  const context = RETRIEVAL.map((d, i) => `[${i + 1}] ${d.id}\n${d.text}`).join("\n\n");
  const request = {
    model: MODEL,
    messages: [
      { role: "system", content: SYSTEM },
      { role: "system", content: `检索资料:\n${context}` },
      { role: "user", content: question }
    ],
    tools: TOOLS,
    tool_choice: "none",
    temperature: 0.2
  };

  const versionMaterial = redact({
    schemaVersion: 1,
    endpoint: API,
    model: MODEL,
    systemPrompt: SYSTEM,
    retrieval: RETRIEVAL,
    tools: TOOLS,
    parameters: { tool_choice: "none", temperature: 0.2 }
  });
  const safeRequest = redact(request);
  const result = await call(request);
  const choice = result.data.choices[0];

  const bundle = {
    schemaVersion: 1,
    createdAt: new Date().toISOString(),
    version: {
      fingerprint: sha256(versionMaterial),
      material: versionMaterial
    },
    caseFingerprint: sha256(safeRequest),
    request: safeRequest,
    response: {
      answer: maskText(choice.message.content ?? ""),
      finishReason: choice.finish_reason,
      usage: result.data.usage ?? null,
      requestId: result.requestId
    }
  };

  await mkdir("evidence", { recursive: true });
  const file = `evidence/${Date.now()}.json`;
  await writeFile(file, JSON.stringify(bundle, null, 2));
  console.log(file, bundle.version.fingerprint, bundle.response.answer);
}

async function replay(file) {
  const bundle = JSON.parse(await readFile(file, "utf8"));
  if (sha256(bundle.version.material) !== bundle.version.fingerprint) {
    throw new Error("版本材料已被修改");
  }
  if (sha256(bundle.request) !== bundle.caseFingerprint) {
    throw new Error("回放请求已被修改");
  }

  const result = await call(bundle.request);
  const current = maskText(result.data.choices[0].message.content ?? "");
  console.log(JSON.stringify({
    versionFingerprint: bundle.version.fingerprint,
    exactMatch: current === bundle.response.answer,
    previous: bundle.response.answer,
    current
  }, null, 2));
}

const [mode, ...args] = process.argv.slice(2);
if (mode === "record" && args.length) await record(args.join(" "));
else if (mode === "replay" && args[0]) await replay(args[0]);
else console.log("用法:node llm-evidence.mjs record 问题 | replay 证据文件");

执行记录与回放:

export OPENAI_API_KEY="你的密钥"
export OPENAI_MODEL="你实际使用的模型 ID"
node llm-evidence.mjs record "订单 13800138000 还没发货,能退款吗?"
node llm-evidence.mjs replay evidence/生成的文件.json

示例故意设置 tool_choice: "none",以便用单次请求演示完整流程;生产环境如果允许工具调用,应把工具返回值、后续消息和每一轮请求也纳入证据链。

如何使用回放结果

exactMatch: false 不应直接判定为故障。先比较版本指纹,再按层定位:

  1. 指纹不同:展开 version.material,比较模型、提示词、检索片段、Schema 与参数。
  2. 指纹相同但回答不同:检查模型是否使用稳定版本 ID,并考虑采样和供应商服务端变化。
  3. 仅检索内容不同:回查索引版本、过滤条件、排序规则及文档更新时间。
  4. Schema 不同:确认工具描述或必填字段是否改变了模型的决策路径。

脱敏也有边界。示例只处理邮箱、手机号和 Bearer Token,不能识别人名、地址或业务账号。更重要的是,脱敏后的回放验证的是“结构和非敏感语义”,不一定等价于原请求。若合规场景要求精确复现,应将原始证据加密保存到受限存储,并设置访问审计和保留期限,而不是写入普通日志。

比较配置变更时,也不要只比较最终文本。可进一步记录结构化断言,例如是否引用资料、是否承诺具体到账时间、是否输出工具调用。这样的业务断言通常比逐字相等更适合作为回归标准。

总结

  • 无法复现通常来自模型、提示词、工具、检索和参数的共同漂移,而不只是 temperature。
  • 对规范化后的版本材料生成 SHA-256 指纹,可以快速判断可观测配置是否变化。
  • 最小证据包应保存脱敏请求、检索快照、版本材料、回答、用量和供应商请求 ID。
  • 回放结果应结合版本差异与业务断言分析,不能把文本不一致直接等同于线上回归。
  • 脱敏证据适合日常排障;需要精确复现敏感请求时,应使用加密、隔离且可审计的存储方案。