iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0

Day 11 我們接上本地的 Ollama + Mistral 7B,在 Day 7-10 共用的資料集上把 F1 從 Day 10 的 0.388 拉到 0.714。但代價很明顯:

三組 SRS(電商 / 醫療 / 社交媒體)
LLM 呼叫:10 次(逐筆驗證 7 次 + 補充 3 次)
總耗時:74.5 秒(每次約 7.5 秒)

Day 11 的逐層分析也發現,驗證層佔了 70% 的呼叫次數,卻一個誤報都沒擋下。

今天的目標是減少 LLM 呼叫次數與等待時間,同時用同一套評測確認準確率沒有下降。

這一天在系列中的位置

第 1 週:讓 SRS 從黑盒變透明 → Day 1-7 架構、分塊、工作流與評測系統
第 2 週:檢測優化與生產系統 → Day 8-10 規則優化、Day 11 引入 LLM,今天 Day 12 優化 LLM 呼叫的性能

今日目標

今天要完成:

  1. 批量驗證:一批候選衝突只呼叫 1 次 LLM(BatchLLMVerifier)
  2. 兩層緩存:記憶體 LRU + 磁碟 JSON,程式重啟後仍能命中(AdvancedCache)
  3. 修正緩存鍵:從 req_id 改為需求內容的 SHA-256,避免不同文件互相污染
  4. 用真實 LLM 量測:呼叫次數、耗時、Precision / Recall / F1

預期結果(Day 7-10 共用資料集,Mistral 7B):

Day 11 Day 12 首次 Day 12 重跑(磁碟緩存)
LLM 呼叫 10 次 6 次 0 次
耗時 74.5 秒 56.1 秒 < 0.1 秒
F1 0.714 0.767 0.767

問題背景:時間花在哪裡

Day 11 的流程是「每個候選衝突呼叫一次 LLM」:

層 1 規則:8 個候選(< 0.01 秒)
層 2 驗證:每個候選 1 次呼叫  → 7 次(其中 1 次被緩存擋下,後面會解釋為什麼這其實是 bug)
層 3 補充:每份 SRS 1 次呼叫  → 3 次

本機 LLM 的耗時主要來自兩部分:每次呼叫的固定開銷(處理提示詞、開始生成)與生成的字數。Mistral 在逐筆驗證時每題都會寫一段幾十字的解釋,而這些解釋我們只用到開頭的「是 / 否」。

所以有兩個優化方向:

  • 合併呼叫:把 N 個候選放進同一個提示詞,固定開銷只付一次
  • 不重複呼叫:同樣的需求對、同一份 SRS,結果直接讀緩存

實現方法

constraints
  ↓
層 1:簡單規則 → 候選衝突
  ↓
層 2:批量驗證
  ├─ 逐個查緩存(記憶體 → 磁碟)  命中 → 直接用結果
  └─ 未命中的整批送 LLM(1 次呼叫)→ 結果寫回緩存
  ↓
層 3:LLM 補充(以整份約束的 SHA-256 查緩存,未命中才呼叫)
  ↓
List[Conflict]
  • 輸入:約束清單 List[Dict](與 Day 10、11 相同)
  • 輸出:List[Conflict],detection_method 標示 simple 或 llm_supplement
  • 檔案:src/performance_optimizer.py(新增),補充層沿用 src/llm_verifier.py 的 LLMVerifier.find_missing_conflicts()
  • 下游:Day 13 的 src/api_main.py 會直接使用這裡的 OptimizedDetector

專案結構變化:

srs-review-agent/
├── src/
│   ├── llm_verifier.py          ← Day 11(沿用 OllamaLLM、補充層、_extract_json)
│   ├── performance_optimizer.py ← 【新增】AdvancedCache、BatchLLMVerifier、OptimizedDetector
│   └── ...
└── tests/
    ├── test_day11_llm.py        ← Day 11(沿用 Day 7 資料集與評測指標)
    ├── test_day12_perf_unit.py  ← 【新增】單元測試(離線)
    └── test_day12_benchmark.py  ← 【新增】基準測試(可切換真實 Ollama)

今天不需要新的 pip 套件,緩存用標準庫的 OrderedDict、hashlib、json 實作。


代碼示例

1. 先修一個 bug:緩存鍵不能用 req_id

