iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0

Day 8-10 三天都在規則上做文章,F1 一路從 Day 7 基線的 0.789 掉到 0.468、0.524,Day 10 的混合方案更只有 0.388。

Day 10 的逐筆分析找到了根本原因:規則看不懂同義詞(「多個主治醫生」≠「多用戶」)、否定詞(「不需要加密」仍被當成「加密」)和領域知識(「公開訪問」vs「HIPAA 認證」)。再多寫幾條關鍵字也解決不了。

今天換個方向:規則照樣保留,改讓 LLM 幫規則把關、補漏。我們會接上本地的 Ollama + Mistral 7B,量出它帶來多少改善,以及它做不到什麼。

這一天在系列中的位置

第 1 週:讓 SRS 從黑盒變透明 → Day 1-7 架構、分塊、工作流與評測系統,基線 F1 = 0.789
第 2 週:檢測優化與生產系統 → Day 8-10 規則優化失敗,今天 Day 11 引入 LLM 驗證與補充

今日目標

今天要完成:

  1. 三層檢測架構 LLMEnhancedDetector:規則 → LLM 驗證 → LLM 補充(src/llm_verifier.py)
  2. 可替換的 LLM 客戶端:離線測試用 MockOllamaLLM,實際評測用 OllamaLLM
  3. 在 Day 7-10 相同的資料集上評測,讓數字可以公平比較
  4. 拆解每一層的貢獻,知道 LLM 在哪裡有用、在哪裡沒用

預期結果(Day 7-10 共用資料集,Mistral 7B):Precision 0.764、Recall 0.722、F1 0.714,比 Day 10 的 0.388 提升 84.0%,但仍低於 Day 7 基線 0.789(-9.6%)。


問題背景:規則漏掉了什麼

回顧 Day 10 在醫療系統上漏掉的衝突:

REQ-2.1.2:患者記錄支持多個主治醫生
REQ-2.1.3:患者記錄僅一個醫生可編輯
→ 規則只認「多用戶 / 單用戶」,看不出這是同一種衝突

REQ-4.4:患者數據可公開訪問
REQ-4.1:患者數據 HIPAA 認證
→ 需要知道 HIPAA 要求保護病患隱私,關鍵字規則無從表達

這兩類問題剛好是 LLM 擅長的:理解換句話說的語義,以及運用一般領域知識。

但 LLM 也有代價:每次呼叫要數秒、輸出格式不穩定、可能編造不存在的需求編號。所以規則負責「快速、確定」的部分,LLM 只做規則做不到的事。


實現方法:三層架構

constraints(約束清單)
  ↓
層 1:簡單規則(毫秒級)        多用戶/單用戶、加密/明文、刪除/永久保存
  ↓
層 2:LLM 驗證(每個候選 1 次呼叫)  「這真的是衝突嗎?」是 → 保留,否 → 丟棄
  ↓
層 3:LLM 補充(每份 SRS 1 次呼叫)  「還有哪些衝突沒被找到?」→ 去重後加入
  ↓
List[Conflict]
  • 輸入:約束清單 List[Dict],每筆含 id 與 text(與 Day 10 相同)
  • 輸出:List[Conflict],多了 verified、reasoning、verification_result 欄位,記錄每個結果的來源與 LLM 的判斷理由
  • 檔案:src/llm_verifier.py(新增)
  • 下游:Day 12 會針對這裡的 LLM 呼叫做快取與批量優化

專案結構變化:

srs-review-agent/
├── .env.example               ← 既有:OLLAMA_URL、OLLAMA_MODEL 範本
├── src/
│   ├── hybrid_detector.py     ← Day 10
│   ├── llm_verifier.py        ← 【新增】LLM 客戶端、驗證器、三層檢測器
│   └── ...
└── tests/
    ├── test_day10_hybrid.py   ← Day 10(今天沿用它的三組約束)
    ├── test_day11_llm_unit.py ← 【新增】單元測試(Mock,離線可跑)
    └── test_day11_llm.py      ← 【新增】評測腳本(可切換真實 Ollama)

環境準備

今天不需要新的 pip 套件(HTTP 呼叫用標準庫 urllib),但需要本地 Ollama:

# 安裝 Ollama 後下載模型(約 4.4GB)
ollama pull mistral

# 確認服務在跑
curl http://localhost:11434/api/tags

