《互联网产品的 AI 架构·模型接入与能力边界》3.3 ai-lab:实现零依赖模型网关

用 node:http 从零实现一个可运行的模型网关:POST /v1/chat 同时支持非流式 JSON 与 SSE 流式,认证、校验、执行由阶段组合器串联,测试在 127.0.0.1:0 上真实监听端口,并断言 HTTP 状态、响应体与每一帧事件。

实验目标与边界

前两节分别讨论了网关的职责与对外契约。这一节把它们落到可运行的代码:一个只依赖 node:http 的模型网关,提供 POST /v1/chat,同时支持非流式 JSON 与 SSE 流式响应,内部用 3.1 的阶段组合器串联认证、校验与执行,提供方使用 3.2 的确定性 mock。

实验刻意不实现配额、缓存与审计。它们在 3.1 里已经作为职责讨论过,实现方式是在 pipeline 上追加阶段,不改变 HTTP 契约。把范围收窄,才能看清“协议转换”与“策略执行”各自的位置。

HTTP 接口

项目约定
方法与路径POST /v1/chat
认证authorization: Bearer <api-key>
请求体ChatRequest JSON
非流式响应200 + ChatResponse JSON
流式响应200 + text/event-stream
参数错误400,错误类别 invalid-request
认证失败401,错误类别 auth
上游失败502 / 503 / 504

请求体在进入 pipeline 之前先做一次形状检查。缺少 requestId、messages 或预算字段时直接返回 400,不进入认证与执行。JSON 解析失败同样在 pipeline 之前被拒绝,避免把解析异常误记成提供方故障。非流式响应直接返回 ChatResponse。流式响应先发 SSE 头,再按 start、若干 delta、usage、done 的顺序写帧;如果中途失败,用 error 帧替代 done,HTTP 状态仍然是 200,因为响应头已经发出。

认证与租户隔离

TenantPolicy 把 API key 映射到租户。网关在认证阶段完成映射,后续阶段只使用租户对象。请求体里的 tenantId 必须与 key 解析出的租户一致,否则按认证失败处理。这不是重复校验,而是防止调用方拿着自己的 key 冒充另一个租户。构造网关时检查 key 是否重复。两个租户共用一个 key,配额、审计与吊销都会失去意义。真实系统还应该使用常量时间比较、支持多 key 与轮换,这些不改变本实验的结构。

服务端实现

createGatewayServer 只负责装配,不监听端口。测试可以先创建实例,再选择临时端口,避免固定端口冲突。请求处理函数把 HTTP 细节翻译成上下文,剩下的顺序与错误归一都交给 pipeline。

// file: src/gateway/server.ts
// tier: L1
// verify: node --test test/gateway/server.test.ts

import { createServer } from "node:http";
import type { IncomingMessage, Server, ServerResponse } from "node:http";
import { AiError, normalizeError } from "../core/errors.ts";
import type { AiErrorKind } from "../core/errors.ts";
import { systemClock } from "../core/types.ts";
import type { ChatRequest, ChatResponse, ModelProfile, Usage } from "../core/types.ts";
import { validateRequest } from "../protocol/chat.ts";
import type { ChatProvider } from "../protocol/chat.ts";
import { encodeStreamEvent } from "../protocol/stream.ts";
import type { StreamEvent } from "../protocol/stream.ts";
import { createPipeline, proceed } from "./pipeline.ts";
import type { Stage } from "./pipeline.ts";

export interface StreamChunk {
  text: string;
  done?: boolean;
  usage?: Usage;
  finishReason?: ChatResponse["finishReason"];
}

export interface StreamingChatProvider extends ChatProvider {
  stream(request: ChatRequest, model: ModelProfile): AsyncIterable<StreamChunk>;
}

export interface TenantPolicy { tenantId: string; apiKey: string }

export interface GatewayOptions {
  provider: ChatProvider;
  model: ModelProfile;
  tenants: TenantPolicy[];
}

