数据截至 (上游 commit e55b2a12c9a5)
模型这条路:一套管线、两种部署、一笔账
30 秒导读: agent 每说一句话都要花钱。这一章讲 Kortix 怎么把「谁在调模型、用谁的 key、走哪个上游、这次多少钱」这四件事,收敛成一条请求管线 + 一个控制面;并且讲清楚它为什么要把这条管线同时跑在 API 进程里和一个独立 pod 里。
本章覆盖模型面与计费面。不讲工具调用怎么执行(见 05-executor-connectors)、也不讲沙箱里 opencode 是怎么被拉起来的(见 03-sandbox-runtime)。
1. 先建立直觉:这是一个"收费站"
把它想成高速公路上的收费站。
- 车 = 一次 chat completion 请求(从沙箱里的 opencode 发出)。
- 收费站 = LLM 网关。它验票(你是谁)、查余额(还有钱吗)、查限额(这个项目这个月的预算用完没)、指路(该开哪条上游车道)、最后按里程记账。
- 绕行的小路 = 直接把
ANTHROPIC_API_KEY塞进 opencode 的环境变量,让它自己去连 Anthropic。
整套设计的核心动作只有一个:把所有小路堵死,让每一辆车都必须过收费站。 后面 §3 会看到 Kortix 是怎么在沙箱里物理地拆掉那条小路的。
一句话定位: POST /v1/llm/chat/completions 是一个 OpenAI 兼容端点,它对外长得像 OpenAI,对内可以把请求翻译成 Anthropic on Bedrock、OpenRouter、或 ChatGPT 的 Responses API。
2. 顶层全景:一次推理的三段路
怎么读这张图: 从左往右是一次请求的时间顺序;上面那条是"钱和身份"(控制面),下面那条是"报文"(数据面)。
沙箱 (opencode)
│ Authorization: Bearer <executor token>
│ POST /chat/completions {model:"glm-5.2", stream:true}
▼
┌─────────────────────────────────────────────────┐
│ @kortix/llm-gateway 管线 │ ← 唯一一份实现
│ │
│ ① 准入 authenticate → billing → budget │
│ ② 解析 requested model → 有序候选列表 │
│ ③ 发车 failover(retry + 熔断) → 上游 │
│ ④ 转播 SSE 中继 + 10s 心跳 │
│ ⑤ 结算 抽用量 → 算钱 → 记账 + 落 trace │
└───────────┬──────────────────────┬──────────────┘
│ hooks(控制面调用) │ transport(数据面)
▼ ▼
apps/api/src/llm-gateway Bedrock / OpenRouter
hooks.ts(唯一真源) / ChatGPT / 用户自己的 key
DB:预算、密钥、密文、
usage_events、账本
管线的五步在 packages/llm-gateway/src/pipeline/handler.ts:238 的 handleChatCompletions 里一眼可见:准入(admit)、解析(hooks.resolveUpstream)、发车(runFailover)、转播(relayStream)、结算(settle)。
部件职责一览:
| 部件 | 干什么 | 在哪 |
|---|---|---|
| 管线包 | 请求全流程:准入、失败转移、熔断、SSE 中继、用量抽取 | packages/llm-gateway/src/ |
| 控制面 | 认证、计费、预算、候选解析、记账、落 trace | apps/api/src/llm-gateway/hooks.ts |
| 内嵌挂载 | 把管线跑在 API 进程里,hooks 直接函数调用 | apps/api/src/llm-gateway/wire.ts:381 |
| 独立 pod | 把同一管线跑在单独进程,hooks 走 HTTP RPC | apps/llm-gateway/src/server.ts:39 |
| RPC 端点 | 控制面的 HTTP 包装,给独立 pod 用 | apps/api/src/llm-gateway/internal-routes.ts:27 |
| 反向代理 | API 上的 /v1/llm-gateway/* → 独立 pod | apps/api/src/llm-gateway/wire.ts:543 |
| 目录数据 | 托管模型清单、BYOK 目录快照 | packages/llm-catalog/ |
3. 为什么一套管线要有两种部署
这是本章最值得学的一个决策。
3.1 问题:长流不该被滚动升级切断
一次带推理的流式对话可以跑几分钟。如果网关就住在 API 进程里,那么每一次 API 发版都会掐断所有在飞的流。API 是个高频改动的服务(路由、账单、项目管理全在里面),网关却需要长连接稳定。两者的发布节奏天然冲突。
于是:把管线再跑一份在独立 pod 里,独立扩缩、独立发版。
3.2 但绝不能变成两份实现
如果独立 pod 自己再写一遍认证和计费,两边就会漂移——线上很快会出现"内嵌路径扣了钱、独立 pod 没扣"这种事故。
Kortix 的解法:管线只有一份代码,控制面也只有一份代码,差别只在 hooks 怎么绑。
createGateway(hooks, config)
│
┌───────────────────┴───────────────────┐
│ │
内嵌(自托管/开发) 独立 pod(云上生产)
hooks = 直接函数调用 hooks = HTTP 调 /internal/gateway
│ │
└──────────► apps/api/src/llm-gateway/hooks.ts ◄──────────┘
authenticatePrincipal
assertGatewayBudget
recordGatewayUsage
persistGatewayTrace
- 内嵌绑定:
createInProcessGatewayHooks()(hooks.ts:202),七个 hook 全是本进程函数引用。 - 独立绑定:
createApiClient(...)(apps/llm-gateway/src/clients/api-client.ts:57),每个 hook 是一次 POST 到/internal/gateway/*。 - 两边的 hook 最终落到同一批函数上,
internal-routes.ts里每个 handler 都只是薄薄一层包装(比如/authorize就是直接await authorizeRequest(token),internal-routes.ts:51-62)。
3.3 跨进程的代价与那次合并
内嵌部署里,"认证 + 计费 + 预算"是三次本地函数调用,几乎免费。跨进程时它们变成三次串行 HTTP 往返,直接堆在首字节 延迟上。
所以 hooks 接口里多了一个可选的合并门 authorize(packages/llm-gateway/src/domain/hooks.ts:36):
- 独立 pod 提供它(
apps/llm-gateway/src/server.ts:64),三次 RPC 折成一次。 - 内嵌不提供,继续用三个细粒度 hook。
- 管线里
admit()负责在两种形态间抹平差异,无论走哪条,拒绝时返回的响应体和落的 trace完全一致(packages/llm-gateway/src/pipeline/handler.ts:176-236)。
这是一个很干净的模式:把"可以合并的优化"做成可选 hook,而不是分叉出第二条代码路径。
3.4 两种部署的差异清单
| 维度 | 内嵌 /v1/llm | 独立 pod apps/llm-gateway |
|---|---|---|
| 进程 | API 进程内 | 单独 Bun 进程 / 单独 pod |
| hooks 绑定 | 直接函数调用 | HTTP → /internal/gateway/* |
| 准入 | 三个细粒度 hook | 合并 authorize 一次 RPC |
| 客户端怎么到达 | KORTIX_URL/v1/llm | KORTIX_URL/v1/llm-gateway/v1/llm 反代,或直连 |
| trace 去向 | 只写 gateway_request_logs | 同时写 DB + Langfuse(配了 key 时) |
| 健康检查 | /v1/llm/health,一句 ok | /health/live + /health(依赖检查、熔断器状态、滚动错误率) |
| 适用 | 自托管 / 开发 / 兜底 | 云上生产 |
独立 pod 的深度健康检查值得一看:它把"API 是否可达""哪些上游熔断器是 open""最近 300 秒错误率是否超过 50%"合成一个 status,不健康时返回 HTTP 503,让监控只看状态码就能报警(apps/llm-gateway/src/server.ts:132-180)。错误率还设了最小样本量 20,避免低流量时几个错误就误报(server.ts:15-16)。
反代那一侧也做了防御:独立 pod 不可达时返回 502 gateway_proxy_unreachable,而不是让 fetch 的 rejection 裸奔;LLM_GATEWAY_PROXY_TARGET 只接受 http/https,配错就直接关掉代理而不是转发到任意主机(wire.ts:527-569)。
3.5 一个容易忽略的细节:Bun 的 10 秒空闲超时
独立 pod 用 Bun.serve 起服务,而 Bun 默认 idleTimeout 是 10 秒。推理模型两个 token 之间停 12 秒是常事,socket 就被杀了,opencode 那边看到的是 "Connection reset by server"。
apps/llm-gateway/src/main.ts:7-25 把它顶到 255(Bun 上限),同时管线自己每 10 秒发一次 SSE 心跳(§7)。两道保险:心跳保证不空闲,超时上限做兜底。
4. 那道刻意的扣押:为什么沙箱里必须没有 provider key
4.1 问题:opencode 太"聪明"了
opencode 有个行为:只要进程环境里出现 ANTHROPIC_API_KEY 这类变量,它就自动接上原生 provider,直接打 Anthropic。 网关被完美绕过——没有日志、没有预算、没有扣费,而且用户在控制台"断开"BYOK 之后,沙箱里那个模型还在。
4.2 解法:按目录算出黑名单,交给守护进程执行
apps/api/src/llm-gateway/sandbox-credentials.ts:12-23 从 LLM 目录快照里把所有 provider 声明的环境变量名收集成一个集合(按目录 revision 缓存):
// 示意,已按当前源码改写 —— providerCredentialEnv()
const names = new Set<string>();
for (const provider of runtimeModelCatalog.snapshot().providers) {
for (const envVar of provider.env ?? []) names.add(envVar);
}
注意这是从数据推导的,不是手写清单——目录里新增一个 provider,黑名单自动跟上。
链路是这样落地的:
API 侧 沙箱侧
───── ──────
nativeProviderEnvNames()
→ "ANTHROPIC_API_KEY,OPENAI_API_KEY,..."
│
├─ 开机注入 KORTIX_OPENCODE_DENY_ENV (projects/lib/sessions.ts:270)
│ │
└─ 热更新 POST /env {llmGatewayDenyEnv} │ (routes/env.ts:72 applyLlmGatewayMode)
▼
守护进程拉起 opencode 之前:
逐个 delete env[name]
(opencode.ts:608-617)
关键点:provider key 仍然进得了沙箱容器(agent 自己写的代码可能要用),被扣掉的只是 opencode 这个子进程的环境。守护进程日志会打印 withheld N provider credential(s) from opencode (gateway-only routing)。