DeepSeek Harness 的重试与超时默认值:退避、抖动与流式空闲

2026-08-16
站内工具 AI 编程工具报错分诊器 → 把报错原文贴进去,先分清是网络、额度、配置还是上游故障,再决定往哪个方向查。

翻一个 Agent 框架的重试实现,最怕的是它把退避逻辑散在十几个 catch 分支里,你想知道”这次失败到底会不会重试、等多久”,得把调用链从头读到尾。

DeepSeek Harness 在这件事上收得比较紧:LLM 请求的重试参数全部落在 packages/llm/llm/src/retry-policy.ts 的四个常量加一份错误码数组上,退避公式落在 packages/llm/llm-retry/src/index.ts 的几行里,超时则完全是另一套东西——由两个适配器各自的一个常量控制,跟重试没有共用配置。

先说清楚前提:截至我们核对的 2026-08-16,这个仓库建立于 2026-08-13,版本号是 0.1.0-rc.5,一个 GitHub Release 都没有发过,README 自述处于开发者预览阶段并明写未来会出现破坏兼容性的变更。下面提到的每一个常量名、默认值和错误码,都可能在后续版本里改掉。这篇文章讲的是”这几行代码现在是怎么写的”,不是”你配成这样就没问题”。

四个常量决定了默认重试长什么样

packages/llm/llm/src/retry-policy.ts 里的默认值,逐行对应如下:

常量行号
DEFAULT_MAX_RETRIES2:14
DEFAULT_INITIAL_DELAY_MS500:15
DEFAULT_MAX_DELAY_MS10_000:16
DEFAULT_JITTER_RATIO0.1:17
DEFAULT_RETRYABLE_CODES[EMPTY_RESPONSE, RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT]:18-24

这张表值得记住的不是数值本身,而是它的位置:这是代码里的默认值resolveRetryPolicy()(:145)在配置缺省时返回的就是一份冻结的 normal 默认策略。它不是对”你调用时会经历什么”的承诺——供应商那边发生了什么、网络怎么抖,这几个数字管不着。

策略有两种模式(:37-54)。normalmaxRetriesretryableCodesbackoffalways 只有 backoff,没有有限上限。也就是说,“重试几次”这个概念只存在于 normal 模式里。

校验规则写得挺细,值得配置时对照:retryableCodes 不得为空、不得含空串、不得重复(:166-174);initialDelayMs 必须小于等于 maxDelayMs(:129-131);jitterRatio 必须落在 [0,1] 区间(:132-134);各延迟不得超过 MAX_TIMER_DELAY_MS。这几条是在解析期就拦下来的,不会等到真的重试时才发现配错了。

退避公式:指数、封顶,再乘抖动

执行侧在 packages/llm/llm-retry/,插件名 llm-retryinject = ['agents']src/index.ts:20-21)。它的 localDelay(:58-63)是这么算的:

先算指数,Math.min(retry - 1, 1024);再算指数退避,exponential = min(initialDelayMs * 2 ** exponent, maxDelayMs);然后乘一个抖动因子 1 - jitterRatio + 2 * jitterRatio * random();最后再对 maxDelayMs 取一次 min。

有两处细节容易被读漏。

一是 maxDelayMs 被取了两次 min:一次在乘抖动之前,一次在乘抖动之后。抖动因子在 jitterRatio = 0.1 时的取值范围是 0.9 到 1.1,上浮的那一半会被后一次 min 重新压回上限内。所以抖动只在没触顶时才双向生效,触顶之后实际只剩向下的一半。

二是那个 Math.min(retry - 1, 1024)。在 normal 模式下 maxRetries 默认是 2,指数走不到 1024 这个上界;而 always 模式(:37-54)没有有限的次数上限,指数会随重试轮次一直增长,这个夹取只在这一侧才有机会生效。代码为什么写成 1024、作者是怎么权衡的,仓库里没有对应注释,我们不替它解释。

哪些错误算”可以再来一次”

默认的 DEFAULT_RETRYABLE_CODES 是五个:EMPTY_RESPONSERATE_LIMITSERVERTIMEOUTTRANSPORT

