契约测试能守住预置样例,却很难覆盖生产里的超长上下文、工具轨迹和异常输入。与其把新模型直接切到主路径,不如把真实请求异步复制到候选模型,对齐版本指纹后对比解析成功率、工具调用序列和延迟。读完可以落地一套不改变用户路径的影子管道,用阈值决定晋升、限流或回滚。

契约测试过了,并不等于能切流

契约测试适合锁住回归:给定一组 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 再打候选,失败吞掉,用滑动窗口决定观察、限流、回滚或准备晋升。用户路径保持不变,升级就不再是一次线上赌博。