夜雨聆风学习资料网

ARTICLE · 1247850

DeepSeek-Harness 源码专题(7):换模型不用重启,下一次请求现取快照

DeepSeek-Harness 源码专题(7):换模型不用重启,下一次请求现取快照

摘要:适配器不存配置,stream() 每次现取连接快照:改 settings.yaml 或换 key,下次请求即生效,在途流拿旧快照跑完;key 与 endpoint 同代冻结,不会新 key 打旧 URL;Agent 换模型在步边界改写;无自动 failover,重试预算记在日志。

还是那个清理 47 个 deprecated 调用的会话。清理推到 60 多个文件,跑了一下午,DeepSeek 官方 key 的限流告警来了:429 一阵一阵,退避越退越长。

你面前两条路:把 settings.yaml 里的 baseURL 切到自建网关,或者去 web 端 Models 页换一个新 key。哪条都不用停进程。改完的那一刻,一个测试文件的分析正在流式输出。

问题就来了:正在吐字的那一步会怎么样?改动多快生效?进程凭什么不用重启?

先给结论。那一步拿旧配置完整跑完,因为它发出请求前就把连接事实取成了一份快照;下一次请求组装时,第一行代码重新解析,新配置已经在手边;进程不重启,因为适配器从头到尾没保存过任何配置。上一篇《没有插件市场,AI 现场写一个给自己装上》里,我们看着 agent 现场写插件、当场装上;这篇把「现场换」推到最底层:模型本身。三段源码,我们挨个看。

一、适配器不存配置,stream() 第一行现取

《直连 DeepSeek 的适配器,一行重试都没写》里说过,这个适配器是纯传输层:拿到请求就发,拿到流就转。这次看它更省事的一面:它连配置都不持有。

