数据截至 (上游 commit 0004b748b71c)
模型层:500+ 模型怎么统一,以及一套自研的原生运行时
30 秒导读: 会话主循环只会做一件事——「拿着一堆消息去问模型」。但「模型」这个词背后是 500+ 个型号、几十家厂商、六种 wire 协议、三套认证方式。这一章讲 Kilo Code 怎么把它们全部压成同一个函数调用,以及为什么它还额外自研了一套 LLM 运行时。
1. 这是什么(零基础也能懂)
一句话定义: 模型层是位于「会话循环」和「厂商 HTTP API」之间的适配层,负责回答四个问题——有哪些模型、这个模型怎么连、这个模型支持什么、这次请求该发什么参数。
它要解决的痛: 你在 CLI 里敲 --model anthropic/claude-sonnet-4-6,明天换成 openai/gpt-5.2,后天换成 kilo/moonshotai/kimi-k2。这三次切换背后差异极大:
| 差异点 | anthropic | openai | kilo 网关 |
|---|---|---|---|
| SDK 包 | @ai-sdk/anthropic | @ai-sdk/openai | @kilocode/kilo-gateway |
| 入口方法 | sdk.languageModel(id) | sdk.responses(id) | sdk.languageModel(id) |
| 认证 | API key header | API key 或 ChatGPT OAuth | Kilo token + 组织 id |
| 思考力度参数 | thinking.budgetTokens | reasoningEffort | reasoning.effort |
| 缓存标记 | 消息级 cacheControl | 服务端隐式 | 内容级 cacheControl |
上层代码不该知道这五行。 模型层的职责就是把这张表吃掉,对外只暴露一个 LanguageModelV3。
用起来什么样。 从用户视角,切换模型只是换一个字符串:
$ kilo run --model openai/gpt-5.2-codex "把这个函数拆成两个"
$ kilo run --model kilo/anthropic/claude-opus-4-8 "同样的活"
这个 provider/model 字符串在代码里由一个 5 行函数拆开(packages/opencode/src/provider/provider.ts:2120 parseModel),后面所有事情都从这两个 id 展开。
一句话直觉: 把模型层当成万能电源转接头 + 一张随身携带的规格表。转接头解决「插得上」(SDK 装载与认证),规格表解决「插上之后能给多少伏」(能力探测与参数整形)。
2. 顶层全景(它大概怎么转)
这条链路从左到右单向流动,每一格只解决一个问题,前一格的产物是后一格的输入:
┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐
│ ① 目录 │ → │ ② 解析 │ → │ ③ 装载 │ → │ ④ 整形 │ → │ ⑤ 运行时 │
│ 有哪些模型 │ │ 选中哪一个 │ │ 怎么连上去 │ │ 发什么参数 │ │ 谁去发请求 │
└────────────┘ └────────────┘ └────────────┘ └────────────┘ └────────────┘
models.dev parseModel BUNDLED_ transform.ts AI SDK
+ Kilo 网关 getModel PROVIDERS request.ts ─或─
+ 本地 config getLanguage 懒加载表 packages/llm
(opt-in)
│
▼
LLMEvent 流
怎么读这张图: ①②③ 一次性把「模型」变成一个可调用的 SDK 对象并缓存住;④ 每次请求都重新算一遍;⑤ 是两条并存的通道,但吐出的事件流是同一种。
各部件的一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
| 上游目录 | 抓 models.dev 的 api.json,磁盘缓存 + 每 60 分钟刷新 | packages/core/src/models-dev.ts |
| 目录合并 | 把 Kilo 网关模型、Apertis 模型叠加进上游目录 | packages/opencode/src/provider/models.ts |
| 模型缓存 | 网关模型的 5 分钟 TTL 缓存与失败记录 | packages/opencode/src/provider/model-cache.ts |
| Provider 注册表 | 目录 → env → apikey → 插件 → custom loader → config,六轮叠加 | packages/opencode/src/provider/provider.ts |
| SDK 懒加载表 | npm 包名 → createXxx 工厂的动态 import() | 同上,BUNDLED_PROVIDERS |
| 认证 | OAuth 授权/回调编排 + provider 插件的 fetch 改写 | provider/auth.ts、plugin/* |
| 请求整形 | 按模型 id / 发布日期推导温度、思考档位、缓存标记 | packages/opencode/src/provider/transform.ts |
| 请求准备 | options 三层 merge、plugin 钩子、归因 header | packages/opencode/src/session/llm/request.ts |
| 运行时选择 | 判定原生运行时是否支持,不支持就回退 | packages/opencode/src/session/llm/native-runtime.ts |
| 自研运行时 | schema-first 的 protocol / endpoint / auth / framing 四轴分解 | packages/llm/ |
3. 目录:500+ 模型从哪来
3.1 三个来源叠成一张表
要解决的小问题: 「有哪些模型可选」这件事,没有任何单一权威。开源目录 models.dev 知道公开模型的价格和上下文长度,但不知道你的 Kilo 账号能用哪些;你的 config.json 里可能还挂着一个自建的 vLLM。
思路: 分层叠加,后来者覆盖先来者。
models.dev/api.json ← 公共目录(磁盘缓存,60 分钟刷新)
│
├── overlay(...) ← Kilo 自家的 desktop overlay
├── providers.kilo ← 网关实时拉取(5 分钟 TTL)
└── providers.apertis ← 同上
│
▼
catalog: Record<ProviderID, Info>
上游目录的抓取在 packages/core/src/models-dev.ts:186 fetchApi:它去 ${source}/api.json 拿全量 JSON,写进 ~/.cache/.../models.json,然后用 Effect.cachedInvalidateWithTTL 做进程内永久缓存(models-dev.ts:202)。刷新是后台 fork 的 Schedule.spaced("60 minutes")(models-dev.ts:229)。
一个容易忽略的细节: 缓存文件的写入用了跨进程文件锁 Flock.effect(lockKey)(models-dev.ts:195,后台刷新那条路径在 :210 也上同一把锁),因为同一台机器上可能同时跑好几个 CLI 实例,都在抢这一个 models.json。
Kilo 自家的两个 provider 在 packages/opencode/src/provider/models.ts:45 的 get 里注入:先 delete providers.kilo 抹掉上游可能带的同名条目,再用 ModelCache 拉一次真实模型列表塞回去(models.ts:84)。拉空了就 fork 一个后台 refresh(models.ts:92),不阻塞启动。
3.2 模型的规格表长什么样
每个模型最终被归一化成一个 Provider.Model(provider/provider.ts:966)。核心字段分四组:
| 字段组 | 内容 | 谁在用 |
|---|---|---|
api | { id, npm, url } —— 真实模型 id、SDK 包名、base URL | SDK 装载(③) |
capabilities | reasoning / temperature / toolcall / attachment / 各模态输入输出 | 请求整形(④) |
limit / cost | 上下文窗口、输出上限、单价与缓存单价 | 上下文管理 |
variants | 思考档位(low / high / xhigh …)到 provider 参数的映射 | 请求整形(④) |
从上游 schema 到这个结构的转换在 fromModelsDevModel(provider.ts:1124)。注意最后一步:variants 不是抄来的,而是当场算出来的——ProviderTransform.variants(base)(provider.ts:1172)。这正是第 6 节要讲的「能力探测表」。
fromModelsDevProvider(provider.ts:1176)还会把上游的 experimental.modes 展开成独立的模型条目:一个 model.id 加一个 mode 后缀就变成一个新模型(provider.ts:1181),带自己的价格和 body 覆盖。这就是为什么模型总数会比厂商官网列的多。
3.3 注册表的六轮叠加
state 的构建(provider.ts:1254)是整个文件最长的一段。它不是「读配置」,而是六轮按优先级叠加,每轮都可能新增 provider 或改写已有 provider:
catalog(models.dev + Kilo)
│
├─ 1. plugin.provider.models() 插件重写模型列表 provider.ts:1311
├─ 2. config.provider 用户配置扩充/新建 provider.ts:1339
├─ 3. env 环境变量里有 key 就点亮 provider.ts:1439
├─ 4. auth(api 类型) 存过 API key 就点亮 provider.ts:1459
├─ 5. plugin.auth.loader() OAuth 插件注入 fetch provider.ts:1471
├─ 6. custom loader 21 个内置特判 provider.ts:1494
└─ 7. config 再刷一遍 让用户配置压过一切 provider.ts:1516
│
▼
providers(只保留「连得上」的)+ 逐模型过滤
最后一轮过滤(provider.ts:1547)做三件事:删掉 deprecated 状态的模型、在没开实验开关时删掉 alpha 模型(provider.ts:1568)、应用用户的 blacklist / whitelist(provider.ts:1570)。一个 provider 被过滤到 0 个模型就整个删掉(provider.ts:1590)。
为什么第 2 步和第 7 步都是 config? 因为第 2 步是「用 config 扩充目录」(新增模型条目),第 7 步是「用 config 覆盖 provider 元信息」(name / options / env)。中间夹着的 env、auth、插件可能改写这些字段,所以 config 要再压一次。这里还有一处 fork 特有的修补:当 OAuth 插件和 config 同时存在时,source 不被改回 "config",以 免打断 OAuth 链路(provider.ts:1519-1522)。
3.4 三个查询入口
| 函数 | 输入 | 输出 | 位置 |
|---|---|---|---|
parseModel | "kilo/anthropic/claude-opus-4-8" | { providerID, modelID } | provider.ts:1974 |
getModel | providerID + modelID | Model 规格,或带模糊建议的 ModelNotFoundError | provider.ts:1783 |
getLanguage | Model | LanguageModelV3(可直接给 AI SDK) | provider.ts:1809 |
parseModel 只做一次 split("/") 然后把剩下的重新 join —— 所以 provider id 不能含斜杠,而 model id 可以(kilo/anthropic/claude-opus-4-8 会被拆成 kilo + anthropic/claude-opus-4-8)。
getModel 找不到时不是简单报错,而是用 fuzzysort 给三个最接近的候选(provider.ts:1221 modelSuggestions);如果连 provider 都不存在,还会退到 catalog 里再找一次(provider.ts:1787),这样「你装了但没连上」和「压根没这个模型」会给出不同的提示。
getLanguage 的缓存键是 ${providerID}/${id}(provider.ts:1812)——同一个模型在一个进程里只构造一次。
3.5 网关模型的缓存: 三个不显然的设计
model-cache.ts 只有 285 行,但塞进了三条值得抄的规则:
- 失败不缓存。
evaluate在 cause 出现时立刻invalidate(provider/model-cache.ts:234),所以一次网络抖动不会把「空模型列表」钉在缓存里 5 分钟。 - 版本号防覆盖。 每次
fetch/refresh先给 provider 的版本号 +1,commit时如果版本对不上就丢弃结果(model-cache.ts:219)。这挡住了「慢请求回来把新结果冲掉」。 - 缓存键包含凭据。
key()把baseURL/ 组织 id / token 一起编进键(model-cache.ts:186)——换账号等于换缓存槽,不会读到上一个账号的模型列表。
登录成功后主动清缓存这件事发生在认证侧:ProviderAuth.callback 最后一行 cache.clear(providerID)(provider/auth.ts:244)。
模型状态是一个四值枚举 ["alpha","beta","deprecated","active"](provider/model-status.ts:5),比上游的三值多一个 active 作为默认。