標籤: Design Pattern

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

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

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

    TL;DR 重點摘要

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

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

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

    為什麼 Semaphore 比 lock 更適合 async?

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

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

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

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

    BlockingCollection:Producer-Consumer 的標準解法

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

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

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


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

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

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

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

    幾個實戰經驗:

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

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


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

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

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

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

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

    幾個決策原則:

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

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


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

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

    JMeter 基本設定

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

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

    關鍵指標怎麼看

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

    常見錯誤

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

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


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

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

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

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

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

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

    給後端工程師的建議

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

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

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


    結語

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

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

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

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

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

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

    TL;DR 重點摘要

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

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

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

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

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

    三種配置單元(Allocation Unit)

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

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

    CHAR vs VARCHAR 的儲存差異

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

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

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

    Azure SQL Database 的常見假警報

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


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

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

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

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

    正確的做法:TABLOCKX 排他鎖

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

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


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

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

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

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

    排序與 TOP 的隱藏陷阱

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

    SQL 執行順序(必背)

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

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

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

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


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

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

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

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

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

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


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

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

    追蹤特定時間範圍的查詢

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

    查看當前執行中的程序

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

    全庫儲存空間盤點

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

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

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

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

    選擇指南

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

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


    結語

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


    常見問題 FAQ

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

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

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

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

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

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

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

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

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

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

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

    重點摘要

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    調適:三層防護

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

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

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

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

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

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

    調適:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    調適:

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

    Domain Brain 的誕生

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

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

    每個 brain file 的格式:

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

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

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

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

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

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

    坑 7:直接推 code 到 main branch

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

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

    鐵律:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    調適:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    驗收標準不該由我想

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

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

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

    2 月的我 vs 4 月的 Claude

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

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

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

    兩個月的數字

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

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

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

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

    這兩個月教我的事:

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

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

    馬沒有變。變的是騎手。

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

    重點摘要

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

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

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

    Harness Engineering 是什麼?一句話定義

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

    Agent = Model + Harness

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

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

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

    Harness 不是 Framework——搞清楚差異

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

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

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

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

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

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

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

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

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

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

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

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

    完整的 Harness Cycle:6 步閉環

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

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

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

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

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

    步驟一:建立 Domain Brain(Guide)

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

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

    每個 brain file 的內容格式:

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

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

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

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

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

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

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

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

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

    步驟三:PreCompact 安全網

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

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

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

    步驟四:Stop hook 結算

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

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

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

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

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

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

    我觀察到的最常見模式:

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

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

    你的 Harness 成熟度在哪一層?

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

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

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

    今天就能做的 3 件事

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

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

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

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

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

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

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

    延伸閱讀

  • 舊系統不死,AI 讓它進化:不重寫也能持續成長

    重點摘要

    • 舊系統不是問題,缺乏 AI 輔助才是問題——AI 讓「不重寫、持續演進」成為可行選項
    • 歷史證明:超過 80% 的「重寫計畫」以失敗或兩個系統都要維護收場
    • AI 最確定的價值是讀懂舊代碼、補文件、補測試,讓團隊敢繼續開發
    • 「AI 能不能讓大規模翻新變安全」——這是尚待驗證的命題,不宜過度樂觀

    你的公司有一套跑了十年的系統。它能動,它撐起了整個業務,但沒有人敢碰它。文件不齊、邏輯散落在各處、原始開發者早就離職了。每次有人提議「重寫」,討論就會陷入沉默——因為大家心裡都知道,這條路走過很多次,沒幾次是成功的。

    AI 的出現,讓這個困境有了新的解法。但不是你想像中的那種解法。

    為什麼重寫這麼難?歷史給了殘酷的答案

    軟體界有個著名的現象叫做 Second System Effect(第二系統效應),由《人月神話》作者 Fred Brooks 提出:工程師在重寫時,往往會把所有「本來想做卻沒做」的功能都塞進去,結果新系統比舊系統更複雜、更難維護。

    但更根本的問題是:舊系統裡有隱藏的業務邏輯,它們沒有寫在文件裡,只存在於代碼的行為中——某個奇怪的判斷式、某個例外處理、某個只在特定情境才觸發的路徑。這些邏輯,是十年來無數個「為什麼這樣做?」的答案。

    重寫計畫的典型失敗模式

    • 兩個系統並行期拉太長:新舊系統同時維護,工程師精力分散,bug 在兩邊都出現
    • 上線那天發現漏了邏輯:舊系統某個角落的行為,新系統根本沒有對應
    • 商業壓力中斷重寫:計畫進行到一半,業務需求改變,只能把新舊系統黏在一起
    • 重寫後反而更慢:新架構雖然「漂亮」,但少了十年累積的效能優化細節

    這不是悲觀,這是現實。重寫不是不可能,但它的成功率遠低於大多數人的預期。在 AI 出現之前,面對舊系統,企業只有兩條路:硬撐,或者賭一把。

    AI 帶來了第三條路:輔助成長

    AI 最確定的價值,不是幫你重寫系統,而是讓「不重寫、持續演進」這件事變得可持續。

    過去,舊系統的最大問題不是代碼本身,而是「沒有人理解它」。原始開發者離職了,知識沒有傳承;文件過時了,沒有人有時間更新;要改一個功能,必須花三天理解上下文,才敢動一行代碼。

    AI 改變了這個方程式。

    AI 能為舊系統做什麼?

    挑戰 過去的困境 AI 輔助後
    理解舊代碼 要花幾天甚至幾週閱讀 AI 幾分鐘內產出架構圖和流程說明
    補充文件 沒時間寫、寫了也快過時 AI 根據現有代碼生成,每次修改後更新
    補充測試 舊系統通常 0 測試,改動沒有安全網 AI 針對現有行為補寫測試,改動有保護
    修復 bug 怕改了 A 壞了 B,只敢最小化改動 AI 追蹤影響範圍,降低連帶破壞風險
    新人上手 要跟著老人學幾個月才敢動 AI 隨時解釋任何一段代碼的邏輯和背景
    新增功能 不知道該插在哪裡,怕破壞現有邏輯 AI 建議最小侵入式的擴充點

    實際做法:AI 如何輔助舊系統成長

    第一步:讓 AI 讀懂系統,產出活的文件

    不要急著改代碼。第一件事是讓 AI 理解現有系統,然後把理解結果固化成文件。

    # 讓 AI 讀整個 codebase,產出架構說明
    # 在 Claude Code 中,直接描述你的需求:
    
    「請閱讀這個專案的所有代碼,並產出:
    1. 系統架構圖(模組與模組之間的關係)
    2. 核心業務流程說明(從使用者角度描述主要流程)
    3. 高風險區域列表(邏輯最複雜、最不敢動的地方)
    4. 技術債清單(有哪些地方明顯需要改善)」

    這份文件不是給外部人看的,是給你的團隊每天使用的工作手冊。它會隨著系統改動而更新——這一點很重要,因為過去文件之所以沒用,是因為沒有人有時間維護它。AI 讓維護文件的成本降低了 90%。

    第二步:為現有行為補測試,建立安全網

    舊系統的問題不是「代碼爛」,而是「沒有測試保護」。任何一個修改都是在沒有安全網的情況下走鋼絲。

    AI 可以閱讀現有代碼,理解它的行為,然後為這些行為寫測試——即使這些行為從來沒有文件。

    # 範例:讓 AI 為現有函式補測試
    「這個函式 calculateDiscount() 已經跑了八年,
    請分析它的所有分支條件,為每個分支寫一個測試案例,
    包括正常情況、邊界值和異常情況。
    不要改動現有邏輯,只補充測試。」

    有了測試,團隊才敢改動。改動有安全網,系統才能持續演進而不是不斷累積技術債。

    第三步:最小侵入式地新增功能

    新增功能不等於重構整個模組。AI 擅長找到「最小侵入式的擴充點」——在不動現有邏輯的前提下,把新功能插進去。

    這個原則來自 Open/Closed Principle(開放封閉原則):對擴充開放,對修改封閉。即使舊系統沒有遵循這個原則,AI 也可以建議如何在外圍包一層,讓新功能不影響舊邏輯。

    第四步:漸進式現代化,而非大爆炸式重寫

    如果真的有部分需要改善,AI 輔助的方式是:一次只動一個模組,改完之後讓它穩定跑一段時間,確認沒有問題再動下一個。

    這不是「重寫」,這是「漸進式現代化」。兩者的關鍵差異:

    重寫 漸進式現代化
    範圍 全部 一次一個模組
    風險 集中在上線日 分散,每步都可以回滾
    業務中斷 長期並行維護兩個系統 系統持續運作,局部更新
    AI 的角色 「幫我重新實作這一切」 「幫我安全地改善這一塊」

    那麼,「重寫」這條路呢?

    這是一個需要誠實面對的問題。

    過去的答案很清楚:重寫計畫成功率低,風險高,通常不是好選擇。大多數成功的案例,仔細看都是「漸進式替換」而不是「一次性重寫」。

    AI 會改變這個答案嗎?

    這是一個尚待驗證的命題。AI 確實讓理解舊系統更容易,讓知識遷移成本降低,理論上應該讓重寫的準備工作做得更完整。但「做得更完整的準備」不等於「執行時不會出問題」。隱藏的業務邏輯、時序問題、效能細節——這些在代碼裡只有在跑了幾百萬筆資料之後才會浮現。

    更誠實的說法是:

    • AI 讓「輔助成長」這條路變得可行——這是現在就可以驗證的事
    • AI 讓「重寫」變得更安全——這是有可能的,但還需要更多實際案例來驗證
    • AI 能取代「漸進式替換」的謹慎原則——不太可能,這個原則的價值在於限制風險暴露,而不是技術能力

    所以,如果有人告訴你「有了 AI,重寫就不危險了」——保持懷疑。如果有人告訴你「AI 讓你不需要擔心舊系統的技術債了」——同樣保持懷疑。

    如何判斷你的系統需要什麼?

    面對舊系統,用這個框架來判斷方向:

    優先考慮 AI 輔助成長,如果:

    • 系統仍然在提供商業價值,只是難以維護
    • 核心業務邏輯複雜,沒有人完整理解
    • 團隊規模小,無法支撐兩個系統並行
    • 業務需求變化頻繁,不能停下來等重寫完成

    可以考慮漸進式替換(不是重寫),如果:

    • 某個模組已經明顯成為瓶頸,且邊界清晰
    • 有足夠的測試保護現有行為
    • 可以部署影子流量,讓新舊模組並行驗證
    • 有明確的回滾機制

    謹慎考慮大規模重寫,只有當:

    • 現有系統在技術上已經無法繼續擴充(不只是難,而是真的不可能)
    • 有足夠的資源支撐至少 18 個月的並行期
    • 有完整的行為規格文件(或 AI 幫你產出的等效文件)
    • 組織願意接受在過渡期期間功能停滯

    結語:舊系統不是問題,缺乏支援才是

    回到最初的問題:那套跑了十年的系統,它的問題不是年齡,而是孤立。沒有人理解它,沒有測試保護它,沒有文件說明它,改動它需要承擔巨大的個人風險。

    AI 能做的,是讓這個系統不再孤立。它可以成為每個工程師的「老前輩」——隨時解釋任何一段邏輯,隨時分析改動的影響,隨時生成測試保護現有行為。

    這不是重寫的故事,這是陪伴成長的故事。

    至於重寫——如果未來真的需要,AI 會讓你準備得更充分。但那是另一個故事,而且它的結局還沒有寫完。