第 3 週的目標是把 F1 推到 0.85 以上。最直覺的做法是「改改 prompt 再跑一次」,但 Day 8-10 已經示範過這樣做的下場:規則越改越多,F1 反而從 0.789 掉到 0.388。
所以今天先不改任何檢測邏輯,而是把每一個錯誤攤開來看,搞清楚它錯在哪一層、為什麼錯。
第 2 週:檢測優化與生產系統 → Day 8-17 完成,Day 12 F1 = 0.767
第 3 週:智能優化與增強
讀完這篇後,你會完成:
src/failure_analysis.py:對照標準答案,把每個誤報 / 漏報歸類到「哪一層出錯」,並標記文字特徵reports/day18_failures.md:用 Mistral 7B 實際跑出來Day 12 的評測只給我們一個數字:F1 0.767。這個數字告訴我們「不夠好」,但沒說哪裡不夠好。
回想 Day 12 的三層架構:
層 1:規則 → 產生候選
層 2:批量驗證 → 保留或丟棄候選
層 3:LLM 補充 → 找規則漏掉的衝突
一個錯誤可能出在任何一層,而修法完全不同:
| 錯誤 | 出錯的層 | 該改的地方 |
|---|---|---|
| 誤報是規則產生、驗證又放行 | 驗證層 | 驗證 prompt |
| 誤報是補充層編出來的 | 補充層 | 補充 prompt |
| 漏報是規則找到了、驗證卻刪掉 | 驗證層 | 驗證 prompt(太嚴格) |
| 漏報是兩層都沒找到 | 補充層 | 補充 prompt,或承認模型做不到 |
如果不先分類,就可能在驗證 prompt 上花一整天,結果問題其實在補充層。
tests/test_day11_llm.py 的 create_day7_test_cases()(Day 7-10 共用資料集)
↓ 每份 SRS
OptimizedDetector.detect_conflicts(verify_strategy="all", enable_supplement=True)
├─ batch_verifier.decisions 每個候選:保留 / 丟棄、理由、是否來自緩存
└─ supplementer.last_supplement 原始回應、被丟棄的項目、解析錯誤
↓
analyze_case():與標準答案對照 → Finding(TP / FP / FN、類別、特徵)
↓
render_markdown() → reports/day18_failures.md
OptimizedDetector
CaseReport 與 Markdown 報告src/failure_analysis.py(新增);src/performance_optimizer.py、src/llm_verifier.py(修改:加上追蹤紀錄)專案結構變化:
srs-review-agent/
├── src/
│ ├── llm_verifier.py ← 修改:last_supplement 追蹤
│ ├── performance_optimizer.py ← 修改:decisions 追蹤
│ └── failure_analysis.py ← 【新增】失敗分析工具
├── tests/
│ └── test_day18_failure_analysis.py ← 【新增】4 個測試(假 LLM,離線)
└── reports/
└── day18_failures.md ← 【新增】Mistral 7B 的實際報告
今天不需要新的套件。實際分析需要 Day 11 的 Ollama 環境(ollama pull mistral)。
原本的 BatchLLMVerifier 只把「保留的」候選交出去,被丟棄的就消失了,連理由都沒有。修改 src/performance_optimizer.py,在 __init__ 加上:
# Day 18:每個候選的驗證決定(保留 / 丟棄、理由、是否來自緩存),供失敗案例分析
self.decisions: List[Dict] = []
並在 _verify_batch_internal() 決定每個候選的去留時記下來:
for idx, conflict in enumerate(batch):
is_real, reason = results[idx]
self.decisions.append({
"req_id_1": conflict.req_id_1,
"req_id_2": conflict.req_id_2,
"text_1": pair_texts[idx][0],
"text_2": pair_texts[idx][1],
"kept": is_real,
"reason": reason,
"from_cache": idx in from_cache,
})
if is_real:
...
from_cache 很重要:如果一個判斷來自 Day 12 的緩存,改 prompt 之後它不會重新詢問 LLM。分析時要知道哪些結果其實是舊的。
同樣地,修改 src/llm_verifier.py 的 find_missing_conflicts(),記下補充層的原始回應:
response = self.llm.invoke(prompt)
self.verification_count += 1
self.last_supplement = {"raw": response, "rejected": [], "parse_error": None}
LLM 回傳不存在的需求編號時放進 rejected,JSON 解析失敗時記下 parse_error。這兩種情況原本都是默默丟掉,現在會出現在報告裡。
建立 src/failure_analysis.py。類別依「哪一層出錯」定義:
# 誤報(FP)
FP_RULE_PASSED = "規則誤報、驗證放行"
FP_SUPPLEMENT = "補充層誤報"
# 漏報(FN)
FN_VERIFY_DROPPED = "規則命中、驗證誤刪"
FN_NOT_FOUND = "兩層都沒找到"
另外用正規表達式標記三種文字特徵,它們都來自前幾天的觀察:
NEGATION_PATTERN = re.compile(r"不|無|非|沒有|禁止")
DIGIT_PATTERN = re.compile(r"\d")
def has_negation(text: str) -> bool:
"""是否含否定詞(Day 10-12 誤報的主要來源)"""
return bool(NEGATION_PATTERN.search(text))
def is_mostly_english(text: str) -> bool:
"""說明文字是否以英文為主(Day 11-13 看到補充層常回英文)"""
letters = re.findall(r"[A-Za-z]", text)
cjk = re.findall(r"[一-鿿]", text)
return len(letters) > 2 * len(cjk)
特徵標記刻意做得很粗:它們不是用來自動判斷對錯,而是讓人一眼看出「這批錯誤有沒有共同點」。
analyze_case() 先跑完整流程,再分兩輪歸類。第一輪看檢測到的每個衝突:
for c in conflicts:
pair = _pair(c.req_id_1, c.req_id_2)
detected_pairs.add(pair)
t1, t2 = texts.get(c.req_id_1, ""), texts.get(c.req_id_2, "")
from_supplement = c.detection_method == "llm_supplement"
detail = c.description if from_supplement else (c.verification_result or "")
if pair in expected:
outcome, category = "TP", ("補充層" if from_supplement else "規則層")
else:
outcome, category = "FP", (FP_SUPPLEMENT if from_supplement else FP_RULE_PASSED)
findings.append(Finding(outcome, category, c.req_id_1, c.req_id_2, t1, t2,
detail, _tags(t1, t2, detail)))
第二輪看標準答案中沒被檢測到的:
dropped = {_pair(d["req_id_1"], d["req_id_2"]): d
for d in verifier.decisions if not d["kept"]}
# 排序:frozenset 的迭代順序不固定,不排序的話每次報告的列順序都不同
for r1, r2 in sorted(sorted(p) for p in expected - detected_pairs):
pair = _pair(r1, r2)
t1, t2 = texts.get(r1, ""), texts.get(r2, "")
if pair in dropped:
category, detail = FN_VERIFY_DROPPED, dropped[pair]["reason"]
else:
category, detail = FN_NOT_FOUND, ""
findings.append(Finding("FN", category, r1, r2, t1, t2, detail, _tags(t1, t2)))
「規則命中、驗證誤刪」的判斷依據就是剛才加的 decisions:這一對需求出現在被丟棄的清單裡,就表示規則曾經找到它。漏報會附上驗證層丟棄它的理由,方便判斷 LLM 為什麼覺得它不是衝突。
需求對一律用 frozenset 表示,A↔B 與 B↔A 視為同一對,和 Day 12 的去重邏輯一致。
analyze() 對每份 SRS 都建立新的 OptimizedDetector:共用同一個的話,第二份 SRS 裡內容相同的需求對會直接讀緩存,就看不到 LLM 在這份 SRS 上的判斷了。報告產生(render_markdown)與統計(summarize、tag_counts)的完整代碼見 src/failure_analysis.py。
一開始我還設計了第三種漏報「補充層找到、但被 ID 檢查丟棄」,寫測試時才發現它不可能發生:被丟棄的項目用的是不存在的需求編號,不可能對上標準答案。所以改成在報告的「其他觀察」裡統計 LLM 編造了幾個編號。
tests/test_day18_failure_analysis.py 用一個照腳本回答的假 LLM,刻意製造每一種結果:
class ScriptedLLM:
"""照腳本回答的假 LLM:批量驗證依序回答 verify_answers,補充層回傳 supplement"""
def invoke(self, prompt: str) -> str:
self.call_count += 1
if "逐題判斷" in prompt:
return json.dumps({"results": [
{"index": i, "is_conflict": keep, "reason": "腳本" + ("保留" if keep else "丟棄")}
for i, keep in enumerate(self.verify_answers, start=1)
]}, ensure_ascii=False)
return json.dumps({"conflicts": self.supplement}, ensure_ascii=False)
9 條需求、4 個標準答案,規則層產生 3 個候選,腳本讓驗證層「保留、丟棄、保留」,補充層回傳 1 個正確、1 個錯誤(英文說明)、1 個編造編號的結果:
cd srs-review-agent
python3 -m pytest tests/test_day18_failure_analysis.py -v
tests/test_day18_failure_analysis.py::test_text_features PASSED [ 25%]
tests/test_day18_failure_analysis.py::test_classification PASSED [ 50%]
tests/test_day18_failure_analysis.py::test_tags_and_summary PASSED [ 75%]
tests/test_day18_failure_analysis.py::test_render_markdown PASSED [100%]
============================== 4 passed in 0.03s ===============================
test_classification 斷言 TP 2、FP 2、FN 2,每一筆都歸到預期的類別,被丟棄的漏報附上「腳本丟棄」這個理由,編造的 REQ-99 被計數。
python3 -m src.failure_analysis --ollama --out reports/day18_failures.md
約 56 秒、6 次 LLM 呼叫,指標與 Day 12 完全相同:
| 測試集 | Precision | Recall | F1 | TP | FP | FN |
|---|---|---|---|---|---|---|
| 電商平台 | 1.000 | 0.500 | 0.667 | 2 | 0 | 2 |
| 醫療系統 | 1.000 | 0.667 | 0.800 | 4 | 0 | 2 |
| 社交媒體 | 0.714 | 1.000 | 0.833 | 5 | 2 | 0 |
| 平均 | 0.905 | 0.722 | 0.767 | 11 | 2 | 4 |
依類別統計:
| 結果 / 類別 | 數量 |
|---|---|
| TP / 規則層 | 4 |
| TP / 補充層 | 7 |
| FP / 規則誤報、驗證放行 | 2 |
| FN / 兩層都沒找到 | 4 |
光這張表就改變了改進方向:
| 需求 A | 需求 B | Mistral 的理由 |
|---|---|---|
| 支持離線消息存儲 30 天內自動刪除 | 帳戶刪除後,數據永久保存 | 自動刪除帳戶刪除後的數據與永久保存帳戶刪除後的數據是矛盾的 |
| 帖子內容採用明文存儲 | 不支持任何加密機制 | 明文存儲與不支持任何加密機制是矛盾的 |
Day 12 的批量 prompt 其實已經擋下了另外 2 個同類型的誤報(「明文顯示訂單 vs 不需要加密個人信息」、「明文存儲 vs 支持端到端加密」)。剩下這 2 個說明:光在 prompt 裡寫一句「注意否定詞」不夠,模型需要看到「什麼樣的配對不算衝突」的具體例子。
歸因:prompt 問題(缺少反例),可以改。
| 需求 A | 需求 B | 特徵 |
|---|---|---|
| 每個時間段最多 50 名患者預約 | 每個時間段最多 1 名患者預約 | 雙方含數字 |
| 系統支持 100 並行用戶 | 系統支持 1000 並行用戶 | 雙方含數字 |
| 頁面加載時間 < 100ms | 系統吞吐量 > 10000 req/s | 雙方含數字 |
| 系統支持多用戶註冊和登入 | 不需要加密用戶的個人信息 | 否定詞 |
分成三種情況:
1. 同一屬性、不同數值(前 2 筆)
「最多 50 名」與「最多 1 名」、「100 並行」與「1000 並行」,描述的是同一個屬性,卻給了不同的值,照規格書寫的一定只能滿足其中一個。這是最明確的衝突,但補充 prompt 只說了「兩個需求無法同時成立」,Mistral 似乎不把「上限不同」當成「無法同時成立」。
社交媒體的「帖子 < 100MB vs < 1GB」補充層就找到了,理由是 "Both requirements specify different size limits"。模型有能力判斷數值衝突,只是沒有被明確要求。
歸因:prompt 問題(缺少「同一屬性、不同數值」的定義),可以改。
2. 性能取捨(第 3 筆)
「頁面加載 < 100ms」與「吞吐量 > 10000 req/s」並不是邏輯上的矛盾,兩者可以同時成立,只是在硬體有限時很難同時達成。Day 7 的標準答案把它標為衝突,說明是「可能衝突」。
歸因:標準答案有爭議。它比較接近「風險提示」而不是「衝突」。改 prompt 讓模型回報這一類,很可能同時帶來更多誤報。今天先記下來,不作為 Day 19 的改進目標。
3. 隱性安全風險(第 4 筆)
「支持多用戶註冊登入」與「不需要加密用戶的個人信息」,兩者也能同時成立。標準答案的理由是「個人信息明文存儲與多用戶系統衝突」,這需要「多用戶系統的個資應該加密」這個領域常識才能推出來。
歸因:標準答案有爭議,也可能超出 7B 模型的能力。和第 3 筆一樣,暫不作為改進目標。
補充層的說明幾乎都是英文。補充層 7 個正確結果中,說明是英文的有 6 到 7 個。prompt 是中文寫的,Mistral 卻常常用英文回答。這不影響 F1,但報告要給寫規格書的人看,英文說明會讓人讀不下去。
同一份資料跑多次,語言會變。這次分析我跑了 3 次,F1、TP、FP、FN 每次都完全相同,但「英文說明」的數量分別是 7、6、7:電商那一筆有時是中文、有時是英文。單獨用同一個 prompt 連續呼叫 3 次,輸出都一模一樣(加不加 seed 都一樣),所以變動可能來自模型載入或前一個請求的狀態。所以 Day 19 比較改進前後時,不能只跑一次,至少要確認指標是穩定的。
補充層沒有出現格式問題。3 次呼叫的 JSON 都解析成功,也沒有編造需求編號。但這只是 3 次,Day 20 會用更多次呼叫確認格式錯誤的比例。
根據上面的歸因,Day 19 要驗證三個假設:
| 編號 | 假設 | 對應案例 | 預期效果 |
|---|---|---|---|
| H1 | 驗證 prompt 加入「方向相同」「對象不同」的反例,Mistral 會丟棄這兩類候選 | 誤報 2 筆 | Precision 0.905 → 1.000 |
| H2 | 補充 prompt 明確定義「同一屬性給出不同數值」是衝突,並附一個例子,Mistral 會找到數值衝突 | 漏報前 2 筆 | Recall 0.722 → 約 0.83 |
| H3 | 補充 prompt 要求「說明一律使用繁體中文」,英文說明會消失 | 7 個英文說明 | 不影響 F1 |
H1 與 H2 都成立的話,粗估平均 F1 會落在 0.9 附近,超過 0.85 的目標。
同時要避免兩個陷阱:
create_test_cases() 有另一組 3 份 SRS,明天會用它檢查改進是否只對 Day 7 資料集有效has_negation 只看有沒有「不、無、非」等字,「不同」也會被當成否定詞。目前只用來輔助人工判讀git add src/failure_analysis.py src/performance_optimizer.py src/llm_verifier.py \
tests/test_day18_failure_analysis.py reports/day18_failures.md
git commit -m "Day 18: 失敗案例分析工具與 Mistral 7B 失敗報告"
今天找到的 6 個錯誤裡,4 個可以透過 prompt 改善。明天 Day 19 我們把驗證與補充的 prompt 抽成有版本的設定(v1 = Day 12 的原版,v2 = 依 H1-H3 修改),用今天的分析工具比較兩個版本,並在 Day 11 的保留集上確認改進不是只對這份資料有效。