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

2
00:00:06,382 --> 00:00:09,206
这是 DeepSeek Harness 系列的又一集。

3
00:00:09,206 --> 00:00:21,598
这一集讲两件事，一件是工具的同一个结果，模型看的和人看的凭什么可以不一样；另一件是智能体改文件之前，怎么确保它确实读过这个文件。

4
00:00:21,598 --> 00:00:23,425
为什么值得关心。

5
00:00:23,425 --> 00:00:28,173
因为它们都是那种不做也能跑、做了才能不出事的设计。

6
00:00:28,173 --> 00:00:34,098
工具返回一串字符串，模型读得懂，界面也画得出来，看起来够用了。

7
00:00:34,098 --> 00:00:40,576
可一旦你要换客户端、要回放、要让程序直接消费结果，字符串就不够了。

8
00:00:40,576 --> 00:00:49,483
文件编辑也是一样，让模型随便写，大多数时候是对的，偶尔一次覆盖掉你没让它碰的文件，那一次就够你受的。

9
00:00:49,483 --> 00:00:51,262
这一集分四块。

10
00:00:51,262 --> 00:01:05,564
先讲一个值三份投影，再讲界面为什么只认卡片不认工具名，然后讲改展示和改值为什么二选一不许混，最后拆那本观测账本和它的版本凭证，收五条能带走的原则。

11
00:01:05,564 --> 00:01:08,822
先给一句能贯穿两件事的线索。

12
00:01:08,822 --> 00:01:17,187
这两件事看起来毫不相干，其实用的是同一招，把判断依据从「当下看起来是什么」换成「一份可验证的凭证」。

13
00:01:17,187 --> 00:01:26,862
工具结果靠 schema 和值来担保，不靠渲染出来的文本；文件编辑靠版本凭证来担保，不靠模型自己记得读过没有。

14
00:01:26,862 --> 00:01:32,836
凡是模型说「我记得是这样」的地方，都值得问一句，有没有东西能替它作证。

15
00:01:32,836 --> 00:01:41,069
顺带提醒，这一集的行号都是二零二六年八月十三日核对源码的结果，版本演进后可能变化。

16
00:01:41,212 --> 00:01:43,433
先回答标题里的问题。

17
00:01:43,383 --> 00:01:46,977
工具结果到底是字符串还是结构化值。

18
00:01:46,977 --> 00:01:51,184
在这套系统里两个都是，但地位完全不同。

19
00:01:51,184 --> 00:01:58,708
工具的 execute 只返回一个规范 JSON 值，这个值必须通过工具自己声明的输出 schema 校验。

20
00:01:58,708 --> 00:02:06,051
字符串是后来才有的，注册表拿着校验过的值调用 render，投影出模型看到的内容块。

21
00:02:06,051 --> 00:02:08,515
所以完整链路是四步。

22
00:02:08,515 --> 00:02:19,705
execute 产出值，schema 把关，render 投影模型内容，可选的 presentationMeta 投影一份可回放的界面数据，最后 presentResult 再把它变成一张卡片。

23
00:02:19,705 --> 00:02:22,398
这里有个关键点值得停一下。

24
00:02:22,398 --> 00:02:27,253
render 和 presentResult 都是纯函数，不做任何输入输出。

25
00:02:27,253 --> 00:02:28,912
为什么非要纯。

26
00:02:28,912 --> 00:02:36,821
因为它们在实时流式输出和会话日志回放两条路径上都要跑，跑出来的结果必须一模一样。

27
00:02:36,821 --> 00:02:43,035
只要有一处偷偷读了当前时间或者磁盘状态，回放就会和当时不一样。

28
00:02:43,035 --> 00:02:45,354
还有一个反直觉的事实。

29
00:02:45,354 --> 00:02:51,280
持久化的结果事件只存 content、error 和 meta，规范值从来不落盘。

30
00:02:51,280 --> 00:02:58,780
也就是说，回放可以重现每一张卡片和每一段模型文本，却永远重建不了那个中间值。

31
00:02:58,780 --> 00:03:00,727
值只活在执行期。