看 stream() 的入口(adapter.ts:228-234):

  async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {    // One resolution per stream call: connection facts and the credential    // freeze here and hold for this whole request, so an in-flight stream    // never observes a configuration change and the next call re-resolves.    // The key resolves *from this snapshot*, so an endpoint and the secret    // sent to it can never come from different configuration generations.    const connection = this.config.options()

这段是每次模型请求的起点(adapter.ts:228-234)。注意看最后一行:this.config.options() 是一次函数调用,不是读一个字段。适配器拿到的从来不是一份配置,而是一个「去哪拿配置」的函数;每次 stream() 调用,现场调用一次,取回一份快照。

注释把两条语义说死了。第一,一次调用一份快照,整次请求从头用到尾,在途的流看不到配置变化,下一次调用重新解析。第二,key 也从这份快照里解析,所以 endpoint 和发往它的密钥永远来自同一代配置。

开篇第一问有了答案:正在流式输出的那一步,握着启动时的快照跑完,中途什么都不感知。第二问的答案也埋在这行里:生效点就是下一次 stream() 的第一行。剩下的只是追问:options() 背后读的是哪份配置,改动怎么流进去的?

二、改动怎么流进来:换源,不换在途请求

你保存 settings.yaml 的瞬间,settings-file 插件的文件 watcher 等写稳之后重读文件、发布新的配置 section;写稳窗口怎么防抖、三方同写怎么对账,在《三方同写一个 settings.yaml,谁的修改都不会丢》里拆过。我们直接跳到 llm-deepseek 收货的那一端:

  installSettingsSection(ctx, NS, Config, config, {    setSource: (source) => {      current = source    },    onChange: ensureRegistrationFacts,  })

这段是配置源的换装口(index.ts:307-312)。新 section 到达,setSource 把 current 换成新的取值函数。current 存的始终是「取配置」这个动作的最新版本,配置内容本身从不在这里落脚;旧快照还握在在途请求手里,不受影响。

那每次 stream() 都要完整解析一遍配置吗?看解析入口(index.ts:236-243):

  const options = (): ResolvedDeepSeekOptions => {    const raw = current()    if (raw === lastRaw && lastGood !== undefined) return lastGood    try {      const next = resolveAdapterOptions(raw, launchEnvironmentOf(ctx))      lastRaw = raw      lastGood = next      return next

这段是快照的生产线(index.ts:236-243)。第三行按对象身份做 memoize:settings 每次发布的是一个新对象,指针没变就直接返回上一次的结果。整个「缓存」就这两个变量,自然也就没有任何失效逻辑可写。

解析失败了怎么办?比如你在 YAML 里把 maxTokens 写成负数。紧接着的 catch 分支(index.ts:244-253):

    } catch (error) {      // Static composition resolves before anything registers, so this branch      // only sees a live settings snapshot failing a beyond-schema bound:      // keep serving the last good facts and say so once per bad snapshot.      if (lastGood === undefined) throw error      lastRaw = raw      ctx.logger.error('llm-deepseek: keeping the last good configuration after an invalid settings section')      ctx.logger.error(error)      return lastGood    }

这段是失败兜底(index.ts:244-253)。注释写明这个分支只接「活配置越过 schema 边界」的失败(静态组装期的问题在插件装的时候就直接抛了):沿用最后一代好配置,日志告警一次。配置要么整代换新,要么整代保持,半新半旧上不了线。

到这里还有个口子没堵:baseURL 在 options() 里就落定了,key 却要绕道 credentials 服务去取,两条路径两个时点,凭什么不会新 URL 配上旧 key?

三、同代冻结:新 key 打不到旧 URL

先看类型定义里的一段注释(adapter.ts:56-62):

  /**   * Credential reference of this same resolution, resolved per request.   * Travelling with the endpoint is the point: a request can never pair one   * generation's URL with another generation's secret. Configuration carries   * only this name — a literal key is not a configuration value.   */  apiKeyEnv: CredentialRef

这段是 endpoint 的随行清单(adapter.ts:56-62)。配置里存的是 key 的名字(比如 DEEPSEEK_API_KEY),不是 key 本身;「与 endpoint 同行」就是设计目的,注释原话:一次请求永远无法把一代配置的 URL 和另一代的密钥配成一对。

名字什么时候变成真 key?回到 stream() 里,快照取完之后有一行 const apiKey = await this.config.resolveApiKey(connection)(adapter.ts:253)。实现只认传入的快照(index.ts:257-264):

  const resolveApiKey = async (connection: ResolvedDeepSeekOptions): Promise<string> => {    // Every credential fact comes from the caller's snapshot, so a rejected    // settings generation cannot leak its key onto the previous endpoint.    const ref = connection.apiKeyEnv    const credentials = ctx.get('credentials')    if (credentials !== undefined) {      const hit = await credentials.resolve(ref)    // …(无 seam 时的环境变量回退与 MISSING_CREDENTIAL 报错略)

这段是 key 的解析入口(index.ts:257-263 节选)。函数签名只接受调用方传进来的快照,注释原话是「被拒的一代设置无法把它的 key 泄漏到上一个 endpoint 上」。整条链闭合了:上一节的 catch 保证 options() 吐出来的永远是完整一代;resolveApiKey 又只从这一代里取 key 的名字。URL 和 key 同代,错配在类型上就不存在。

credentials 那头同样是热的。你在 web 端 Models 页换 key,credentials-local 的文件 watcher 触发 reconcileFromDisk:重读文件、整表替换内存里的映射、按变更的 ref 逐个通知(credentials-local/src/index.ts:886-908);resolve 时三层取值,环境变量、凭证文件、dotenv 兜底(credentials-local/src/index.ts:617-625)。名字到真 key 这一步也是每次现取,和 options() 同构。

单 provider 的账算完了。pi-ai 那种多 provider 路由是另一回事:增删一个 provider,改的是路由集,而路由集是注册期装进 registry 的东西。

四、路由集热增删:同实例原地 replace

llm-pi-ai 的 settings section 是一个 providers 字典(config.ts:320),往里加一个 provider 配置块,路由集就多一条。路由集和每条路由的 retryPolicy 都是注册期事实,registry 在 registerAdapter 那一刻捕获。配置变了怎么办?看 ensureRegistrationFacts 的换轨动作(llm-pi-ai/src/index.ts:270-282 核心,冷启动分支略):

    const routes = [...profiles().keys()]    if (registration === undefined) {      // …(裸挂载冷启动分支略)      registration = ctx.llm.registerAdapter(routes, adapter)    } else {      registration.replace(routes)    }    registeredFacts = facts

这段是路由集的换轨(llm-pi-ai/src/index.ts:270-282 核心)。函数每次先做一次事实比对(deepEqualJson,:263),没变直接返回,registry 一个指头都不动;变了才走 replace。注意动作本身:同一个适配器实例的原地替换,验证通过才动,registeredFacts 只在 registry 真正持有新集合之后才推进。注释把反面写得很清楚:先 dispose 再 register,两步之间会发布一个空路由集,观察者会看到这个 provider 消失又出现。顺带一提,profiles() 也是同款 memoize(llm-pi-ai/src/index.ts:156-164),路由名的解析成本同样压到一次指针比较。

llm-deepseek 那边有个更细的例外:retryPolicy 是注册期捕获的,请求期解析刷不动它,所以 retryPolicy 变化时走一个同步注册段做 replace 重读(llm-deepseek/src/index.ts:294-305),注释给出的理由同款,拆开重装会露出空窗。《写一个新后端,只要三个钩子》拆过 provider 的注册面;这篇补上的是分界线本身:路由集是注册期事实,变了要 replace;连接与密钥是请求期事实,永远现取。

路由集热得动了。再往上一层是 agent 本身:它正在跑,每一步都在选模型。

五、Agent 层换模型:与 steer 同一个步边界

场景还是那个清理会话。跑了一下午你发现 reasoner 在这种机械替换上性价比不行,想换回 chat,同样不用停:改配置,或者直接在界面上选。选择落进 agent 的方式在 model-selection.ts,两个作用域监听器。第一个挂在 system-prompt/assemble 上:组装提示词之前快照 selection.current,组装完写进 selection.assembled(model-selection.ts:40-53)。第二个挂在 agent/request 上,这是真正改写请求的地方(model-selection.ts:57-68):

      const resolved = await next()      const selected = selection.assembled      if (selected === undefined) return resolved      const { reasoningEffort: _inheritedEffort, ...withoutInheritedEffort } = resolved      return {        ...withoutInheritedEffort,        provider: selected.provider,        model: selected.model,        ...selected.reasoningEffort === undefined          ? {}          : { reasoningEffort: selected.reasoningEffort },      }

这段是请求级的模型改写(model-selection.ts:57-68 节选)。注意两个时点。改写读的是 selection.assembled,也就是提示词组装那一刻钉下的选择:提示词里的模型名和请求实际打的模型来自同一份快照,docstring 原文说 a concurrent switch takes effect on a later step instead of splitting the two surfaces,并发的切换落到后面的步上,两个表面不被劈开。改写又发生在请求组装期:正在生成的步不会中途换脑,新选择落进下一步。

这就对上了《正在干的 agent 递条子,它凭什么不停手》里的 step 边界:递条子要等步边界才进请求,换模型也等同一个边界。agent-loop 每步之间重组一次请求,边界两边各干各的。

到这里「换」字拆完了。还剩一个危险的想象得按住:既然切换这么顺,那切过去的自建网关也 429 了,运行时会自动切回官方吗?

六、没有自动 failover:退避归机器,换人归人

不会。整个仓库找不到一条跨 provider 的自动切换链。热切换解决的是「人的决定尽快生效」,决定本身永远留给人;机器自动做的只有同 provider 的指数退避,在 llm-retry 插件里。它的计数方式值得单独看(llm-retry/src/index.ts:182-191):

    const priorPolicyRetry = agent.session.events.findLast((event): event is SessionEvent<'llm/retry'> =>      event.type === 'llm/retry'      && event.data.turn === turn      && event.data.step === step      && event.data.provider === provider      && event.data.policyKey === policyKey,    )    const previousRetry = priorPolicyRetry?.data.retry ?? 0    if (policy.mode === 'normal' && previousRetry >= policy.maxRetries) return next()    const retry = previousRetry + 1

这段是重试预算的账本(llm-retry/src/index.ts:182-191)。注意看 findLast:重试计数住在会话事件流里,每次从 agent.session.events 现查最近一条 llm/retry,按 turn、step、provider、policyKey 四个键匹配。《守不变量的不是测试,是编译器和 Object.freeze》拆过的 append-only 事件流,这里又一次当账本用。效果是重试预算跟着日志走:进程崩了、会话重放了,预算照样在,不会清零再来一轮。

顺序也讲究:先 agent.session.append('llm/retry', …) 落盘,再进 cancellableDelay 等待(index.ts:150-151)。先记账后睡觉,进程死在等待里,这条重试也已经记上了。退避时长服从 provider 的 retry-after 口径,超了 maxDelayMs 才落回本地指数曲线(index.ts:194-199)。

为什么不做自动 failover?源码里没有写答案的注释。把这篇的机制读完,一个说得通的读法是:换 provider 改变的是输出质量、成本、行为,属于语义决定;这套设计里运行时只负责把决定无感地落地。决定与执行分开,热切换的边界才是清楚的。

七、对账:哪些注册期定,哪些请求期取

把整篇的事实归一下账:

事实归属变化时的动作
路由集(provider 列表)注册期registration.replace 同实例换
每路由 retryPolicy注册期同步注册段 replace 重读
baseURL、模型默认值、上下文窗口请求期下一次 stream() 现取
key(按名字解析)请求期resolveApiKey 从快照取
Agent 选中的模型组装期(下一步改写)agent/request 读组装期快照

请求期解析长什么样?以 baseURL 为例(index.ts:216-218,返回对象中段节选):

    baseURL: config.baseURL      ?? environment?.get(BASE_URL_ENV)?.value      ?? PUBLIC_BASE_URL,

这段是三层取值(index.ts:216-218):配置显式给的优先,其次启动环境里的变量,最后落回公共端点。模型目录、上下文窗口默认值,全在这份返回对象里,同样免重启。

回头看会发现,热切换没有一个专门的模块。llm 家族里找不到一个叫 refresh 或 invalidate 的函数,因为从来没有一份常驻缓存在等它失效;代价只是每次请求多一次 memoize 检查,一次指针比较(index.ts:238)。把配置读取从注册期挪到请求期,这条纪律顺着走,热切换是它的副产品。

结语

回到主线。那条流式输出到一半的分析,握着切换前的快照把字吐完了;你保存 settings.yaml 之后的下一次请求组装,this.config.options() 一调用,新网关的 baseURL 已经解析完毕。60 多个文件的清理没停一秒,进程没重启,模型换了。

47 个 deprecated 调用清到这儿基本见底。但复盘时有个问题一直没碰:清理过程中模型跑的那些 bash 命令,删文件、改 fixtures、起测试进程,是在什么隔离下跑的?万一某次替换路径写歪了,凭什么伤不到仓库外面?这是专题的收官一篇:《沙箱不写一句禁令,让内核自己当狱警》,三层沙箱,优先 bwrap,降级 Landlock,配不上就拒绝启动。下篇见。

你有过模型跑到一半非换 key 不可的时刻吗,当时是重启了事,还是硬扛到跑完?评论区聊聊。


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

相关学习资料