interface Context {
  res: ServerResponse;
  requestId: string;
  authorization: string;
  startedAt: number;
  request: ChatRequest;
  tenant?: TenantPolicy;
  response?: ChatResponse;
  streamStarted: boolean;
}

function statusOf(kind: AiErrorKind): number {
  if (kind === "auth") return 401;
  if (kind === "quota" || kind === "rate-limit") return 429;
  if (kind === "timeout") return 504;
  if (kind === "unavailable") return 503;
  if (kind === "invalid-request") return 400;
  return 502;
}

/** 对外只给稳定类别与安全描述;提供方原文与 cause 留在服务端日志里。 */
function publicMessage(error: AiError): string {
  if (error.kind === "provider" || error.kind === "unavailable") return "上游模型服务暂时不可用";
  if (error.kind === "auth") return "认证失败";
  return error.message;
}

function isChatRequest(value: unknown): value is ChatRequest {
  if (typeof value !== "object" || value === null) return false;
  const request = value as ChatRequest;
  if (!Array.isArray(request.messages)) return false;
  const messagesOk = request.messages.every(
    (message) => typeof message.role === "string" && typeof message.content === "string",
  );
  return (
    messagesOk &&
    typeof request.requestId === "string" &&
    typeof request.capability === "string" &&
    typeof request.tenantId === "string" &&
    typeof request.maxOutputTokens === "number" &&
    typeof request.deadlineMs === "number" &&
    (request.stream === undefined || typeof request.stream === "boolean")
  );
}

function supportsStream(provider: ChatProvider): provider is StreamingChatProvider {
  return typeof (provider as Partial<StreamingChatProvider>).stream === "function";
}

function writeJson(res: ServerResponse, status: number, payload: unknown): void {
  if (res.headersSent || res.writableEnded) return;
  res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
  res.end(JSON.stringify(payload));
}

function writeError(res: ServerResponse, error: AiError, requestId: string): void {
  writeJson(res, statusOf(error.kind), {
    error: {
      kind: error.kind,
      message: publicMessage(error),
      retryable: error.retryable,
      requestId,
    },
  });
}

function writeEvent(res: ServerResponse, event: StreamEvent): void {
  if (!res.writableEnded && !res.destroyed) res.write(encodeStreamEvent(event));
}

async function readJson(req: IncomingMessage): Promise<unknown> {
  const chunks: Buffer[] = [];
  for await (const chunk of req) chunks.push(chunk as Buffer);
  if (chunks.length === 0) throw new AiError("invalid-request", "请求体为空");
  try {
    return JSON.parse(Buffer.concat(chunks).toString("utf8"));
  } catch {
    throw new AiError("invalid-request", "请求体不是合法 JSON");
  }
}

function authStage(options: GatewayOptions): Stage<Context> {
  return {
    name: "auth",
    run(context) {
      const token = context.authorization.startsWith("Bearer ")
        ? context.authorization.slice(7).trim()
        : "";
      const tenant = options.tenants.find((item) => item.apiKey === token);
      if (token === "" || !tenant) throw new AiError("auth", "无效的 API key");
      if (context.request.tenantId !== tenant.tenantId) {
        throw new AiError("auth", "租户与 API key 不匹配");
      }
      context.tenant = tenant;
      return proceed(context);
    },
  };
}

function validateStage(): Stage<Context> {
  return {
    name: "validate",
    run(context) {
      validateRequest(context.request);
      return proceed(context);
    },
  };
}

