3087 字
约 10 分钟
1
凭据、设置、存储与遥测
无标签

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,坏文件系统可以退到几种回滚日志模式,但 memory 和 off 被从类型上排除了(同文件第 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 伺候三个消费方,没用过就不落盘。

凭据、设置、存储与遥测
http://www.clxhxhhr.top/posts/4039/
作者
clxstart
发布于
2026-09-25
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。