32
00:03:00,727 --> 00:03:04,032
输出契约的全部字段只有九行。

33
00:03:04,032 --> 00:03:08,684
schema 是强制的，render 是强制的，presentationMeta 可选。

34
00:03:08,684 --> 00:03:12,626
两个投影器的注释都强调同一个词，纯。

35
00:03:12,626 --> 00:03:15,018
这是回放确定性的地基。

36
00:03:15,018 --> 00:03:19,597
出处是核心源码第二百一十一到二百一十九行。

37
00:03:19,732 --> 00:03:22,674
现在看界面那一头拿到了什么。

38
00:03:22,624 --> 00:03:32,853
它拿到的东西叫渲染意图，是一个带 card 标签的联合类型，值域只有六种卡片，generic、terminal、diff、read、search、web。

39
00:03:32,853 --> 00:03:35,521
这个设计的价值在于解耦。

40
00:03:35,521 --> 00:03:41,050
客户端只需要对 card 做一次分支判断，不需要认识任何工具名。

41
00:03:41,050 --> 00:03:48,333
换一个搜索后端，工具实现整个换掉，只要它照样产出 search 卡，界面一行都不用改。

42
00:03:48,333 --> 00:03:50,653
反过来想一下另一种做法。

43
00:03:50,653 --> 00:04:02,937
如果渲染逻辑长在工具自己身上，每个工具自带一套渲染代码，那换一个客户端，比如从终端换到编辑器插件，就要把每个工具的渲染层重写一遍。

44
00:04:02,937 --> 00:04:10,605
而且回放的时候还得重新执行渲染代码，回放就不再是重放数据，而是重跑逻辑了。

45
00:04:10,605 --> 00:04:19,127
把渲染翻译成数据，代价是表达力受限于那六种卡片，收益是谁来渲染都行、什么时候渲染都行。

46
00:04:19,127 --> 00:04:21,531
这是一笔很清楚的买卖。

47
00:04:21,531 --> 00:04:25,689
截断必须亮牌这件事也挂在这套卡片上。

48
00:04:25,689 --> 00:04:33,802
search 卡强制携带 truncated 和 total 两个字段，界面永远不会把砍过的结果当成完整结果画出来。

49
00:04:33,802 --> 00:04:39,475
read 卡同理带 offset 和 totalLines，能画出显示多少行、共多少行。

50
00:04:39,475 --> 00:04:45,737
很多误导人的界面，问题就出在把截断结果画得跟完整结果一个样。

51
00:04:45,892 --> 00:04:50,156
值与展示分离还解释了一条看起来很怪的规矩。

52
00:04:50,106 --> 00:04:55,707
后置处理插件在放行的时候，换内容和换值只能二选一。

53
00:04:55,707 --> 00:05:00,828
这条规矩不是靠文档约定的，是写死在类型定义里的。

54
00:05:00,828 --> 00:05:11,669
放行决定有两个分支，一个分支允许带 content，同时把 value 字段的类型标成 never；另一个分支反过来，允许带 value，把 content 标成 never。

55
00:05:11,669 --> 00:05:20,299
在 TypeScript 里，never 类型没有任何合法取值，谁想在一个决定里同时塞两个字段，编译器直接报错。

56
00:05:20,299 --> 00:05:24,205
出处是核心源码第五百九十三到六百行。

57
00:05:24,205 --> 00:05:25,852
为什么这么严。

58
00:05:25,852 --> 00:05:28,063
因为两边语义不一样。

59
00:05:28,063 --> 00:05:33,748
换内容是展示层的动作，值保持原样，只改模型看到的文本。

60
00:05:33,748 --> 00:05:43,760
换值是数据层的动作，注册表会拿新值重新过一遍 schema，再重新算 content 和 meta，保证三份投影出自同一个源头。

61
00:05:43,760 --> 00:05:49,517
允许同时换，就可能出现文本说一套、值是另一套的分裂结果。

62
00:05:49,517 --> 00:06:01,260
文档里还补了一句很要害的提醒，内容替换只是展示策略，想对程序隐藏值的插件必须换值或者拦截，光改文本瞒不住那些直接拿值的程序。

