分類: 🤖 AI 與自動化

  • 你的 AI Agent Team 為什麼越做越小?視角驅動的編制方法論

    重點摘要

    • 你以為的「3 個 Agent 上限」可能是創傷的外推,不是設計 — 來自一次 OOM 事故的過度修正,結果反過來殺死你專案的角色細分工
    • 正確的限制是記憶體預算,不是數量 — 16GB 機器可用 ~11GB agent 預算,混合模型(Opus/Sonnet/Haiku)可以安全跑 8+ 個專職 agent
    • 但更深的問題是:角色不是頭銜,是視角 — 你該問「這個計畫需要哪些思考視角」,不是「我有幾個 slot」
    • 三個永遠不能省的視角:使用者、PM、測試者 — 即使是純技術、只有 Python、只有你一個人的專案,這三個視角的思考必須發生
    • 視角打分模型:每個視角用「風險 × 範圍」打 0-9 分,高分獨立成 agent,低分 fold 到其他 agent 的 prompt
    • 反直覺發現:小任務(3 行 API)比大任務更需要資深視角(架構師、運維、測試),因為實作已經不是風險,「該不該存在」才是
    • 壞規則會透過 template anchoring 自我強化 — 修了 runtime 規則還不夠,必須連 template 一起改

    我的 AGENTS.md template 有一條規則:Agent Team 最多 3 個同時跑。這條規則是對的 — 直到我發現它正在偷偷殺死我的專案設計。

    這篇文章是一趟很誠實的反思紀錄。從一次 16GB 機器 OOM 當機開始,到發現我對 AI Agent Team 的所有規則其實都建立在錯誤的抽象層上。結論不是「解除限制、大膽開 agent」,而是更根本的東西:角色根本不是我以為的那個東西

    第一層:我的規則其實是創傷,不是設計

    事情是這樣開始的。2026 年 3 月 3 日,我嘗試在 16GB 的 Mini PC 上同時跑 9 個 Opus agent 做大型重構,結果系統 OOM 當機。從那一天起,我的 MEMORY.md 多了一條 Iron Rule:「Agent Team 最多 3 個同時跑」。

    這條規則是理性的。9 個 Opus × 1GB ≈ 9GB,加上系統和 Docker 的常態佔用,16GB 當然吃不下。3 個是「經驗上安全」的數字。但我沒注意到一件事:這條規則是在「全 Opus」的情境下歸納出來的。我直接把它當成所有情境的通用上限,沒考慮混合模型的狀況。

    更糟的是,這條規則開始滲透到我的 AGENTS.md template、我的專案計畫、我對每個新專案的初始設計。結果是:即使某個專案明明應該有架構師、後端、前端、Kafka、SQL、Test、PM、QA 八個角色,我也會「收斂」到「全端 + 檢查者 + 架構師」三人組。我不是選擇了簡化設計,是我被一條舊規則綁架了設計想像力

    第二層:記憶體預算制取代數量制

    第一個修正很直覺:限制應該是記憶體總量,不是 agent 數量。16GB 機器扣掉系統和 Docker 的 ~5GB,還剩 ~11GB 可以給 agent 用。而不同模型的記憶體占用差很多:

    模型 記憶體 適合任務類型
    Opus ~1.0 GB 架構決策、跨檔案推理、複雜邏輯、資深思考
    Sonnet ~0.6 GB 實作、API、測試、文件
    Haiku ~0.4 GB 檔案掃描、config 對照、簡單查詢

    換算一下:一個架構師(Opus 1.0)+ 兩個後端(Sonnet × 2 = 1.2)+ 前端(Sonnet 0.6)+ SQL(Sonnet 0.6)+ Kafka(Sonnet 0.6)+ Test(Sonnet 0.6)+ QA + PM(Haiku × 2 = 0.8)= 5.4 GB,8 個 agent。完全在 11GB 預算內,還有 5.6GB 的 headroom。

    所以「3 個 agent」的舊規則其實是錯的。正確的說法是「全 Opus 時 ≤ 3 個;混合模型時可以到 8+ 個,只要總和在預算內」。這一步修完,我理論上可以解鎖原本想要的專職團隊設計。

    但這還不夠:我一直在錯的抽象層解決問題

    我以為修到這裡就結束了。結果真正的問題還在更上面一層。

    記憶體預算制解決了「能不能開 8 個 agent」的技術問題,但沒解決「我該開哪 8 個角色」的設計問題。我仍然在用「頭銜思維」想 Agent Team — 有一個固定的角色清單(架構師、後端、前端、QA…),看看預算能放幾個,從清單挑幾個進來。

    這個思維的錯誤在哪?它把「角色」當成頭銜 / headcount,而角色的本質其實是視角 / perspective — 一種「怎麼思考這件事」的觀點。兩個是完全不同的東西:

    思維 頭銜 Headcount 視角 Perspective
    單位 angular-dev、backend-dev 「使用者會怎麼用」「PM 會怎麼想」
    數量 固定清單 每個 plan 動態決定
    「只有 Python」專案 1 個 Python dev 就好 仍需使用者、PM、測試視角
    小任務(3 行) 給最便宜的 dev 可能需要最資深的運維視角

    三個永遠不能省的視角

    這是最實用的一條發現。不管你的專案是什麼技術、什麼規模、幾個人做,這三個視角永遠必須存在:

    • 使用者視角 — 誰會用這個東西?他們的心智模型是什麼?介面怎麼設計才順?
    • PM 視角 — 這件事符合大方向嗎?是現在最該做的嗎?會影響別的優先級嗎?
    • 測試者視角 — 什麼會壞?邊界 case 在哪?漏掉什麼?失敗模式是什麼?

    這三個視角可以 fold 到其他 agent 的 prompt 裡(例如讓 backend-dev 在設計階段戴 PM 帽子),但絕對不能默默省略。省略它們的結果是:你會產出「技術上正確、實用上無用」的東西 — API 能跑但沒人想用、feature 完成但 PM 說這不是重點、code 過 review 但上線就爆 edge case。

    「只有 Python」不是跳過使用者視角的理由。「只有你一個人」不是跳過 PM 視角的理由。「只是改一行」不是跳過測試者視角的理由。這三個視角的思考必須發生,差別只在「由誰承擔」,不在「要不要做」。

    視角打分模型:風險 × 範圍

    光知道「要列視角」還不夠,還要知道哪些重要、哪些次要。我用的打分模型很簡單:Score = Risk × Scope,兩個維度都是 0-3,總分 0-9。

    • 風險:這個視角漏掉的話多慘?(0 = 無所謂,3 = 災難)
    • 範圍:這個視角涉及多少產出物?(0 = 無,3 = 全部)

    分數算出來後按以下規則分組到 agent:

    • Score ≥ 6:獨立 agent,很可能要用 Opus 或高階 Sonnet
    • Score 3-5:獨立 agent,或跟另一個視角配對(Sonnet)
    • Score 1-2:fold 到其他 agent 的 prompt,明確列入該 agent 的優先順序
    • Score 0:在計畫裡 acknowledge 就好,不花 agent 資源

    最後檢查記憶體預算是否超過 11GB,超過就把分數最低的兩個合併。

    反直覺發現:小任務需要更資深的視角

    這是這整套方法論裡最違反直覺、也最容易忽略的一條。實作越簡單的任務,反而越需要資深視角

    原因很微妙:大任務的風險分散在許多實作細節裡,實作者視角得分最高(因為「寫錯」的機率大)。但小任務的實作幾乎免費 — 風險已經轉移到別的地方了:「這東西該不該存在」(架構師視角)、「會不會在奇怪情境下爆」(測試者視角)、「運維怎麼用這個 endpoint 做決策」(運維視角)

    具體例子:幫一個 Docker 服務加 /health endpoint,回 {"status": "ok"}

    • 實作者視角:風險 1 × 範圍 1 = 1 分(3 行誰都會寫)
    • 運維視角:風險 3 × 範圍 2 = 6 分(這個 endpoint 決定服務要不要被 monitoring 重啟)
    • 架構師視角:風險 3 × 範圍 1 = 3 分(「健康」的定義是最大問題)
    • 測試者視角:風險 2 × 範圍 1 = 2 分
    • 使用者視角:風險 2 × 範圍 1 = 2 分

    結論:這個任務要派一個 Sonnet agent,但 prompt 的優先順序是運維 > 架構 > 測試 > 使用者 > 實作。它不是「後端 dev 寫 3 行」,是「一個有運維腦袋的人剛好以 3 行 code 為產出」。

    這個例子回答了我長久的困惑:「小任務該加一個專職 agent,還是從 tester 出發、還是從 dev 出發?」答案是 — 從得分最高的視角出發,不從頭銜出發。小任務通常是資深視角得分高,所以該派的不是 junior implementer,是帶著資深視角的人。

    三個完整案例

    案例 1:純 Python script(200 行,讀 CSV 做統計)

    視角枚舉:Python 實作者、使用者、PM、測試者、架構師

    打分:實作者 6、測試者 6(資料邊界 case 是真風險)、使用者 2、PM 1、架構師 1

    分組:2 個 Sonnet(~1.2 GB)

    • Agent 1:實作 + 架構 + PM 視角(優先:實作)
    • Agent 2:測試 + 使用者視角(優先:測試 — 資料邊界主導)

    2 個 agent,但 5 個視角都被想過了。要的是「每個視角都有思考發生」,不是「每個視角都有一個人」。

    案例 2:OMS 訂單退款功能(跨前後端/Kafka/SQL/admin)

    視角枚舉:架構師、安全、後端、前端、Kafka、SQL、Admin UI、測試、使用者、PM、合規

    打分:每個都 ≥ 4 分,因為範圍廣且風險高(金流)

    分組:9 個 agent(~6.6 GB,預算內)

    • Architect(Opus)、Security(Opus)
    • Backend-dev、Frontend-dev、Kafka、SQL、Admin-UI、Tester(全部 Sonnet)
    • 使用者 + PM + 合規視角 fold 到一個 Haiku

    這就是大型專案值得完整專職團隊的時候。注意:即使 9 個 agent,使用者和 PM 視角還是被 fold 到一個 Haiku,不是各自獨立。視角沒被省,但不一定要佔獨立 slot

    案例 3:3 行 /health endpoint(已在前面詳細分析)

    分組:1 個 Sonnet,所有 5 個視角都 fold,優先順序運維 > 架構 > 測試 > 使用者 > 實作。

    計畫修訂時必須重算

    一個容易忽略的點:staffing 是 live document,不是一次性決定。當計畫執行到一半發現:

    • 新需求浮現(「喔原來這個也要接 Kafka」)→ 新視角需要打分,決定獨立或 fold
    • 某視角發現其實很關鍵(原本 fold 的安全視角,發現有 PII 暴露)→ 升級為獨立 agent
    • 某視角發現其實不必要(決定不做 admin UI)→ 該 agent 退場或重新分配

    每次 plan 修訂都該觸發 staffing 重算。如果 AGENTS.md 跟當前 plan 不同步,應該先更新 AGENTS.md 再繼續 dispatch。

    壞規則會自我強化 — template anchoring 的陷阱

    最後我想分享一個元層級的教訓,是這整件事最讓我不舒服的部分。

    我一開始以為只需要改 runtime 規則(把「3 個上限」改成「預算制」)就好了。但我後來發現:壞規則會透過 template 自我強化。我的 AGENTS.md template 原本的例子只有 2 個角色(implementer + reviewer),這個「低錨」讓我每次初始化新專案時,都自動往「3-5 個角色」的方向填,幾乎從不主動設計 7-10 個。

    這形成一個 feedback loop:

    • 創傷 → 記憶體規則收緊到「3 個」
    • Template 的例子反映記憶體規則 → 錨在低數量
    • 我用 template 寫新 AGENTS.md → 產出低角色的設計
    • 低角色的設計變成「標準」→ 我的記憶認為「就是這樣」
    • 循環加強

    光改規則不夠,template 也得改。我最後把 template 改成強迫跑完整個視角打分流程的版本:頂部要求先讀「視角驅動 staffing」的 brain,中間有「Perspective Inventory」區塊要你填每個視角的 Risk × Scope 分數,再下面是「Memory Budget」計算區塊。沒跑完流程根本填不完 template。

    這個 template 和配套的方法論 brain 都放在公開的領域腦 repo裡,歡迎 clone 參考。

    跟 VCS / 版本控制的關係

    如果你讀過我之前那篇 Jujutsu vs Git,可能會問:視角驅動 staffing 跟 jj 有什麼關係?

    答案是:沒有關係,而且這個順序很重要。視角驅動 staffing 是計畫期的事 — 你在寫 plan、決定 Agent Team 編制時發生。VCS(git / jj / worktree / workspace)是執行期的事 — Agent Team 已經成立、開始跑、產出變動需要合併的時候才登場。不能倒過來。

    我之前一度把這兩件事混在一起寫(在「VCS + AI 工作流」的 brain 裡討論 Agent Team 編制),後來意識到這是錯的。Staffing 決定「要做什麼」,VCS 決定「做的產出怎麼合併」。兩者相關但獨立,混在一起會讓人以為「切到 jj 就能多開 agent」— 不是的,能多開 agent 是記憶體預算 + 視角分析的事,跟 VCS 無關。

    結論:為什麼這個順序重要

    我從「Agent Team 最多 3 個」走到「視角驅動編制」的這段路程,對我來說最重要的一個 meta-lesson 是:你的限制規則可能不是來自設計,而是來自創傷。一次系統炸掉的記憶,會變成一條 Iron Rule,然後透過 template anchoring 滲透到你所有未來的設計決策。

    修復這種規則不能只修表面。你得:

    1. 檢視規則的原始情境 — 它是在什麼條件下產生的?那些條件還成立嗎?
    2. 把規則轉成可計算的東西 — 不是「最多 3 個」,是「11GB 預算」;不是「寫 3 行 API」,是「運維視角 6 分」
    3. 上升到更對的抽象層 — 從「數量」到「預算」是一次升級,從「頭銜」到「視角」是又一次升級
    4. 同時改 template — 不然新專案會繼承舊錨,規則永遠修不乾淨

    這個流程不只適用 Agent Team 編制,適用任何你「歸納」出來的工程規則。下次你發現自己下意識地拒絕某個選項(「不能這樣做,會爆」),請誠實問自己:這個規則是設計來的,還是創傷來的?如果是後者,它可能正在偷偷殺死你的系統設計,而你根本沒意識到。

  • Jujutsu vs Git:從底層原理到 AI Agent Team 時代的版本控制選擇

    重點摘要

    • Git 的本質是一個 content-addressed key-value 資料庫,所有的 branch、rebase、reflog 都是建構在這個底層上的抽象
    • Jujutsu(jj)不是 Git 的替代品,而是把 Git 的 content-addressed 哲學「再往上推一層」 — 從檔案快照擴展到操作快照
    • Git 的核心限制不是指令不爽,而是它要求人類的同步注意力 — merge 衝突會 block 整個世界等你處理
    • jj 允許異步注意力 — 衝突變成 commit 的一種狀態,rebase 永遠不會停,你可以明天再一次處理完
    • Agent Team 有三種任務類型,jj 的價值差很多:Review(只讀)完全不需要 jj;Debug(試驗假設)是 jj 最被低估的甜蜜點;Dev(平行開發)在合併階段需要 jj
    • 最優的使用模式是 git 為主、jj 只在 Agent Team 合併階段出場 — 由 orchestrator agent 承擔 jj 學習成本,人類使用者和 subagent 全程保持 git 心智
    • 決策準則:默認 Git,三個訊號任一觸發時考慮 jj — 平行 Agent 撞車、review round 批次修正、Agent 實驗成本太高

    最近 GitHub 上 jj-vcs/jj(Jujutsu)越來越熱,社群討論開始出現「Git 要被取代了嗎」的聲音。我花了幾個小時認真研究這個工具,過程中意外地被迫重新理解 Git 的底層設計 — 因為只有懂 Git 真正是什麼,才看得懂 jj 想解決什麼。這篇文章就是這趟理解的完整紀錄,從 Git 底層原理開始,一路推到 AI Agent Team 時代的務實使用模式。

    結論先說:Jujutsu 和 Git 不是互斥關係。jj 的儲存後端其實就是 Git,你可以隨時切回純 Git 指令。真正該問的不是「要不要換」,而是「你的工作流是否已經撞到 Git 的設計假設」。而這個答案,必須先從 Git 的底層長什麼樣講起。

    先搞懂一件事:Git 其實是一個資料庫

    很多人用 Git 很多年,但沒意識到一件事:Git 本身就是一個資料庫,而且是一個設計極其精巧的資料庫。整個 .git/ 目錄就是一個 key-value store,只是它用檔案系統當儲存引擎而已。

    你現在去任何 git repo 跑 ls .git/objects/,會看到一堆兩位數的資料夾:00/ 01/ ... ff/,每個資料夾裡面有一堆 hash 開頭的檔案。這就是 Git 的資料庫。它叫 Object Database,key 是 SHA-1 hash,value 是壓縮過的內容。所有其他概念 — commit、branch、tag、stash、reflog — 全部都是建構在這個 key-value store 之上的抽象。

    四種物件,解構整個 Git

    • Blob:儲存單一檔案的內容,不包含檔名。SHA1(content) = hash,內容相同 hash 必然相同。同樣內容只存一份(自動去重)。
    • Tree:儲存一個資料夾的結構,內容是一張表 — 列出這個資料夾有哪些檔案(指向 blob)和哪些子資料夾(指向另一個 tree)。Tree 可以遞迴指向其他 tree。
    • Commit:儲存一個時間點的快照,內容只有幾行 — 指向根目錄的 tree hash、前一個 commit 的 hash、作者時間、commit message。Commit 本身不含任何檔案內容,它只是一個指標鏈的進入點。
    • Tag:annotated tag 的容器,對理解本質不重要,跳過。

    一個 commit 怎麼「記錄整個資料夾」

    你 commit 時,Git 為每個檔案內容計算 hash 建 blob,為每個資料夾建 tree(一張表,列出那個資料夾裡所有 blob 和 sub-tree 的 hash + 檔名),最後建 commit,只包含 root tree hash + parent hash + 作者 + 訊息。結果是一個指標鏈:commit → tree → sub-tree → blob每個 commit 都完整地、不可變地描述了那一刻整個資料夾的狀態,不是 diff,是完整快照。

    但每次存完整快照不會爆嗎?

    這就是 Git 設計最美的地方。答案是:不會爆,因為 content-addressed 帶來自動去重。你 commit 了 1000 次,但 99% 的檔案沒動,那 99% 的檔案永遠只有一個 blob,因為內容一樣 hash 一樣,儲存只有一份。改一個檔案的成本 = 1 個新 blob + 2-3 個新 tree + 1 個新 commit,其他全部重用。實際效果:Linux kernel 有 100 萬+ commits,整個 .git/ 也就 5 GB 左右。

    Git 從來不儲存 diffgit diff 是每次問的時候走訪兩個 commit 的 tree DAG、現場跑 Myers 演算法算出來的。這個設計的好處是:讀任何一個歷史版本都是 O(1),不用還原一串 diff 才能看到那個版本的樣子。這一切都是「content-addressed immutable store + pointer DAG」這一個設計決策的延伸。Linus 2005 年花兩週寫出 Git 核心,就是這個設計。後來所有東西都是在這個底層上加糖。

    Jujutsu 的核心創新:把 Git 的哲學再推一層

    現在你懂了 Git 的底層,就能看懂 jj 在做什麼。jj 沒有推翻 Git 的設計,它做的事情是:把 Git 的 content-addressed DAG 從「檔案狀態」擴展到「操作狀態」 — 同樣的哲學,應用在不同的層級。

    雙層身份:commit hash + change-id

    Git 只有一個 ID — commit hash,由內容決定。這導致 rebase 後 hash 全變,你找不到「原本那個 commit」。jj 對每個 commit 同時維護兩個 ID:commit hash(跟 Git 一樣)+ change-id(隨機產生的 128-bit ID,只在 commit 誕生時生成一次,rebase/amend/split/squash 都不變)。這個設計的意義是:身份認同不綁在 hash 上,綁在 change-id 上。你可以隨便改歷史,change-id 永遠追蹤得到「同一個概念上的改動」。這就是為什麼 jj 可以把「歷史重寫」當日常操作,而 Git 必須當儀式感操作。

    Op Log:第二層 content-addressed DAG

    jj 的另一個核心是 operation log。每個 jj 操作都產生一個 operation 物件,包含 operation id(hash)、parent operation、執行的指令、操作前後 repo 整體狀態的快照。這些 operation 本身也組成一個 DAG,content-addressed 儲存。注意這裡的模式跟 Git 完全一樣:不可變物件 + 指標 DAG + 內容定址。jj 只是把 Git 用來管檔案狀態的那套機制,再蓋一層管操作狀態。

    結果是 jj undo 變成一步 pointer 移動 — 直接把 repo view 指回前一個 operation 的「操作前快照」就好,不用反向計算任何東西。而且 undo 本身也是一個 operation,可以被 undo,可以無限回退。

    指令層面的六個差異

    面向 Git Jujutsu (jj)
    Staging Area working dir → index → commit(三層) working dir 就是 commit(兩層,無 git add)
    Branch 概念 一等公民,必須 checkout/rebase/merge 可選的 bookmark,每個 commit 有穩定 change-id
    Undo 機制 reflog(補救機制,90 天 TTL) jj undo(第一等操作,可無限回退)
    Conflict 處理 停止世界,當場解完才能繼續 衝突直接存進 commit,可延後解
    Stash 必要指令(stash push/pop/apply) 不存在,jj new 直接開新 commit
    歷史重寫 rebase -i 是儀式感操作 jj split / jj squash 是日常

    最重要的差異:同步注意力 vs 異步注意力

    到這裡為止,都還是「指令差異」和「設計哲學」的層面。但 jj 真正的價值,不在指令爽不爽,而在它如何消耗人類的注意力。這個差異是質變,不是量變。

    Git 是同步注意力模型 — merge/rebase 遇到衝突會停止世界,直到人類出現、看懂、解決、繼續。它要求你在「不可預期的時刻」立刻在場。這個模型在單人或 2-3 人團隊下完全沒問題,因為衝突頻率低、你本來就在鍵盤前。

    jj 是異步注意力模型 — rebase 遇到衝突不停下來,衝突被存進 commit 變成一種有效狀態,rebase 繼續往下跑。你可以明天處理、或一次批次處理所有衝突、或開另一個 Agent 專門解衝突。jj 允許人類「異步在場」 — 系統自己跑,有問題幫你記下來,你有空再來看。

    Git 的設計誕生於 2005 年,當時的假設是「careful human curator」 — 每個 commit 都是人類慎重思考後的決定,每個 merge 都有人類在場。但 Claude Code、Cursor、Copilot Agent 這類 AI 工具改變了「誰在產生 commit」這件事。Agent 產出的是高頻、探索性、非線性、大量並行的中間態。你不可能一邊看 3 個 Agent 平行工作,一邊在它們合併時即時解衝突,一邊決定每個中間態要不要保留。人類同步注意力的頻寬根本跟不上 Agent 的產出速度。

    jj 在這裡的價值不是「比 Git 更爽」,而是它讓 Git 做不到的事變可能 — 讓 Agent Team 真的平行跑到底,明天早上來一次處理所有衝突。這不是量變(爽度提升),是質變(能做的事情不同)。

    Workspace 隔離:平行的真正解法

    要澄清一個常見誤解:jj 本身不能解決「兩個 Agent 同時寫同一個檔案」這種檔案系統 race condition。檔案系統的併發是 OS 管的,任何 VCS 都介入不了。平行 Agent 的正確架構是每個 Agent 給一間房 — Git 用 git worktree add,jj 用 jj workspace add。每個 workspace 是獨立的 working directory,三個 Agent 在不同資料夾各寫各的,完全不會互相污染。

    Git 和 jj 在 workspace 隔離這一步的行為相同。差異出現在下一步 — 把三個 Agent 的輸出合併回 main 時。Git 會序列化 blocking(解一個、繼續、解下一個、繼續),你的注意力被三次阻塞切碎;jj 則是三個動作一氣呵成,最後一次性 batch 處理所有衝突。Workspace 隔離是必要條件,jj 的優勢出現在合併階段的併發模型

    Agent Team 的三種任務類型:jj 的價值差很多

    這是這篇文章最重要的一個區分,也是大部分「jj 推坑文」忽略的。你叫 Agent Team 做不同類型的事,jj 的價值完全不同 — 甚至有些情境 jj 根本沒有用。決定性的問題是:這些 agent 需要寫檔案才能完成任務嗎?

    類型 1:Review(只讀) — 完全不需要 jj

    場景:叫 Agent Team 做 code review、security audit、dead code 掃描、架構審閱。這些 agent 只讀檔案,產出報告,不寫任何東西

    這種場景 jj 沒有價值:沒有 commit、沒有 merge、沒有 undo 需求。三個 agent 同時讀同一個檔案完全沒問題,OS 的 file handle 對讀不是互斥資源。純 git 就好,甚至不需要 workspace 隔離。

    常見誤區:很多人把「開 Agent Team」跟「需要 jj」畫等號,導致在純 review 場景也導入 jj 的學習成本。這是沒必要的。

    類型 2:Debug(試驗假設) — jj 最被低估的甜蜜點

    場景:叫 Agent Team debug 複雜 bug。Debug 的本質是「形成假設 → 驗證 → 推翻 → 再假設」的循環,而驗證通常需要動手改 code — 加 log、改條件、插 assert、暫時跳過某段邏輯。

    Git 下的 debug 痛點:加了 10 個 print() 驗證完要全部刪掉,漏刪一個就污染 production;改了某個函式測試假設,後來發現方向錯,手動改回去容易漏;想試兩個假設 A 和 B 哪個對,兩個互相覆蓋,不能同時存在。

    這是 jj 最強的場景,**比一般開發還強**。原因很微妙:development 的產出是「最終正確的 code」,中間過程 git 可以容忍(最終 squash 成 clean commit 就好)。但 debug 的產出是「對問題的理解」,這個理解存在於中間過程本身,不存在於最終狀態。

    Git 丟掉中間過程 = 丟掉 debug 的精華。jj 保留所有中間過程 — 每個假設是一個 commit、jj undo 可以乾淨撤銷、多個假設可以平行存在於 op log,等於保留 debug 的完整價值。debug agent 可以試 5 個假設、留下完整推理紀錄、報告說「我試了 A/B/C/D,D 可行因為…」,你事後可以 jj op restore 跳回任何一個假設狀態看 agent 那時候看到什麼。

    結論:如果你常叫 AI debug 複雜問題,jj 的價值比你想的大很多。這是 op log 從「檔案歷史」升級為「推理紀錄」的唯一場景。

    類型 3:Dev(平行開發) — jj 在合併階段是必要品

    場景:叫 Agent Team 平行開發多個模組。多個 agent 會產出多個改動分支,最後要合併回 main。這是大家熟悉的場景 — 前面講的「同步 vs 異步注意力」、「workspace 隔離」、「conflict as commit」全部都在這個場景發揮。

    但注意:jj 在這個場景的價值集中在合併階段,不是 agent 工作期間。agent 自己工作時,Git 和 jj 沒差。差異出現在「把 N 個平行輸出 reconcile 回主線」這一步。沒有 jj 的非阻塞合併,你的 Agent Team 上限會被 git 的序列化 blocking 硬生生壓到小規模 — 即使記憶體還有餘裕。

    三種使用模式:該怎麼在日常工作裡擺 jj

    理解完上面的分析,最後的問題是:你該怎麼把 jj 放進你的日常工作?有三種模式,各自適合不同情境。

    模式 1:純 git(現狀)

    適合:單人或「1 RD + 1 Reviewer」工作流、穩定專案、強烈依賴 IDE Git 整合、團隊只認 git。這個模式沒什麼好討論,就是你現在的樣子,對大多數人完全夠用。

    模式 2:jj 為主,git 只是後端

    安裝 jj colocated mode,日常指令都打 jj,幾乎不打 git。這個模式的問題是:邊界仍然是 git 世界。IDE Git panel 顯示的是 jj 上次匯出的狀態,你在本地 jj rebase 一下,push 後 GitHub 還是看到 commit hash 全變,change-id 根本沒傳出去。你本地很爽,對外還是 git 的世界。

    這個模式只適合「單人、沒有協作壓力、不在意 IDE 整合退化」的使用者。對多數人代價太大。

    模式 3:git 為主,jj 只在 Agent Team 合併階段出場

    這是我研究完之後推薦的模式,也是最被低估的一個。玩法是:

    • 你日常全程用 git — 習慣、IDE、CI、PR 流程完全不變
    • Subagent 也用 git — 他們在各自 workspace 裡用 git commit,根本不知道 jj 存在
    • Orchestrator agent(Claude Code)在合併階段用 jj — 只需要學 5 個指令:workspace addgit importrebaselogworkspace forget
    • 一次性設定:jj git init --colocate,之後你完全忘記它存在

    這個模式最精妙的地方:學習成本完全落在 orchestrator agent 身上,不是你身上。你的認知負擔幾乎為零,只需要記住一個 trigger:「要開 Agent Team 時,叫 Claude」。其他時間 jj 完全不存在。

    失敗零成本:任何一個環節出問題,你都可以回退到純 git。subagent 的 commit 是正常 git commit,無論是否用 jj 都能 git cherry-pick;合併失敗就 git merge 手動解;jj 整個壞掉就刪掉 .jj/,git repo 毫髮無傷。沒有 lock-in,沒有 point of no return

    Review Round 批次修正:寫之前切 vs 寫之後切

    另一個 jj 爆發的場景是 review round 工作流。很多團隊的 QA/review 會產出幾十個 issue,你拿到一份清單要一次修完。典型的 commit message 長這樣:fix(fee): R5-12/13/03/04/10/16/17 remaining module items — 塞了 7 個獨立的 issue 修正在一個 commit。

    為什麼塞一起?因為 Git 下的三個選擇都不理想:每個 issue 一個 commit 要 git add -p 切七次太累;全部一個 commit 未來想單獨 backport 做不到;開 7 個 branch 管理成本爆炸。結果大多數人選「全部一個 commit」,歷史變成粗顆粒。

    jj 的解法:打開 editor,一次改完 7 個 issue,不用管 commit 切分。改完後 jj split,互動畫面讓你把每個 hunk 分配到哪個 commit,七次後自動產生 7 個獨立 commit。Git 的 add -p 是「寫之前」切,jj 的 split 是「寫之後」切。這讓你把「commit 切分」這個決策延後到資訊最充足的時候(全部改完後),而不是在你還在思考邏輯的時候被迫處理。

    決策準則:你該用哪個模式?

    純 git 就夠的訊號

    • 單人或「1 RD + 1 Reviewer」工作流 — 沒有平行檔案衝突
    • Agent Team 只做 review / audit(agent 不寫檔案) — jj 沒有價值
    • 穩定專案、強依賴 IDE Git 整合
    • 老 Git 肌肉記憶還在運作,且團隊只認 git

    考慮模式 3(git + jj for Agent Team merge)的訊號

    1. Agent Team 在共享檔案上撞車 — 你為了避免 merge 衝突開始序列化 Agent,生產力被 VCS 綁住
    2. Review round 產生批次修正 — 你寫過大量 fix: XXX-01/02/03/04/05 這種塞一堆 issue 的 commit,事後想拆卻沒工具
    3. AI 常常需要做 debug 試驗 — 你希望 AI 大膽試很多假設,但現在因為回退成本太高,限制了 agent 的自由度

    三個訊號都沒中?純 git 就夠了,別為了新鮮感切工具。

    我的具體建議:安全試用路徑

    1. 選一個個人實驗性專案,執行 jj git init --colocate(一次性,30 秒)
    2. 繼續用 git 做 99% 的事 — 你的肌肉記憶完全不變
    3. 下次真的要開 Agent Team 時,讓 orchestrator agent 用 jj 的 workspace + 非阻塞 rebase 處理合併階段
    4. 如果是 debug 場景,給 agent 「大膽試驗」的權限,利用 op log 保留推理紀錄
    5. 一個月後評估:有沒有真的遇到 jj 幫上忙的場景?沒有就卸載,git repo 完全不受影響

    跨專案的架構原則

    這次研究讓我提煉出一個跨專案適用的原則,寫進我的領域腦系統:

    VCS 的選型要跟「人類注意力的可用模式」匹配,不是跟「指令爽不爽」匹配。Git 假設人類可以同步在場,jj 允許人類異步在場。AI Agent 工作流打破了 Git 的第一個假設,但是否影響到你,取決於你的團隊形狀和工作型態,不取決於你對 AI 的熱情。

    更深一層的 insight:Git 的偉大不在於它的指令設計,而在於 content-addressed DAG 這個設計模式。這個模式你會在 IPFS、Nix、Docker layers、區塊鏈到處看到 — 懂 Git 底層等於懂了這一整類系統的通用結構。jj 的聰明,是看到這個模式可以往上再推一層,應用到操作本身,而不只是檔案狀態。

    AI 時代最大的認知陷阱是「新工具一定比舊工具好」,但真實世界是新舊工具的取捨點會隨場景變化。把場景描述清楚,比把工具吹捧清楚重要得多。下次你看到任何新的版本控制、儲存系統、分散式工具時,問自己這三個問題就好:它的不可變單位是什麼?怎麼定址?DAG 結構長怎樣?答案對上 content-addressed DAG 模式,你就已經懂 80% 了

  • Vibe Coding 協助物聯 AI 開發:從 RTSP 撞牆到 Telegram Bot 上線的完整實戰

    這是一篇關於 Vibe Coding(憑感覺邊聊邊寫)在物聯 AI 開發上的實戰紀錄。主題聽起來很酷,但實情是:我只是想解決一個很平凡的問題 —— 怎麼把家裡長照白板上的磁鐵打點,自動變成醫生回診時能看的資料。

    這篇會誠實記錄這一晚跟 AI 協作的過程、做對的決策、撞到的牆、以及學到的方法論。不是成功故事,中間我還沒跑出一份真正可用的資料。但我覺得過程本身比結果更值得寫下來。

    重點摘要

    • Vibe Coding 不是憑空想像,而是即時驗證 —— 每個假設都在對話中被一條 curl / 一行 ffmpeg 打臉或確認
    • Gemma 4 跟 Gemini 3 Flash Preview 在同一張模糊中文手寫白板上差距巨大:前者一直幻覺亂編項目,後者誠實回 null
    • Schema-constrained Prompt + 信心度分級是物聯 AI 應用的關鍵 —— 寧缺勿錯,比準度更重要
    • 最後撞到的不是模型牆,是光學物理牆 —— RTSP 1080p 遠距離下磁鐵只有 3~5 像素,任何 VLM 都無解
    • 與 AI 協作最大的價值是迭代速度:從想法 → curl 驗證 → 決策 → 下一步,一晚走完通常要一週的探索

    什麼是 Vibe Coding?為什麼它適合物聯 AI 開發

    Vibe Coding 是一種開發風格,核心精神是:不先寫規格、不先畫架構,先跟 AI 對話,在對話中讓問題結構自己浮現。聽起來很鬆散,但在物聯 AI 開發(IoT + AI 結合)的情境下,這種作法反而比傳統瀑布式規劃更有效。

    原因很簡單:物聯 AI 專案有太多無法事先確定的變數 —— 攝影機實際畫質、現場光線、模型對中文手寫的容忍度、API 認證流程的細節、現場使用者的真實習慣。這些東西你寫再多規格也寫不清,只能邊試邊改。

    傳統作法是先買硬體、接好線、寫一堆程式、跑起來才發現 OCR 讀不到。Vibe Coding 作法是:在對話中模擬整條管線,每一步都先驗證再決定下一步。成本極低,迭代極快。

    實戰情境:家母的長照交班白板

    先講背景。家裡照顧阿嬤的居服員會在客廳牆上用一塊大白板做交班記錄:夜間活動、睡前狀況、昨夜睡眠、早/午/晚餐、活動、外出、陪伴狀況、排泄狀況、洗澡,共 12 個項目。每一項下面有幾個選項方格,居服員把一顆彩色磁鐵貼在正確的選項上。

    用意是交班:早班居服員進門就知道夜間情況,中班看早上發生什麼事,晚班接手時看到整天脈絡。白板是「人看的 UI」,極度務實。

    我這邊的需求是:這些資料要每月匯整,讓神經內科醫師看到阿嬤這個月的生活趨勢 —— 是不是睡眠惡化、食慾變差、活動量下降。這關係到藥物調整。原本我每天晚上八點親自去拍照記錄到 iDempiere,但我很清楚一件事:

    靠人不穩定。我不能保證自己每天都拍照,請妹妹拍她都不拍。任何依賴人的環節最終都會斷掉。

    所以目標很明確:零人工依賴的自動化管線。RTSP 攝影機 → 抽幀 → VLM 識別白板狀態 → 寫進 iDempiere。今晚就是要從零對話出這套設計。

    第一步:用 ffmpeg 抓一張給 AI 看

    對話一開始,AI 想直接讀 RTSP 流,但我的 Ubuntu 上沒裝 ffmpeg,只有 VLC,而且 VLC 一直被 satip 模組攔截。試了幾個旗標都失敗。最後決定直接 apt-get install ffmpeg,然後:

    ffmpeg -y -rtsp_transport tcp \
      -i "rtsp://:@192.168.0.53:554/stream1" \
      -frames:v 1 -update 1 -q:v 2 /tmp/tapo/whiteboard_hd.jpg

    一張 1920×1080 的 frame 落地。第一個教訓:TAPO 有 stream1(主碼流,1080p)跟 stream2(子碼流,較低畫質),白板 OCR 一定要用 stream1,子碼流字跡會糊到任何模型都讀不出來。

    把第一張 frame 丟給 AI 看,AI 發現:

    • 畫面是廣角客廳全景,白板只佔左後方約 25%
    • 白板有角度(側視),字有透視變形
    • 阿嬤正躺在沙發上睡 —— 這畫面本身就能當「夜間睡眠」的訊號來源
    • 時間戳在左上角,直接可當記錄時間

    這是 Vibe Coding 的第一個價值:「看到了才知道要問什麼」。事先想像不到的細節(阿嬤就在沙發上、一顆鏡頭可同時驅動多個欄位),第一張實拍 frame 全部揭露。

    Gemma 4 vs Gemini 3 Flash Preview:一張圖的殘酷對比

    我手上的 Gemini API Key 支援 Google AI Studio,我問 AI 能不能用我已經在跑的 gemma-4-26b-a4b-it(我本來拿它做 HN 文章摘要)。AI 坦白說:不確定 Gemma 4 這個 MoE 版本有沒有 vision 能力,最好的方式是直接寫 script 試。

    寫完測下去,兩個結論:

    1. Gemma 4 確實吃圖 —— 這是好消息
    2. 但它嚴重幻覺 —— 它把日期讀成「4月10日」(實際 4/14)、項目名自創「睡眠品質/活動量/食事/盥洗」(白板上根本沒這些)、備註整段編造「各位家屬提醒…」。所有項目都回「綠」,明顯是在套公版答案

    切換到 gemini-3-flash-preview(Gemini 3 系列的 flash preview 版本),同一張圖,結果判若兩人:

    項目Gemma 4Gemini 3 Flash Preview
    項目名自創 5 個通用項目讀出 13 個具體項目
    備註段落純幻覺讀出「為了照護過程順暢請大家配合…飲食攝入小於 500ml 請 Line 通知…」
    品質差距低解析度下直接編造雖有 OCR 誤差但是「在讀真的東西」

    Gemini 3 Flash Preview 讀出的備註段落跟我白板上實際寫的紅字幾乎一致 —— 它不是在猜,是真的看到了。雖然還有誤差(把「外出狀況」讀成「外服藥記」、把「排泄」讀成「排便」),但那些是視覺相似字誤差,不是幻覺。

    這是第二個教訓:「同一張圖丟不同模型」是物聯 AI 應用最該做的前測。同價位的模型在模糊中文手寫這種邊緣任務上差距可以是幾倍級的。不要以為「我手上這把就夠用」。

    iDempiere REST API:從文件陷阱到 10 分鐘跑通

    OCR 能讀不代表能寫。寫入端是我自己建的 iDempiere,一張叫 Z_momSystem 的表。AI 不知道 schema,我也懶得翻 UI,直接讓它打 REST API 取。

    這裡踩第一個坑:iDempiere REST API 的認證流程文件跟實作不一致。我的 brain 筆記寫兩步驟(POST 拿暫時 token → PUT 帶 clientId 換 session token),但 AI 試了之後發現:直接在 POST 時一次帶齊所有 ID 也可以成功:

    POST /api/v1/auth/tokens
    {
      "userName": "",
      "password": "",
      "parameters": {
        "clientId": 1000000,
        "roleId": 1000000,
        "organizationId": 0,
        "warehouseId": 0,
        "language": "zh_TW"
      }
    }
    → 直接回 session token

    四個參數缺一不可,少 roleId 就回 Missing roleId parameter。這種小細節沒人會寫在文件裡,只能撞過才知道。AI 在對話中撞完就直接更新我的 brain file:

    [source: 長照 OCR 專案 2026/4/14] 驗證過 POST 一次帶齊 clientId/roleId/organizationId/warehouseId 可直接拿到可用 token,不用走 PUT 兩步。

    這是 Vibe Coding 第三個價值:踩到的坑立刻回饋到未來。不寫記錄的話,下個專案又會踩一次同樣的坑。

    Schema-Constrained Prompt:讓模型不能亂編

    認證跑通後,AI 直接查 AD_Column + AD_Ref_List,把 Z_momSystem 的 40 個欄位跟 12 個清單型欄位的所有合法選項抓齊:

    欄位合法選項(id → 中文)
    NightActivity 夜間活動C=完成 / N=未完成 / S=抗拒
    BeforeSleepStatus 睡前狀況X=亢奮 / N=正常 / S=疲倦
    LastNightSleep 昨夜睡眠G=良好 / S=斷續 / N=差
    Breakfast/Lunch/DinnerN=正常 / 10000001=少 / 10000009=多 / 10000002=拒食
    ExcretionStatus 排泄狀況10000006=正常 / 10000007=便秘 / 10000008=稀

    拿到完整選項清單後,我跟 AI 共同寫出 Prompt v2,核心設計有四塊:

    1. 嚴格列出 12 個欄位名稱 + 每個欄位的合法 enum,告訴模型「只能從清單裡選,不要自由發揮」
    2. 明確定義磁鐵規則:磁鐵貼在選項上 = 選中;磁鐵在左側待命區 / 沒看到磁鐵 = 未填(null)
    3. 強制輸出信心度:high / medium / low,並規定「寧可標 low 也不要亂猜」
    4. 禁止事項明確化:不要輸出清單外的項目、不要用清單外的值、不要把空白當選中

    這個 Prompt 模式我叫它 Schema-Constrained Prompt:輸出 schema 先於輸入內容確定。模型的自由度被壓縮到只剩「讀」,不能「想」。對資料入庫型應用來說,這是我目前看過最能壓住幻覺的作法。

    信心度機制:為什麼寧缺勿錯比高準度更重要

    我跟 AI 討論一個核心問題:如果 OCR 讀錯一個欄位,後果是什麼?答案是 —— 醫生根據錯誤資料調藥

    這條紅線決定了整個系統的預設行為:

    • High confidence → 直接寫入 iDempiere
    • Medium confidence → 寫入但標記為待確認,月底回診前我掃一次
    • Low confidence → 不寫,保留原圖,等我手動補

    這樣最差情況是「資料不全」而不是「資料錯誤」。對臨床情境來說,null 永遠比錯值安全

    最後的物理牆:磁鐵只有 3 像素

    所有準備工作做完,Prompt v2 在手、schema 對齊、認證跑通。我說「跑」,ffmpeg 抓一張新的 frame、裁切、送 Gemini 3 Flash Preview。結果回來:

    {
      "date": "4月10日",
      "patient_name": "",
      "items": {
        "NightActivity": {"value": null, "confidence": "high"},
        "BeforeSleepStatus": {"value": null, "confidence": "high"},
        ... 12 個項目全部 null ...
      },
      "overall_notes": "白板所有項目的磁鐵均位於最左側待命區,表示尚未填寫狀態。"
    }

    問題是 —— 我明明告訴 AI,最上面兩項的磁鐵是貼在選項上的。模型卻全讀成 null。這代表什麼?

    我們一起算了一下畫素:在當前 TAPO 攝影機位置,1080p 畫面中白板只佔約 25%,每個選項方格大概 40×20 像素,磁鐵大約 3~5 像素寬。

    任何 VLM 都讀不出 3~5 像素的物體位置。不是模型問題,是物理極限。

    這是整晚最重要的一個教訓。我原本以為換到 Gemini 3 就能解決一切,但真正的瓶頸不在模型,在光學。你沒辦法用演算法放大不存在的資訊。

    有意思的是,這個結論不是我預設的,是對話中算出來的。如果我沒跟 AI 一起 debug、一起看 crop、一起數像素,我可能還在抱怨「模型不夠強」,然後浪費錢去升級 model tier。實際上答案是「把鏡頭移近到 1.2 公尺」或「把磁鐵換大到 3 公分以上」—— 兩個都是硬體側的改動。

    Vibe Coding 在物聯 AI 開發上的六個心得

    整晚下來,我想整理出一些可以帶到下個專案的方法論:

    1. 第一張真實 frame 永遠比規格重要

    物聯 AI 專案第一件事不是畫架構圖,是用 ffmpeg / curl 抓一張真實資料給 AI 看。看到之後你才會發現「原來畫面裡還有沙發」、「原來字有角度」、「原來磁鐵只有 3 像素」—— 這些事事先想不到。

    2. 同一任務丟多個模型前測

    不要假設「手上這把就夠」。Gemma 4 跟 Gemini 3 Flash Preview 在同一張圖上差距巨大,如果我沒前測直接開始寫 production code,下游全部要重做。前測成本只有一個 Python script。

    3. Schema 先於 Prompt

    Prompt 不應該從「我想知道什麼」開始寫,應該從「我資料庫裡有什麼欄位、合法值是什麼」開始寫。把 enum 硬塞進 prompt,模型就只能從那個清單選。幻覺空間瞬間縮到 0。

    4. 信心度是一等公民

    每個欄位都要輸出信心度,而且模型要被明確告知「寧缺勿錯」。信心度不是錦上添花,是讓系統可以在「自動化」跟「人工審核」之間無縫切換的基礎設施。

    5. 踩到的坑要立刻寫回知識庫

    iDempiere REST API 的認證細節、TAPO stream1/stream2 的差異、VLM 對 3 像素物體的物理極限 —— 這些東西踩過一次就要寫下來。否則半年後你或你的下一個 AI 助手會原地再踩一次。

    6. 永遠先問「這個瓶頸是模型還是物理」

    當 AI 應用做不出效果時,工程師的直覺是「換更大的模型」。但物聯 AI 應用有一半以上的瓶頸是光學 / 聲學 / 感測器物理,不是模型。在升級 model tier 之前,先算一下原始訊號的解析度夠不夠。

    一晚的進度清單:完成、撞牆、下一步

    分類項目狀態
    環境ffmpeg + venv + google-genai SDK
    模型選型gemini-3-flash-preview 確認為白板 OCR 唯一可用
    API 認證iDempiere REST auth flow + brain 更新
    SchemaZ_momSystem 40 欄位 + 12 清單欄位合法值
    Promptv2 schema-constrained + 信心度
    實拍 OCR12 項全 null(物理極限)
    下一步鏡頭移近至白板前 ~1.2m 或把磁鐵換大🔜

    尾聲:AI 協作不是減少思考,是加速思考

    這一晚最大的收穫不是程式,是幾個原本需要好幾天才能驗證的判斷,在對話中幾個小時就走完了:

    • 「Gemma 4 能不能做 vision OCR」—— 10 分鐘驗證完
    • 「iDempiere REST API 認證怎麼打」—— 15 分鐘跑通
    • 「Z_momSystem schema 長怎樣」—— 5 分鐘 curl 查完
    • 「Prompt 用什麼結構壓住幻覺」—— 30 分鐘寫完 + 測完
    • 「OCR 失敗的根本原因是什麼」—— 從「模型不夠強」修正到「物理像素不夠」

    如果自己慢慢摸,這些決策可能得花一個禮拜,還未必會發現最後那個「像素物理牆」的結論。

    但 Vibe Coding 也不是萬靈丹。它的前提是你自己要會即時判斷 AI 給的答案對不對。如果我不知道「1080p 遠距離下一顆磁鐵只有幾像素」該怎麼算,我不會發現光學才是真兇,可能還會繼續跟 AI 討論要換什麼模型。AI 負責快速生成假設跟驗證工具,但關掉討論的那個人還是我

    下一步很清楚:明天白天我會去把攝影機搬到白板正對面 1.2 公尺處,如果還不夠,就把磁鐵換成 3 公分的大顆粒。然後再跑一次 Prompt v2,看看準度能衝到 8/12 還是 12/12。

    等驗證完整條管線跑通,我會再寫第二篇:零人工依賴的長照自動化 —— 從 OCR 到 iDempiere 到每月回診的完整管線。這一篇先停在「撞到物理牆」這裡,因為這個結論本身就夠值得記下來了。

    後續實戰:從撞到物理牆到 Telegram Bot 上線

    前一篇的結尾停在「撞到物理牆」,隔天白天我接著實戰。這一段記錄從 PTZ 鏡頭控制、模型賽馬、到最後關鍵 pivot 的完整過程。

    後續重點摘要

    • ONVIF PTZ 跑通:TAPO C200 用 RTSP 同帳密就能從 ONVIF 控制 pan/tilt,存 preset 在「看媽媽」跟「看白板」間自動切換
    • 但物理牆還在:C200 沒有 ONVIF zoom,白板在畫面裡的絕對大小被鏡頭距離鎖死
    • 模型賽馬揭露真相:Gemma 4 / Gemini 2.5 Flash 會套預設答案假裝在看;只有 Gemini 3 Flash Preview 跟 2.5 Pro 真的「在看」
    • 關鍵 pivot:放棄 RTSP 自動化,改走「手機拍照 + Telegram Bot」,把 human 設計進 loop 當 verifier
    • 最後上線:Telegram Bot + Gemini 3 Flash Preview + iDempiere REST API + systemd service,免費版每日 20 次額度剛好夠 production 用

    第一回合:ONVIF PTZ 成功,但沒有 zoom

    既然 TAPO C200 是 PTZ 型號(有 pan/tilt 馬達),自然想法就是「平常看媽媽、每天 20:00 轉過去拍白板、拍完轉回來」。代價幾乎為零:不改位置、不買硬體、不影響監護。

    TAPO 的本地 API 很頭痛,新版韌體要另外設定「Camera Account」。試了 pytapo 套件一直認證失敗。最後發現 ONVIF 標準協定可以直接用 RTSP 同帳密,port 2020:

    from onvif import ONVIFCamera
    cam = ONVIFCamera("192.168.0.53", 2020, "", "")
    ptz = cam.create_ptz_service()
    media = cam.create_media_service()
    token = media.GetProfiles()[0].token
    
    # Save current position as preset
    req = ptz.create_type("SetPreset")
    req.ProfileToken = token
    req.PresetName = "mom"
    ptz.SetPreset(req)
    
    # Move to whiteboard preset
    req2 = ptz.create_type("GotoPreset")
    req2.ProfileToken = token
    req2.PresetToken = "2"   # whiteboard preset token
    ptz.GotoPreset(req2)

    30 分鐘內建好兩個 preset:mom (pan=0.165, tilt=-0.714) 看沙發、whiteboard (pan=0.2, tilt=-0.3) 看白板。自動切換、抽幀、切回,全程 10 秒內。

    但查 PTZ 能力的時候發現一個殘酷事實:

    AbsoluteZoomPositionSpace: []

    C200 的數位 zoom 不對 ONVIF 開放。pan/tilt 能改變「看哪裡」,但不能改變「看多近」。白板在畫面裡的絕對大小還是被物理距離鎖死,磁鐵還是只有幾個像素。

    這是今天的第一個重要教訓:即使有 PTZ,沒有 zoom 的 PTZ 解決不了光學採樣率不足的問題。pan/tilt 的價值在於切換主題,不是放大細節。

    第二回合:四個模型的殘酷賽馬

    既然 PTZ 轉過去也只是切換主題、不會變清楚,我決定做一件更有意義的事:在同一張困難的 RTSP 照片上,把所有可能的模型跑一遍,看哪個真的「在看」。

    測試方法:請媽媽的居服員確認白板上 12 個項目的真實狀態(4 個 box 1、2 個 box 2、6 個 null),這是 ground truth。同一張 RTSP 照片丟給四個模型,計算準度。

    模型命中行為模式
    Gemini 2.5 Pro8/12 (67%)6/6 null 正確、off-by-one 錯誤,真的有在讀像素
    Gemini 3 Flash Preview6/12非常保守,看不清就全回 null(這在醫療情境反而安全)
    Gemini 2.5 Flash4/12預設 box 1,剛好對 4 個 box 1 項目,是幻覺而不是識別
    Gemma 4 (26B-A4B-IT)3~4/12兩次跑結果不一樣:第一次全 box 2、第二次全 box 1

    最有意義的發現:跑同一張圖兩次讓偽答案現形。Gemma 4 第一次答「10 項 box 2 + 1 項 box 1 + 1 項 null」,第二次答「12 項 box 1」。兩次完全不同,同一張靜態圖,同一個 prompt。這證明 Gemma 4 根本沒在看磁鐵,只是在套一個預設樣板。

    便宜模型「預設 box 1」這個偏誤特別危險,因為它剛好能騙過不注意的測試者:真實世界裡大部分項目本來就選 box 1(例如「完成/正常/穩定」通常是第一格),所以「全答 box 1」天然就有 30~50% 命中率。

    Gemini 2.5 Pro 的 8/12 表現是最高的,錯誤都是「off-by-one」(看到磁鐵、算錯格子),至少是在做真正的視覺識別。但 2.5 Pro 免費版不開放 vision,要付費才能用。

    Gemini 3 Flash Preview 的 6/12 看起來輸給 2.5 Pro,但它的錯誤類型完全不一樣:它的「錯」是把有磁鐵的項目讀成 null,不會產生錯誤的 value。這在醫療場景反而是最安全的行為 —— 寧缺勿錯永遠優於硬填。

    第三回合:投票沒用、upscale 沒用

    既然 Gemini 2.5 Pro 有 67% 準度,同一張圖跑 3 次取多數票應該能把 67% 拉高吧?

    結果投票後掉到 6/12。為什麼?因為 2.5 Pro 的錯誤不是隨機噪音,是系統性偏誤:某個項目 3 次都答同一個錯的答案。投票對系統性錯誤完全沒用,它只會把多數的錯誤鎖定成最終答案。

    接著試 pre-upscale:先用 ffmpeg 把白板區域裁出來、用 Lanczos 4 倍放大、加 unsharp,再丟 Gemini。直覺上放大應該有用,對吧?

    ffmpeg -i wb.jpg -vf "crop=720:820:480:30,scale=iw*4:ih*4:flags=lanczos,unsharp=7:7:1.5" wb_up.jpg

    結果:2.5 Pro 從 8/12 掉到 6/12,3 Flash Preview 從 6/12 掉到 2/12。放大反而讓準度下降。

    事後想通:Lanczos 是重採樣,不是超解析。它只是讓相鄰像素平滑插值,沒有增加資訊量。原本 3 像素的磁鐵放大成 12 像素,但那 12 像素是模糊的平均值,模型反而更難從中辨認形狀。

    要真的增加資訊量必須用 AI 超解析(Real-ESRGAN 這類),而不是幾何插值。但那會在機器上增加 PyTorch + 模型的負擔。與其走這條,不如直接面對問題:RTSP 這條路本身就有上限

    關鍵 Pivot:把 human 設計進 loop

    這是今天最重要的決策點。

    我原本堅持 RTSP 全自動,理由是「靠人不穩定,請妹妹拍她都不拍」。這個理由本身沒錯 —— 強制依賴他人的流程一定會斷。但我把「靠他人」跟「靠自己」混為一談了。我自己每天可以拍照,不穩定的只是「依賴別人」這一環

    更深層的領悟:為什麼執著於「100% 自動」?因為我下意識想把人完全排除。但對一個給醫生回診參考的月報系統來說,人的角色不該是全被排除,而應該是最後一道驗證。理由:

    • OCR 永遠有錯,醫療資料的錯誤比缺失代價更高
    • 家屬(我)本來就是最終看月報的人,有修正權限跟動機
    • 拍照的動作本身就是「我已經確認白板內容正確」的確認點
    • 沒有必要為了系統 purity 去承擔「誤導醫生」的風險

    這是整個專案最有價值的 design pattern 轉變 —— 從「zero-human automation」到「human-as-verifier」。具體分工:

    角色職責
    我(家屬)看白板、判斷、拍清楚的照片、事後在 iDempiere 修正 OCR 誤判
    Bot + Gemini 3把照片轉成結構化資料寫進 iDempiere、附上原檔
    iDempiere永久保存、提供 UI 讓月底回診時查閱

    最可怕的情境 —— OCR 幻覺亂寫進 DB 誤導醫生 —— 被「我本來就要檢查 iDempiere」這個 policy 擋住了。系統可以不完美,因為人在迴圈裡。

    Telegram Bot 架構

    決定走 human-in-the-loop 後,剩下的工程問題是「怎麼把手機拍的照片快速送到 server」。選項比較:

    方案優點缺點
    Telegram Bot零 app 安裝、即時回饋、從任何地方都能傳要建 bot token
    Syncthing 資料夾同步純本機、無雲端要裝 app + 手動移動檔案
    自架 HTTP 上傳頁不用 app要開 browser、每次登入

    Telegram 明顯最低摩擦,而且我本來就用 Telegram 傳照片 debug。一個 @BotFather 建 bot、5 分鐘拿到 token、寫一個 Python 長輪詢腳本就搞定。

    Bot 的最終能力:

    使用者動作Bot 行為
    📸 傳白板照片Gemini 3 Flash Preview OCR → 找今天紀錄 → update 或 create → 附加照片 → drain 文字佇列
    📝 傳 LINE 居服員回報文字[SHIFT|HH:MM] 前綴 → prepend 到今天紀錄的 Description 欄位
    🕑 今天還沒紀錄時傳文字存 pending 佇列,等照片來時自動塞入
    /pending, /clear, /id管理指令

    Shift 標記:用時段而不是時間點

    iDempiere 既有資料的 Description 欄位有一個我沒注意到的約定格式:

    [NIGHT|20:13]今天把東西往外丟
    [DAY|17:56]攻擊居服員

    格式是 [SHIFT|HH:MM]事件,新的在最上面,\n 分隔。照顧者三班制:

    • DAY(早班):07:00-17:59
    • NIGHT(晚班):18:00-23:59
    • GRAVEYARD(大夜):00:00-06:59

    Bot 根據當下時間自動產生正確的 shift 標記。這個設計好處是醫生看月報時能直接對應到「哪一班回報的事件」,這對失智照護尤其重要 —— 同一個病人,不同班次的表現可能完全不同(例如日落症候群)。

    Find-or-Create:同一天一筆紀錄

    一個設計決策:同一天傳多次照片要建新紀錄還是更新舊的?

    選項:

    • (a) 每次都 create 新的 → iDempiere 一天可能有多筆,報表聚合需要 GROUP BY 日期
    • (b) 同天 find-or-update → 資料乾淨,但要實作 OData filter 查當天紀錄
    • (c) 帶時序保留所有版本 → 最完整但最複雜

    選 (b)。理由是月底回診時醫生看的是「今天的狀態」,不需要看一天之內的修改歷史。實作核心:

    def idempiere_find_today(token):
        date_str = datetime.now().strftime("%Y-%m-%d")
        r = requests.get(
            f"{IDEMPIERE_URL}/models/z_momsystem",
            headers={"Authorization": f"Bearer {token}"},
            params={"$filter": f"DateDoc eq '{date_str}' and Name eq '{PATIENT_NAME}'", "$top": 1},
        )
        recs = r.json().get("records", [])
        return recs[0]["id"] if recs else None

    還有一個巧妙設計:OCR 回 null 的欄位不進 PUT payload。這樣早上拍一次只填前 6 項、晚上拍一次只改後 6 項,早上的資料不會被晚上的 null 洗掉。整個合併邏輯在 Python 端就搞定,iDempiere 端只接受「明確填入」的欄位。

    iDempiere 附件上傳的小坑

    iDempiere REST API 的附件端點是 POST /models/{table}/{id}/attachments,payload 是 {"name": "...", "data": "base64..."}。踩了一個坑:重複檔名會回 409

    解法:上傳時把當下的 HHMMSS 塞進檔名 suffix。同一張照片重複上傳也不會衝突:

    ts = datetime.now().strftime("%H%M%S")
    fn = f"{orig.stem}_up{ts}{orig.suffix}"

    Systemd Service 自動重啟

    最後收尾:bot 用 systemd service 跑,開機自動啟動 + 崩潰自動重啟。關鍵是設定 Restart=on-failureRestartSec=10

    [Unit]
    Description=Tapo Caregiver Telegram Bot
    After=network-online.target
    Wants=network-online.target
    
    [Service]
    Type=simple
    User=tom
    WorkingDirectory=/home/tom/tapo-caregiver
    ExecStart=/home/tom/tapo-caregiver/venv/bin/python telegram_bot.py
    Restart=on-failure
    RestartSec=10
    Environment=PYTHONUNBUFFERED=1
    
    [Install]
    WantedBy=multi-user.target

    成本:免費版剛好夠用

    這個專案我原本因為 debug 爆量升級了 Google AI Studio 付費版。但實際跑 production 的時候算了一下用量:

    • 晚上 8 點拍一次照片 = 1 次 Gemini 3 Flash Preview 呼叫
    • LINE 回報文字 = 0 次(純文字不進 OCR,只是 prepend 到 Description)
    • 一天總用量:1~2 次

    Gemini 3 Flash Preview 的免費 tier 是 每天 20 次GenerateRequestsPerDayPerProjectPerModel-FreeTier, limit: 20, model: gemini-3-flash),實際從先前的 429 錯誤訊息確認過。

    也就是說 完整上線後可以馬上取消付費,免費額度就夠日常跑。debug 階段的付費只是為了不被 quota 卡住開發速度。

    兩天下來的反思

    1. 物理限制不是模型問題,不要用演算法去試

    RTSP 畫面中磁鐵 3~5 像素這件事,不管換什麼模型、不管怎麼放大、不管怎麼 prompt,都解決不了。我浪費了兩輪嘗試才認清:先驗證訊號源的解析度是否足夠,再決定上面要疊什麼演算法

    2. 便宜模型會偽裝在看

    Gemma 4 和 Gemini 2.5 Flash 在困難任務上會套預設答案(全 box 1 或全 box 2),這種偽答剛好能騙過不注意的準度統計。同一張圖跑兩次是揭穿偽答最便宜的方法 —— 真的在看的模型答案應該高度一致,套公版的會變。

    3. 投票救不了系統性偏誤

    Gemini 2.5 Pro 跑 3 次投票準度從 8/12 掉到 6/12,因為它的錯誤是系統性的(3 次都答同一個錯的答案)。投票只對隨機噪音有用,對系統性偏誤沒用。想救系統性偏誤,要用不同模型交叉比對,而不是同一個模型多次跑。

    4. Human-in-the-loop 不是妥協,是正確設計

    我原本想做 100% 自動化是出於「系統純粹」的直覺。但對醫療情境來說,把人排除才是風險來源。正確問題不是「怎麼不靠人」而是「人在哪個環節最有價值」。答案是:人當最終 verifier,機器做勞力密集的 OCR + 結構化 + 儲存。

    5. 工具思維 vs 平台思維

    我原本想把 RTSP 攝影機變成一個「全知白板記錄系統」,這是平台思維,野心大但難。pivot 之後變成「Telegram bot 幫我打字少打一些」,這是工具思維,目標小但能跑。工具思維的上線速度 ≫ 平台思維的完美程度

    6. 讓 AI 自己跑模型賽馬

    過程中我讓 AI 自動跑一圈 Gemma 4 / 2.5 Flash / 2.5 Pro / 3 Flash Preview,把命中率、錯誤類型、各自的 observation 都列出來。我只要看那張表就能判斷用哪個。這種「讓 AI 幫你比較 AI」的 meta-loop 是 vibe coding 很爽的一點 —— 選型決策從「讀文件猜性能」變成「直接實測算分」。

    最終上線狀態

    組件狀態
    Telegram bot(@your_bot_name,白名單限制)✅ LIVE
    Gemini 3 Flash Preview OCR (schema-constrained + box index + layout map)
    iDempiere REST find-or-create + attachment upload
    Description shift tag (DAY/NIGHT/GRAVEYARD) 自動 prepend
    pending notes queue(文字先到、照片後到的情境)
    systemd service 開機自動啟動
    PTZ ONVIF 控制(暫時不用,因為 pivot 到手機路線)✅ 備用

    每日 SOP:

    1. 晚上 8 點我拍一張白板照片
    2. Telegram 分享給 @your_bot_name
    3. Bot 跑 OCR + 寫 iDempiere + 回覆結果(10~20 秒)
    4. 看到有讀錯的,打開 iDempiere 手動改
    5. 居服員白天在 LINE 的回報,我看到時直接貼到 bot,自動 prepend 到今天的 Description
    6. 月底回診時點開 iDempiere 給醫生看趨勢

    總結:這篇文章的「二階 vibe coding」

    這篇文章本身也是 vibe coding 的產物 —— 我叫 AI 把兩天的工作回溯成一篇技術文,自己只負責審核跟指正敏感資料。這種「AI 幫你跟 AI 協作的過程寫回顧」也算是一種 meta-loop:決策的當下有 AI 幫忙驗證,事後有 AI 幫忙紀錄。我負責方向跟價值判斷,剩下的雜活都給 AI。

    兩天下來最重要的一句話:「vibe coding 不是減少思考,是加速思考。」 AI 幫你快速驗證假設、生成原型、寫測試、跑實驗、甚至幫你寫反思文章。但你要知道該往哪個方向走、什麼時候該 pivot、什麼時候該停手。AI 是踏板車,你是駕駛。

  • 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)才能真正發揮。

  • 訓馬筆記:兩個月把 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 之前先建好兩份文

  • LangGraph 多模型實戰:從零到 Production 的完整教學

    重點摘要

    • LangGraph 讓你把不同 AI 模型串成自動化流水線:Claude 負責「想」,Groq Llama 負責「寫」,各司其職
    • 本文從零開始,帶你走完四個階段:基本流水線 → 智慧路由 → Streaming + 容錯 → FastAPI 部署
    • 每個階段都有完整可執行的程式碼,跟著做就能跑
    • 核心區別:Claude Code / Cursor 是「你的工具」,LangGraph 是「你造工具的材料」——當你要造產品給別人用時才需要它
    • 實測結果:比全用 Claude Sonnet 省 75% 成本,同時保留深度分析的品質

    這篇文章是給誰看的?

    如果你符合以下任一情況,這篇文章就是為你寫的:

    • 你想做一個 AI chatbot(客服、內部助手、產品功能),但不知道怎麼開始
    • 你已經在用 Claude / GPT,但覺得全部用最貴的模型太浪費錢
    • 你聽過「多模型協作」但不確定具體怎麼實作
    • 你想知道 LangGraph 跟你平常用的 Claude Code / Cursor / Copilot 到底差在哪

    本文從零開始,帶你走完四個階段,每個階段都有完整可執行的程式碼。你可以在任何一個階段停下來——不是每個人都需要走到 production。

    先搞清楚:這跟 Claude Code / Cursor / Copilot 完全不同

    在往下讀之前,先釐清最容易搞混的一件事:

    面向 Claude Code / Cursor / Copilot LangGraph
    本質 開發者工具 — 你用它寫 code 開發框架 — 你用它造產品
    使用者是誰 你自己(開發者) 你的客戶 / 你的系統 / 你的團隊
    使用場景 日常寫程式、debug、重構 建 chatbot、自動化流程、API 服務
    互動方式 人 ↔ AI 即時對話 程式碼自動跑,可以無人值守
    模型選擇 工具幫你選好(通常固定一個) 你自己決定哪個步驟用哪個模型
    計費方式 月費訂閱(工具包了) 純 API 按量計費,你自己控制成本

    用一個比喻:Claude Code 是你請了一個很強的工程師坐在旁邊幫你寫 code;LangGraph 是你在蓋一條自動化產線,產線上有不同的機器人各司其職

    如果你只是日常寫程式,用 Claude Code 就好,不需要 LangGraph。往下讀之前,確認你的需求是「造一個東西給別人用」,而不是「讓自己寫 code 更快」。

    為什麼不同的 AI 模型要分工合作?

    沒有一個模型什麼都最好。每個模型有不同的強項和定價:

    能力 Claude Sonnet Groq Llama 70B Groq Llama 8B
    架構設計 / Code Review ⭐ 最強 普通
    程式碼生成 ⭐ 強且快 普通
    簡單問答 大材小用 大材小用 ⭐ 夠用且極便宜
    生成速度 ~50 tok/s ~300 tok/s ~800 tok/s
    費用 (Output/1M tokens) $15.00 $0.79 $0.08

    核心邏輯:讓擅長「想」的模型去想,讓擅長「做」的模型去做,讓便宜的模型處理瑣事。就像軟體團隊裡,架構師出規格、工程師寫程式、實習生回答簡單問題一樣。

    LangGraph 是什麼?一分鐘看懂

    LangGraph 是 LangChain 團隊開發的有狀態流程編排框架。三個核心概念:

    1. Node(節點)— 一個步驟,比如「用 Sonnet 設計」或「用 Llama 實作」
    2. Edge(邊)— 步驟之間的連線,決定「做完 A 接著做 B」
    3. State(狀態)— 所有節點共享的資料,比如設計規格、程式碼、審查結果

    把這三個組合起來,就是一個可以自動跑的流水線。你定義「什麼步驟做什麼、什麼條件走什麼路」,框架負責執行。

    環境準備(所有階段共用)

    開始之前,你需要準備兩個 API key 和安裝套件。這一步做完,後面四個階段都不用再設定。

    1. 取得 API Key(兩個都有免費額度)

    ⚠️ 注意:這是 API key,跟 Claude.ai 的月費訂閱、GitHub Copilot 的訂閱完全無關。API 是按用量計費的。

    2. 建立專案

    mkdir langgraph-duo && cd langgraph-duo
    
    # 安裝套件
    pip install langgraph langchain-anthropic langchain-groq python-dotenv
    
    # 建立 .env 檔案(填入你的 key)
    cat > .env << 'EOF'
    ANTHROPIC_API_KEY=sk-ant-xxxx
    GROQ_API_KEY=gsk_xxxx
    EOF

    3. 驗證連線

    python3 -c "
    from dotenv import load_dotenv; load_dotenv()
    from langchain_anthropic import ChatAnthropic
    from langchain_groq import ChatGroq
    
    sonnet = ChatAnthropic(model='claude-sonnet-4-6', max_tokens=50)
    llama = ChatGroq(model='llama-3.3-70b-versatile', max_tokens=50)
    
    print('Sonnet:', sonnet.invoke('Say OK').content)
    print('Llama:', llama.invoke('Say OK').content)
    "

    兩行都印出 OK,就可以開始了。

    第一階段:Duo 流水線(設計 → 實作 → 審查)

    目標:讓 Claude Sonnet 設計規格,Groq Llama 寫程式碼,Sonnet 再審查品質。審查不通過自動重做,最多 3 次。

    適合場景:批量生成程式碼、自動化 code review、需要品質把關的程式碼生成。

    Task → [Sonnet 設計] → [Llama 實作] → [Sonnet 審查]
                                ↑               |
                                └── 未通過 ──────┘  (最多 3 次)
                                      ↓ 通過
                                     END

    完整程式碼:duo.py

    from typing import TypedDict
    from dotenv import load_dotenv
    from langgraph.graph import StateGraph, END
    from langchain_anthropic import ChatAnthropic
    from langchain_groq import ChatGroq
    from langchain_core.messages import HumanMessage, SystemMessage
    
    load_dotenv()
    
    # 模型分工:Sonnet 想,Llama 做
    sonnet = ChatAnthropic(model="claude-sonnet-4-6", max_tokens=4096)
    llama = ChatGroq(model="llama-3.3-70b-versatile", max_tokens=4096)
    
    # 所有節點共享的狀態
    class AgentState(TypedDict):
        task: str             # 原始需求
        design: str           # Sonnet 的設計規格
        code: str             # Llama 的實作
        review: str           # Sonnet 的審查結果
        revision_notes: str   # 修改指示(給重試用)
        approved: bool        # 是否通過
        attempt: int          # 重試次數
    
    MAX_ATTEMPTS = 3
    
    # Node 1: Sonnet 設計
    def design_node(state):
        response = sonnet.invoke([
            SystemMessage(content="You are a senior architect. Produce a precise technical spec with function signatures, edge cases, and pseudocode."),
            HumanMessage(content=f"Request: {state['task']}")
        ])
        return {"design": response.content}
    
    # Node 2: Llama 實作
    def implement_node(state):
        prompt = f"Spec:\n{state['design']}"
        if state.get("revision_notes"):
            prompt += f"\n\nFix these issues:\n{state['revision_notes']}"
        response = llama.invoke([
            SystemMessage(content="Implement per spec. Output only Python code."),
            HumanMessage(content=prompt)
        ])
        return {"code": response.content, "attempt": state.get("attempt", 0) + 1}
    
    # Node 3: Sonnet 審查
    def review_node(state):
        response = sonnet.invoke([
            SystemMessage(content="Review code vs spec. Reply VERDICT: APPROVED or REJECTED with details."),
            HumanMessage(content=f"Spec:\n{state['design']}\n\nCode:\n{state['code']}")
        ])
        review = response.content
        approved = "APPROVED" in review.upper()
        return {"review": review, "approved": approved,
                "revision_notes": "" if approved else review}
    
    # 條件路由:通過 → 結束,未通過 → 回去重做
    def should_continue(state):
        if state["approved"] or state["attempt"] >= MAX_ATTEMPTS:
            return "end"
        return "revise"
    
    # 組裝 Graph
    workflow = StateGraph(AgentState)
    workflow.add_node("design", design_node)
    workflow.add_node("implement", implement_node)
    workflow.add_node("review", review_node)
    workflow.set_entry_point("design")
    workflow.add_edge("design", "implement")
    workflow.add_edge("implement", "review")
    workflow.add_conditional_edges("review", should_continue,
                                   {"end": END, "revise": "implement"})
    app = workflow.compile()
    
    # 跑!
    result = app.invoke({
        "task": "Write a Python function that reads a CSV and returns column averages as a dict",
        "design": "", "code": "", "review": "",
        "revision_notes": "", "approved": False, "attempt": 0,
    })
    
    print("=== CODE ===")
    print(result["code"])
    print(f"\n{'✅ APPROVED' if result['approved'] else '⚠️ BEST EFFORT'} after {result['attempt']} attempt(s)")

    實測結果

    階段 模型 結果
    🧠 Design Claude Sonnet 4.6 產出 4 個參數、10 個 edge case、9 步 pseudocode 的完整規格
    ⚡ Implement Groq Llama 70B 完整 Python 函數,含 type hints、docstring、error handling
    🔍 Review Claude Sonnet 4.6 VERDICT: APPROVED — 第一次就通過,沒有重試

    到這裡你已經有一個能跑的多模型流水線了。如果你的需求是「批量生成程式碼 + 自動品質把關」,可以停在這個階段。

    第二階段:Smart Router(自動選模型的聊天機器人)

    目標:做一個像 ChatGPT 一樣的對話介面,但底下不是固定一個模型,而是自動根據問題類型選最適合的模型。

    適合場景:客服 chatbot、團隊內部 AI 助手、需要控制 API 成本的聊天服務。

    你的問題 → [Llama 8B 分類器] → 判斷類型
                                      |
                      ┌────────────────┼────────────────┐
                      ↓                ↓                ↓
                [Claude Sonnet]  [Llama 70B]      [Llama 8B]
                 深度分析          寫程式            簡單問答
                      ↓                ↓                ↓
                      └────────────────┼────────────────┘
                                       ↓
                                    回答你

    分類器怎麼運作?

    分類器用最便宜的 Llama 8B(每次呼叫不到 $0.0001)讀取問題,輸出一個 JSON 判斷結果。分類規則:

    分類 路由到 觸發條件 費用 (Output/1M)
    🧠 深度思考 Claude Sonnet 架構分析、比較權衡、code review、規劃 $15.00
    ⚡ 寫程式 Llama 70B 實作函數、生成腳本、重構、修 bug $0.79
    💨 快速回答 Llama 8B 打招呼、簡單問答、定義、基礎算數 $0.08

    核心程式碼

    import json
    from langgraph.graph import StateGraph, END
    
    # 分類器:Llama 8B 讀問題,判斷該走哪條路
    def router_node(state):
        response = llama_8b.invoke([
            SystemMessage(content="""Classify into one category:
    - "sonnet": complex reasoning, analysis, architecture
    - "llama_70b": write code, implement, fix bugs
    - "llama_8b": greetings, simple facts, casual chat
    Reply ONLY JSON: {"route": "...", "reason": "..."}"""),
            HumanMessage(content=state["question"])
        ])
        parsed = json.loads(response.content)
        return {"route": parsed["route"]}
    
    # 條件路由:根據分類結果,走不同的回答節點
    workflow = StateGraph(RouterState)
    workflow.add_node("router", router_node)
    workflow.add_node("sonnet", answer_sonnet)
    workflow.add_node("llama_70b", answer_llama_70b)
    workflow.add_node("llama_8b", answer_llama_8b)
    
    workflow.set_entry_point("router")
    workflow.add_conditional_edges("router", lambda s: s["route"], {
        "sonnet": "sonnet",
        "llama_70b": "llama_70b",
        "llama_8b": "llama_8b",
    })
    app = workflow.compile()

    實測分類準確度:7/7

    問題 路由結果 正確?
    Hi! 💨 Llama 8B
    1+1=? 💨 Llama 8B
    Write a Python quicksort ⚡ Llama 70B
    Compare microservices vs monolith 🧠 Sonnet
    What is Docker? 💨 Llama 8B
    幫我寫一個 REST API ⚡ Llama 70B
    分析 Redis cache vs CDN 優缺點 🧠 Sonnet

    到這裡你有一個能自動選模型的聊天機器人了。但它還缺兩個東西:回答會一次全部吐出(不是逐字顯示),而且 Groq 掛了就整個壞掉。第三階段解決這兩個問題。

    第三階段:Streaming + Fallback(讓它不會掛)

    目標:回答逐字出現(像 ChatGPT 一樣流暢),而且模型掛了自動切換備用模型。

    為什麼這一步很重要:沒有 Streaming 的 chatbot,使用者體驗像 2010 年的網頁——按下送出,等 5 秒,突然一大段文字出現。沒有 Fallback 的服務,Groq 一限速(免費版每分鐘 30 次),你的整個服務就掛了。

    Streaming:體感延遲降 25 倍

    方式 使用者體驗 程式碼差別
    invoke()(非串流) 等 5 秒 → 突然出現整段文字 response = model.invoke(messages)
    stream()(串流) 0.2 秒開始出字 → 像打字一樣流暢 for chunk in model.stream(messages)
    # 只需要把 .invoke() 改成 .stream()
    # 然後迭代每個 chunk 即時輸出
    
    for chunk in model.stream(messages):
        print(chunk.content, end="", flush=True)  # flush=True 強制即時顯示

    Fallback:模型掛了自動切換

    主要模型 Fallback 模型 切換代價
    🧠 Sonnet ⚡ Llama 70B 分析品質略降,速度更快
    ⚡ Llama 70B 🧠 Sonnet 速度略慢,品質更高
    💨 Llama 8B ⚡ Llama 70B 稍慢稍貴,但一定能回答
    # Fallback 模式:try 主要模型,失敗自動切備用
    try:
        for chunk in primary_model.stream(messages):
            print(chunk.content, end="", flush=True)
    except Exception:
        print("⚠️ Primary failed, switching to fallback...")
        for chunk in fallback_model.stream(messages):
            print(chunk.content, end="", flush=True)

    完整的 router_stream.py 把 Streaming + Fallback + 對話歷史 + 使用統計整合在一起,不到 200 行。跑起來就是一個帶有自動模型切換的 terminal 聊天機器人。

    到這裡你有一個穩定的、體驗流暢的聊天機器人了。但它還是跑在你的 terminal 裡,只有你能用。第四階段讓它變成任何人都能呼叫的 API 服務。

    第四階段:FastAPI 部署(讓別人能用)

    目標:把 chatbot 包成 HTTP API,讓網頁、App、Line bot、Slack bot 都能呼叫。

    為什麼是 FastAPI:Python 生態最主流的 API 框架,原生支援 async、自動生成 API 文件、社群龐大。

    API 端點設計

    端點 方式 適合場景
    POST /chat 非串流,回傳完整 JSON Slack/Line bot、後端呼叫、批次處理
    POST /chat/stream SSE 串流,逐 token 推送 網頁聊天窗、需要即時體感的 UI
    GET /health 健康檢查 負載平衡器、監控系統
    GET /sessions/{id} 取得對話歷史 Debug、對話紀錄查詢
    DELETE /sessions/{id} 清除對話 使用者開始新對話

    啟動與測試

    # 安裝額外套件
    pip install fastapi uvicorn sse-starlette
    
    # 啟動 server
    python server.py
    # → 🤖 Smart Router API running on http://localhost:8900
    
    # 測試非串流
    curl -X POST http://localhost:8900/chat \
      -H "Content-Type: application/json" \
      -d '{"message": "什麼是 Docker?", "session_id": "test-user"}'
    
    # 回應範例:
    # {
    #   "answer": "Docker 是一個容器化平台...",
    #   "model_used": "llama_8b",
    #   "route_reason": "simple definition question",
    #   "session_id": "test-user",
    #   "fallback_used": false,
    #   "elapsed_seconds": 0.85
    # }

    前端怎麼接 SSE 串流?

    如果你要做網頁聊天窗,前端只需要幾行 JavaScript:

    // 瀏覽器原生 EventSource API
    const response = await fetch('/chat/stream', {
      method: 'POST',
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({message: '寫一個排序函數', session_id: 'user-1'})
    });
    
    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    
    while (true) {
      const {done, value} = await reader.read();
      if (done) break;
      const text = decoder.decode(value);
      // 每個 chunk 到了就即時顯示在畫面上
      document.getElementById('chat').innerHTML += text;
    }

    為什麼用 SSE 而不是 WebSocket?

    聊天場景是「使用者送一次訊息、AI 回一次」的單向推送。SSE 比 WebSocket 更適合:

    • 更簡單 — 單向傳輸,不需要管雙向連線
    • 瀏覽器原生 — EventSource API 內建自動重連
    • 穿透力好 — 走標準 HTTP,能通過代理和 CDN
    • 夠用 — 使用者的訊息用 POST 送,AI 的回應用 SSE 推

    四個階段的完整對照

    階段 檔案 新增能力 適合誰 可以停在這嗎?
    1. Duo 流水線 duo.py 設計→實作→審查 批量生成程式碼
    2. Smart Router router.py 自動選模型 個人實驗、學習
    3. Streaming + Fallback router_stream.py 逐字輸出 + 自動容錯 團隊內部使用
    4. FastAPI 部署 server.py HTTP API + SSE + Session 對外服務、產品整合

    費用比較:到底能省多少?

    假設一天 100 個問題,其中 20% 深度分析、30% 寫程式、50% 簡單問答:

    方案 月成本估算 品質
    全部用 Claude Sonnet ~$54 最高,但簡單題大材小用
    Smart Router(自動切換) ~$13.50 深度題用 Sonnet,其餘用 Llama
    全部用 Groq Llama 70B ~$4.20 最便宜,但分析品質弱

    Smart Router 方案比全用 Sonnet 省 75%,同時保留了深度分析任務的 Sonnet 品質。規模越大差距越明顯——1000 個使用者的 SaaS 產品,月省 $3000+。

    什麼場景適合?什麼場景不適合?

    ✅ 適合的場景

    1. 客服 Chatbot — 70% 簡單題用 Llama 8B 秒回,複雜題自動升級到 Sonnet
    2. 團隊 AI 助手 — 接 Slack,PM 問策略用 Sonnet,工程師要 code 用 Llama 70B
    3. 自動化 Pipeline — CI/CD 中的 AI code review,PR 提交自動跑
    4. SaaS 產品 — 加 AI 功能但要控成本,簡單摘要用 Llama,深度分析用 Sonnet
    5. 批量內容生成 — 50 篇產品描述:Sonnet 定規範 → Llama 批量寫 → Sonnet 抽檢

    ❌ 不適合的場景

    • 日常寫程式 — 用 Claude Code 或 Cursor 就好,不需要 LangGraph
    • 一次性分析 — 直接貼給 Claude 問就好,不需要搭 pipeline
    • 不需要控成本 — 個人使用月花不到 $10,Smart Router 省下的錢不值得建置成本

    三個問題判斷法

    在決定要不要用之前,問自己:

    1. 使用者是誰? — 你自己 → 不需要。別人(客戶/團隊/系統)→ 繼續看
    2. 會跑多少次? — 幾次 → 直接呼叫 API。幾百幾千次 → LangGraph 有意義
    3. 需要品質分級嗎? — 所有問題都要最高品質 → 用最強模型。不同問題可以不同品質 → Smart Router

    三個都答「後者」才值得用 LangGraph。

    走完四個階段之後,還有什麼?

    如果你的服務要上正式商業環境,還有幾個面向需要處理:

    面向 做什麼 不做的後果
    RAG(檢索增強) 接向量資料庫,讓 AI 查你的文件回答 AI 只能回答通用知識,不懂你的業務
    評估(LangSmith) 追蹤每次呼叫的路由、延遲、成本 不知道 Router 分類準不準,成本失控
    Session 持久化 用 Redis 存對話歷史(目前在記憶體) Server 重啟,所有對話消失
    認證 API key 或 JWT 驗證 任何人都能呼叫你的 API,幫你花錢
    Prompt Injection 防護 驗證使用者輸入,防止惡意 prompt 使用者讓 AI 做不該做的事
    Docker 容器化 打包成 Docker image 部署 環境不一致,部署困難
    Subgraph 嵌套 Router 判斷「寫程式」→ 啟動整個 Duo 流水線 只能單步回答,沒有品質把關

    完整專案結構

    langgraph-duo/
    ├── .env                  # API keys(不要 commit)
    ├── .gitignore            # 排除 .env
    ├── requirements.txt      # 所有套件
    ├── duo.py                # 階段 1:設計→實作→審查 流水線
    ├── duo_notebook.ipynb    # 階段 1 的 Jupyter 版
    ├── router.py             # 階段 2:Smart Router 基本版
    ├── router_stream.py      # 階段 3:+ Streaming + Fallback
    └── server.py             # 階段 4:FastAPI HTTP API

    所有程式碼都有詳細的中英文註解,說明每個模型的選用原因和適用場景。從哪個階段開始都可以,每個檔案都是獨立可執行的。

    總結

    LangGraph 多模型流水線的核心價值不是「用 AI 寫程式更快」——如果只是速度,直接用一個模型最快。它的價值在於把 AI 當成團隊來管理:讓擅長設計的去設計、擅長實作的去實作、擅長審查的去審查。

    從 Duo 流水線到 Smart Router,再到帶有 Streaming、Fallback、API 部署的生產版本,每一步都是從「能跑」走向「能上線」的必經之路。你不需要一次走完四個階段——先跑通第一階段,確認這個架構對你有用,再往下走。

    相關閱讀:LangChain/LangGraph 深度分析:架構師、顧問、個人公司的實戰指南 | Claude Code Agent Teams:從穩定執行到自動化代碼審查的完整指南 | 舊系統整合場景下,會用 vs 不會用 Claude Code 的差距