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

2
00:00:06,382 --> 00:00:10,781
这一集聊一个几乎每个 Agent 框架都会撞上的问题。

3
00:00:10,781 --> 00:00:13,894
同一个内核，要同时长五张脸。

4
00:00:13,894 --> 00:00:25,576
Web 界面要能用，无人值守的脚本要能用，编辑器通过 ACP 协议要能接，Python 这边要有一个 SDK，还得有一条 HTTP 接口给外部系统调。

5
00:00:25,576 --> 00:00:30,264
最笨的办法是把业务逻辑复制五份，每个入口一份。

6
00:00:30,264 --> 00:00:37,968
复制的第一天很爽，第三个月就开始出事，改一个会话字段要改五处，而且总有一处会漏。

7
00:00:37,968 --> 00:00:41,538
DeepSeek Harness 的解法是把这件事反过来做。

8
00:00:41,538 --> 00:00:46,490
内核对入口一无所知，所谓入口只是一份配置文件。

9
00:00:46,490 --> 00:00:48,581
今天我们拆三件事。

10
00:00:48,581 --> 00:00:53,557
五个入口怎么共享同一个内核而不把逻辑分叉成五份。

11
00:00:53,557 --> 00:01:00,108
为什么 JSON-RPC 和 ACP 这两张脸的配置里明令禁止往标准输出打日志。

12
00:01:00,108 --> 00:01:05,745
以及那层自研的 RPC 到底解决了什么现成框架管不了的问题。

13
00:01:05,745 --> 00:01:19,987
顺带说一句，这三条看着是三件事，其实是一条主线的三个切面，把入口做薄之后，剩下的复杂度和风险会集中到两个地方，一个是传输线的归属，一个是跨进程调用的约定。

14
00:01:19,987 --> 00:01:23,076
今天的重点就压在这两处。

15
00:01:23,212 --> 00:01:24,940
先把背景补齐。

16
00:01:24,890 --> 00:01:33,700
前面几讲说过，DSH 里的一切功能都是 Cordis 插件，进程启动时按一份 cordis.yml 把插件挂成一棵树。

17
00:01:33,700 --> 00:01:36,381
这个设计在这一课收获回报。

18
00:01:36,381 --> 00:01:40,395
所谓入口，就是一份不同的 cordis.yml。

19
00:01:40,395 --> 00:01:47,150
仓库的 examples 目录下躺着现成的三份配置，headless、JSON-RPC、ACP。

20
00:01:47,150 --> 00:01:50,143
翻开对比会发现它们大同小异。

21
00:01:50,143 --> 00:01:58,761
DeepSeek 适配器、bash 执行器、JSONL 会话持久化、压缩、文件系统工具，这些内核插件三份全有。

22
00:01:58,761 --> 00:02:01,273
差异集中在最上面几行。

23
00:02:01,273 --> 00:02:13,268
JSON-RPC 入口多挂一个 JSON-RPC 服务器插件，ACP 入口多挂一个协议桥和沙箱策略，headless 干脆什么服务器都不挂，进程本身就是入口。

24
00:02:13,268 --> 00:02:15,383
这个结构意味着什么。

25
00:02:15,383 --> 00:02:21,104
意味着加一张新面孔的成本，约等于写一个翻译插件加一份配置。

26
00:02:21,104 --> 00:02:28,232
翻译插件只负责把外部协议的语言翻译成内核能懂的会话操作，其余一概不管。

27
00:02:28,232 --> 00:02:33,592
而内核插件那一份清单，照抄就行，一个字都不用改。

28
00:02:33,592 --> 00:02:37,895
更关键的一句在课程的演示里被反复强调。

29
00:02:37,895 --> 00:02:43,364
同一个操作从哪张脸进来，会话日志里落下的事件一字不差。

30
00:02:43,364 --> 00:02:47,078
这句话其实是整套设计的验收标准。

31
00:02:47,078 --> 00:02:53,712
如果五张脸落下的事件不一样，就说明逻辑在某个地方被分叉了，架构已经开始漏。