63
00:06:01,260 --> 00:06:03,027
再补两个兜底。

64
00:06:03,027 --> 00:06:10,924
render 抛异常，注册表把它转成安全的错误结果，模型看到一条错误文本，流水线照常走完。

65
00:06:10,924 --> 00:06:19,025
第三方工具没写呈现回调，客户端回退到通用卡，标题就是工具名，原始参数当输入展示。

66
00:06:19,025 --> 00:06:21,429
都不崩，都有着落。

67
00:06:21,580 --> 00:06:24,270
把这个选择放到同行里看。

68
00:06:24,220 --> 00:06:32,189
Claude Code 的渲染直接长在工具接口上，工具文件本身就是 tsx，渲染逻辑是工具自带的组件。

69
00:06:32,189 --> 00:06:41,107
好处是工具作者掌控每个像素，代价是换客户端要重写渲染层，回放也要重新执行渲染代码。

70
00:06:41,107 --> 00:06:48,367
Grok Build 用 Rust 枚举给工具输出做类型化，失败形态在编译期就定死了，这是它的强项。

71
00:06:48,367 --> 00:06:57,910
至于输出的界面呈现和模型文本是否走统一的卡片词汇，已核对的材料里没看到等价机制，这条我保留。

72
00:06:57,910 --> 00:07:00,770
结果超限的处理两家能对上。

73
00:07:00,770 --> 00:07:05,662
CC 用字符上限，超了就落盘、给模型留预览加路径。

74
00:07:05,662 --> 00:07:08,475
这里的对应机制是溢写策略。

75
00:07:08,475 --> 00:07:15,482
两家都想清楚了同一件事，工具结果的体量必须有人管，不能放任它撑爆上下文。

76
00:07:15,482 --> 00:07:18,030
这里可以提炼一条选型建议。

77
00:07:18,030 --> 00:07:23,607
渲染长在工具身上，适合单一客户端、追求像素级控制的产品。

78
00:07:23,607 --> 00:07:29,004
渲染翻译成数据，适合多客户端、要回放、要审计的产品。

79
00:07:29,004 --> 00:07:35,530
判断依据只有一条，你的渲染需要被重新执行，还是只需要被重新展示。

80
00:07:35,530 --> 00:07:41,287
需要重新执行的放工具里，只需要重新展示的做成数据。

81
00:07:41,428 --> 00:07:43,565
现在换到文件编辑。

82
00:07:43,515 --> 00:07:51,616
智能体改文件有三大翻车现场，改错位置、覆盖没读过的文件、拿着过期内容做编辑。

83
00:07:51,616 --> 00:07:54,428
对策是三件套加一本账。

84
00:07:54,428 --> 00:07:58,275
三件套是面向模型的 read、edit、write。

85
00:07:58,275 --> 00:08:04,945
read 窗口化读取带行号的文本，edit 做字面量替换，write 整文件创建或覆盖。

86
00:08:04,945 --> 00:08:12,265
账是观测策略插件肚子里的一张表，以会话为键，记下每个文件目标的观测状态。

87
00:08:12,265 --> 00:08:14,092
状态只有三种。

88
00:08:14,092 --> 00:08:17,421
未见，表里压根没这个文件的条目。

89
00:08:17,421 --> 00:08:22,614
present 加版本号，表示读到过，而且读到的是那一个版本。

90
00:08:22,614 --> 00:08:27,638
absent，确认过这个路径不存在，比如读的时候扑了个空。

91
00:08:27,638 --> 00:08:33,287
版本号是文件系统后端签发的不透明新鲜度凭证，文件一变它就变。

92
00:08:33,287 --> 00:08:39,741
每次读取、写入、编辑成功后，工具会发一个观测事件，插件同步记账。

93
00:08:39,741 --> 00:08:42,097
判定发生在动手之前。

94
00:08:42,097 --> 00:08:54,428
工具要写或要改时，会分发写入意图或编辑意图事件，这是单槽瀑布，第一个返回决定的监听器独占决策权，按部署约定就是这个策略插件。

95
00:08:54,428 --> 00:08:58,022
注意单槽和上一集讲的多槽瀑布的差别。

