DeepSeek Harness 终端 LLM 流失败:LlmRuntime 如何将适配器异常统一为单一 terminal finish 协议
2026/9/19 1:32:51 网站建设 项目流程

DeepSeek Harness 终端 LLM 流失败:LlmRuntime 如何将适配器异常统一为单一 terminal finish 协议

【免费下载链接】deepseek-harnessDeepSeek Harness: Everything is a Plugin.项目地址: https://gitcode.com/gh_mirrors/de/deepseek-harness

本文基于 DeepSeek Harness(dsh)仓库中的架构决策笔记 Terminal LLM stream failures,完整解读该方案如何消除 LLM 适配器失败的双轨表示(抛出异常 + 流内 finish),把归一化职责收敛到LlmRuntime一个边界上。读完后你可以掌握:LlmFailure归一化的 catch 边界到底覆盖哪些语句、重试策略为何从“流键控 sidecar”改为由PreparedLlmCall显式携带,以及 agent loop 如何仅凭一个终端 finish 完成失败消费——这套设计对任何需要编排流式 LLM 调用的 agent 框架都有直接参考价值。

背景:适配器失败曾经有两种“公开表示”

在该决策落地之前,一次 LLM 适配器调用失败会以两种互不相同的形态暴露给上层:

  1. 抛出的异常:可能来自最终适配器的选择(selection)、同步 dispatch、迭代器构造(iterator construction)或迭代(next());
  2. 流内的终端 finishfinish { kind: 'error' | 'aborted' }这种 in-band 结束协议。

为了让 agent loop 能区分“这是适配器失败”还是“中间件/消费者自己的代码出错”,当时的做法是:LlmRuntime在一个**以流为键(stream-keyed)的 sidecar(旁路映射表)**里标记抛出的对象,消费方据此识别适配器失败。

这个机制的代价在原文档的 Problem 一节说得很直白:

  • 消费方仍然必须围绕迭代过程包一个 catch,而这个 catch 区域里混着自己的信号检查(signal checks)、chunk 日志、消息组装(assembly)等本身可能出错的代码
  • 正确性依赖于“证明到底是哪条语句抛了异常”,并去查询恰好附着在那个返回的 iterable 上的元数据——分类逻辑与 iterable 包装器的对象身份耦合;
  • 重试策略的所有权同样间接:prepareCall()明明已经捕获了 serving registration(服务注册),策略却只能在 dispatch 之后通过 stream sidecar 发现。于是 wrapper 拥有的路由和 adapter 拥有的路由共享同一个不透明查找 API,却拥有不同的权限(authority)

决策一:LlmRuntime 是“单次适配器尝试”的归一化边界

新设计的核心断言只有一句:LlmRuntime是单次适配器尝试(one adapter attempt)的归一化边界。它只 catch 四类失败:

catch 范围说明
最终适配器选择(final-adapter selection)路由/注册查找阶段的同步失败
同步 dispatch发起请求时的同步抛出
迭代器构造(iterator construction)构造AsyncIterator时的抛出
next()失败迭代过程中 SDK/transport 抛出

对落入上述边界的抛出值,LlmRuntime将其转换为不可变的LlmFailure,并恰好发出一个终端finish。原因(reason)的选择规则是:

  • 调用方取消(caller cancellation)或ABORTED失败 → 选择aborted
  • 其余一切适配器失败 → 选择error
  • 适配器也可以直接发出两种终端原因之一(in-band 路径不受影响)。

