iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0

「每個指令分開測都綠燈,不代表串起來也沒問題;每個指令分開測都很快,不代表串起來也很快。」

Day 06 到 Day 11,brain-cli 陸續長出了 scan(掃描全庫)、capture(快速捕捉筆記)、health(偵測孤立筆記與斷鏈)三個指令,internal/vault 也一路疊出 Scan、AST 解析、ExtractWikilinksBuildIndexRunHealthCheck 這條處理鏈。但每個 change 的測試都只用了個位數到十幾篇筆記的小型 fixture,從來沒有人在「數量級接近真實使用情境」的 vault 上,驗證過這三個指令串在一起的行為是否正確、也沒有人量過全庫分析到底要花多久。README 對 Day 12 的承諾是「整合展示:毫秒級跑完全庫分析」——今天要把這句話從形容詞變成有數據支撐的結論,作為 Stage 2 的收尾。

為什麼合成 vault 產生器要「決定性」而不是隨機

要量測效能,第一步是要有一份夠大的 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 篇筆記、100 毫秒

選 1000 篇作為 benchmark 規模,是因為這已經遠超目前 vault/ 底下實際筆記數量(個位數到十幾篇),足以代表「知識庫經營一段時間之後」的規模,又不會大到讓開發者等待過久。上限訂在 100 毫秒(Scan+BuildIndex+RunHealthCheck 全流程),理由有兩層:

  • 技術上,filepath.WalkDir 循序讀 1000 個小型 Markdown 檔、goldmark 解析 AST、BuildIndex 建記憶體 map,這些操作單獨看都是次毫秒到個位數毫秒級的成本,100 毫秒留了充足的安全邊際,不會因為測試機器效能波動就變成 flaky 判定。
  • 體感上,「毫秒級」在 README 裡本來就是形容「使用者操作完全無感」的門檻,100 毫秒符合這個體感;訂在 10 毫秒太貼近機器效能波動邊界、容易在資源受限的 CI runner 上 flaky,訂在 1 秒則已經失去「毫秒級」宣稱的意義,等於沒有真正驗證到體感門檻。

實測結果:make bench 跑出來的數字

internal/vault/full_analysis_bench_test.go 用合成 vault 產生器生出 1000 篇筆記,量測 ScanBuildIndexRunHealthCheck 全流程:

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 3

Stage 2(Day 06-12)到這裡收尾:internal/vaultinternal/capturescan/capture/health 三個指令不只各自正確,串起來在千篇筆記規模下也依然是毫秒級。這件事對接下來的 Stage 3 很重要——Day 13 起要開始疊 Claude Code Agent 工作流,Agent 會頻繁呼叫 brain-cli 來讀取與更新知識庫狀態,如果每次呼叫都要等上幾百毫秒甚至幾秒,整個 Agent 互動的體感會被拖垮。今天的效能驗收,正是進入 Stage 3 之前必須先確認的地基。

👉 明天 Day 13,我們要開始介紹 Claude Code 讀取上下文與執行機制,正式進入「用 Claude Code Agent 智慧化工作流」的階段。我們明天見!


上一篇
知識庫健康檢查:偵測孤立筆記 (Orphan Notes) 與斷鏈
下一篇
認識 Claude Code:終端機 AI Agent 的優勢與運作機制
系列文
打造 AI Agent 驅動的第二大腦:用 Go + Claude Code + Obsidian + Graphify 打造工程師知識作業系統15
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言