12.1 11 座孤岛,缝合成一个可部署系统
跟到这里的读者已经手握 11 份 example. 每一份都能独立 npm install 跑起来,每一份都
对应一章的核心议题:
examples/
01-what-is-a-gateway/ 30 行透传
02-one-endpoint-three-providers/ 按 model 路由
03-anthropic-is-different/ 协议适配
04-stop-sharing-the-master-key/ Key 鉴权
05-who-spent-my-money/ 两阶段计费
06-someone-is-abusing-my-gateway/ QPS + TPM
07-stream-is-broken/ SSE 流式
08-the-key-just-got-banned/ 渠道池故障转移
09-where-did-this-request-go/ trace_id + 看板
10-why-is-relay-cheaper/ 降本三件套
11-how-do-i-charge-users/ 钱包 + 支付11 份各跑各的,单独看每一份都是合格的教学示例: 但作为一个系统, 它有四个缺口:
- schema 没合并. 11 份各有自己的
data/gateway.db, 切到下一章必须删表重灌。 - 环境变量分散. 每一份的
.env覆盖范围不一样,上线时不知道哪个字段一定要配。 - 依赖手动起. 跑 v0.11 的 e2e 要先开 mock LLM 上游 (terminal 1) + mock 支付平台 (terminal 2) + 主网关 (terminal 3). 同事尝试一下要消耗 3 个 terminal + 一份操作 清单,没有「一条命令起整套」的体验。
- 没有健康检查. v0.9 加了一个
/healthz, 但它返的是「网关启动后的状态总览」, 不是 k8s 期望的「liveness + readiness 二分」. 部署到 k8s / Nginx 反代后面,探针 不知道该问哪个端点。
第 12 章的目标是把这 4 个缺口补上,不增加业务功能。跟完这一章,读者得到的是一个单一
工程目录, docker compose up 起整套,端到端 smoke 一次跑通完整请求生命周期,三种
部署目标 (Node / Bun / Cloudflare Workers) 各自的 trade-off 写得清清楚楚。
四个缺口背后其实是同一件事。教学示例与工程产品的完成度差距. 教学示例只需要在「能 说明问题」的范围内可运行。工程产品需要让一个没看过书的同事也能拉起来用。这两者 的工作量比通常是 1:3 ——示例代码写完,整合工作才刚开始。第 12 章把这 1:3 里属于网关 本身的部分填齐,让读者拿到这本书时,完整路径已经走完。
v1.0 不增加业务功能这件事值得多说一句。整本书的 v0.X 编号意味着「能跑但仍有明显缺 口」, v1.0 意味着「可上线」. 从 v0.11 到 v1.0 不是新功能跃升,而是工程成熟度跃升。 对照语义版本号 (semver) 的语义, major 版本号增加表达的是「兼容性 / 接口稳定性」,不是 「新增能力」. v1.0 的兑现处恰好是这个——前 11 章的能力一个不少,但「拼成一个能交付出去 的东西」.
工程成熟度跃升的另一个潜台词是「问题暴露的时机往前移」. v0.11 之前,一个错误的环境 变量、一个 schema 没跑过的部署、一个 channel 全部 disabled 的状态,这些问题都要等到 第一次发请求时才暴露。流量进来之后才挂,客户已经看到了 502, 才反过来排查. v1.0 把这些问题统统提前到「启动期」或「探针期」: 配错 env 启动失败, schema 没跑 readyz 503, channel 全挂 readyz 503 + 看板秒级可见: 流量被挡在网关之前,客户看不到 502, 你看到 告警。这是 v0.X 跟 v1.0 之间最实在的差别。
跟到这里的读者大概率会做一件事。把 v0.11 的某个 example 或 v1.0 拉出来,配上自己的 真实上游 key, 起在公司内网或某个 vps 上,让小范围用户开始用。这一章就是为了让这个动作 能在两小时内完成,而不是两周——后者通常发生在「示例代码很零散,整合工作要从头做」的 项目上。
下面按四个缺口逐一处理。
12.2 配置管理: zod 校验 + 三套环境
v0.11 的代码里有这种散读:
// v0.11 (src/index.ts)
const PORT_FROM_ENV = Number(process.env.PORT ?? 3000);
const MOCK_PAY_BASE_URL = process.env.MOCK_PAY_BASE_URL ?? 'http://localhost:5010';
const MOCK_PAY_SECRET = process.env.MOCK_PAY_SECRET ?? 'test-mock-secret';
const PAYMENT_NOTIFY_BASE = process.env.PAYMENT_NOTIFY_BASE ?? `http://localhost:${PORT_FROM_ENV}`;
const DEFAULT_MAX_TOKENS = Number(process.env.DEFAULT_MAX_TOKENS ?? 4096);
const GLOBAL_QPS_LIMIT = Number(process.env.GLOBAL_QPS_LIMIT ?? 0);
// ... 12 行类似它能跑。但它有三个问题:
- 拼写错误不报错.
OPENAI_API_KEY写成OPENAI_KEY, 上线后第一次发请求才上游 401. - 类型错误延迟暴露.
PORT=abc npm run start, 启动成功,但 listen 时报「invalid port: NaN」,看不出根因。 - 必填字段没强制.
ADMIN_TOKEN留默认值admin-change-me上生产,等于裸奔。
v1.0 把所有 env 收口到 src/config/env.ts, 用 zod 在启动时一次性校验:
// src/config/env.ts (节选)
const EnvSchema = z.object({
NODE_ENV: z.enum(['dev', 'prod', 'test']).default('dev'),
PORT: intFromString(3000),
DATABASE_URL: z.string().min(1, 'DATABASE_URL is required (SQLite file path)'),
ADMIN_TOKEN: z.string().min(8, 'ADMIN_TOKEN must be at least 8 chars'),
// ... 30+ 字段
});
export function loadEnv(): AppEnv {
const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
console.error('[env] config validation failed:');
for (const issue of parsed.error.issues) {
console.error(` - ${issue.path.join('.')}: ${issue.message}`);
}
process.exit(1);
}
// 跨字段约束: prod 模式 ADMIN_TOKEN 不能是默认值
if (parsed.data.NODE_ENV === 'prod' && parsed.data.ADMIN_TOKEN === 'admin-change-me') {
console.error('[env] ADMIN_TOKEN is still the default placeholder. set a strong random token in production.');
process.exit(1);
}
return parsed.data;
}主入口 src/index.ts 启动第一行就调:
const env = loadEnv();
const bootLogger = getRootLogger();
bootLogger.info({ node_env: env.NODE_ENV, port: env.PORT }, 'env_validated');故意配一个错误的 env 看效果:
ADMIN_TOKEN=abc npm run start
# [env] config validation failed:
# - ADMIN_TOKEN: ADMIN_TOKEN must be at least 8 chars
# (process exits with code 1)启动失败比运行时失败快得多。一个 typo 的 env 名在 v0.11 之前会等到第一次发请求时上游 401 才报错; v1.0 启动就报, exit code = 1, systemd / docker / k8s 都能识别为启动失败, 不会把流量调度到这个坏实例。
业务代码不再 process.env.X 散读。主路径直接用强类型 env:
// v1.0 (src/index.ts)
const DEFAULT_MAX_TOKENS = env.DEFAULT_MAX_TOKENS; // 类型 number
const GLOBAL_QPS_LIMIT = env.GLOBAL_QPS_LIMIT; // 类型 number
const SSE_HEARTBEAT_MS = env.SSE_HEARTBEAT_MS; // 类型 number不再有 Number() 转换,也不再有 ?? defaultVal. 默认值在 src/config/defaults.ts
统一管理。
三套环境通过 NODE_ENV=dev|prod|test 区分,主要差异是:
dev: ADMIN_TOKEN 可以用默认值 (开发方便), LOG_LEVEL 通常debug.prod: ADMIN_TOKEN 不能等于admin-change-me(启动失败), 没配真实上游 key 时会 warn (允许只用 mock channel 跑,但是会提醒).test: 与 dev 相同的宽容度,但用更短的健康检查 / SSE 心跳间隔 (跑 e2e 测试更快).
跨字段校验集中在 loadEnv() 内部,不散落到业务代码。这种「校验是 env.ts 的责任,
业务只读强类型 env 对象」的纪律,让后续加新字段的工作量稳定在「改 1 个文件」.
值得特别说的是 loadEnv() 的「单例 + 缓存」语义: 它内部维护一个 cached: AppEnv | null,
第一次调用做完整 zod 解析,后续调用直接返回缓存。这避免了「同一份代码多次跑 schema
解析」的浪费 (schema 实例化是一次性成本,但 safeParse 本身有可观开销). 同时它也强制
了一个不变量——整个进程生命周期内 env 不可变. 想动态改环境。先重启进程,别在运行
时偷偷修改 process.env. 这条不变量让限流 / 计费这些「读 env 配置作为决策因子」的模块
不需要担心配置突变。
另一个易踩的坑是 migrate.ts 这种 cli 入口要不要校验 env. v1.0 的选择是不强制——
migrate.ts 只读 DATABASE_URL 一个字段,让它跑全套 zod 校验等于让运维必须配上所有
上游 key 才能 migrate, 这违反直觉。所以入口分两类: index.ts (主网关。进 loadEnv(),
migrate.ts 直接读 process.env.DATABASE_URL. 这是工程上的合理妥协: env.ts 只对有
义务校验全集的入口强制,不污染纯运维工具。
12.3 Migration 合并。保留 0001-0007, 新增 0008 v1 marker
v0.11 跑 npm run migrate 会顺序应用 7 份 migration:
0001_init.sql orgs / users / keys
0002_billing.sql prices / usage_records
0003_quota.sql keys.monthly_quota
0004_channels.sql channels / abilities + 反范式索引
0005_observability.sql usage_records 加 trace_id / channel_id / latency
0006_cost_optimization.sql channels.cost_priority + prices 加 cache/batch 单价
0007_payment.sql wallets / recharges / refundsv1.0 的合理做法是「合并成一份初始化 schema」吗。实际上不是。三个理由:
理由一。不破坏老读者的升级路径. 如果一个读者从 v0.4 一路跟到现在,他的本机 SQLite
里已经有 0001-0007 全部应用记录。我们如果把这些合并成 0001_initial_v1.sql, 他的
__drizzle_migrations 表与新 schema 不一致,升级会跑挂。真实生产中也是这个原则——
migration 一旦发布就不能回头改, 它是不可变的历史。
理由二。教学价值. 7 份 migration 是「从空网关到 v0.11」的演化史,每一份对应一章 的新增功能。保留对照关系比合并更有用。读者按章对照「这一章动了哪些表」,能看到 schema 怎么从 3 张表 (orgs / users / keys) 长到 13 张表的全过程。
理由三: fresh install 体感等价. 如果是全新部署, npm run migrate 按 0001-0008
顺序一次跑完,总耗时 < 100ms. 体感上和「一份初始化 schema」没区别。
v1.0 新增的 0008_v1_release.sql 不增加任何表,只是一个版本标识:
-- drizzle/0008_v1_release.sql
SELECT 'v1.0_release_marker' AS version;它写进 __drizzle_migrations 表后,运维直接 SELECT MAX(filename) FROM __drizzle_migrations
就能确认「这个数据库到没到 v1.0 schema」. 以后 v1.1 再加新表时,编号往 0009 走。
migration 的另一道纪律是「从不跑 down」. 这一点跟很多教程相反——很多 ORM 教学会同时
教 up 和 down. 真实生产的经验是。一次错误的 down 比十次错误的 up 更致命 (前者是数据
不可逆丢失). 我们的 migrate.ts 实现里完全没有 down——文件名只匹配 .sql, 不区分 up/down,
不暴露 rollback 接口: 想要回退。备份 SQLite 文件, restore 回去。这种朴素方法跑得稳,
不引入新故障面。
跨章节的 schema 演化里有几个值得记的设计动作:
- v0.4 → v0.5:
users加balance_micro, 但不动任何老字段: 这是「演进不破坏」 的第一次实践。 - v0.5 → v0.8:
usage_records表 (Ch5 建。在 Ch8 加channel_id, 在 Ch9 加trace_id. 既不重命名字段,也不删字段,只 ALTER TABLE ADD COLUMN. SQLite 在这种纯加列的场景 下零代价 (不重写表). - v0.10 → v0.11: 余额从
users.balance_micro搬到wallets.balance_micro. 这次有「数据 迁移逻辑」(老 user 的 balance 复制到新 wallet, 由 cli/seed-wallet.ts 完成), 但 SQL 层依然是「只加新表 + 只加新字段」,不破坏users.balance_micro. 老接口仍然可用, v1.0 整合时才彻底删。
8 份 migration 加起来约 530 行 SQL. 一份「初始化 schema」如果合并起来也是这个量级。
跑 npm run migrate 在新环境的总耗时 < 100ms, 不是性能瓶颈。
12.4 Docker 化。多阶段构建 + docker-compose
v1.0 的 Dockerfile 分三阶段,这是 Node.js 镜像的标准模式
(Docker 官方最佳实践,
Node.js 官方 docker 推荐):
ARG NODE_VERSION=20
ARG NODE_IMAGE=node
ARG NODE_VARIANT=slim
# ---------- stage 1: deps ----------
FROM ${NODE_IMAGE}:${NODE_VERSION}-${NODE_VARIANT} AS deps
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++
COPY package.json package-lock.json* ./
RUN npm ci
# ---------- stage 2: builder ----------
FROM ${NODE_IMAGE}:${NODE_VERSION}-${NODE_VARIANT} AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build # = tsc --noEmit, 失败时整个镜像构建失败
# ---------- stage 3: runner ----------
FROM ${NODE_IMAGE}:${NODE_VERSION}-${NODE_VARIANT} AS runner
WORKDIR /app
ENV NODE_ENV=prod
RUN mkdir -p /app/data && chown -R node:node /app
COPY --from=builder --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/package.json ./package.json
COPY --from=builder --chown=node:node /app/drizzle ./drizzle
COPY --from=builder --chown=node:node /app/src ./src
COPY --from=builder --chown=node:node /app/tsconfig.json ./tsconfig.json
USER node
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD node -e "fetch('http://localhost:'+(process.env.PORT||3000)+'/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
EXPOSE 3000
CMD ["npx", "tsx", "src/index.ts"]三阶段的工程价值:
- deps 阶段只跑一次
npm ci. Docker 构建缓存按 layer 命中, package.json 不变时 这一层完全不重跑。这是镜像构建提速的核心——平时改业务代码, deps 层永远命中。 - builder 阶段装上完整源码 + 跑
npm run build. v1.0 用tsc --noEmit当 build, 只做类型校验 + 不落 dist 文件。想用 dist 的部署见DEPLOYMENT.md. - runner 阶段用最干净的基底,只复制必要的部分: node_modules + drizzle + src + tsconfig. 不带文档、测试脚本、git 历史。攻击面更小,镜像更轻。
USER node 让进程以非 root 运行. node 官方镜像预置 uid=1000 的 node 用户,直接用
即可。跑 docker exec -it 进容器,默认就是 node 用户。
HEALTHCHECK 让 docker 自带的健康探针每 30 秒打一次 /healthz. 失败 3 次后容器状态
变成 unhealthy, docker swarm / k8s 据此摘流或重启。
为什么不用 alpine? alpine 用 musl libc, better-sqlite3 / node-gyp 在它上面经常踩坑——
编译产物在 glibc 容器里又跑不动。教学版用 -slim (Debian glibc) 最稳,镜像 ~250MB,
比 alpine 多 100MB 但兼容性零负担。真要用 alpine, 把 ARG NODE_VARIANT 传成 alpine,
RUN apt-get 换成 RUN apk add. v1.0 的 Dockerfile 就把这两个 ARG 暴露出来了,国内 /
内网环境也可以传 NODE_IMAGE=docker.m.daocloud.io/library/node 走镜像加速。
docker-compose.yml 起整套环境:
services:
gateway:
build: .
ports: ['3000:3000']
environment:
MOCK_BASE_URL: http://mock-upstream:4010 # 用 docker network 内的 hostname
MOCK_PAY_BASE_URL: http://mock-payment:5010
PAYMENT_NOTIFY_BASE: http://gateway:3000
volumes:
- ./data:/app/data # SQLite 持久化到宿主机
depends_on:
mock-upstream: { condition: service_healthy }
mock-payment: { condition: service_healthy }
healthcheck:
test: ['CMD', 'node', '-e', "fetch('http://localhost:3000/readyz').then(r=>process.exit(r.ok?0:1))..."]
# 片段截断, 完整版 (含 interval / timeout / retries / start_period) 见 examples/12-ship-it/docker-compose.yml
mock-upstream:
build: .
command: ['npx', 'tsx', 'src/scripts/mock-upstream.ts']
ports: ['4010:4010']
healthcheck: ...
mock-payment:
build: .
command: ['npx', 'tsx', 'src/scripts/mock-payment-platform.ts']
ports: ['5010:5010']
healthcheck: ...depends_on.condition: service_healthy 让 gateway 等 mock 两个服务都 healthy 才启动。
不然 gateway 可能在 mock 还没起来时第一次跑 channel 健康探针就把 mock 标成 disabled.
volumes: ./data:/app/data 把 SQLite 文件挂到宿主机,容器重启不丢数据: 真要做生产
持久化用命名卷 (gateway-data:/app/data) 更稳,但教学场景用 bind mount 让读者直接看
到文件。
跑 docker compose up --build 的预期输出:
[+] Building 6.3s (24/24) FINISHED
✔ Service mock-upstream Built
✔ Service mock-payment Built
✔ Service gateway Built
[+] Running 4/4
✔ Network ship-it_default Created
✔ Container llm-gateway-mock-upstream Healthy
✔ Container llm-gateway-mock-payment Healthy
✔ Container llm-gateway Healthy三个容器都到 Healthy 后,主网关在 :3000 可用。一条命令。
实测构建一次镜像 (alpine + 国内 daocloud 镜像源。总耗时约 60 秒,最终镜像大小 ~366MB. 这个数字是用 alpine variant 实测的; slim 默认会略大 (约 450MB), 多出来的是 glibc + Debian 基础包。
这里面 node_modules 占大头,一些极致优化 (only production deps + 不带 src + tsc 编译
后只放 dist) 能压到 200MB 左右,但教学版选稳定不选极致——直接 npx tsx src/index.ts
启动,容器里同时存在 src + node_modules + drizzle, 出问题时 docker exec -it 进去
直接读源码。真要节流再走 dist 路径, DEPLOYMENT.md 的 Node.js 一节给完整步骤。
docker compose down -v 关闭整套环境并清掉 volumes (注意 -v 会删本地 SQLite 文件).
平时改业务代码后只需 docker compose up --build gateway, 重新构建并起 gateway 一个
服务, mock 两个保持不变。这是 docker-compose 的增量构建特性,不需要每次都把整套
拆掉重起。
12.5 健康检查端点: liveness vs readiness
v0.9 加的 /healthz 把所有信息塞一起:
// v0.9
app.get('/healthz', (c) => {
const snap = registry.snapshot();
return c.json({
ok: true,
version: 'v0.9',
channels: { total, active, disabled, probing },
streaming: { sse: true, heartbeat_ms, max_channel_attempts },
health_checker: { interval_ms },
limits: { global_qps, global_tpm, ... },
});
});它对人友好 (curl 一下能看到所有状态), 对 k8s 不友好. k8s 的探针有两种:
- liveness: 失败重启 pod. 只能放「重启能解决」的检查 (例如事件循环卡死).
- readiness: 失败摘流。放「依赖未就绪」的检查 (例如 DB 还没 migrate).
把两件事混在一起的后果。一个 channel 暂时全部 disabled (受上游 401 风控影响。让 v0.9 的 healthz 返 200 还是 503? 返 503 会触发 pod 重启,但重启没用——上游 401 不 随重启消失,重启后还是 disabled, 反复 crashloop. 返 200 又骗了 k8s, 流量继续打过 来全部 502.
v1.0 把它拆开:
/healthz (liveness), 只问「进程还活着吗」:
// src/health/liveness.ts
app.get('/', (c) =>
c.json({
ok: true,
uptime_s: Math.floor((Date.now() - startedAt) / 1000),
ts: new Date().toISOString(),
}),
);任何更复杂的逻辑都不属于 liveness. 进程不死, /healthz 就 200.
/readyz (readiness), 跑三项检查:
// src/health/readiness.ts (节选)
function checkDb(): ReadinessCheck {
const sqlite = getRawSqlite();
const row = sqlite.prepare('SELECT 1 as v').get() as { v: number };
return { name: 'sqlite', ok: row.v === 1, duration_ms: ... };
}
function checkChannels(minActive: number): ReadinessCheck {
const snap = getChannelRegistry().snapshot();
const active = snap.filter((s) => s.status === 'active').length;
return {
name: 'channels', ok: active >= minActive,
detail: `active=${active}/${snap.length} (need ≥${minActive})`,
duration_ms: ...,
};
}
function checkWalletsTable(): ReadinessCheck {
// SQLite 元表查询, 不动业务数据
const row = sqlite.prepare(
"SELECT COUNT(*) as c FROM sqlite_master WHERE type='table' AND name='wallets'"
).get();
return { name: 'wallets_table', ok: row.c === 1, ... };
}/readyz 任一项失败就返 503 + 失败明细:
curl http://localhost:3000/readyz | jq
{
"ok": true,
"checks": [
{ "name": "sqlite", "ok": true, "duration_ms": 0 },
{ "name": "channels", "ok": true, "detail": "active=5/5 (need ≥1)", "duration_ms": 0 },
{ "name": "wallets_table", "ok": true, "detail": "present", "duration_ms": 0 }
],
"ts": "2026-05-15T..."
}/readyz 503 时, k8s 把 pod 从 service endpoints 摘掉,不重启 (因为重启没用——比如
DB 文件没挂上来,重启还是没 DB). 等依赖恢复后 readyz 自动 200, k8s 自动把流量加回来。
每个检查都套超时 (默认 2s), 防止 readyz 自己卡死. SQLite ping 通常 < 1ms, 但极端 场景 (磁盘 IO 阻塞: 可能挂住,不能让 readyz 跟着挂。
v1.0 旧的「状态总览」端点搬到 /admin/info, 走 admin Bearer 鉴权,不让公网随便看
内部状态。探针端点保持公开 + 极薄。
readiness 检查可以更激进一点——比如要求至少 N 个 channel 是 active (默认 1, 通过
env MIN_ACTIVE_CHANNELS 调). 单 channel 满足语义底线,但生产场景下通常希望同 group
至少有 2-3 个 channel 互为备份。用 N=3 时,一个 channel 被风控 disabled 不会让 readyz
立即 503 (因为还有 2 个 active), 但全部 3 个挂掉会立即触发 503 + 摘流——再来的请求
会被 LB 路由到其他 region 的网关实例。
全新部署的边界值得提一句。默认 MIN_ACTIVE_CHANNELS=1, 意味着 admin 必须先注册至少 1 个 active channel 后 readyz 才会 200. 全新部署还没注册任何 channel 时 readyz 会一直 503, 这是预期行为不是 bug——它的语义是「网关还没准备好接流量」. admin 注册第一个 active channel 后 readyz 自动 200, 不需要重启进程。第一次按 docker compose up 起整套的读者看到 503 不要慌,检查 /admin/channels 是不是空的。
readiness 还有一个常见误区。把它做成「全功能验证」,比如往上游真的发一笔请求看返回。 这是反模式. readiness 该做的是「依赖是否就绪」,不是「依赖是否完美工作」. 真发请求会 让上游频繁吃到无意义流量,上游又会按 QPS 限流, readyz 跟着 503, 雪崩。我们的 readyz 只查本地状态——SQLite 在不在、channel 在不在、wallets 表在不在。上游真坏了,让 channel 故障转移 + 健康检查机制自己处理 (Ch8 的内容), 不要拉着 readyz 一起报警。
12.6 端到端冒烟。一次跑完整请求生命周期
v0.11 之前每个能力有自己的 e2e 脚本——stream-test.ts / failover-test.ts /
payment-flow-test.ts 等。它们各跑各的,没有一个脚本能验证「整个系统串起来工作」.
v1.0 加 npm run smoke, 用 src/scripts/e2e-smoke.ts 一次跑完 10 步:
// e2e-smoke 自动起所有依赖, 跑完清理:
// step 1. /healthz pass (liveness)
// step 2. /readyz pass (readiness, 含 sqlite + channels + wallets)
// step 3. admin 建 org + user (自动建 wallet)
// step 4. admin 建 key (sk-gw-xxx)
// step 5. admin 创建充值订单 (10 元 mock 支付)
// step 6. 等 mock 平台异步回调入账, wallet.balance = 10
// step 7. 非流式 chat 调用 (鉴权 → preConsume → 上游 → postConsume → observability)
// step 8. 流式 chat 调用 (SSE 透传 + [DONE] 哨兵 + 流式计费)
// step 9. dashboard JSON 总览查得到 totalRequests >= 2
// step 10. 退款触发 + 异步回调到账, 钱包扣穿实测一次:
[23:46:09.367] ready: gateway-liveness {"url":".../healthz","latency_ms":1532}
[23:46:09.370] ready: gateway-readiness {"url":".../readyz","latency_ms":3}
[23:46:09.370] --- step 1: /healthz pass ---
[23:46:09.370] --- step 2: /readyz pass ---
[23:46:09.429] --- step 3: user + wallet created --- {"userId":1,"walletId":1}
[23:46:09.440] --- step 4: key issued --- {"keyId":1}
[23:46:09.480] --- step 5: recharge order created --- {"rechargeId":1,"outTradeNo":"USR1NO..."}
[23:46:10.984] --- step 6: recharge callback processed --- {"balanceCny":10}
[23:46:11.308] --- step 7: non-stream chat ok ---
[23:46:11.827] --- step 8: stream chat ok --- {"received_bytes":2612}
[23:46:12.300] --- step 9: dashboard observable --- {"total_requests":4,"total_cost_micro":156}
[23:46:13.300] --- step 10: refund callback drained wallet --- {"balanceMicro":-78}
[23:46:13.300] === e2e-smoke PASSED === {"checks_passed":10,...}10 步全过, 5 秒. smoke 用临时目录 (/tmp/gw-smoke-xxxxx) 做 SQLite 文件,跑完 rm -rf,
不污染开发数据: 它也用一组独立端口 (13000 / 14010 / 15010), 不跟你正在开发的本地实例
冲突。
smoke 在 CI 里就是「一次必跑的回归」: 每次合并 PR 前都跑一遍, 9 个能力全部健在才放过。 跑挂了直接看哪一步报错,每步都明确写出语义。
e2e-smoke.ts 内部用 child_process.spawn 起三个独立进程 (mock-upstream / mock-payment
/ gateway), 然后 Promise.race + 轮询 /healthz 等它们都 ready. 每个 child stdout
带前缀输出到主进程 ([mock-upstream] ...), 方便定位是哪一边的日志: 跑完时通过
SIGTERM 优雅关闭,不靠 kill -9 (后者会让 SQLite 来不及 flush WAL, 下次跑可能数据
不一致).
设计上有一个小细节值得说: smoke 不依赖任何网络。三家上游 (OpenAI / DeepSeek / Anthropic) 的 API key 留空也能跑通——所有调用都打到 mock-upstream 这个本地进程上,走完一整套 鉴权 / 计费 / 流式 / 看板逻辑: 这意味着 CI runner 可以完全离线,不需要为测试单独申请 真实 key + 不需要担心被上游限流。上线前再单独跑一笔「打真实 OpenAI」的烟雾测试就行, 那条不进 CI.
smoke 故意覆盖到的「易回归」场景,每一个都对应一类历史踩坑:
- 建 user 时自动建 wallet (step 3): v0.10 → v0.11 升级时,老 user 没 wallet 被 漏掉,第一次发请求 409. v1.0 在 admin/users POST 路由里强制 ensureWallet.
- mock 平台异步回调入账 (step 6): v0.11 时 PAYMENT_NOTIFY_BASE 在 docker compose 环境里写错主机名 (写 localhost 而非 gateway), mock 平台无法回调. smoke 的 step 6 是这条链路的回归保险。
- 流式 [DONE] 哨兵 (step 8): v0.7 加流式, v0.10 改 mock 上游 + cache 字段时漏 发 [DONE], 客户端等超时. smoke 显式断言 sawDone.
- dashboard JSON 字段 (step 9): v0.9 把
getTodaySummary().totalRequests字段 改名时,仪表盘前端没同步,看板空白. smoke 显式 assert >= 2. - 退款扣穿 (step 10): v0.11 设计「允许扣穿」是为了应对「用户已花掉部分余额」 的退款场景. smoke 故意先消费再退款,验证 wallet.balanceMicro < 0 而不是抛异常。
这些场景在不跑 smoke 的项目里通常不会自动暴露,而是要等到生产用户上报「我的钱怎么 没到账」「为什么充了钱看板没数」之类的问题,才反向定位到代码改动. CI 跑 smoke 把这 个反馈延迟从「天」缩短到「PR 提交后几分钟」.
12.7 短时压测: autocannon 30 并发
教学版的压测目的不是「跑一个高数字」,而是「证明 v1.0 在合理并发下不挂」. 选 autocannon
(repo) 因为它是 Node 原生 + 不需要额外安装,
直接 import autocannon from 'autocannon'.
src/scripts/stress.ts:
const result = await autocannon({
url: `${BASE}/v1/chat/completions`,
method: 'POST',
connections: CONCURRENCY, // 默认 30, env 调
duration: DURATION_S, // 默认 10s, env 调
headers: {
Authorization: `Bearer ${key.plaintext}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ model: 'mock-gpt-4o-mini', messages: [...], max_tokens: 4 }),
});实测一次 (10 秒 30 并发, mock 上游, SQLite 后端):
{
"stage": "result",
"duration_s": 10,
"concurrency": 30,
"requests": 2561,
"rps_avg": 256.11,
"latency_p50_ms": 106,
"latency_p99_ms": 402,
"errors": 0,
"non2xx": 0
}256 RPS, p50 106ms, p99 402ms, 0 错误. 这个数字不是绝对值,不要拿它跟生产网关比。 教学版的 mock 上游每条 chunk sleep 250ms (打字机效果), 跟真实 OpenAI / DeepSeek 的 延迟分布不一样。
要拿到更高的真实数字,跑 100 并发 30s:
STRESS_CONCURRENCY=100 STRESS_DURATION_S=30 npm run stressenv 名称 STRESS_CONCURRENCY / STRESS_DURATION_S 定义在 src/scripts/stress.ts 顶部 (约 L10), 改名时要同时改这两个地方。
要逼出 SQLite 的写锁瓶颈,把 MOCK_CHUNK_INTERVAL_MS=10 让 mock 上游打字快一点,然后
看 RPS 上升到 1000-2000 后就上不去了——这是 SQLite 单写者模型的天花板,突破要切 Postgres.
真实生产压测建议跑 wrk2 (--rate 控速率。或 k6 (stages 阶梯式增压), autocannon 在
教学版里够用。
压测里要看的几个数字,每一个都对应一类生产问题:
- errors / timeouts: 必须 0. 任何非 0 都意味着代码层面有 bug 或资源耗尽 (文件句柄、 内存、连接池).
- non2xx: 限流 / 余额不足 / channel 全挂会让请求返 4xx / 5xx, 数字大说明业务策略 设得太紧或上游不健康。不一定是 bug.
- p99 latency: 与 p50 的比值 (尾延迟比。通常生产健康线是 < 5x. 我们的实测 402/106 ≈ 3.8x, 在线。比值过高意味着 GC / SQLite 写锁 / 上游慢请求拖累了部分请求。
- rps_avg vs rps_max: 我们没单独输出 max, 但 autocannon 的明细里有。如果 max >> avg 说明 throughput 抖动大,有「成片成片的等待 + 成片成片的处理」,通常是事件循环被 同步阻塞 (例如 better-sqlite3 同步写吞掉了一段时间的 IO).
把这几个数字的基线记在仓库里 (例如 docs/baseline.md), 每次大改后跑 stress 对比基线,
能在合并代码前发现性能退化。这是教学版没做但生产强烈建议的工程动作。
12.8 三种部署目标: Node / Bun / Cloudflare Workers
v1.0 的代码在三种目标上都能跑,但每种目标对外部依赖 (better-sqlite3 native /
in-memory cache / 长连接 SSE) 的兼容程度不同,改动量也不同。完整对比表见
examples/12-ship-it/DEPLOYMENT.md. 这里给出选型决策:
Node.js + 单机 (推荐起点). 改动量 0, v1.0 默认就是这条路径. PM2 跑 cluster 模式
注意 SQLite 的多进程行为——SQLite 文件的多进程读写没问题 (文件锁串行化写), 但 in-memory
状态 (限流 / 看板缓存。不跨进程。真要跨进程上 Redis. 上 Nginx 反代时必须 proxy_buffering off
proxy_read_timeout 600s, 否则 SSE 流式被缓冲或中途切流。
Bun + 单机. 改动量小. Bun (bun.sh) 的 fetch / TS 内置带来 30-50%
RPS 提升,但 better-sqlite3 在 Bun 上偶有 native binding 加载问题
(Bun docs - bun:sqlite). 推荐方案 A: 在
src/db/client.ts 加 adapter 层,探测到 Bun 时用 bun:sqlite. v1.0 教学版没附带这个
adapter, 留作扩展。
Cloudflare Workers + D1. 改动量大. Workers 的运行时 限制 决定了至少 7 处必改:
better-sqlite3完全不可用,必须换 D1 (Drizzle + D1 适配).pino文件日志换console.log+ Logpush.- SSE 长连接受 CPU 时间 30s (付费 5min) 限制,大流式要拆。
- in-memory 限流换 Durable Objects 或 KV.
setInterval健康检查换 Cron Triggers.node:crypto换 Web Crypto.- Subrequest 50 个并发限制 (我们 fallback 最多 3 attempts, 远低于此,不影响).
适合什么场景: 流量分布广 (北美 + 欧洲 + 亚太都要低延迟) + 流量极不均 (白天高晚上几乎 为零,不想为闲置容量付费). 不适合。大流量稳定 (Worker request 单价 + D1 行单价加起来 比单机 vps 贵).
简短决策树:
- 现在就要上线 + 团队 < 50 人: Node.js + systemd. v1.0 本身。
- 追求 runtime 性能 + 团队接受 Bun: Bun + 单机。加一个 db client adapter.
- 流量全球分布 + 不想运维: CF Workers + D1. 改动大,长期收益高。
绝大多数读者起步就用 Node.js + 单机。跑到 SQLite 的并发瓶颈 (1-2k QPS, 取决于写比例) 再考虑切 Postgres 或拆服务。
下面就 Node.js + 单机这条路径再展开两个真实生产场景的细节,因为这是绝大多数读者会 直接踩到的。
systemd 配置的细节. v1.0 的服务文件在 DEPLOYMENT.md 里有完整版本,几个关键字段 值得展开:
Type=simple: 进程一启动 systemd 就认为「服务起来了」,不依赖任何 ready 信号。 对 Node 进程来说够用,因为 Node 的 listen() 是同步的,一行serve()之后服务 立即可用。Restart=on-failure+RestartSec=5: 进程因任何原因 exit code != 0 时自动重启, 间隔 5 秒。避免 crashloop 把日志刷爆 (每秒重启 vs 每 5 秒重启在故障期间是十倍量 级的差异).EnvironmentFile=/opt/llm-gateway/.env: 把 .env 交给 systemd 加载,不需要自己 source. 这条让 .env 与代码分离,升级版本时不动 .env, 降低误操作。StandardOutput=journal: 日志走 systemd journal,journalctl -u llm-gateway -f实时看。比自己写文件 + logrotate 简单。真要把日志推到 ELK / Loki, 加一个journaldoutput plugin 即可。
Nginx 反代的细节. SSE 流式不能被 Nginx 缓冲, nginx.conf.example 里有完整配置
但有两条最关键:
location /v1/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_buffering off; # (new) SSE 不能缓冲
proxy_read_timeout 600s; # (new) 长流式不能中途切流
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}proxy_buffering off 不开会发生什么: Nginx 默认把上游的响应缓存到 4KB 以上才下发,
SSE 的每条 chunk 通常 < 100 字节,客户端要等 N 条 chunk 累积到 4KB 才看到第一字节。
对话式 UI 看到的就是「等几秒突然全部跳出来」,完全失去打字机效果。
proxy_read_timeout 600s 不调会发生什么: Nginx 默认 60 秒,流式响应只要在 60 秒内
没有任何字节传输就被切。长一些的回答 (例如代码生成 800 token) 单条 chunk 间隔可能
60 秒,主链路就突然 502.
这两条 Nginx 配置占了绝大多数「v1.0 部署上线后流式异常」的根因。上 Nginx 之前先
直连测试 (跑 npm run smoke 第 8 步,流式过), 上 Nginx 之后再测一次,任何不一致
都按这两条排查。
Bun 的现状细节. Bun 1.x 的 fetch 实现确实更快,主要因为它用 BoringSSL + 自家 HTTP 解析器替代了 Node 的 undici. 但 LLM 网关的瓶颈在上游延迟 (一次 chat completion 通常 1-30 秒), runtime 开销占比 < 1%, 所以 Bun 的 30-50% RPS 提升对实际系统体感 不明显。真正受益的是「冷启动延迟」——Serverless 场景下 Bun 比 Node 快 200-400ms, 但我们 v1.0 是常驻进程,冷启动只发生一次,也不太关心。
值得 Bun 的两类场景: (1) 已经在用 Bun 的团队,不要为这一个网关单独引入 Node 心智
负担; (2) 极度追求开发体验 (Bun 的 bun --watch、test runner 都很顺手。的小团队。
其他场景 Node 完全够用。
Cloudflare Workers + D1 的现状细节. v1.0 教学版没有附带 wrangler.toml + D1 改造 的完整代码,这条路径的工作量基本等于「重写持久层 + 改一些 N-API 调用」. 但只要走 完上面的 7 处必改,我们的业务逻辑 (鉴权 / 计费 / 限流 / 渠道管理。几乎可以原样保留—— 这是 IR + Adaptor + 抽象层设计的红利. Drizzle ORM 切换 SQLite ↔ D1 ↔ Postgres 只动 client 初始化, schema.ts / queries 不动。想接 Workers 的读者按 DEPLOYMENT.md 的清单 逐项改,大约 1-2 周能把第一个版本跑起来。
12.9 续作路线图: v2.0 该解决什么
v1.0 已具备上线能力,但仍有不少工程问题留作 v2.0:
多区域部署 (跨大区延迟). 单机部署对国内外用户的延迟差异显著——美国服务器对国内 用户的 RTT 通常 200-300ms, 流式响应的首字节延迟劣化明显。多区域要解决。按用户地理位置 路由到最近的网关、跨区域 channel 池共享、跨区域账单聚合等。工程量大. CF Workers + D1 是其中一条路径。
审计合规 (SOC2 / 金融客户). 网关一旦服务金融 / 医疗等行业客户,会被要求 SOC2 Type II 审计。主要改动是。所有金额变动写不可变审计日志 (我们的 usage_records 已经接近这个语义, 但还需要追加防篡改的 hash 链), admin 操作全部留痕,数据加密 (at-rest + in-transit), 访问控制矩阵。这条路径不增加业务能力,但增加非常多的代码量。
多租户子账户 (每个外部客户独立子账户). v1.0 的 org 是单层的——一个 org 直接挂用户。 真实 SaaS 卖 token 业务里,每个外部客户 (公司。是一个 org, org 下还要细分子账户给客户 公司的不同部门 / 项目,每个子账户独立计费 / 独立配额 / 独立 admin. 数据模型变成 org → sub-account → user → key 四层。配额传递、子账户间转账、跨子账户聚合账单是新工作量。
Postgres 切换 (突破 SQLite 单文件并发). SQLite 单写者模型在 1-2k QPS 后就吃满写锁,
上 Postgres 是必经路径: 因为我们用 Drizzle ORM, schema 层切换工作量小,但有些细节要重写:
SQLite 的「全库写锁 + 乐观锁单 SQL」在 Postgres 是「行级锁 + 真正的并发」,行为略不同;
SQLite 的 INSERT ... RETURNING 在 Postgres 上对应 INSERT ... RETURNING 没问题,但
某些 RAISE EXCEPTION 模式要换。计费 / 钱包模块完全可以保持 API 不变。
Redis 切换 (限流 / 缓存跨进程共享). 单机扩成多机后,限流必须跨进程. Redis (或更 轻量的 KeyDB / Dragonfly) 是标准答案. v0.6 的 RateLimiter 接口已经预留了 adapter 扩展点,加一个 RedisRateLimiter 实现就好. TPM 预扣的「乐观锁两阶段」在 Redis 上用 Lua 脚本能搞定,不会引入新难题。
MCP server 对接. Anthropic 的 MCP (Model Context Protocol) 规范 2025 年起在 Claude
生态推开。把 LLM 网关包装成 MCP server, 让 Claude Code / Claude Desktop 等客户端能
直接调用网关里的工具 / 资源,是一条新增能力。不影响现有 OpenAI / Anthropic 协议路径,
独立挂一组 /mcp/* 路由就行. v2.0 的 nice-to-have.
MCP 在网关侧落地的几个具体动作: 暴露 list_tools (返回这个网关提供哪些工具,例如
「按 trace_id 反查请求」「按 user 看消费详情」「按 model 算成本对比」), 暴露 list_resources
(每个 channel / 每个 user / 每条最近请求都是一个资源, MCP client 可以拉起来直接看),
以及暴露 prompts (例如「为某个客户的对话历史做摘要」这类常用提示词). 这条路径让
Claude Desktop 用户不离开他们的 IDE 就能管理整个网关——对内部工具型团队来说体验跃升。
除了上面六个明显方向,还有一类不大不小的工程改进。把 v1.0 的「目录化 schema」彻底
collapse 成「class-based service」. 现在我们的 wallet/service.ts 暴露的。函数列表
(deductBalance / creditBalance / handleRechargeCallback / handleRefundCallback),
这种风格在小团队里读起来直接,但当业务方法到 30 个以上时, namespace 会发散。切到
class-based service (例如 WalletService.deduct(walletId, amount)) 能让 IDE 跳转和
单元测试 mock 都更顺手。这种重构不影响行为,但能让代码长期可维护性更好——大约 v2.0
中期会面对。
这些都是工程问题,不是科研问题: 投入足够时间都能解决。但对小团队 / 创业者来说, v1.0 已经够用——10 人左右的内部 LLM 平台,或者月活几千的对外中转站, v1.0 的能力清单已经 覆盖核心需求。把 v2.0 的功能压到 v1.0 里完成不仅会让书臃肿,也会让初次部署的读者疲于 理解次要细节。
「v1.0 已经够用」这句话不是营销话术,它是有具体数字支撑的。实测 v1.0 在 mock 上游
- SQLite 后端 + 30 并发下能跑到 256 RPS, 0 错误: 真实 OpenAI 上游的延迟通常 2-30 秒, 单实例并发上限被上游响应时间拉低,实际 QPS 能到 50-200. 一个月活 1 万的中转站,假设 每个用户每天 10 次调用,月调用量 = 1 万 × 10 × 30 = 300 万次,折合 QPS ≈ 1.2. v1.0 的容量是这个数字的 50-150 倍,完全不需要担心架构层面的扩容。真要担心的是上游账户额度、 合规、客服响应速度——这些都不是网关代码能解决的问题。
配套代码
完整可运行的 v1.0 代码在 examples/12-ship-it/. 完整目录:
examples/12-ship-it/
package.json # 整合所有依赖 + 新加 build/start/migrate/smoke/stress/docker:* scripts
tsconfig.json
.env.example # 完整环境变量清单 (zod 校验输入源)
Dockerfile # 多阶段构建 (deps → builder → runner)
docker-compose.yml # gateway + mock-upstream + mock-payment-platform 一键起整套
.dockerignore
drizzle/
0001_init.sql ... 0007_payment.sql # 沿用 v0.4 ~ v0.11 的 7 份, 历史读者也能跑
0008_v1_release.sql # v1.0 release marker
src/
config/
env.ts # (new) 新增: zod schema 校验所有 env, 启动时一次性检查
defaults.ts # (new) 新增: 默认值集中管理
health/
liveness.ts # (new) 新增: /healthz (k8s liveness)
readiness.ts # (new) 新增: /readyz (k8s readiness, DB + channels + wallets 三检查)
index.ts # (new) 改: 启动时调 loadEnv() + 挂 /healthz /readyz, 旧 inline /healthz 搬 /admin/info
(其余沿用 v0.11)
scripts/
e2e-smoke.ts # (new) 新增: 一次跑完整请求生命周期 (10 步, 自动起依赖)
stress.ts # (new) 新增: autocannon 短时压测
(其余沿用 v0.11: mock-upstream / mock-payment-platform / payment-flow-test 等)
README.md # 完整部署手册 + 9 项核心能力对照
DEPLOYMENT.md # (new) 新增: Node / Bun / CF Workers 三种部署目标对比 + trade-off启动方式 (二选一):
docker compose (一条命令起整套):
cd examples/12-ship-it
cp .env.example .env # 至少改 ADMIN_TOKEN
docker compose up --build本地 Node (不用 docker):
cd examples/12-ship-it
cp .env.example .env # 至少改 ADMIN_TOKEN
npm install
npm run migrate # 0001..0008, 共 8 份
npm run mock & # mock LLM 上游, :4010
npm run mock-pay & # mock 支付平台, :5010
npm run start # 主网关, :3000跑 e2e 冒烟 (任一启动方式都适用):
npm run smoke
# === e2e-smoke PASSED === {"checks_passed":10,...}跑短时压测:
npm run stress
# {"stage":"result","requests":2561,"rps_avg":256.11,"latency_p99_ms":402,"errors":0}构建 docker 镜像:
npm run docker:build
# llm-gateway:v1.0更详细的部署手册 (PM2 / systemd / Nginx 反代 / Cloudflare Workers 改动清单。见
examples/12-ship-it/DEPLOYMENT.md.
续作路线图
第 1 章承诺过用 TS 从 0 搭一个能覆盖企业基建与对外卖 token 双场景的最小可上线网关。
v1.0 兑现了这个承诺。对照 book.meta.yaml 的 minimal_prototype 清单, 9 项能力全部
落到代码:
- 多上游路由 (Ch2)
- 协议转换 (Ch3)
- API Key 鉴权 (Ch4)
- 计费 (Ch5)
- 限流 (Ch6)
- 流式 (Ch7)
- 渠道管理 (Ch8)
- 可观测性 (Ch9)
- 成本优化 (Ch10)
加上 v0.11 的钱包 + 支付链路,这是一个今晚就能交付出去的网关。跑 docker compose up 起整套,配上你的真实上游 key, 改 ADMIN_TOKEN, 上 Nginx 反代,一个能用的中转站就上线了。
如果要把它推到 v2.0, 上面 12.9 节列的六个方向是优先级最高的工程问题: 但对于绝大多数 读者——准备给公司搭内部 AI 网关的工程师,想做对外卖 token 创业的开发者——v1.0 的能力 清单已经覆盖了起步阶段所有的核心需求。
最后说一句感谢。跟着这本书一路从 30 行的透传写到 v1.0 的可上线原型,是一段不短的旅程。 中间有 6 张数据库表的领域建模, 11 份独立 example 的工程演化,几千行 TypeScript 代码的 实际产出。如果你跟到这里,那么你不仅理解了 LLM 中转站每一层的设计取舍,也手握一份能 独立运行 / 二次开发 / 长期演进的代码骨架。这个骨架未必能覆盖你具体业务的所有需求,但 它给出了一个干净的起点——比从零写起省下数周时间,比直接 fork 一个庞然大物易于改动。
写到这里,这本书要交付给读者的内容到此为止。余下的工作是你自己的——把它部署到你的 服务器,接上你的真实上游,配上你的客户,让它跑起来。祝顺利。
最后留一份「上线检查清单」,真实上线前过一遍。不是流程教条,是踩过坑后回头总结的 最低门槛:
-
.env里的ADMIN_TOKEN已经改成强随机字符串 (至少 32 位), 没留默认admin-change-me. - 至少配了一家真实上游的 API key, 并且通过 admin/channels 接口注册成 active channel.
- 跑过一次
npm run smoke, 10 步全过。 - 跑过一次
npm run stress, errors = 0. - Nginx 反代配了
proxy_buffering off+proxy_read_timeout 600s(流式必备). - /healthz 与 /readyz 在外网可访问,但 /admin/* 走 Bearer 鉴权 (公网能 curl 但不能 绕过 admin token).
- 看板鉴权 token 不要等于 ADMIN_TOKEN (万一泄露范围更小), 单独配一个 dashboard token.
- 数据备份策略明确: SQLite 文件每天凌晨 cron 备份到对象存储 (R2 / S3), 至少保留 30 天.
- 监控告警配上: /readyz 5 分钟 503 → 告警, channel active 数 = 0 → 告警,看板 today_cost_micro 超阈值 → 告警 (防止某个用户失控刷上游费用).
清单上每一条都对应一类真实事故: 全部打勾再上线,心里踏实很多。
本章来自《AI Token 中转站实战:从 0 搭建企业级 LLM 网关》开源版 · 作者「递归客」
在线阅读完整书系:inferloop.dev
源码仓库:github.com/diguike/book-llm-gateway
本书资源
- 源码仓库 · github.com/diguike/book-llm-gateway
- 在线阅读 · inferloop.dev/llm-gateway
- 所有书目 · inferloop.dev
继续阅读 · 同作者其他书
- 《Transformer 工程实战》从注意力机制到生产部署
- 《自己动手写 AI Agent》从 Claude Code 开源架构到你的第一个编程助手
- 《AI 时代的 CLI 工具开发实战》用 TypeScript 构建现代 CLI 工具
- 《LLM Infra 工程实战》从入门到实践
- 《Hermes Agent 实战》构建会成长的个人 AI Agent
- 《OpenClaw 源码解析》现代 Agent 系统的架构设计与工程实践
- 《Agent Memory 工程实战》从 claude-mem 源码到企业级记忆平台
- 《LangChain.js Agent 开发权威指南》从 1.x 抽象到生产级 Agent
- 《百万级 AI Agent 平台架构》智能客服 SaaS 实战
- 《Claude Code Skill 指南》
- 《Claude 插件官方指南》