連線設定從環境變數 OLLAMA_URL、OLLAMA_MODEL 讀取,未設定時預設為 http://localhost:11434 與 mistral,與 .env.example 相同。


代碼示例

1. 可替換的 LLM 客戶端

在 src/llm_verifier.py 中定義兩個介面相同(都只有 invoke(prompt) -> str)的類別。MockOllamaLLM 用關鍵字模擬回答,讓單元測試不需要網路也能跑;OllamaLLM 則真的呼叫模型:

import json, os, urllib.request

class OllamaLLM:
    def __init__(self, model_name=None, base_url=None, timeout=120):
        self.model_name = model_name or os.getenv("OLLAMA_MODEL", "mistral")
        self.base_url = (base_url or os.getenv("OLLAMA_URL", "http://localhost:11434")).rstrip("/")
        self.timeout = timeout
        self.call_count = 0

    def invoke(self, prompt: str) -> str:
        self.call_count += 1
        payload = json.dumps({
            "model": self.model_name, "prompt": prompt, "stream": False,
            "options": {"temperature": 0},  # 固定輸出,讓評測可重現
        }).encode("utf-8")
        request = urllib.request.Request(f"{self.base_url}/api/generate", data=payload,
                                         headers={"Content-Type": "application/json"})
        try:
            with urllib.request.urlopen(request, timeout=self.timeout) as resp:
                return json.loads(resp.read())["response"]
        except OSError as e:
            raise ConnectionError(f"無法連線到 Ollama({self.base_url}),請先執行 `ollama serve`") from e

temperature: 0 很重要:沒有它,同一份 SRS 每次評測的結果都可能不同,評測數字就失去意義。連線失敗時會拋出帶有修復提示的 ConnectionError,不會只丟出一串 socket 錯誤。

LLMVerifier 與 LLMEnhancedDetector 都接受 llm 參數,不傳時預設用 Mock:

from src.llm_verifier import LLMEnhancedDetector, OllamaLLM

detector = LLMEnhancedDetector()                 # 離線、結果固定(單元測試用)
detector = LLMEnhancedDetector(llm=OllamaLLM())  # 真實 Mistral 7B

2. 層 2:LLM 驗證

LLMVerifier.verify_conflict() 把兩條需求與規則的檢測理由交給 LLM,要求先回答「是」或「否」:

prompt = f"""檢查以下兩個需求是否存在衝突:

需求 1 ({req_id_1}):
{text_1}

需求 2 ({req_id_2}):
{text_2}

初始檢測理由:{detected_reason}

請判斷這是否是真正的衝突。回答「是」或「否」,然後簡短解釋。"""

response = self.llm.invoke(prompt)
is_real = _parse_yes_no(response)

解析結果時只看開頭:

def _parse_yes_no(response: str) -> bool:
    head = response.strip().lstrip("「\"'*# ").lower()
    return head.startswith("是") or head.startswith("yes")

一開始我寫的是 "是" in response[:20],結果「否,這不是一個衝突」也被判成「是」,因為中文的否定句裡常常出現「是」字。只檢查開頭、同時接受英文 yes(Mistral 有時會用英文回答)才可靠。

驗證結果會以 (req_id_1, req_id_2) 的 MD5 為鍵存進 self.cache,同一對需求不會重複呼叫 LLM。

3. 選擇性驗證:verify_all 參數

LLMEnhancedDetector.detect_conflicts() 預設只驗證置信度 < 0.85 的候選,高置信度的直接通過:

should_verify = verify_all or conflict.confidence < 0.85

這是為了省 LLM 呼叫。但要注意:目前三條規則的置信度是 0.90-0.95,預設設定下層 2 一次都不會呼叫 LLM。評測時要傳 verify_all=True,驗證層才會真正起作用。

4. 層 3:LLM 補充

find_missing_conflicts() 把整份約束清單交給 LLM,要求只回傳 JSON:

prompt = f"""分析以下需求列表,找出潛在的衝突:

{json.dumps(constraints, ensure_ascii=False, indent=2)}

只列出兩個需求無法同時成立的情況,req_id 必須來自上面的列表。
只返回 JSON,不要其他文字,格式:
{{ "conflicts": [ {{ "req_id_1": "REQ-X", "req_id_2": "REQ-Y", "type": "衝突類型", "reason": "為什麼是衝突" }} ] }}"""