96
00:08:58,022 --> 00:09:04,392
多槽是每位监听器都能表态、能短路，适合放行这类顺序敏感的判断。

97
00:09:04,392 --> 00:09:10,378
单槽只有第一位说话，适合守卫条件这类必须由一个人拍板的场合。

98
00:09:10,378 --> 00:09:15,811
选哪种不是风格问题，取决于这个决定允不允许被后来者推翻。

99
00:09:15,811 --> 00:09:19,056
还有一处细节能看出设计者的用心。

100
00:09:19,056 --> 00:09:25,570
插件只对账本给出守卫条件，真正的检查由后端在一个原子临界区里完成。

101
00:09:25,570 --> 00:09:28,888
为什么不在插件里直接查完就放行。

102
00:09:28,888 --> 00:09:34,789
因为从查完到真正写入之间有一段空隙，文件可能在空隙里被改掉。

103
00:09:34,789 --> 00:09:39,705
把检查和执行放进同一个临界区，这段空隙就被消灭了。

104
00:09:39,705 --> 00:09:45,955
凡是「先检查再执行」的逻辑，都值得问一句，中间那段空隙有没有人管。

105
00:09:45,955 --> 00:09:55,823
它对着账本给出守卫条件，真正的检查由后端在一个原子临界区里完成，先验版本再匹配再替换，中途谁也插不进来。

106
00:09:55,823 --> 00:10:00,787
整个插件不到一百四十行，核心就是两个查账函数。

107
00:10:00,940 --> 00:10:04,964
两个工具的严格程度不一样，这个差别很值得记。

108
00:10:04,914 --> 00:10:06,681
write 永远有路走。

109
00:10:06,681 --> 00:10:12,847
没读过就 write，守卫是 createIfAbsent，文件不存在就创建，存在就拒绝。

110
00:10:12,847 --> 00:10:17,402
读过再 write，守卫是 replaceIfVersion，版本对上才替换。

111
00:10:17,402 --> 00:10:22,895
翻译成人话就是，新建文件不用先读，覆盖别人的文件不行。

112
00:10:22,895 --> 00:10:24,698
edit 一步都不让。

113
00:10:24,698 --> 00:10:35,251
没读过直接报 FS_NOT_OBSERVED，账本记着不存在就报 FS_NOT_FOUND，读过了才带版本守卫上路。

114
00:10:35,251 --> 00:10:44,193
出处是策略插件第六十一到八十八行，那个编辑意图函数里两处抛错，就是这一节标题的全部内容。

115
00:10:44,193 --> 00:10:48,580
版本检查排在字面量匹配之前，这一点很重要。

116
00:10:48,580 --> 00:10:56,164
拿过期内容编辑报的是 FS_STALE_VERSION，不会退化成一个误导性的匹配失败。

117
00:10:56,164 --> 00:11:09,481
想想看，如果顺序反过来，模型拿到一条「没找到匹配」的错误，它会以为是自己写错了字符串，于是反复改字符串重试，永远想不到真正的原因是文件早就被人改过了。

118
00:11:09,481 --> 00:11:12,522
错误的指向性本身就是可用性。

119
00:11:12,522 --> 00:11:23,604
匹配那关也有讲究，old_string 必须恰好命中一次，命中多处报歧义，一处都没有报未找到，除非模型显式传了 replace_all。

120
00:11:23,604 --> 00:11:29,638
匹配、行尾处理、陈旧检查、原子替换全在一个临界区内完成。

121
00:11:29,638 --> 00:11:31,681
还有三个细节值得记。

122
00:11:31,681 --> 00:11:42,811
第一，这套防线是可拔的，卸掉插件，写入和编辑退回无条件的裸行为，工具的 schema 一个字不变，因为工具只分发事件、从不直接调策略。

123
00:11:42,811 --> 00:11:52,318
第二，读取的授权只看新鲜度，不分整读还是窗口读，只要文件没变，读十行也能授权后续整个文件的编辑。

124
00:11:52,318 --> 00:11:58,652
第三，条件注册是常态，部署没有某个能力就根本不注册那个工具。

