契约的目标不是字段统一
很多团队把“统一契约”理解成把字段名改成一样:都叫 messages,都叫 max_tokens,都返回一个 choices。这只能算格式对齐。真正的契约要解决三个更根本的问题:业务方需要知道多少、失败之后能做什么、以及提供方的实现细节会不会泄漏到业务代码里。
「回声」的业务方不应该知道这次调用最终落在哪家提供方、哪个模型版本、哪个区域。它只需要描述:我要什么能力、我愿意等多久、我属于哪个租户、我最多接受多少输出。剩下的选择、重试、降级与计量都由网关完成。契约就是这条边界上的语言。
本节先看请求与响应,再看错误为什么要归一,最后看流式事件。所有代码都来自实验工程,可直接运行。
请求:只描述能力与预算
请求契约的核心是“不指定模型”。业务方指定模型,等于把选型决策提前写死在业务代码里;模型升级、区域切换、故障转移都会变成业务发布。业务方应该指定能力,例如 chat.reply 或 search.rerank,由网关把它映射到具体模型。
| 字段 | 作用 | 谁负责填写 |
|---|---|---|
requestId | 全链路追踪与幂等排查 | 调用方生成 |
capability | 业务能力标识,不暴露模型 | 调用方 |
tenantId | 配额、预算与审计的归属 | 调用方,需与 key 一致 |
region | 数据驻留与就近路由 | 调用方可选 |
messages | 本次请求需要的消息 | 调用方 |
maxOutputTokens | 输出上限 | 调用方 |
deadlineMs | 端到端剩余预算 | 调用方 |
stream | 是否要求流式 | 调用方 |
idempotencyKey | 同一业务动作的去重键 | 调用方可选 |
metadata | 非业务数据的路由提示 | 调用方可选 |
deadlineMs 是剩余预算,不是提供方超时。调用方在入口处知道自己还能等多久,网关在这个预算内完成或明确失败。把提供方超时暴露给业务方,会让业务方误以为“超时”只发生在最后一次网络调用。
metadata 只能放路由提示,例如灰度标签、调试开关,不能放用户隐私或业务正文。它是字符串字典,不参与模型输入;一旦有人把订单号、手机号塞进去,审计日志就会变成新的数据泄漏面。
validateRequest 只做结构校验:标识非空、消息非空、输出上限为正、预算为正。它不判断内容是否合规,也不判断租户是否有配额;那些是后续阶段的职责。结构错误不消耗模型预算,这是阶段顺序的直接结果。
响应:结果、用量与降级
响应契约要回答四个问题:用了哪个模型、产出了什么、花了多少、有没有降级。modelId 不是给业务方做逻辑分支用的,而是给排障与计量用的;业务方不应该写 if (modelId === "..."),否则路由策略又被写死了一次。
usage 包含输入 token、输出 token 与成本微元。成本用整数微元,避免浮点误差;estimateCostMicros 对输入与输出分别向上取整,保证一次请求不会被算成零成本。这个数字是网关的成本口径,不是对客户的报价。
finishReason 只有四种:正常结束、达到长度上限、触发工具调用、被内容策略拦截。业务方需要区分 length 与 stop:前者说明输出被截断,可能需要提高上限或缩短输入;后者才是完整结果。
degraded 是可选的降级原因。主模型不可用时切换到备用模型、预算不足时返回模板,都属于降级。业务方可以选择把降级结果展示给用户,也可以选择重试;但网关不能替业务方决定“降级后的文案是什么”。
流式协议:事件而不是 HTTP 分块
流式不是把响应体切成几段发出去。HTTP 分块是传输层细节,客户端真正依赖的是事件语义。实验里的 StreamEvent 定义五类事件:start、delta、usage、done、error。客户端按事件类型处理,而不是按字节位置猜。
start 告诉客户端本次请求最终用了哪个模型;delta 只携带新增文本;usage 在结束时给出 token 与成本;done 给出结束原因;error 表示流在中途失败。encodeStreamEvent 把事件编码成单行 JSON 的 SSE 帧,decodeStreamEvent 忽略空行、注释帧与 [DONE],格式错误的帧直接丢弃而不是中断整条流。
这个设计有两个直接后果。第一,提供方的流式格式不需要和客户端一致;网关负责把提供方的 chunk 翻译成统一事件。第二,一旦发出过 delta,就不能重试整条请求,因为客户端已经消费了部分输出;此时只能发 error 并结束。重试只对“还没有产生任何输出”的失败有意义。
错误为什么要归一
如果每个提供方都把自己的错误码透给业务方,业务方就要为每家提供方写一套 switch。更糟的是,同一类失败在不同提供方那里叫法不同:有的叫 rate_limit_exceeded,有的叫 too_many_requests,有的把配额和限流都放在 429 里。业务方无法用一套逻辑判断“该不该重试”。
归一后的错误只有九类:auth、quota、rate-limit、timeout、unavailable、invalid-request、invalid-output、content-blocked、provider。每一类都带一个稳定的 retryable 布尔值。业务方只需要处理九种情况,新增提供方不会改变它的错误处理代码。
auth 表示调用方身份有问题;quota 表示租户额度用尽;rate-limit 表示暂时太频繁,可以退避后重试;timeout 表示预算耗尽;unavailable 表示上游整体不可用;invalid-request 表示请求结构或参数错误;invalid-output 表示模型输出无法解析或不符合 schema;content-blocked 表示内容策略拦截;provider 是兜底类别,表示上游返回了未归类的失败。
usage 在错误上也要保留。一次超时可能已经消耗了输入 token;如果错误对象不带用量,计量就会漏掉这部分成本。AiErrorOptions 因此允许传入 usage,网关在抛出错误前把已知消耗填进去。
提供方错误码为什么不能外泄
提供方错误码不能直接透给业务方,原因有三个。
第一是耦合。业务方一旦按提供方错误码写分支,换提供方就要改业务代码。契约的意义就是把这种变化挡在网关内部。
第二是误导重试。提供方的 401 通常表示网关配置的密钥失效,不是调用方的 key 错了。如果把它映射成 auth 返回给业务方,业务方会去轮换自己的 key,问题却仍然存在。正确做法是映射成 provider,由网关告警并轮换上游密钥。
第三是信息泄漏。提供方错误里可能包含账号标识、配额余量、内部请求 ID、区域拓扑,甚至部分请求内容。这些信息对业务方没有价值,却会进入业务日志,扩大泄漏面。对外只返回稳定类别与安全描述,原始错误放在 cause 里,只进网关日志。
| 提供方返回 | 网关类别 | 对外消息 | 理由 |
|---|---|---|---|
| 401 invalid_api_key | provider | 上游模型服务暂时不可用 | 网关密钥问题,不是调用方身份问题 |
| 429 rate_limit_exceeded | rate-limit | 请求过于频繁 | 可退避重试,带 retryAfterMs |
| 400 context_length_exceeded | invalid-request | 输入超出上下文窗口 | 调用方可以缩短输入 |
| 500 / 502 / 503 | provider / unavailable | 上游模型服务暂时不可用 | 可重试,按预算决定 |
| content_filter | content-blocked | 内容被策略拦截 | 不可重试,业务需要换表达 |
| 响应 JSON 解析失败 | invalid-output | 模型输出无法解析 | 可重试,但要有次数上限 |
normalizeError 把未知异常统一收敛成 provider,并保留原始 cause。这条兜底很重要:没有它,一个未捕获的 TypeError 会以 500 的形式穿透到业务方,契约出现一个没有定义的缺口。
契约代码
错误模型与请求契约分别放在两个文件里。它们不依赖任何提供方 SDK,也不依赖 HTTP 框架。
// file: src/core/errors.ts
// tier: L1
// verify: node --test test/core/foundation.test.ts
/** 网关对外只暴露这一组错误类别,避免把提供方的私有错误码泄漏给业务方。 */
export type AiErrorKind =
| "auth"
| "quota"
| "rate-limit"
| "timeout"
| "unavailable"
| "invalid-request"
| "invalid-output"
| "content-blocked"
| "provider";
const RETRYABLE: Record<AiErrorKind, boolean> = {
auth: false,
quota: false,
"rate-limit": true,
timeout: true,
unavailable: true,
"invalid-request": false,
"invalid-output": true,
"content-blocked": false,
provider: true,
};
export interface AiErrorOptions {
cause?: unknown;
retryAfterMs?: number;
/** 已经消耗的预算,失败也要计量。 */
usage?: { inputTokens: number; outputTokens: number; costMicros: number };
}
export class AiError extends Error {
readonly kind: AiErrorKind;
readonly retryable: boolean;
readonly retryAfterMs?: number;
readonly usage?: { inputTokens: number; outputTokens: number; costMicros: number };
constructor(kind: AiErrorKind, message: string, options: AiErrorOptions = {}) {
super(message);
this.name = "AiError";
this.kind = kind;
this.retryable = RETRYABLE[kind];
this.retryAfterMs = options.retryAfterMs;
this.usage = options.usage;
if (options.cause !== undefined) {
this.cause = options.cause;
}
}
}
/** 未知异常统一收敛为 provider 类错误,保留原始 cause 供日志使用。 */
export function normalizeError(error: unknown): AiError {
if (error instanceof AiError) {
return error;
}
const message = error instanceof Error ? error.message : String(error);
return new AiError("provider", message, { cause: error });
}
export function isRetryable(error: unknown): boolean {
return normalizeError(error).retryable;
}
// file: src/protocol/chat.ts
// tier: L1
// verify: node --test test/core/foundation.test.ts
import { AiError } from "../core/errors.ts";
import type { ChatRequest, ChatResponse, ModelProfile } from "../core/types.ts";
/** provider 只能用统一契约实现,网关不关心它背后是哪家厂商。 */
export interface ChatProvider {
readonly id: string;
complete(request: ChatRequest, model: ModelProfile): Promise<ChatResponse>;
}
/** 请求进入网关的第一道检查:结构错误不消耗模型预算。 */
export function validateRequest(request: ChatRequest): void {
if (request.requestId.trim() === "") {
throw new AiError("invalid-request", "requestId is required");
}
if (request.capability.trim() === "") {
throw new AiError("invalid-request", "capability is required");
}
if (request.messages.length === 0) {
throw new AiError("invalid-request", "messages must not be empty");
}
if (request.maxOutputTokens <= 0) {
throw new AiError("invalid-request", "maxOutputTokens must be positive");
}
if (request.deadlineMs <= 0) {
throw new AiError("invalid-request", "deadlineMs must be positive");
}
}
/** 成本按整数微元计算,向上取整,避免把一次请求算成零成本。 */
export function estimateCostMicros(
model: ModelProfile,
inputTokens: number,
outputTokens: number,
): number {
const input = Math.ceil((inputTokens * model.inputMicrosPerMTok) / 1_000_000);
const output = Math.ceil((outputTokens * model.outputMicrosPerMTok) / 1_000_000);
return input + output;
}
两个实现细节值得注意。RETRYABLE 是一张显式的表,而不是在构造函数里用 if 推断;新增错误类别时,编译器会要求补全这张表。estimateCostMicros 对输入与输出分别向上取整,再相加,避免“输入一半、输出一半”被合并成一次取整而少算。
mock provider:契约的可执行样例
契约不能只停留在类型定义。下面这个 mock provider 实现了 complete 与 stream,不访问网络、不 sleep,同样的请求一定得到同样的文本、token 与成本。它通过 metadata 注入失败,用来测试网关的错误路径。
// file: src/gateway/mock-provider.ts
// tier: L1
// verify: node --test test/gateway/server.test.ts
import { AiError } from "../core/errors.ts";
import type { AiErrorKind } from "../core/errors.ts";
import type { ChatRequest, ChatResponse, ModelProfile, Usage } from "../core/types.ts";
import { estimateCostMicros } from "../protocol/chat.ts";
import type { ChatProvider } from "../protocol/chat.ts";
import type { StreamChunk, StreamingChatProvider } from "./server.ts";
/** 固定档案,让实验里的 token 与成本口径可重复。 */
export const mockModel: ModelProfile = {
id: "mock-echo-1",
provider: "mock",
capabilities: ["chat.reply", "search.rerank"],
qualityTier: "economy",
contextWindow: 8_192,
maxOutputTokens: 1_024,
inputMicrosPerMTok: 100_000,
outputMicrosPerMTok: 400_000,
p95LatencyMs: 50,
regions: ["cn"],
healthy: true,
};
const FAILURE_KINDS: readonly AiErrorKind[] = [
"rate-limit",
"timeout",
"unavailable",
"content-blocked",
"provider",
];
/** 案例估算:两个字符约一个 token;真实口径必须用分词器,这里只要确定性。 */
export function estimateTokens(text: string): number {
return Math.max(1, Math.ceil([...text.replace(/\s+/g, "")].length / 2));
}
interface MockPlan {
output: string;
usage: Usage;
finishReason: ChatResponse["finishReason"];
}
function buildPlan(request: ChatRequest, model: ModelProfile): MockPlan {
const injected = request.metadata?.mockFail;
if (injected !== undefined && FAILURE_KINDS.includes(injected as AiErrorKind)) {
throw new AiError(injected as AiErrorKind, `mock provider 注入错误: ${injected}`, {
retryAfterMs: injected === "rate-limit" ? 1_000 : undefined,
});
}
const lastUser = [...request.messages].reverse().find((message) => message.role === "user");
const blocked = request.metadata?.mockFinish === "content_filter";
const full = blocked
? ""
: `【回声 mock】收到 ${request.messages.length} 条消息。回应:${lastUser?.content ?? ""}`;
const chars = [...full];
const maxChars = Math.max(1, request.maxOutputTokens * 2);
const output = chars.length > maxChars ? chars.slice(0, maxChars).join("") : full;
const inputTokens = request.messages.reduce(
(sum, message) => sum + estimateTokens(message.content) + 4,
0,
);
const outputTokens = output === "" ? 0 : estimateTokens(output);
return {
output,
usage: { inputTokens, outputTokens, costMicros: estimateCostMicros(model, inputTokens, outputTokens) },
finishReason: blocked ? "content_filter" : output.length < full.length ? "length" : "stop",
};
}
export interface MockProviderOptions {
/** 流式切片大小,按字符计。 */
chunkSize?: number;
}
/**
* 确定性 mock:同样的请求一定得到同样的文本、token 与成本。
* 不访问网络、不 sleep,测试因此不需要等待真实模型。
*/
export function createMockProvider(options: MockProviderOptions = {}): StreamingChatProvider {
const chunkSize = Math.max(1, options.chunkSize ?? 3);
const provider: ChatProvider & StreamingChatProvider = {
id: "mock",
async complete(request: ChatRequest, model: ModelProfile): Promise<ChatResponse> {
const plan = buildPlan(request, model);
return {
requestId: request.requestId,
modelId: model.id,
output: plan.output,
usage: plan.usage,
finishReason: plan.finishReason,
...(plan.finishReason === "content_filter"
? { degraded: "content-blocked" as const }
: {}),
};
},
async *stream(request: ChatRequest, model: ModelProfile): AsyncIterable<StreamChunk> {
const plan = buildPlan(request, model);
const chars = [...plan.output];
const failAfter = Number(request.metadata?.mockFailAfter ?? "0");
let emitted = 0;
for (let offset = 0; offset < chars.length; offset += chunkSize) {
yield { text: chars.slice(offset, offset + chunkSize).join("") };
emitted += 1;
if (Number.isFinite(failAfter) && failAfter > 0 && emitted >= failAfter) {
throw new AiError("provider", "mock provider 在流中途断开");
}
}
yield { text: "", done: true, usage: plan.usage, finishReason: plan.finishReason };
},
};
return provider;
}
它引用的 StreamChunk 与 StreamingChatProvider 定义在 3.3 的 server.ts 里:流式是网关对提供方的扩展要求,普通提供方只实现 complete 也能工作。mock 的文本由消息数量与最后一条用户消息决定,长度受 maxOutputTokens 限制;达到上限时 finishReason 是 length,不是 stop。这两个行为让测试可以稳定断言截断与正常结束。
版本演进的三条规则
契约一旦发布,就只能以兼容的方式演进。三条规则足够覆盖大多数情况。
第一,只增字段,不删字段,不改已有字段的语义。新增可选字段时,旧客户端不填也能工作;把 deadlineMs 的含义从“提供方超时”改成“端到端预算”则是破坏性变更。
第二,未知事件与未知字段一律忽略。客户端遇到不认识的 StreamEvent 类型时跳过,而不是崩溃;网关遇到不认识的请求字段时忽略,而不是拒绝。这条规则让双方可以独立升级。
第三,错误类别只增不改。新增 kind 时,旧客户端走默认分支;不要用旧的 provider 表示一种全新的、需要特殊处理的失败。类别是协议,不是内部枚举。
小结
统一契约不是把字段名改成一样,而是划清业务方与网关的认知边界:业务方只描述能力、预算与租户,网关负责模型选择、重试、降级与计量。请求不指定模型,响应携带用量与降级原因,流式用事件而不是字节分块。
错误必须归一成固定类别,并提供稳定的可重试性。提供方错误码不能外泄:它会造成耦合、误导重试、泄漏内部信息。原始错误留在 cause 与网关日志里,对外只给安全描述。
下一节把这些契约落到 HTTP:用 node:http 起一个零依赖网关,真实监听端口,验证非流式响应与 SSE 帧。
阅读导航:上一节:3.1 模型网关的职责边界 · 下一节:3.3 ai-lab:实现零依赖模型网关 。
常见错误与边界
- 让业务方指定模型。模型升级与故障转移会变成业务发布,路由策略被写死在调用点。
- 把提供方超时当端到端预算。排队、路由与序列化同样消耗时间,预算必须覆盖全链路。
- 在
metadata里放业务正文或个人信息。它不参与模型输入,却会进入日志与审计。 - 把所有 429 都映射成
rate-limit。租户配额用尽与瞬时限流的重试策略不同。 - 把提供方 401 映射成
auth。这是网关密钥问题,业务方轮换自己的 key 不会解决。 - 流式输出开始后还重试整条请求。客户端已经消费了部分内容,只能发错误事件。
- 错误对象不带
usage。超时前消耗的输入 token 会从计量里消失。 - 新增错误类别时复用旧名字。客户端会按旧语义处理,问题被延迟到更难排查的位置。
- 删除或改写已发布字段的含义。契约演进只能增量,破坏性变更需要新版本。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。