DeepSeek Harness · 持久化与基建

凭据、设置、存储与遥测

不起眼但全是坑:凭据每次现取、配置不落盘。

课程目标读完你能说清三件事:为什么在 DSH 里轮换 API key 不需要重启任何进程;两个进程同时写设置文件,为什么不会互相抹掉对方的改动;以及一个匿名 UUID 怎么同时伺候遥测、反馈和 DeepSeek 请求头,还能做到你连 key 都没配时压根不被创建。
交互演示 · 凭据轮换演练

先玩再讲。上方是磁盘上的凭据文件,下面两个进程同时在跑请求:左边是 DSH 的做法,每次请求都回文件现取一遍 key;右边是很多程序的惯用做法,启动时读一次,存进内存用到死。脚本会在运行中途轮换一次 key、再把 key 清空,点播放,看两边各是什么下场。

磁盘上的凭据文件($DSH_HOME/.credentials.yaml,web 的 Models 页写的就是它) credentials/updated (DEEPSEEK_API_KEY)
DEEPSEEK_API_KEY:sk-live-01
DSH:每次操作现取
进程内存里不缓存 key,每个请求开始时回存储解析一次
(还没有请求)
对照组:启动时读一次
内存缓存:(进程未启动)
(还没有请求)
点「播放」,两个进程开始向 DeepSeek 发请求。
演示为教学化模拟:key 的值和请求内容是课程虚构的 fixture,但左侧缺 key 时的那条报错逐字复刻自 packages/llm/llm-deepseek/src/index.ts 第 241 至 245 行的源码模板。真实 DSH 没有右边这个「启动时读一次」的进程,它是用来对照的反面教材。
逻辑拆解 · 配置里只有引用,值每次现取

先说清一件事:DSH 的设置文件和 cordis.yml 里没有任何一处写着 API key 的值。它们携带的是引用,一个 POSIX 风格的环境变量名,比如 DEEPSEEK_API_KEY。值归凭据提供方所有,本地提供方按四层来源找:进程环境优先级最高,然后是 $DSH_HOME/.credentials.yaml 文档,最后是项目和用户的 .env。这就是副标题说的「配置不落盘」:落盘的只有名字,机密被挡在配置之外(docs/subsystems/credentials.zh.md 第 5 行)。

然后是本课最重要的一条规则:消费方在每个操作中重新解析引用,绝不跨操作缓存。文档原话说得很直白,这种按操作进行的读取正是热更新机制(同文档第 20 行)。落到 DeepSeek 适配器上,就是 packages/llm/llm-deepseek/src/adapter.ts 第 214 至 222 行:每次 stream() 开头,把连接配置和 key 一起冻成一份快照,这个请求从头到尾用这一份,下一次请求自动重新解析。

大纲里问的边界条件在这里有了答案。请求进行到一半你轮换了 key,本次请求拿旧 key 跑完,新 key 从下一次请求开始生效,中间不会出现半新半旧。而且 key 是从连接快照里解析出来的,端点和发给它的密钥永远来自同一代配置,配置回滚时不会出现新端点配旧 key 的杂交(该处注释写明了这个意图)。

还有两条容易忽视的 seam 级规则。第一,空的存储值在任何地方都视为不存在,把 key 设成空字符串等于没配,下一次请求直接报 MISSING_CREDENTIAL,演示最后一步就是它。第二,配置界面走 describe(ref),只回「配没配、来自哪层、能不能写」,绝不回值;由进程环境供值的引用被报成 writable: false,因为往那里写会表面成功、而解析继续返回环境里的旧值,seam 干脆提前拒绝(同文档第 34 行)。

最能看出这套架构干净的是 credentials/updated 事件(同文档第 50 行)。凭据变更时确实会发事件,但文档专门写了一句:消费方不需要它,它只服务于配置界面刷新「已配置」徽标。热更新靠的是读取时机,压根不靠通知广播,没有失效消息要追、没有订阅要管理。

