夜雨聆风学习资料网

ARTICLE · 1039548

DeepSeek-Harness 源码深读(10):流可以脏,错误可以带毒,llm 内核凭什么不崩

DeepSeek-Harness 源码深读(10):流可以脏,错误可以带毒,llm 内核凭什么不崩

摘要:教程篇走通了一次 stream() 的主链路,这一篇反过来施压。不发开块标记的脏流、截断成半截的 tool-call、带 getter 陷阱的错误对象、成环的 cause 链,五种脏行为逐个塞给 llm 内核。语法门禁 fail 即抛,装配器先关闭者胜,归一只认 own 属性,渲染链把敌意节点塌缩成占位符,四层防线逐段落到 file:line。

教程线那篇《不封装任何 SDK,llm 插件凭什么接住所有模型调用》跟的是一次顺利的调用:请求进 waterfall 钩子链,七种 StreamChunk 流出来,agent loop 装成消息。全程都在依赖一个前提:adapter 是守规矩的。

深读篇把这个前提抽掉。dsh-llm 是被三十多个插件共享的内核,adapter 却是谁都能写的扩展点,官方仓库里就有两个实现(直连的 llm-deepseek、背靠第三方库的 llm-pi-ai),你给公司网关写的会是第三个。adapter 背后是供应商端点、第三方 SDK、网络栈,任何一层都可能让流变得不干净。所以问题收窄成一个:adapter 乱来的时候,内核的行为是什么?

主线案例定下来。我们给自建网关写一个 OpenAI 兼容的 adapter,然后往它的输出里塞脏行为,五种,都是真实世界出现过的款式:

  1. 不发 block-start,上来直接甩 text-delta;
  2. block-end 关了块之后继续补 delta,还重复关闭;
  3. finish 报 max-tokens,一个 tool-call 的参数只传了一半;
  4. 抛出的错误对象带 getter 陷阱,failure 快照是伪造的;
  5. cause 链首尾相接成环,toString 还会抛。

下面涉及的文件都在 packages/llm/llm/src/ 下,引用只写文件名和行号。每往里走一层,就有一个脏行为被接住,先看第一层。

一、语法门禁:fail 即抛,但门卫不保证在场

第一个脏行为最直接。协议要求 delta 之前先有 block-start 开块,我们的 adapter 嫌麻烦直接省了。谁来管?dsh-llm 包里有个伴随插件 llm-invariant(invariant.ts:10),注入 invariants 服务(:12),给每一条模型流套一层语法校验。判定 delta 合法性的代码长这样(invariant.ts:22-33):

function validateDelta(  open: ReadonlyMap<number, ContentBlockType>,  index: number,  expected: ContentBlockType,  fail: InvariantFailure,): void {  validateIndex(index, fail)  const actual = open.get(index)  if (actual !== expected) {    fail(`${expected} delta at index ${index} requires an open ${expected} block, got ${String(actual)}`)  }}

这段是 delta 的合法性判定。注意 fail 的语义:类型签名是 (message: string) => never(runtime-diagnostics/invariants/src/index.ts:29),调用即抛出绑定包名的 InvariantError。它记警告然后放行吗,不,当场终止。同一段生成器里还有一串同类检查:finish 之后不许再有任何 chunk(invariant.ts:44)、block-start 不许重复(:48)、usage 至多一次(:71)、非 error 非 aborted 的 finish 不许留开放块(:75-77)、流走完必须有 finish(:83)。

安装位置也有讲究,invariant.ts:88 一行挂上钩子链:

ctx.on('llm/stream', (_options, next) => validateStream(next(), fail), { global: true, prepend: true })

prepend: true 让它排在链的最外层,消费端拿到的每一个 chunk 都先过完语法关。脏行为一在这里就死了:迭代抛出 InvariantError,一个脏 chunk 都到不了装配现场。

看起来收工了。但这个门卫有先天限制:它挂在可配置的 invariants 服务上,配置里有 enabledpackage_allowlistpackage_blocklist 三道开关(runtime-diagnostics/invariants/README.md:10-17),部署可以把它整个关掉。内核不能把自己的健壮性押在一个不保证在场的插件身上,装配器必须自己扛住同一份脏流。

二、宽容装配:先关闭者胜

