《互联网产品的 AI 架构·模型接入与能力边界》3.2 统一契约与流式协议

业务方不该为每家模型提供方写一套错误处理。本节把网关对外的请求、响应与错误收敛成一套契约,逐字段说明能力、预算、用量与降级,解释提供方错误码为什么必须留在网关内部,并给出按契约实现、可确定性测试的 mock provider,作为 3.3 实验的提供方基座。

契约的目标不是字段统一

很多团队把“统一契约”理解成把字段名改成一样:都叫 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_keyprovider上游模型服务暂时不可用网关密钥问题,不是调用方身份问题
429 rate_limit_exceededrate-limit请求过于频繁可退避重试,带 retryAfterMs
400 context_length_exceededinvalid-request输入超出上下文窗口调用方可以缩短输入
500 / 502 / 503provider / unavailable上游模型服务暂时不可用可重试,按预算决定
content_filtercontent-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 会从计量里消失。
  • 新增错误类别时复用旧名字。客户端会按旧语义处理,问题被延迟到更难排查的位置。
  • 删除或改写已发布字段的含义。契约演进只能增量,破坏性变更需要新版本。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查