每次操作现取

轮换 key 免重启,下一次请求自动用新值。进行中的请求用同一代快照跑完,端点和密钥永不杂交。

空值 = 未配置

seam 级规则,处处一致。缺 key 报 MISSING_CREDENTIAL 并点名配置入口;describe 回答一切但绝不回显值。

一个匿名 id 三个消费方

OTel 的 user.id/feedback 回执、DeepSeek 请求头共用一个 UUID,懒创建:没成功用过就不落盘。

关键证据 · 解析发生在每次请求里

这段在 resolveApiKey 函数体内(第 225 行起),每次模型请求都会走一遍:挂了凭据 seam 就向它现解析,没挂 seam 就退回启动环境变量。注意 else 分支里的注释,没有 seam 时不存在可排序的托管存储,环境就是全部的凭据平面:

packages/llm/llm-deepseek/src/index.ts第 230 至 240 行
    if (credentials !== undefined) {
      const hit = await credentials.resolve(ref)
      if (hit !== undefined) return assertUsableApiKey(hit.value, 'llm-deepseek', ref)
    } else {
      // Without the seam there is no managed store to rank against, so the
      // environment is the whole credential plane.
      const ambient = launchEnvironmentOf(ctx).get(ref)
      if (ambient !== undefined && ambient.value.length > 0) {
        return assertUsableApiKey(ambient.value, 'llm-deepseek', ref)
      }
    }

两条路都落空,紧跟着抛出的就是 MISSING_CREDENTIAL(第 241 至 245 行),报错把两个配置入口都写在话里,演示左侧最后那条红字就是它的原文。

源码快照说明:依据本地仓库 deepseek-harness-master,核对文件 packages/llm/llm-deepseek/src/index.ts,核对日期 2026-08-13。代码块保留源码原文,为 resolveApiKey 函数体节选。
设置文件 · 谁都能写,谁也别抹掉谁

设置是另一处坑。用户拿编辑器改 settings.yaml,web 界面也在改,两个 harness 进程可能同时开着。朴素实现是把内存里的设置快照直接序列化写回,后写的赢,把先写的整段抹掉:你在编辑器里刚加的配置,被另一个进程一次保存冲得干干净净。

DSH 的写路径把这条堵死了(Agent Note 2026-07-30-settings-write-path-integrity.md)。每次写盘之前先重读磁盘、合并外部改动,然后在一把跨进程文件锁里完成「读、渲染、原子提交」一整轮。锁的实现是 withFileLock:用 wx 标志独占创建 <文件名>.lock,创建成功即持锁;别人占着就指数退避重试,从初始延迟一路翻倍到上限,超过时限报错。读者不参与抢锁,提交靠临时文件 rename 原子替换,读到的永远是完整的一版。

有个细节值得停一下:等锁超时后,它宁可报错也不删掉别人的锁文件。函数上方的注释给了理由,锁文件的年龄证明不了它的主人已经死了,抢占一把还活着的锁比等待超时危险得多,清理孤儿锁是运维动作。又是熟悉的配方:拿不准,宁可吵闹地失败,别静默地闯祸。

出处:packages/util/atomic-write/src/index.ts 第 86 至 111 行的 withFileLock,核对日期 2026-08-13。

存储与遥测 · 拒绝迁移,一个身份

KV 存储的 SQLite 后端把版本立场延续了下来。STORAGE_SQLITE_SCHEMA_VERSION 当前是 1,写在 PRAGMA user_version 里;打开数据库时,全新的空库盖上当前版本戳,其他任何版本一律拒绝打开,没有就地迁移。和上一课的会话日志版本是同一套哲学:未发布软件没有需要保全的历史数据,与其背着一堆迁移代码,不如明确拒绝。