32
00:02:53,712 --> 00:03:00,900
你可以用这句话去检验自己的系统，从不同的门走进来，留下的痕迹是不是完全一样。

33
00:03:00,900 --> 00:03:10,575
如果你现在手上有好几个入口，我建议你今天就做一次这个检查，把同一个操作从不同入口跑一遍，比对落下的日志。

34
00:03:10,575 --> 00:03:17,234
这个检查花不了十分钟，但它能立刻告诉你，你的内核到底是一个还是五个半。

35
00:03:17,380 --> 00:03:20,935
Web 面孔的皮厚一点，但它仍然是插件。

36
00:03:20,885 --> 00:03:28,842
host-webserver 是个纯粹的 node http 载体，文档里明说它不属于 agent loop，也不了解任何 harness 概念。

37
00:03:28,842 --> 00:03:33,578
这句话很重要，它把皮和身的边界定义得非常清楚。

38
00:03:33,578 --> 00:03:38,169
frontend-static 认领回退席位，当一个单页应用的服务器。

39
00:03:38,169 --> 00:03:47,171
client-modules 用注入机制往 index.html 里塞一份启动清单，浏览器端照单加载各插件的前端模块。

40
00:03:47,171 --> 00:03:55,056
换句话说，连前端模块的清单都不是写死的，是各插件自己声明、由框架在启动时拼出来的。

41
00:03:55,056 --> 00:04:00,573
这跟服务端那棵插件树是同一个思路，只是长在浏览器那一侧。

42
00:04:00,573 --> 00:04:06,042
同一个设计原则在两端各自长了一遍，而不是两端各搞一套。

43
00:04:06,042 --> 00:04:10,801
HTTP API 这张脸则是一条链，一段一段拆得很清楚。

44
00:04:10,801 --> 00:04:21,282
api-remotes 做身份解析，api-gateway 做参数解码和方法调用，connection 独占 api 路由的 RPC 信封，最后落回同一个 webserver。

45
00:04:21,282 --> 00:04:25,633
四段职责，每段只做一件事，谁都不越界。

46
00:04:25,633 --> 00:04:28,602
Python SDK 最能说明皮可以有多薄。

47
00:04:28,602 --> 00:04:35,441
pip 装 SDK 会连带装一个平台 wheel，里面是一个单文件可执行的 JSON-RPC agent。

48
00:04:35,441 --> 00:04:42,917
SDK 启动它当子进程，通过环境变量注入默认组合，然后在 stdio 上说 JSON-RPC。

49
00:04:42,917 --> 00:04:52,183
所以 Python SDK 和 JSON-RPC 入口其实是同一张脸的两种穿法，Python 这一层只是把协议包成了一个 run 方法。

50
00:04:52,183 --> 00:04:58,013
你在这里能看到的全部新增代码，就是把子进程拉起来、把管道接上。

51
00:04:58,013 --> 00:05:07,376
这也解释了为什么 SDK 的版本升级几乎不需要改业务代码，因为它这一层根本不碰业务，它只是一个管道工。

52
00:05:07,540 --> 00:05:13,090
接下来这条是本集最硬的一条纪律，也是新手最容易踩的一条。

53
00:05:13,040 --> 00:05:22,427
翻开 JSON-RPC 那份配置，第一行注释说明这是给捆绑运行时用的无人值守部署，第二行就是那条铁律的原文。

54
00:05:22,427 --> 00:05:28,052
标准输出保留给 JSON-RPC，不要加 console logger 或者终端 UI。

55
00:05:28,052 --> 00:05:33,822
ACP 那份配置同样声明，整棵树不挂标准输出日志和热更新。

56
00:05:33,822 --> 00:05:35,925
为什么要定得这么狠。

57
00:05:35,925 --> 00:05:37,367
道理一句话。

58
00:05:37,367 --> 00:05:41,226
这两种协议把标准输出当传输线用。

59
00:05:41,226 --> 00:05:49,074
报文一个字节一个字节从这条线上过去，你往上面混打一行日志，对端的解析器当场就断线。

60
00:05:49,074 --> 00:05:53,473
这不是性能问题，也不是洁癖，是正确性问题。

