1
00:00:00,100 --> 00:00:06,432
本内容改编自小山学堂《学 AI 产品，从入门到精通》，为二次演绎配音版。

2
00:00:06,382 --> 00:00:11,911
这一集聊的是 DeepSeek Harness 里最不起眼的那一层，持久化与基建。

3
00:00:11,911 --> 00:00:15,192
凭据、设置、存储、遥测。

4
00:00:15,192 --> 00:00:18,401
平时没人关心，一出事全是坑。

5
00:00:18,401 --> 00:00:21,947
先把本集要解决的三件事摆在桌面上。

6
00:00:21,947 --> 00:00:27,439
为什么在这个系统里轮换模型密钥，不需要重启任何一个进程。

7
00:00:27,439 --> 00:00:33,557
两个进程同时写同一份设置文件，为什么不会互相把对方的改动抹掉。

8
00:00:33,557 --> 00:00:43,557
一个匿名身份怎么同时伺候遥测上报、反馈回执和模型请求头，还能做到你压根没配密钥的时候它压根不被创建。

9
00:00:43,557 --> 00:00:49,615
这三件事看着彼此无关，其实是同一套脾气在三个地方的各别体现。

10
00:00:49,615 --> 00:00:55,997
这套脾气一句话说完，配置里只有引用，值每次现取，不存多余的缓存。

11
00:00:55,997 --> 00:00:59,134
还有一条判断标准贯穿全程。

12
00:00:59,134 --> 00:01:03,954
拿不准的时候宁可吵闹地失败，绝不静默地闯祸。

13
00:01:03,954 --> 00:01:06,598
本集你至少会四次撞见它。

14
00:01:06,598 --> 00:01:16,802
如果你在做常驻后台型程序，尤其是 Agent 底座这种一套进程要陪跑几十个会话的东西，这一集几乎每条都能直接搬回家。

15
00:01:16,802 --> 00:01:24,290
如果你做的是终端工具类产品，最后那段对比会告诉你，为什么两边的最佳实践恰好相反。

16
00:01:24,290 --> 00:01:26,670
还有一句提醒放在前面。

17
00:01:26,670 --> 00:01:33,762
这一层的工作在产品上几乎没有存在感，做对了没人夸，做错了全是你的锅。

18
00:01:33,762 --> 00:01:39,579
但它是那种一旦设计歪了，后面每一个功能都要替它擦屁股的地方。

19
00:01:39,579 --> 00:01:44,495
花两节课的时间把它想明白，比你加十个新功能划算。

20
00:01:44,640 --> 00:01:46,945
先破一个常见误解。

21
00:01:46,895 --> 00:01:53,650
它的设置文件，以及那个挂满插件的配置文件里，没有任何一处写着密钥的值。

22
00:01:53,650 --> 00:01:55,225
一个都没有。

23
00:01:55,225 --> 00:01:59,996
文件里躺的是引用，一个符合 posix 规范的环境变量名。

24
00:01:59,996 --> 00:02:03,025
真正的价值属于凭据提供方。

25
00:02:03,025 --> 00:02:06,030
本地提供方按四层来源往下找。

26
00:02:06,030 --> 00:02:13,818
进程环境优先级最高，往下是主目录下的凭据文档，再往下是项目和各自的环境变量文件。

27
00:02:13,818 --> 00:02:16,799
这就是副标题说的配置不落盘。

28
00:02:16,799 --> 00:02:20,717
落盘的只有名字，机密被挡在配置的门外。

29
00:02:20,717 --> 00:02:23,374
多问一句，为什么要这么绕。

30
00:02:23,374 --> 00:02:28,830
因为值一旦进了配置文件，这个文件的命运就不再由你掌握。

31
00:02:28,830 --> 00:02:34,407
它会被打进容器镜像，会被贴进排障群，会在备份里躺上三年。

32
00:02:34,407 --> 00:02:37,460
而一个名字泄露了，不痛不痒。

33
00:02:37,460 --> 00:02:40,357
接着是本课最重要的一条规则。

34
00:02:40,357 --> 00:02:45,669
消费方在每个操作里重新解析引用，绝不跨操作缓存。

35
00:02:45,669 --> 00:02:52,797
官方文档把这句话写得毫不含糊，这种按操作进行的读取，就是热更新机制本身。

36
00:02:52,797 --> 00:02:55,008
请注意这句话的分量。

37
00:02:55,008 --> 00:03:01,174
它没有说热更新靠某个事件通知系统驱动，它说读取时机就是热更新。

38
00:03:01,174 --> 00:03:07,616
于是系统里根本不存在配置缓存失效这件事，因为没有缓存需要失效。

