接口返回 HTTP 200,只说明通道还通、对端愿意写出一段正文,并不等于首字延迟、结构化约束和关键事实仍在你能接受的区间。等用户投诉再切模型,窗口往往已经过去。下面用 Node.js 做一组廉价合成探针,把这三件事写成可告警金丝雀,并在连续失败时从路由池摘流、排空在途请求。

HTTP 200 解决不了质量窗口

网关和 Provider 适配层最容易养成一个习惯:把「可路由」定义成 status === 200 且响应体能读到。对传统 HTTP 服务这大致够用;对模型接口不够。

常见恶化并不走 5xx。流式连接建立成功,但第一个 delta.content 很晚才出现;response_format 仍是 json_object,模型却吐出残缺对象或夹杂解释性文字;固定事实题开始胡编,而错误码一切正常。用户侧的表现是「有时很慢」「偶发解析失败」「答案不对」,监控面板上却是绿的。

金丝雀要回答的不是「这个 URL 还能打通」,而是「这条路由现在还值不值得接真实流量」。它必须和线上同一套鉴权、同一模型名、同一套超时与重试预算,否则测到的是另一条路。探针失败也不该立刻全切:一次抖动和持续劣化要分开。连续失败再摘流,比单次超时更接近运维直觉。

本文只覆盖合成探针与路由保护,不替代离线评测集,也不声称某家模型更稳。阈值是你自己的 SLO,不是行业常数。

三类合成探针:延迟、可解析性、固定题

探针要便宜、可重复、失败原因可区分。建议固定三道题,而不是每次随机生成:随机题无法判断是模型变差还是题目变难。

  1. 首字延迟(TTFT):开 stream: true,从 fetch 发起到第一个非空 choices[0].delta.content 的时间。测完即可 AbortController.abort(),不必把完整回答收完。
  2. 结构化可解析性:开 OpenAI Chat Completions 已支持的 response_format: { type: "json_object" },要求返回指定字段,然后 JSON.parse 并校验键。解析失败、缺字段、类型不对,都记为质量失败,而不是「HTTP 失败」。
  3. 固定题质量:问一个答案稳定、便于比对的短事实,例如首都名,归一化后做精确匹配。这测的是「这条路由是否还在胡说」,不是知识广度。

下面是一段可运行的 ESM 示例,依赖 Node.js 18+ 内置 fetch,走官方 Chat Completions(也兼容把 OPENAI_BASE_URL 指到同协议网关)。请用环境变量提供密钥,不要把密钥写进仓库。

// canary-probes.mjs
const BASE = process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1";
const API_KEY = process.env.OPENAI_API_KEY;
const MODEL = process.env.CANARY_MODEL ?? "gpt-4o-mini";

if (!API_KEY) {
  console.error("缺少 OPENAI_API_KEY");
  process.exit(1);
}

function chatHeaders() {
  return {
    Authorization: `Bearer ${API_KEY}`,
    "Content-Type": "application/json",
  };
}

async function probeTtft({ timeoutMs = 8000, maxTtftMs = 2500 } = {}) {
  const ac = new AbortController();
  const timer = setTimeout(() => ac.abort(), timeoutMs);
  const t0 = performance.now();
  try {
    const res = await fetch(`${BASE}/chat/completions`, {
      method: "POST",
      headers: chatHeaders(),
      signal: ac.signal,
      body: JSON.stringify({
        model: MODEL,
        stream: true,
        max_tokens: 16,
        messages: [
          { role: "user", content: "只回复一个字:好" },
        ],
      }),
    });
    if (!res.ok) {
      const text = await res.text();
      return { ok: false, kind: "http", status: res.status, detail: text.slice(0, 200) };
    }
    const reader = res.body.getReader();
    const decoder = new TextDecoder();
    let buf = "";
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      buf += decoder.decode(value, { stream: true });
      const lines = buf.split("\n");
      buf = lines.pop() ?? "";
      for (const line of lines) {
        const trimmed = line.trim();
        if (!trimmed.startsWith("data:")) continue;
        const data = trimmed.slice(5).trim();
        if (data === "[DONE]") continue;
        const json = JSON.parse(data);
        const piece = json.choices?.[0]?.delta?.content;
        if (typeof piece === "string" && piece.length > 0) {
          const ttftMs = performance.now() - t0;
          ac.abort();
          return {
            ok: ttftMs <= maxTtftMs,
            kind: "ttft",
            ttftMs,
            maxTtftMs,
          };
        }
      }
    }
    return { ok: false, kind: "ttft", detail: "流结束仍无首字" };
  } catch (err) {
    const aborted = err?.name === "AbortError";
    return { ok: false, kind: aborted ? "timeout" : "network", detail: String(err) };
  } finally {
    clearTimeout(timer);
  }
}

