DeepSeek Harness 的 llm 包测试行数多于源码:适配层怎么测住的
翻 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 明写它是封闭判别联合,消费端的 switch 以 assertNever 收尾——也就是说,往这个联合里加第八个变体,每一处没处理它的消费点都会编译失败。这不是运行时断言,是类型层面的拦截。
同一个思路在内容块那边也用了一遍:ContentBlockMap 在 types.ts:99,键只有 text / reasoning / image / tool-call / tool-result 五种;ContentBlockType = keyof ContentBlockMap(types.ts:108)。模态更窄,ModelModalityMap 在 types.ts:152 只有 text 与 image 两项。这些「就这么几种」的枚举,全都是靠 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-ai(package.json 里写 ^0.82.1,pnpm-lock.yaml:9255 锁的是 0.82.1)。外部库会变,于是 catalog.ts 里放了三张手写清单:
| 清单 | 位置 | 内容 |
|---|---|---|
MODALITY_GATE | catalog.ts:42-45 | 只有 text、image 两项 |
THINKING_LEVEL_GATE | catalog.ts:69-77 | 七档,升序 off、minimal、low、medium、high、xhigh、max |
THINKING_FORMAT_GATE | catalog.ts:100-109 | 八项:openai、deepseek、openrouter、together、zai、qwen、string-thinking、ant-ling |
MODALITY_GATE 的注释直接把它叫 drift gate:上游增删模态,会在这里编译失败。这三张表不是给人查的文档,是给编译器踩的地雷。
另有两项 thinking format 被显式扣留(WithheldThinkingFormat,catalog.ts:89):chat-template 与 qwen-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。两处提到的版本不同,位置如上,说完就停。
六、配置解析期就点名报错
不是所有约束都能靠类型拦住。配置是运行时读进来的,于是这层把校验前移到解析期,并且报错时点名出错的 key。catalog.ts:338-357、:458-475、:499-516 覆盖的情形包括:reasoningEfforts 为空、只声明了 off 而没有更高档、modelOverrides 指向目录里不存在的模型、modelOverrides 与 models 列表并存、条目里又写了一遍 id、contextWindow / maxTokens 不是正整数。
被移除的旧字段也有专门的拒绝话术(config.ts:276-291):profile 里写 provider 会被告知「moved to the providers dict key」;写 maxRetries 或 maxRetryDelayMs 会被告知已移除、请改用 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_000(llm-deepseek/src/adapter.ts:89、llm-pi-ai/src/config.ts:35),但默认上下文窗口不同——llm-deepseek/src/adapter.ts:89-93 是 DEFAULT_CONTEXT_WINDOW = 1_000_000、DEFAULT_MAX_TOKENS = 256_000,llm-pi-ai/src/config.ts:35-53 是 DEFAULT_CONTEXT_WINDOW = 262_144、DEFAULT_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 是什么:建仓三天、13 万 star 的 Agent 框架
- 本专题共 45 篇,完整分组目录见专题页
- DeepSeek Harness 的重试与超时默认值:退避、抖动与流式空闲
- DeepSeek Harness 的模型接入分层:adapter、provider 与手写路由
本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的
架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-16,对应仓库快照 47f9438(版本 0.1.0-rc.5)。
本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目,
因此不涉及界面外观、操作手感与运行速度的任何描述。
该仓库建立于 2026-08-13,README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更,
文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。