39
00:03:07,616 --> 00:03:16,535
没有订阅关系要维护，没有失效广播要追赶，也不存在那条最折磨人的排障线索，明明改了就是不生效。

40
00:03:16,535 --> 00:03:23,602
换个角度想，如果这里用的是经典的缓存加失效通知，你需要额外回答一堆问题。

41
00:03:23,602 --> 00:03:30,032
通知丢了怎么办，订阅方上线前那次变更怎么补，多个实例之间要不要同步。

42
00:03:30,032 --> 00:03:34,900
按操作现取把这些问题的答案统一成一句，不需要。

43
00:03:34,900 --> 00:03:39,732
能用一次多余的读取换掉的协调机制，都是划算的买卖。

44
00:03:39,732 --> 00:03:43,121
落到模型适配器上，形态是这样的。

45
00:03:43,121 --> 00:03:53,133
每次流式请求开始的时候，把连接配置和密钥一起冻成一份快照，这个请求从头到尾用这一份，下一次请求自动重新解析。

46
00:03:53,280 --> 00:03:58,157
上面那句话里藏着一个边界条件，值得单独拆出来讲。

47
00:03:58,107 --> 00:04:02,302
假设请求正跑到一半，你去模型页换了新密钥。

48
00:04:02,302 --> 00:04:03,829
会发生什么。

49
00:04:03,829 --> 00:04:11,954
答案是本次请求拿旧密钥跑完，新密钥从下一次请求开始生效，中间不会出现半新半旧的中间态。

50
00:04:11,954 --> 00:04:14,574
这个行为不是巧合，是设计。

51
00:04:14,574 --> 00:04:21,268
快照一次成型，要么整份是旧的，要么整份是新的，不存在更新了一半的可能。

52
00:04:21,268 --> 00:04:24,165
更妙的是快照里包含了什么。

53
00:04:24,165 --> 00:04:28,071
它把端点和发给这个端点的密钥冻在同一份里。

54
00:04:28,071 --> 00:04:32,194
也就是说，端点和密钥永远来自同一代配置。

55
00:04:32,194 --> 00:04:42,446
假设你回滚了配置，把端点换回了旧地址，那么跟着它走的必然也是那一代的密钥，绝不会出现新端点配旧密钥的杂交组合。

56
00:04:42,446 --> 00:04:45,740
源码注释把这个意图写得明明白白。

57
00:04:45,740 --> 00:04:47,927
为什么杂交这么可怕。

58
00:04:47,927 --> 00:04:50,667
因为它的报错形态极难归因。

59
00:04:50,667 --> 00:04:57,458
请求会打到对的机器上，带着一把没有权限的钥匙，服务端返回的是鉴权失败。

60
00:04:57,458 --> 00:05:02,002
你会怀疑密钥过期，怀疑账号欠费，怀疑网关策略。

61
00:05:02,002 --> 00:05:09,333
真正的答案藏在两个配置的代数错位里，而这个错位在日志里不留任何痕迹。

62
00:05:09,333 --> 00:05:13,648
设计上只要做一件事就能彻底消灭这类故障。

63
00:05:13,648 --> 00:05:20,127
必须保持一致的几个值，放进同一份不可变快照，别让它们各自去别处取。

64
00:05:20,280 --> 00:05:26,215
再看两条容易被忽略的规则，它们卡在同一层，我管它叫接缝层规则。

65
00:05:26,165 --> 00:05:30,660
第一条，空的存储值在任何地方都视为不存在。

66
00:05:30,660 --> 00:05:36,153
你把密钥设成空字符串，等于没配，下一次请求直接报缺凭据。

67
00:05:36,153 --> 00:05:38,485
这条规则的意义是统一。

68
00:05:38,485 --> 00:05:47,547
系统里不需要到处写那种既判空指针又判空字符串的防御代码，所有人只问一件事，这个值存在吗。

69
00:05:47,547 --> 00:05:49,326
第二条更值得学。

70
00:05:49,326 --> 00:05:58,136
配置界面查询一个引用的时候走的是描述接口，只回答三个问题，配了没有，值来自哪一层，能不能写。

71
00:05:58,136 --> 00:05:59,867
它绝不回显值。

72
00:05:59,867 --> 00:06:05,143
哪怕只是一次普通的页面渲染，也不让机密出现在响应体里。

73
00:06:05,143 --> 00:06:07,788
这里面有个很细腻的取舍。

74
00:06:07,788 --> 00:06:12,667
由进程环境供值的引用，会被描述接口报成不可写。

75
00:06:12,667 --> 00:06:13,809
为什么。

