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

2
00:00:06,382 --> 00:00:11,526
这一集要解决一个特别具体、又特别容易被忽略的工程问题。

3
00:00:11,526 --> 00:00:18,762
你接了一个大模型，代码跑得好好的，某天用户反馈说这一轮特别慢，慢到十几秒。

4
00:00:18,762 --> 00:00:28,233
你去查日志，什么都查不到，因为重试发生在软件开发工具包的内部循环里，它替你悄悄试了三次，一次都没告诉你。

5
00:00:28,233 --> 00:00:32,500
你看到的只有一次成功，和一段说不清的耗时。

6
00:00:32,500 --> 00:00:37,235
DeepSeek Harness 这个底座对这个问题的答案是三条硬规矩。

7
00:00:37,235 --> 00:00:43,509
第一，一次适配器调用就是一次提供方尝试，连库自带的重试都要禁用。

8
00:00:43,509 --> 00:00:51,274
第二，一次流式响应以两种形态落盘，原始分片流管回放保真，派生消息流管对话历史。

9
00:00:51,274 --> 00:00:57,512
第三，重试不是函数里的循环，而是持久日志里的一条显式事件。

10
00:00:57,512 --> 00:00:59,074
这是上半场。

11
00:00:59,074 --> 00:01:06,334
下半场换个更难的题目，模型输出每次都不一样，这样一个非确定性系统到底怎么测。

12
00:01:06,334 --> 00:01:16,237
答案也是三件武器，确定性回放、专门骗大模型客户端的故障服务器，还有用随机交错序列扫荡协议代码的性质测试。

13
00:01:16,237 --> 00:01:20,444
而性质测试第一次运行，就抓到了一个真 bug。

14
00:01:20,600 --> 00:01:21,944
先立规矩。

15
00:01:21,894 --> 00:01:30,355
适配器约定里有一条写得很硬，一次适配器调用就是一次提供方尝试，适配器必须禁用库自带的重试。

16
00:01:30,355 --> 00:01:40,235
这条规矩背后是一句很朴素的判断，超文本传输协议库们都爱替你悄悄重试，看起来是贴心，实际是把信息藏起来了。

17
00:01:40,235 --> 00:01:46,810
请求为什么慢，重试了几次，每次因为什么失败，全部消失在库的内部循环里。

18
00:01:46,810 --> 00:01:50,427
剥掉这层之后，适配器只干一件事。

19
00:01:50,427 --> 00:02:00,848
发一次请求，把响应转成统一的分片吐出来，失败就规范化成一个可序列化的失败对象，带一个稳定的错误码，别的一概不管。

20
00:02:00,848 --> 00:02:05,199
重试住到更高的层次去，住到能记账的地方去。

21
00:02:05,199 --> 00:02:07,747
防挂起也在这层解决。

22
00:02:07,747 --> 00:02:16,665
两个已经交付的远程适配器都带一个空闲看门狗，默认五分钟，提供方停顿超时就映射成超时错误。

23
00:02:16,665 --> 00:02:20,367
还有一条容易漏的规矩，空回复算错误。

24
00:02:20,367 --> 00:02:28,961
模型返回一个不带任何内容块的停止信号，适配器把它映射成空响应错误，而不是当作一次静默的成功。

25
00:02:28,961 --> 00:02:38,204
为什么要这么较真，因为静默接受一个空成功，等于把退化响应写进了对话历史，后面每一轮都要为它付利息。

26
00:02:38,204 --> 00:02:42,314
只有把它变成错误，重试层才有机会救它。

27
00:02:42,460 --> 00:02:44,068
然后看落盘。

28
00:02:44,018 --> 00:02:47,888
agent loop 消费分片流的时候同时干两件事。

29
00:02:47,888 --> 00:02:54,475
每个分片原样追加一条分片事件进会话日志，同时把这个分片喂给组装器。

30
00:02:54,475 --> 00:02:59,487
流成功结束，组装结果再作为一条消息事件落一次盘。