脏行为二、三现在直接怼到 BlockAssembler 脸上。这个类是 agent loop 装配 assistant 消息的唯一算法(assembler.ts:1-4),输入原始 chunk 流,输出内容块和消息。看它怎么入账 delta(assembler.ts:61-67):

      case 'text-delta':      case 'reasoning-delta': {        const partial = this.ensure(chunk.index, chunk.type === 'text-delta' ? 'text' : 'reasoning')        if (partial.block) return // closed by block-end; ignore stragglers        partial.text += chunk.text        return      }

这段是 delta 的入账逻辑。注意看第一行的 ensure:这个 index 还没有块的话,就按 delta 的类型隐式开一个(assembler.ts:97-105)。脏行为一在这里被另一副面孔接住,门禁层判它违规,装配层收它合法,没开块的流照样装出消息。第二行注释写死了对已关闭块的态度:stragglers(掉队者)直接忽略,tool-call-delta 分支是同一个模式(:68-74)。

再看 block-end 自己(assembler.ts:76-83):

      case 'block-end': {        const partial = this.ensure(chunk.index, chunk.block.type)        // First close wins; ignoring re-close stragglers keeps streamed output        // and the final assembled block in agreement.        if (partial.block) return        partial.block = chunk.block        return      }

这段是块关闭的裁决,注释就是规则:first close wins,谁先关闭谁是权威版本,重复关闭同样忽略。我们推演一下脏行为二的攻击面:一个劣质 adapter 想撑大内核内存,手段无非是无限补 delta、反复开关同一个 index。走到这里全部失效,已关闭的块就此冻结,后续分文不入。类文档把后果钉死了:「a misbehaving adapter cannot grow memory or corrupt a completed block」(assembler.ts:33-35)。

两层摆在一起,有个对照值得停一下:门禁从严,违规即抛,可它允许缺席;装配从宽,来者先收进 partial 再说,可它永远在场。同一份协议,两套判定,互不指望。

脏流从 adapter 出来,门禁、装配、取舍、归一逐层过筛

语法全对、流也走完了,麻烦才刚开始。脏行为三根本不违规。

三、截断的取舍:半截 tool-call 必须丢

脏行为三最阴险:块正常开了也正常关了(block-end 带着半截参数),finish 规规矩矩报 max-tokens,语法挑不出毛病。装配器面对的是一个语义决定:半截 JSON 参数的 tool-call,装不装?

裁决集中在一个私有方法里(assembler.ts:135-142):

    const all = this.order.map(index => this.assemble(this.mustGet(index), index))    const kept = this.finish.kind === 'max-tokens'      ? all.map(block => block.type !== 'tool-call')      : undefined    const blocks = kept === undefined ? all : all.filter((_, position) => kept[position])    const envelope = this._replayState    if (envelope?.blocks === undefined) return { blocks, replay: envelope }    if (envelope.blocks.length !== all.length) return { blocks, replay: undefined }

这段是保留与丢弃的总决策。finish 是 max-tokens 时,tool-call 块整类标记为丢弃,因为截断的 tool 参数不可安全执行,agent loop 拿到它就会带着半个参数去调工具。注意后两行对 replayState 的处理:adapter 私有的逐块回放元数据,长度跟装配出的块数对不上就整体作废(:142),对得上就跟着 kept 同步剪枝(:143-148)。内容块和回放元数据从同一个函数出来,天生不会错位。

用户主动打断是镜像场景。取消信号砍断流之后,agent loop 不读 blocks(),改读 interruptedBlocks()(assembler.ts:169-177):

    return this.order      .map((index) => {        const partial = this.mustGet(index)        const type = partial.block?.type ?? partial.blockType        if (type !== 'text' && type !== 'reasoning') return undefined        return this.assemble(partial, index)      })      .filter((block): block is ContentBlock =>        (block?.type === 'text' || block?.type === 'reasoning') && block.text.trim() !== '')

这段是被打断时能保住什么。只留 text 和 reasoning,还必须 trim 后非空;tool-call 一律不保。文档给了理由(:163-164):打断发生在派发之前,保留一个 tool-call 就得给它伪造一个结果。宁可丢,不造假。

还有个边角:流正常走完却一个块都没有。装配器不管这事,照实装出零块消息,判定发生在 adapter 侧。零内容完成被分类成 EMPTY_RESPONSE(error.ts:39),错误码上方九行注释把动机写透了:空消息会让 turn 无声终止,谁都没得到可行动的东西,所以按可重试的失败处理。默认重试码表第一位就是它(retry-policy.ts:18-24),后面跟着 RATE_LIMIT、SERVER、TIMEOUT、TRANSPORT。

装配的账结清了,可脏行为四换了个方向:adapter 可以根本不发流,直接 throw。

四、错误归一:只认 own 属性,快照要验指纹

设想脏行为四的具体形状:adapter 抛出的是第三方 SDK 的错误对象,.code 是个 getter,一访问就触发副作用;对象上还挂了个 failure 属性,内容是伪造的 { code: 'RATE_LIMIT', providerRetryAfterMs: 999999 },想骗重试插件长时间干等。

throw 首先在 adapterStream 边界被接住,变成一个终止 chunk。教程篇讲过 yield 刻意放在 adapter 拥有的 try 块之外的那行注释(index.ts:890-892),这里不重复。变形的终点是(index.ts:931-939):

function adapterFailureChunk(error: unknown, signal?: AbortSignal): StreamChunk {  const failure = normalizeLlmFailure(error)  return {    type: 'finish',    reason: signal?.aborted || failure.code === 'ABORTED'      ? { kind: 'aborted', failure }      : { kind: 'error', failure },  }}

这段是把任意 throw 折成流协议终态的转换器。signal 已 abort 或 code 为 ABORTED 就归 aborted,其余归 error。净化发生在 normalizeLlmFailure 里(adapter-failure.ts:20-27):

  // Cross-package copies preserve own data but not class identity. Trust the  // carried facts only when both own properties agree after validation.  const carried = ownFailureSnapshot(error)  if (carried !== undefined && carried.code === ownErrorCode(error)) return carried  return Object.freeze({    message: errorMessage(error),    code: harnessErrorCode(error),  })

这段是信任判定的全部。carried 是错误对象自带的 failure 快照,但采信它要过一道指纹校验:快照里的 code 必须跟错误对象自己的 own code 属性一致(:23)。伪造者想塞一个 RATE_LIMIT 快照,就得让对象本身的 code 也是 RATE_LIMIT,而 harnessErrorCode 只认 HarnessError 的 code,其余一律折成 UNKNOWN(adapter-failure.ts:101-104)。注释点破了另一层考虑(:20-21):跨包副本保得住 own data、保不住 class identity,instanceof 会失灵,所以只剩 own 属性可信。

own 属性怎么读,是这套防御的手筋(adapter-failure.ts:40-48):

/** Read a foreign error's own data-backed `code` without invoking accessors. */function ownErrorCode(error: Error): unknown {  try {    const descriptor = Object.getOwnPropertyDescriptor(error, 'code')    return descriptor !== undefined && 'value' in descriptor ? descriptor.value : undefined  } catch (_sdkPropertyTrap) {    return undefined  }}

这段是读 code 的实现。注意看两个细节:getOwnPropertyDescriptor 只看自有属性,'value' in descriptor 把 getter 描述符挡在门外,SDK 的访问器没有执行机会;连 catch 变量名都写了实情,_sdkPropertyTrap。同文件的 thrownMessage、errorMessage、failureSnapshot 是同一模式各管一段,非 Error 的值先包一层,hostile toString 抛了就折成固定短句(:31-38)。

净化完的错误对象,最后还要变成人能读的诊断文本,脏行为五就在这里等着。

五、渲染防环:敌意节点塌缩,整条链不许炸

脏行为五:错误对象这次规范了些,可它的 cause 链 A→B→C→A 首尾相接,中间某个节点的 toString 还会抛。渲染它的函数是 errorChain(error.ts:114-154),文档先划了边界:输出仅供诊断面(消息、通知、日志),禁止拿它做路由,路由认 HarnessError.code(error.ts:106-108)。防御姿态在渲染器的收尾处(error.ts:142-151):

    } catch {      // Only hostile coercion or hostile accessors (a throwing toString /      // Symbol.toPrimitive on a non-Error, or a throwing message/name/cause/      // errors getter on an Error subclass): this renderer feeds UI notices      // and logs, so nothing may escape. Inner frames catch their own throws,      // so only the hostile node collapses, not the whole chain.      return '<unrenderable value>'    } finally {      path.delete(current)    }

这段是敌意节点的处置。注释把威胁模型写明白了:渲染器喂的是 UI 通知和日志,任何东西都不许逃逸;内层帧各自接住自己的异常,塌缩的只是敌意节点自己,整条链照常输出。环的检测在链头,一个活跃路径 Set,进节点加、出节点删(:117-120 配上这里的 finally),于是只有真环触发 ,同一个 cause 被两个分支共享的菱形结构仍能完整渲染。顺带一处小刀法:包装器把 cause 原文抄进自己 message 的,重复文本直接跳过(:138-140),日志里不会同一句话连播。

结语

五种脏行为逐个对账。不发开块标记的 delta,门卫在场时被 validateDelta 拦下(invariant.ts:22-33),缺席时被 ensure 隐式开块接住(assembler.ts:97-105)。block-end 之后的补刀与重复关闭,first close wins 一律忽略(assembler.ts:80)。截断的半截 tool-call 在总决策里被整类丢弃,回放元数据同步剪枝或整体作废(assembler.ts:136-148)。带毒错误对象经 adapterFailureChunk 变终止 chunk(index.ts:931-939),快照要过 code 指纹校验(adapter-failure.ts:23),own 属性之外的读取各自包 try。成环的 cause 链塌缩成占位符,其余节点照常渲染(error.ts:142-148)。

这套防御的账单同样是具体的:每一个碰外来对象属性的读取点都得记得 try/catch,漏一处就是新的逃逸口;isContextWindowExceededError 靠匹配供应商报错措辞的正则识别上下文溢出(error.ts:80-86),新供应商换套说法就得有人补。

我想单独指给你看的是最不起眼的一行:errorChain 的 finally 里那句 path.delete(current)(error.ts:150)。没有它,路径集合只进不出,第一次遇见共享 cause 就永久误判成环。进出配对,是菱形结构能完整渲染的全部前提。

防线走完了还剩一个口子没讲:这些 chunk 从哪来。下一篇进 llm-deepseek 适配器,看 SSE 字节流怎么被逐帧翻译成七种 chunk,[DONE] 标记和 EOF 早退在传输层怎么判生死。

你项目里接第三方 SDK,抛出来的非 Error 对象是直接 String() 了事,还是也做过 own 属性这样的隔离?评论区聊聊。


本系列基于 DeepSeek Harness 源码(MIT,0.1.1-rc.1)与官方 Agent Notes 整理,仓库:github.com/deepseek-ai/deepseek-harness。有收获就点个关注,下一篇见。

相关学习资料