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 不应直接判定为故障。先比较版本指纹,再按层定位:
- 指纹不同:展开
version.material,比较模型、提示词、检索片段、Schema 与参数。 - 指纹相同但回答不同:检查模型是否使用稳定版本 ID,并考虑采样和供应商服务端变化。
- 仅检索内容不同:回查索引版本、过滤条件、排序规则及文档更新时间。
- Schema 不同:确认工具描述或必填字段是否改变了模型的决策路径。
脱敏也有边界。示例只处理邮箱、手机号和 Bearer Token,不能识别人名、地址或业务账号。更重要的是,脱敏后的回放验证的是“结构和非敏感语义”,不一定等价于原请求。若合规场景要求精确复现,应将原始证据加密保存到受限存储,并设置访问审计和保留期限,而不是写入普通日志。
比较配置变更时,也不要只比较最终文本。可进一步记录结构化断言,例如是否引用资料、是否承诺具体到账时间、是否输出工具调用。这样的业务断言通常比逐字相等更适合作为回归标准。
总结
- 无法复现通常来自模型、提示词、工具、检索和参数的共同漂移,而不只是 temperature。
- 对规范化后的版本材料生成 SHA-256 指纹,可以快速判断可观测配置是否变化。
- 最小证据包应保存脱敏请求、检索快照、版本材料、回答、用量和供应商请求 ID。
- 回放结果应结合版本差异与业务断言分析,不能把文本不一致直接等同于线上回归。
- 脱敏证据适合日常排障;需要精确复现敏感请求时,应使用加密、隔离且可审计的存储方案。
评论