EMPTY_RESPONSE 出现在这份清单里是这套设计里比较有意思的一条。docs/subsystems/llm-streaming.md:206-216 列的七条适配器义务里,第七条明写:空补全是可重试错误而不是静默成功——终态是 stop 且一个内容块都没有的流,要以 finish {kind:'error'} 结束并带上 EMPTY_RESPONSE 码,dsh-llm-retry 默认会重试它。对应的常量在 packages/llm/llm/src/error.ts:39EMPTY_RESPONSE_CODE = 'EMPTY_RESPONSE')。

反过来,同一份义务清单的第六条说,上下文溢出只有一个规范码 CONTEXT_WINDOW_EXCEEDED,消费方按 code 路由、绝不解析供应商返回的文本。这个码不在可重试清单里——同样的请求再发一遍不会变短,这是符合直觉的。

那么这些 code 是从哪来的?两个随仓适配器的做法不一样,这一点直接影响你排查时该往哪看。

dsh-llm-deepseek 走的是 HTTP 状态码映射,packages/llm/llm-deepseek/src/adapter.ts:138-149httpErrorCode:401 与 403 映射为 AUTH;命中额度文本映射为 QUOTA;429 映射为 RATE_LIMIT;400 且命中上下文溢出文本时映射为 CONTEXT_WINDOW_EXCEEDED,否则 INVALID_REQUEST;状态码大于等于 500 映射为 SERVER;其余一律 HTTP_<status>。注意最后这一条:一个没被前面几条命中的状态码,会拼成 HTTP_<status> 形式的码,而这类码不在默认可重试清单里。

dsh-llm-pi-ai 那边是 packages/llm/llm-pi-ai/src/stream.ts:39-61classifyPiAiError(),按文本匹配分类:401/403 到 AUTH,额度文本到 QUOTA,429 或 rate limit 到 RATE_LIMIT,400 或 invalid request 到 INVALID_REQUEST,5xx 到 SERVER,timeout 到 TIMEOUT,“stream ended before/without …”以及一组网络词(含 undici 的裸 terminated、Node 的 Premature close)到 TRANSPORT,兜底是 PI_AI_ERROR

同文件 :31-38 有一段 XXX(pi-ai upstream) 注释解释了为什么只能这么写:pi-ai 把捕获的错误摊平成了 error.message,原始 Error 与 cause 链都丢了,所以拿不到 code 来分类。注释里还写着,如果 pi-ai 以后转发原始 Error,就改回按 code/cause 分类。

对照上一节那份清单可以看出:兜底码 PI_AI_ERROR 不在默认可重试之列。按这两处代码的语义,一条措辞没有被那组网络词命中的错误会落到兜底码上,也就不会进入默认重试的范围。

供应商说”等 X 秒”的时候

LlmFailurepackages/llm/llm/src/types.ts:40)有一个 providerRetryAfterMs? 字段,专门承载供应商给出的等待时长。

dsh-llm-deepseek 的解析在 adapter.ts:117-125:纯数字按秒处理,乘 1000;否则按 HTTP 日期解析后减去当前时间;结果非有限或非正的,一律返回 undefined。也就是说一个已经过期的 HTTP 日期不会变成负延迟,而是直接被丢掉。

拿到这个值之后的处置在 packages/llm/llm-retry/src/index.ts:197-202,这里的分叉是我读这个包时最没料到的一处:如果 providerRetryAfterMs 大于 maxDelayMsnormal 模式会放弃重试(走 next()),而 always 模式改用本地退避。

按常见写法,供应商说等久一点,客户端就等久一点。这里的选择是另一种:normal 模式下 maxDelayMs 的默认值是 10_000,供应商要求的等待一旦超过它,这次就不重试了,把失败交给上层。这意味着 maxDelayMs 在这套代码里不止是”退避上限”,它还兼任了”愿意为一次供应商级等待付出的时间上限”。改它会同时牵动这两件事——这一点在事实层面就是这样,具体该配多少取决于你的用法,项目没有给通用值。

