DeepSeek Harness 的模型接入分层:adapter、provider 与手写路由
翻 deepseek-harness 这个仓库的时候,最容易被绕进去的就是「模型接入」这一块。你会同时看到 LlmAdapter、provider、providers 字典、目录(catalog)里的 provider、还有设置页上手工加的 provider——四个词长得像,但在代码里管的完全不是一件事。这篇就顺着 packages/llm/ 下的源码走一遍,把这几层各自的职责边界拆开。
先说清限定:本文写的一切来自仓库快照的静态阅读。该仓库建立于 2026-08-13,我们采集时(2026-08-16)版本是 0.1.0-rc.5,GitHub 上没有任何 Release,README 自述处于开发者预览阶段并明写未来会有破坏兼容性的变更。下面提到的每一个字段名、错误码、默认值,都在这条限定之内。
这一层被切成了五个包
packages/llm/README.md 第 7 到 13 行给了一张表,列出五个子目录及其在 Cordis 里的 ctx key:
| 目录 | README 表格里的 Role 原文 | ctx key |
|---|---|---|
llm/ | LLM service and shared streaming vocabulary | ctx.llm |
token-meter/ | Replay-aware token measurement | ctx.tokenMeter |
llm-retry/ | Provider-scoped retry policy | 监听 agent/request-error |
llm-deepseek/ | Direct DeepSeek adapter | 注册到 ctx.llm |
llm-pi-ai/ | Multi-provider pi-ai adapter | 注册到 ctx.llm |
五个包的 package.json 我们逐个读过,name 分别是 @deepseek-ai/dsh-llm、@deepseek-ai/dsh-token-meter、@deepseek-ai/dsh-llm-retry、@deepseek-ai/dsh-llm-deepseek、@deepseek-ai/dsh-llm-pi-ai,版本清一色 0.1.0-rc.5。
先给一个体量感:截至 2026-08-16,packages/llm/ 目录下共 116 个文件,其中 .ts 文件 88 个、合计 20,414 行。按 glob 分开数,packages/llm/*/src/**/*.ts 是 46 个文件、8,040 行,packages/llm/*/tests/**/*.ts 是 41 个文件、12,349 行——测试代码的行数比 src 多。这只是文件计数,我们没有跑过其中任何一个测试。
这张表里第一行和后两行的分工,就是这一层最关键的一刀:按 README 的角色描述,llm/ 提供的是「LLM service and shared streaming vocabulary」,也就是类型词汇加一张注册表;真正被定性为 adapter、知道「请求长什么样、响应怎么解析」的,是 llm-deepseek/ 和 llm-pi-ai/ 这两个包。
ctx.llm:注册表 + 词汇,不碰网络
export class LlmRuntime extends Service 在 packages/llm/llm/src/index.ts:284,整个文件 947 行。它对外暴露的方法,在 docs/subsystems/llm-streaming.md 生成的 Cordis 目录里列了一整张表,挑其中与「谁来接这次调用」直接相关的几个:
registerAdapter(providers, adapter):把一个适配器实例绑到一组 provider 名上。文档写明重复的 provider 会抛DUPLICATE_ADAPTER,且是全有或全无——多路由注册只要有一个冲突,整次注册都不生效。返回值是一个带replace()的 disposer,随 fiber 释放。listProviders():按注册顺序返回脱附的 provider 元数据。registerConfigurableProviders(entries)/listConfigurableProviders():声明「可以通过配置激活」的路由目录,后者返回的条目里包含还处于休眠状态的。registerModelDiscovery(settingsNs, discover)/discoverModels(settingsNs, request):注意前者是按 settings 命名空间注册,不是按路由;文档在这里专门写了一句「nothing here reads or writes settings or credentials」。resolveCallConfig(config, signal?)/prepareCall(config, signal?)/stream(options):从一份 call config 解析到一次可执行的调用。
这批方法在参数不合法时抛的都是 LlmError。在 packages/llm/llm/src/index.ts 里 grep throw new LlmError,能数出 INVALID_ADAPTER、REGISTRATION_DISPOSED、DUPLICATE_ADAPTER、INVALID_DIRECTORY、DUPLICATE_DIRECTORY、INVALID_DISCOVERY、DUPLICATE_DISCOVERY、NO_DISCOVERY、INVALID_CATALOG、INVALID_MODEL_INFO、INVALID_MODEL_CONTEXT、INVALID_MODEL_MAX_TOKENS、INVALID_MODEL_REASONING、INVALID_PREPARED_CALL、NO_ADAPTER 这一串码。光看这份码表就能反推出这个 Service 在管什么:注册表的合法性、目录的合法性、模型元数据的合法性——没有一个码跟「请求失败了」有关,那类错误在适配器那一侧。
顺带一提 AdapterRegistrationHandle.replace(providers):文档(docs/subsystems/llm-streaming.md:327-345)写它先整体校验候选集合,冲突、非法名、坏元数据都抛错并保持当前路由不动;交换本身是一个同步段,请求观察不到空窗。有意思的是它允许传空数组,而初次注册不允许空。
适配器:抽象类里只有一个方法是必须实现的
LlmAdapter 的抽象类签名在 docs/subsystems/llm-streaming.md:660-701。providerInfo()、providerRetryPolicy()、listModels()、resolveModel() 都是可覆写的,abstract stream(options): AsyncIterable<StreamChunk> 是唯一必须实现的方法。
也就是说,写一个新适配器的最小义务,就是把供应商的流翻译成 StreamChunk 这个封闭判别联合。docs/cookbook/adding-an-llm-adapter.md:9-21 给的骨架只有几行:
class MyAdapter extends LlmAdapter {
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> { … }
}
export const name = 'llm-myprovider'
export const inject = ['llm']
export const Config: z<Config> = z.object({ apiKey: z.string(), … })
export function apply(ctx: Context, config: Config) {
ctx.llm.registerAdapter(['my-provider'], new MyAdapter(…))
}
同一篇文档第 23 行还补了三条约束:注册是 effect-based 的(HMR 安全);一个 provider route 只能有一个适配器,重复会抛错;密钥走 cordis 原生方式(schemastery Config 加环境变量兜底,从 cordis.yml 用 !!js process.env.MY_KEY 喂入),不许在代码里读临时的密钥文件。
docs/subsystems/llm-streaming.md:206-216 另有七条「每个适配器必须遵守」的条款,这里只挑与本文分层直接相关的两条:一是一次适配器调用等于一次供应商尝试,适配器要关掉库自带的重试,重试归 dsh-llm-retry 管;二是上下文溢出只有一个规范码 CONTEXT_WINDOW_EXCEEDED,消费方按 code 路由,绝不解析供应商返回的文本。流式协议本身与重试策略我们另有专门的篇目讲,这里不展开。
provider route 才是主键
这套体系里真正被当成主键用的,既不是包名也不是类名,而是 provider route 那个字符串。
dsh-llm-deepseek 只拥有一个 route:packages/llm/llm-deepseek/src/index.ts:47 写着 const PROVIDER = 'deepseek-official'。为什么不叫 deepseek?仓库里的 Agent Note .agents/notes/implemented/architecture/2026-07-30-web-config-plane.md:21 记着原因:pi-ai 的目录里已经正当地占用了 deepseek 这个名字作为聚合入口,所以这个直连适配器的 route 改名为 deepseek-official,并且原文写的是 pre-release stance、没有做别名兼容。
route 是主键这件事,在配置形状上体现得更明显。packages/llm/llm-pi-ai/src/config.ts:172-179 里,Config.providers 是一个以 provider route 为键的 dict,而不是一个数组。所以如果你在某个 profile 里再写一个 provider 字段,config.ts:276-291 会直接报错,提示它已经「moved to the providers dict key」;同一处还会拒绝 maxRetries 与 maxRetryDelayMs,提示改用 dsh-llm-retry。
docs/user/guide/providers.md:21-29 从使用者角度说了同一件事:手工加一个 provider 需要提供小写的 Provider ID、base URL、API 协议、凭据和至少一个模型,而 Provider ID 是永久的——请求、已保存的会话、模型默认值、凭据引用全都拿它当键,改名只能新增再删旧;显示名、baseURL、协议、凭据、模型列表则都可以改。
目录路由与手写路由的分界线:三种线协议
packages/llm/llm-pi-ai/src/provider.ts:47-51 的 PROTOCOLS 表只有三项:
| 协议名 | 对应实现 |
|---|---|
openai-completions | openAICompletionsApi |
openai-responses | openAIResponsesApi |
anthropic-messages | anthropicMessagesApi |
supportedProtocols()(provider.ts:61)就是 Object.keys(PROTOCOLS),config.ts:235 直接用 z.union(supportedProtocols()) 当作 api 字段的取值域。
为什么只有三个?provider.ts:32-45 的注释解释得很直白:Bedrock 要用 AWS 凭据加 region 做 SigV4 签名,Vertex 要 project / location / ADC,Azure 要在 provider 环境上再加 api-version,Codex 走 OAuth——这套配置形状表达不了这些认证方式,所以「offering them would hand back a provider that cannot authenticate」。注释还补了一句:其余协议的缺席「是因为还没有消费方,不是因为有障碍」,一旦某个部署需要,在这里加一行即可。
这条注释里最容易被读漏的是后半句:目录路由仍然可以通过自带的 provider 走到全部协议,被拒绝的只是显式的 override。换句话说,三协议这个限制约束的是「你手写的那条路由」,不是整个适配器的能力范围。
这条分界线在模型发现上也是一致的。packages/llm/llm-pi-ai/src/discovery.ts:38-41 的 LISTABLE_PROTOCOLS 只有 openai-completions 与 openai-responses 两项,其余抛 DISCOVERY_UNSUPPORTED(:226-231);未指定协议的草稿默认按 openai-completions 询问(:225)。而目录路由是短路的:discovery.ts:201-211 里,若 catalogModels(provider) 非空,直接从安装目录返回,根本不发网络请求。docs/user/guide/providers.md:92 的用户侧说法是同一件事:模型发现调的是 OpenAI 兼容的 GET /models,不提供该端点的就手动填模型。
顺带记一个实现细节:discovery.ts:86-88 拼 URL 时把 baseURL 当前缀而不是可解析 URL 处理,写法是 `${baseURL.replace(/\/+$/, '')}/models`;响应体上限 MAX_RESPONSE_BYTES = 4 * 1024 * 1024(:50),先看 content-length 再按实际累计字节强制,超限抛 DISCOVERY_FAILED。
手写路由要自己声明的东西
既然手写路由拿不到目录里的元数据,那些元数据就得自己声明,其中最容易踩的是模态。
docs/user/guide/providers.md:31-50 写得很清楚:手写的模型默认按纯文本对待,理由是「because nothing can ask an endpoint which modalities it accepts」——没有任何办法去问一个端点它接受哪些模态。给这类模型发图片会在发送前被拒绝,并点名是哪个模型。要开图像输入,得在 $DSH_HOME/settings.yaml 的模型条目上写 input: [text, image]。
路由级还有个 defaultInput 兜底,providers.md:67 明写它是 fallback 而不是 override:默认 [text],在目录 provider 上只对目录未描述的模型生效,永远不会把图像能力从目录模型上拿掉;要收窄只能用该模型自己的 input。第 78 行补充每个列表至少要有一种模态,只有「模型自己的 input」允许写空列表(等同省略)。第 80 行还有一句值得抄下来:这两个字段都是对你的端点的声明,而不是检查——声称支持而端点其实不支持的,会在请求时被供应商拒绝。
代码侧的对应物是 packages/llm/llm-pi-ai/src/catalog.ts:42-45 的 MODALITY_GATE,只有 text 和 image 两项,注释称它是 drift gate:pi-ai 升级时增删模态,会在这里编译失败。这和 packages/llm/llm/src/types.ts:152 的 ModelModalityMap 同样只有两种模态是对得上的。
凭据被刻意挪出了这一层
packages/bundle/base/cordis.patch.yml 里,DeepSeek 适配器那个条目的注释(:447-449)写着不内联 key 与 endpoint,两者都在每次请求时从 llm-deepseek: settings 段叠加解析。pi-ai 那一条(:88-94)写的是「mounted dormant: zero routes(and no extra models in the picker)until a llm-pi-ai: settings section supplies provider profiles」——挂载了但零路由,直到配置段给出 profile。
凭据本身由 packages/credentials/ 管。CredentialRef 是个品牌类型,语义是「POSIX 风格的环境变量名」(packages/credentials/credentials/src/types.ts:13);ctx.credentials 只有 resolve / describe / set / unset 四个方法,其中 describe() 返回 { configured, source?, writable },永不返回值本身。本地实现 dsh-credentials-local 的模块注释(packages/credentials/credentials-local/src/index.ts:1-37)给了四层优先级,原文顺序是:继承的进程环境(只读,优先级最高)> $DSH_HOME/.credentials.yaml(provider 管理、可写)> 调用目录下的 .env(只读兜底)> $DSH_HOME/.env(只读兜底)。
所以这一层的分工是:适配器代码里只写一个环境变量名——llm-deepseek 的默认值是 DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY'(llm-deepseek/src/index.ts:45),真正的值由凭据层解析,落盘在 $DSH_HOME/.credentials.yaml。写自己的配置时,密钥请一律以 $DEEPSEEK_API_KEY 这类环境变量引用的形式出现,不要把明文写进任何会进版本库的文件——这一句是通用运维做法,不是该项目文档里的说法。
两处名单对不上
最后记一处我们核对时发现的差异,只陈述、不推断。
docs/user/guide/providers.md:19 把 Codex 与 Bedrock / Vertex / Azure 并列,描述为「用 OAuth,只填 API-key 字段配不起来」;而 .agents/notes/implemented/bug-fix/2026-08-13-oauth-only-providers-withheld.md:37 写的是只提供 OAuth 而没有 api-key 方法的目录 provider 会被整体扣留,openai-codex 是安装目录里唯一这样的。对照 Web 端的 golden 快照 apps/web/tests/snapshots/models-settings/empty.expected.md:25-60(这是测试期望文件里的下拉选项文本,不是我们跑出来的界面),其中的 36 个选项里确实没有 openai-codex。两处对「Codex 在设置页上还看不看得到」的表述不一致,以实读的仓库状态为准。
同一份 Agent Note 第 37 行还点名了六个「同时提供 OAuth 与 api-key、因此保留条目」的路由:anthropic、github-copilot、kimi-coding、openrouter、radius、xai。其中 radius 不在那份 36 项的快照名单里,两处名单对不上。这份 36 项的清单反映的是快照录制时 pi-ai 目录中「能被本适配器认证」的路由集合,不代表任何一家供应商的可用性,我们也不对其中任何一项做推荐。
收一下
这一层的切法可以概括成四句话:ctx.llm 只管注册表和类型词汇;适配器只被要求把供应商的流翻译成 StreamChunk;provider route 是贯穿请求、会话、模型默认值、凭据引用的主键;凭据和端点都不写死在适配器里,而是每次请求从设置层叠加解析。目录路由与手写路由的差别,本质上是「元数据从哪来」——目录路由从安装目录拿,手写路由必须自己声明协议、模型和模态,声明还只是声明、不是检查。
再重复一遍开头那条限定:这些都是 0.1.0-rc.5 状态下的字段名与默认值,仓库建立才三天、没有 Release、README 自述会有破坏性变更,你照着写之前请先回仓库对一遍当前的实际内容。
延伸阅读
- 从头读起:DeepSeek Harness 是什么:建仓三天、13 万 star 的 Agent 框架
- 本专题共 45 篇,完整分组目录见专题页
- DeepSeek Harness 手写路由支持的三种线协议分别是什么
- DeepSeek Harness 支持哪些供应商:清单不在仓库,只有一份快照
本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的
架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-16,对应仓库快照 47f9438(版本 0.1.0-rc.5)。
本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目,
因此不涉及界面外观、操作手感与运行速度的任何描述。
该仓库建立于 2026-08-13,README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更,
文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。