async function probeJson({ timeoutMs = 12000 } = {}) {
  const ac = new AbortController();
  const timer = setTimeout(() => ac.abort(), timeoutMs);
  try {
    const res = await fetch(`${BASE}/chat/completions`, {
      method: "POST",
      headers: chatHeaders(),
      signal: ac.signal,
      body: JSON.stringify({
        model: MODEL,
        response_format: { type: "json_object" },
        messages: [
          {
            role: "user",
            content:
              '返回 JSON 对象,键只能是 city 与 country,对应「法国首都」。不要其它文字。',
          },
        ],
      }),
    });
    if (!res.ok) {
      const text = await res.text();
      return { ok: false, kind: "http", status: res.status, detail: text.slice(0, 200) };
    }
    const body = await res.json();
    const content = body.choices?.[0]?.message?.content ?? "";
    let parsed;
    try {
      parsed = JSON.parse(content);
    } catch {
      return { ok: false, kind: "parse", detail: content.slice(0, 200) };
    }
    const ok =
      parsed &&
      typeof parsed.city === "string" &&
      parsed.city.includes("巴黎") &&
      typeof parsed.country === "string";
    return { ok, kind: "json", parsed };
  } catch (err) {
    return { ok: false, kind: "network", detail: String(err) };
  } finally {
    clearTimeout(timer);
  }
}

async function probeFact({ timeoutMs = 12000 } = {}) {
  const ac = new AbortController();
  const timer = setTimeout(() => ac.abort(), timeoutMs);
  try {
    const res = await fetch(`${BASE}/chat/completions`, {
      method: "POST",
      headers: chatHeaders(),
      signal: ac.signal,
      body: JSON.stringify({
        model: MODEL,
        temperature: 0,
        max_tokens: 8,
        messages: [
          { role: "user", content: "法国的首都是哪座城市?只回答城市名。" },
        ],
      }),
    });
    if (!res.ok) {
      const text = await res.text();
      return { ok: false, kind: "http", status: res.status, detail: text.slice(0, 200) };
    }
    const body = await res.json();
    const answer = String(body.choices?.[0]?.message?.content ?? "")
      .trim()
      .replace(/[。.\s]/g, "");
    const ok = answer === "巴黎";
    return { ok, kind: "fact", answer };
  } catch (err) {
    return { ok: false, kind: "network", detail: String(err) };
  } finally {
    clearTimeout(timer);
  }
}

export async function runCanary() {
  const ttft = await probeTtft();
  const json = await probeJson();
  const fact = await probeFact();
  const ok = ttft.ok && json.ok && fact.ok;
  return { ok, ttft, json, fact, at: new Date().toISOString() };
}

if (import.meta.url === `file://${process.argv[1]}`) {
  runCanary().then((r) => {
    console.log(JSON.stringify(r, null, 2));
    process.exit(r.ok ? 0 : 2);
  });
}

本地验证:

export OPENAI_API_KEY=sk-...
node canary-probes.mjs

进程退出码 0 表示三道探针都过;2 表示至少一道质量失败。把 kind 打进日志,告警时才能区分超时、HTTP 4xx 和「200 但 JSON 坏了」。max_tokens 压得很低,是为了控制金丝雀成本;不要用长生成当心跳。

金丝雀:连续失败才动路由

单次失败常见原因包括限流、短暂排队、客户端超时设得过紧。直接切主模型,会造成不必要的抖动。更稳的做法是:每个上游一个窗口,连续 N 次失败才标记为不可用;连续 M 次成功再接回。N 和 M 不对称通常更合适,摘流要快,恢复要慢。

// shed-pool.mjs
import { runCanary } from "./canary-probes.mjs";

export function createProvider(name) {
  return {
    name,
    healthy: true,
    consecutiveFails: 0,
    consecutivePasses: 0,
    inflight: new Map(),
  };
}

export function recordCanary(provider, passed, { failN = 3, passM = 5 } = {}) {
  if (passed) {
    provider.consecutiveFails = 0;
    provider.consecutivePasses += 1;
    if (!provider.healthy && provider.consecutivePasses >= passM) {
      provider.healthy = true;
      console.warn(`[canary] 接回 ${provider.name}`);
    }
  } else {
    provider.consecutivePasses = 0;
    provider.consecutiveFails += 1;
    if (provider.healthy && provider.consecutiveFails >= failN) {
      provider.healthy = false;
      console.warn(`[canary] 摘流 ${provider.name},连续失败 ${failN} 次`);
    }
  }
}

export function pick(pool) {
  const ready = pool.filter((p) => p.healthy);
  if (ready.length === 0) return null;
  return ready[Math.floor(Math.random() * ready.length)];
}

export async function loop(pool, intervalMs = 30000) {
  for (;;) {
    for (const provider of pool) {
      const result = await runCanary();
      recordCanary(provider, result.ok);
      console.log(JSON.stringify({ provider: provider.name, ...result }));
    }
    await new Promise((r) => setTimeout(r, intervalMs));
  }
}

