分類: 💻 技術實踐

  • AI 逆向工程協作:Source Inventory 協議讓一比一重建不再落東掉西

    重點摘要

    • AI 協作做逆向工程,最大的坑不是技術,而是 AI 「選擇性閱讀」原始碼
    • 根治方法:強制 Source Inventory 協議 — 動手前先列清單、附行號、用戶確認邏輯後才寫程式
    • 用戶不需要知道原版長什麼樣,清單上的每一項都要有原始碼行號出處
    • 這套協議已寫入 CLAUDE.md,適用於任何 legacy code 逆向重建專案

    最近在做一個系統重建專案:把一套已上線多年的 Ionic + PHP 系統,逐頁逐模組一比一重建為 Flutter 應用。聽起來直白,實際做起來卻踩了一個讓人很沮喪的坑——不是技術難度的問題,而是 AI 協作流程設計的問題

    這篇文章記錄我如何診斷這個問題,並建立一套可複用的 AI 逆向工程協作協議。

    問題:一比一複製,卻一直落東掉西

    我明確告訴 AI:「這是舊版原始碼,你幫我一比一複製到 Flutter。」但每次交付,都會發現缺少功能。最典型的例子:登入後的登出功能不見了

    登出不是什麼複雜功能,但它不在主流程頁面——它藏在 header component 的某個角落。AI 在讀原始碼的時候,只讀了主頁面,沒讀共用元件,自然就沒看到登出按鈕的存在。

    更麻煩的是:我自己也不知道原版長什麼樣(這是別人開發的系統),所以我也沒辦法直接指出「你少了這個」。每次都要在用起來的時候才發現缺少什麼,然後重來。

    根本原因:AI 的「選擇性閱讀」

    深入想了一下,問題的根源在於 AI 讀原始碼的方式:

    錯誤的讀法 正確的讀法
    找「我預期會有的東西」 逐行掃描,列出「所有看到的東西」
    只讀主要頁面檔案 讀所有相關檔案(service、routing、共用元件)
    憑印象補齊功能 每一項都有原始碼出處(檔案 + 行號)
    「讀完」就開始寫 產出清單、確認後才開始寫

    用一句話說:AI 是在「確認自己的預期」,不是在「完整盤點原始碼」。這種差距在簡單頁面感覺不出來,在有大量共用邏輯的真實系統裡,每個模組都會漏東西。

    解法:Source Inventory 協議

    我和 AI 一起設計了一套強制執行的協議,並寫入專案的 CLAUDE.md,讓每個接手這個專案的 AI session 都必須遵守。

    Step 0 — 列出所有要讀的檔案(動手前)

    在讀任何內容之前,先列出這個模組涉及的所有檔案,包含:

    • 頁面本體(template + logic)
    • 所有被引用的 service
    • 共用元件(header、footer、tab-bar、nav)
    • 路由設定
    • 後端 handler

    每個檔案讀完打勾,沒打勾的不算讀過。這一步強迫 AI 在開始之前就意識到「這個模組牽涉哪些檔案」,避免只讀主檔就動手。

    Step 1 — 產出 Source Inventory(每項附行號)

    讀完所有檔案後,產出功能清單。格式固定:

    - [ ] 登出按鈕
          來源:home.page.html:142 <ion-button (click)="logout()">
    
    - [ ] 下拉刷新
          來源:ticket.page.html:38 <ion-refresher (ionRefresh)="doRefresh($event)">
    
    - [ ] 空狀態提示
          來源:ticket.page.html:91 <div *ngIf="tickets.length === 0">目前沒有票券</div>

    關鍵點:每一項都必須有原始碼的檔案和行號

    這解決了「用戶不知道原版長什麼樣,所以沒辦法驗證清單正確性」的問題。用戶不需要看過原版,只需要問:「這一項你是從哪行讀到的?」答不出來,就代表是 AI 憑印象補的,不是真的在原始碼裡。

    Step 2 — 逐項實作,逐項打勾

    對照清單一項一項做,每完成一項就打勾。不允許「大概實作了」或「之後再補」。

    Step 3 — Self-Verify 後才交付

    交付前,AI 自己對照清單跑一遍,確認每項都存在於程式碼中。如果 Step 3 發現漏項,必須補完再說「完成」。漏項不能留給用戶發現。

    一條關鍵規則:禁止在原始碼存在的情況下向用戶索取截圖

    另一個常見的壞模式是:AI 讀不懂某個行為,就跟用戶要截圖或圖片。

    這在逆向工程的情境下尤其荒謬。用戶拿到舊系統原始碼,通常自己也沒有截圖,甚至從來沒用過那個系統。要求用戶提供截圖,是把 AI 本來應該做的工作(讀原始碼)轉嫁給用戶。

    正確的做法是:原始碼讀不懂的地方,標記 [?],說明不確定的點,讓用戶確認業務邏輯——而不是要求視覺確認。

    這套協議適用於任何逆向升級工程

    雖然這次的情境是 Ionic → Flutter,但這套協議的核心邏輯對任何 legacy 重建都適用:

    • jQuery / AngularJS → React / Vue
    • PHP monolith → 現代後端(Go、Node、Python)
    • 原生 Android/iOS → Flutter / React Native
    • 桌面應用 → Web 應用

    只要是「拿到舊系統 code,重寫到新技術棧」,用戶通常都不知道原版的全貌,也無法驗證 AI 是否讀完了所有東西。Source Inventory + 行號出處 是讓這個過程可稽核的最低門檻。

    如何在你的專案裡執行這套協議

    把以下內容加入你的專案 CLAUDE.md(或任何 AI 工作指引文件):

    ## 一比一複製協議(MANDATORY)
    
    > 原始碼是唯一的 ground truth。用戶不應該被要求提供截圖。
    
    ### Step 0 — 列出所有要讀的檔案
    (所有頁面、service、共用元件、routing、後端 handler)
    每個檔案讀完打勾,沒打勾不算讀過。
    
    ### Step 1 — Source Inventory(每項附檔案:行號)
    格式:
    - [ ] 功能描述
          來源:filename.html:LINE_NUMBER <原始碼片段>
    
    ### Step 2 — 逐項實作,逐項打勾
    
    ### Step 3 — Self-Verify 後才交付
    
    ### 禁止行為
    - 在原始碼存在的情況下向用戶索取截圖
    - 沒有產出 Source Inventory 就開始寫程式
    - 只讀主頁面,不讀 service / routing / 共用元件

    結語

    逆向升級工程的難點不在技術轉換,而在於如何保證「沒有遺漏」。AI 的天性是預測下一個 token,這讓它容易「補全期望的樣子」,而不是「如實記錄看到的東西」。

    Source Inventory 協議的作用,就是把 AI 的工作模式從「自由發揮」強制切換成「逐行比對」。這不是在限制 AI,而是在讓 AI 的輸出變得可以被信任。

    如果你也在做類似的逆向重建專案,歡迎把這套協議複製進你的 CLAUDE.md,看看效果如何。

  • gemma4:e4b 在 AMD Renoir iGPU Vulkan 輸出亂碼的完整排查

    重點摘要

    • gemma4:e4b 在 AMD Renoir iGPU(Vulkan)超過 30 層就輸出亂碼,低層數正常
    • 根本原因:RADV Vulkan shader compiler 的精度優化破壞 f16 運算順序,誤差隨層數滾雪球
    • iGPU 沒有獨立 VRAM,記憶體從系統 RAM 借,RAM 不足會讓模型載入循環崩潰
    • Mac Metal / 純 CPU 不受影響;GGML_VK_DISABLE_F16 等環境變數無法修復底層 compiler bug

    在本地端跑大型語言模型時,GPU 加速聽起來理所當然——更快嘛。但如果 GPU 算出來的答案是錯的,快有什麼用?這篇文章記錄了一次真實的踩坑:gemma4:e4b 在 AMD Renoir iGPU 上透過 Vulkan 跑出亂碼的完整排查過程,以及背後一層比一層深的技術原因。

    事件起因:模型跑得快,答案全錯

    測試環境是一台搭載 AMD Renoir APU 的 mini PC,透過 Ollama 跑 gemma4:e4b 模型。Ollama 偵測到 iGPU 後自動啟用 Vulkan 加速,把 42/43 層 offload 到 GPU。

    速度看起來不錯,約 8 tok/s。但仔細看輸出內容,發現問題大了:

    • 問「什麼是 Kafka Consumer Group Rebalancing」→ 回答泰文的學習理論
    • 問 Python 日期解析函數 → 回答 JavaScript offsetTop 的用法
    • 問燈泡謎題 → 回答哲學框架

    模型完全沒理解問題,像是跑在另一個平行宇宙一樣。

    第一個坑:RAM 不足讓模型連載入都不穩定

    在問為什麼答案亂之前,先碰到一個更早的問題:模型根本載入不起來。第一次啟動時,Ollama 日誌出現了這個循環:

    16:35:53  offloaded 42/43 layers to GPU(模型開始載入)
    16:38:53  llm server not responding
    16:38:54  llm server loading model
    16:39:02  llm server not responding
    16:39:03  llm server loading model
    ...(反覆了將近 6 分鐘)
    16:42:28  llama runner started in 410.58 seconds(終於啟動)
    17:04:49  -- Boot --(機器直接重開機)

    當時系統狀況:OMS 全套服務正在運行,RAM 只剩 1.7 GiB 可用。停掉 OMS 釋放記憶體後重跑:

    llama runner started in 42.04 seconds

    差了 10 倍。問題不在模型,在記憶體。

    為什麼 iGPU 需要大量系統 RAM?

    AMD Renoir iGPU 沒有獨立顯存,它的記憶體架構分兩層:

    類型 來源 大小 特性
    UMA(Unified Memory) BIOS 開機時從系統 RAM 切出 2 GiB(此台設定) 固定預留,GPU 專用,穩定
    GTT(Graphics Translation Table) 從剩餘系統 RAM 動態借用 最多 ~6.8 GiB 依可用 RAM 決定,借不到就崩

    Ollama 顯示「8.8 GiB 可用 VRAM」= UMA(2 GiB)+ GTT 上限(~6.8 GiB)合計。

    跑 gemma4:e4b 需要 2.8 GiB GPU weights + 224 MB KV cache + 289 MB compute graph ≈ 3.3 GiB。流程是:

    1. 先用完 2 GiB UMA
    2. 再向 GTT 借 ~1.3 GiB 的系統 RAM 頁面
    3. GTT 的頁面必須來自空閒的系統 RAM

    OMS 開著只剩 1.7 GB 可用,GPU 借不到足夠的 GTT 頁面,runner 一直崩潰重試,才出現那個 6 分鐘的死循環。最後機器因為 OOM 直接 reboot。

    換句話說:iGPU 的「VRAM」是假的,它在跟你的程式搶同一塊系統 RAM。獨顯不會有這個問題,因為它有真正的 GDDR 記憶體。

    GPU 與 GPU 的差異:不是每顆都一樣精準

    解決了載入問題,答案還是亂的。這時才是 GPU 精度的核心問題。

    GPU API f16 硬體支援 ML 成熟度
    NVIDIA RTX CUDA 原生 Tensor Core,為 ML 設計
    Apple M 系列 Metal Neural Engine + Metal 緊密整合
    AMD RX 獨顯 ROCm / Vulkan 原生支援,ROCm 驗證過 中高
    AMD Renoir iGPU Vulkan(RADV) Vega 架構,遊戲導向,未針對 ML 驗證

    f16 規格一樣,為什麼結果不同?

    很多人的疑問:IEEE 754 fp16 是標準規格,大家都遵守,為什麼不同 GPU 算出來不一樣?

    因為規格只定義單一運算的結果,但沒有規定三件事:

    1. Denormal 數的處理方式:非常接近 0 的浮點數(小於 6×10⁻⁸)稱為 denormal。IEEE 標準要求保留精度處理,但 AMD Vega 架構預設會把這些數 flush to zero(FTZ)——直接歸零——以換取效能。NVIDIA 不做 FTZ。這在遊戲中完全沒影響,但 LLM attention 的 softmax 運算大量出現這個範圍的數值。
    2. 運算順序:浮點數不符合結合律(a + b) + c ≠ a + (b + c) 在 fp16 中是真的。Shader compiler 為了讓 GPU 並行效率最大化,會自由重排運算順序,這對遊戲結果沒有可見影響,但 LLM 的 attention 矩陣乘法有幾萬次浮點運算,順序一改,誤差模式就完全不同。
    3. FMA(Fused Multiply-Add)的使用時機a × b + c 可以分兩步算(兩次取整),也可以融合成一步(只取整一次,精度更高)。NVIDIA CUDA 強制所有路徑用 FMA。RADV 的 ACO shader compiler 根據優化情境決定,在某些 LLM 矩陣模式下選了非 FMA 路徑。

    這三個問題疊在一起,每一層的誤差模式都不同,而且誤差會被下一層當成合法輸入繼續放大。

    為什麼低層數正常、高層數亂碼?

    什麼是 Transformer 的「層」

    gemma4:e4b 是 Transformer 架構,共 43 層。每個 token 的生成,資料要依序流過全部 43 層,每一層的輸出是下一層的輸入:

    Token 輸入
      → Layer 1(基礎語法、token 特徵)
      → Layer 2 ~ 10(語法結構、基礎語意)
      → Layer 10 ~ 25(上下文理解、語意推理)
      → Layer 25 ~ 42(高階抽象、輸出生成)
      → 下一個 token

    當設定 num_gpu=N 時,前 N 層在 GPU 算,剩下的在 CPU 算

    誤差如何滾雪球

    Layer  1:正確值 2.000,Vulkan 算出 2.001 → 誤差 0.001(無感)
    Layer  5:誤差疊加,下層把這個「略偏的 2.005」當成正確輸入繼續算
    Layer 10:誤差影響語意方向判斷
    Layer 20:模型對問題的理解開始偏移
    Layer 30:完全脫離正確答案空間
    Layer 42:輸出隨機語言的 token,變成泰文或法文

    這就像傳話遊戲:每個人傳話都有一點偏差,傳到第 43 個人時,內容已經面目全非。

    為什麼簡單問題(1+1)還能過

    「1+1 等於幾」的答案空間極小——就算誤差很大,最終 token 機率分布仍然在「2」附近。但「請用 Python 寫一個函數處理三種日期格式」的答案空間是整個程式語言的 token 集合,一旦偏移,就隨機落到任何地方。這也是為什麼診斷時不能只用簡單問題測試,必須用複雜 prompt 才能暴露問題。

    診斷過程:二分法找臨界點

    for layers in 1 5 10 20 30 40 42; do
      echo -n "=== num_gpu=$layers === "
      curl -s http://localhost:11434/api/chat \
        -d "{\"model\":\"gemma4:e4b\",
             \"messages\":[{\"role\":\"user\",\"content\":\"1+1等於幾?\"}],
             \"stream\":false,
             \"options\":{\"num_gpu\":$layers,\"num_predict\":30}}" \
        | python3 -c "import json,sys; print(json.load(sys.stdin)['message']['content'])"
    done
    num_gpu 結果 狀態
    1 1+1 等於 2 ✅ 正確
    5 1+1 等於 2 ✅ 正確
    10 (空白) ⚠️ 不穩定
    20 簡單問題偶爾正確 ⚠️ 不穩定
    30 法文回應 ❌ 亂碼
    42 隨機英文片段 ❌ 亂碼

    嘗試修復:三種方案全部失敗,以及為什麼

    方案一:GGML_VK_DISABLE_F16=1

    理論上這個環境變數應該告訴 llama.cpp 在 Vulkan 路徑使用 f32 計算,避開 f16 精度問題。但沒有效果。原因在於 Vulkan 推論的整個管線分四層:

    1. llama.cpp         → 寫 GLSL compute shader 原始碼        ← 環境變數影響到這層
    2. glslang           → 編譯成 SPIR-V bytecode
    3. RADV / ACO        → 把 SPIR-V JIT 編譯成 AMD GCN 機器碼  ← 精度 bug 在這層
    4. AMD Vega 硬體     → 執行機器碼

    精度 bug 在第 3 層——RADV 的 ACO shader compiler 在把 SPIR-V 轉成 GCN ISA 時,對某些矩陣運算的指令做了重排或合併,這個行為由 RADV 自己決定,應用層的環境變數完全碰不到。就算第 1 層寫了 f32,RADV 也可能在編譯時把某些中間值降轉成 f16——這對遊戲是合法的效能優化,對 LLM 是致命的。

    方案二:OLLAMA_KV_CACHE_TYPE=f32

    只影響 KV cache 的儲存精度,不影響主要的矩陣乘法計算路徑。輸出直接變成旁遮普語。

    方案三:num_gpu=20 折衷

    1+1 這種極簡問題可以過,但稍微複雜的 prompt 仍然亂答。誤差臨界點比簡單測試顯示的還要低,不穩定。

    最終結論

    方案 速度 正確性 建議
    純 CPU(num_gpu=0) ~1 tok/s ✅ 正確 唯一可用方案
    Vulkan 全層(num_gpu=42) ~8 tok/s ❌ 亂碼 不可用
    Mac Metal(同款模型) ~8 tok/s ✅ 正確 最佳選擇

    快 8 倍但答案全錯,還不如 1 tok/s 的純 CPU。資料正確性永遠優先於速度。

    如果你也遇到類似問題:排查步驟

    1. 先確認系統有足夠空閒 RAM(至少要比模型大小多 2 GiB),不然連載入都會失敗或 OOM reboot
    2. num_gpu=0 跑純 CPU,確認模型本身答案正確
    3. 逐步增加 num_gpu(1 → 5 → 10 → 20 → …),用複雜問題測試(不要只用 1+1),找到亂碼的臨界層數
    4. 環境變數(GGML_VK_DISABLE_F16、OLLAMA_KV_CACHE_TYPE)對 RADV 底層 compiler bug 無效,不要在這裡浪費時間
    5. 若根本無法修,接受純 CPU 或換有 CUDA / Metal 支援的環境

    這不是 Ollama 的 bug,也不是模型的問題。是 AMD Renoir Vega iGPU + RADV Vulkan shader compiler 在 LLM 工作負載下的精度限制,加上 iGPU 共享系統 RAM 的架構特性,兩個問題同時踩到。

  • 本地 AI 模型完整實測:五款模型 × 兩台機器 × 三種設定,找出真正的上限

    重點摘要

    • Mini PC(Ryzen 7 4700U):gemma4:e4b 最快(1.45 tok/s),qwen3:14b 最完整(7/7 題全答);Bonsai 8B 因 AVX-512 需求完全無法使用
    • 換一台機器差多少?:同款 gemma4:e4b,MacBook Air M3 跑出 9.75 tok/s,是 Mini PC 的 6.7 倍;Q2 輸出從截斷 300 tokens 變成完整 2218 tokens
    • 開 Thinking 差多少?:速度幾乎不變(-6%),但 Q2 程式碼輸出 +65%,Q7 技術解釋 +124%,品質接近 GPT-3.5

    你想在本地跑 AI 模型,但不知道哪款模型值得裝?硬體夠不夠?開 Thinking 模式到底有沒有差?這篇文章用實際跑出來的數據回答這三個問題——五款模型 × 兩台機器 × 三種設定,全部實測,沒有廣告。

    測試環境分別是一台平價 Mini PC(Ryzen 7 4700U,16GB RAM,CPU-only)和 MacBook Air M3(24GB 統一記憶體)。七道測試題涵蓋:文字理解、Python 程式碼生成、SQL 查詢、TCP 技術解釋等真實使用情境。

    測試環境規格

    項目 Mini PC MacBook Air M3
    處理器 AMD Ryzen 7 4700U Apple M3
    記憶體 16GB DDR4(CPU-only) 24GB 統一記憶體
    顯示卡 AMD Vega 7 iGPU,僅 128MB VRAM(不可用) Apple GPU(共享統一記憶體)
    推論框架 Ollama + llama.cpp(CPU 模式) Ollama + llama.cpp(Metal)
    特殊限制 無 AVX-512,部分模型無法執行 無限制

    重點摘要表(先看這三張表)

    表 1:Mini PC 五款模型速度與完成度對比

    模型 平均速度 完成度 主要問題
    Bonsai 8B 0.001 tok/s 完全不可用 需要 AVX-512,CPU 不支援
    qwen3:4b 0.78 tok/s 5/7(部分截斷) Q2/Q5 在 300 token 限制下截斷或夾雜文字
    qwen3.5:9b 0.57 tok/s 5/7 Q6/Q7 需延長 timeout 至 1800s
    qwen3:14b 0.58 tok/s 7/7 全答 無截斷問題,但速度慢
    gemma4:e4b 1.45 tok/s 5/7(舊測試 600 上限) Q2/Q4 在 600 token 舊限制下截斷

    表 2:同款 gemma4:e4b,硬體差距

    指標 Mini PC(CPU) MacBook Air M3 差距
    平均速度 1.45 tok/s 9.75 tok/s 6.7×
    Q2 輸出長度 600 tokens(截斷) 2218 tokens(完整) 記憶體夠,才不截斷
    Q4 輸出長度 600 tokens(截斷) 1043 tokens(完整) SQL 查詢完整輸出
    最大 num_ctx 受 16GB 限制 65536+ 長文件處理能力天差地別

    表 3:Mac 上同款模型,三種設定對比

    設定 平均速度 Q2 輸出 Q7 輸出 適合場景
    Mini PC 舊測試(600 limit) 1.45 tok/s 截斷 309 tokens 快速查詢
    Mac think:false,無限制 9.75 tok/s 2218 tokens ✅ 442 tokens 日常程式碼
    Mac Thinking 開啟,無限制 9.16 tok/s(-6%) 3670 tokens ✅ 990 tokens 複雜推理、技術解釋

    第一層:Mini PC 上,哪個模型值得跑?

    在只有 CPU 推論的環境下,選模型就是在「速度」與「品質」之間做取捨。以下是完整的三維評估。

    Bonsai 8B:直接淘汰

    Bonsai 8B 的速度是 0.001 tok/s——不是很慢,是根本無法執行。原因是它的量化版本依賴 AVX-512 指令集,而 Ryzen 7 4700U 不支援 AVX-512(只有 AVX2)。llama.cpp 在這種情況下會退回軟體模擬,速度接近零。如果你的機器是 Intel 第 11 代以後或 AMD Zen 4 以後,才有機會跑 Bonsai 8B。

    qwen3:4b:最快但有截斷風險

    qwen3:4b 在 Q1~Q7 七個量化等級測試中,平均跑出 0.78 tok/s,是 CPU 上可用模型裡的最高速。但在 num_predict=300 的限制下,Q2(程式碼生成)和 Q5(格式輸出)出現截斷或夾雜不相關文字的問題。如果你只需要短問短答,qwen3:4b Q6 量化(約 21 元台幣月費 API 等級)是最划算的選擇。

    qwen3.5:9b vs qwen3:14b:9B 更快但 14B 更可靠

    qwen3.5:9b 平均 0.57 tok/s,但 Q6/Q7 題遇到了 timeout 問題——需要將請求超時設定延長到 1800 秒才能完成。原因是 9B 模型在複雜任務上思考時間較長,但預設 timeout 不夠。

    qwen3:14b 同樣 0.58 tok/s,卻跑出 7/7 完整答題率。它的 Q2 完整輸出 500 tokens、Q4 完整輸出 500 tokens,沒有截斷。代價是記憶體佔用更高,在 16GB 機器上跑 14B 模型時需要注意 KV Cache 可能 OOM(記憶體不足),建議設定 num_ctx 不超過 4096。

    gemma4:e4b:速度最快的完整模型

    gemma4:e4b 平均 1.45 tok/s,是所有可用模型中最快的,幾乎是 qwen3:14b 的 2.5 倍。在舊測試的 600 token 限制下,Q2 和 Q4 被截斷。但如果移除限制(num_predict=-1),這個問題就不存在——這正是下一層要說的。

    Mini PC 選模型建議:

    • 追求速度 → gemma4:e4b(1.45 tok/s,移除 token 限制)
    • 追求品質 → qwen3:14b(7/7 完整,0.58 tok/s)
    • 省記憶體 → qwen3:4b Q3/Q4(短任務夠用)
    • 不建議 → Bonsai 8B(AVX-512 門檻)、qwen3.5:9b(需調 timeout)

    第二層:同款模型,換台機器差多少?

    用同款 gemma4:e4b,在 Mini PC 和 MacBook Air M3 上各跑一輪,看看換硬體能得到什麼。

    速度差 6.7 倍,不只是快慢的問題

    Mini PC 平均 1.45 tok/s,Mac 平均 9.75 tok/s。這個差距背後的原因是架構:Mini PC 用 x86 CPU 做矩陣運算,效率遠低於 Apple Silicon 的 Neural Engine + 統一記憶體架構。M3 的統一記憶體讓 CPU 和 GPU 共享同一塊 24GB,模型權重可以直接放在 GPU 能讀取的記憶體,不需要搬移。

    記憶體夠,輸出才完整

    這是硬體差距最直接的體現:Q2 要求生成一個能解析多種日期格式的 Python 函式,Mini PC 在 600 token 限制下就截斷了(回答還在中途),而 Mac 無限制跑出 2218 tokens 的完整函式

    Q4 要求生成帶有 CTE 和 Window Function 的複雜 SQL,Mini PC 截斷,Mac 輸出完整 1043 tokens 含說明。這不是模型能力的差異,是記憶體和 KV Cache 空間的差異。

    24GB 統一記憶體的隱藏優勢:num_ctx 可拉到 65536+

    num_ctx 決定模型能「看到」的上下文長度。Mini PC 的 16GB RAM 在跑 gemma4:e4b 時,實際可用的 KV Cache 空間有限,num_ctx 設太高就 OOM。Mac 的 24GB 統一記憶體可以輕鬆設定 num_ctx=65536,意味著可以貼入整個程式碼檔案、長文件、對話紀錄,模型不會「忘記」前面說了什麼。這個差距在實際工作流中比速度差距更重要。

    第三層:同台機器,調設定差多少?

    在 MacBook Air M3 上,用同款 gemma4:e4b 比較三種設定:舊測試的 600 token 限制、無限制(think:false)、開啟 Thinking 模式。

    速度幾乎不變,但輸出長度和品質大幅提升

    think:false 無限制:平均 9.75 tok/s。Thinking 開啟無限制:平均 9.16 tok/s。速度只差 6%,但輸出品質差距大:

    • Q2(Python 程式碼):2218 tokens → 3670 tokens(+65%),Thinking 版本多了完整的邊界條件處理和說明文字
    • Q4(SQL 查詢):1043 tokens → 1413 tokens(+35%),多了欄位說明和效能備注
    • Q7(TCP 三次握手):442 tokens → 990 tokens(+124%),Thinking 版本多了為何需要三次的深層解釋

    Thinking 模式讓模型在回答前先「想」一遍,輸出的答案更完整、更有條理。對於技術問題、程式碼生成、複雜推理,這個設定幾乎是免費的品質提升。

    gemma4:e4b 全力展開:這就是本地 LLM 的真實上限

    以下展示 Thinking 模式在 Mac 上的實際輸出,讓你判斷品質是否符合你的需求。

    Q2 Thinking:完整的多格式日期解析函式

    問題:「寫一個 Python 函式,能解析 YYYY-MM-DD、YYY/MM/DD(民國年)、YYYYMMDD(8位)、YYYMMDD(7位民國年)四種格式。」

    Thinking 模式輸出 3670 tokens,以下是完整的函式程式碼:

    from datetime import datetime
    import re
    
    def parse_mixed_date_string(date_str: str) -> datetime:
        if not isinstance(date_str, str):
            raise TypeError("輸入必須是字串 (string) 格式。")
        date_str = date_str.strip()
    
        # 模式 A: YYYY-MM-DD
        if re.fullmatch(r'\d{4}-\d{2}-\d{2}', date_str):
            try:
                return datetime.strptime(date_str, "%Y-%m-%d")
            except ValueError:
                pass
    
        # 模式 B: YYY/MM/DD(含民國年)
        if re.search(r'[/]', date_str):
            parts = date_str.split('/')
            if len(parts) == 3:
                year, month, day = parts[0], parts[1], parts[2]
                try:
                    temp_date_str = f"{year.zfill(4)}-{month.zfill(2)}-{day.zfill(2)}"
                    return datetime.strptime(temp_date_str, "%Y-%m-%d")
                except ValueError:
                    pass
    
        # 模式 C: YYYYMMDD(8位)
        if re.fullmatch(r'\d{8}', date_str):
            try:
                return datetime.strptime(date_str, "%Y%m%d")
            except ValueError:
                pass
    
        # 模式 D: YYYMMDD(7位民國年)
        if re.fullmatch(r'\d{7}', date_str):
            try:
                year = date_str[:-4]
                month = date_str[-4:-2]
                day = date_str[-2:]
                temp_date_str = f"{year.zfill(4)}-{month}-{day}"
                return datetime.strptime(temp_date_str, "%Y-%m-%d")
            except ValueError:
                pass
    
        raise ValueError(f"無法識別或解析的日期格式:'{date_str}'")

    非 Thinking 模式在同題只輸出 2218 tokens,函式邏輯正確但缺少邊界案例說明和型別標注說明。Thinking 版本多的那 1452 tokens 全是有用的:錯誤處理說明、邊界條件討論、使用範例。

    Q4 Thinking:帶 CTE 和視窗函數的複雜 SQL

    問題:「找出每個城市近 30 天消費金額前三名的顧客,輸出城市、姓名、消費總額。」

    WITH RecentSpending AS (
        SELECT o.customer_id, SUM(o.amount) AS total_spending
        FROM orders o
        WHERE o.created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
        GROUP BY o.customer_id
    ),
    RankedCustomers AS (
        SELECT c.name, c.city, rs.total_spending,
            RANK() OVER (PARTITION BY c.city ORDER BY rs.total_spending DESC) as city_rank
        FROM RecentSpending rs
        JOIN customers c ON rs.customer_id = c.id
    )
    SELECT city, name, total_spending
    FROM RankedCustomers
    WHERE city_rank <= 3
    ORDER BY city, city_rank;

    Thinking 版本(1413 tokens)在 SQL 後面額外附上了欄位說明對照表、RANK vs DENSE_RANK 的差異說明、以及在資料量大時建議加索引的備注。這種「自動補充說明」的行為,在程式碼審查或教學場景特別有用。

    Q7 Thinking:TCP 三次握手的技術解釋

    問題:「解釋 TCP 三次握手的過程,並說明為什麼需要三次而不是兩次。」

    Thinking 模式的回答(節錄,990 tokens):

    TCP 三次握手是在兩台設備開始傳輸實際資料之前,建立穩定可靠連線的過程:

    1. Client → Server (SYN):「我想連線,序列號從 X 開始」
    2. Server → Client (SYN-ACK):「我收到了,我也準備好,序列號從 Y 開始」
    3. Client → Server (ACK):「我收到你的確認,正式連線」

    為何需要三次而不是兩次:三次握手確保雙方的「發送能力」和「接收能力」都得到驗證。第三次 ACK 讓客戶端確認成功收到伺服器的肯定回應,確保雙方對初始序列號達成共識。若只有兩次,Server 無法確認 Client 是否真的收到了 SYN-ACK,可能導致半開連線(half-open connection)堆積。

    非 Thinking 模式(442 tokens)的回答只覆蓋了步驟本身,沒有解釋半開連線問題。Thinking 版本的 990 tokens 多出了協議設計的「為什麼」。

    技術踩坑筆記

    坑 1:AVX-512 問題不是模型 bug,是 CPU 選錯了

    Bonsai 8B 需要 AVX-512,AMD Ryzen 4000 系列(Zen 2 架構)不支援。解法:換用 AVX2 相容的量化版本,或換到支援 AVX-512 的 CPU(Intel 11th Gen+、AMD Zen 4+)。在買 Mini PC 跑本地 LLM 之前,先確認 CPU 指令集支援情況。

    坑 2:num_predict=300 在 CPU 機器上是陷阱

    設 num_predict=300 看起來是「省時間」,但會讓程式碼生成等長輸出任務的測試結果完全失效。正確做法是設 num_predict=-1(無限制),然後觀察模型自然停止的位置。如果真的需要截斷,至少設到 1000 以上再做程式碼類測試。

    坑 3:qwen3.5:9b 的 timeout 問題

    qwen3.5:9b 在 Q6(長文生成)和 Q7(技術解釋)上,Ollama 預設的請求 timeout 不夠,導致連線中斷而不是模型輸出完成。解法:在呼叫 API 時設定 timeout 參數為 1800 秒,或在 Ollama 的環境變數中調整 OLLAMA_TIMEOUT。

    坑 4:KV Cache OOM 發生在 num_ctx 設太高時

    在 16GB 機器上跑 qwen3:14b,如果 num_ctx 設到 8192 以上,KV Cache 的記憶體需求會超過可用 RAM,導致 OOM 或系統卡死。建議 16GB RAM 跑 14B 模型時,num_ctx 不超過 4096;跑 4B 模型時,num_ctx 可以到 8192。

    坑 5:think:false 是必要的,否則輸出會混入思考過程

    qwen3 系列模型如果不設定 think:false,輸出會包含 <think> 標籤包裹的推理過程,混在正式答案裡,對程式解析造成困擾。在 Ollama API 呼叫時加上 "options": {"think": false},或使用 /set parameter think false。只有在你明確需要 Thinking 輸出時才開啟。

    坑 6:num_predict 是物理截斷,不是智慧壓縮

    很多人以為設 num_predict=600 會讓模型「給出精簡版本」,實際上不是。模型不知道 num_predict 這個參數的存在——它只是一個一個往下生成 token,到達上限時被外力硬切斷。結果是:程式碼寫到一半沒有結尾大括號、SQL 少了 WHERE 條件、解釋說到一半消失。

    這次測試的 Q2(日期解析函數)和 Q4(SQL 排名查詢)在 num_predict=300 時全數截斷就是這個原因。移除限制(num_predict=-1)之後,模型自然停止,輸出完整。

    坑 7:有些內容本質上無法壓縮到指定長度內

    假設你問模型「給我一份完整 Kafka 設定檔,限制 50 個 token 內」——這兩個要求本身就互相矛盾。一份能正常運作的 Kafka 設定檔,光是必要欄位就需要遠超 50 token。模型沒有辦法把磚頭塞進比磚頭小的洞。

    面對不可壓縮的內容,有三種做法:(1)直接移除 token 上限;(2)分步請求——先要最精簡模板,確認結構後再要完整版;(3)在 prompt 明確說「輸出必須完整可執行,不要省略任何欄位」,但前提是 token 上限本身要夠大。

    補充:不同模型怎麼面對衝突指令?

    這裡有個值得理解的差異:本地模型(Ollama + llama.cpp)的 token 限制來自 API 參數,是硬體截斷,模型本身完全不知道有這個限制存在。雲端模型(Claude、GPT-4 等)的「限制」則來自 prompt 文字指令,模型讀到這個指令後會嘗試推理你的真實意圖。

    情境 本地模型(Ollama) 雲端模型(Claude)
    限制來源 num_predict API 參數 prompt 文字指令
    遇到「50 token 內給完整設定檔」 不知道有衝突,第 50 個 token 硬截 判斷兩個要求互相矛盾,主動說明,給完整版
    答案冗長可縮短的情況 按字數截斷,不壓縮 推理目標,給出精簡版本
    答案本質不可壓縮 截斷,輸出殘缺內容 告知無法在限制內完整輸出,給出建議
    GPT / Gemini 回答為什麼那麼短? 不是 token 限制,是 System Prompt + RLHF 訓練偏好所致

    這個差異的實際意義是:在本地 LLM 環境下,token limit 是你唯一能控制輸出長度的工具,設太小就會截斷。雲端模型則更像是在和一個理解你意圖的人對話——你不需要精準計算 token,只需要把你真正想要的說清楚。

    進階應用:讓本地 LLM 記住對話上下文

    本地 LLM 的 API 呼叫預設是無狀態的——每次送出的是獨立的單輪問答,模型不記得你上一題問了什麼。如果你想做多輪對話助理、程式碼審查工具、或任何需要「記住脈絡」的應用,就需要自己管理對話歷史。

    為什麼不能無限加長 messages?

    標準做法是把整段對話歷史塞進 messages 陣列一起送出。但對話越長,messages 陣列越大,最終超過 num_ctx 上限,前面的對話就會被硬截斷——模型在不知情的狀況下「失憶」,不會告訴你它看不到前面的內容。

    解法不是把 num_ctx 設更大(那只是延後問題),而是主動管理 messages 陣列:用摘要壓縮舊對話,只保留近期原文加上一段精簡的歷史摘要。

    三種管理策略比較

    方式 num_ctx 用量 記憶效果 適合場景
    無管理(全部塞) 持續增長,最終截斷 前期對話被硬切,模型不自知 短對話、單次任務
    Sliding Window(只保留近 N 輪) 固定 早期資訊完全消失 客服機器人、無需長記憶的助理
    摘要壓縮(推薦) 固定,摘要極短 保留關鍵結論、數字、決策 開發助理、長程任務、知識型問答

    摘要壓縮的運作方式

    核心思路是:用同一個本地模型來摘要自己的舊對話。超過門檻後,把早期輪次壓縮成一段文字,之後每次送出時帶著「摘要 + 近期原文」,而不是全部歷史。

    第 1-8 輪:原文保存在 messages[]
    
    第 9 輪觸發壓縮:
      old[1-5輪] → summarize() → "重點:xxx, yyy, zzz"
      messages 只留 [6-8輪原文] + 新問題
    
    第 9 輪實際送出的內容:
      system: "對話背景摘要:重點 xxx, yyy, zzz"
      user(6): ...  ai(6): ...
      user(7): ...  ai(7): ...
      user(8): ...  ai(8): ...
      user(9): 現在的問題

    完整 Python 實作(本地 Ollama 版)

    import json, urllib.request
    
    MAC_URL = "http://192.168.1.xxx:11434/api/chat"  # Mac 的 Ollama,Mini PC 不跑本地模型
    MODEL  = "gemma4:e4b"
    
    def call_model(messages, think=False):
        payload = {
            "model": MODEL,
            "messages": messages,
            "stream": False,
            "think": think,
            "options": {"num_ctx": 4096, "num_predict": -1}
        }
        data = json.dumps(payload).encode()
        req = urllib.request.Request(
            MAC_URL, data=data,
            headers={"Content-Type": "application/json"}
        )
        with urllib.request.urlopen(req, timeout=300) as r:
            return json.loads(r.read())["message"]["content"]
    
    def summarize(messages):
        """把舊對話丟給模型,壓縮成條列式重點"""
        history_text = "\n".join(
            f"{'User' if m['role'] == 'user' else 'AI'}: {m['content']}"
            for m in messages
        )
        prompt = f"""以下是一段對話記錄,請用條列式摘要最重要的資訊、結論、已確認的事實。
    保留具體數字、決策、技術細節。100字以內。
    
    對話:
    {history_text}
    
    摘要:"""
        return call_model([{"role": "user", "content": prompt}])
    
    class ChatSession:
        def __init__(self, keep_recent=4, compress_threshold=8):
            self.messages = []
            self.summary = ""               # 累積摘要
            self.keep_recent = keep_recent             # 保留最近幾輪原文
            self.compress_threshold = compress_threshold   # 超過幾輪就壓縮
    
        def chat(self, user_input):
            self.messages.append({"role": "user", "content": user_input})
    
            # 超過門檻 → 壓縮舊對話
            if len(self.messages) > self.compress_threshold:
                old = self.messages[:-self.keep_recent]
                new_summary = summarize(old)
    
                # 把舊摘要 + 新摘要合併
                self.summary = f"{self.summary}\n{new_summary}".strip()
                self.messages = self.messages[-self.keep_recent:]
                print(f"[已壓縮,摘要更新:{len(self.summary)} chars]")
    
            # 組合本次送出的 messages
            send_messages = []
            if self.summary:
                send_messages.append({
                    "role": "system",
                    "content": f"對話背景摘要(已發生的重點):\n{self.summary}"
                })
            send_messages.extend(self.messages)
    
            response = call_model(send_messages)
            self.messages.append({"role": "assistant", "content": response})
            return response
    
    # 使用方式
    if __name__ == "__main__":
        session = ChatSession(keep_recent=4, compress_threshold=8)
        while True:
            user = input("你:").strip()
            if user.lower() == "exit":
                break
            reply = session.chat(user)
            print(f"AI:{reply}")
            if session.summary:
                print(f"[背景摘要:{len(session.summary)} chars]")

    效能調優:摘要用小模型,回答用大模型

    摘要這個步驟本身也消耗一次推理呼叫。如果 Mac 上同時有快慢兩個模型,可以分工:快的模型做摘要,慢的(品質更好的)做正式回答:

    MAIN_MODEL    = "qwen3:14b"   # 回答主要問題,品質優先
    SUMMARY_MODEL = "qwen3:4b"   # 做摘要,速度優先(簡單任務夠用)
    
    def summarize(messages):
        # 使用小模型做摘要
        payload = {
            "model": SUMMARY_MODEL,
            ...
        }

    這樣摘要時間從數十秒縮短到幾秒,而主要對話品質不受影響。MacBook Air M3 速度夠快(9+ tok/s),用同一個模型做摘要也無妨。

    日常應用:把 Mac 當成你的私人 AI 主機

    gemma4:e4b 在 MacBook Air M3 上跑出 9+ tok/s,這個速度對互動式日常使用完全夠用——不是只能跑 benchmark,而是可以當成隨時待命的個人助理。重點是:所有資料留在本機,不送雲端,不計費。

    架構很簡單:Mini PC 負責發問和顯示,Mac 負責思考和回答。Mini PC 本身不跑模型,只是一個入口。

    你(Mini PC)→ 問題 → Mac gemma4 → 回答 → 你
    
    Mini PC:入口,不思考
    Mac:腦子,負責推理

    兩個最常用的場景

    場景一:快速摘要

    把一篇文章、一份 log、一段程式碼丟給 Mac,要它用幾句話說重點。不需要 Claude 等級的推理,gemma4 速度更快、更省錢(免費)。

    #!/usr/bin/env python3
    # summarize.py — 從 stdin 讀內容,打到 Mac gemma4 要摘要
    # 用法:cat article.txt | python3 summarize.py
    #       cat error.log   | python3 summarize.py --prompt "這份 log 的錯誤原因是什麼"
    
    import json, sys, urllib.request, argparse
    
    MAC_URL = "http://192.168.1.xxx:11434/api/chat"
    MODEL   = "gemma4:e4b"
    
    parser = argparse.ArgumentParser()
    parser.add_argument("--prompt", default="請用5句話以內摘要以下內容的重點:")
    args = parser.parse_args()
    
    content = sys.stdin.read().strip()
    if not content:
        print("ERROR: no input"); sys.exit(1)
    
    payload = {
        "model": MODEL,
        "messages": [{"role": "user", "content": f"{args.prompt}\n\n{content}"}],
        "stream": False, "think": False,
        "options": {"num_ctx": 8192, "num_predict": -1},
    }
    req = urllib.request.Request(
        MAC_URL, data=json.dumps(payload).encode(),
        headers={"Content-Type": "application/json"},
    )
    with urllib.request.urlopen(req, timeout=300) as r:
        print(json.loads(r.read())["message"]["content"])
    # 使用範例
    cat ~/Downloads/article.txt   | python3 summarize.py
    cat /var/log/app.log          | python3 summarize.py --prompt "這份 log 有什麼異常?"
    git diff HEAD~5               | python3 summarize.py --prompt "這幾個 commit 改了什麼?"

    場景二:快速產程式驗證

    想驗證一個想法、寫一段臨時腳本、或確認某個 API 用法——不需要開 IDE,直接從命令列問 Mac,幾秒鐘拿到可以跑的程式碼片段。

    #!/usr/bin/env python3
    # ask.py — 命令列直接問 Mac,拿回程式碼或答案
    # 用法:python3 ask.py "寫一個 Python 函數,把 list 裡的重複元素移除但保留順序"
    #       python3 ask.py "用 curl 怎麼測試一個需要 Bearer token 的 API"
    
    import json, sys, urllib.request
    
    MAC_URL = "http://192.168.1.xxx:11434/api/chat"
    MODEL   = "gemma4:e4b"
    
    question = " ".join(sys.argv[1:])
    if not question:
        print("Usage: python3 ask.py \"your question\""); sys.exit(1)
    
    payload = {
        "model": MODEL,
        "messages": [{"role": "user", "content": question}],
        "stream": False, "think": False,
        "options": {"num_ctx": 4096, "num_predict": -1},
    }
    req = urllib.request.Request(
        MAC_URL, data=json.dumps(payload).encode(),
        headers={"Content-Type": "application/json"},
    )
    with urllib.request.urlopen(req, timeout=300) as r:
        print(json.loads(r.read())["message"]["content"])
    # 使用範例
    python3 ask.py "寫一個 bash script,每天早上備份 ~/Documents 到外接硬碟"
    python3 ask.py "Python requests 怎麼設定 retry 和 timeout"
    python3 ask.py "這個 SQL 有什麼問題:SELECT * FROM orders WHERE date > NOW() - 30"

    什麼時候還是要 Claude

    任務 Mac gemma4 Claude API
    文章/log 摘要 ✅ 夠用,免費
    快速程式片段 ✅ 夠用,快
    翻譯、改寫 ✅ 夠用
    私人資料(不想送雲端) ✅ 最佳選擇
    程式碼審查(跨檔案) ❌ 沒有 context
    複雜架構決策 ❌ 推理不足
    Agent Team 自動化開發 ⚡ 出草稿 ✅ 審查整合

    原則是:不需要記憶 codebase、不需要複雜推理的任務,都可以先試 Mac。速度快、免費、資料不出門。遇到 Mac 答不好的,再升到 Claude。

    進階應用二:Mini PC + Mac 混合架構,讓 Agent Team 更有效率

    角色定義(固定,不隨任務改變)
    
    Mini PC  → 純指揮中心:跑 Claude Code、管理 Agent Team、處理串接邏輯
               不跑任何本地模型,資源用在穩定性和協調上
    
    Mac      → 推理後端:跑 Ollama + gemma4:e4b
               只負責生成,不做決策
    
    Claude API → 審查 + 架構:程式碼審查、複雜邏輯、跨檔案推理
                 Mini PC 透過網路呼叫,不在本地
    
    規則:Mac 不在線 → fallback 給 Claude API,不是 Mini PC 自己跑

    當你用 Claude Code 的 Agent Team 跑自動化程式開發時,會面對一個現實問題:Claude API 的費用隨 token 用量線性增長,而很多任務其實不需要 Claude 的完整推理能力——DTO 生成、CRUD 樣板、SQL migration 這類結構性重複工作,本地的 gemma4:e4b 就能處理。

    解法是把 Mac 當成 Agent Team 的「草稿後端」:Claude Agent 負責架構決策和程式碼審查,Mac gemma4 負責產生第一版草稿,再由 Claude 驗證整合。

    架構分工

    任務類型 交給誰 原因
    DTO / model class Mac gemma4 結構固定,重複性高
    CRUD endpoints 樣板 Mac gemma4 Pattern 固定,不需要推理
    SQL migration Mac gemma4 有範本可循
    Unit test 骨架 Mac gemma4 快速產出結構,Claude 填邏輯
    複雜業務邏輯 Claude sonnet 需要跨檔案理解,Mac 沒有 context
    安全相關程式碼 Claude sonnet/opus 不可靠的輸出風險太高
    架構決策 / Code Review Claude opus 需要深度推理與判斷

    前置設定:讓 Mac 的 Ollama 對區網開放

    Ollama 預設只監聽本機。在 Mac 上把它開放給區網,Mini PC 才能連進來:

    # Mac 上執行(停掉 Ollama app 後)
    OLLAMA_HOST=0.0.0.0 ollama serve
    
    # 從 Mini PC 驗證是否連得到(換成 Mac 的區網 IP)
    curl http://192.168.1.xxx:11434/api/tags

    不想暴露 port 的話,用 SSH Tunnel:Mini PC 上執行 ssh -L 11435:localhost:11434 [email protected] -N,之後打 localhost:11435 就等於打 Mac 的 Ollama。

    工具腳本:mac_draft.py

    Agent 透過 Bash tool 呼叫這支腳本,傳入任務描述,拿回草稿程式碼。腳本會自動檢查 Mac 是否在線,不在線就回傳 exit code 1,讓 Agent 自行處理 fallback。

    #!/usr/bin/env python3
    """
    mac_draft.py — Call Mac's local gemma4 for code draft generation.
    
    Usage:
        python3 mac_draft.py "write a SQLAlchemy User model with id, name, email"
        python3 mac_draft.py --task "CRUD for User" --context "FastAPI, SQLAlchemy async"
    
    Exit codes:
        0 = success, draft printed to stdout
        1 = Mac unreachable → fallback: implement with Claude directly
        2 = model error
    """
    import json, sys, urllib.request, urllib.error, argparse
    
    MAC_HOST = "http://192.168.1.xxx:11434"   # 改成 Mac 的實際 IP
    MODEL    = "gemma4:e4b"
    TIMEOUT  = 600
    
    SYSTEM_PROMPT = """You are a code generation assistant. Output ONLY code —
    no explanations, no markdown fences, no comments unless essential.
    The output will be reviewed and integrated by another agent."""
    
    def check_reachable():
        try:
            with urllib.request.urlopen(f"{MAC_HOST}/api/tags", timeout=5):
                return True
        except Exception:
            return False
    
    def generate(task, context=""):
        prompt = f"Context: {context}\n\nTask: {task}" if context else task
        payload = {
            "model": MODEL,
            "messages": [
                {"role": "system", "content": SYSTEM_PROMPT},
                {"role": "user",   "content": prompt},
            ],
            "stream": False, "think": False,
            "options": {"num_ctx": 4096, "num_predict": -1},
        }
        data = json.dumps(payload).encode()
        req  = urllib.request.Request(
            f"{MAC_HOST}/api/chat", data=data,
            headers={"Content-Type": "application/json"},
        )
        with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
            return json.loads(r.read())["message"]["content"]
    
    parser = argparse.ArgumentParser()
    parser.add_argument("task", nargs="?")
    parser.add_argument("--task", dest="task_flag")
    parser.add_argument("--context", default="")
    args = parser.parse_args()
    
    task = args.task or args.task_flag
    if not task:
        print("ERROR: no task provided", file=sys.stderr); sys.exit(1)
    
    if not check_reachable():
        print(f"MAC_UNREACHABLE: {MAC_HOST}. Fallback: implement with Claude.",
              file=sys.stderr); sys.exit(1)
    
    try:
        print(generate(task, args.context))
    except Exception as e:
        print(f"ERROR: {e}", file=sys.stderr); sys.exit(2)

    Agent 的實際使用流程

    # Agent (sonnet) 在 Bash tool 中這樣呼叫:
    
    # 1. 請 Mac 出草稿
    draft=$(python3 ~/llm-benchmark/scripts/mac_draft.py \
      --task "generate SQLAlchemy User model" \
      --context "PostgreSQL, async, Pydantic v2")
    
    # 2. 檢查是否成功
    if [ $? -ne 0 ]; then
      echo "Mac unavailable, implementing directly"
      # Claude 自己寫
    fi
    
    # 3. 草稿給 Claude 審查後整合進 codebase
    echo "$draft"  # Claude 讀到這裡,決定是否採用、修改哪裡

    告訴 Agents 這條規則:寫入 AGENTS.md

    Claude Code 的 Agent Team 每個 subagent 啟動時沒有對話歷史。規則要寫進 AGENTS.md,agent 才會在每次任務開始時讀到它。在專案的 AGENTS.md 加上這個區塊:

    ## Mac Draft Resource (Local LLM Offload)
    
    Mac (gemma4:e4b) is available as a fast code draft generator.
    Tool: python3 ~/llm-benchmark/scripts/mac_draft.py
    
    Use Mac draft BEFORE writing code yourself for:
    - DTO / model class boilerplate      ✅
    - CRUD endpoints (standard pattern)  ✅
    - SQL migration scripts              ✅
    - Unit test scaffolding              ✅
    
    Do NOT use Mac draft for:
    - Complex business logic             ❌ (no codebase context)
    - Security-sensitive code            ❌ (unreliable)
    - Cross-file refactoring             ❌ (no context)
    - Architecture decisions             ❌ (use opus)
    
    Workflow:
    1. Call mac_draft.py with task description
    2. exit code 1 (MAC_UNREACHABLE) → implement with Claude directly
    3. Review draft: check patterns, imports, logic, security
    4. Integrate into codebase
    
    Mac generates the shape. Claude ensures it fits.

    這樣每個 subagent 都會知道「遇到樣板類任務先叫 Mac 出草稿」,不需要每次重新交代規則。

    結論與推薦

    本次測試跨越三個維度,每層都有明確的答案:

    Mini PC + Mac 混合架構的定位

    Mini PC 的角色是指揮中心,不是推理引擎。它跑 Claude Code、管理 Agent Team、處理串接邏輯,資源用在穩定性和協調上。推理工作全部交給 Mac 的 gemma4:e4b。

    • 日常問答 / 摘要:Mini PC 發問 → Mac gemma4 回答,免費、快速、資料不出門
    • 草稿程式碼:Agent 呼叫 mac_draft.py → Mac 出草稿 → Claude 審查整合
    • 複雜推理 / 架構決策:直接用 Claude API,不走 Mac
    • Mac 不在線:fallback 給 Claude API,Mini PC 本身不需要跑任何模型

    如果你的情境是 Mini PC 獨立運作(沒有 Mac),模型選擇建議:gemma4:e4b 速度最快(1.45 tok/s)、qwen3:14b 完成度最高(7/7 全答)、qwen3:4b 最省記憶體。但這個架構的速度上限就是 CPU 推論,不如 Mac 的 Apple Silicon。

    MacBook Air M3 的最佳設定

    • 日常程式碼:think:false,num_predict=-1,9.75 tok/s,輸出完整
    • 複雜推理/技術解釋:Thinking 開啟,速度只慢 6%,品質提升明顯
    • 長文件處理:設定 num_ctx=65536,充分利用 24GB 統一記憶體

    本地 LLM 的真實上限在哪裡?

    gemma4:e4b 在 MacBook Air M3 + Thinking 模式下,輸出品質接近 GPT-3.5 的水準——在程式碼生成、SQL 查詢、技術解釋這些結構清晰的任務上。它不是 GPT-4,創意寫作和跨領域推理還是有差距,但對開發者的日常工作場景已經夠用。

    真正的瓶頸不是模型大小,而是硬體架構。同款模型在 Apple Silicon 上跑出的效果,在 x86 CPU 上根本發揮不出來。如果你認真考慮本地 LLM,MacBook Air M3 是目前性價比最高的入門選擇;Mini PC 路線則需要搭配 NVIDIA GPU(VRAM ≥ 8GB)才能真正發揮。

  • 後端老兵的工具箱:C# 非同步、Python 逆向工程、架構選型實戰

    後端老兵的工具箱:C# 非同步、Python 逆向工程、架構選型實戰

    寫了十幾年後端,從 SQL Server DBA 一路走到架構師,我發現後端開發實戰能力的核心從來不只是會寫 SQL。真正拉開差距的,是你工具箱裡有多少把不同的扳手。這篇文章整理了我在 dotblogs 上累積的幾個關鍵主題:C# 非同步模式、Python 逆向工程實戰、資料庫架構選型、壓力測試,以及從 DBA 到架構師的技術演進。每一個主題都不是教科書式的介紹,而是實際踩過坑之後的心得。後端開發實戰最重要的一課:不要只讀,要動手測。

    TL;DR 重點摘要

    • C# 非同步控制:SemaphoreSlim 比 lock 更適合 async 場景,BlockingCollection 是 producer-consumer 的標準解法,別再自己造輪子。
    • Python 逆向工程:當 API 太貴,直接逆向 Web 介面的 JSON 回應是可行路線,但要做好延遲控制與路徑文件化。
    • 架構選型:資料庫不是選最潮的,而是看存取模式決定 — 高頻讀用 cache、搜尋用倒排索引、日誌用列式儲存。
    • 壓測不是選配:沒壓測過的系統就是紙老虎,JMeter 的 Thread Group + Listener 是最基本的品質門檻。

    1. C# 非同步模式 — Semaphore 與 BlockingCollection

    在 .NET 後端開發中,非同步處理是繞不開的主題。當你有 100 個 HTTP 請求要同時發出去,但目標伺服器只能承受 10 個並發時,你需要的不是 lock,而是 SemaphoreSlim

    為什麼 Semaphore 比 lock 更適合 async?

    很多人習慣用 lock 來控制並發,但 lock 有一個致命問題:它不支援 async/await。你不能在 lock 區塊裡面 await,否則會收到編譯錯誤。即使你繞過去了(用 Monitor),async 的 continuation 可能在不同執行緒上執行,導致 unlock 失敗。

    SemaphoreSlim 則原生支援 WaitAsync(),專為 async 場景設計。它的心智模型是「停車場」:車位(permit)有限,滿了就在外面等,有車出來才放行。

    // SemaphoreSlim throttling concurrent HTTP requests
    public async Task<List<string>> FetchAllAsync(List<string> urls)
    {
        // Only allow 10 concurrent requests
        var semaphore = new SemaphoreSlim(10);
        var httpClient = new HttpClient();
        var tasks = urls.Select(async url =>
        {
            await semaphore.WaitAsync();
            try
            {
                var response = await httpClient.GetStringAsync(url);
                return response;
            }
            finally
            {
                semaphore.Release();
            }
        });
    
        var results = await Task.WhenAll(tasks);
        return results.ToList();
    }
    

    注意 finally 裡的 Release() — 不管成功或失敗都要釋放,否則 permit 會洩漏,最終所有請求都會卡住。

    BlockingCollection:Producer-Consumer 的標準解法

    BlockingCollection<T> 是 .NET 內建的執行緒安全佇列,底層預設使用 ConcurrentQueue。它最強大的特性是 GetConsumingEnumerable() — consumer 端可以用 foreach 持續等待新資料,直到 producer 呼叫 CompleteAdding()

    // Producer-Consumer pattern with BlockingCollection
    var queue = new BlockingCollection<WorkItem>(boundedCapacity: 100);
    
    // Producer thread
    Task.Run(() =>
    {
        foreach (var item in GetWorkItems())
        {
            // Blocks if queue is full (back-pressure!)
            queue.Add(item);
            Console.WriteLine($"Produced: {item.Id}");
        }
        queue.CompleteAdding(); // Signal no more items
    });
    
    // Consumer thread
    Task.Run(() =>
    {
        // Blocks automatically when queue is empty
        // Exits when CompleteAdding() is called and queue is drained
        foreach (var item in queue.GetConsumingEnumerable())
        {
            ProcessItem(item);
            Console.WriteLine($"Consumed: {item.Id}");
        }
    });
    

    這裡的 boundedCapacity: 100 是關鍵 — 它提供了背壓(back-pressure)機制。當 consumer 處理速度跟不上 producer 時,佇列滿了 producer 就會被阻塞,而不是無限制地吃記憶體。這跟傳統 ThreadPool 的固定 worker 模型不同:ThreadPool 維護一組固定的執行緒,而 Semaphore + BlockingCollection 用的是「等待」機制,更彈性也更省資源。


    2. Python 逆向工程 — 當 API 太貴,我就自己拆

    2021 年我碰到一個需求:要把 2600 多筆地址轉成經緯度座標。Google Maps Geocoding API 當時每 1000 次要收 5 美元,算一算要十幾美元。對一個一次性的專案來說,這太不划算了。

    於是我打開 Chrome DevTools,觀察 Google Maps 搜尋框的網路請求。發現它回傳的不是標準 JSON API,而是一個巢狀極深的陣列結構。座標藏在類似 d[16][0][0][7][1][3] 這種路徑裡。

    import requests
    import json
    import time
    import random
    
    def geocode_address(address):
        """Reverse-engineered Google Maps search to extract coordinates."""
        url = "https://www.google.com/maps/search/"
        params = {"q": address}
        headers = {
            "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                           "AppleWebKit/537.36 Chrome/91.0"
        }
    
        resp = requests.get(url, params=params, headers=headers)
        text = resp.text
    
        # The response contains a deeply nested JSON-like structure
        # Find the coordinate data block
        try:
            # Extract the nested array from response
            start = text.find("window.APP_INITIALIZATION_STATE")
            if start == -1:
                return None
    
            # Parse the nested structure
            data_start = text.find("[", start)
            data_end = text.find("];", data_start) + 1
            raw = text[data_start:data_end]
    
            # Navigate the deeply nested path for coordinates
            parsed = json.loads(raw)
            # Path varies by response type, document it!
            lat = parsed[16][0][0][7][1][3][0]
            lng = parsed[16][0][0][7][1][3][1]
            return (lat, lng)
        except (IndexError, KeyError, json.JSONDecodeError):
            return None
    
    
    def batch_geocode(addresses):
        """Process addresses with random delays to avoid detection."""
        results = {}
        for i, addr in enumerate(addresses):
            coords = geocode_address(addr)
            results[addr] = coords
            print(f"[{i+1}/{len(addresses)}] {addr} -> {coords}")
    
            # Random delay: 3~15 seconds to mimic human behavior
            delay = random.uniform(3, 15)
            time.sleep(delay)
    
        return results
    

    幾個實戰經驗:

    • 隨機延遲是必須的:固定間隔很容易被偵測,3 到 15 秒的隨機延遲更像人類行為。
    • 文件化提取路徑d[16][0][0][7][1][3] 這種路徑完全沒有語意,Google 隨時可能改結構。你必須在程式碼旁邊寫清楚這條路徑代表什麼,否則三個月後你自己也看不懂。
    • 錯誤處理要寬容:有些地址搜不到、有些回傳結構不同,用 try/except 包住並記錄失敗的地址,事後手動補。
    • 這不是長期方案:逆向工程的結果隨時會因為前端改版而失效,只適合一次性或低頻的資料收集。

    最終我用這個方法在一個週末處理完 2600 多筆地址,成本是零。但我也很清楚:如果這是一個需要長期維護的服務,老老實實付 API 費用才是正道。


    3. 架構選型 — 不同系統該用什麼資料庫?

    2021 年我在部落格上寫了一系列「架構師慢慢學」的文章,其中最受歡迎的是資料庫選型。核心觀點只有一句話:資料庫的選擇應該由存取模式驅動,而不是技術潮流。

    太多團隊因為「大家都在用 MongoDB」就把所有東西塞進去,結果需要 JOIN 時痛不欲生。也有人因為「Redis 很快」就把所有資料都放 cache,然後面對一致性問題束手無策。

    系統類型 vs 資料庫選型對照表

    系統類型 建議資料庫 原因
    後台管理系統(Admin) RDBMS(PostgreSQL / SQL Server) 低併發、需要複雜查詢和 JOIN、資料完整性優先
    高流量前台系統 RDBMS 後端 + Redis 前端 RDBMS 保證資料正確,Redis 用 Key/Value 加速讀取
    日誌 / Log 系統 列式儲存(ClickHouse)+ 倒排索引(Elasticsearch) 寫入量大、需要聚合分析和全文搜尋
    交易系統 RDBMS + Cache + 一致性協議 ACID 不可妥協,cache 用於讀加速但需要失效策略
    即時監控系統 時序資料庫(InfluxDB / TimescaleDB) 時間序列寫入優化、自動聚合降精度
    搜尋系統 RDBMS 後端 + Elasticsearch 前端 RDBMS 為資料源,ES 提供倒排索引加速模糊搜尋

    幾個決策原則:

    1. 先問讀寫比例:讀多寫少 → 考慮加 cache;寫多讀少 → 考慮列式儲存或訊息佇列緩衝。
    2. 再問一致性需求:金融交易不能最終一致,社群按讚可以。
    3. 最後問查詢模式:需要 JOIN → RDBMS;需要全文搜尋 → 倒排索引;需要時間範圍聚合 → 時序 DB。

    這不是什麼高深的理論,但我看過太多團隊在第一步就跳過去,直接被「這個技術很紅」帶著走。


    4. JMeter 壓測 — 不壓測的系統都是紙老虎

    你寫的 API 在開發機上跑得飛快,但上線後 100 人同時用就當機了。這種事我見過不止一次。壓力測試不是「有空再做」的事情,它是品質門檻。

    JMeter 基本設定

    Apache JMeter 是免費的壓測工具,核心概念只有三個:

    • Thread Group(執行緒群組):模擬多少使用者同時操作。設定 Number of Threads = 100 就是 100 個並發使用者。
    • Sampler(取樣器):每個使用者要做什麼動作。最常用的是 HTTP Request Sampler,填入 URL、Method、Body 就行。
    • Listener(監聽器):收集結果的報表。Summary Report 給你吞吐量和錯誤率,Aggregate Report 給你百分位數響應時間。

    關鍵指標怎麼看

    指標 意義 健康標準(參考)
    Throughput 每秒處理的請求數 依業務而定,但應隨並發數線性增長直到瓶頸
    P90 Response Time 90% 的請求在此時間內完成 一般 API < 500ms
    P99 Response Time 99% 的請求在此時間內完成 應 < P90 的 3 倍,否則有長尾問題
    Error Rate 失敗請求的百分比 < 0.1% 為優秀,> 1% 要警覺

    常見錯誤

    1. 在同一台機器上跑 JMeter 和被測服務:JMeter 本身也吃 CPU 和記憶體,會互相干擾。壓測機和被測機必須分開。
    2. 沒有暖機(Warm-up):JVM 或 .NET 的 JIT 編譯在前幾次請求時會比較慢,應該先跑一輪不計入結果的請求。
    3. 只看平均值:平均響應時間 200ms 看起來很好,但如果 P99 是 5 秒,代表每 100 個使用者就有 1 個等 5 秒。看百分位數才有意義。
    4. 不模擬真實場景:所有人都打同一個 API endpoint 不代表真實負載。應該混合不同操作的比例。

    我的習慣是:在專案中期就開始跑基準壓測,而不是上線前才慌張地補。早發現瓶頸,修復成本低十倍。


    5. 從 DBA 到架構師 — 我的技術演進路線

    我的技術路線不是一開始就規劃好的,而是一步步堆疊出來的:

    • 2020:SQL Server DBA — 每天看執行計劃、調索引、處理 deadlock。這個階段讓我理解了資料庫內部的儲存引擎、鎖機制、B-Tree 索引結構。
    • 2021:.NET 後端開發 — 開始寫 C# Web API,發現 DBA 背景讓我寫出的 SQL 比大多數工程師都好。但也發現自己在非同步、設計模式上的不足。
    • 2022-2023:全端開發 — 接觸前端、Python、爬蟲、自動化。工具箱從一把螺絲起子變成一整個工具箱。
    • 2024-2026:架構設計 — 開始做系統設計、技術選型、效能規劃。發現以前每個階段的經驗都在這裡匯聚。

    DBA 背景帶給我的不公平優勢

    理解資料庫內部運作,會從根本上改變你寫應用程式的方式:

    • 你知道 SELECT * 在有 covering index 時多浪費多少 I/O,所以你會主動只選需要的欄位。
    • 你知道 NVARCHARVARCHARDATALENGTH() 下的差異,所以你會根據實際資料選擇正確的型別。
    • 你看過太多 table scan 的慘案,所以你設計 API 時會強制分頁,而不是讓使用者一次撈全部。
    • 你理解 transaction isolation level 的差異,所以你知道什麼時候用 READ COMMITTED SNAPSHOT 可以大幅降低鎖爭用。

    給後端工程師的建議

    如果你想往架構方向發展,我的建議是:學任何東西都要動手測試

    • 想搞懂索引?建一個百萬筆的測試表,比較有索引和沒索引的執行計劃。
    • 想搞懂 SemaphoreSlim?寫一個 console app,開 1000 個 task,觀察不同 permit 數量的效果。
    • 想搞懂資料庫選型?不要只讀比較文章,自己用 Docker 裝一個 ClickHouse、一個 PostgreSQL,塞相同的資料,跑相同的查詢,比較速度。

    2021 年我在部落格上寫「架構師慢慢學」系列時,最大的收穫不是寫出來的文章,而是為了寫文章去做的那些實驗。讀十篇文章不如自己跑一次 DATALENGTH() 比較 CHARVARCHAR 的儲存差異。那個數字會刻在你腦子裡,比任何文章都深。


    結語

    後端工程師的價值不在於精通某一個框架或語言,而在於工具箱的廣度和深度。C# 的非同步模式讓你處理高並發,Python 的靈活性讓你快速解決一次性問題,架構選型的思維讓你做出正確的技術決策,壓力測試讓你對系統有信心。

    這些東西沒辦法在一天內學會,但每一個都值得你花時間去實驗。畢竟,不壓測的系統是紙老虎,不動手的學習是紙上談兵

    如果你也是從 DBA 或其他專精領域起步的工程師,不要覺得自己起步晚。每一個階段的深度經驗,都會在你走向架構師的路上成為別人沒有的武器。

  • SQL Server 踩坑實錄:從 DELETE 不釋放空間到 NOT IN 效能炸彈

    SQL Server 踩坑實錄:從 DELETE 不釋放空間到 NOT IN 效能炸彈

    這篇文章是我從 DBA 到全端架構師這幾年,在 SQL Server 效能優化上踩過的坑的總整理。不是教科書式的理論,而是每一條都是我實際測試、實際踩雷後的血淚經驗。如果你正在處理 SQL Server 效能優化的問題——DELETE 後空間沒釋放、查詢莫名其妙變慢、鎖定機制搞不清楚——這篇應該能幫你少走不少冤枉路。

    TL;DR 重點摘要

    • DELETE 不會釋放磁碟空間,只是標記刪除。要真正回收空間,必須用 TRUNCATE 或 ALTER INDEX REBUILD。
    • NOT IN 是效能炸彈,改用 NOT EXISTS 可以讓查詢快數十倍,尤其在子查詢結果集大的時候。
    • 沒有 TABLOCKX 的交易不安全,並行交易會讀到未提交的資料(Dirty Read),高併發場景務必設定正確的隔離層級。
    • 暫存表不是都一樣,@ 表變數、# 本地暫存表、## 全域暫存表各有適用場景,選錯會嚴重影響效能。

    1. 儲存空間的真相 — DELETE 真的刪除了嗎?

    這大概是我當 DBA 第一年最震驚的發現:DELETE 不會釋放磁碟空間。它只是把資料列標記為「ghost record」,等待背景的 Ghost Cleanup 程序來處理。但即使 Ghost Cleanup 跑完了,那些頁面(Page)還是屬於該表的配置空間,不會歸還給作業系統。

    如果你想真正回收空間,只有兩條路:

    • TRUNCATE TABLE:直接釋放所有資料頁面,包含 7-byte 的列標頭(row header),速度極快,但會清除所有資料。
    • ALTER INDEX … REBUILD:重建索引時重新組織頁面,回收碎片化的空間。

    三種配置單元(Allocation Unit)

    SQL Server 在底層把資料分成三種配置單元儲存:

    配置單元 儲存內容 典型欄位類型
    IN_ROW_DATA固定長度 + 行內變動長度資料char, int, datetime, nvarchar(100)
    LOB_DATA大型物件資料nvarchar(max), text, image, xml
    ROW_OVERFLOW_DATA超過 8060 bytes 的變動長度資料nvarchar 超過行內限制時溢出

    CHAR vs VARCHAR 的儲存差異

    很多人以為「反正都是存字串」,但底層差異巨大。CHAR(100) 不管你存 1 個字還是 100 個字,永遠佔用 100 bytes。VARCHAR(100) 則只儲存實際資料長度加上 2 bytes 的長度前綴。

    -- Verify with DATALENGTH()
    DECLARE @fixed CHAR(100) = 'Hello';
    DECLARE @variable VARCHAR(100) = 'Hello';
    
    SELECT DATALENGTH(@fixed) AS CharLength,      -- Result: 100
           DATALENGTH(@variable) AS VarcharLength; -- Result: 5

    另外要注意:索引佔用的是真實空間(IN_ROW_DATA)。當你清空表後,索引也被清空。但只要 INSERT 新資料,索引會立即重新填充。而且 VARCHAR 欄位建索引時,仍然受到 900 bytes 索引鍵大小限制

    Azure SQL Database 的常見假警報

    在 Azure SQL Database 上,大量 DELETE 後看到儲存空間快滿了——這是假警報。空間根本沒被釋放。DELETE ... WITH (TABLOCK) 效果有限,必須搭配 TRUNCATEALTER INDEX ALL ON [TableName] REBUILD 才能真正回收。


    2. 鎖定機制 — 你的交易真的安全嗎?

    我曾經在生產環境遇過一個離奇的 Bug:兩筆交易同時更新同一張表,結果一筆交易讀到了另一筆還沒 COMMIT 的資料。這就是經典的 Dirty Read(髒讀)

    不使用適當鎖定機制時,會碰到三種資料異常:

    異常類型 說明 情境
    Dirty Read(髒讀)讀到其他交易未提交的資料T1 UPDATE 未 COMMIT,T2 SELECT 讀到修改後的值
    Non-repeatable Read(不可重複讀)同一交易內兩次讀取結果不同T1 SELECT → T2 UPDATE COMMIT → T1 再次 SELECT 結果變了
    Phantom Row(幻影列)同一交易內多出新的資料列T1 SELECT → T2 INSERT COMMIT → T1 再次 SELECT 多了一列

    正確的做法:TABLOCKX 排他鎖

    -- Problem: Without TABLOCKX, T2 can read T1's uncommitted changes
    -- Session 1
    BEGIN TRAN T1;
    UPDATE Orders SET Amount = 999 WHERE OrderID = 1;
    -- (not committed yet)
    
    -- Session 2 (runs concurrently, sees Amount = 999 → Dirty Read!)
    SELECT Amount FROM Orders WHERE OrderID = 1;
    
    -- Solution: Use TABLOCKX for exclusive access
    BEGIN TRAN T1;
    SELECT * FROM Orders WITH (TABLOCKX) WHERE OrderID = 1;
    -- Now T2 is BLOCKED until T1 commits or rolls back
    UPDATE Orders SET Amount = 999 WHERE OrderID = 1;
    COMMIT TRAN T1;

    在高併發場景中,如果不想鎖整張表,也可以考慮設定交易隔離層級為 SERIALIZABLE 或使用 ROWLOCK, UPDLOCK 組合,但 TABLOCKX 是最簡單粗暴且確定有效的方式。


    3. 查詢優化 — EXISTS vs IN 的效能陷阱

    這個坑我踩了不止一次。先講結論:

    • 正向查詢(EXISTS vs IN):執行計畫幾乎相同,效能差異不大。
    • 否定查詢(NOT EXISTS vs NOT IN)NOT EXISTS 遠遠快於 NOT IN,差距可達數十倍。
    -- NOT IN: Slow — performs O(n*m) comparison, NULL handling issues
    SELECT * FROM Products
    WHERE ProductID NOT IN (
        SELECT ProductID FROM OrderDetails
    );
    
    -- NOT EXISTS: Fast — uses semi-join, stops at first match
    SELECT * FROM Products p
    WHERE NOT EXISTS (
        SELECT 1 FROM OrderDetails od
        WHERE od.ProductID = p.ProductID
    );
    
    -- Additional trap: if OrderDetails.ProductID contains ANY NULL value,
    -- NOT IN returns ZERO rows! NOT EXISTS handles NULL correctly.

    NOT IN 之所以慢,是因為它必須對子查詢的每一筆結果做比對,而且還要處理 NULL 的三值邏輯。NOT EXISTS 則是用半連接(Semi-Join)策略,找到第一筆匹配就停止。

    排序與 TOP 的隱藏陷阱

    加上 TOP 之後,SQL Server 的排序演算法會完全改變。沒有 TOP 時用完整排序(Full Sort),有 TOP 時用 Top-N Sort,記憶體需求和執行路徑完全不同。

    SQL 執行順序(必背)

    很多查詢優化的問題,根源是不理解 SQL 的實際執行順序:

    FROM → JOIN → WHERE → GROUP BY → HAVING → SELECT → DISTINCT → ORDER BY → TOP/OFFSET

    注意 SELECT 在 WHERE 之後,所以你不能在 WHERE 中使用 SELECT 裡定義的別名。而 ORDER BY 在 SELECT 之後,所以可以用別名排序。理解這個順序,很多「為什麼這樣寫不行」的問題都迎刃而解。

    另外,索引不只消除全表掃描,還能跳過排序階段。如果 ORDER BY 的欄位剛好有索引,SQL Server 可以直接按索引順序讀取,省掉排序的 CPU 和記憶體開銷。


    4. 全文檢索 — 比 LIKE ‘%keyword%’ 快一百倍

    如果你的應用有「搜尋文章內容」的需求,還在用 LIKE '%keyword%',那你的查詢基本上每次都是全表掃描。全文檢索(Full-Text Search)透過反向索引(Inverted Index)來加速文字搜尋,效能差距是數量級的。

    建立全文檢索的前提與步驟

    前提:目標表必須有主鍵(Primary Key)。因為反向索引需要唯一識別碼來對應每筆資料。

    -- Step 1: Enable full-text search on the database (if not already)
    -- (SQL Server installs Full-Text Search as a feature)
    
    -- Step 2: Create a full-text catalog
    CREATE FULLTEXT CATALOG ftCatalog AS DEFAULT;
    
    -- Step 3: Create a full-text index on the table
    -- The table MUST have a primary key
    CREATE FULLTEXT INDEX ON Articles (
        Title LANGUAGE 1028,      -- 1028 = Traditional Chinese
        Content LANGUAGE 1028
    )
    KEY INDEX PK_Articles          -- Must reference the PK
    ON ftCatalog
    WITH CHANGE_TRACKING AUTO;     -- Auto-update when data changes
    
    -- Step 4: Query using CONTAINS or FREETEXT
    SELECT * FROM Articles
    WHERE CONTAINS(Content, N'效能優化');
    
    -- Compare with LIKE (full table scan every time)
    SELECT * FROM Articles
    WHERE Content LIKE N'%效能優化%';

    語言設定很重要:LANGUAGE 1028(繁體中文)會使用對應的斷詞器(Word Breaker),直接影響搜尋品質。英文斷詞用空格就行,但中文斷詞需要語意分析,設錯語言會導致搜不到結果。


    5. 監控與追蹤 — 沒有 Profiler 怎麼辦?

    SQL Server Profiler 在生產環境不一定能用(效能開銷太大,或者根本沒權限)。這時候 DMV(Dynamic Management Views)就是你的救星。

    追蹤特定時間範圍的查詢

    -- Find top queries by CPU time within a time range
    SELECT TOP 20
        qs.last_execution_time,
        qs.execution_count,
        qs.total_worker_time / 1000 AS total_cpu_ms,
        qs.total_elapsed_time / 1000 AS total_elapsed_ms,
        qs.total_logical_reads,
        SUBSTRING(st.text,
            (qs.statement_start_offset / 2) + 1,
            ((CASE qs.statement_end_offset
                WHEN -1 THEN DATALENGTH(st.text)
                ELSE qs.statement_end_offset
              END - qs.statement_start_offset) / 2) + 1
        ) AS query_text
    FROM sys.dm_exec_query_stats qs
    CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) st
    WHERE qs.last_execution_time >= '2026-04-04 09:00:00'
      AND qs.last_execution_time <= '2026-04-04 18:00:00'
    ORDER BY qs.total_worker_time DESC;

    查看當前執行中的程序

    -- Quick check: who's running what right now?
    EXEC sp_who2;
    
    -- Or with more detail via DMV
    SELECT
        r.session_id,
        r.status,
        r.command,
        r.wait_type,
        r.wait_time,
        t.text AS query_text,
        r.cpu_time,
        r.reads,
        r.writes
    FROM sys.dm_exec_requests r
    CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) t
    WHERE r.session_id > 50; -- Exclude system sessions

    全庫儲存空間盤點

    -- Iterate all user tables and check space usage
    CREATE TABLE #SpaceUsed (
        TableName NVARCHAR(128),
        Rows NVARCHAR(20),
        Reserved NVARCHAR(20),
        Data NVARCHAR(20),
        IndexSize NVARCHAR(20),
        Unused NVARCHAR(20)
    );
    
    DECLARE @tbl NVARCHAR(128);
    DECLARE tbl_cursor CURSOR FOR
        SELECT TABLE_SCHEMA + '.' + TABLE_NAME
        FROM INFORMATION_SCHEMA.TABLES
        WHERE TABLE_TYPE = 'BASE TABLE';
    
    OPEN tbl_cursor;
    FETCH NEXT FROM tbl_cursor INTO @tbl;
    
    WHILE @@FETCH_STATUS = 0
    BEGIN
        INSERT INTO #SpaceUsed
        EXEC sp_spaceused @tbl;
        FETCH NEXT FROM tbl_cursor INTO @tbl;
    END
    
    CLOSE tbl_cursor;
    DEALLOCATE tbl_cursor;
    
    SELECT * FROM #SpaceUsed ORDER BY CAST(REPLACE(Reserved, ' KB', '') AS BIGINT) DESC;
    DROP TABLE #SpaceUsed;

    6. 暫存表大全 — @、#、## 到底差在哪?

    SQL Server 有三種暫存表,看起來差一個符號,但行為天差地別:

    特性 @TableVar(表變數) #TempTable(本地暫存表) ##GlobalTemp(全域暫存表)
    儲存位置記憶體(小量時)/ tempdbtempdbtempdb
    統計資訊無(優化器假設 1 列)
    作用範圍當前批次/程序當前 Session所有 Session
    交易回滾不受 ROLLBACK 影響受 ROLLBACK 影響受 ROLLBACK 影響
    可建索引僅限宣告時的約束可隨時建立可隨時建立
    適用場景少量資料(< 100 列)大量資料、需要索引跨 Session 共享資料

    選擇指南

    • 資料量 < 100 列,且不需要索引 → 用 @TableVar
    • 資料量大,需要統計資訊讓優化器做正確決策 → 用 #TempTable
    • 需要跨 Session 共享(例如 ETL 中間結果) → 用 ##GlobalTemp(但要小心生命週期管理)
    • 在 ROLLBACK 時需要保留資料(例如錯誤日誌) → 用 @TableVar,因為它不受交易回滾影響
    -- Table variable: optimizer always estimates 1 row
    DECLARE @small TABLE (ID INT, Name NVARCHAR(50));
    INSERT INTO @small SELECT TOP 10 ID, Name FROM Products;
    
    -- Local temp table: has statistics, better for large datasets
    CREATE TABLE #bigtemp (ID INT, Name NVARCHAR(50));
    INSERT INTO #bigtemp SELECT ID, Name FROM Products;
    CREATE INDEX IX_bigtemp_ID ON #bigtemp(ID); -- Can add indexes
    
    -- Global temp table: visible to all sessions
    CREATE TABLE ##shared (ID INT, Name NVARCHAR(50));
    -- Other sessions can SELECT from ##shared

    最常見的錯誤是:把幾萬筆資料塞進 @TableVar,然後納悶為什麼 JOIN 超慢。原因是優化器認為裡面只有 1 列,選了 Nested Loop 而不是 Hash Join。換成 #TempTable 就正常了。


    結語

    SQL Server 的這些坑,有的文件上有寫但沒人看,有的要自己測過才會懂。我把這幾年的經驗整理在這裡,希望能幫到正在跟 SQL Server 搏鬥的你。如果只能記住一件事,請記住:NOT IN 是效能炸彈,永遠用 NOT EXISTS 取代它。這一個改動可能就能讓你的查詢從分鐘級變成秒級。


    常見問題 FAQ

    Q: DELETE 後空間沒有減少,是 Bug 嗎?

    不是 Bug,這是 SQL Server 的設計。DELETE 只標記刪除,頁面仍歸屬於表。使用 TRUNCATE TABLE 或 ALTER INDEX REBUILD 來真正回收空間。

    Q: NOT IN 和 NOT EXISTS 結果一樣嗎?

    不一定。如果子查詢包含 NULL 值,NOT IN 會返回空結果集(因為 NULL 的比較結果是 UNKNOWN)。NOT EXISTS 則能正確處理 NULL。除了效能差異,正確性也是選擇 NOT EXISTS 的原因。

    Q: 什麼時候該用表變數 @,什麼時候用暫存表 #?

    簡單判斷:資料量少於 100 列用 @,超過就用 #。關鍵差異在於統計資訊——表變數沒有統計資訊,優化器會做出錯誤的執行計畫。

    Q: 全文檢索和 LIKE 差多少?

    在百萬筆資料的文字欄位上,全文檢索可以比 LIKE '%keyword%' 快 100 倍以上。LIKE 前綴帶 % 時無法使用索引,只能全表掃描;全文檢索則使用反向索引,直接定位包含關鍵字的列。

    Q: 生產環境不能用 SQL Profiler 該怎麼監控?

    使用 DMV(動態管理檢視):sys.dm_exec_query_stats 搭配 sys.dm_exec_sql_text 可以按時間範圍追蹤查詢,sys.dm_exec_requests 可以看當前正在執行的查詢,效能衝擊遠小於 Profiler。

  • 訓馬筆記:兩個月把 Claude Code 從脫韁野馬馴成工作夥伴的完整紀錄

    重點摘要

    • 這是一篇真實的「訓馬筆記」——記錄一個工程師花兩個月,把 Claude Code 從一匹脫韁野馬馴成穩定的工作夥伴
    • 每一條規則背後都是一次災難。32 個具體的坑7 條鐵律9 個領域知識庫,全部是用血淚換來的
    • 結論:AI 不是買回來就能用的工具,它是一匹需要調教的馬。你的 harness 決定它能跑多遠

    2026 年 2 月,我開始全職跟 Claude Code 合作。寫 ERP 外掛、做電商 OMS、搞量化回測、建爬蟲系統——大概七八個專案同時推進。

    兩個月後回頭看,我發現最有價值的不是寫了多少 code,而是我踩了多少坑、立了多少規矩。這篇文章是完整的訓馬筆記——每一個階段的災難、調適、和最後形成的紀律。

    如果你也在用 AI coding agent,這些坑你可能正在踩,或者即將踩。

    第一階段:裸奔期(2 月)——什麼規矩都沒有

    剛開始合作的時候,我就像買了一匹賽馬,直接騎上去就跑。沒有韁繩、沒有馬鞍、沒有圍欄。

    坑 1:回測引擎 37 筆交易全部假停損(2/27)

    我讓 Claude 幫我寫量化回測引擎。跑出來 350 根 K 棒的上漲趨勢數據,結果 37 筆交易全部在第一天就觸發停損退場,勝率 0%。在一個明顯的上漲趨勢裡。

    花了兩天才找到 root cause:引擎把「含滑價的進場價」和「原始市場價」搞混了。

    具體來說:原始價格 $26.84,加上 $1.0 滑價後進場價 $27.84。停損線 = $27.84 × 0.97 = $27.01。隔天價格 $26.87,因為 $26.87 < $27.01 就觸發停損了。但如果用原始價格算:$26.84 × 0.97 = $26.03,$26.87 > $26.03,根本不該停損。

    一個欄位的混用,讓整個系統的行為完全反轉。

    教訓:技術指標和風險管理用原始市場價格,損益計算用含滑價的有效價格。兩個值必須分開追蹤,永遠不能混用。

    坑 2:OMS 上線一天爆 5 個 bug(2/25)

    電商 OMS 系統上線第一天,同時爆了 5 個 bug:

    1. Health Check 用了獨立的 DTO,結果 channel job 不認這個格式,健康檢查直接壞掉
    2. String → JsonNode 反序列化失敗,Kafka consumer 一直報錯
    3. ChannelSyncLog 少了 syncType 欄位,資料寫不進去
    4. Health check 的 log 缺必要欄位(merchantId、platformId、status、detail)
    5. 改完 code 沒重新編譯就部署,舊版本還在跑

    每一個都不是什麼高深的 bug,但它們同時出現就是災難。問題出在哪?沒有人看全景。改了 producer 沒看 consumer,改了 DTO 沒看 caller,改了 code 沒重新 build。

    這次事件催生了後來的「OMS 約法三章」:

    1. 基礎架構(Docker/PostgreSQL/Kafka/Nginx)不輕易變動
    2. 安全機制必須全系統同步
    3. 任何 Kafka producer/consumer 的改動,必須驗證完整的事件流

    第二階段:立規矩期(3 月初)——從災難中學會設限

    如果第一階段是「馬亂跑」,第二階段就是「開始圍柵欄」。每一條規矩都是某次災難的直接產物。

    坑 3:9 個 Opus Agent 同時跑,系統直接當機(3/3)

    這是整個兩個月最慘烈的事件。

    我的機器是 16GB RAM 的 mini PC,上面常態跑著 26 個 Docker 容器。那天早上 8:36 我開始研究 Claude Code 的 Agent Team 功能,覺得很興奮——「可以同時派好多 agent 幫我做事!」

    11:18,我啟動了一個叫 simpleec-review 的 team,裡面有 9 個 Opus agent。11:56,覺得不夠快,又啟動了 whale-51w,再加 2 個 agent。

    12:00 左右,整台機器凍結

    每個 in-process Opus agent 大約佔 1GB RAM(Node.js runtime + API connection + streaming buffer + context window)。9 個就是 ~9GB。加上 Docker 的 3-5GB 和系統本身的 1-2GB,總共超過 16GB。OOM killer 開始殺進程,但殺完又重啟,無限循環。

    事後盤點:18 個任務中 8 個卡在 in_progress 永遠不會完成,1 個 pending,0 個 completed。全軍覆沒。

    調適:三層防護

    • 第一層(軟限制):CLAUDE.md 規定 Agent Team 最多 3 個同時跑
    • 第二層(硬限制):建了 claude-limited 指令,用 systemd cgroup 限制記憶體上限 10GB
    • 第三層(核心參數)vm.swappiness 從 60 降到 10,swap 從 512MB 擴到 8GB

    從此以後再也沒有 OOM 過。代價是一個下午的工作歸零。

    坑 4:爬蟲日期解析——西元 1150 年(3/10)

    台灣用民國年曆。TWSE 的 API 回傳日期格式是 7 位數字,例如 "1150309" 代表民國 115 年 3 月 9 日(= 西元 2026 年)。

    Claude 把它解析成西元 1150 年 3 月 9 日

    同一天還發現:TPEX 的 API 欄位名叫 TransactionAmount,但 code 裡寫的是 TradingMoney。一個是 API 的真實名稱,一個是文件上寫的名稱——它們不一樣。

    調適:

    • 7 位數字 = ROC 格式,前 3 碼是民國年
    • 欄位名永遠用 API 實際回傳的,不用文件寫的
    • 最重要的:不准重寫爬蟲。爬蟲系統已經穩定,只能用 CLI(analyst collect twse_price --date 2026-03-10

    為什麼「不准重寫」這麼重要?因為隔天,Claude 在另一個任務裡又建了一個 /tmp/backfill_twse.py,把爬蟲邏輯整個複製出來。同樣的錯,不到 24 小時就重演了。

    這讓我意識到一件事:教訓會跨 session 遺失。我在 session A 教了「不要重寫爬蟲」,session B 完全不知道這件事。這催生了後來的 Domain Brain 系統。

    坑 5:中文寫進 code 裡(3 月初)

    Claude 很貼心,知道我是台灣人就開始在 code 裡寫中文 comment 和中文 variable name。

    問題是:中文 comment 在很多終端機上會亂碼、在 grep 時很痛苦、在 code review 時外國同事看不懂。我直接跟它說:

    「中文我看不懂」(在 code context 裡)

    於是立了一條看似矛盾但完全合理的雙重規則:

    • 對話用繁體中文——因為我是台灣人,中文溝通效率最高
    • Code 全部英文——comment、variable、output message、文件,一律英文

    第三階段:建立知識系統(3 月中)——從「個別規則」到「領域知識庫」

    到了 3 月中,我已經有十幾條規則了。但我發現一個根本問題:規則散落在各個專案的 CLAUDE.md 裡,跨專案不通

    在 analyst 專案學到的「ROC 日期要特別處理」,到了 stock-verify 專案就不知道了。在 OMS 專案學到的「Kafka 改動要看全景」,到了 AI Assistant 專案就忘了。

    坑 6:Agent Team 卡死 80 分鐘,因為一個文件不存在(3/16)

    我設計了一個 Agent Team 來做 code review,其中 Task 5 需要讀 docs/5-FRONTEND/ADMIN_APP_IMPLEMENTATION.md

    這個文件不存在。目錄是 5-KAFKA,不是 5-FRONTEND

    Task 5 啟動後在 1 分鐘內就卡住了,然後卡了 80 分鐘。因為 Task 7-9 都依賴 Task 5 的輸出,整個 team 全部癱瘓。9 個 agent 的鏈式架構,一個環節斷了全部死。

    調適:

    • 9-agent 鏈式架構改成 3-agent 星狀拓撲——降低相依性
    • 建立 Agent Team Pre-Flight Checklist——每次啟動前必須:檢查記憶體、確認文件存在、設計拓撲、計算資源、取得用戶確認
    • 寫下 root cause:Agent Team 卡住的根本原因是文件缺失,不是模型能力問題

    Domain Brain 的誕生

    3/16 事件之後,我決定建一個跨專案的知識系統。我叫它 Domain Brain——按技術領域分類的「踩坑筆記」。

    ~/.claude/projects/-home-tom/memory/brain/
    ├── python-crawler-data.md      # 爬蟲的坑
    ├── python-llm-integration.md   # LLM 整合的坑
    ├── idempiere-osgi-bundle.md    # OSGi 的坑
    ├── idempiere-2pack.md          # 2Pack 部署的坑
    ├── idempiere-po-model.md       # PO Model 的坑
    ├── idempiere-rest-api.md       # REST API 的坑
    ├── stock-backtesting.md        # 回測的坑
    ├── oms-event-driven.md         # OMS 事件驅動的坑
    └── design-principles.md        # 設計原則的坑

    每個 brain file 的格式:

    ## ROC Date Format
    - [source: analyst] "1150309" 被解析成 AD 1150 年,要用 7 位 YYMMDD ROC 格式
    
    ## Holiday / Empty Response
    - [source: analyst] TWSE API 假日返回空值,必須 guard if not data: return []

    [source: analyst] 標記這個教訓來自哪個專案。這樣在其他專案讀到時,知道這不是泛泛之談,是某次真實事件的結論。

    然後在全域 CLAUDE.md 裡加一條:

    「開工前必須讀 Domain Brain。如果你跳過這步,bug 出在 brain 裡有記錄的東西,那是你的失敗。」

    第四階段:行為紀律(3 月下旬)——從「知道」到「做到」

    知識庫建好了,但新的問題出現:Claude 知道規則但不一定遵守。就像你告訴馬「不要踩田裡的菜」,牠聽懂了,但一興奮起來照踩不誤。

    坑 7:直接推 code 到 main branch

    有一天我發現 Claude 直接把 code 推到 main branch。main 是我的穩定版本,只有 dev 確認穩定後才 merge 回去。

    這不是什麼複雜的規則,但 Claude 就是沒有這個概念。它看到 repo 就 commit、就 push,不管你在哪個 branch。

    鐵律:

    • Session 開始第一件事:git branch 確認在 dev
    • 永遠不准 git push origin main
    • 如果不小心在 main 上 commit 了:cherry-pick 到 dev,push dev,main 不動

    坑 8:過度設計——給低頻查詢加 Redis cache(3/26)

    我讓 Claude 設計一個功能,它自動加了 Redis cache。問題是:這個功能一天被呼叫不到 10 次。

    Claude 的邏輯是:「cache 可以提升效能」→「所以應該加 cache」。這在教科書上沒錯,但在現實中,一天 10 次的查詢加 cache 只是增加了一個可能壞掉的元件

    我因此制定了頻次驅動設計原則——所有功能設計前必須先問三個問題:

    1. 多常被觸發?→ 決定要不要 cache
    2. 計算有多貴?→ 決定要不要預計算
    3. 需要即時還是最終一致?→ 決定要不要 event-driven

    禁止的 pattern:給低頻讀取加 Redis、給低頻單 consumer 寫入加 Kafka、沒有數據支撐就做「效能優化」。

    坑 9:iDempiere 的 10 個坑(持續累積)

    iDempiere 是一個 15 年歷史的 ERP 系統,Claude 的訓練資料裡幾乎沒有它。所以每一步都是坑:

    發生什麼 正確做法
    @Model annotation 用錯 package 用了不存在的 org.idempiere.base.annotation.Model org.adempiere.base.Model
    initPO 用不存在的方法 POInfo.getPOInfo(ctx, tableName) 沒有 String 參數版本 MTable.getTable_ID() 拿 int,再傳入
    List 欄位 type cast (Integer) get_Value() 對 CHAR 欄位爆 ClassCastException instanceof 判斷型別
    2Pack UUID 永遠 NULL IsUpdateable=N 導致 PO framework 寫不進去 _UU 欄位 IsUpdateable 必須 Y
    Grid View 點新增就爆 AD_FieldSeqNoGridIsDisplayedGrid 每個 field 兩個屬性都要有
    Menu ID hardcode 寫死 AD_Menu_ID = 146,目標環境沒這個 ID 用 UUID reference:reference="uuid"
    REST API token 沒換 POST 拿到 token 後沒做 PUT 換 session token 兩步驟:POST → PUT,舊 token 立即失效
    OData 過濾用 ne $filter=... ne ... 結果不對 要用 neq,不是 ne
    OSGi 兩個 component 放一個 XML 只有第一個被 SCR 讀到 一個 XML 一個 component
    Plugin class 找不到 Class.forName() 用 core classloader 實作 OSGi DS component,用 bundle 自己的 classloader

    這 10 個坑全部記在 brain/idempiere-*.md 裡。現在每次開 iDempiere 相關的工作,Claude 會先讀這些 brain file。同一個坑,不會踩第二次。

    坑 10:LLM 回傳的 JSON 炸掉整條 pipeline

    做 AI Assistant 的時候,我讓 LLM 回傳 JSON 來做 routing。prompt 裡寫了「ONLY return valid JSON」。

    現實是:LLM 就是會回傳無效的 JSON。有時候前面加一句「Sure! Here’s the JSON:」,有時候 response.content 直接是 None,呼叫 .strip() 就爆 AttributeError

    一個 router/classifier 的 crash 會癱瘓整條 pipeline。

    調適:

    • 永遠 catch (json.JSONDecodeError, AttributeError, TypeError)
    • 永遠有 fallback 值(例如 "general_knowledge"
    • Router/classifier 不可以 crash 整條 pipeline
    • LLM client 在 module level 初始化會阻擋 mock mode → 改成 lazy-init
    • 沒設 timeout → 無限 hang → 所有 client 設 timeout=25.0
    • 最重要:永遠不讓 LLM 生成 SQL。只用 pre-defined SQL,安全參數從 request 強制注入

    第五階段:自動化閉環(4 月初)——從「靠記憶」到「系統強制」

    到了 3 月底,我有了 7 條鐵律、9 個 brain file、32 個記錄的坑。但還是有一個根本問題:

    Brain 的更新靠 Claude 記得做。它經常忘記。

    CLAUDE.md 裡寫著「每次 fix: commit 後必須更新 brain」,但這只是文字。就像公司牆上貼的「安全第一」標語——大家都看到了,沒人真的做

    4 月 3 日,我決定把這個 cycle 自動化。用 Claude Code 的 Hooks 系統(Harness Engineering)建了 4 個自動化 sensor:

    Hook 觸發時機 做什麼
    PostToolUse 每次 git commit 偵測 fix: 開頭 → 注入「必須更新 brain」的指令到 context
    PreCompact context 壓縮前 掃描最近 5 個 commit,有 fix: 就提醒
    Stop session 結束 比對 fix: 數量 vs brain 更新數量
    SessionStart session 開始 標記開始時間(給 Stop hook 用)

    效果:Claude commit 了 fix: handle empty API response → hook 自動偵測到 → Claude 的 context 被注入一段「你現在必須更新 brain file,不准做下一件事」的強制指令。

    它不能「忘記」了,因為系統不讓它忘記。

    第六階段:照鏡子——工作流程的精煉(4 月)

    走到第五階段,系統穩了、規則立了、自動化跑了。

    但有一天我問 Claude 一個問題:「我們現在跟最早的你,差距多遠?」

    它的回答讓我意識到,我一直在修正一個更深層的問題——不只是 bug,而是合作模式本身

    坑 11:AGENTS.md 從來沒有被建立過

    Agent Team 一再失敗,我長期把原因歸咎到記憶體不夠、文件缺失、拓撲設計問題。這些都是真的,但都是症狀。

    真正的根本原因是:每個 agent 啟動時,不知道自己是誰

    AGENTS.md 是一份定義 Agent Team 組織結構的文件——誰負責什麼、用什麼模型、任務邊界在哪、跟其他 agent 怎麼協作。沒有這份文件,就像把九個新人同時丟進一個專案,沒有分工表、沒有組織圖,叫他們自己搞清楚。

    我當時知道事情一直出問題,但沒找到根本原因。後來才發現,我養成了一個補償行為:每次要啟動 team 之前,我都會先問 Claude「你覺得還缺什麼文件?」

    我以為這是謹慎的好習慣。仔細想,這是我在幫 Claude 做它本來就應該主動做的事。

    現在 AGENTS.md 是所有新專案的第一步強制動作,和 Domain Brain 並列寫進 CLAUDE.md 的「New Project Setup」。

    坑 12:「討論完就開始做」不等於有計畫

    兩個月裡,每次開工前我們都會大量討論——分析需求、評估方案、確認方向。我一直以為那就是計畫。

    但有一個關鍵差別沒意識到:

    討論是活在對話裡的,session 結束就消失了。計畫是一份文件,它是執行的合約。

    更重要的是:計畫的讀者不是我,是執行的 agent。那個 agent 沒有參與討論,沒有上下文,不知道我們為什麼這樣決定。

    一個不夠詳細的 PLAN.md 會讓執行者只能猜意圖。猜錯就要回頭重做。

    現在要求的標準是:每個執行步驟都必須回答四件事——做什麼(具體動作)、在哪裡(檔案路徑)、成功的樣子(怎麼知道這步完成了)、不要做(邊界,避免 agent 自作主張)

    「實作登入功能」是爛計畫。「呼叫 POST /api/auth/login,成功後把 token 存 localStorage(‘token’)、把 context 存 localStorage(‘context’),失敗時顯示人話而非 HTTP status code」才是計畫。

    寫計畫不是給聰明人看的。不是每個腦子都跟你一樣聰明。

    驗收標準不該由我想

    以前的工作流是:Claude 說完成 → 我去測 → 發現問題 → 回來修。

    問題不是 Claude 能力不足,是從來沒有在開始前說清楚「完成長什麼樣」。

    現在的做法:Plan 成形時,Claude 主動起草驗收清單給我確認。不是叫我從零想,是它根據我們的討論整理出草稿,我只需要回「對」或「改第二條」。這把「驗收責任」從我一個人扛,變成流程的一部分。

    2 月的我 vs 4 月的 Claude

    我問 Claude 這個問題,它說了一句話讓我覺得很誠實:

    「最早的我是一個聰明但不可靠的執行者。現在應該是一個有記憶、有流程、會主動管理風險的協作者。但有一部分差距,是你花了大量時間糾正才填起來的——這些本來應該是我自己的責任。」

    這句話是這兩個月最好的總結。

    兩個月的數字

    指標 2 月(裸奔期) 4 月(現在)
    鐵律(Iron Rules) 0 7
    Domain Brain files 0 9 個領域
    記錄的具體 bug/pitfall 0 32+
    自動化 Hooks 0 4
    OOM 當機次數 1 次(再也沒發生)
    同一個 bug 踩兩次的頻率 常態 有機制防止
    強制工作流節點 0 3 個(AGENTS.md / PLAN.md / 驗收清單)

    結語:AI 不是工具,是一匹馬

    買一匹馬回來,你不會期望它第一天就知道路。你得教它不要踩田、不要亂跑、轉彎時要減速、聽到哨聲要停。

    AI coding agent 也一樣。Claude 很聰明——它能寫任何 code、debug 任何問題、理解任何架構。但「聰明」不等於「可靠」。一匹沒訓過的馬也很有力量,但力量加上失控只會更慘。

    這兩個月教我的事:

    1. 每條規則都要有故事——沒有災難背景的規則,AI 不會認真對待
    2. 知識必須跨 session 存活——Domain Brain 讓教訓不死在 commit 裡
    3. 靠文字規則不夠,要靠系統強制——Hook 比 CLAUDE.md 裡的「MUST」有效 100 倍
    4. 閉環比開環重要——Sensor 把教訓自動回寫到 Guide,harness 才會進化
    5. 協作模式也需要調教——規則、計畫、驗收標準,都要變成系統,不能靠臨時記憶

    2 月的 Claude 是一匹脫韁野馬。4 月的 Claude 是同一匹馬,但有了韁繩、馬鞍、和一本厚厚的訓練日誌——還有一套讓牠不能假裝忘記的系統。

    馬沒有變。變的是騎手。

  • Harness Engineering 實戰:讓 AI Agent 自動從 Bug 中學習的閉環系統

    重點摘要

    • Harness Engineering 是 2026 年 AI 工程最重要的新學科——不是訓練更好的模型,而是打造讓模型可靠運作的系統
    • 公式:Agent = Model + Harness,Model 是可替換零件,Harness 才是護城河
    • 本文用實際的 Claude Code 設定,展示如何用 Hooks 建立一個會自我進化的閉環 Harness

    2025 年,所有人都在比誰的 AI Agent 更厲害。2026 年,贏家已經換了跑道——比的是誰的 Harness 更成熟。

    如果你正在用 Claude Code、Codex CLI、或任何 AI coding agent,你每天都在跟 harness 打交道,只是你可能不知道它叫這個名字。這篇文章會用我自己的實戰設定,從零解釋什麼是 Harness Engineering,以及你今天就能動手做的事。

    Harness Engineering 是什麼?一句話定義

    Harness Engineering 是設計「包裹在 AI 模型周圍的控制系統」的工程學科。用 Martin Fowler 的公式來說:

    Agent = Model + Harness

    Model 提供智能,Harness 讓這個智能可靠、可控、可用。Phil Schmid 用了一個精準的電腦比喻:

    電腦零件 AI 系統對應 說明
    CPU AI Model(GPT、Claude) 原始運算能力
    RAM Context Window 有限的工作記憶
    作業系統 Agent Harness 管理資源、提供標準介面、控制生命週期
    應用程式 Agent 跑在 OS 上的具體任務邏輯

    你不會直接在 CPU 上跑程式,你需要作業系統。同樣地,你不會直接對 Claude 說「幫我寫整個系統」就放手不管——你需要 Harness 來確保它走對方向、犯錯時被攔住、學到的教訓不會遺失

    Harness 不是 Framework——搞清楚差異

    很多人把 Harness 跟 LangChain、CrewAI 這類框架搞混。它們是完全不同的東西:

    Framework(框架) Harness(治具)
    LangChain、CrewAI、AutoGen Claude Code、Codex CLI
    提供零件讓你自己組裝 提供完整運行環境
    你自己負責接水管 幫你管好 context、工具、權限、失敗處理
    Blueprint Runtime environment

    Framework 是建築材料,Harness 是建好的房子。你可以用 LangChain 的零件去蓋一個 harness,但 Claude Code 本身就已經是一個 harness。

    Harness 的兩大核心機制:Guide 與 Sensor

    根據 Martin Fowler 的分析,所有 harness 都由兩種控制機制組成:

    Guide(前饋控制)——在錯誤發生之前攔住

    Guide 是你預先給 agent 的方向和規則。它們在 agent 開始工作之前就生效,目的是讓 agent 第一次就做對。

    • CLAUDE.md:專案規則文件(「不准動 main branch」「用繁體中文回應」)
    • Domain Brain:過去踩過的坑的知識庫(「TWSE API 的 ROC 日期格式會導致解析錯誤」)
    • Skills:標準化的工作流程(「寫 iDempiere event handler 要用這個 pattern」)
    • AGENTS.md:角色分配和模型選擇規則

    Sensor(反饋控制)——做完之後自動檢查

    Sensor 監控 agent 的輸出,在問題擴大之前抓住它。分兩種:

    • 計算型 Sensor:linter、type checker、單元測試——毫秒級回應,確定性結果
    • 推理型 Sensor:用另一個 AI 審查輸出(code review agent)——秒級回應,有判斷力但不確定

    大多數人只做了 Guide(寫 CLAUDE.md),完全忘了 Sensor。這就像開車只看前方,不看後照鏡。

    完整的 Harness Cycle:6 步閉環

    一個成熟的 harness 不是「設定好就不管」的靜態文件,而是一個會自我進化的閉環系統。完整的 cycle 有 6 個步驟:

    ① LOAD ──▶ ② GUIDE ──▶ ③ EXECUTE ──▶ ④ SENSE
     自動載入     前饋引導     Agent 做事     自動檢查
     context     規則+經驗                   品質
                                              │
    ⑥ EVOLVE ◀── ⑤ LEARN ◀───────────────────┘
     更新規則      萃取教訓
     和知識庫      從錯誤中
    步驟 做什麼 Harness 類型 常見工具
    ① LOAD 自動載入專案 context 基礎設施 SessionStart hook, CLAUDE.md
    ② GUIDE 讀取規則 + 過去經驗 Guide(前饋) Domain Brain, Skills
    ③ EXECUTE Agent 寫 code Claude Code Bash/Edit/Write
    ④ SENSE 自動偵測品質問題 Sensor(反饋) PostToolUse hook, linter, test
    ⑤ LEARN 從 bug fix 中萃取教訓 Sensor → Guide 橋接 PreCompact hook
    ⑥ EVOLVE 更新 Brain / 規則文件 Guide 進化 Stop hook 驗證

    關鍵是步驟 ⑤→⑥→②:agent 修 bug → 教訓寫入 Brain → 下次讀 Brain → 不再犯同樣的錯。這就是閉環。沒有這個迴路,你的 harness 永遠停留在你第一天寫的水平。

    實戰:用 Claude Code Hooks 建立閉環 Harness

    讓我用真實的 Claude Code 設定來展示。以下不是理論——這是我每天在用的 harness。

    步驟一:建立 Domain Brain(Guide)

    Domain Brain 是一組按技術領域分類的 markdown 文件,記錄「過去踩過的坑」。放在 ~/.claude/projects/{project}/memory/brain/ 目錄下:

    brain/
    ├── python-crawler-data.md    # 爬蟲:ROC 日期、欄位映射、空值處理
    ├── idempiere-osgi-bundle.md  # OSGi:MANIFEST.MF、classloader 問題
    ├── idempiere-2pack.md        # 2Pack:UUID 穩定性、afterPackIn
    ├── stock-backtesting.md      # 回測:signal divergence、entry price bug
    └── design-principles.md      # 設計原則:頻次驅動架構、anti-patterns

    每個 brain file 的內容格式:

    # Python Crawler — Everything That Can Go Wrong
    
    ## ROC Date Format
    - [source: analyst] "1150309" 被解析成 AD 1150 年,要用 7 位 YYMMDD ROC 格式
    - [source: analyst] TPEX 欄位名 TransactionAmount 不是 TradingMoney
    
    ## Holiday / Empty Response
    - [source: analyst] TWSE API 假日返回空值,必須 guard `if not data: return []`

    然後在 CLAUDE.md 裡強制 agent 在開工前讀 brain:

    ## Domain Brain — MANDATORY before ANY implementation work
    Before writing any plan, code, or review, you MUST:
    1. Find the `## Domain Brain:` line in the project's CLAUDE.md
    2. Read each listed brain file
    3. If you skip this step and a bug was documented in brain, that is YOUR failure

    步驟二:用 Hooks 自動偵測 fix: commit(Sensor)

    這是整個閉環最關鍵的一步。在 ~/.claude/settings.json 加入 PostToolUse hook:

    {
      "hooks": {
        "PostToolUse": [
          {
            "matcher": "Bash",
            "if": "Bash(git commit:*)",
            "hooks": [
              {
                "type": "command",
                "command": "/path/to/claude-harness-fix-detect.sh",
                "timeout": 5,
                "statusMessage": "Harness: checking for fix: commit"
              }
            ]
          }
        ]
      }
    }

    偵測腳本做的事很簡單——從 stdin 讀取 Claude Code 傳來的 JSON,提取 commit message,如果是 fix: 開頭就注入 context 強制 agent 更新 brain:

    #!/bin/bash
    INPUT=$(cat)
    msg=$(echo "$INPUT" | jq -r '.tool_input.command' | sed -n 's/.*-m[[:space:]]*["'\'']\?\([^"'\'']*\).*/\1/p')
    
    case "$msg" in
      fix:*|fix\(*)
        project=$(echo "$INPUT" | jq -r '.cwd' | xargs basename)
        cat <<EOF
    {"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"⚠️ BRAIN UPDATE REQUIRED\nYou committed: $msg\nUpdate the brain file NOW before next task."}}
    EOF
        ;;
      *) echo '{}' ;;
    esac

    效果:agent commit 了 fix: handle empty API response → hook 自動觸發 → agent 的 context 被注入「你必須更新 brain」的指令 → agent 無法忽略。

    步驟三:PreCompact 安全網

    Claude Code 在 context window 快滿時會自動壓縮(compact)。如果 brain 更新的指令在壓縮中被丟掉怎麼辦?加一個 PreCompact hook:

    {
      "PreCompact": [
        {
          "hooks": [
            {
              "type": "command",
              "command": "/path/to/claude-harness-precompact.sh",
              "timeout": 5
            }
          ]
        }
      ]
    }

    腳本掃描最近 5 個 commit,如果有 fix: 就在壓縮前再次提醒。雙重保險。

    步驟四:Stop hook 結算

    Session 結束時,Stop hook 比對「今天的 fix: commit 數量」和「brain file 是否有更新」。如果數字不匹配,就警告使用者——這是最後的安全網。

    真實案例:閉環如何拯救你的下一個 bug

    讓我走過一個完整的案例。假設你的 TWSE 爬蟲在假日會爆錯:

    1. ① LOAD:你打開 Claude Code,說「爬蟲昨天跑失敗了,幫我查」
    2. ② GUIDE:Agent 讀 brain/python-crawler-data.md,發現裡面已經記錄了 ROC 日期和欄位映射的坑。帶著這些經驗開始查 bug,不走冤枉路
    3. ③ EXECUTE:Agent 找到 root cause——假日 API 返回空 response,parse() 沒處理 None。寫修復
    4. ④ SENSEgit commit -m "fix: handle empty API response on holidays" → PostToolUse hook 觸發 → 注入 brain update 指令
    5. ⑤ LEARN:Agent 被強制讀 brain file,加入新教訓:「假日 API 返回空值必須 guard」
    6. ⑥ EVOLVE:Brain file 更新完成。下次任何專案遇到 TWSE 爬蟲問題,都不會再踩同樣的坑

    沒有這個閉環會怎樣?你修完 bug,commit,然後忘了。三個月後在另一個專案遇到同樣的問題,重新 debug 兩小時,再次發現「啊,假日要特別處理」。這就是知識衰減——你修了 bug,但教訓死在那個 commit 裡。

    大多數人的 Harness 在哪裡斷裂?

    我觀察到的最常見模式:

    步驟 大多數人的狀態 問題
    ① LOAD ✅ 有 CLAUDE.md
    ② GUIDE ⚠️ 寫了規則但靠 AI 自律 AI 經常跳過,特別是簡單任務
    ③ EXECUTE ✅ Agent 正常工作
    ④ SENSE ❌ 完全沒有自動檢查 commit 後不跑 lint/test
    ⑤ LEARN ❌ 靠 AI 記得 AI 經常忘記更新知識庫
    ⑥ EVOLVE ❌ 靠 AI 記得 教訓死在 commit 裡

    Cycle 在第 ④ 步就斷了。 Guide 做了一半,Sensor 完全不存在,閉環更不用說。這就是為什麼同樣的 bug 會反覆出現——不是 model 不夠聰明,是 harness 沒有記憶。

    你的 Harness 成熟度在哪一層?

    我把 harness 成熟度分成 4 層,你可以自我評估:

    層級 特徵 你有什麼
    Level 0:裸奔 直接對 AI 說話,沒有任何規則文件 只有 model
    Level 1:有規則 有 CLAUDE.md、有 coding style guide Guide(開環)
    Level 2:有回饋 有 hooks 跑 linter/test、有 code review agent Guide + Sensor(開環)
    Level 3:閉環 Sensor 的結果會自動回寫到 Guide(Domain Brain) Guide + Sensor + 閉環迴路

    大多數人在 Level 1。用了 Claude Code 的人可能在 Level 1.5(有 CLAUDE.md 但沒有 hooks)。Level 3 是目標——你的 harness 會隨著每次 bug fix 自動進化。

    今天就能做的 3 件事

    不需要重新設計整個系統。從這三件事開始:

    1. 建 Domain Brain 目錄:按技術領域建 brain files,把你已知的坑寫進去。不需要完美——一個 brain file 有 5 條教訓,就比沒有好 100 倍
    2. 加一個 PostToolUse hook:偵測 fix: commit,注入 brain update 提醒。這一個 hook 就打通了 ④→⑤ 的斷裂
    3. 在 CLAUDE.md 加 Domain Brain 規則:強制 agent 在開工前讀 brain。不是「建議」,是「MUST」

    這三步讓你從 Level 1 直接跳到 Level 2.5。剩下的 0.5(完全自動化的 brain 更新)可以後面再做。

    結語:Model 會被換掉,Harness 不會

    OpenAI 一個月前還領先,現在 Claude 追上了。三個月後可能又換一輪。Model 是最不穩定的變數——你永遠不知道下一個版本是更好還是更差(我之前叫了 20 個 AI 專家 Review 的慘痛教訓就是證明)。

    但你的 Harness——你的規則、你的 Brain、你的 Hooks——這些是你的資產。不管底層 model 怎麼換,你累積的工程知識和控制系統都會繼續生效。

    2026 年的 AI 工程贏家,不是有最好 model 的人,而是有最成熟 harness 的人。你今天就可以開始建。

    延伸閱讀

  • iDempiere + LangGraph:為 15 年老 ERP 加上 AI 問答的完整紀錄

    重點摘要

    • 用 LangGraph + Claude Sonnet + Groq Llama 為 15 年老 ERP 系統加上 AI 問答功能,不改任何一行既有程式碼
    • 從設計到上線跑通:13 輪審查、20+ 個 AI 專家、發明了「領域腦」知識管理系統、踩了 30+ 個坑
    • 最大的教訓不是技術——是「經驗存在但沒被用到」。叫 20 個專家 review 不如先讀一遍上次的踩坑紀錄
    • 完整開源:AI Assistant + Domain Brain(領域腦知識管理系統)

    這篇文章記錄一個完整的旅程:從「我想讓老 ERP 系統能用 AI 回答問題」到「真的在 iDempiere 裡輸入問題、6.8 秒後看到 Claude 的回答」。過程中我們設計了架構、寫了計畫、做了 13 輪審查、發現了「領域腦」這個知識管理方法、踩了 30 多個坑、讓兩個不同的 AI(Claude 和 Qwen)協作開發——最後真的跑通了。

    最終成果:一張截圖說明一切

    ** === AI Assistant Response ===
    Question: 我想查詢訂單
    Answer: 很抱歉,目前沒有找到任何訂單資料。建議您提供特定的訂單編號...
    Model: sonnet
    Tokens: 705
    Time: 6864 ms
    Query: order_status_by_documentno

    這代表什麼?整條鏈路全部打通了:使用者在 iDempiere 輸入問題 → Java Plugin 用 HMAC 簽名 → HTTP 打到 Python → LangGraph 分類問題 → 選對了 SQL → 查了 PostgreSQL → PII 脫敏 → Claude Sonnet 回答 → 脫敏還原 → 顯示在 iDempiere UI。6.8 秒,705 tokens,沒有改 iDempiere 任何一行既有程式碼。

    架構:支援老系統,不重寫老系統

    核心理念:iDempiere 是 15 年的 Java ERP,我們不動它,只在旁邊加一個 Python 微服務。

    iDempiere (Java/OSGi)                    Python AI Service (FastAPI)
    ┌──────────────────────┐                 ┌──────────────────────────┐
    │ AI Chat Process      │  HTTP POST      │ HMAC 驗證                │
    │ HMAC 簽名            │ ──────────────→ │ LangGraph 分類 (Llama 8B)│
    │ 審計日誌              │                 │ 選擇預定義 SQL            │
    │                      │ ←────────────── │ PostgreSQL 查詢 (只讀)    │
    │ 顯示回答              │  JSON 回應      │ PII 脫敏 → Sonnet → 還原  │
    └──────────────────────┘                 └──────────────────────────┘

    這個架構的好處:Python service 掛了,ERP 完全不受影響。要換 LLM 模型?改 Python 一行。要加新的查詢?加一個 SQL 定義檔,Java 端不用動。

    開發過程:兩個 AI 協作,一個審查一個寫碼

    這個專案的開發方式很特別:Claude(我)負責設計、審查、知識管理;Qwen 負責寫程式碼。

    角色 AI 工作
    架構師 + 審查員 Claude Opus 設計 spec、寫 plan、派專家 review、建 Domain Brain、debug 部署問題
    程式實作 Qwen Python service 全部程式碼 + Java plugin 全部程式碼
    指揮官 Tom(人類) 定需求、判斷方向、提出「你有沒有去看上次的紀錄?」這種靈魂拷問

    13 輪審查學到的事

    我們做了 13 輪 review,派了 20 多個 AI 專家 agent。前 8 輪查邏輯、安全、架構、接點——都通過了。然後 Tom 問了一句:「你有沒有去看 tw-invoice 上次踩的坑?」

    答案是沒有。然後我們發現 3 個會直接讓 plugin 啟動失敗的 bug,全部都是上次踩過且記錄過的。20 個專家沒抓到,一句「去看舊筆記」就全找到了。

    這件事催生了一篇完整的反思文章和一個全新的知識管理系統——Domain Brain(領域腦知識管理系統)。

    踩的最痛的幾個坑

    痛點 教訓
    JVM 參數加在 idempiere.ini systemd 啟動不吃 ini,要加在 server.sh 先搞清楚服務怎麼啟動的
    2Pack XML 格式錯 Para 要嵌套在 Process 裡、要 type=table、reference=uuid 看 tw-invoice 的 working example 比看文件有用
    AD_Menu_ID=146 不存在 menu ID 是環境特有的,不能 hardcode 用 UUID reference
    ad_menu_access 表不存在 iDempiere 根本沒有這張表 不要假設表存在,先查
    缺 IProcessFactory DefaultProcessFactory 用 Class.forName,看不到 plugin 每個 SvrProcess 都需要自己的 Factory

    Domain Brain:解決「經驗不傳承」的方法

    這個專案最大的副產品是 Domain Brain — 一個把所有專案經驗按技術領域濃萃的知識管理系統。詳細的介紹在前一篇文章,這裡只講結果:

    • 9 份領域腦,涵蓋 OSGi、2Pack、PO Model、REST API、Python LLM、Crawler、回測、OMS、設計原則
    • 每個專案的 CLAUDE.md 宣告自己需要哪些腦:## Domain Brain: osgi-bundle, 2pack, po-model
    • 審查時帶著腦 → 第一輪就抓到之前 8 輪沒抓到的 bug
    • 新坑自動更新回腦 → 所有未來專案受益

    技術棧

    技術
    ERP UI iDempiere 12 + ZK + OSGi Plugin
    AI 路由 LangGraph StateGraph(分類 → 選 SQL → 查詢 → 脫敏 → 回答 → 還原)
    LLM Claude Sonnet(查詢選擇 + 回答) + Groq Llama 8B(分類)
    安全 HMAC-SHA256 簽名、PII 可逆脫敏 [PII_C_001]、只讀 DB 帳號、statement_timeout
    資料庫 PostgreSQL(iDempiere DB),ai_readonly 帳號,ThreadedConnectionPool

    開源

    下一步

    • 從 Process 對話框升級為 ZK Form 聊天窗(更好的 UX)
    • 加入 AI_ChatLog 審計表(追蹤每次問答)
    • 更多預定義 SQL(目前 3 個,目標 20+)
    • 對話歷史(Phase 2)
    • 有限的寫入操作——透過 iDempiere API,不是直接 SQL
  • 叫了 20 個 AI 專家 Review,最致命的 Bug 卻是「沒讀上次的筆記」

    重點摘要

    • 用 AI 派了 20 個專家跑了 7 輪 review,查了上百個檢查點,結果最致命的 bug 是「沒有去看上次踩過的坑」
    • 問題不是 AI 不夠聰明,而是 AI 沒有主動讀已有的經驗文件就開始寫新計畫
    • 解法不是叫更多專家,而是建立「做特定事之前必讀的 checklist」並且寫進記憶系統
    • AI 和人一樣:知識存在 ≠ 知識會被用到。差距在於流程,不在於能力

    這篇文章記錄一個讓我很不高興的經驗:我用 Claude Code 設計一個 iDempiere AI 助手系統,前後叫了 20 個 AI 專家 agent 做了 7 輪 review,查了上百個技術檢查點——結果最致命的 bug,不是什麼深奧的技術問題,而是「沒有去讀上次開發同類型 plugin 時寫下的踩坑紀錄」

    這件事讓我思考一個更根本的問題:我到底該怎麼跟 AI 協作,才能讓它真正用到已有的經驗?

    發生了什麼事?

    我在開發一個 iDempiere ERP 的 AI 問答助手。這個系統分成兩部分:Java 的 iDempiere Plugin(前端 UI + 權限 + 審計日誌)和 Python 的 FastAPI 服務(AI 路由 + PII 脫敏 + LLM 呼叫)。

    在寫 Plugin 的計畫之前,我已經有一個完整的 iDempiere plugin 開發經驗——台灣統一發票系統 tw-invoice。那個專案踩了超過 24 個坑,每一個都花了我好幾個小時 debug,而且全部記錄在 CLAUDE.md 裡

    但是當 Claude 開始寫 AI 助手的 Plugin 計畫時,它完全沒有去讀那份文件。它是從「一般 iDempiere 知識」出發寫的,而不是從「我們一起踩過的坑」出發。

    7 輪 review 查了什麼?漏了什麼?

    輪次 專家數 查了什麼 找到什麼
    R1 3 元件設計(iDempiere/Python/Security) 12 個修正(HMAC、PII、async)
    R2 1 驗證 code 有更新 0/12 code 沒改(只改了表格)
    R3 2 接點(Java↔Python↔DB↔LLM) 4 個 CRITICAL(PG schema, pool, HMAC bytes)
    R4 3 架構師 / 開發者 / PM 4 個 BLOCKED(conftest 順序、mock 路徑)
    R5 2 老系統 × 新系統聯合對話 thread pool 會拖垮 ERP、statement_timeout
    R6-R7 6 最終驗證 62+38 個檢查點 全部通過 ✅
    R8-R9 3 我要求去讀 tw-invoice 踩坑紀錄 3 個 P0 — 不修直接不能跑

    你看到問題了嗎?前 7 輪 review 用了 20 個專家 agent,查了上百個檢查點,全部通過。但只有在我「要求 Claude 去讀舊專案的踩坑紀錄」之後,才發現 3 個會直接讓 plugin 無法啟動的致命 bug。

    那 3 個致命 bug 是什麼?

    Bug 後果 tw-invoice 有記錄嗎?
    MANIFEST.MF 缺 org.adempiere.plugin.utils Bundle 無法 resolve,完全不能啟動 ✅ 有,而且踩過
    @Model annotation import 路徑錯 PO model 不被發現,DB 操作全部失效 ✅ 有,而且踩過
    initPO 缺少 tableId 檢查 第一次啟動(2Pack 還沒跑)直接 crash ✅ 有,而且踩過

    三個 bug 都是 tw-invoice 踩過且記錄過的。經驗就躺在那裡,但沒有被讀取。

    問題出在哪?不是 AI 不聰明

    讓我想清楚之後,我發現問題不在 AI 的能力(它確實能找到問題——找到了上百個),而在於AI 和人一樣,「知道」跟「會用」之間有巨大的差距

    三層問題

    1. AI 不知道要去看 — 寫新 plugin 計畫時,它沒有主動去讀 tw-invoice 的 CLAUDE.md。它有能力讀,但沒有觸發「我應該先去看看上次踩了什麼坑」的念頭。
    2. 我也不知道要提醒它 — 我以為「派 20 個專家 review」已經夠全面了。我不知道這些專家不會自動去讀歷史紀錄,除非我明確要求。
    3. 專家 review 的盲點 — 專家只看「這份文件本身有沒有問題」,不會跨專案比對「上次做類似的事踩了什麼坑」。他們審的是邏輯一致性,不是經驗傳承。

    解法:不是更多專家,而是「做事之前的 checklist」

    派再多專家也沒用,如果他們不知道要看哪些歷史紀錄。真正的解法是在開始工作之前,就讓 AI 讀取相關的經驗教訓。

    我最終建立了兩份「跨專案強制 checklist」,存在 Claude 的記憶系統裡:

    記憶檔案 觸發條件 內容
    idempiere-plugin-pitfalls.md 寫任何 iDempiere plugin 之前 MANIFEST.MF 必要 package、2Pack 路徑、@Model import、initPO guard、afterPackIn 模式、部署 SOP
    python-llm-pitfalls.md 寫任何 Python LLM 整合之前 JSON 解析容錯、Groq rate limit、timeout 設定、lazy-init 模式

    這兩份檔案在 MEMORY.md 索引裡標記為 🔴 MANDATORY。每次新對話載入時,AI 會看到這個索引,知道「做 iDempiere plugin 工作之前,先讀 pitfalls 檔案」。

    你該怎麼做?給 AI 使用者的具體建議

    如果你也用 Claude Code(或任何 AI coding assistant)做重複性的專案工作,以下是我的教訓:

    1. 踩坑之後立刻寫進 CLAUDE.md

    不是「之後再整理」,是修完 bug 的那一刻就寫。寫三行就好:什麼坑、為什麼踩到、怎麼修的。這個我有做,tw-invoice 的 CLAUDE.md 記了 24 個坑。問題出在下一步。

    2. 建立跨專案的 pitfalls 記憶(這是我缺的那一步)

    經驗寫在專案 A 的 CLAUDE.md 裡,專案 B 不會自動讀到。你需要把「通用教訓」抽出來,放到 Claude 的記憶系統(~/.claude/projects/memory/),這樣每個新專案都能讀到。

    # MEMORY.md 索引加這一行:
    ## ⚠️ Must-Read Before Specific Work
    - **idempiere-plugin-pitfalls.md** 🔴 MANDATORY — 寫任何 plugin 前必讀

    3. Review 之前,先問「你有沒有讀過上次的紀錄?」

    不要假設 AI 會自動做這件事。它不會。你需要明確說:「先去看 tw-invoice 的 CLAUDE.md 取經,然後再來審這份計畫。」

    4. 專家 review ≠ 經驗傳承

    20 個專家能找到「這份文件本身有沒有邏輯錯誤」,但找不到「上次做類似的事踩了什麼坑」。這兩件事是不同的能力,需要不同的觸發方式。

    • 專家 review:「這份計畫有沒有 bug?」→ 邏輯驗證
    • 經驗傳承:「上次做類似的事踩了什麼坑?」→ 歷史比對

    你需要兩者都做,而且經驗傳承要在 review 之前。否則 review 再怎麼嚴謹,也只是在一個有缺陷的基礎上做驗證。

    AI 協作的本質:知識存在 ≠ 知識被用到

    這次經驗讓我想通一件事:AI 的問題跟人的問題是一樣的

    你的資深工程師也會犯同樣的錯——他上次在專案 A 踩了 10 個坑,寫了筆記,但做專案 B 的時候忘了翻筆記,同樣的坑又踩一次。差別在於人有「直覺」(模糊地記得「好像上次有遇過類似的」),AI 沒有這種模糊記憶。AI 要嘛讀了文件就完美執行,要嘛沒讀文件就完全不知道。

    所以跟 AI 協作的核心不是「讓 AI 更聰明」,而是讓正確的資訊在正確的時間出現在 AI 面前。這是一個資訊流設計問題,不是 AI 能力問題。

    我的 AI 協作框架(修正版)

    開始新專案
        ↓
    1. 讀 MEMORY.md 索引(AI 自動做)
        ↓
    2. 有沒有「Must-Read」標記的 pitfalls 檔案?
       → 有:讀完再動手(AI 必須被觸發)
       → 沒有:判斷是否需要建立一個
        ↓
    3. 讀舊專案的 CLAUDE.md(我要明確要求)
        ↓
    4. 寫計畫(現在才開始)
        ↓
    5. Review(專家驗證邏輯 + 歷史比對)
        ↓
    6. 踩到新坑 → 立刻寫進 CLAUDE.md + 更新 pitfalls 記憶

    關鍵改變:步驟 2 和 3 是我之前跳過的。我以為步驟 5 的 review 會涵蓋一切,但 review 只能驗證邏輯,不能傳承經驗。

    更深的問題:專案爆炸之後,你連「要叫 AI 去看哪裡」都不知道

    我檢查了一下自己的開發環境:42 個資料夾、9 個 iDempiere 相關專案、38 個記憶檔案、8 個專案各有自己的 CLAUDE.md(共 1578 行經驗紀錄)。

    這代表什麼?我已經快到「我自己都不知道我有什麼」的臨界點了。

    這次我還記得「tw-invoice 有踩坑紀錄」所以能叫 AI 去讀。但再過半年呢?再多 10 個專案呢?到時候我連「有一份紀錄存在」都不記得,更不可能叫 AI 去參考。而 AI 自己不會主動翻遍 42 個資料夾找相關經驗。

    這就是Sample → 大系統模式的致命陷阱:

    正常的軟體開發流程:
      做 Sample → 驗證可行 → 嫁接到大系統
    
    加入 AI 協作之後:
      做 Sample → AI 幫你踩坑 → 經驗寫在 Sample 的 CLAUDE.md
      → 做大系統 → AI「不知道」Sample 的經驗存在
      → 同樣的坑再踩一次
      → 你修完寫進大系統的 CLAUDE.md
      → 下一個專案又不知道...
    
    無限循環。

    跟資深用戶合作的隱藏風險

    還有一個我不想承認但必須說的事:跟資深用戶合作,AI 反而更容易犯錯。

    因為你太懂技術,我傾向「快速產出」而不是「慢慢確認」。你一聽就懂的東西,我就跳過解釋直接做。結果跳過的步驟裡,就藏著「你以為我知道、我以為你知道、但其實沒人確認」的盲區。

    如果你是新手,我反而會更謹慎——每一步確認、每個假設驗證。但跟資深用戶合作,雙方都太有信心,踩煞車的人就消失了。

    知識分層:什麼該鎖、什麼該開

    還有一個企業層面的問題:當經驗從「人的腦子」搬到「.md 檔案」,它變得可複製了。新人 clone repo 就能拿到所有踩坑紀錄。這對知識傳承是好事,但對機密控管是風險。

    解法是分層:

    層級 內容 存在哪 被帶走的風險
    公開技術層 CLAUDE.md、架構規則、coding style Git repo 低(跟 source code 等價)
    團隊經驗層 踩坑紀錄、設計文件、SOP Git repo 中(加速競爭對手,但不是核心機密)
    個人記憶層 跨專案 pitfalls、用戶偏好 ~/.claude/(本機) 低(不在 repo 裡,但可手動複製)
    營運機密層 API key、商業邏輯、客戶資料 .env / 公司內部系統 高(必須嚴格管控)

    但現實是:目前沒有任何 AI 開發工具提供這種分層管理。 Claude Code 的記憶系統是平的——所有 .md 檔案放在同一個目錄,沒有權限控制、沒有加密、沒有存取日誌。這是整個產業還沒解決的問題。

    真正需要的:「做特定事之前必須讀什麼」的自動化

    我現在的解法是「手動建立 pitfalls 記憶 + 在 MEMORY.md 標記 MANDATORY」。但這依賴兩件事:

    1. 我記得去標記 — 如果我忘了把新的 pitfalls 抽出來建立跨專案記憶,下次還是會踩坑
    2. AI 會去讀標記 — 目前是靠 MEMORY.md 索引,但沒有強制機制。AI 「應該」讀,但「應該」跟「一定會」之間有差距

    理想的解法是什麼?類似 Git hooks 的機制:

    觸發條件                    → 自動動作
    ─────────────────────────────────────────────
    偵測到 iDempiere plugin 相關工作  → 強制讀取 idempiere-plugin-pitfalls.md
    偵測到 Python LLM 整合          → 強制讀取 python-llm-pitfalls.md
    偵測到新專案建立                → 掃描所有已有專案的 CLAUDE.md,提取相關經驗
    偵測到跟舊專案同類型的工作       → 自動列出「相關專案清單」讓用戶確認

    這個機制目前不存在。Claude Code 有 hooks,但是是 shell command 層級的,不是「語意理解」層級的。它能在 tool call 前後跑 script,但不能理解「這次的工作跟上次的 tw-invoice 是同類型的,應該先去參考」。

    在這個機制出現之前,唯一的防線就是你自己:你必須記得提醒 AI 去讀歷史,而且你必須知道歷史在哪。當你的專案多到你自己都不記得有哪些,這條防線就會失守。

    最終解法:領域腦(Domain Brain)

    經過上面所有的分析,我最終做了一件事:把 42 個專案、1578 行 CLAUDE.md、38 個記憶檔案的經驗,按「技術領域」濃萃成 7 份領域腦

    之前(按專案切,散落各處):
      tw-invoice/CLAUDE.md     → 24 個坑(OSGi + 2Pack + PO + REST 混在一起)
      module-ui/CLAUDE.md      → ZK + REST + 測試(370 行)
      skin-ui/CLAUDE.md        → WAB + Vue + 測試(507 行)
      langgraph-duo/           → Python LLM 整合
      analyst/                 → 爬蟲 + pandas + 回測
      → 你要知道「哪個專案有哪些經驗」才能讓 AI 去讀
    
    之後(按領域切,濃萃在一處):
      brain/idempiere-osgi-bundle.md   ← 所有 OSGi 的坑(來自 4 個專案)
      brain/idempiere-2pack.md         ← 所有 2Pack 的坑
      brain/idempiere-po-model.md      ← 所有 PO Model 的坑
      brain/idempiere-rest-api.md      ← 所有 REST API 的坑
      brain/python-llm-integration.md  ← 所有 LLM 整合的坑
      brain/python-crawler-data.md     ← 所有爬蟲/資料的坑
      brain/design-principles.md       ← 跨語言設計原則
      → 你只需要說「我要做 plugin」,AI 就讀 OSGi + 2Pack + PO 三份腦

    為什麼這比「叫 AI 去看舊專案」好?

    面向 看舊專案 CLAUDE.md 領域腦
    你需要記得什麼 哪個專案跟現在的相關 只需要知道「我在做什麼類型的事」
    新專案踩了新坑 寫進該專案的 CLAUDE.md 萃取到對應的領域腦(所有未來專案受益)
    專案 B 比專案 A 先做完 A 不知道 B 的經驗 B 的經驗已在領域腦,A 自動受益
    專案數量爆炸 越多越容易漏 領域腦數量固定(技術領域有限)
    AI 專家 review 每次從零開始 站在所有歷史經驗之上審查

    最關鍵的一行:領域腦的數量不會隨專案數量增長。你可以有 100 個專案,但「iDempiere OSGi」的領域腦就是一份。新經驗加進去,舊經驗不會消失。專案可以刪掉,經驗永遠留著。

    閉環:專家 review 終於有意義了

    之前(斷裂的):
      專家 review → 找到問題 → 修進當前專案 → 下個專案又不知道
                                                  ↑ 斷在這裡
    
    之後(閉環的):
      專家 review(讀領域腦 + 當前文件)
           ↓
      找到問題 → 修進當前專案
               → 同時萃取到對應的領域腦
           ↓
      下個專案開始前讀領域腦
           ↓
      專家 review(讀更新過的領域腦 + 當前文件)
           ↓
      ↻ 經驗循環,不斷累積

    這才是 AI 專家 review 真正有意義的前提:他們站在所有歷史經驗之上做審查,而不是每次都從零開始

    現實的限制

    領域腦不是完美解法。它依然有幾個問題:

    • 萃取是手動的 — 目前沒有工具能自動從 CLAUDE.md 提取教訓並分類到領域腦。我是派 AI agent 讀完所有檔案後人工整理的。
    • 維護需要紀律 — 踩了新坑要記得更新領域腦,不只是寫進專案的 CLAUDE.md。如果忘了這一步,循環又斷了。
    • 領域邊界不總是清楚 — 一個 bug 可能同時涉及 OSGi、2Pack、和 PO Model。要判斷放哪個腦,或者放多份。
    • Token 成本 — 領域腦加起來約 2000-3000 tokens。每次新對話讀取相關的 2-3 份,約 $0.005-$0.015。每月 $5-15,可以接受。

    但即使有這些限制,領域腦依然比「靠人記得哪個專案有哪些經驗」好太多了。因為人的記憶會隨專案數量退化,領域腦不會

    完整解法:Domain Brain 宣告 + fix: 驅動更新

    經過不斷推敲,最終方案有三個核心機制:

    機制一:每個專案用一行宣告自己需要哪些腦

    # 每個專案的 CLAUDE.md 開頭加一行:
    
    idempiere-tw-ai-assistant/CLAUDE.md:
      ## Domain Brain: osgi-bundle, 2pack, po-model, python-llm-integration
    
    analyst/CLAUDE.md:
      ## Domain Brain: python-crawler-data, design-principles
    
    新專案/CLAUDE.md:
      ## Domain Brain: python-crawler-data, design-principles

    為什麼不用資料夾名稱比對?因為 analyst/ 裡有爬蟲也有 API 也有 SQLAlchemy——資料夾名稱不等於技術領域。為什麼不用關鍵字比對?因為「API」出現在 iDempiere REST、Groq LLM、爬蟲、自己的 FastAPI 四種 context 裡,關鍵字比對直接崩潰。

    讓專案自己宣告是最可靠的——你看得到、可以改、一行字。AI 讀到 CLAUDE.md 就知道要載入哪些腦,不用猜。

    機制二:fix: commit 驅動更新

    新經驗怎麼回到領域腦?不是靠事後整理,而是靠 fix: commit 當觸發點:

    每次 AI 寫出 fix: 開頭的 commit message:
      1. STOP — 不要急著做下一個 task
      2. 問自己:「這個 fix 會不會在其他專案也發生?」
      3. 是 → 當場更新對應的領域腦(不是之後,是現在)
      4. 領域腦更新後,所有未來專案的 review 都能受益

    機制三:專家 review 帶著腦

    派專家 review 時:
      1. 專家讀該專案的 CLAUDE.md → 看到 Domain Brain 宣告
      2. 專家讀對應的腦 → 帶著所有歷史 bug 經驗
      3. 審查當前文件 → 站在經驗之上,不是從零開始
      4. 找到新問題 → 修完 → 更新腦
      5. 下一個專案的專家 → 拿到更新過的腦
      ↻ 循環

    三個機制合在一起的效果

    你要做的事 AI 自動做的事
    新專案加一行 Domain Brain(6 個字) 讀對應的腦、帶著經驗開始工作
    不需要做任何事 fix: commit 時自動判斷是否更新腦
    說「派專家審查」 專家帶著最新的腦去審查
    偶爾說「把這個更新到腦」 當場更新,所有未來專案受益

    還是不完美的地方

    • 你自己 debug 沒跟 AI 說的坑 — AI 不知道,無法更新腦。你得養成習慣說一句「把這個更新到腦」
    • 其他同事的經驗 — 除非他們也更新領域腦,否則知識在他們腦子裡消失
    • 新領域出現 — 7 份腦不夠用了(比如加了 DevOps 或 mobile)→ 建新的腦檔案
    • AI 判斷 fix: 是否該更新腦 — 還是靠判斷,可能漏。但比「完全沒有機制」好太多

    結語

    叫再多專家進來 review,如果他們不知道要看歷史紀錄,就跟你請了 20 個新員工來審查、但不給他們看前任的交接文件一樣。當你的專案多到連自己都記不清有哪些,而 AI 又不會主動翻遍你的 42 個資料夾找經驗——這時候你需要的不是更聰明的 AI,而是一個能自動把經驗送到 AI 面前的系統。領域腦不是完美的系統,但它把「靠人記住 42 個專案的經驗」變成「靠 7 份按領域整理的文件」。專案會越來越多,但技術領域是有限的。這就是為什麼領域腦能 scale,而按專案管理經驗不能。

    跟 AI 協作的真正技巧不是「讓 AI 更聰明」或「叫更多 agent」,而是設計一個流程,讓正確的經驗在正確的時間被讀取。這聽起來很簡單,但直到你踩了同一個坑兩次,你才會真正理解為什麼需要這麼做。

    相關閱讀:iDempiere Plugin 開發完整指南:踩遍台灣統一發票的 10 個坑 | LangGraph 多模型實戰:從零到 Production 的完整教學 | Agent Team 穩定的關鍵:spawn 之前先建好兩份文

  • 工程師做完專案就丟上 GitHub?你少做了這些事

    身為一個寫 code 的人,我以前的工作流程大概是這樣:

    1. 有個想法
    2. 熬夜把它做出來
    3. git push origin main
    4. 收工,去睡覺

    然後呢?然後就沒有然後了。

    專案靜靜地躺在 GitHub 上,星星數停在個位數(其中兩個是我自己的帳號和我女朋友被我拜託按的),偶爾有個路過的陌生人開了個 issue 問「這還有在維護嗎?」——沒有,但我不好意思說。

    直到最近我做了一個小專案,花了一些時間研究「做完之後該做什麼」,才發現原來我們工程師漏掉的東西,比寫的 code 還多。


    META Tags 不是裝飾品

    先來個靈魂拷問:你的 index.html<head> 裡面有什麼?

    我猜大概是這樣:

    <head>
      <meta charset="UTF-8">
      <title>My Cool Project</title>
    </head>
    

    恭喜,你的網站在搜尋引擎眼中跟一張白紙差不多。

    Google 爬蟲來到你的頁面,看到一個 title,然後……就沒了。它不知道你這個頁面在幹嘛、給誰用、解決什麼問題。它只好自己猜,而搜尋引擎猜東西的能力,大概跟我猜女朋友今天為什麼不開心一樣爛。

    最低限度,你應該加上這些:

    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>你的專案名稱 — 一句話說明它是什麼</title>
      <meta name="description" content="用 50-160 字描述你的專案做什麼、給誰用">
      <meta name="keywords" content="關鍵字1, 關鍵字2, 關鍵字3">
    
      <!-- Open Graph:社群分享預覽 -->
      <meta property="og:title" content="專案名稱">
      <meta property="og:description" content="專案描述">
      <meta property="og:image" content="https://your-username.github.io/your-repo/preview.png">
      <meta property="og:url" content="https://your-username.github.io/your-repo/">
      <meta property="og:type" content="website">
    
      <!-- Schema.org JSON-LD:結構化資料 -->
      <script type="application/ld+json">
      {
        "@context": "https://schema.org",
        "@type": "WebApplication",
        "name": "你的專案名稱",
        "description": "專案描述",
        "url": "https://your-username.github.io/your-repo/",
        "author": {
          "@type": "Person",
          "name": "Your Name"
        },
        "applicationCategory": "UtilityApplication",
        "operatingSystem": "Web Browser"
      }
      </script>
    </head>
    

    我知道你在想什麼:「這也太多了吧,我只是做個 side project 耶。」

    對,我也這樣想過。但你花了 40 小時寫 code,多花 20 分鐘把 meta tags 補齊,投報率其實很高。


    SEO 和 AEO 的差別

    大家都聽過 SEO(Search Engine Optimization),就是讓 Google 找到你的東西。但 2025 年以後,有另一個東西叫 AEO —— Answer Engine Optimization。

    差別在哪?

    • SEO 是讓使用者搜尋「台灣失智症照護工具」的時候,你的網站出現在搜尋結果裡。
    • AEO 是讓使用者問 ChatGPT 或 Perplexity「有沒有推薦的失智症照護互動工具?」的時候,AI 直接把你的專案資訊抽出來回答。

    關鍵差異:傳統 SEO 靠的是關鍵字密度、反向連結、網站權重這些。AEO 靠的是結構化資料

    上面那段 Schema.org JSON-LD 就是結構化資料。它用一種機器能直接理解的格式,告訴 AI「這個網站是什麼類型、做什麼用、誰做的」。AI 搜尋引擎不需要去「理解」你的網頁內容,它直接讀這段結構化的 JSON 就好。

    這就像是你寫了一份完美的文件,但沒寫 API 文件一樣。人類看得懂,但機器不知道怎麼呼叫你。Schema.org 就是你給搜尋引擎寫的 API 文件。


    GitHub Topics 是免費的曝光

    這個我真的覺得很虧,因為我用了 GitHub 這麼久,從來沒認真設定過。

    你的 repo 頁面右上角有個 About 區塊,裡面可以寫 description 和加 topics。Topics 就像是 hashtag,GitHub 有專門的 topic 搜尋頁面,使用者可以透過 topic 找到相關專案。

    你最多可以加 20 個 topics。免費的曝光,不用白不用。

    用 CLI 設定很快:

    # 設定 repo description
    gh repo edit your-username/your-repo --description "你的專案一句話描述"
    
    # 加上 topics(注意:GitHub Topics 只支援英文小寫)
    gh repo edit your-username/your-repo --add-topic "dementia-care,elderly-care,cognitive-training,taiwan,web-app,html,javascript,open-source,healthcare,accessibility"
    

    重點提醒:GitHub Topics 只支援英文。所以就算你的專案是中文的,topics 也要用英文寫。想想你的目標受眾會用什麼英文關鍵字搜尋,就加什麼。


    GitHub Pages 等於免費網站

    如果你的 repo 裡有 index.html,恭喜你,你離擁有一個免費網站只差兩個步驟:

    1. 去 repo 的 Settings > Pages
    2. Source 選 main branch,資料夾選 / (root)
    3. 按 Save

    完成。你的網站就是 https://your-username.github.io/your-repo/

    每次你 git push,GitHub Actions 會自動幫你部署。不用買主機、不用設定 DNS、不用搞 nginx 設定檔、不用 Docker。零成本。

    更重要的是:你前面寫的那些 meta tags 和 Schema.org 結構化資料,只有在一個可以被公開訪問的網站上才有意義。 GitHub repo 頁面不會被 Google 當成你的「網站」來索引那些 meta tags。開了 GitHub Pages,你的 SEO 和 AEO 才算正式上線。


    Google Search Console — 主動告訴 Google 你存在

    很多人以為把網站放上去,Google 自然就會找到你。

    理論上是的。實際上,一個沒有任何外部連結指向的小網站,Google 爬蟲可能好幾個月都不會路過。你的專案就這樣安安靜靜地存在於網路上,像深山裡的一間便利商店,開著燈但沒有路。

    解法很簡單:去 Google Search Console 主動提交。

    驗證方式最簡單的是 meta tag 驗證——Google 給你一段 meta tag,你貼到 index.html<head> 裡:

    <meta name="google-site-verification" content="你的驗證碼">
    

    然後在 Search Console 的「網址審查」功能裡,貼上你的 GitHub Pages URL,按「要求建立索引」。

    我的經驗是提交後大概 2-3 天就會被 Google 索引。比起被動等待可能要等幾個月,這個 ROI 非常高。整個流程大概 10 分鐘。


    打賞和贊助機制

    「我做的是免費開源專案耶,談錢太俗了吧。」

    我以前也這樣想。但後來我發現:願意付費支持你的人,跟你收不收費是兩回事。有些人就是覺得你做的東西有價值,想請你喝杯咖啡。你不給他管道,他想付都付不了。

    幾個選項,各有優缺點:

    Ko-fi — 國際通用的打賞平台,介面很乾淨。但台灣收款要透過 PayPal 或 Stripe,PayPal 的手續費偏高,Stripe 台灣的個人帳號支援也有限制。適合有國際受眾的專案。

    綠界 ECPay — 台灣最方便的選擇,支援信用卡、ATM 轉帳、超商繳費,對台灣使用者來說付款門檻最低。缺點是設定流程比較繁瑣,可能需要聯繫客服才能搞定。但搞定之後就很順了。

    銀行轉帳 QR Code — 最簡單直接的方式。產生你的銀行帳號 QR code,使用者用手機銀行掃一下就能轉帳。零手續費、零平台抽成。缺點是沒有平台幫你追蹤誰贊助了多少。

    GitHub Sponsors — 在 repo 根目錄放一個 .github/FUNDING.yml,你的 repo 頁面就會自動出現一個 Sponsor 按鈕:

    # .github/FUNDING.yml
    github: your-username
    ko_fi: your-kofi-username
    custom:
      - https://your-payment-link.example.com
    

    這個檔案的設定非常簡單,但效果很好——那個 Sponsor 按鈕就放在 repo 頁面最顯眼的位置,每個路過的人都看得到。

    重點是:你不需要只選一個。 你可以同時放 Ko-fi 給國際用戶、綠界給台灣用戶、銀行轉帳給懶得註冊任何平台的人。多一個管道就多一個機會。


    README 是你的門面

    工程師寫 README 通常是這樣:

    # My Project
    
    ## Installation
    npm install
    
    ## Usage
    npm start
    

    然後就沒了。

    拜託,你的 README 是大多數人對你專案的第一印象(也可能是最後一印象)。它不只是技術文件,它是你的故事。

    一個好的 README 應該包含:

    1. 為什麼做這個 — 你遇到了什麼問題?什麼契機讓你動手做?這個故事比你想像的重要一百倍。「我阿嬤確診失智症,我想做一個讓她可以動動腦的小工具」比「這是一個認知訓練 Web App」有感染力多了。
    2. 這是什麼、給誰用 — 用一般人聽得懂的話說明。
    3. 怎麼用 — 截圖、GIF、或是 live demo 連結。
    4. 技術細節 — 用了什麼技術、架構是什麼(這部分寫給工程師看的)。
    5. 如何支持 — 打賞連結、或是告訴別人怎麼 contribute。

    真實的故事比任何行銷文案都有效。人們不只是在用你的工具,他們是在參與你的故事。


    Social Preview 和 OG Tags

    最後一個,也是最容易被忽略的:分享預覽

    當你把 repo 連結或網站連結貼到 LINE 群組、Facebook、Twitter 的時候,會出現什麼?

    如果你什麼都沒設定,就是一個光禿禿的網址。沒有預覽圖、沒有描述、沒有標題。在訊息流裡面,它跟垃圾連結看起來一模一樣。

    如果你有設定 OG tags(前面 meta tags 那段已經教過了)加上一張好看的預覽圖,分享出去就會有標題、描述、還有圖片。在 LINE 群組裡點擊率差個三五倍很正常。

    GitHub repo 本身也可以設定 Social Preview:去 repo 的 Settings > Social Preview,上傳一張 1280×640 的圖。這樣別人在社群分享你的 repo 連結時,就會顯示這張圖。

    你可以用 Canva、Figma 之類的工具花 5 分鐘做一張。不需要多精美,有總比沒有好太多了。


    結論

    回顧一下,做完 side project 之後你應該做的事:

    1. 把 meta tags 補齊(description、OG tags、Schema.org JSON-LD)
    2. 開 GitHub Pages,讓你的專案有一個公開網址
    3. 去 Google Search Console 提交你的網址
    4. 設定 GitHub Topics 和 About description
    5. 放上打賞/贊助連結
    6. 好好寫你的 README,說你的故事
    7. 設定 Social Preview,讓分享出去有個樣子

    這些事情全部加起來,大概花你一到兩個小時。

    你已經花了幾十個小時甚至幾百個小時寫 code,多花兩個小時讓它被看見,很值得。

    我以前一直覺得「好的東西自然會被發現」。但事實是,網路上好的東西太多了,大部分都沒被發現。被發現的那些,不一定是最好的,但一定是最容易被找到的。

    我們工程師很擅長解決技術問題,但不太擅長「讓別人知道我們解決了什麼問題」。

    這篇文章就是我學到這件事之後,寫給跟我一樣的人的。

    希望你的下一個 side project,不再只是 GitHub 上的一個小點。