还有一处小而硬的取舍:journal 模式默认 WAL,坏文件系统可以退到几种回滚日志模式,但 memoryoff 被从类型上排除了(同文件第 23 至 29 行注释)。理由一句话,扔掉日志持久性会静默违反 KV 后端合同里的持久性条款。想快可以,想快到说谎不行。

遥测这块最怕的是喧宾夺主,DSH 把它做成一项可选能力 seam:不在 agent loop 主干上,没有任何遥测内容会进入模型请求,harness 的职责到 emit() 为止(docs/subsystems/session-telemetry.zh.md)。每条记录导出前要过一道脱敏流水线,部署方挂规则监听器;监听器抛异常按 fail-closed 处理,直接扣下这条记录不发。脱敏只改导出副本,权威会话日志一个字都不动。

最后是匿名身份的设计。一个随机 UUID v4 落在 $DSH_HOME/.anonymous-user-id,三个消费方共用:OTel 上报的 user.id/feedback 命令的确认回执、以及每次发往 DeepSeek 的 x-deepseek-harness-user-id 请求头(packages/identity/anonymous-user-id/README.zh.md)。共用一个 id,接收侧才能把三路记录关联起来,不用各自生成三个身份。

妙在创建时机。llm-deepseek 里这个 id 是懒创建的,userId ??= getOrCreateAnonymousUserId(),第一次真正要用才生成文件(index.ts 第 248 至 249 行);而 stream() 里凭据解析排在身份解析之前(adapter.ts 第 221 至 222 行)。连起来看:一台从没配过 key 的机器,发起的请求在凭据那步就失败了,磁盘上不会平白多出一个跟踪身份。工具还没为你干过一件事,就先给你编了个号,这种事 DSH 不干。

横向对比 · 别家怎么伺候凭据

Grok Build

凭据走 AuthCredentialProvider 接口(crates/codegen/xai-grok-auth/src/auth_provider.rs)。接口文档要求实现方在每次取快照前做一次廉价的磁盘重读,让 grok-desktop、grok login 这些兄弟进程写入的新凭据能被当前进程看到,方向和 DSH 的按操作重解析一致。

它还多一层事后兜底:refresh_after_unauthorized(),请求吃到 401 就尝试刷新 token 并重试一次,主要伺候会过期的 OAuth 场景。事前现取加事后重试,比单靠缓存的方案稳得多。

Claude Code

它的功课做在启动那一刻:utils/secureStorage/keychainPrefetch.ts 在进程启动时并行发出 macOS Keychain 读取,跟约 135ms 的模块 import 同时跑,业务代码真正要用时才等结果,把原本约 200ms 的串行读省到接近零(书稿第 1 章启动分析)。

优化方向和 DSH 相反:它在乎启动那一次读多快,DSH 在乎轮换后下一次读多对。终端产品重启成本低、凭据轮换少,预取加缓存划算;基建进程长时间驻留,重启要中断所有会话,每次现取划算。两边都对,因为伺候的场景不一样。

课堂练习
01

轮换了 key,为什么没生效

你的部署在启动脚本里 export DEEPSEEK_API_KEY=旧key,后来又在 web 的 Models 页写过一份新值到 .credentials.yaml。现在旧 key 泄露要紧急吊销,你在 Models 页填了新 key,保存成功,但下一次请求用的还是旧的。推演原因:四层来源里进程环境优先级最高,文件层写得再新也排在它后面。再想想界面本可以怎么救你:describe 会把这个引用报成 writable: false,界面提前把输入框渲染成只读,你就不会白填了。真正的出路是改启动环境,或者别在环境里放这个变量。

Takeaway:配置里只存引用,值每个操作现取一次,轮换免重启,热更新靠读取时机而非通知广播。设置写盘先合并外部改动,再在跨进程文件锁里做原子提交,孤儿锁宁可超时报错也不抢占。存储 schema 非当前版本拒绝打开,不做就地迁移。遥测止于 emit()、脱敏 fail-closed,一个懒创建的匿名 id 伺候三个消费方,没用过就不落盘。