async function streamChat(context: Context, options: GatewayOptions): Promise<void> {
  const { res, request } = context;
  res.writeHead(200, {
    "content-type": "text/event-stream; charset=utf-8",
    "cache-control": "no-cache, no-transform",
    "x-accel-buffering": "no",
  });
  context.streamStarted = true;
  writeEvent(res, { type: "start", requestId: request.requestId, modelId: options.model.id });

  let usage: Usage | undefined;
  let finishReason: ChatResponse["finishReason"] = "stop";
  for await (const chunk of options.provider.stream(request, options.model)) {
    if (res.destroyed) throw new AiError("timeout", "客户端在流式响应中断开");
    if (chunk.text !== "") writeEvent(res, { type: "delta", text: chunk.text });
    if (chunk.usage) usage = chunk.usage;
    if (chunk.finishReason) finishReason = chunk.finishReason;
  }
  writeEvent(res, { type: "usage", usage: usage ?? { inputTokens: 0, outputTokens: 0, costMicros: 0 } });
  writeEvent(res, { type: "done", finishReason });
  res.end();
}

function executeStage(options: GatewayOptions): Stage<Context> {
  return {
    name: "execute",
    async run(context) {
      if (!context.request.stream) {
        context.response = await options.provider.complete(context.request, options.model);
      } else if (supportsStream(options.provider)) {
        await streamChat(context, options);
      } else {
        throw new AiError("invalid-request", "provider 不支持流式响应");
      }
      return proceed(context);
    },
  };
}

// 零依赖模型网关:HTTP 只做协议转换,认证、校验、执行由 pipeline 串起来。
export function createGatewayServer(options: GatewayOptions) {
  if (new Set(options.tenants.map((item) => item.apiKey)).size !== options.tenants.length) {
    throw new Error("gateway: apiKey 必须在租户之间隔离");
  }
  const clock = systemClock;

  const server = createServer((req, res) => {
    void route(req, res).catch((error: unknown) => {
      const failure = normalizeError(error);
      if (!res.headersSent) writeError(res, failure, "unknown");
      else if (!res.writableEnded) res.end();
    });
  });

  async function route(req: IncomingMessage, res: ServerResponse): Promise<void> {
    const path = (req.url ?? "/").split("?")[0];
    if (path !== "/v1/chat" || req.method !== "POST") {
      writeJson(res, 404, { error: { kind: "invalid-request", message: "not found" } });
      return;
    }

    let payload: unknown;
    try {
      payload = await readJson(req);
    } catch (error) {
      writeError(res, normalizeError(error), "unknown");
      return;
    }
    if (!isChatRequest(payload)) {
      writeError(res, new AiError("invalid-request", "请求体缺少必要字段"), "unknown");
      return;
    }

    const context: Context = {
      res,
      requestId: payload.requestId,
      authorization: req.headers.authorization ?? "",
      startedAt: clock.now(),
      request: payload,
      streamStarted: false,
    };

    const pipeline = createPipeline<Context>("chat-gateway", {
      clock,
      remainingMs: (current) => current.startedAt + current.request.deadlineMs - clock.now(),
    })
      .use(authStage(options))
      .use(validateStage())
      .use(executeStage(options));

    try {
      await pipeline.run(context);
      if (!context.request.stream && context.response && !res.writableEnded) {
        writeJson(res, 200, context.response);
      }
    } catch (error) {
      const failure = normalizeError(error);
      if (!context.streamStarted) {
        writeError(res, failure, context.requestId);
        return;
      }
      writeEvent(res, {
        type: "error",
        kind: failure.kind,
        message: publicMessage(failure),
        retryable: failure.retryable,
      });
      if (!res.writableEnded) res.end();
    }
  }

  return {
    server,
    listen(port = 0, host = "127.0.0.1") {
      return new Promise((resolve, reject) => {
        const onError = (error: Error): void => reject(error);
        server.once("error", onError);
        server.listen(port, host, () => {
          server.off("error", onError);
          const address = server.address();
          const actualPort = typeof address === "object" && address ? address.port : port;
          resolve({ port: actualPort, url: `http://${host}:${actualPort}` });
        });
      });
    },
    async close() {
      if (!server.listening) return;
      server.closeAllConnections();
      await new Promise<void>((resolve, reject) => {
        server.close((error) => (error ? reject(error) : resolve()));
      });
    },
  };
}

