人類程式設計師還是比 LLM 厲害
原文由 Salvatore Sanfilippo 于 發布,訂閱此部落格
這是一個關於人類能力依然遠勝 LLM 的小故事。先說清楚,我不是反 AI 之類的人,認識我或在什麼地方追蹤我的人都知道。我平常就很常用 LLM,就像今天一樣,拿來驗證想法、做程式碼審查、看看有沒有比我原本想的更好的做法,或是探索那些在我專業邊緣的東西等等(我大約兩年前就寫過一篇關於用 LLM 寫程式的部落格文章,那時候還不算流行:我從那時起就一直用 LLM 寫程式到現在都沒停過,之後得寫篇更新,但那不是這篇文章的主題)。
但話說回來:現在的 AI 是很實用、也很棒,但離人類智慧還差得非常遠,我想特別強調這點,因為最近根本很難進行平衡的對話。
所以,今天我在處理 Redis 的 Vector Sets,想修一個很複雜的 bug:自我離開 Redis 的這段時間,同事們為 RDB 和 RESTORE 的 payload 加入了對抗損毀資料的保護,就算資料的 checksum 通過了也一樣會檢查。這個功能預設是關閉的,但對想要多一層安全保障的人來說,多了一道防線。
但……這裡有個像大象一樣大的「但是」:為了讓 HNSW 能夠快速地存進 Redis 的 RDB 並快速載回來,我序列化的是 *graph* 本身的結構,而不是 element-vector pair,否則就得把資料重新插回 HNSW,那會慢上大概 100 倍(!)。所以我把節點之間所有的連結都存成整數,載入時再解析成指標,這是個不錯的技巧,效果也很好。但如果你把這套做法、加上隨機的結構損毀,再加上我自己改良的 HNSW 會強制節點之間的連結必須是雙向的(我自己實作了一套 HNSW,加了不少實用的功能,但要實現這些功能,雙向連結是必要的),那就可能會發生這種事:
- 我們載入了損毀的資料,它說 A 連到 B,但 B 卻沒有連回 A(node ID 已經損毀)。
- 我們刪除了節點 B:因為雙向性被破壞了,所以不會清除 A 到 B 的那條連結。
- 接著我們掃描整張圖,掃到 B 時卻去存取了 A:use-after-free :-D :-) :-|
所以載入資料後,我必須檢查每一條連結是不是雙向的,而最原始的做法會是 O(N^2),每個節點都要把所有層級掃一遍,每個層級再把該節點的所有鄰居都看過,並透過掃描對方在同一層級的連結來確認對方也有連回來。這可不行。
人類 vs LLM
一開始,我先實作了這個最原始的版本,想看看 fuzzer 是不是就找不到那個 bug 了,結果確實有效,但一個有 2000 萬個向量的大型 Vector Set,載入時間卻從 45 秒變成 90 秒左右。搞什麼鬼。所以我開了一個 Gemini 2.5 PRO 的對話,問 LLM 說,嘿,這邊能怎麼辦?有沒有什麼超快的做法?
Gemini 能想到的最好解法是說:把鄰居連結的指標排好序,這樣就能用二分搜尋。喔,好吧,當然,我知道這個,我也不確定在只有 16/32 個指標的陣列上這樣做到底會更快還是更慢。於是我又問,還有別的嗎?沒有,沒有更好的解法了。
於是我跟它說:你看,如果我們在層級 X 看到 A 連到 B,就在 hash table 裡存一個 A:B:X(但我們永遠把 A 和 B 排序,讓 A>B,這樣不管哪個方向,連結都算同一條),等到第二次看到同一條連結時就把它清掉,這次我們只要像本來在解析連結中 ID 到指標的過程那樣把全部掃一遍就好,如果最後 hash table 不是空的,就知道一定有某條連結不是雙向的,怎麼樣?
Gemini 說這是個不錯的主意,但提到了要用 snprintf() 來產生 key 還有雜湊的成本等等,不過是的,這確實比我原本的做法(甚至比把指標排序)要好。我提醒它其實根本不需要 snprintf()。我們可以直接用 memcpy() 把指標拷貝到一個固定大小的 key 裡就好。它也認同這是可行的,然後我又想到一件事……
欸,我跟 Gemini 說,那如果直接用一個固定的 accumulator 來處理 A:B:X 呢?完全不用 hash table。每次看到一條連結(A:B:X,也就是 8+8+4 bytes)就把它 xor 進目前這個 12 bytes 的 accumulator。如果同一條連結存了兩次,就會互相抵消,所以最後如果暫存器不是零,就知道有點不對勁!不過我也先跟 Gemini 說,這個作法有可能會有碰撞,請它評估一下。就算這個功能在 Redis 裡預設是關掉的,當使用者開啟這種額外檢查時,往往也會期待它對刻意構造惡意 payload 的攻擊者能多提供一點保護。
Gemini 對這個想法還滿驚豔的,但還是說指標嘛……你知道的,結構都很相似,只差幾個位元,所以如果有三條異常的連結 L1、L2、L3,就有可能 L1 和 L2 xor 起來的結果剛好跟 L3 的位元一樣,導致我們出現 false negative(暫存器變成零)。我也注意到 allocator 往往非常可預測,很容易被外部猜到。
我請 Gemini 想想有沒有辦法改進,它想不出什麼好主意。然後我想,等等,其實我們可以用一個夠好又夠快的雜湊函式來雜湊,像 murmur-128 或類似的(這個任務不需要具備密碼學等級的特性),接著我向 Gemini 提出了以下方案:
- 拿連結 A:B:X,但用一個從 /dev/urandom 取得的 seed 當作所有 key 的前綴,所以實際上是 S:A:B:X。
- 我們直接把 murmur-128(S:A:B:X) 的輸出 xor 進 128 bit 的暫存器裡。
- 最後檢查暫存器是不是 0(如果為 0,就代表所有連結都是雙向的)。
我請 Gemini 分析這個做法,它終於滿意了,說這樣不管是要偶然碰到幾條孤立的連結剛好 xor 成 0,或是外部攻擊者想刻意利用這點來做些什麼,都會變得困難得多,因為「S」是未知的,又要控制指標等等,全部加起來真的很難湊得齊。而且,這個功能本來就是一個需要手動開啟的額外盡力而為(best effort)保護,預設是關閉的,為了實用性,本來就不該帶來太大的效能損失。
總之,說了這麼多:我剛做完分析就停下來寫了這篇部落格文章,我還不確定是不是真的會採用這個方案(但很可能會),不過,想說的是:人類的創造力還是有優勢的,我們能夠跳出框架思考,想到一些奇怪又不那麼精確、卻可能比其他方法更有效的解法。這種能力對 LLM 來說還是極其困難的。不過話說回來,為了驗證我的各種想法,Gemini 還是非常有用的,或許我之所以會用這種角度去思考問題,正是因為有個「聰明的橡皮鴨」可以對話。
隨機一篇部落格
留言
登入後參與討論