31
00:02:59,487 --> 00:03:01,037
这就是双流。

32
00:03:01,037 --> 00:03:06,434
分片流是原始录像，回放测试靠它逐帧重建当年那次响应。

33
00:03:06,434 --> 00:03:11,434
消息流是派生历史，下一次请求的对话上下文从它派生。

34
00:03:11,434 --> 00:03:18,802
推理块和正文块在分片协议里本来就是两个不同的类型，各自独立组装，各自落盘。

35
00:03:18,802 --> 00:03:22,347
核心的那九行代码在 agent.ts 里。

36
00:03:22,347 --> 00:03:27,347
先建一个组装器，开一个空数组记分片序号，然后遍历流。

37
00:03:27,347 --> 00:03:34,259
循环体里就两件事，先把分片追加进会话日志拿到序号，再把分片推给组装器。

38
00:03:34,259 --> 00:03:37,660
先落盘再组装，一个分片都不例外。

39
00:03:37,660 --> 00:03:39,799
流结束之后走分岔。

40
00:03:39,799 --> 00:03:46,638
如果结束状态是错误或者中止，就把失败交给请求错误事件去决定重不重试。

41
00:03:46,638 --> 00:03:56,518
只有成功，才追加消息事件，并且把这一批分片的序号写进来源序号字段，标明这条消息是从哪些分片派生出来的。

42
00:03:56,518 --> 00:04:04,042
这个设计给失败的半截输出安排了明确的归宿，它留在分片流里作证，绝不进派生历史。

43
00:04:04,042 --> 00:04:11,759
下一次请求重建上下文的时候，那半截就像从来没发生过，模型看到的上下文是干净的。

44
00:04:11,910 --> 00:04:14,227
组装器本身值得停一下。

45
00:04:14,177 --> 00:04:24,658
它是整个仓库里唯一的分片折叠实现，好处是适配器只要按索引吐格式正确的分片，块重组这件事不用每家自己写一遍。

46
00:04:24,658 --> 00:04:27,699
它对畸形流有非常明确的态度。

47
00:04:27,699 --> 00:04:36,870
处理块结束分片的时候，组装器先看这个块是不是已经关闭过，关过就直接返回，后面再来的重复关闭一律忽略。

48
00:04:36,870 --> 00:04:39,658
注释里管这叫首次关闭优先。

49
00:04:39,658 --> 00:04:46,136
理由是只有第一次关闭说了算，流式输出和最终组装出来的块才能保持一致。

50
00:04:46,136 --> 00:04:51,653
这行防御不是拍脑袋加的，它是性质测试抓过真 bug 的地方。

51
00:04:51,653 --> 00:04:56,593
同一个索引处重复的块结束分片，会改写已经完成的块。

52
00:04:56,593 --> 00:05:01,785
而这个 bug 是在正常路径百分之百行覆盖率之下活下来的。

53
00:05:01,785 --> 00:05:06,954
这句话值得念两遍，行覆盖率全绿，bug 依然在里面。

54
00:05:06,954 --> 00:05:12,963
因为覆盖率只证明这行被执行过，不证明它在所有交错顺序下都对。

55
00:05:12,963 --> 00:05:17,266
修复之后加的，就是刚才说的首次关闭优先。

56
00:05:17,430 --> 00:05:19,711
重试住在更高一层。

57
00:05:19,661 --> 00:05:27,438
有一个专门的插件监听请求错误事件，按每条提供方路由注册时捕获的策略决定救不救。

58
00:05:27,438 --> 00:05:39,541
默认策略只对五种错误码重试，空响应、限流、服务端错误、超时、传输错误，最多两次，退避从五百毫秒到十秒，带百分之十的抖动。

59
00:05:39,541 --> 00:05:44,733
提供方用重试间隔头指定的合法延迟，会替换本地退避。

60
00:05:44,733 --> 00:05:47,618
真正的关键在于它怎么记账。

