「每個指令分開測都綠燈,不代表串起來也沒問題;每個指令分開測都很快,不代表串起來也很快。」
Day 06 到 Day 11,brain-cli 陸續長出了 scan(掃描全庫)、capture(快速捕捉筆記)、health(偵測孤立筆記與斷鏈)三個指令,internal/vault 也一路疊出 Scan、AST 解析、ExtractWikilinks、BuildIndex、RunHealthCheck 這條處理鏈。但每個 change 的測試都只用了個位數到十幾篇筆記的小型 fixture,從來沒有人在「數量級接近真實使用情境」的 vault 上,驗證過這三個指令串在一起的行為是否正確、也沒有人量過全庫分析到底要花多久。README 對 Day 12 的承諾是「整合展示:毫秒級跑完全庫分析」——今天要把這句話從形容詞變成有數據支撐的結論,作為 Stage 2 的收尾。
要量測效能,第一步是要有一份夠大的 vault。手動寫 1000 篇筆記不現實,所以先做一個測試用的合成 vault 產生器:輸入筆記數量 N,在暫存目錄產生 N 篇皆具備合法 Frontmatter 的 .md 筆記,並在筆記之間建立 Wikilink 交叉引用。
關鍵決定是「決定性」而非隨機:每篇筆記固定連結前一篇,並每隔固定間距連結一篇距離固定偏移量之外、模擬跨區塊參照的筆記——完全不使用 math/rand:
const (
crossLinkInterval = 10
crossLinkOffset = 137
)
func syntheticNoteBody(i, n int) string {
var links []string
if i > 0 {
links = append(links, syntheticNoteTitle(i-1))
}
if i%crossLinkInterval == 0 {
if target := (i + crossLinkOffset) % n; target != i {
links = append(links, syntheticNoteTitle(target))
}
}
// ...組出 "參考 [[...]]。" 的正文
}
如果每次呼叫產生器都得到不同的圖結構,benchmark 結果就沒辦法互相比較——這次跑 53ms、下次跑 61ms,沒辦法判斷是實作變慢了,還是這次剛好連結分佈得比較密集。用固定規則產生固定結構,才能讓「同一個 N,每次呼叫都產生結構相同的 vault」,ns/op 的變化才單純反映實作效能,不會被輸入資料的隨機性污染判斷。internal/vault/testdata_gen_verify_test.go 直接把這個承諾釘死成測試:
func TestGenerateSyntheticVault_IsDeterministicAcrossRuns(t *testing.T) {
dirA, dirB := t.TempDir(), t.TempDir()
require.NoError(t, generateSyntheticVault(dirA, 30))
require.NoError(t, generateSyntheticVault(dirB, 30))
// ...逐檔案比對 dirA、dirB 內容完全相同
}
另外一個測試 TestGenerateSyntheticVault_AllNotesParseableByScan 則確保產生出來的每篇筆記都能被既有 Scan 成功解析、不會出現在 ScanError 裡——否則 benchmark 量到的就不是「全庫分析」的耗時,而是混進了一堆解析失敗的雜訊。
選 1000 篇作為 benchmark 規模,是因為這已經遠超目前 vault/ 底下實際筆記數量(個位數到十幾篇),足以代表「知識庫經營一段時間之後」的規模,又不會大到讓開發者等待過久。上限訂在 100 毫秒(Scan+BuildIndex+RunHealthCheck 全流程),理由有兩層:
filepath.WalkDir 循序讀 1000 個小型 Markdown 檔、goldmark 解析 AST、BuildIndex 建記憶體 map,這些操作單獨看都是次毫秒到個位數毫秒級的成本,100 毫秒留了充足的安全邊際,不會因為測試機器效能波動就變成 flaky 判定。make bench 跑出來的數字internal/vault/full_analysis_bench_test.go 用合成 vault 產生器生出 1000 篇筆記,量測 Scan → BuildIndex → RunHealthCheck 全流程:
func BenchmarkFullAnalysis(b *testing.B) {
const noteCount = 1000
dir := b.TempDir()
if err := generateSyntheticVault(dir, noteCount); err != nil {
b.Fatalf("產生合成 vault 失敗: %v", err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
notes, scanErrors, err := vault.Scan(dir)
if err != nil {
b.Fatalf("Scan 失敗: %v", err)
}
idx := vault.BuildIndex(notes)
vault.RunHealthCheck(idx)
}
}
Makefile 新增對應的 bench 目標:
bench: ## 執行全庫分析(Scan+BuildIndex+RunHealthCheck)效能 benchmark,不影響 test 目標的預設測試套件
go test -run=^$$ -bench=. -benchmem ./internal/vault/...
實際跑兩次 make bench 的結果:
BenchmarkFullAnalysis-8 21 54116554 ns/op 33239934 B/op 264343 allocs/op
BenchmarkFullAnalysis-8 21 53540340 ns/op 33231507 B/op 264344 allocs/op
兩次都落在 53.5~54.1 毫秒,穩定落在 100 毫秒上限之內,還留了將近一半的安全邊際。1000 篇筆記、跨檔案 I/O、AST 解析、記憶體索引全部做完只要 50 幾毫秒——「毫秒級跑完全庫分析」這句話,現在有數據撐著了。
值得注意的是,上限判定是人工核對 ns/op,不是寫在程式碼裡的自動化斷言:go test -bench 本身就不會被預設的 go test ./...(不帶 -bench)執行到,這件事不需要額外寫任何 gate 邏輯——benchmark 函式的存在本身就滿足了「效能判定不影響既有測試套件穩定性」的要求,也避免了效能量測在資源不穩定的 CI runner 上把整個測試套件搞成 flaky。
效能之外,還要驗證三個指令串起來的行為是對的。但這裡故意收得很窄:只驗證 capture 寫出來的檔案格式能不能被 scan 正確解析、新筆記在沒有連結時會不會被 health 判定為孤立——不重複驗證 Day 07 已經測過的標題衍生規則、Day 11 已經測過的排序邏輯:
func TestCaptureScanHealth_UnlinkedNewNoteIsOrphan(t *testing.T) {
root := t.TempDir()
now := time.Date(2026, 8, 18, 10, 0, 0, 0, time.UTC)
note, err := capture.BuildNote("尚未與任何筆記建立連結的新想法", now)
require.NoError(t, err)
writtenPath, err := capture.Write(root, note)
require.NoError(t, err)
notes, scanErrors, err := vault.Scan(root)
require.NoError(t, err)
require.Empty(t, scanErrors)
var captured *vault.Note
for _, n := range notes {
if n.FilePath == writtenPath {
captured = n
}
}
require.NotNil(t, captured, "capture 寫入的筆記應該出現在 scan 結果中")
idx := vault.BuildIndex(notes)
report := vault.RunHealthCheck(idx)
assert.Contains(t, report.OrphanNotes, captured)
}
這個測試唯一在意的是「串接點」:capture.Write 產出的檔案,vault.Scan 讀得懂嗎?讀出來的筆記,丟進 BuildIndex 再跑 RunHealthCheck,孤立筆記的判定邏輯會不會正確地把它抓出來?至於 capture 怎麼衍生標題、health 怎麼排序輸出,那些細節已經有各自的單元測試守著,這裡重複斷言只是增加測試維護成本,對「整合是否正確」這個問題沒有額外幫助。
53~54 毫秒已經在 100 毫秒上限之內,但即使超出了,這次 change 的範圍也不包含優化——這是刻意訂下的 Non-Goal。filepath.WalkDir 循序掃描、BuildIndex 全部建在記憶體 map 裡,這些是 Day 06 提案時就做的取捨:先求正確與簡單,效能留到有需要時再優化。Day 12 要驗證的是「這個取捨到目前為止還撐得住」,不是預先做平行化掃描、快取或增量索引這些還沒有被證明必要的工程。如果未來 vault 規模成長超出 1000 篇筆記這個驗證範圍、實測結果不再滿足 100 毫秒上限,優化會是一個獨立 change 的範疇,不會回頭修改今天訂下的 Requirement——今天的效能 Requirement 明確標註「以 1000 篇筆記為驗證規模」,不是對任意規模的保證。
Stage 2(Day 06-12)到這裡收尾:internal/vault、internal/capture 與 scan/capture/health 三個指令不只各自正確,串起來在千篇筆記規模下也依然是毫秒級。這件事對接下來的 Stage 3 很重要——Day 13 起要開始疊 Claude Code Agent 工作流,Agent 會頻繁呼叫 brain-cli 來讀取與更新知識庫狀態,如果每次呼叫都要等上幾百毫秒甚至幾秒,整個 Agent 互動的體感會被拖垮。今天的效能驗收,正是進入 Stage 3 之前必須先確認的地基。
👉 明天 Day 13,我們要開始介紹 Claude Code 讀取上下文與執行機制,正式進入「用 Claude Code Agent 智慧化工作流」的階段。我們明天見!