把 failN = 3、intervalMs = 30000 只当成示例:间隔太密会把金丝雀变成额外 QPS;太疏则发现劣化偏晚。和错误率告警一样,先按你的预算和 SLO 估一版,再根据误摘次数调整。金丝雀进程应独立于请求进程,避免探针把工作线程打满。

告警建议按 kind 分组,而不是一条「模型挂了」。TTFT 连续超阈值,优先怀疑排队或限流;parse 失败更像约束漂移或模型切换;fact 失败才更接近「这条路由的答案不可信」。后两种即使 HTTP 仍是 200,也不该继续吃主流量。

摘流与排空在途请求

摘流有两步,缺一就会把尾部延迟和取消放大。

第一步:停止新流量。pick() 只从 healthy 集合选。若池被摘空,应返回明确的 503 或切到事先准备的降级文案,而不是继续打已经判定失败的上游。

第二步:排空在途。金丝雀判定失败时,已经发出的用户请求不会自动消失。用每请求一个 AbortController 登记,摘流后给一个很短的 drain 窗口;窗口结束仍未返回的,主动 abort,让调用方走超时/重试,而不是无限挂起。

// drain.mjs
export function track(provider, ac) {
  const id = crypto.randomUUID();
  provider.inflight.set(id, ac);
  return () => provider.inflight.delete(id);
}

export async function shedAndDrain(provider, drainMs = 2000) {
  provider.healthy = false;
  await new Promise((r) => setTimeout(r, drainMs));
  for (const ac of provider.inflight.values()) {
    ac.abort();
  }
  provider.inflight.clear();
}

export async function proxyChat(provider, payload, { timeoutMs = 30000 } = {}) {
  if (!provider.healthy) {
    const err = new Error("provider_shed");
    err.code = "PROVIDER_SHED";
    throw err;
  }
  const ac = new AbortController();
  const untrack = track(provider, ac);
  const timer = setTimeout(() => ac.abort(), timeoutMs);
  try {
    const res = await fetch(
      `${process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1"}/chat/completions`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
          "Content-Type": "application/json",
        },
        signal: ac.signal,
        body: JSON.stringify(payload),
      }
    );
    return res;
  } finally {
    clearTimeout(timer);
    untrack();
  }
}

drain 窗口不要按「模型最长生成时间」来设。金丝雀已经认为这条路不可用,再等完整生成,只会让用户请求堆在一条已知劣化的链路上。更干净的是:摘流后两秒内能结束的结束,不能结束的取消,由上层重试到仍 healthy 的实例。

注意:abort() 只会取消本进程发出的 HTTP;对端是否停止计费、是否继续生成,取决于 Provider,不要假设取消等于零成本。金丝雀本身也要登记 abort,避免摘流后探针还在占连接。

接到网关时的边界

接到现有 Node 网关或 Provider 适配层时,有几条边界值得写死。

探针必须走真实路由。 不要另开一个「监控专用 key」打到不同的部署。模型名、base URL、地域、是否走兼容网关,都应与线上一致。否则 200 探针永远是绿的,线上用户已经在排队。

不要用业务请求当唯一探针。 真实流量分布不均,安静时段会漏检;用户问题也无法自动打分。合成题的价值就是可重复。反过来,也不要把金丝雀得分当成评测集:三道固定题只能抓住「明显坏了」,抓不住文风或长尾知识。

摘流要有人工覆盖。 自动接回前,至少保留强制摘除和强制接回。误摘的成本是容量下降;该摘不摘的成本是错误答案进入产品。后者通常更贵,所以失败阈值可以偏紧,恢复阈值必须偏松。

日志里留下证据。 每次金丝雀保留 ttftMs、原始 JSON 片段、固定题回答和连续失败计数。事后复盘需要的是这几段,而不是「当时好像变慢了」。若你已有 Prometheus,把 ok 做成 gauge、把 kind 做成 counter 即可,不必上新平台。

若上游是 Azure OpenAI 或其它兼容层,只要路径仍是 /chat/completions、流式仍是 SSE data: 行,上述探针不必改协议。字段名若有差异,只改解析,不要为了「统一封装」丢掉 kind。

总结

HTTP 200 是传输结果,不是质量结果。用 Node.js 对同一条线上路由打三道廉价合成探针——首字延迟、JSON 可解析、固定事实——已经足够把「还在回答,但已经不能用」从用户投诉里提前剥离出来。连续失败再摘流,并给在途请求一个短 drain,是为了避免把一次抖动升级成雪崩,也避免摘流后连接悬挂。

把它接到网关时,保持克制:阈值按自己的 SLO 设,探针走真实模型,失败分类写入日志,恢复慢于摘除。可用性监控仍然要做,只是不要再把它误当成模型可用。