Day 11 的緩存鍵是 md5(f"{req_id_1}_{req_id_2}")。問題在於 req_id 只在單一份 SRS 內唯一:

  • Day 7 資料集中,電商和社交媒體都有 REQ-2.4.2 ↔ REQ-2.4.3,但內容完全不同(支付加密 vs 帖子加密)
  • Day 11 跑評測時有 8 個候選卻只呼叫了 7 次,就是社交媒體直接拿到電商的驗證結果
  • Day 13 的 API 每次請求都會把約束重新編號成 REQ-1、REQ-2…,不同使用者的文件會互相命中

在 src/performance_optimizer.py 的 BatchLLMVerifier 中改用需求內容當鍵:

def _generate_cache_key(self, text_1: str, text_2: str) -> str:
    """生成緩存鍵:用需求「內容」而非 req_id"""
    key = "\n".join(sorted([text_1, text_2]))
    return hashlib.sha256(key.encode("utf-8")).hexdigest()

先排序再雜湊,讓 A↔B 與 B↔A 命中同一筆。

2. 兩層緩存:記憶體 LRU + 磁碟 JSON

建立 AdvancedCache。記憶體層用 OrderedDict 實作 LRU(最近用過的移到末尾,超過容量時移除最前面的);磁碟層是可選的 JSON 檔:

class AdvancedCache:
    def __init__(self, max_memory_size: int = 1000, disk_path: Optional[str] = None):
        self.memory_cache = OrderedDict()           # 第 1 層:記憶體 LRU
        self.max_memory_size = max_memory_size
        self.disk_path = disk_path                  # 第 2 層:磁碟 JSON(可選)
        self.disk_cache: Dict[str, list] = self._load_disk()

    def get(self, key: str) -> Optional[Tuple[bool, str]]:
        if key in self.memory_cache:
            self.memory_cache.move_to_end(key)
            self.stats["hits"] += 1
            return self.memory_cache[key]
        if key in self.disk_cache:
            value = tuple(self.disk_cache[key])     # JSON 沒有 tuple,存的是 list
            self._put_memory(key, value)            # 載回記憶體
            self.stats["hits"] += 1
            self.stats["disk_hits"] += 1
            return value
        self.stats["misses"] += 1
        return None

put() 會同時寫入記憶體與磁碟;_load_disk() 在檔案不存在或 JSON 損壞時回傳空字典,讓緩存壞掉時退化成「沒有緩存」而不是讓程式崩潰。統計欄位與寫入邏輯的完整代碼見 src/performance_optimizer.py。

為什麼要磁碟層?記憶體緩存在程式重啟後就消失了,而 SRS 審查的典型場景是「改幾行需求 → 重新跑一次」,大部分需求對沒變,結果應該能直接重用。

3. 批量驗證:一批只呼叫一次 LLM

BatchLLMVerifier 接受 llm 參數。不傳時使用關鍵字模擬(單元測試與 API 預設),傳入 Day 11 的 OllamaLLM() 時才呼叫真實模型。_verify_batch_internal() 先逐個查緩存,把未命中的收集起來一次送出:

def _llm_verify_batch(self, pairs: List[Tuple[str, str]]) -> List[Tuple[bool, str]]:
    lines = [f"{i}. 需求 A:{t1}\n   需求 B:{t2}"
             for i, (t1, t2) in enumerate(pairs, start=1)]
    prompt = (
        "你是需求工程師。以下每一題有兩條軟體需求,請逐題判斷兩者是否無法同時成立。\n"
        "注意否定詞(如「不需要」「不支持」)與描述對象是否相同。\n\n"
        + "\n".join(lines)
        + '\n\n只返回 JSON,不要其他文字,格式:\n'
        '{"results": [{"index": 1, "is_conflict": true, "reason": "簡短理由"}]}'
    )
    self.llm_call_count += 1
    response = self.llm.invoke(prompt)
    ...

和 Day 11 的逐筆提示詞相比,有兩個刻意的改變:

  • 拿掉「初始檢測理由:加密 vs 明文」:Day 11 發現這句話會讓小模型順著附和
  • 明確提醒否定詞與描述對象:這正是 Day 10、11 誤報的來源

解析回應時沿用 Day 11 的 _extract_json():

