Day 10 的逐筆分析找到了根本原因:規則看不懂同義詞(「多個主治醫生」≠「多用戶」)、否定詞(「不需要加密」仍被當成「加密」)和領域知識(「公開訪問」vs「HIPAA 認證」)。再多寫幾條關鍵字也解決不了。
今天換個方向:規則照樣保留,改讓 LLM 幫規則把關、補漏。我們會接上本地的 Ollama + Mistral 7B,量出它帶來多少改善,以及它做不到什麼。
第 1 週:讓 SRS 從黑盒變透明 → Day 1-7 架構、分塊、工作流與評測系統,基線 F1 = 0.789
第 2 週:檢測優化與生產系統 → Day 8-10 規則優化失敗,今天 Day 11 引入 LLM 驗證與補充
今天要完成:
LLMEnhancedDetector:規則 → LLM 驗證 → LLM 補充(src/llm_verifier.py)MockOllamaLLM,實際評測用 OllamaLLM
預期結果(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(新增)專案結構變化:
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 相同。
在 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
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。
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,驗證層才會真正起作用。
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。
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 ===============================
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選項的原因:比較不同版本時,資料集必須相同。
同一組資料集(電商 / 醫療 / 社交媒體):
版本 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 都相同。
補充層新增了 7 個正確衝突,0 個誤報:
| 測試集 | LLM 補充找到的衝突 |
|---|---|
| 電商 | 「購物車實時同步到伺服器」vs「支持本地離線購物車」 |
| 醫療 | 「多個主治醫生」vs「僅一個醫生可編輯」 |
| 醫療 | 「僅一個醫生可編輯」vs「單醫生操作設計」 |
| 醫療 | 「患者數據可公開訪問」vs「HIPAA 認證」 |
| 社交 | 「帖子 < 100MB」vs「帖子 < 1GB」 |
| 社交 | 「支持端到端加密」vs「不支持任何加密機制」 |
| 社交 | 「離線消息存儲 30 天」vs「30 天內自動刪除」 |
Day 10 分析的三類漏報(同義詞、領域知識、數值單位)都有被補上。醫療系統的 Recall 從 0.167 升到 0.667。
規則層的 4 個誤報,Mistral 7B 全部判定為「是,這是衝突」。例如:
需求 1:支持明文顯示訂單細節
需求 2:不需要加密用戶的個人信息
Mistral:是。需求 A 要求訂單細節以明文顯示,而需求 B 要求不需要加密用戶的個人信息,
這兩個需求之間存在衝突……
兩條需求其實都偏向「不加密」,並不矛盾,但模型順著「初始檢測理由:加密 vs 明文」的方向附和了。這是小模型常見的附和偏誤:提示詞裡已經暗示了答案,模型就傾向同意。所以 Precision 的變化(0.633 → 0.764)完全來自補充層增加了正確結果,而不是驗證層濾掉了錯誤結果。
| 漏掉的衝突 | 類型 |
|---|---|
| 「頁面加載 < 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% 的呼叫次數,最該優先優化。
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 會處理這個瓶頸:多層快取(記憶體 + 磁碟)與批量驗證,並用同一套評測確認加速之後準確率沒有下降。