给 DeepSeek Harness 接一个新模型:LLM 适配器怎么写

2026-08-17

先把限定说在前面:DeepSeek Harness 的 README 里有一节就叫 Developer preview,明写它处于开发者预览阶段并且会有破坏兼容性的变更。下面提到的字段名、默认值、错误码,都是仓库快照 47f9438(版本 0.1.0-rc.5)里的样子,随时可能改。我们没有安装、也没有运行过这个项目,所有结论都来自读仓库里的文件。

第一步不是写代码,是确认你要不要写

接新模型这件事在这个仓库里有两条路,走错了会白写一个包。

packages/llm 下一共五个包:llm(服务与协议本体)、llm-deepseekllm-pi-aillm-retrytoken-meter。其中 llm-pi-ai 允许你在 cordis.yml直接声明一条路由,不写任何代码。它的 src/provider.ts 里有一张 PROTOCOLS 表,目前三个键:openai-completionsopenai-responsesanthropic-messages。配置里 api 只能从这三个里选,配上 baseURLmodels,再按需给 compat.thinkingFormat——src/catalog.tsTHINKING_FORMAT_GATE 里列了 8 种拼写:openaideepseekopenroutertogetherzaiqwenstring-thinkingant-ling

也就是说,如果你要接的是一个说 OpenAI 兼容协议的自建网关,仓库里给的路子是在 llm-pi-ai 的配置里加一条 provider 条目,而不是新写一个适配器包。真正需要写包的,是你的提供方协议不在那张三键表里,或者你需要一套 llm-pi-ai 表达不了的请求语义。

骨架是四个导出

docs/cookbook/adding-an-llm-adapter.md 给的形状原样抄过来是这样:

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(…))
}

LlmAdapter 定义在 packages/llm/llm/src/index.ts 第 180 行往下,只有 stream()abstractproviderInfo()providerRetryPolicy()listModels()resolveModel() 基类都给了默认实现:默认的 listModels() 返回空数组,默认的 resolveModel() 返回 { provider, id: model, name: model },仅此而已。所以最小可跑的适配器确实只需要实现一个流。

真正会在你还没发出第一个请求前就把你拦下来的,是注册这一步。registerAdapter 在同一文件第 338 行,它把校验和提交拆成 prepareRoutescommitRoutes 两个私有方法:候选路由集合全部通过校验才会写进注册表,被拒的候选不会留下任何痕迹。会抛的几处很密集——空数组抛 INVALID_ADAPTER(第 346 行)、空字符串路由名抛 INVALID_ADAPTER(第 378 行)、路由已被别的适配器占了抛 DUPLICATE_ADAPTER(第 380 行)。还有一条最容易踩:你重写的 providerInfo(provider) 返回值里 id 必须逐字等于传进去的 providername 必须非空字符串,否则第 384 行同样抛 INVALID_ADAPTER

llm-deepseek 就是这么用的。它在 src/index.ts 里把常量 PROVIDER 定成 'deepseek-official'providerInfo() 里把 name 改成 'DeepSeek'id 原样返回。README 里还专门说明这个路由名跟 llm-pi-ai catalog 里的 deepseek故意不同名的,好让一份 composition 同时挂两条 DeepSeek 路径。

StreamChunk 的几条义务,落到参考实现的哪一段

packages/llm/llm/src/types.ts 里的 StreamChunk 是七种形状的联合:block-starttext-deltareasoning-deltatool-call-deltablock-endusagefinish。cookbook 把约定写成几条义务,值得对着 llm-deepseek 的实现逐条看,因为文档那几句话单看是抽象的。

usage 必须在 finish 之前,finish 之后什么都不许再发。 cookbook 说稳妥做法是把 finish/usage 缓冲到提供方的流结束标记再统一 flush。具体到 packages/llm/llm-deepseek/src/translate.ts,它维护 pendingFinishpendingUsage 两个变量,一路只发 delta;直到 parseSse 吐出字面量 [DONE],才依次把所有 block-end 发完、发 usage、最后发 finish。理由文档自述得很清楚:DeepSeek 的 usage 可能挂在 finish 那一片上,也可能是尾部一个只有 usage 的分片,缓冲能同时吃下这两种形状。

工具调用参数全程是原始 JSON 字符串。 流式片段走 argumentsDelta,块结束时拼成 arguments。如果你的提供方给回来的是已解析的对象,cookbook 要求在 block-end 时重新 stringify。反过来的例子在 llm-pi-ai/src/replay.ts 里:它的 parseArguments() 遇到模型吐出的坏 JSON 会退回成 {},因为下游那个库要的是对象。

块 index 按首次出现顺序分配,同一块的每次 delta 复用同一个 index。 translate.ts 里就一个 nextIndex++ 计数器,文本块、reasoning 块各一个,工具调用按 wire 的 call.index 存进 Map 再映射到自己的 index。这里有个细节值得抄:reasoning_content 的第一片经常是空字符串,代码里判断 reasoning.length > 0 才开块,避免凭空多出一个空的 reasoning 块。

错误只有两条合法路径。 要么从 stream() 抛(传输与协议故障,用带稳定 code 的 LlmError),要么以 finish {kind: 'error' | 'aborted'} 收尾(提供方带内故障)。但你抛出去的东西不会直接穿到消费方——packages/llm/llm/src/index.ts 的私有方法 adapterStream() 把适配器选择、派发、迭代三个阶段的异常统一交给 adapterFailureChunk(),转成一个终止 finishsignal 已 abort 或 code 是 ABORTED 的走 aborted,其余走 error。你抛的那个 code,最终是以 finish.reason.failure.code 的形式出现的。这一点第一次读会反直觉:你写 throw,消费方读到的是 chunk。

一处口径不一致