(格式範例在原始碼中是多行縮排,這裡壓成一行節省篇幅。)

即使要求「只返回 JSON」,模型還是常常加上說明文字或包在 ```json 區塊裡,所以解析時要先把 JSON 物件取出來,並丟棄 LLM 編造的需求編號:

def _extract_json(response: str) -> Dict:
    match = re.search(r"\{.*\}", response, re.DOTALL)
    if not match:
        raise json.JSONDecodeError("找不到 JSON 物件", response, 0)
    return json.loads(match.group(0))

# find_missing_conflicts() 中
valid_ids = {c["id"] for c in constraints}
for c in data.get("conflicts", []):
    if (c["req_id_1"] not in valid_ids or c["req_id_2"] not in valid_ids
            or c["req_id_1"] == c["req_id_2"]):
        continue
    ...

解析失敗時(JSONDecodeError、缺欄位等)回傳空清單,讓整個流程降級成「只有規則 + 驗證」,而不是整個崩潰。

補充的結果會與前兩層做無方向去重(frozenset((req_id_1, req_id_2))),A↔B 與 B↔A 視為同一對。完整代碼見 src/llm_verifier.py。


驗證結果

單元測試(離線,使用 Mock)

cd srs-review-agent
python3 -m pytest tests/test_day11_llm_unit.py -v

預期輸出:

tests/test_day11_llm_unit.py::test_simple_conflicts PASSED               [ 16%]
tests/test_day11_llm_unit.py::test_llm_verification PASSED               [ 33%]
tests/test_day11_llm_unit.py::test_caching PASSED                        [ 50%]
tests/test_day11_llm_unit.py::test_three_layer_detection PASSED          [ 66%]
tests/test_day11_llm_unit.py::test_selective_verification PASSED         [ 83%]
tests/test_day11_llm_unit.py::test_conflict_properties PASSED            [100%]

============================== 6 passed in 0.01s ===============================

真實 LLM 評測

tests/test_day11_llm.py 有兩個選項:

  • --ollama:改用 OllamaLLM,並開啟 verify_all=True 與補充層
  • --dataset day7:使用與 Day 7-10 完全相同的三組約束與標準答案(src/evaluation.py 的 GROUND_TRUTH_TEST_1/2/3)
python3 tests/test_day11_llm.py --ollama --dataset day7

輸出重點:

平均指標(3 個測試用例):
  平均精確度:0.764
  平均召回率:0.722
  平均 F1 分數:0.714

LLM 總調用次數:10
總耗時:74.5 秒

注意:不加 --dataset day7 時,腳本使用 Day 11 自帶的小型資料集(每組 4-6 條需求),數字會偏高(Mistral 版 F1 0.867),不能直接和 Day 7-10 比較。這也是今天新增 --dataset 選項的原因:比較不同版本時,資料集必須相同。


評測結果:與 Day 7-10 對比

同一組資料集(電商 / 醫療 / 社交媒體):

版本                    Precision  Recall   F1      vs Day 7
──────────────────────────────────────────────────────────────
Day 7 簡單規則          0.850      0.739    0.789   基線 ⭐
Day 8 規則擴展          0.722      0.422    0.468   -40.7%
Day 9 智能分析          0.651      0.467    0.524   -33.6%
Day 10 混合方案         0.667      0.339    0.388   -50.8%
Day 11 僅規則(Mock)   0.633      0.272    0.340   -56.9%
Day 11 Mistral 7B       0.764      0.722    0.714   -9.6%

「Day 11 僅規則(Mock)」是不加 --ollama 的結果:此時不開 verify_all 也不開補充層,規則置信度又都 ≥ 0.85,所以一次 LLM 都沒呼叫,等於只剩層 1。它是今天的對照組,拿來看 LLM 到底貢獻了多少:F1 從 0.340 → 0.714,提升 110%。

分領域(Mistral 7B):

測試集 Precision Recall F1 TP / FP / FN
電商平台 0.667 0.500 0.571 2 / 1 / 2
醫療系統 1.000 0.667 0.800 4 / 0 / 2
社交媒體 0.625 1.000 0.769 5 / 3 / 0

temperature: 0 下重複執行,每一組的 TP / FP / FN 都相同。


逐層分析:LLM 在哪裡有用

層 3 補充:Recall 的全部來源

補充層新增了 7 個正確衝突,0 個誤報:

測試集 LLM 補充找到的衝突
電商 「購物車實時同步到伺服器」vs「支持本地離線購物車」
醫療 「多個主治醫生」vs「僅一個醫生可編輯」
醫療 「僅一個醫生可編輯」vs「單醫生操作設計」
醫療 「患者數據可公開訪問」vs「HIPAA 認證」
社交 「帖子 < 100MB」vs「帖子 < 1GB」
社交 「支持端到端加密」vs「不支持任何加密機制」
社交 「離線消息存儲 30 天」vs「30 天內自動刪除」

Day 10 分析的三類漏報(同義詞、領域知識、數值單位)都有被補上。醫療系統的 Recall 從 0.167 升到 0.667。

層 2 驗證:一個誤報都沒擋下

規則層的 4 個誤報,Mistral 7B 全部判定為「是,這是衝突」。例如:

需求 1:支持明文顯示訂單細節
需求 2:不需要加密用戶的個人信息

Mistral:是。需求 A 要求訂單細節以明文顯示,而需求 B 要求不需要加密用戶的個人信息,
這兩個需求之間存在衝突……

兩條需求其實都偏向「不加密」,並不矛盾,但模型順著「初始檢測理由:加密 vs 明文」的方向附和了。這是小模型常見的附和偏誤:提示詞裡已經暗示了答案,模型就傾向同意。所以 Precision 的變化(0.633 → 0.764)完全來自補充層增加了正確結果,而不是驗證層濾掉了錯誤結果。

仍然漏掉的 4 個

漏掉的衝突 類型
「頁面加載 < 100ms」vs「吞吐量 > 10000 req/s」 性能取捨,需要推理
「不需要加密個人信息」vs「多用戶註冊登入」 隱性安全風險
「最多 50 名患者」vs「最多 1 名患者」 數值衝突
「100 並行用戶」vs「1000 並行用戶」 數值衝突

同樣是數值衝突,LLM 抓到了「100MB vs 1GB」,卻漏了「50 vs 1 名患者」。可見 LLM 補充不會穩定覆蓋某一類衝突,所以規則層不能拿掉。


實戰考量

成本與時間

本機 Mistral 7B(Q4_K_M 量化,約 4.4GB)不需要 API 費用,但時間成本很明顯:

項目 數值
LLM 呼叫次數(三組 SRS) 10 次(驗證 7 + 補充 3)
總耗時 74.5 秒
平均每次呼叫 約 7.5 秒
規則層耗時 < 0.01 秒

驗證呼叫次數與規則候選數成正比。目前驗證層沒有擋下任何誤報,卻佔了 70% 的呼叫次數,最該優先優化。

版本選擇

  • Day 7 基線仍是 F1 最高的版本(0.789),Day 11 還差 9.6%
  • 但 Day 11 是第一個 Recall 接近基線(0.722 vs 0.739)且醫療系統大幅改善的版本
  • 如果場景重視「不漏報」(例如醫療、金融的合規審查),補充層的價值大於它的時間成本;如果只要快速初篩,只跑層 1 即可

從今天學到的權衡

  • LLM 擅長「找」,不擅長「否決」:開放式的補充任務效果好;帶著暗示的是非題,小模型容易附和
  • 評測資料必須一致:同一個 detector 在 Day 11 小資料集上 F1 0.867、在 Day 7 資料集上 0.714,換資料集就能讓數字「變好看」
  • LLM 輸出要當成不可信輸入處理:擷取 JSON、驗證需求編號、解析失敗時降級,三件事都要做

提交變更

git add src/llm_verifier.py tests/test_day11_llm.py tests/test_day11_llm_unit.py
git commit -m "Day 11: 實現 LLM 驗證與補充層,接入 Ollama Mistral 7B"

明天預告

今天三組 SRS 就花了 74.5 秒,其中大部分時間花在逐一呼叫 LLM。明天 Day 12 會處理這個瓶頸:多層快取(記憶體 + 磁碟)與批量驗證,並用同一套評測確認加速之後準確率沒有下降。


上一篇
Day 10:混合方案與實戰部署
下一篇
Day 12:性能優化與批量處理
系列文
解決需求規格書矛盾:用 Claude Code × MCP 實作自律型文檔審查 Agent 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言