61
00:05:47,618 --> 00:05:59,000
等待退避之前，插件先往会话日志里追加一条重试事件，里面装着重试编号、提供方、策略模式、完整的失败信息和计划延迟。

62
00:05:59,000 --> 00:06:03,567
退避结束真要动手了，再追加一条重试开始事件。

63
00:06:03,567 --> 00:06:08,724
这两条事件不进模型可见的表层，模型对重试一无所知。

64
00:06:08,724 --> 00:06:11,813
但界面和事后分析全靠它们。

65
00:06:11,813 --> 00:06:17,534
界面据此撤回失败的半截输出，显示第几次重试、几秒后开始。

66
00:06:17,534 --> 00:06:23,123
排查问题的时候，日志里每一次尝试、每一段等待都有时间戳。

67
00:06:23,123 --> 00:06:31,500
重试本身则开一个新的编号轮次，从持久历史重建同样的请求重发，旧轮次的记录一个字不改。

68
00:06:31,500 --> 00:06:37,942
时间线永远向前，没有任何记录被改写，两次尝试在日志里各自完整。

69
00:06:37,942 --> 00:06:40,442
还有一个跨适配器的细节。

70
00:06:40,442 --> 00:06:46,440
成功的结束分片可以携带适配器私有的回放状态，跟着消息一起存。

71
00:06:46,440 --> 00:07:01,889
下次请求把历史发给适配器之前，运行时会逐条检查历史消息，历史提供方和目标提供方归同一个适配器实例，状态才透传，换了适配器，状态被剥离，对方只拿到提供方无关的内容。

72
00:07:01,889 --> 00:07:05,110
一家的私货绝不喂给另一家。

73
00:07:05,260 --> 00:07:09,284
把三家的做法摆在一起看，差别就一句话。

74
00:07:09,234 --> 00:07:17,527
Grok Build 把流式与重试一起放进采样器，重试模块是纯逻辑的分类与退避，actor 层包着重试循环。

75
00:07:17,527 --> 00:07:23,633
预算给得很足，默认最多重试十五次，三十秒退避上限下大约六分钟。

76
00:07:23,633 --> 00:07:33,717
限流单独降到两次就上报，图片超限走特殊通道，剥掉图片再试一次还不占预算，服务端甚至能用响应头一票否决。

77
00:07:33,717 --> 00:07:41,349
分类做得非常细，但重试循环发生在采样器内部，对上层来说就是一次格外慢的调用。

78
00:07:41,349 --> 00:07:50,592
Claude Code 的做法是一个包在接口外面的应用层包装器，默认最多十次，带重试间隔感知和快速模式的冷却处理。

79
00:07:50,592 --> 00:07:55,027
重试发生在客户端内部的循环里，会打调试日志。

80
00:07:55,027 --> 00:08:03,116
这里要诚实地说一句，基于已公开的证据，没有看到把每次重试作为持久会话事件落盘的机制。

81
00:08:03,116 --> 00:08:11,554
所以对齐下来，Grok 和 Claude Code 的重试是函数内部的循环，DeepSeek Harness 的重试是日志里的一等公民。

82
00:08:11,554 --> 00:08:21,806
前两家省事，后者可审计，策略、延迟、失败原因、第几次尝试全部持久化，界面和回放测试都能拿它当事实依据。

83
00:08:21,806 --> 00:08:31,193
代价也有，它的重试边界只有 agent 轮次这一层，绕过循环直接去调流式接口的调用方，拿到的就是一次裸尝试。

84
00:08:31,350 --> 00:08:33,030
下半场换题。

85
00:08:32,980 --> 00:08:38,557
agent 的行为依赖模型输出，模型输出每次都不一样，这系统怎么测。

86
00:08:38,557 --> 00:08:43,774
思路只有一条，把不确定的部分圈起来，让其余一切确定。

87
00:08:43,774 --> 00:08:46,442
第一件武器是确定性回放。

88
00:08:46,442 --> 00:08:51,250
既然每个分片都原样落进了日志，那就把这件事反过来用。