76
00:06:13,809 --> 00:06:20,925
因为你往文件层写会成功，界面提示保存成功，可下一次解析继续从环境里拿旧值。

77
00:06:20,925 --> 00:06:24,086
用户以为改了，实际上什么都没变。

78
00:06:24,086 --> 00:06:31,526
与其制造一个成功的假象，不如在接缝这儿提前拒绝，让界面把输入框渲染成只读。

79
00:06:31,526 --> 00:06:34,194
这就解释了课上的那个练习。

80
00:06:34,194 --> 00:06:41,826
有人在启动脚本里导出了旧密钥，后来又在网页往凭据文档写了一份新值，改了半天不生效。

81
00:06:41,826 --> 00:06:47,944
因为四层来源里进程环境优先级最高，文件层写得再新也排在它后面。

82
00:06:47,944 --> 00:06:53,473
真正的出路，要么改启动环境，要么别在这个环境里放这个变量。

83
00:06:53,473 --> 00:07:01,550
而界面本来能救你一把，只要照着描述接口的结果把那一行渲染成只读，你就不会白填。

84
00:07:01,700 --> 00:07:06,926
最能看出这套架构干净的地方，是那个凭据更新事件。

85
00:07:06,876 --> 00:07:10,566
凭据真的变更时，系统确实会发事件。

86
00:07:10,566 --> 00:07:18,186
但文档专门补了一句，消费方不需要它，这个事件只服务于配置界面刷新那个已配置的徽标。

87
00:07:18,186 --> 00:07:20,253
请停下来体会一下。

88
00:07:20,253 --> 00:07:26,828
一个设计了事件总线的系统，明确告诉所有人，热更新不靠你们订阅这个事件。

89
00:07:26,828 --> 00:07:29,761
可靠的来源是读取时机本身。

90
00:07:29,761 --> 00:07:34,761
这是把复杂度按在系统内侧，而不是摊给每一个使用方。

91
00:07:34,761 --> 00:07:39,280
事件只服务于展示，这已经是近乎洁癖的边界划分。

92
00:07:39,280 --> 00:07:45,398
设置文件的写法是另一个坑，也是这四个题里最像分布式问题的那个。

93
00:07:45,398 --> 00:07:47,068
场景很日常。

94
00:07:47,068 --> 00:07:53,294
你拿编辑器改设置文件，网页端也在改，还可能有两个进程同时开着。

95
00:07:53,294 --> 00:07:59,244
朴素的实现是把内存里的设置快照直接序列化写回，后写的赢。

96
00:07:59,244 --> 00:08:07,032
于是你在编辑器里刚加的一行配置，被另一个进程一次保存冲得干干净净，连个提示都没有。

97
00:08:07,032 --> 00:08:09,929
它的写路径把这条路堵死了。

98
00:08:09,929 --> 00:08:21,251
每次写盘之前，先重读磁盘，把外部改动合并进来，然后在一把跨进程文件锁的保护下，完成读取、渲染、原子提交这一整轮。

99
00:08:21,251 --> 00:08:23,318
锁的实现值得一提。

100
00:08:23,318 --> 00:08:28,739
用独占创建标志去创一个锁文件，创建成功就算持锁。

101
00:08:28,739 --> 00:08:35,866
别人占着就指数退避重试，从初始延迟一路翻倍到上限，超过时限报错。

102
00:08:35,866 --> 00:08:38,210
读者完全不参与抢锁。

103
00:08:38,210 --> 00:08:47,321
提交靠临时文件改名原子替换，所以任何时刻读到的都是完整的一版，永远读不到写了一半的文件。

104
00:08:47,321 --> 00:08:50,193
这里有个细节值得单独停一下。

105
00:08:50,193 --> 00:08:54,712
等锁超时以后，它宁可报错，也不删掉别人的锁文件。

106
00:08:54,712 --> 00:09:00,638
函数上方的注释给了理由，锁文件的年龄证明不了它的主人已经死了。

107
00:09:00,638 --> 00:09:07,934
抢占一把还活着的锁，比等待超时危险得多，清理孤儿锁是运维动作，不是代码行为。

108
00:09:07,934 --> 00:09:10,073
又是那个熟悉的配方。

109
00:09:10,073 --> 00:09:12,801
拿不准，就吵闹地失败。

110
00:09:12,930 --> 00:09:15,091
再看存储和遥测。

111
00:09:15,041 --> 00:09:19,632
这两块放一起讲，是因为它们共享同一套版本立场。

112
00:09:19,632 --> 00:09:24,945
键值存储的数据库后端延续了讲会话日志时那套哲学。

