契约测试能守住预置样例,却很难覆盖生产里的超长上下文、工具轨迹和异常输入。与其把新模型直接切到主路径,不如把真实请求异步复制到候选模型,对齐版本指纹后对比解析成功率、工具调用序列和延迟。读完可以落地一套不改变用户路径的影子管道,用阈值决定晋升、限流或回滚。
契约测试过了,并不等于能切流
契约测试适合锁住回归:给定一组 messages、tools 和采样参数,断言输出能被 schema 解析,该出现的 function 名出现在 tool_calls 里。它回答的是「你写过的例子上结构没崩」,不是「生产分布上还能接进现有编排」。
线上请求很少长得像夹具。检索片段会把上下文顶到接近窗口上限;工具列表随功能开关增减;用户消息里常见半截 JSON、空的 tool result、重复的 function 名。这些输入在预置样例里要么被洗干净,要么根本没有。候选模型在这种分布上的典型故障,也通常不是文风差一点,而是解析失败、漏调或多调工具、尾延迟把网关超时打穿。
直接切到主路径,等于用真实用户做赌博。影子流量的意义很窄:主路径照旧,把同一份请求异步复制到候选模型,用可机器判定的结构差和延迟差做决策,而不是再调一个 LLM 打「像不像」分。语义相似度和系统能否继续跑是两件事。
影子管道只复制请求,不碰用户路径
这条管道只做三件事:主路径打稳定模型并返回;按采样率把请求体复制给候选模型;候选结果只进对比和指标,不回写响应。用户路径因此为零改动。影子侧的超时、断连、限流必须全部吞掉,不能 await 在主请求上,也不能因为对比逻辑抛错而影响状态码。
复制时对齐的是「模型配置」,不是「两次生成必须字节相等」。把模型名、系统提示词版本、工具 schema 版本、temperature 收成一条指纹。指纹不一致时,差异可能来自提示词改写,这类样本应记为未对齐,而不是候选更差。工具参数也不建议做字符串全等:name 序列能接进编排是硬条件,arguments 用 schema 校验合法性即可,模型对检索词的表述本来就会漂。
延迟同样克制地看。先看候选是否越过你已经在网关里写死的超时,再看它相对稳定模型的差值。新模型更慢但仍在 SLO 内,可以限流观察,不必立刻判死刑。
用 Node.js 接一条可运行的影子管道
下面这份脚本用 Node.js 18+ 内置 http、crypto 和 fetch,本地起两个兼容 OpenAI POST /v1/chat/completions 形态的假上游,再起一个网关:主路径打稳定版,响应写出之后用 setImmediate 打候选版。把 STABLE_URL / CANDIDATE_URL 换成预发的真实地址即可接入,不必改用户路径。
import http from 'node:http';
import crypto from 'node:crypto';
const STABLE_URL = process.env.STABLE_URL || 'http://127.0.0.1:4010/v1/chat/completions';
const CANDIDATE_URL = process.env.CANDIDATE_URL || 'http://127.0.0.1:4011/v1/chat/completions';
const SHADOW_SAMPLE_RATE = Number(process.env.SHADOW_SAMPLE_RATE ?? 1);
const SHADOW_TIMEOUT_MS = Number(process.env.SHADOW_TIMEOUT_MS ?? 2500);
const WINDOW = 50;
function fingerprint(meta) {
return crypto.createHash('sha256').update(JSON.stringify(meta)).digest('hex').slice(0, 16);
}
function toolSeq(message) {
const calls = message?.tool_calls;
if (!Array.isArray(calls)) return [];
return calls.map((c) => c.function?.name).filter(Boolean);
}
function parseOk(message) {
if (!message) return false;
if (Array.isArray(message.tool_calls) && message.tool_calls.length > 0) {
return message.tool_calls.every(
(c) => typeof c.function?.name === 'string' && typeof c.function?.arguments === 'string'
);
}
if (typeof message.content !== 'string' || message.content.length === 0) return false;
try {
JSON.parse(message.content);
return true;
} catch {
return false;
}
}
async function callChat(url, body, timeoutMs) {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);
const t0 = Date.now();
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
signal: ac.signal,
});
const json = await res.json();
return {
httpOk: res.ok,
latencyMs: Date.now() - t0,
message: json?.choices?.[0]?.message ?? null,
};
} catch {
return { httpOk: false, latencyMs: Date.now() - t0, message: null };
} finally {
clearTimeout(timer);
}
}
const windows = new Map();
const serving = { mode: 'stable', shadowRate: SHADOW_SAMPLE_RATE };
function bucket(fp) {
if (!windows.has(fp)) windows.set(fp, { samples: [] });
return windows.get(fp);
}
function record(fp, stable, shadow) {
const sOk = stable.httpOk && parseOk(stable.message);
const cOk = shadow.httpOk && parseOk(shadow.message);
const seqMismatch =
sOk && cOk && toolSeq(stable.message).join('>') !== toolSeq(shadow.message).join('>');
const b = bucket(fp);
b.samples.push({ parseGap: Boolean(sOk && !cOk), seqMismatch, latencyMs: shadow.latencyMs });
if (b.samples.length > WINDOW) b.samples.shift();
}
function decide(fp) {
const samples = bucket(fp).samples;
if (samples.length < 20) return { action: 'observe', reason: '样本不足', n: samples.length };
const n = samples.length;
const parseGapRate = samples.filter((s) => s.parseGap).length / n;
const seqMismatchRate = samples.filter((s) => s.seqMismatch).length / n;
const sorted = samples.map((s) => s.latencyMs).sort((a, b) => a - b);
const p95 = sorted[Math.min(n - 1, Math.floor(n * 0.95))];
// 阈值按故障预算自行校准;这里只是可运行的默认值,不是行业常数
if (parseGapRate > 0.03 || seqMismatchRate > 0.08) {
return { action: 'rollback', n, parseGapRate, seqMismatchRate, p95 };
}
if (p95 > 2000) return { action: 'throttle', n, parseGapRate, seqMismatchRate, p95 };
return { action: 'promote-ready', n, parseGapRate, seqMismatchRate, p95 };
}
function applyDecision(d) {
if (d.action === 'rollback') {
serving.mode = 'stable';
serving.shadowRate = 0;
} else if (d.action === 'throttle') {
serving.shadowRate = Math.min(serving.shadowRate, 0.05);
}
}
function mockModel(port, handler) {
http
.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify(handler(body)));
});
})
.listen(port);
}
mockModel(4010, () => ({
choices: [
{
message: {
role: 'assistant',
tool_calls: [
{
id: 'call_stable',
type: 'function',
function: { name: 'search_docs', arguments: '{"q":"shadow"}' },
},
],
},
},
],
}));
mockModel(4011, (body) => {
const long = JSON.stringify(body.messages || []).length > 8000;
const message = long
? { role: 'assistant', content: 'not-json' }
: {
role: 'assistant',
tool_calls: [
{
id: 'call_cand',
type: 'function',
function: { name: 'search_docs', arguments: '{"q":"shadow"}' },
},
],
};
return { choices: [{ message }] };
});
http
.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', async () => {
const reqBody = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
const fp = fingerprint({
model: reqBody.model || 'candidate-x',
systemPromptVersion: reqBody.systemPromptVersion || 'sp-3',
toolsVersion: reqBody.toolsVersion || 'tools-7',
temperature: reqBody.temperature ?? 0,
});
const chatBody = {
model: reqBody.model || 'stable',
messages: reqBody.messages || [],
tools: reqBody.tools,
temperature: reqBody.temperature ?? 0,
};
const stable = await callChat(STABLE_URL, chatBody, 2500);
res.writeHead(stable.httpOk ? 200 : 502, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
fingerprint: fp,
serving: serving.mode,
message: stable.message,
latencyMs: stable.latencyMs,
})
);
if (Math.random() > serving.shadowRate) return;
setImmediate(async () => {
try {
const shadow = await callChat(
CANDIDATE_URL,
{ ...chatBody, model: 'candidate' },
SHADOW_TIMEOUT_MS
);
record(fp, stable, shadow);
const d = decide(fp);
applyDecision(d);
console.log(JSON.stringify({ fingerprint: fp, serving, ...d }));
} catch {
// 影子失败不影响用户
}
});
});
})
.listen(3000, () => {
console.log('gateway http://127.0.0.1:3000');
});
保存为 shadow-pipeline.mjs 后执行 node shadow-pipeline.mjs。另开终端打短请求时,候选与稳定的工具序列一致,决策会停在 observe 或 promote-ready;把 messages 撑大之后,候选会返回无法解析的文本,滑动窗口里的 parseGapRate 上升,日志里会出现 rollback,并把影子采样关掉。
curl -s http://127.0.0.1:3000 \
-H 'content-type: application/json' \
-d '{"messages":[{"role":"user","content":"hi"}],"systemPromptVersion":"sp-3","toolsVersion":"tools-7"}'
接入真实上游时,请求体保持 Chat Completions 字段即可,指纹仍从你自己的提示词版本和工具版本注入,不要指望模型厂商在响应里带回这些信息。
比什么:解析、工具序列、延迟
对比必须能在进程里用确定性规则算完。解析成功率看的是主路径能用的结构,候选是否仍能用:有 tool_calls 时校验 name 与 arguments 字符串存在;要求 JSON 正文时只做 JSON.parse,不要在这里引入第二个模型。工具序列把 function.name 收成一条链,稳定侧成功而候选侧多一步、少一步或顺序颠倒,都记为不匹配。延迟只记录影子调用耗时,超时和 abort 记为失败样本,避免把「没回来」美化成「较慢」。
滑动窗口比全量累计更接近值班时的判断:模型问题往往是某一类超长上下文或某一种工具组合突然变差。窗口太短,分位数会抖;上面用 50 次、满 20 次才决策,只是为了让脚本能跑,线上应按 QPS 和故障预算改。指纹要分桶,混在一起会把「旧提示词 + 新模型」和「新提示词 + 新模型」叠成一笔糊涂账。
不要把文本 diff 或 embedding 距离当成晋升条件。它们对客服话术也许有参考价值,对工具型接口几乎不预测故障。若业务确实关心措辞,把它留在离线抽检,不要塞进自动回滚。
阈值只驱动三种动作
影子阶段的默认动作是继续观察:用户仍走稳定模型,候选只吃复制流量。窗口内解析缺口或工具序列不匹配越过阈值,应回滚——对影子阶段意味着禁止晋升并把采样打到 0;若你已经把一小部分真实流量切到候选,则把 serving.mode 拨回 stable。这是自动回滚该覆盖的范围:撤销一次未完成的升级,而不是在指标抖动时反复横跳。
尾延迟越过网关预算时,更稳妥的是限流而不是立刻定性。候选可能只是冷启动或配额被打满,把影子采样降到很低,既能继续收集信号,又避免把预发打挂。只有解析、序列、延迟同时落在预算内,才标记 promote-ready。晋升仍建议先切很小的真实比例,用同一套窗口继续跑;不要从影子直接跳到 100%。
阈值必须来自你们已经在承受的故障预算,而不是从别处抄一个百分比。解析失败会直接打进 5xx 或降级文案,通常应比「工具多调了一次」更敏感。序列不匹配的容忍度取决于编排能不能幂等重试。把这些写成配置,按指纹分开,比做全局大盘平均值有用。
落地时还有两条容易漏的工程约束。影子日志按指纹聚合即可,不要把完整 messages 默认落盘;复制请求去掉鉴权无关、但会改变模型行为的头,保留 messages 与 tools。主路径超时和影子超时分开配:影子可以更短,宁可少一个样本,也不能反压事件循环。
总结
模型升级的风险在生产分布,不在夹具是否全绿。契约测试继续守预置样例;真实请求用异步影子复制,对齐版本指纹后只比较解析成功率、工具调用序列和延迟。Node.js 里这套管道不需要新协议:主路径先返回,setImmediate 再打候选,失败吞掉,用滑动窗口决定观察、限流、回滚或准备晋升。用户路径保持不变,升级就不再是一次线上赌博。
评论