接口返回 HTTP 200,只说明通道还通、对端愿意写出一段正文,并不等于首字延迟、结构化约束和关键事实仍在你能接受的区间。等用户投诉再切模型,窗口往往已经过去。下面用 Node.js 做一组廉价合成探针,把这三件事写成可告警金丝雀,并在连续失败时从路由池摘流、排空在途请求。
HTTP 200 解决不了质量窗口
网关和 Provider 适配层最容易养成一个习惯:把「可路由」定义成 status === 200 且响应体能读到。对传统 HTTP 服务这大致够用;对模型接口不够。
常见恶化并不走 5xx。流式连接建立成功,但第一个 delta.content 很晚才出现;response_format 仍是 json_object,模型却吐出残缺对象或夹杂解释性文字;固定事实题开始胡编,而错误码一切正常。用户侧的表现是「有时很慢」「偶发解析失败」「答案不对」,监控面板上却是绿的。
金丝雀要回答的不是「这个 URL 还能打通」,而是「这条路由现在还值不值得接真实流量」。它必须和线上同一套鉴权、同一模型名、同一套超时与重试预算,否则测到的是另一条路。探针失败也不该立刻全切:一次抖动和持续劣化要分开。连续失败再摘流,比单次超时更接近运维直觉。
本文只覆盖合成探针与路由保护,不替代离线评测集,也不声称某家模型更稳。阈值是你自己的 SLO,不是行业常数。
三类合成探针:延迟、可解析性、固定题
探针要便宜、可重复、失败原因可区分。建议固定三道题,而不是每次随机生成:随机题无法判断是模型变差还是题目变难。
- 首字延迟(TTFT):开
stream: true,从fetch发起到第一个非空choices[0].delta.content的时间。测完即可AbortController.abort(),不必把完整回答收完。 - 结构化可解析性:开 OpenAI Chat Completions 已支持的
response_format: { type: "json_object" },要求返回指定字段,然后JSON.parse并校验键。解析失败、缺字段、类型不对,都记为质量失败,而不是「HTTP 失败」。 - 固定题质量:问一个答案稳定、便于比对的短事实,例如首都名,归一化后做精确匹配。这测的是「这条路由是否还在胡说」,不是知识广度。
下面是一段可运行的 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 设,探针走真实模型,失败分类写入日志,恢复慢于摘除。可用性监控仍然要做,只是不要再把它误当成模型可用。
评论