113
00:09:24,945 --> 00:09:29,080
模式版本号当前是一，写在数据库的版本戳里。

114
00:09:29,080 --> 00:09:38,394
打开的时候只有两种命运，全新的空库盖上当前版本戳直接开，其他任何版本一律拒绝打开，没有就地迁移。

115
00:09:38,394 --> 00:09:40,870
立场很硬，理由也简单。

116
00:09:40,870 --> 00:09:44,488
还没发布的软件没有需要保全的历史数据。

117
00:09:44,488 --> 00:09:50,726
与其背着一整套迁移代码到处兜，不如明确拒绝，让它响亮地失败。

118
00:09:50,726 --> 00:09:53,178
还有一处小而硬的取舍。

119
00:09:53,178 --> 00:10:06,027
日志模式默认写前日志，文件系统不支持的时候可以退到几种回滚日志模式，可是仅在内存和彻底关闭这两种被从类型上排除了，连传都传不进去。

120
00:10:06,027 --> 00:10:14,067
注释里的理由只有一句话，扔掉日志的持久性，会静默违反键值后端合同里的持久性条款。

121
00:10:14,067 --> 00:10:16,904
想快可以，想快到说谎不行。

122
00:10:16,904 --> 00:10:20,161
这句话我建议直接抄进团队规范。

123
00:10:20,161 --> 00:10:22,890
遥测这块最怕喧宾夺主。

124
00:10:22,890 --> 00:10:28,010
它被做成一项可选能力，挂在接缝上，不在 Agent 主干逻辑里。

125
00:10:28,010 --> 00:10:35,414
结论很干脆，没有任何遥测内容会进入模型请求，底座的职责到把记录发出去为止。

126
00:10:35,414 --> 00:10:38,707
导出之前还要过一道脱敏流水线。

127
00:10:38,707 --> 00:10:43,346
每条记录发出前先过监听器，规则由部署方自己挂。

128
00:10:43,346 --> 00:10:53,587
关键在异常处理，监听器抛异常的时候按失败即扣下处理，这条记录直接不发，而不是放行一份可能漏脱敏的数据。

129
00:10:53,587 --> 00:10:58,947
而且脱敏只改导出副本，权威会话日志一个字都不动。

130
00:10:58,947 --> 00:11:05,065
排障的时候你有原始数据可查，对外出去的永远是处理过的那一份。

131
00:11:05,200 --> 00:11:10,787
最后是匿名身份的设计，我个人认为这是本集最精巧的一笔。

132
00:11:10,737 --> 00:11:16,193
一个随机生成的通用唯一标识，落在主目录下的一个小文件里。

133
00:11:16,193 --> 00:11:18,032
它有三个消费方。

134
00:11:18,032 --> 00:11:27,011
遥测上报里的用户标识，反馈命令的确认回执，以及每一次发往模型服务的请求头里那个用户标识字段。

135
00:11:27,011 --> 00:11:28,609
为什么要共用。

136
00:11:28,609 --> 00:11:33,405
因为只有共用一个身份，接收侧才能把这三路记录关联起来。

137
00:11:33,405 --> 00:11:39,390
看到一条投诉，能顺着找到那段时间的所有上报和所有模型调用。

138
00:11:39,390 --> 00:11:45,628
如果三处各生成自己的身份，等于同一件事写了三本账，事后没法对账。

139
00:11:45,628 --> 00:11:47,948
真正妙在创建时机。

140
00:11:47,948 --> 00:11:52,804
这个身份是懒创建的，第一次真正要用才生成文件。

141
00:11:52,804 --> 00:11:58,429
更关键的是调用顺序，流式请求里凭据解析排在身份解析前面。

142
00:11:58,429 --> 00:12:01,914
这两件事连起来看，结论是这样的。

143
00:12:01,914 --> 00:12:12,624
一台从没配过密钥的机器，发起的请求在凭据那一步就失败了，身份那一行代码根本执行不到，磁盘上不会平白多出一个跟踪编号。

144
00:12:12,624 --> 00:12:15,917
这句话背后那条原则我特别认同。

145
00:12:15,917 --> 00:12:21,061
工具还没为你干过一件事，就先给你编了个号，这种事不该干。

146
00:12:21,061 --> 00:12:24,246
身份是服务的结果，不是安装税。

147
00:12:24,246 --> 00:12:27,455
拿它和你见过的很多软件对照一下。

148
00:12:27,455 --> 00:12:34,847
装完就在后台生成机器指纹、设备号、安装序号，甚至还没联网就把遥测队列攒起来了。

149
00:12:34,847 --> 00:12:38,225
区别不在技术难度，在谁跟着谁走。

