三个看似无关的日常故障,其实出自同一套设计取舍。
这一层平时没人关心,因为它是基建。但它一旦设计歪了,后面每个功能都要替它擦屁股。本集要解释的问题是:
答案一句话:配置里只有引用,值每个操作现取,不存多余的缓存。
| 能力 | 判据 | 对应源码位置 |
|---|---|---|
| 凭据引用解析 | 设置文件与插件配置里不写值,只写 POSIX 风格环境变量名 | docs/subsystems/credentials.zh.md 第 5 行 |
| 按操作热更新 | 消费方每个操作重新解析引用,绝不跨操作缓存 | 同文档第 20 行 |
| 连接快照一次成型 | 每次 stream() 开头把连接配置与密钥冻成快照 | packages/llm/llm-deepseek/src/adapter.ts 第 214-222 行 |
| 四层来源排序 | 进程环境 > $DSH_HOME/.credentials.yaml > 项目/用户 .env | packages/llm/llm-deepseek/src/index.ts 第 230-240 行 |
| 缺凭据自检报错 | 两条路都落空抛 MISSING_CREDENTIAL,报错点名两个配置入口 | 同文件第 241-245 行 |
| 描述接口不回显 | describe(ref) 只回「配没配 / 来自哪层 / 能不能写」 | docs/subsystems/credentials.zh.md 第 34 行 |
| 事件只服务界面 | credentials/updated 存在,消费方不需要它 | 同文档第 50 行 |
| 设置写盘完整性 | 先重读合并外部改动,再在跨进程锁里原子提交 | packages/util/atomic-write/src/index.ts 第 86-111 行 |
| 版本拒绝策略 | STORAGE_SQLITE_SCHEMA_VERSION = 1,非此版本拒绝打开 | SQLite KV 后端 |
| 遥测可选边界 | 不在 agent loop 主干,harness 职责到 emit() 为止 | docs/subsystems/session-telemetry.zh.md |
| 匿名单一身份 | 一个 UUID v4 伺候三个消费方,懒创建 | packages/identity/anonymous-user-id/README.zh.md |
读这一层代码前,先接受四条硬约束,否则很多取舍看起来像过度设计:
if 拦,而是从类型上让它们传不进来。设置文件和 cordis.yml 里没有任何一处写着 API key 的值。它们携带的是引用:一个环境变量名,比如 DEEPSEEK_API_KEY。
值归凭据提供方所有,本地提供方按四层来源找:
| 优先级 | 来源 | 备注 |
|---|---|---|
| 1 | 进程环境 | 最高,无法被下层覆盖 |
| 2 | $DSH_HOME/.credentials.yaml | Web 的 Models 页写的就是它 |
| 3 | 项目 .env | |
| 4 | 用户 .env |
所谓「配置不落盘」,落盘的只有名字,机密被挡在配置之外。
为什么值必须留在外面::值一旦进了配置文件,它的命运就不再由你掌握。它会被打进容器镜像、贴进排障群、躺进备份三个周期。一个名字泄露了,不痛不痒。
resolveApiKey 函数体(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)
}
}
两处信号值得注意:
else 分支的注释。 没有接缝时不存在可排序的托管存储,环境就是全部的凭据平面。这不是兜底,这是明确承认的另一整套世界观。MISSING_CREDENTIAL(第 241-245 行),报错把两个配置入口一并写在话里,演示里左侧最后那条红字就是它的原文。源码核对依据:本地仓库 deepseek-harness-master,核对日期 2026-08-13。
朴素实现是把内存里的设置快照直接序列化写回,后写的赢,把先写的整段抹掉。DSH 的写路径把这条路堵死了(Agent Note 2026-07-30-settings-write-path-integrity.md):
withFileLock:用 wx 标志独占创建 <文件名>.lock,创建成功即持锁;rename 原子替换,读到的永远是完整的一版。最容易被误读的一处细节: 等锁超时后,它宁可报错,也不删掉别人的锁文件。理由写在函数上方注释里——锁文件的年龄证明不了它的主人已经死了,抢占一把还活着的锁比等待超时危险得多;清理孤儿锁是运维动作,不是代码行为。
又是熟悉的配方:拿不准,宁可吵闹地失败,别静默地闯祸。
版本立场。 STORAGE_SQLITE_SCHEMA_VERSION 当前是 1,写在 PRAGMA user_version 里。打开数据库时,全新的空库盖上当前版本戳,其他任何版本一律拒绝打开,没有就地迁移。与上一课的会话日志同源同哲学:未发布软件没有需要保全的历史数据。
为什么确定性要让位于响亮的失败: 背着一整套迁移代码,意味着每个新版本都要为所有历史版本负责;而明拒只需要在打开时响亮地失败一次。
日志模式的取舍。 journal 默认 WAL,坏文件系统可以退到几种回滚日志模式,但 memory 和 off 被从类型上排除了(同文件第 23-29 行注释)。理由一句话:扔掉日志持久性会静默违反 KV 后端合同里的持久性条款。想快可以,想快到说谎不行。
遥测边界。 遥测是可选能力接缝,不在 agent loop 主干,没有任何遥测内容进入模型请求,harness 职责到 emit() 为止。每条记录导出前要过一道脱敏流水线,脱敏只改导出副本,权威会话日志一个字都不动。监听器抛异常按 fail-closed 处理,直接扣下这条记录不发。
匿名身份。 一个随机 UUID v4 落在 $DSH_HOME/.anonymous-user-id,三个消费方共用:OTel 上报的 user.id、/feedback 命令的确认回执、每次发往 DeepSeek 的 x-deepseek-harness-user-id 请求头。共用一个 id,接收侧才能把三路记录关联起来。
妙在创建时机。 llm-deepseek 里这个 id 懒创建(userId ??= getOrCreateAnonymousUserId(),index.ts 第 248-249 行),而 stream() 里凭据解析排在身份解析之前(adapter.ts 第 221-222 行)。连起来看:一台从没配过 key 的机器,发起的请求在凭据那步就失败了,磁盘上不会平白多出一个跟踪身份。工具还没为你干过一件事,就先给你编了个号,这种事 DSH 不干。
| 产品 | 做法 | 优化目标 | 为什么合理 |
|---|---|---|---|
| DeepSeek Harness | 每个操作回存储现取,请求内冻结快照 | 轮换后下一次读多对 | 长驻进程,重启代价高 |
| Grok Build | AuthCredentialProvider 要求取快照前廉价磁盘重读;refresh_after_unauthorized() 401 后刷新重试一次 | 事前现取 + 事后兜底 | 兼顾会过期的 OAuth 令牌 |
| Claude Code | keychainPrefetch.ts 启动时并行读 macOS Keychain,与约 135ms 的模块 import 同时跑,把约 200ms 串行读省到接近零 | 启动那一次读多快 | 终端产品重启成本低、轮换少 |
两边都对,因为伺候的场景不一样。别抄最佳实践,先算一下你的进程平均活多久。
改这一层的代码之前,按顺序问自己:
热更新最常见的做法是缓存加失效通知。这套机制一旦引入,你就必须回答:通知丢了怎么办、订阅方上线前那次变更怎么补、多实例之间要不要同步。
按操作现取把这些问题的答案统一成一句:不需要。
判断口诀:能用一次多余的读取换掉的协调机制,都是划算的买卖。 反过来,只有当这一次读取真的昂贵(跨网络、握手成本高、有速率限制)时,才值得引入缓存和它的全部配套复杂度。
端点和密钥如果各去各的地方取,就会出现「新端点配旧密钥」的杂交。这类故障最要命的地方不是它难修,而是它难归因:请求会打到对的机器上,带着一把没权限的钥匙,服务端返回鉴权失败。你会怀疑密钥过期、账号欠费、网关策略,真正的答案藏在两个配置的代数错位里,而日志里不留任何痕迹。
做法只有一行:进入请求时把连接配置和密钥一起冻成快照,本次请求从头到尾用这一份。顺手还解决了另一个问题——轮换不会造成半新半旧,请求直接用旧的那代跑完,新值从下一次开始生效。
反面设计是这样的:用户改了、保存成功了、结果没生效。这比直接报错糟糕得多,因为它消耗了用户的信任成本,还让他怀疑自己的操作。
正确的做法是让描述接口给出三个布尔式答案——配了没有、来自哪层、能不能写,然后让界面在不可写时把输入框渲染成只读。用户第一眼就知道改这里没用,直接去找真正生效的那层。
这条同样适用于你自己的产品:凡是「改了也不生效」的输入,都应该在渲染阶段禁用或标注,而不是让用户提交后猜。
两处可以直接抄的实现方式:
memory 和 off 从类型上排除,连传都传不进去,而不是写一行「不建议在生产使用」;if (telemetryEnabled) 散落在主干逻辑里。共同点是:把「不该发生」从运行时约束,提前到编译期或装配期约束。 注释会过期,类型不会。
配置里只存引用,值每个操作现取一次,轮换免重启,热更新靠读取时机而非通知广播。设置写盘先合并外部改动,再在跨进程文件锁里做原子提交,孤儿锁宁可超时报错也不抢占。存储 schema 非当前版本拒绝打开,不做就地迁移。遥测止于 emit()、脱敏 fail-closed,一个懒创建的匿名 id 伺候三个消费方,没用过就不落盘。
本页内容整理自 xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》),音频为二次演绎配音版,文字稿与解读部分由本站在原课程内容基础上整理与延展。