89
00:08:51,250 --> 00:09:00,432
拿一份录好的会话日志，把分片事件按轮次和步骤分组，每一组就是当年一次模型调用的完整分片序列。

90
00:09:00,432 --> 00:09:08,064
测试的时候真实 agent 照常跑，只是模型那头换成回放适配器，逐帧吐回录制的分片。

91
00:09:08,064 --> 00:09:15,144
不确定性只存在于录制那一次，之后每次重跑都逐字节一致，而且不需要密钥。

92
00:09:15,144 --> 00:09:23,930
这就是日志即测试资产的意思，测试数据不用手写模拟数据，直接就是生产格式的会话日志本身。

93
00:09:23,930 --> 00:09:31,466
快照测试拿它固定整个组装后的行为，改一行代码导致行为分叉，差异当场标红。

94
00:09:31,466 --> 00:09:46,458
还有个精巧的细节在分叉会话，子会话的日志开头继承了父会话的种子事件，推导回放脚本时必须从种子边界之后开始切，否则父会话的分片会被误当成子会话的调用重放一遍。

95
00:09:46,458 --> 00:09:56,434
跨平台纪律也从这里来，签入的测试数据必须在两个主流平台上都能回放，录出来的快照哪个平台挂了就修数据本身。

96
00:09:56,434 --> 00:10:00,352
仓库里的原话是修数据不要写归一化器。

97
00:10:00,352 --> 00:10:06,193
归一化器是在测试和现实之间垫棉花，垫多了就测不到现实了。

98
00:10:06,193 --> 00:10:19,018
假设录制的快照在一个平台通过、另一个平台因为路径分隔符的写法不同而失败，按这条纪律你改的是快照本身，而不是在比对器里把路径统一替换掉。

99
00:10:19,018 --> 00:10:22,203
因为后者抹平的不是差异，是现实。

100
00:10:22,203 --> 00:10:24,258
顺带说一句分层。

101
00:10:24,258 --> 00:10:29,799
单元测试盯边界情况、错误路径、事件顺序和并发竞态。

102
00:10:29,799 --> 00:10:44,599
持续集成的覆盖率门禁对源码目录按文件要求百分之百，但文档同时把话说死，行覆盖率是必要条件，永远不是充分条件，没跑过的行往往是你该删的死代码，而不是你该补的测试。

103
00:10:44,599 --> 00:11:00,949
再往上还有一层带真实密钥的接口测试，这里有句很有身份特色的话，我们是 DeepSeek，不要吝惜真实接口测试，无密钥测试只能证明底层通路，只有带密钥运行才能证明 agent 能对接真实模型正常工作。

104
00:11:00,949 --> 00:11:12,355
断言也有讲究，端到端测试要重新读文件、重跑命令来验证结果，只对 agent 自己的输出做关键词探测，会让作弊的 agent 顺利通过。

105
00:11:12,355 --> 00:11:16,730
缺密钥的环境自动跳过，不阻塞任何人。

106
00:11:16,880 --> 00:11:21,902
回放测的是行为不变，还差一块，传输层的花式死法。

107
00:11:21,852 --> 00:11:34,123
连接被拒、发一半就重置套接字、正常关闭但没发结束标记、限流带重试间隔、干脆停滞不动，每一种在适配器和恢复层眼里都是不同的东西。

108
00:11:34,123 --> 00:11:44,304
用进程内模拟全测不到，因为模拟绕过了真实的网络请求、服务端推送事件的分帧、套接字终止和空闲看门狗这些边界。

109
00:11:44,304 --> 00:11:54,892
所以他们造了一台真的节点服务器，说 OpenAI 方言，行为完全由脚本控制，每接一个请求消耗一个行为，脚本耗尽就明确报错。

110
00:11:54,892 --> 00:12:01,611
它还有个随机模式，按权重随机抽故障做压力测试，随机种子公开且可复现。