61
00:05:53,473 --> 00:05:57,139
一行调试日志就能让整条连接失效。

62
00:05:57,139 --> 00:05:58,774
那日志去哪。

63
00:05:58,774 --> 00:06:01,995
走运行时自己的 logger 另寻出路。

64
00:06:01,995 --> 00:06:07,764
协议入口的铁律就是，传输线只能跑协议，别的什么都别往上写。

65
00:06:07,764 --> 00:06:10,516
这条纪律其实可以推得更广。

66
00:06:10,516 --> 00:06:15,817
凡是你的进程把某个输出流当成管道用，那个流就必须被独占。

67
00:06:15,817 --> 00:06:19,447
谁都可以往里写的管道，等于没有管道。

68
00:06:19,447 --> 00:06:28,461
你在自己的系统里划定管道的时候，第一件事应该是宣布它归谁独占，而不是先想着怎么往里塞东西。

69
00:06:28,612 --> 00:06:35,268
五张脸里有两张要跨进程调用 Host 里的业务方法，就是 Web 和 HTTP API。

70
00:06:35,218 --> 00:06:37,466
这就需要一层 RPC。

71
00:06:37,466 --> 00:06:41,096
DSH 没用现成框架，自研了 Typert。

72
00:06:41,096 --> 00:06:43,992
先看业务开发者要做多少事。

73
00:06:43,992 --> 00:06:46,276
少到只剩一个装饰器。

74
00:06:46,276 --> 00:06:53,752
在服务方法上标一个 Remote 装饰器，构建时 Typert 分析 TypeScript 类型图，生成三样东西。

75
00:06:53,752 --> 00:07:00,038
校验参数的 Zod schema、描述调用的 descriptor、给浏览器端用的类型声明。

76
00:07:00,038 --> 00:07:05,483
不需要手写路由表，不需要写参数转换，也不需要写客户端 stub。

77
00:07:05,483 --> 00:07:11,108
改一处方法签名，重新构建之后所有端的调用约定同步更新。

78
00:07:11,108 --> 00:07:13,536
那为什么现成的方案不行。

79
00:07:13,536 --> 00:07:20,579
因为要跨 wire 的东西里有 Cordis 特有的概念，通用的 schema 生成器根本没有词汇去描述它们。

80
00:07:20,579 --> 00:07:22,009
三个例子。

81
00:07:22,009 --> 00:07:33,103
第一，Host 和浏览器是两个独立的 TypeScript Program，同名的 Cordis Context 在两边的类型合并结果不一样，一份 schema 想同时喂通两边行不通。

82
00:07:33,103 --> 00:07:39,437
第二，业务方法的参数可能是 Agent 这样的活对象，它不能被序列化过 wire。

83
00:07:39,437 --> 00:07:50,110
Typert 用一套 lookup 机制，把 agent 参数改写成 wire 上的 agentId 字段，Gateway 收到请求之后先把这个 id 解析回活对象，再去调方法。

84
00:07:50,110 --> 00:07:56,048
这就是活对象和它的身份证之间的转换，过 wire 的永远是身份证。

85
00:07:56,048 --> 00:08:04,677
第三，客户端上的远程服务是随插件挂载卸载的活服务，最后一个方法撤回，整个命名空间跟着卸载。

86
00:08:04,677 --> 00:08:09,557
这是 OpenAPI 那种静态描述根本表达不了的生命周期。

87
00:08:09,557 --> 00:08:13,776
把这三个例子放在一起看，它们讲的是同一件事。

88
00:08:13,776 --> 00:08:18,451
通用方案的问题不在于能力不够，而在于词汇不够。

89
00:08:18,451 --> 00:08:30,879
它描述的是静态的数据形状，而这里要过 wire 的是一套活的运行时的语义，两个独立的类型世界、会变的引用关系、会挂载也会卸载的服务。

90
00:08:30,879 --> 00:08:35,651
词汇不对，再强的生成器也只能描述错的东西。

91
00:08:35,788 --> 00:08:39,812
Typert 身上还有几条纪律值得单独拎出来讲。

