学 AI 产品 · 专业 AI 产品经理播客第 4 章 · T4 解剖 DeepSeek Harness:一切皆插件的 Agent 底座 · EP 18
第 4 章 · EP 18

凭据、设置、存储与遥测

时长 15:57音色 云健 · 男声

同步字幕

章节导航(点击跳转)

0:00开场 · 改了密钥,为什么进程不用重启1:44
1:44一 · 落盘的只有名字,机密被挡在门外2:08
3:53二 · 一次请求一份快照,端点和密钥不杂交1:27
5:20三 · 空值等于没配,界面只答三问1:41
7:01四 · 热更新不靠通知,事件只给界面用2:11
9:12五 · 拒绝迁移,以及遥测止步于发出1:52
11:05六 · 一个匿名身份,三个消费方1:37
12:42七 · 别家怎么伺候凭据1:18
14:00八 · 可带走的设计原则1:55
解读全文

ep50 · 凭据、设置、存储与遥测 · 解读与音频稿件

  • 模块:T4 解剖 DeepSeek Harness:一切皆插件的 Agent 底座
  • 集页:https://xueai-podcast.pages.dev/t/dsh18/
  • 来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)
  • 说明:本页为音频的文字稿与延展解读,音频为二次演绎配音版,内容以源课程素材为准。

本集解决什么工程问题

三个看似无关的日常故障,其实出自同一套设计取舍。

这一层平时没人关心,因为它是基建。但它一旦设计歪了,后面每个功能都要替它擦屁股。本集要解释的问题是:

  1. 为什么在 DeepSeek Harness 里轮换模型密钥,不需要重启任何进程?
  2. 两个进程同时写同一份设置文件,为什么不会互相抹掉对方的改动?
  3. 一个匿名身份怎么同时伺候遥测、反馈回执和模型请求头,还能做到你没配过密钥时它压根不被创建?

答案一句话:配置里只有引用,值每个操作现取,不存多余的缓存。


能力地图

能力判据对应源码位置
凭据引用解析设置文件与插件配置里不写值,只写 POSIX 风格环境变量名docs/subsystems/credentials.zh.md 第 5 行
按操作热更新消费方每个操作重新解析引用,绝不跨操作缓存同文档第 20 行
连接快照一次成型每次 stream() 开头把连接配置与密钥冻成快照packages/llm/llm-deepseek/src/adapter.ts 第 214-222 行
四层来源排序进程环境 > $DSH_HOME/.credentials.yaml > 项目/用户 .envpackages/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

约束说明

读这一层代码前,先接受四条硬约束,否则很多取舍看起来像过度设计:

  1. 长驻进程假设。 一套进程要陪跑几十个会话,重启一次中断全部会话。所有取舍都建立在这个前提上,终端产品的结论恰好相反。
  2. 接缝层统一裁决。 空值等于未配置、要不要回显、能不能写,这类判断只能在接缝层做一次,不允许各调用方自由发挥。
  3. 类型系统参与防御。 不被允许的值不是靠运行时 if 拦,而是从类型上让它们传不进来。
  4. 新增 plumbing 的裁决条款。 引入任何协调机制(缓存、通知、订阅)之前,先回答一个问题:能不能用一次多余的读取替代它。

逻辑拆解 · 配置里只有引用,值每次现取

设置文件和 cordis.yml 里没有任何一处写着 API key 的值。它们携带的是引用:一个环境变量名,比如 DEEPSEEK_API_KEY。

值归凭据提供方所有,本地提供方按四层来源找:

优先级来源备注
1进程环境最高,无法被下层覆盖
2$DSH_HOME/.credentials.yamlWeb 的 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):

  1. 每次写盘前重读磁盘,合并外部改动;
  2. 在一把跨进程文件锁里完成「读 → 渲染 → 原子提交」整轮;
  3. 锁的实现 withFileLock:用 wx 标志独占创建 <文件名>.lock,创建成功即持锁;
  4. 别人占着就指数退避重试,从初始延迟翻倍到上限,超时报错;
  5. 读者不参与抢锁;提交靠临时文件 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 BuildAuthCredentialProvider 要求取快照前廉价磁盘重读;refresh_after_unauthorized() 401 后刷新重试一次事前现取 + 事后兜底兼顾会过期的 OAuth 令牌