answers = {}
try:
    for item in _extract_json(response).get("results", []):
        answers[int(item["index"])] = (bool(item["is_conflict"]), str(item.get("reason", "")))
except (json.JSONDecodeError, KeyError, TypeError, ValueError, AttributeError):
    pass

return [answers.get(i, (True, "LLM 未回答此題,保留"))
        for i in range(1, len(pairs) + 1)]

批量回答的風險是模型漏答某一題或整個 JSON 格式錯誤。這裡選擇「保留」該候選:寧可多一個誤報給人工複核,也不要因為格式問題漏掉真實衝突。

4. 補充層也要緩存

OptimizedDetector.detect_conflicts() 新增 enable_supplement 參數,補充層以整份約束的 SHA-256 為鍵:

def _supplement_cached(self, constraints: List[Dict]) -> List[Conflict]:
    fingerprint = hashlib.sha256(
        json.dumps(constraints, ensure_ascii=False, sort_keys=True).encode("utf-8")
    ).hexdigest()

    cached = self.supplement_cache.get(fingerprint)
    if cached is None:
        found = self.supplementer.find_missing_conflicts(constraints)  # Day 11
        self.batch_verifier.llm_call_count += 1
        cached = tuple((m.req_id_1, m.req_id_2, m.description) for m in found)
        self.supplement_cache.put(fingerprint, cached)
    ...

注意這裡的鍵是整份約束:任何一條需求改了,補充層就要重跑。補充層看的是需求之間的關係,改一條可能影響全部。

使用方式:

from src.llm_verifier import OllamaLLM
from src.performance_optimizer import OptimizedDetector

detector = OptimizedDetector(llm=OllamaLLM(), cache_path=".cache/verify_cache.json")
conflicts = detector.detect_conflicts(
    constraints,
    enable_verification=True,
    batch_size=10,           # 每次呼叫最多驗證 10 個候選
    verify_strategy="all",   # 規則置信度都 ≥ 0.85,用 selective 的話不會驗證任何候選
    enable_supplement=True,
)
print(detector.batch_verifier.get_stats()["llm_call_count"])

不傳 llm 時,OptimizedDetector() 的行為與原本相同(模擬驗證、無補充層),所以 Day 13 的 API 不需要跟著改。


驗證結果

單元測試(離線)

cd srs-review-agent
python3 -m pytest tests/test_day12_perf_unit.py -v
tests/test_day12_perf_unit.py::test_advanced_cache PASSED                [ 20%]
tests/test_day12_perf_unit.py::test_batch_verifier PASSED                [ 40%]
tests/test_day12_perf_unit.py::test_optimized_detector PASSED            [ 60%]
tests/test_day12_perf_unit.py::test_batch_size_impact PASSED             [ 80%]
tests/test_day12_perf_unit.py::test_time_estimation PASSED               [100%]

============================== 5 passed in 0.01s ===============================

真實 LLM 基準測試

tests/test_day12_benchmark.py --ollama 會在 Day 7-10 共用資料集上跑兩輪。每輪都建立新的 OptimizedDetector(記憶體緩存清空),第二輪只能靠第一輪寫下的磁碟緩存,模擬「程式重啟後重跑同一批 SRS」:

python3 tests/test_day12_benchmark.py --ollama
  [冷緩存] 電商平台:F1=0.667(TP 2 / FP 0 / FN 2)
  [冷緩存] 醫療系統:F1=0.800(TP 4 / FP 0 / FN 2)
  [冷緩存] 社交媒體:F1=0.833(TP 5 / FP 2 / FN 0)
  [冷緩存] 平均 Precision=0.905  Recall=0.722  F1=0.767
  [冷緩存] LLM 呼叫:6 次,耗時:56.1 秒,磁碟緩存命中:0

  [磁碟緩存] 平均 Precision=0.905  Recall=0.722  F1=0.767
  [磁碟緩存] LLM 呼叫:0 次,耗時:0.0 秒,磁碟緩存命中:8

對照:Day 11 逐筆驗證 = LLM 呼叫 10 次、74.5 秒、F1 0.714

不加 --ollama 時執行的是離線的模擬基準測試,用來比較不同 batch_size 下的批次數與緩存命中率。


評測結果

性能

