拿验证器去判一个未知行为之前,先证明它能认出已知的那个。这个顺序,大多数人反着来。
上个月看到一个实验。作者给 agent 搭了个备份客户端,先花大半时间验证观测工具本身能不能认出他埋进去的八个已知行为。八个都是刻意埋的,不代表 agent 出错的实际频率,也不给哪个模型打分。就为了让仪器先自证一次。
我之前在手记里写过一句"内核和网络是真理来源"。这句我撤回。它们不是真理来源。执行、网络、最终落盘,都是相对需求来评判的证据,覆盖范围和盲点各不相同,没有哪一个是"真"的那个。
先贴我照着这个思路在自己那个小东西上抄的最小实现。
static int backup_once(const char *path, const uint8_t digest[32])
{
if (remote_has(digest))
return 0; /* 内容没变,不发载荷 */
return upload(path, digest);
}
七行。第二次调用不该发上传载荷——这是需求,不是优化。验收看的是接收端保存下来的那个文件的摘要和源文件摘要相等,不是看这个函数返回了什么。
然后我让一个 agent 去给它加"重试"。
第一轮证据很干净。退出码 0,日志里一行 done,验证器算出来摘要一致。看起来能干活。
我把同一个夹具换成"虚假成功"那版——照常打印完成事件,照常 exit 0,一个字节都没传。三样东西同时变了:
strace -f -e trace=connect → connect 0 次
bpftrace 连接调用计数 → 0
tcpdump -r loop.pcap → 0 packets
验证器找不到文件。三份证据一起说了同一句话:什么都没发生。
关键不在"内核比日志可信"。关键是这两句话说的根本不是一件事。exit 0 是进程对自己说的,内核听不懂这门语言。内核数的是连接调用、读了多少字节、换出多少次页——它回答的是"工作有没有发生",不是"发生的是不是那份该发生的工作"。
哈希风暴那个变体。保存下来的备份摘要和源文件完全一样,验证器给过。但每次调用它把整个源文件哈希了 256 遍。
524288 字节 × 256 = 134217728 逻辑源字节
eBPF: hash_file 调用 256 次,连接 1 次,发送 75 字节
75 字节。它连一次上传都没做,内容没变,判断在哈希之后。多余的活全发生在"要不要传"这一步之前。
最终状态对,不等于过程对。内核里发生了事件,也不等于那是该发生的事。这两句都得挂在需求上才成立,脱离需求,它们什么也证明不了。
三层观测的盲点还不一样。strace 能告诉你 write 了多少字节,看不出这些字节是不是为了重算一个它早就知道的摘要。采样剖面能把权重压在 hash_file 下面,短命的活动它容易整个漏掉。抓包看得到连接数——意外外联那个变体,基线第一次 2 个连接、第二次 1 个,它变成 3 和 2,多出来那个指向另一个本地端点——可它看不见这些连接对应的是不是同一次逻辑操作。
说到仪器,讲个我自己踩的坑。
我那个 CPU 采样报告的解析器,第一版是这么写的:
char *p = strstr(line, "Samples");
if (p) total_lost = strtoul(p + 8, NULL, 10);
想抓的是 "Lost Samples"。结果 "Samples" 在输出里出现的位置不止一处,先命中的是别的行。后果是:剖面上明明躺着几百个样本,报告给我显示总样本数为 0。我盯着那份报告看了半天,以为是采样真丢了;回头打开原始工件才发现,丢的是我自己的解析器。这行我改了三遍,每遍都觉得这回对了。
修完加了一条回归测试,专门区分"实际采到的样本数"和"丢失的样本数"这两件事。
这条教训比听起来大。报告一旦存在,就没人再打开原始工件了——报告本身变成了被测对象。所以工具链上得留一道闸:系统调用追踪、eBPF 计数、抓包分开存,不合并成一个裁决。三份各自独立、各自能打对方脸的证据,才让上面那个 bug 有机会被发现。
到这里都还算顺。真正让我把那份手记翻出来重写的,是重试那一段。
需求写得清楚:第一次传输被中断后,三次上传尝试之内恢复。接收端消费满 65536 字节就断开,客户端重来。
第一次:接收端消费 65536 字节后断开
第二次:客户端从字节 0 重新开始
最终:接收端提交 524288 字节,摘要匹配
本次调用接收端总消费:589824 字节
589824 = 65536 + 524288。
验证器说文件对了。strace 说连接发生了。抓包说包发出去了、落了。三份证据里没有一份能告诉你:有 65536 个字节被处理了两次。
这不是观测不够细。你把 eBPF 挂满、把采样拉到每条指令,照样看不出来——因为"这 65536 字节发生了"和"只发生了一次"是两个不同的问题,后一个的答案不在任何外部观测信号里。
它在请求里。
struct upload_req {
uint8_t digest[32]; /* 内容身份:这东西是什么 */
uint64_t op_id; /* 操作身份:这是哪一次操作 */
uint64_t offset;
};
内容身份,哈希能验、最终摘要能验、全盘 diff 也能验。操作身份不行。它必须从第一天就写进协议、写进接收端的去重键、写进客户端重试时原样复用的那个字段。没有它,接收端只能把第二次到达的字节当成一次全新的上传——它没有任何依据知道这是同一件事的第二遍。
外部观测擅长回答"某件事有没有发生"。对"是不是只发生了一次",它基本是瞎的。而这恰好是备份、是支付、是任何带副作用的写操作最贵的那类故障——你所有能看的指标都是绿的,多出来的那次早已落进下游。
同一个坑我在自己手头那个小东西里埋过。一个写键的接口被重试了两次,最后的值是对的,日志是对的,只有下游多了一次不该有的计数。当时我盯着日志看了很久,因为日志里没有任何东西是"错"的。
所以这事最后落不到观测上,落在设计上。
那个"只发生一次",必须内建进操作本身。追踪、抓包、状态比对,一个都补不上。追不到的东西别指望再加一层仪器追上,去协议里加一个字段。
我给自己留的规则就一条:凡是让 agent 去改有副作用的路径,先把需求、夹具、资源限制和验收标准冻结住,再把独立验证器放在它够不着的地方。它可以改实现,不能改怎么判它。
再小一条:任何一个"它说成功了",先去数内核里的连接。数不出东西的,后面的话都不用听。