Claude CodekeychainPrefetch.ts 启动时并行读 macOS Keychain,与约 135ms 的模块 import 同时跑,把约 200ms 串行读省到接近零启动那一次读多快终端产品重启成本低、轮换少

两边都对,因为伺候的场景不一样。别抄最佳实践,先算一下你的进程平均活多久。


审查清单

改这一层的代码之前,按顺序问自己:

  1. 新增凭据读取路径,是不是跨操作缓存了? 缓存一旦形成,就必须额外回答通知丢失、订阅漏接、多实例同步三个问题。
  2. 必须保持一致的几个值,是不是在同一份不可变快照里? 端点和密钥必须同代,否则报错形态极难归因。
  3. 空值路径是否统一? 系统里不该出现既判空指针又判空字符串的防御代码,所有人只问「存在吗」。
  4. 界面查询接口是否回显了值? 任何一次渲染都不该让机密出现在响应体里。
  5. 不可写的判断有没有提前到接缝层? 让输入框变只读,而不是让用户白填后提示成功。
  6. 写同一份文件时,重读是不是排在持锁之后? 重读在锁外,等锁期间别人可能又写了一版,你照样覆盖它。
  7. 超时后有没有尝试删别人的锁文件? 有,就是 bug。清理孤儿锁是运维动作。
  8. 版本号不匹配时是迁移还是拒绝? 未发布软件应当拒绝,理由写在悲剧发生之前。
  9. journal 模式能不能关掉? 应当从类型上禁止。持久性不是性能旋钮。
  10. 遥测内容有没有可能进入模型请求? 应当零可能,且脱敏异常要 fail-closed。

提示一 · 用一次多余的读取,替代整套缓存失效机制

热更新最常见的做法是缓存加失效通知。这套机制一旦引入,你就必须回答:通知丢了怎么办、订阅方上线前那次变更怎么补、多实例之间要不要同步。

按操作现取把这些问题的答案统一成一句:不需要。

判断口诀:能用一次多余的读取换掉的协调机制,都是划算的买卖。 反过来,只有当这一次读取真的昂贵(跨网络、握手成本高、有速率限制)时,才值得引入缓存和它的全部配套复杂度。

提示二 · 把必须保持一致的几个值,冻进同一份不可变快照

端点和密钥如果各去各的地方取,就会出现「新端点配旧密钥」的杂交。这类故障最要命的地方不是它难修,而是它难归因:请求会打到对的机器上,带着一把没权限的钥匙,服务端返回鉴权失败。你会怀疑密钥过期、账号欠费、网关策略,真正的答案藏在两个配置的代数错位里,而日志里不留任何痕迹。

做法只有一行:进入请求时把连接配置和密钥一起冻成快照,本次请求从头到尾用这一份。顺手还解决了另一个问题——轮换不会造成半新半旧,请求直接用旧的那代跑完,新值从下一次开始生效。

提示三 · 界面只读,是对用户最体面的诚实

反面设计是这样的:用户改了、保存成功了、结果没生效。这比直接报错糟糕得多,因为它消耗了用户的信任成本,还让他怀疑自己的操作。

正确的做法是让描述接口给出三个布尔式答案——配了没有、来自哪层、能不能写,然后让界面在不可写时把输入框渲染成只读。用户第一眼就知道改这里没用,直接去找真正生效的那层。

这条同样适用于你自己的产品:凡是「改了也不生效」的输入,都应该在渲染阶段禁用或标注,而不是让用户提交后猜。

提示四 · 持久性与可选项,用类型禁止而不是用注释劝阻

两处可以直接抄的实现方式:

  • SQLite journal 的 memory 和 off 从类型上排除,连传都传不进去,而不是写一行「不建议在生产使用」;
  • 遥测做成可选能力接缝,依赖注入缺失时整条链路消失,而不是留一堆 if (telemetryEnabled) 散落在主干逻辑里。

共同点是:把「不该发生」从运行时约束,提前到编译期或装配期约束。 注释会过期,类型不会。


一句话 Takeaway

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


本页内容整理自 xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》),音频为二次演绎配音版,文字稿与解读部分由本站在原课程内容基础上整理与延展。