cookbook 里写「提供方无法支持的 GenerateOptions 字段,抛 LlmError(..., 'UNSUPPORTED') 而不是静默丢弃」。而 llm-deepseek/src/serialize.ts 里实际用的 code 是 UNSUPPORTED_REASONING_EFFORT(不合法的推理强度)和 UNSUPPORTED_CONTENT(图片内容,这条 wire 路由是纯文本的)。两处不一致,以实读的源码为准。至于文档里那个 UNSUPPORTED 是泛指还是确指,我们不做推断。

密钥:配置里存的是变量名,不是值

cookbook 有一句很硬的话:切勿在代码里读自行约定的密钥文件。llm-deepseek 的 Config 里对应字段叫 apiKeyEnv,schemastery 里标了 role('credential-ref'),默认值是 'DEEPSEEK_API_KEY'——存的是环境变量名。真正的值在每次 stream() 调用时才解析:先问可选的 ctx.credentials 服务,没挂这个 seam 就退回启动环境层;哪儿都找不到就抛 MISSING_CREDENTIAL

注意这个失败发生的时机:不是插件加载时炸,而是请求时炸,路由仍然注册着、catalog 仍然可以浏览。README 自述这是为了让首次上手是「先浏览模型、再存 key、再试一次」,中间不用重启。

还有一条更隐蔽的纪律,写在 llm-deepseek/src/adapter.tsstream() 开头:先 this.config.options() 拿到一份连接快照,再把这份快照传给 resolveApiKey(connection)。注释写明了理由——密钥必须和它要发往的 endpoint 来自同一次 resolve,否则配置改到一半时可能把新 key 发到旧网关上。你自己写适配器时,这个顺序别图省事颠倒。

至于变量怎么进到进程环境里,cordis.yml 侧的写法 cookbook 给了:!!js process.env.MY_KEY。这是 YAML 层的注入写法,跟操作系统无关;但把变量导出给启动进程这一步,PowerShell 和 POSIX shell 的语法不同,仓库文档里没有给 Windows 侧的具体命令。仓库里能看到的一个例子是 .github/workflows/e2e.yml,它把 CI secret 映射成测试要读的 DEEPSEEK_API_KEY 环境变量。

默认值:是配置里的数,不是运行表现的承诺

llm-deepseek/src/adapter.ts 顶部导出了三个常量,src/index.ts 把它们接到 schema 的 .default() 上:

常量对应配置项
DEFAULT_STREAM_IDLE_TIMEOUT_MS300_000streamIdleTimeoutMs
DEFAULT_CONTEXT_WINDOW1_000_000defaultContextWindow
DEFAULT_MAX_TOKENS256_000maxTokens

llm-pi-ai/src/config.ts 里同名概念的数不一样:DEFAULT_STREAM_IDLE_TIMEOUT_MS 同样是 300_000,但 DEFAULT_CONTEXT_WINDOW262_144DEFAULT_MAX_TOKENS32_768。这些都是「调用方什么都不填时写进请求的数」,不是对速度、成本或稳定性的任何承诺——streamIdleTimeoutMs 按 README 自述约束的是每一次未完成的提供方读取(含首个 fetch),不计消费方在两片之间花的时间。

顺带一个容易被忽略的强制项:LlmAdapter 的类文档写明每一次提供方 HTTP 请求都必须带上 attributionHeaders()。它在 packages/llm/llm/src/attribution.ts,产出一个 user-agent,值的形状是 product/version (+url)APP_IDENTITY.product'deepseek-harness',version 从包自己的 package.json 读,不许手抄。

resolveModel 和 replayState 这两处别自作主张

resolveModel() 返回的东西会被 LlmRuntime 逐字段校验:provider 和 id 必须回等于传入值、name 非空,否则 INVALID_MODEL_INFOcontext.contextWindow 不是正整数则 INVALID_MODEL_CONTEXTreasoning.efforts 为空、effort 的 id 或 name 不是非空字符串、id 重复、或者 defaultEffort 不在 efforts 里,都会抛 INVALID_MODEL_REASONING(第 680、695、708 行三处)。cookbook 那边写的是「仅当存在配置指定的默认值时才声明 defaultEffort」;源码这边能核到的校验是「一旦声明,它必须是 efforts 里已有的 id」。两句放在一起看,就是:宁可不声明,也别声明一个 efforts 里没有的值。

replayState 是给需要携带响应 id、签名之类原生元数据的提供方用的。LlmRuntime 的私有方法 forAdapter() 会遍历历史消息:只有当那条历史消息的 provider 路由当前由同一个 adapter 实例持有时,replayState 才留着;否则原地剥掉,只留 provider 和 model。llm-pi-ai/src/replay.ts 把自己那份状态定成 { kind: 'pi-ai', version: 1, … },校验不过一律 INVALID_REPLAY_STATE;状态缺失时它构造一个 api/provider/model 全填 'dsh-foreign' 的消息,注释写明这个值刻意永不等于任何 catalog API——就是为了不让「名字看着一样」被误当成同源回放。

落到文件上

真要动手,参考布局是 packages/llm/llm-deepseek/srctypes.ts(wire 格式类型)、serialize.ts(请求序列化)、sse.ts(传输解析)、translate.ts(分片转换)、adapter.ts(适配器类)、index.ts(插件契约)、invariant.ts(不变量伴生插件,这个包里是空实现)。cookbook 明说让这几件事各自独立担责,别糊成一坨。验证那一节它把责任推给了 docs/testing.md,那份文档负责适配器覆盖、真实提供方检查和已发布入口要求。

以上都是仓库当下的样子。开发者预览阶段的项目,别把这些默认值和错误码当长期契约看。


本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的 架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。 本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目, 因此不涉及界面外观、操作手感与运行速度的任何描述。 该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更, 文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。

安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。