归一化函数在源码中对应 normalizeLlmFailure(@deepseek-ai/dsh-llm/adapter-failure模块),调用点在 LlmRuntime.stream 的实现 中(const failure = normalizeLlmFailure(error))。从源码实现看,这个函数体现了“归一化”的三个具体要点:

  1. 剥离对象身份,保留可序列化事实:抛出值如果不是Error,会包成带UNKNOWNcode 的HarnessError;函数最终返回Object.freeze(...)的纯净负载,原始 Error 对象不再跨越 LLM 流接缝。这正对应文档中“恢复机制放弃了精确的抛出对象身份,只暴露与 provider 无关的事实(detached provider-neutral facts)”;
  2. 防御宿主 SDK 的属性陷阱:读取error.codeerror.failureerror.message时全部走Object.getOwnPropertyDescriptor只读自有数据属性,任何 getter 抛出都被吞掉并回退到安全值(如消息回退为'LLM adapter failed')。这是对第三方 SDK 错误对象“可能具有恶意/异常 getter”的防御式处理;
  3. code 分类法只信任自有体系:harnessErrorCode 只认HarnessError实例的 code,第三方 SDK 的 code 一律归为UNKNOWN——因为“第三方的 code 不是我们的分类法(taxonomy)”。

LlmFailure负载本身的字段由 bounded LLM request recovery 笔记 定义,本文档不取代那部分:messagecode、可选的statusproviderRetryAfterMs、带品牌(brand)的requestId。这些结构化失败事实、重试策略、持久化尝试记录等,仍归前述笔记所有;本文档只取代其中“抛出异常的身份识别 + call-local sidecar”这一机制。

决策二:适配器拥有的 catch 在“每个 yield 的 chunk 之前”结束

这是边界设计中容易误解的一点。不是“适配器相关的所有异常都归一化”。文档明确:来自llm/stream中间件、嵌套调用、适配器清理(cleanup)、chunk 消费者、日志、信号检查和组装(assembly)的错误继续按缺陷(defect)或生命周期失败抛出,绝不进入模型请求恢复路径。

一个关键推论是流不变量(stream invariant)的放宽:由于 transport 在已经流出部分 delta 之后才失败,块(block)可能来不及闭合,因此流不变量只允许终端 error 或 aborted finish 时保留未闭合块;而且这种不完整输出永远不会被组装成 assistant 消息或 tool call——重试会从持久化日志重建下一个尝试,而不是拼接残缺输出。

决策三:重试策略挂在 PreparedLlmCall 上,替代 sidecar 查找

旧机制里,消费方要通过 stream sidecar 才能查到服务策略。新设计中:

  • PreparedLlmCall显式暴露不可变的重试策略,该策略与其 config 和 registration 在准备阶段一起捕获;
  • 一次性的 prepared call 被复用、或 config 不匹配,仍是同步的INVALID_PREPARED_CALL误用错误(misuse error);
  • 完全由llm/stream中间件服务的路由没有 prepared registration,因此也没有服务策略(serving policy)——中间件独占路由在类型层面就是“无策略”的,而不是查不到值。

这一点在 agent loop 源码中可以直接验证:agent loop 组装agent/request-error事件 时,retryPolicy直接取自preparedCall?.retryPolicy——策略随已准备调用到达扩展点,不再经过任何以流为键的元数据表。

策略本身的形态由 retry-policy 模块 定义:provider 拥有的、随注册解析的不可变策略,分normal(有界瞬时重试)与always(无界重试)两种模式。从当前源码可确认normal模式的默认值:maxRetries: 5initialDelayMs: 500maxDelayMs: 10_000jitterRatio: 0.1,默认可重试 code 集合为EMPTY_RESPONSERATE_LIMITSERVERTIMEOUTTRANSPORTbackoff的延迟上限被 Node 最大定时器延迟(MAX_TIMER_DELAY_MS)约束。策略的执行者是函数式插件@deepseek-ai/dsh-llm-retry(监听agent/request-error),它不引入新服务或新 loop 分支——这与本文档的归一化边界是互补关系:前者管“失败之后要不要重试、等多久”,后者管“失败事实从哪里来、以什么形态到达”。

决策四:agent loop 只消费一个失败表示

归一化边界建立后,agent loop 的消费路径被简化为三步:

  1. 迭代并记录 chunk,不带分类 catch——因为适配器操作失败已经全部变成终端 finish,catch 不再需要“猜这是谁的异常”;
  2. 检查终端 finish——如果带LlmFailure,则失败事实已经就绪;
  3. 把失败事实 + prepared 策略一起交给agent/request-error