125
00:11:58,804 --> 00:12:02,996
先读后写这条规则，三家给出的浓度不一样。

126
00:12:02,946 --> 00:12:07,874
Claude Code 也强制先读后写，规则直接写进了工具说明书。

127
00:12:07,874 --> 00:12:14,881
原文大意是，编辑之前必须在对话里至少用过一次读取工具，否则这个工具会报错。

128
00:12:14,881 --> 00:12:21,588
会报错」这三个字说明它的运行时确有强制检查，不只是提示词客气一下。

129
00:12:21,588 --> 00:12:36,288
差别在防线的挂载位置，它的检查长在编辑工具自己身上，而这里把它抽成独立插件，读取、编辑、写入、字符串替换四个工具共享同一本账，工具本体一行权限代码都没有。

130
00:12:36,288 --> 00:12:39,389
Grok Build 留下了同一场斗争的痕迹。

131
00:12:39,389 --> 00:12:48,127
配置里有个跳过先读字段，注释标着已废弃的运行时空操作，说明先读后写曾是硬开关、后来松了绑。

132
00:12:48,127 --> 00:12:58,571
它对过期读取的处理更能看出取向，文件被人改了导致匹配失败时，它在报错文案里加一句提示，劝模型重新读一遍再试。

133
00:12:58,571 --> 00:13:02,454
这是提示语浓度的防线，靠模型自觉。

134
00:13:02,454 --> 00:13:08,619
这里是版本凭证浓度，版本对不上就直接报陈旧错误，物理上不给写。

135
00:13:08,619 --> 00:13:20,651
公平地说，Grok 也有自己的强项，编码坑那关它准备了归一化回退，智能引号、长横线这类肉眼难辨的字符匹配失败时可以做归一化重试。

136
00:13:20,651 --> 00:13:24,978
这里的编辑目前只按行尾规范化后精确匹配。

137
00:13:24,978 --> 00:13:27,033
各有各的取舍。

138
00:13:27,172 --> 00:13:29,201
最后收五条原则。

139
00:13:29,151 --> 00:13:32,588
第一条，值是源头，其余都是投影。

140
00:13:32,588 --> 00:13:38,418
先把规范值定义清楚，再让模型文本和界面卡片都从它投影出来。

141
00:13:38,418 --> 00:13:43,033
反过来做，你会得到两份需要人工保证一致的数据。

142
00:13:43,033 --> 00:13:45,930
第二条，投影必须是纯函数。

143
00:13:45,930 --> 00:13:52,180
只要它要在回放时重跑一次，任何隐藏的输入都会变成回放的不确定性。

144
00:13:52,180 --> 00:13:56,891
第三条，能用类型表达的禁令，别写成文档约定。

145
00:13:56,891 --> 00:14:04,439
换内容和换值二选一这条，写在类型里是编译器报错，写在文档里是评审时吵架。

146
00:14:04,439 --> 00:14:07,853
第四条，防线的浓度要显式选择。

147
00:14:07,853 --> 00:14:16,386
是提示语浓度、运行时检查浓度，还是版本凭证浓度，选错了不是强度问题，是可靠性量级问题。

148
00:14:16,386 --> 00:14:20,016
第五条，错误的指向性就是可用性。

149
00:14:20,016 --> 00:14:26,867
版本检查排在匹配之前，报的是陈旧而不是没找到，模型才能一次性做对动作。

150
00:14:26,867 --> 00:14:28,285
留一道练习。

151
00:14:28,285 --> 00:14:35,184
你要接入一个第三方 SQL 查询工具，查询返回一千二百行但只保留前五十行。

152
00:14:35,184 --> 00:14:45,497
想一想值的 schema 里哪三个字段少不了，给模型的文本要不要包含全部五十行，界面那头选六种卡片里的哪一种、截断信息放哪。

153
00:14:45,497 --> 00:14:52,480
最后一问最难，安全插件想对模型隐藏其中的手机号列，该换 content 还是换 value。

154
00:14:52,480 --> 00:14:57,108
提示是，想想那些直接拿值的程序拿到的是什么。