階段 Day 11 Day 12 首次 Day 12 重跑
驗證呼叫 7 次(逐筆) 3 次(每份 SRS 1 批) 0 次
補充呼叫 3 次 3 次 0 次
總耗時 74.5 秒 56.1 秒(-24.7%) < 0.1 秒

首次執行只快了約 25%,沒有想像中多。把每一層分開計時:

測試集 批量驗證 補充
電商(2 個候選) 5.2 秒 9.9 秒
醫療(1 個候選) 2.5 秒 11.8 秒
社交媒體(5 個候選) 8.7 秒 14.8 秒
合計 16.4 秒 36.5 秒

補充層佔了約 70% 的時間。它每份 SRS 本來就只呼叫 1 次,批量化幫不上忙,而且要讀完整份約束、生成較長的 JSON。批量化省下的只是驗證層的固定開銷。(分開計時的總和與基準測試的 56.1 秒略有差異,來自每次執行的波動。)

大幅加速來自磁碟緩存:同一批 SRS 重跑時,LLM 呼叫是 0 次。

準確率:沒有下降,反而提升

版本 Precision Recall F1
Day 7 簡單規則 0.850 0.739 0.789
Day 11 逐筆驗證 0.764 0.722 0.714
Day 12 批量驗證 0.905 0.722 0.767

Recall 不變(補充層的提示詞沒改),Precision 從 0.764 升到 0.905。逐筆看規則層的 4 個誤報:

候選 Day 11 Day 12
「支持明文顯示訂單細節」vs「不需要加密用戶的個人信息」 保留 丟棄 ✓
「帖子內容採用明文存儲」vs「支持端到端加密」 保留 丟棄 ✓
「帖子內容採用明文存儲」vs「不支持任何加密機制」 保留 保留 ✗
「離線消息 30 天內自動刪除」vs「帳戶刪除後數據永久保存」 保留 保留 ✗

批量化本身不會讓模型變聰明,Precision 的提升來自提示詞的改變:拿掉會引導答案的「初始檢測理由」,並提醒注意否定詞。不過新提示詞只擋下一半的誤報:「不支持任何加密」即使有否定詞提醒,Mistral 7B 仍判定與「明文存儲」矛盾。

F1 0.767 距離 Day 7 基線 0.789 還差 2.8%。

temperature: 0 下重跑冷緩存,每一個候選的保留 / 丟棄結果都相同。


權衡與未採用的優化

批量大小

batch_size 越大,呼叫次數越少,但:

  • 一個提示詞裡的題目越多,小模型越容易漏答或把題號對錯
  • 漏答的題目會被「保留」,等於那一題沒有驗證

今天的資料集每份 SRS 最多 5 個候選,batch_size=10 剛好一份一批。更大的 SRS 需要重新量測合適的批量大小。

非同步並行(未實作)

把驗證與補充用 asyncio.gather 同時送出,理論上可以把總時間壓到兩者中較長的那個。但本機 Ollama 能否同時處理多個請求,取決於 OLLAMA_NUM_PARALLEL 設定與記憶體大小;在同一顆 GPU / CPU 上,並行請求也可能只是互相搶資源。這需要另外量測,今天先列為可選延伸。

緩存失效

  • 驗證緩存的鍵是需求內容,改一條需求只會讓涉及它的候選重新驗證
  • 補充緩存的鍵是整份約束,改任何一條都會重跑補充層
  • 換模型(例如從 Mistral 換成 Llama)時,應該換一個 cache_path,否則會讀到舊模型的答案

提交變更

git add src/performance_optimizer.py tests/test_day12_perf_unit.py tests/test_day12_benchmark.py
git commit -m "Day 12: 批量 LLM 驗證、記憶體+磁碟緩存、內容雜湊緩存鍵"

明天預告

檢測器現在跑一份 SRS 的速度可以接受,重跑時幾乎不花時間。明天 Day 13 會把 OptimizedDetector 包裝成 FastAPI 服務,讓前端與其他工具可以透過 HTTP 呼叫衝突檢測。


上一篇
Day 11:LLM 輔助檢測與驗證
下一篇
Day 13:Web API 設計與實現
系列文
解決需求規格書矛盾:用 Claude Code × MCP 實作自律型文檔審查 Agent 共 16 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言