92
00:08:39,762 --> 00:08:44,197
第一条，descriptor 是本地反射信息，绝不上 wire。

93
00:08:44,197 --> 00:08:52,683
Host 和客户端各自在构建时生成彼此对应的 descriptor，真正发过去的请求里只有 endpoint 和具名参数。

94
00:08:52,683 --> 00:08:57,358
取消信号作为带外信号注入，绝不混进业务参数。

95
00:08:57,358 --> 00:09:05,976
这个取舍跟前面那条标准输出铁律是同一个味道，通道要专用，控制面和数据面不要挤在同一条线上。

96
00:09:05,976 --> 00:09:10,075
第二条，只有被 Remote 标记的方法才是远程方法。

97
00:09:10,075 --> 00:09:15,050
没标记的既不进客户端类型，也没法被远程调用。

98
00:09:15,050 --> 00:09:21,673
方法不标记就不存在，这保证了远程面是显式声明出来的，不是碰巧长出来的。

99
00:09:21,673 --> 00:09:23,692
第三条最有意思。

100
00:09:23,692 --> 00:09:30,712
生成器遇到表达不了的类型投影会直接报错，绝不把源类型展平弱化蒙混过去。

101
00:09:30,712 --> 00:09:35,820
这条跟前面几讲反复出现的拒绝解读是一脉相承的。

102
00:09:35,820 --> 00:09:38,440
说不清楚的事宁可不做。

103
00:09:38,440 --> 00:09:45,856
我特别认同这个取向，因为一个会悄悄降级的生成器，比一个会报错的生成器危险得多。

104
00:09:45,856 --> 00:09:52,575
报错你立刻就知道，降级会让你以为类型是对的，然后在很远的地方炸掉。

105
00:09:52,732 --> 00:09:57,573
横向看两家，能看出这是产品取舍，不是技术优劣。

106
00:09:57,523 --> 00:10:01,802
Grok Build 在 ACP 这张脸上跟 DSH 正面相遇。

107
00:10:01,802 --> 00:10:13,244
它有独立的 Rust 实现，stdin 行读取器、双向通道、网关收发器，同样跑在 stdio 的 JSON-RPC 上，所以同样得遵守标准输出归协议这条纪律。

108
00:10:13,244 --> 00:10:17,752
编辑器接编码 agent，ACP 正在变成事实标准。

109
00:10:17,752 --> 00:10:20,504
差别在面孔的数量和长法。

110
00:10:20,504 --> 00:10:26,610
Grok Build 以桌面端和命令行客户端为主体，ACP 是专门给编辑器的接口。

111
00:10:26,610 --> 00:10:29,807
Claude Code 的路线相反，是终端优先。

112
00:10:29,807 --> 00:10:40,372
终端 CLI 是主体，headless 是同一个可执行文件的另一种模式，Agent SDK 再往外包一层，多张面孔从同一个 CLI 衍生出来的。

113
00:10:40,372 --> 00:10:46,862
一个进程一个用户界面，不需要 RPC 层，也就没有 Typert 要解决的那个问题。

114
00:10:46,862 --> 00:10:55,985
DSH 是 Web 优先，发布时甚至没有传统的终端交互入口，当时发布讨论里最响的声音就是问我的命令行在哪。

115
00:10:55,985 --> 00:11:03,196
这是入口取舍上的产品决策，先把内核和面孔解耦的架构立住，缺哪张皮补哪张。

116
00:11:03,196 --> 00:11:06,694
两条路线没有对错，成本结构不一样。

117
00:11:06,694 --> 00:11:10,696
终端优先要加 Web 面孔，得补一整层 RPC。

118
00:11:10,696 --> 00:11:16,694
Web 优先要加命令行面孔，理论上只是一份新的配置加一个驱动器。

119
00:11:16,694 --> 00:11:22,535
你要先想清楚自己缺的是哪张皮，再决定内核该长成什么样。

120
00:11:22,684 --> 00:11:26,636
我们用课程里那道练习题把今天的东西串起来。