三个细节对应前两节的结论。第一,statusOf 把错误类别映射成 HTTP 状态,业务方不需要解析提供方状态码。第二,流式分支在写响应头之前不会设置 streamStarted,因此认证与校验失败仍然能返回带正确状态的 JSON,而不是一条已经开始的 SSE。第三,publicMessage 对 provider 与 unavailable 返回统一话术,提供方原文只留在服务端。

一次 SSE 请求长什么样

下面是一次流式请求的帧序列,文本与用量为案例示例,实际值由 mock 的确定性算法给出:

data: {"type":"start","requestId":"req-sse","modelId":"mock-echo-1"}

data: {"type":"delta","text":"【回声 "}

data: {"type":"usage","usage":{"inputTokens":14,"outputTokens":18,"costMicros":8}}

data: {"type":"done","finishReason":"stop"}

每个 data: 行都是单行 JSON,帧之间用空行分隔。客户端按事件类型处理,不依赖 delta 的切分位置;切分大小是网关的实现细节,可以随提供方变化。

测试:真实端口与真实请求

测试覆盖非流式契约、缺少与伪造 key、租户不匹配、结构错误、SSE 帧顺序、流中途失败与非法 JSON。在实验工程根目录执行:

node --test test/gateway/server.test.ts

本机一次运行的输出末尾如下,耗时随机器变化:

ℹ tests 7
ℹ pass 7
ℹ fail 0
ℹ duration_ms 288.151708

非流式用例先调用 provider.complete 算出期望值,再发真实请求,逐字段比较 output、usage 与 finishReason。SSE 用例把响应体按空行拆帧,用 decodeStreamEvent 解码,断言第一帧是 start、最后一帧是 done、所有 delta 拼接后等于期望输出。流中途失败用例通过 metadata.mockFailAfter 让 mock 在第一个 chunk 后抛错,断言收到 error 且没有 done。

从实验到生产还差什么

  • 认证:当前是线性查找与普通字符串比较,生产环境需要常量时间比较、多 key、过期与轮换。
  • 配额:实验没有实现租户配额与限流,需要按 3.1 的阶段顺序补上,并区分配额与限流。
  • 请求体上限:readJson 没有限制大小,生产环境必须设置上限并在超限时断开连接。
  • 取消传播:客户端断开后,当前实现只停止写帧,没有把取消信号传给提供方。
  • 背压:res.write 的返回值没有处理,慢客户端会让内存持续增长。
  • 持久化:审计与用量只在内存里,进程重启即丢失,生产环境需要落盘或发送到消息队列。

小结

零依赖网关的结构可以很小:HTTP 层只做解析与写回,认证、校验、执行由 pipeline 串联,提供方通过统一契约接入。非流式与流式共享同一套请求、错误与计量,区别只在响应如何写出。SSE 的关键不是分块传输,而是事件语义:start 定模型,delta 增量文本,usage 给用量,done 给结束原因,error 表示中途失败。一旦发出过 delta,就不能重试整条请求。

实验省略的配额、缓存、审计与取消传播,都是生产网关必须补上的部分;它们应该作为新阶段加入 pipeline,而不是散落在 HTTP 处理函数里。

阅读导航:上一节:3.2 统一契约与流式协议 · 下一节:4.1 路由的五个维度 。

常见错误与边界

  • 先写 SSE 响应头再认证。认证失败时状态码已经固定为 200,客户端只能从错误帧推断失败。
  • 用固定端口写测试。并行运行或端口占用会让测试随机失败,应该监听 127.0.0.1:0。
  • 把提供方状态码直接写回。业务方会被迫理解上游语义,换提供方就要改代码。
  • 流式失败后补发 done。客户端会把半截输出当成完整结果,应该发 error 并结束。
  • 在 HTTP 处理函数里堆积配额与审计。职责会重新散落,pipeline 也失去复用价值。
  • 忽略 res.write 的背压。慢客户端会让缓冲区无限增长,最终拖垮进程。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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