这是一篇来自 Antirez(Redis 之父Salvatore Sanfilippo)的博文,分享给大家
人类程序员依然技高一筹:为什么说 AI 目前还差点火候
这篇短文,是想聊聊为什么我觉得咱们人类程序员,比起现在大火的 LLM(大语言模型)还是要强太多。先声明,我可不是什么 AI 反对者,了解我或者关注我动态的朋友应该都清楚。LLM 我经常用,就像今天,我会用它来碰撞灵感、做代码评审、看看有没有比我最初构想更好的方案、探索那些快要超出我知识边界的领域,诸如此类吧。(大约两年前,LLM 还没那么流行的时候,我就写过一篇关于用 LLM 辅助编程的博客:那时候我就已经在用 LLM 写代码了,并且一直没停过。这事儿我得抽空再写篇更新,但今天的主题不是这个。)
但是,话说回来:眼下 AI 的水平,确实有用,也相当不错,可要跟人类智能相比,那还差得远呢。我特别想强调这一点,因为近来想就这个问题进行平衡的讨论都很难,舆论往往一边倒。
话说今天,我正在为 Redis 的向量集(Vector Sets)功能修复一个特别复杂的缺陷:在我离开 Redis 开发的那段时间里,我的同事们为 RDB 和 RESTORE 的数据引入了一种防损坏机制,即便数据的校验和通过了,这个机制也会生效。这个功能默认是关闭的,但为有需求的用户提供了一个增强的安全层。
但……这里有个巨大的隐患:为了让 HNSW(分层可导航小世界图)能够快速地存入 Redis RDB 并加载回来,我序列化的是整个图结构本身,而不是单个的“元素-向量”对。否则的话,我得把数据重新插入到 HNSW 中,那样速度会慢上大概 100 倍(没错,就是这么多!)。所以我把节点之间所有的连接关系都以整数形式存储,然后在加载时再将它们解析成指针。这个技巧很巧妙,效果也非常好。可如果你把这套机制,跟数据表示层面可能出现的随机损坏,再加上我自己对 HNSW 的一点改进——强制节点间的连接必须是双向的(我自己实现了一套 HNSW,加入了不少实用特性,而双向连接是启用其中许多特性的基础)——这些因素结合起来,就可能出现以下情况:
- 我们加载了一份已损坏的数据,数据显示 A 链接到 B,但 B 已经不再链接回 A(因为节点 ID 损坏了)。
- 我们删除了节点 B:由于双向性被破坏,从 A 到 B 的链接并没有被清除。
- 然后当我们扫描图结构,访问到 B 时,试图去访问 A:好了,use-after-free(悬垂指针访问)发生了!:-D :-) :-|
所以,数据加载完成后,我需要检查每一个链接是否都是双向的。如果用最直接的方法,复杂度会是 O(N^2)——对于每个节点,都需要遍历其所有层级,在每个层级遍历其所有邻居节点,并且还要反过来检查该邻居节点在这一层级是否也链接回当前节点。这效率太低了,行不通。
人类 vs LLM
起初,我按照常规方法实现了这个检查,想看看模糊测试工具(fuzzer)是否还会发现那个缺陷。结果确实没再出现,但是,一个包含 2000 万个向量的大型向量集,加载时间从 45 秒一下子增加到了 90 秒左右。太坑了!于是我赶紧打开一个 Gemini 2.5 PRO 的聊天窗口,问它:“老兄,这情况有什么好办法吗?有没有什么超快的解决方案?”
Gemini 能给出的最佳方案是:对邻居链接的指针进行排序,这样就可以使用二分查找了。嗯,好吧,这个方法我懂,但我不太确定在一个只有 16 或 32 个指针的数组里,这样做到底是更快还是更慢。我又追问:“还有其他方案吗?” 回答:“没有更好的了。”
于是我跟它说:你看这样如何?当我们发现 A 在 X 层链接到 B 时,我们把 A:B:X 存入一个哈希表(但我们总是对 A 和 B 进行排序,使得 A>B,这样无论链接方向如何,其表示都是一致的)。当我们再次遇到这个链接时,就把它从哈希表中移除。这一次,我们就像之前解析 ID 到指针的链接时那样,完整地扫描整个图结构。如果最后哈希表不为空,那就说明肯定存在非双向的链接,对吧?
Gemini 表示这是个不错的主意,但指出使用 snprintf() 生成键以及哈希运算本身会耗费时间,不过,它也承认这确实比我最初的方法(即使是排序指针的版本)要好。我提醒它,snprintf() 并非必需。我们可以直接用 memcpy() 将指针复制到一个固定大小的键中。它认可了这种做法的可行性,然后,我突然有了个新想法……
我跟 Gemini 说,我们能不能用一个固定大小的累加器来处理 A:B:X 呢?完全不需要哈希表。每次我们遇到一个链接(A:B:X,也就是 8+8+4 字节),就把它与当前一个 12 字节的累加器进行异或(XOR)运算。如果一个链接出现两次,两次异或操作就会相互抵消。所以,最后如果累加器的值不为零,我们就知道数据有问题了!不过,我预先跟 Gemini 指出,这个系统可能存在冲突(collision)的风险,需要进行评估。即使这个功能在 Redis 中通常是关闭的,但当用户启用这种额外的检查时,他们通常也期望能获得更多保护,以防止攻击者故意构造恶意的负载。
Gemini 对这个想法印象相当深刻,但仍然指出,指针这种东西……你知道,它们的结构都很相似,只在少数几个比特位上有差异。所以,如果恰好出现了三个伪造的链接 L1、L2、L3,有可能 L1 和 L2 异或的结果正好与 L3 的比特位相同,这样我们就可能得到一个假阴性(即累加器为零,但实际上存在问题)。我还注意到,内存分配器的行为往往非常具有可预测性,并且容易被外部猜测到。
我让 Gemini 帮忙想办法改进这个方案:它没能提出什么特别好的主意。然后我想,等等,我们实际上可以用一个足够好并且速度仍然很快的哈希函数来处理它,比如 murmur-128 或类似的算法(这个任务并不要求它具备密码学级别的安全性),然后我向 Gemini 提出了以下方案:
- 取链接 A:B:X,但使用一个通过 /dev/urandom 获取的种子 S 作为所有键的前缀,所以我们实际处理的是 S:A:B:X。
- 我们将 murmur-128(S:A:B:X) 的输出与一个 128 位的寄存器进行异或操作。
- 最后,检查该寄存器是否为 0(如果为 0,则表示所有链接都是双向的)。
我让 Gemini 对这个方案进行分析,它最终表示满意,说这样做会使得无论是偶然发现几个恰好异或结果为 0 的孤立链接,还是外部攻击者想利用这一点进行有效攻击,都变得困难得多。因为“S”是未知的,攻击者还需要同时控制指针,所有这些因素组合起来非常难以实现。此外,这个功能本身就是一种尽力而为的额外保护措施,需要用户手动启用,它通常是关闭状态,并且为了保证实用性,它不应该带来过大的性能开销。
代价
嗯,说了这么多,其实就是想表达:我刚刚完成了分析,就停下来写这篇博客了。我不确定最终是否会采用这个系统(但可能性很大),但是,人类的创造力依然占据着优势。我们能够真正地跳出固有思维模式,构想出那些看似奇特和不那么精确,但实际上却能比其他方案更好地解决问题的办法。这种能力,对于 LLM 来说是极其困难的。当然,在验证我所有想法的过程中,Gemini 还是非常有用的,或许我之所以能从这些角度思考问题,也是因为我有一个“聪明的橡皮鸭”(指可以与之对话并梳理思路的对象)可以交流吧。
source:
https://www.antirez.com/news/153