DeepSeek Harness 的 llm 包测试行数多于源码:适配层怎么测住的

2026-08-16

deepseek-harness 的模型接入层时,最先让人停下来的不是某个精巧的抽象,而是一个数字比例:packages/llm/ 下的测试代码行数,比它测的源码还多一半。

先把口径摆出来,免得你以为这是从 README 上抄的形容词。

一、先说这个数字是怎么数出来的

我们在本批采用的仓库快照 47f9438(截至 2026-08-16)里,用两条 glob 分别收集 packages/llm/*/src/packages/llm/*/tests/ 下的 .ts 文件,按行统计:

python -c "
import glob
src=glob.glob('packages/llm/*/src/**/*.ts',recursive=True)
tst=glob.glob('packages/llm/*/tests/**/*.ts',recursive=True)
print('src',len(src),sum(sum(1 for _ in open(p,encoding='utf8')) for p in src))
print('tests',len(tst),sum(sum(1 for _ in open(p,encoding='utf8')) for p in tst))
"

结果是:src 46 个文件、8,040 行;tests 41 个文件、12,349 行。测试文件里 packages/llm/*/tests/*.spec.ts 有 34 个,packages/llm/*/tests/*.e2e.ts 有 3 个。

放大一层看整个目录:packages/llm/ 一共 116 个文件,其中 .ts 88 个、.md 12 个、.json 10 个、.yaml 6 个。这里有个容易踩的坑——如果你在别处看到「packages/llm 有 88 个文件」,那说的其实是 88 个 .ts 文件,不是目录里的文件总数。数文件时先说清楚数的是哪一类。

还有一条必须先讲明白的边界:我们没有安装 node_modules,没有跑过这里的任何一个测试。上面 34 个 .spec.ts 与 3 个 .e2e.ts 只是文件计数,不是「有 34 组测试通过了」。行数比也不等于覆盖率,更不等于质量——它只是一个结构事实:这个目录里,用来钉住行为的代码比实现行为的代码多。至于这个比例说明了什么,我们没有依据下结论。

真正值得看的,是这层代码把什么变成了可以被钉住的东西。沿着源码走一遍,你会发现它反复在做同一件事:把「靠人记住的约定」改写成「编译器或解析器会当场报错的形状」。

二、协议本身是封闭的:新增一个变体,所有消费点一起报错

packages/llm/llm/src/types.ts:291-299 定义了 StreamChunk,七个变体:

type StreamChunk =
  | { type: 'block-start'; index: number; blockType: ContentBlockType }
  | { type: 'text-delta'; index: number; text: string }
  | { type: 'reasoning-delta'; index: number; text: string }
  | { type: 'tool-call-delta'; index: number; id: CallId; name?: string; argumentsDelta: string }
  | { type: 'block-end'; index: number; block: ContentBlock }
  | { type: 'usage'; usage: TokenUsage }
  | { type: 'finish'; reason: FinishReason; replayState?: unknown }

docs/subsystems/llm-streaming.md:158 明写它是封闭判别联合,消费端的 switchassertNever 收尾——也就是说,往这个联合里加第八个变体,每一处没处理它的消费点都会编译失败。这不是运行时断言,是类型层面的拦截。

同一个思路在内容块那边也用了一遍:ContentBlockMaptypes.ts:99,键只有 text / reasoning / image / tool-call / tool-result 五种;ContentBlockType = keyof ContentBlockMaptypes.ts:108)。模态更窄,ModelModalityMaptypes.ts:152 只有 textimage 两项。这些「就这么几种」的枚举,全都是靠 keyof 从一张表推出来的,而不是各处各写一份字符串联合。

三、契约写成了七条可断言的句子

docs/subsystems/llm-streaming.md:206-216 列了七条「每个适配器必须遵守」的条款。挑几条看它们的形状:

  • usage 必须出现在 finish 之前,finish 之后不得再有任何块;
  • tool-call 的 arguments 端到端保持原始 JSON 字符串,分片走 argumentsDelta,若供应商返回的是已解析对象,则在 block-end 处重新序列化;
  • 只有两条被认可的错误路径:从 stream() 抛出,或以 finish {kind:'error'|'aborted', failure} 结束流;
  • 一次适配器调用 = 一次供应商尝试,适配器要关闭库自带的重试;
  • 上下文溢出只有一个规范码 CONTEXT_WINDOW_EXCEEDED,消费方按 code 路由,绝不解析供应商文本;
  • 空补全是可重试错误而非静默成功:终态 stop 却没有任何内容块,就要以 finish {kind:'error'} 结束并给码 EMPTY_RESPONSE

每一条都能直接翻译成一次断言:喂一段构造好的块序列进去,看它有没有在该报错的地方报错、报的是不是那个码。契约写成这个样子,测试才写得出来。

docs/user/develop/practice/llm-adapter.md:56-101 干脆给了一段完整的样例序列:block-start → text-delta ×2 → block-end → tool-call 的 block-start/delta/block-end → usage → finish,后面 :105-109 再用四条「Key rules」把规则复述一遍。

顺带记一处措辞不一致:关于 index 怎么分配,docs/user/develop/practice/llm-adapter.md:106 写的是「index increases from 0 and identifies content-block order」,而 docs/cookbook/adding-an-llm-adapter.md:29 写的是「Allocate block indexes in first-seen stream order; reuse the index for every delta of the same block」。两处措辞不同,位置如上,以你实读的仓库状态为准。我们不推断哪一处更准。

四、装配器故意容忍畸形流

packages/llm/llm/src/assembler.ts(164 行)里的 BlockAssembler 提供 push() / blocks() / usage / finish / replayState / message(source)。文档(docs/subsystems/llm-streaming.md:278-281)写了两条明确的容忍策略:它容忍只有 delta 的协议;对已经被 block-end 关闭的 index 再来的 delta,直接忽略当作畸形流处理,理由是不让行为不良的适配器撑大内存或污染已完成的块。

这类「明写出来的容忍」和上面「明写出来的禁止」是一对:禁止的部分交给类型和错误码,容忍的部分交给装配器兜底。两边都写死了,行为才是可预期的。

五、三张 drift gate:让上游升级在编译期暴露

packages/llm/llm-pi-ai/ 这个包包装的是外部库 @earendil-works/pi-aipackage.json 里写 ^0.82.1pnpm-lock.yaml:9255 锁的是 0.82.1)。外部库会变,于是 catalog.ts 里放了三张手写清单:

清单位置内容
MODALITY_GATEcatalog.ts:42-45只有 textimage 两项
THINKING_LEVEL_GATEcatalog.ts:69-77七档,升序 offminimallowmediumhighxhighmax
THINKING_FORMAT_GATEcatalog.ts:100-109八项:openaideepseekopenroutertogetherzaiqwenstring-thinkingant-ling

MODALITY_GATE 的注释直接把它叫 drift gate:上游增删模态,会在这里编译失败。这三张表不是给人查的文档,是给编译器踩的地雷。

另有两项 thinking format 被显式扣留WithheldThinkingFormatcatalog.ts:89):chat-templateqwen-chat-template,理由是它们走 chatTemplateKwargs,本配置不暴露。这是「代码里有名字但配置里不能写」的情况,别当成可用能力。

再记一处不一致:catalog.ts:96-98 的注释提到「a pi-ai upgrade that adds a format(0.84 added baseten)fails compilation here」,而依赖声明与 lock 文件里是 0.82.1。两处提到的版本不同,位置如上,说完就停。

六、配置解析期就点名报错

不是所有约束都能靠类型拦住。配置是运行时读进来的,于是这层把校验前移到解析期,并且报错时点名出错的 keycatalog.ts:338-357、:458-475、:499-516 覆盖的情形包括:reasoningEfforts 为空、只声明了 off 而没有更高档、modelOverrides 指向目录里不存在的模型、modelOverridesmodels 列表并存、条目里又写了一遍 idcontextWindow / maxTokens 不是正整数。

被移除的旧字段也有专门的拒绝话术(config.ts:276-291):profile 里写 provider 会被告知「moved to the providers dict key」;写 maxRetriesmaxRetryDelayMs 会被告知已移除、请改用 dsh-llm-retry。同样地,packages/llm/llm-retry/src/index.ts:32-34 里,Config 是空对象类型,你要在它下面写 retryPolicy,它会明确拒绝并提示这属于「each provider configuration」。

这类错误信息本身也是可断言的:写一段错的配置,断言它抛错并且抛在那个 key 上。

七、第二个适配器,一半身份是验证装置

仓库里带了两个适配器。packages/llm/llm-deepseek/ 走原生 fetch + SSE,只拥有一个 provider route(const PROVIDER = 'deepseek-official'index.ts:47);packages/llm/llm-pi-ai/ 包装 pi-ai 库、按 provider route 为键做 dict 配置。

有意思的是这个包的自我定性在三处不一样:packages/llm/llm-pi-ai/package.json:3 的 description 写它是「design-verification twin of dsh-llm-deepseek」;packages/llm/README.md:12 的表格写它是「Multi-provider pi-ai adapter」;docs/user/develop/practice/llm-adapter.md:149 写「Pi AI adapter using a different API format」。三处用词不同,位置如上,我们不推断原因。

撇开措辞差异不谈,仓库里摆着的结构性事实是:这层抽象同时接着两个形状不同的实现。一个直连 HTTP + SSE,一个套外部多供应商库,两边都得吐出同一套 StreamChunk。抽象只有一个实现时,你分不清哪些字段是通用词汇、哪些是某家供应商的漏网之鱼。

而两个适配器有些数值确实是各写各的:DEFAULT_STREAM_IDLE_TIMEOUT_MS 两边都是 300_000llm-deepseek/src/adapter.ts:89llm-pi-ai/src/config.ts:35),但默认上下文窗口不同——llm-deepseek/src/adapter.ts:89-93DEFAULT_CONTEXT_WINDOW = 1_000_000DEFAULT_MAX_TOKENS = 256_000llm-pi-ai/src/config.ts:35-53DEFAULT_CONTEXT_WINDOW = 262_144DEFAULT_MAX_TOKENS = 32_768。这些都是配置里的默认值,不是对实际表现的承诺,也推不出任何性能或成本结论。该调成多少取决于你的用法,项目没给通用值。

八、还有一份 golden 快照,以及它和另一份名单对不上

除了单元测试,仓库里还有面向 Web 端的期望文件。apps/web/tests/snapshots/models-settings/empty.expected.md:25-60 记录的是 Models 设置页「提供方」下拉的期望文本,我们用 Python 正则数出其中有 36 个 option。它反映的是快照录制时该 pi-ai 版本目录里「能被本适配器认证」的路由集合——不是我们跑出来的界面,也不代表任何一家供应商的可用性或推荐。

这份名单和另一处记录对不上,按纪律照实记:.agents/notes/implemented/bug-fix/2026-08-13-oauth-only-providers-withheld.md:37 点名了六个「除 OAuth 外还有 api-key、因此保留条目」的路由,其中包含 radius;而上述 36 个选项里没有 radius。同一份笔记还写 openai-codex 会从可配置目录中整体消失,而 docs/user/guide/providers.md:19 仍把 Codex 与 Bedrock / Vertex / Azure 并列描述。两处名单与两处表述都不一致,位置如上,说完就停。

扣留逻辑本身能在代码里核到:packages/llm/llm-pi-ai/src/index.ts:142-144 只有在 catalogProviderTakesApiKey(provider) 为真时才 declare,判定实现在 catalog.ts:160-162

九、我们没能核到的部分

写这类文章最容易滑坡的地方,是把「读到的结构」说成「验证过的行为」。所以把边界也列清楚:

  • 我们没有跑过任何测试,34 个 .spec.ts 与 3 个 .e2e.ts 只是文件计数;
  • .github/workflows/pi-ai-provider-e2e.yml 存在,名为「E2E (pi-ai Azure OpenAI and Anthropic)」,需要仓库 secrets 才能跑,我们没有也无法查看其运行结果;
  • pi-ai 库本身不在仓库内,vendor/ 下没有它,我们没装 node_modules,所以 builtinProviders() 之类的真实定义无法从快照核实;
  • packages/llm/token-meter/llm-pi-ai/src/replay.ts 我们只读了文件清单与行数,没有展开。

最后是那条对所有细节都成立的限定:这个仓库建立于 2026-08-13,我们采集时是 2026-08-16,前后只差三天;根 package.json 的版本是 0.1.0-rc.5,没有任何 GitHub Release,README 自述处于开发者预览阶段并明写未来会有破坏兼容性的变更。本文提到的每一个字段名、默认值、错误码、行数,都随时可能变。要照着做,先回仓库对一遍当前值。

延伸阅读


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

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