150
00:12:38,225 --> 00:12:42,119
行为跟着服务走，还是服务跟着采集走。

151
00:12:42,260 --> 00:12:46,080
先看另外两家怎么做的，方向很有意思。

152
00:12:46,030 --> 00:12:50,176
另一款 Agent 产品把凭据抽成一个提供者接口。

153
00:12:50,176 --> 00:13:00,609
接口文档明确要求实现方在每次取快照之前先做一次廉价的磁盘重读，好让兄弟进程写进去的新凭据能被当前进程看到。

154
00:13:00,609 --> 00:13:04,780
方向和我们讲的按操作重解析完全一致。

155
00:13:04,780 --> 00:13:13,758
它还多一层事后兜底，请求吃到未授权状态码就尝试刷新令牌并重试一次，主要伺候会过期的令牌场景。

156
00:13:13,758 --> 00:13:17,881
事前现取加事后重试，比单靠缓存稳得多。

157
00:13:17,881 --> 00:13:20,645
终端那边的思路恰好相反。

158
00:13:20,645 --> 00:13:23,698
它在乎的是启动那一次读多快。

159
00:13:23,698 --> 00:13:38,882
做法是在进程启动时并行发出系统钥匙串的读取请求，让它和大约一百三十五毫秒的模块加载同时跑，业务代码真正要用时才等结果，把原本约两百毫秒的串行等待省到接近零。

160
00:13:38,882 --> 00:13:42,079
两边都对，因为伺候的场景不一样。

161
00:13:42,079 --> 00:13:47,163
终端产品重启成本低，凭据轮换少，预取加缓存划算。

162
00:13:47,163 --> 00:13:53,101
基建进程长时间驻留，重启一次要中断所有会话，每次现取划算。

163
00:13:53,101 --> 00:13:55,144
反过来就没道理了。

164
00:13:55,144 --> 00:14:00,613
所以别急着抄最佳实践，先算一下你的进程平均活多久。

165
00:14:00,760 --> 00:14:05,048
收尾，把本集能直接搬回家的东西压成几条。

166
00:14:04,998 --> 00:14:10,744
第一，配置里只存引用，值每个操作现取一次，轮换免重启。

167
00:14:10,744 --> 00:14:16,429
热更新靠读取时机，不靠通知广播，事件只服务于界面刷新。

168
00:14:16,429 --> 00:14:22,222
第二，必须保持一致的几个值，冻进同一份不可变快照，一次成型。

169
00:14:22,222 --> 00:14:27,462
这一条能同时消灭半新半旧的中间态和跨代配置的杂交。

170
00:14:27,462 --> 00:14:32,739
第三，空值等于未配置，这条规则统一写在接缝层执行。

171
00:14:32,739 --> 00:14:38,893
界面走描述接口，只答配没配、来自哪层、能不能写，绝不回显。

172
00:14:38,893 --> 00:14:44,001
判断为不可写就提前把输入框变只读，别让用户白填。

173
00:14:44,001 --> 00:14:51,946
第四，多人写同一份文件，先重读合并外部改动，再在跨进程锁里做原子提交。

174
00:14:51,946 --> 00:14:55,900
读者不参与抢锁，提交靠临时文件改名。

175
00:14:55,900 --> 00:14:59,626
超时宁可报错，也不抢占别人的锁文件。

176
00:14:59,626 --> 00:15:11,152
这里有个容易漏掉的顺序问题，重读必须在持锁之后而不是之前，否则你合并完、等锁的那段时间里，别人可能又写进去一版，你还是会覆盖它。

177
00:15:11,152 --> 00:15:16,032
第五，版本号不是当前值就拒绝打开，不做就地迁移。

178
00:15:16,032 --> 00:15:20,840
日志模式宁可牺牲一点性能也不允许关掉持久性。

179
00:15:20,840 --> 00:15:30,022
遥测止步于发出这一步，绝不进模型上下文，脱敏失败就扣下整条记录，改的是导出副本，不是权威日志。

180
00:15:30,022 --> 00:15:31,910
第六条偏产品。

181
00:15:31,910 --> 00:15:35,611
身份懒创建，没为你服务过就不创建。

182
00:15:35,611 --> 00:15:39,013
这条几乎零成本，体感差别巨大。

183
00:15:39,013 --> 00:15:43,941
如果明天只能改一处，我建议从第三条的只读渲染开始。

184
00:15:43,941 --> 00:15:51,116
它改动最小，见效最快，而且用户能直接感知，界面终于不再骗我说保存成功了。

185
00:15:51,116 --> 00:15:56,597
本集就到这里，下一集接着往下拆它的工具系统与执行层。