同时,旧的三个公开 sidecar API——isLlmAdapterFailurellmFailureOfllmRetryPolicyOf——被删除。消费方删除了“识别哪个适配器抛出”的 catch 和流键控元数据,这是该重构在消费侧的主要收益。

考虑并否决的备选方案

原文档记录了四个被否决的替代方案,其否决理由本身就是理解设计约束的最好材料:

  1. 保留 call-local 错误标记——保留了抛出对象身份,但每个消费方都得 catch 一个包含自身易错工作的区域,且分类耦合在 iterable 包装器身份上。“原始错误对象在恢复中没有持久角色,归一化事实才是有用的边界值。”
  2. 要求所有适配器只发失败 chunk、禁止 throw——库迭代器、transport 和 JavaScript dispatch 本来就会抛。要求每个适配器复刻同一套 catch 边界等于复制所有权,也保护不了直接使用LlmRuntime的消费者。
  3. 在 agent loop 里 catch 所有迭代错误——loop 无法可靠区分 provider 失败、中间件失败、session append、取消或组装失败,除非重建一个“流对象 → 适配器调用”的 sidecar 映射。结论:分类应该发生在发起适配器调用的地方
  4. 在流之前返回Result——流前结果无法表示“部分输出之后的 transport 失败”,除非引入第二套响应生命周期。既有的终端 chunk 协议已经能表达“早失败”和“晚失败”两种尝试结果,不必再造一套。

后果与权衡

按原文档 Consequences 一节,这套取舍的账是:

  • 所有LlmRuntime.stream()消费者通过同一个类型化终端协议接收适配器操作失败,而编程错误和生命周期失败保留普通异常语义——两类故障从此有清晰的分界线;
  • 恢复机制放弃了精确的抛出对象身份,只暴露与 provider 无关的解耦事实(诊断原始错误仍留在 cause 链和会话日志中);
  • 流服务承担了略多的适配器管线代码(catch 边界内移),换来消费侧删除识别性 catch 和流键控元数据;
  • prepared call显式携带策略,纯中间件路由在类型上就“可见地无策略”,不再依赖不透明查找。

边界关系:与相邻恢复机制的分工

为避免与其他机制混淆,值得把三条笔记的分工钉死:

  • bounded LLM request recovery 继续拥有LlmFailure结构化事实、有界重试策略(瞬时 code 选择、指数退避 + 抖动、Retry-After处理)、llm/retry持久化事件与dsh-llm-retry插件;本文档取代其中的抛出错误身份与 stream-sidecar 机制;
  • after-call compaction pressure and context-overflow recovery 拥有上下文溢出恢复(CONTEXT_WINDOW_EXCEEDED归一化后走同一条agent/request-error路径,replaceGeneration变化作为重试凭证);
  • 本文档独占的只有:最终适配器边界的归一化、单一终端 finish、PreparedLlmCall上的策略携带、以及 sidecar API 的删除

对应的验证面在仓库中可查:归一化函数的单测 adapter-failure.spec.ts、服务层(含PreparedLlmCall误用检查)的 service.spec.ts,以及 agent loop 侧的 request-error.spec.ts——覆盖“thrown 与 in-band 失败统一到达agent/request-error、携带当前 prepared 策略”等场景。

小结

这篇架构笔记解决的是一个非常典型的 agent 框架问题:流式 LLM 调用的失败事实,应该在哪里被定型。答案是把它定型在离适配器最近、离消费语义最远的地方——LlmRuntime的 catch 边界——让上层(agent loop、重试插件、溢出恢复插件)只面对一种失败表示和一份随调用显式携带的策略。对于正在设计 LLM 编排层、为不同 provider SDK 统一错误语义的开发者,这套“归一化边界 + 终端协议 + 显式策略携带”的组合,比“在每个消费点各自 try/catch 再查元数据”的常见做法更易于推理和测试。

【免费下载链接】deepseek-harnessDeepSeek Harness: Everything is a Plugin.项目地址: https://gitcode.com/gh_mirrors/de/deepseek-harness

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询