121
00:11:26,586 --> 00:11:34,735
假设要给 DSH 加一个聊天软件机器人入口，用户在群里艾特机器人说话，回复流回群里。

122
00:11:34,735 --> 00:11:37,439
照着今天的路子列一张清单。

123
00:11:37,439 --> 00:11:39,206
哪些东西不用写。

124
00:11:39,206 --> 00:11:45,300
内核插件、会话持久化、压缩、工具，全部照抄现有配置就行。

125
00:11:45,300 --> 00:11:52,355
这正是入口等于配置这个设计最直接的收益，你要写的代码量跟入口的复杂度无关。

126
00:11:52,355 --> 00:11:54,254
哪些东西必须写。

127
00:11:54,254 --> 00:12:00,721
一个协议桥插件，把群消息翻成会话 prompt，再把会话事件翻成群回复。

128
00:12:00,721 --> 00:12:03,016
就这一件，不多不少。

129
00:12:03,016 --> 00:12:05,696
还有一个细节值得多想一层。

130
00:12:05,696 --> 00:12:08,737
这个桥有没有标准输出互斥问题。

131
00:12:08,737 --> 00:12:12,800
大概率没有，因为群消息不跑在标准输出上。

132
00:12:12,800 --> 00:12:15,853
那它的传输线纪律等价物是什么。

133
00:12:15,853 --> 00:12:19,627
是群平台的速率限制和消息长度限制。

134
00:12:19,627 --> 00:12:27,283
模型往外吐事件是连续的、细碎的，而群消息不能这么发，否则刷屏不说，还会被限流。

135
00:12:27,283 --> 00:12:30,300
所以事件流必须做节流和合并。

136
00:12:30,300 --> 00:12:36,730
这道题真正的价值在于，它逼你把一条具体的纪律抽象成一条一般原则。

137
00:12:36,730 --> 00:12:44,362
不是记住标准输出不能打日志，而是先问一句，我的传输线是什么，它的约束又是什么。

138
00:12:44,500 --> 00:12:47,550
最后给你几条能直接拿走的东西。

139
00:12:47,500 --> 00:12:52,356
第一，先立内核与面孔的边界，再谈支持几个入口。

140
00:12:52,356 --> 00:12:59,111
判断标准很具体，同一个操作从不同的入口进来，落下的事件是否一字不差。

141
00:12:59,111 --> 00:13:02,993
有差异就说明逻辑被分叉了，边界没立住。

142
00:13:02,993 --> 00:13:07,320
第二，入口应该退化成一份配置加一个翻译插件。

143
00:13:07,320 --> 00:13:12,248
翻译插件只做协议翻译，业务逻辑一行都不要进它。

144
00:13:12,248 --> 00:13:18,137
加新面孔的成本应该接近写一份配置，而不是接近重写一遍系统。

145
00:13:18,137 --> 00:13:22,416
第三，凡是被当成传输线的输出流，必须独占。

146
00:13:22,416 --> 00:13:27,056
标准输出归协议的时候，日志一条都不能往上打。

147
00:13:27,056 --> 00:13:32,981
这条纪律可以推广到任何管道、任何队列、任何共享通道上去。

148
00:13:32,981 --> 00:13:35,734
第四，远程面要显式声明。

149
00:13:35,734 --> 00:13:46,106
方法不标记就不存在，宁可让人多写一个装饰器，也不要让远程面碰巧长出来，因为碰巧长出来的接口没人维护也没人审计。

150
00:13:46,106 --> 00:13:51,863
第五，生成器和编译器遇到表达不了的东西要报错，不要悄悄降级。

151
00:13:51,863 --> 00:13:58,366
一个会弱化的工具比一个会拒绝的工具危险得多，因为它让你以为事情是对的。

152
00:13:58,366 --> 00:14:00,181
这一集就到这里。

153
00:14:00,181 --> 00:14:06,815
最后留一个问题给你，你的系统现在有几个入口，它们各自落下的痕迹一致吗。

154
00:14:06,815 --> 00:14:13,258
如果不一致，先别急着加第六张脸，那个洞会跟着一起复制过去。