111
00:12:01,611 --> 00:12:24,944
默认权重表本身就是一份清单，正常成功占四十八，慢速成功十，半途掐线十，然后是各占五的连接重置、断流、空回复和限流，服务端错误四，触顶长度上限、停滞挂起和服务不可用各二，最刁的两种各占一，流正常收尾却没发完，和吐出坏数据的畸形报文。

112
00:12:24,944 --> 00:12:31,411
源码注释还专门提醒，这是可调的测试压力配置，并非生产事故频率的估算。

113
00:12:31,411 --> 00:12:38,803
这台服务器的操守很克制，只报告协议层事实，不判断可不可以重试，策略归底座自己。

114
00:12:38,803 --> 00:12:44,944
测试基础设施保持中立，才能同时给适配器、循环和重试层作证。

115
00:12:44,944 --> 00:12:48,935
最后一件武器对付的是没人想得到的交错。

116
00:12:48,935 --> 00:12:55,786
协议形态的代码输入空间是组合爆炸的，示例测试只能固定想到的用例。

117
00:12:55,786 --> 00:13:08,550
于是每个协议形态的包都配一个性质测试，生成器造出逼真但对抗性的输入，重复索引、滞后分片、缺块开始的畸形流，断言的不是具体输出，是不变式。

118
00:13:08,550 --> 00:13:15,389
比如组装出的块数不能超过见过的不同索引数，重复调用的结果必须稳定。

119
00:13:15,389 --> 00:13:18,466
失败自动打印可复现的种子。

120
00:13:18,466 --> 00:13:26,916
它的战绩写在开发笔记第一行，属性测试套件首次运行即发现了组装器重复块结束的真实 bug。

121
00:13:27,060 --> 00:13:30,663
最后收一下，几条可以直接拿走的原则。

122
00:13:30,613 --> 00:13:34,844
第一，把重试从库里拿出来，做成显式事件。

123
00:13:34,844 --> 00:13:37,597
省的是代码，丢的是证据。

124
00:13:37,597 --> 00:13:43,726
你今天省下的那点封装，明天会以线上排障的形式连本带利还回来。

125
00:13:43,726 --> 00:13:51,407
判断标准很简单，你能不能从日志里说出这一轮慢在哪里、等了几次、每次因为什么。

126
00:13:51,407 --> 00:13:55,685
第二，失败的输出要留证据，但不要污染历史。

127
00:13:55,685 --> 00:14:01,791
分片流保回放保真，消息流保历史干净，半截输出进前者不进后者。

128
00:14:01,791 --> 00:14:08,642
这是一个通用的分层思路，原始层和派生层要分开，派生层只接受成功的结果。

129
00:14:08,642 --> 00:14:11,839
第三，空成功比报错更危险。

130
00:14:11,839 --> 00:14:21,214
任何看起来成功但没有内容的返回，都要显式映射成错误，否则退化响应会一路写进历史，污染后面每一轮。

131
00:14:21,214 --> 00:14:27,188
同理，超时也要有看门狗，不能让一个停滞的连接挂死整条链路。

132
00:14:27,188 --> 00:14:30,469
第四，覆盖率是必要不充分条件。

133
00:14:30,469 --> 00:14:36,623
按文件百分之百可以是持续集成的门禁，但它只证明行被执行过。

134
00:14:36,623 --> 00:14:44,484
真 bug 藏在交错序列里，那是性质测试的地盘，藏在传输边界里，那是故障服务器的地盘。

135
00:14:44,484 --> 00:14:49,868
第五，测试基础设施要保持中立，只报事实不做判断。

136
00:14:49,868 --> 00:14:54,231
故障服务器不决定重不重试，策略归被测系统。

137
00:14:54,231 --> 00:15:01,250
一旦测试装置开始替被测系统做决定，你就再也分不清是代码对了还是夹具对了。

138
00:15:01,250 --> 00:15:06,935
顺带一句，夹具挂了就修夹具，别去写归一化器把它抹平。