每一次计划中的重试还会在会话里留痕(:150-153):先 agent.session.append('llm/retry', ...) 落盘,等待完成后再 append('llm/retry-started', ...),然后返回 { kind: 'retry' }。两条事件是分开的——落盘在前、开始在后。

超时是另一套东西

重试参数在 packages/llm/llm/,超时参数在两个适配器包里,二者没有共用常量。

两个适配器各自定义了 DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000:一处在 packages/llm/llm-deepseek/src/adapter.ts:89,另一处在 packages/llm/llm-pi-ai/src/config.ts:35。数值相同,但是两份独立的常量声明,不是从公共包里导入的同一个。

docs/subsystems/llm-streaming.md:206-216 的第五条义务对应的是这件事:供应商停顿在传输层有界,两个随仓远端适配器都暴露正有限的 streamIdleTimeoutMs,默认五分钟;看门狗只在迭代器 next() 未决期间上膛;看门狗自身的超时映射为 TIMEOUT,而更早发生的调用方 abort 保留为 ABORTED

“只在 next() 未决期间上膛”这半句是关键。它计的不是整次请求的总时长,而是两个块之间的间隔——消费方还没把上一个块处理完、没有回来要下一个的时候,看门狗是不上膛的。按文档这条语义,这个默认值约束的是块与块之间的空档,不是整次请求的总时长;至于真实运行时会是什么样子,我们没有运行过这套代码,不做判断。

实现侧:idleWatchdog(...) 分别在 llm-deepseek/src/adapter.ts:227llm-pi-ai/src/adapter.ts:299 被调用,两边的超时码常量都是 'LLM_STREAM_IDLE_TIMEOUT',对外都映射为 TIMEOUTllm-deepseek/src/adapter.ts:94:248llm-pi-ai/src/adapter.ts:348)。

TIMEOUT 恰好在默认可重试清单里。所以这两套机制在这里接上了:空闲超时先由适配器判定并转成 TIMEOUT,再由 dsh-llm-retry 按 code 决定要不要重来。ABORTED 则不在清单里,调用方主动取消不会被当成故障重试。

这些东西该配在哪一层

最后是一处容易配错的地方。dsh-llm-retryConfig空对象类型packages/llm/llm-retry/src/index.ts:24、:27)。你如果在这个插件下面写 retryPolicy,会被明确拒绝,提示信息说它”belongs under each provider configuration”(:32-34)。

对应到 pi-ai 侧,packages/llm/llm-pi-ai/src/config.ts:65-141 的每个 profile 字段表里确实有 retryPolicy?,同时还有三个跟时间有关的字段:timeoutMs?websocketConnectTimeoutMs?streamIdleTimeoutMs?。也就是说重试策略与超时都是 per-provider 的,不是全局一份。

同一个文件 :276-291 还记着两个被显式拒绝的旧字段:profile 里写 maxRetriesmaxRetryDelayMs,会报”已移除、请改用 dsh-llm-retry”。提示原文就是这么写的:这两个字段已经不在 profile 这一层收了。如果你手边参考的是更早的写法,解析期就会直接撞上这条拒绝。

还有一层分工要清楚:第四条适配器义务写着”一次适配器调用 = 一次供应商尝试”,适配器要关闭库自带的重试;直接调用 ctx.llm.stream() 的调用方拿到的就是单次尝试。重试是由 dsh-llm-retry 监听 agent/request-error 之后叠上去的一层(packages/llm/README.md:7-13 的表格里,llm-retry/ 那一行的角色原文写的是 provider-scoped retry policy)。所以”我调了 stream 怎么没重试”这个问题,答案在分层上,不在参数上。

至于这几个默认值该不该改、改成多少合适——retry-policy.ts 里就是这四行,项目没有给出适用于各种部署的推荐值,我们也没有运行过这套代码,没有依据给建议。能确定的只是:改 maxDelayMs 会同时改变退避上限与”供应商 retry-after 超限就放弃”这条分支的触发点,改 retryableCodes 时要对着上面两张 code 映射表看清楚你加进去的码到底由谁产生。

延伸阅读


本文依据 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?报名体系课或加入会员,照着学、照着用。