iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0

第 2 週結束時,系統已經能從瀏覽器登入、提交需求、看到衝突結果。但檢測品質本身停在 Day 12:在 Day 7-10 共用資料集上,Mistral 7B 版本 F1 = 0.767,仍比 Day 7 的基線 0.789 低。

第 3 週的目標是把 F1 推到 0.85 以上。最直覺的做法是「改改 prompt 再跑一次」,但 Day 8-10 已經示範過這樣做的下場:規則越改越多,F1 反而從 0.789 掉到 0.388。

所以今天先不改任何檢測邏輯,而是把每一個錯誤攤開來看,搞清楚它錯在哪一層、為什麼錯。

這一天在系列中的位置

第 2 週:檢測優化與生產系統 → Day 8-17 完成,Day 12 F1 = 0.767
第 3 週:智能優化與增強

  • Day 18 → 失敗案例分析 ← 今天
  • Day 19 → 依分析結果改進 prompt
  • Day 20 → 重試機制與輸出 guardrail

今日目標

讀完這篇後,你會完成:

  1. 追蹤每一層的決定:驗證層記下每個候選被保留或丟棄的理由,補充層記下 LLM 的原始回應
  2. 失敗分析工具 src/failure_analysis.py:對照標準答案,把每個誤報 / 漏報歸類到「哪一層出錯」,並標記文字特徵
  3. 失敗案例報告 reports/day18_failures.md:用 Mistral 7B 實際跑出來
  4. 逐筆歸因:區分是 prompt 問題、模型能力問題,還是標準答案本身有爭議
  5. 明天的改進假設:每一個都對應到具體的失敗案例

問題背景:為什麼要先分析

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
  • 輸入:Day 7-10 共用資料集(3 份 SRS、15 個標準答案衝突)與 Day 12 的 OptimizedDetector
  • 輸出:每份 SRS 的 CaseReport 與 Markdown 報告
  • 檔案:src/failure_analysis.py(新增);src/performance_optimizer.py、src/llm_verifier.py(修改:加上追蹤紀錄)
  • 下游:Day 19 用同一個工具比較改 prompt 前後的差異

專案結構變化:

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)。


代碼示例

1. 讓每一層留下紀錄

原本的 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。這兩種情況原本都是默默丟掉,現在會出現在報告裡。

2. 失敗類別

建立 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)

特徵標記刻意做得很粗:它們不是用來自動判斷對錯,而是讓人一眼看出「這批錯誤有沒有共同點」。

3. 歸類每一筆結果

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

光這張表就改變了改進方向:

  • 沒有任何「驗證誤刪」:驗證層從沒把真的衝突刪掉,它的問題只有「太寬鬆」
  • 沒有任何「補充層誤報」:補充層回報的 7 個全都正確,它的問題只有「找得不夠多」
  • 誤報全部來自驗證放行,漏報全部是兩層都沒找到

逐筆分析

誤報:驗證層放行了 2 個

需求 A 需求 B Mistral 的理由
支持離線消息存儲 30 天內自動刪除 帳戶刪除後,數據永久保存 自動刪除帳戶刪除後的數據與永久保存帳戶刪除後的數據是矛盾的
帖子內容採用明文存儲 不支持任何加密機制 明文存儲與不支持任何加密機制是矛盾的
  • 第 1 筆:描述對象不同。前者講的是「離線消息」,後者講的是「帳戶刪除後的數據」。Mistral 的理由把兩者混成「帳戶刪除後的數據」,是讀錯了需求
  • 第 2 筆:方向相同。「明文存儲」和「不支持加密」都是不加密,兩者一致。Day 12 的 prompt 已經寫了「注意否定詞」,但 Mistral 仍然只看到「明文」與「加密」這兩個關鍵字

Day 12 的批量 prompt 其實已經擋下了另外 2 個同類型的誤報(「明文顯示訂單 vs 不需要加密個人信息」、「明文存儲 vs 支持端到端加密」)。剩下這 2 個說明:光在 prompt 裡寫一句「注意否定詞」不夠,模型需要看到「什麼樣的配對不算衝突」的具體例子。

歸因:prompt 問題(缺少反例),可以改。

漏報:兩層都沒找到 4 個

需求 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 的目標。

同時要避免兩個陷阱:

  • 不能把測試案例直接寫進 prompt 當例子:那樣只是讓模型背答案,換一份 SRS 就沒用了。反例必須用資料集裡沒有的句子
  • 需要保留集:Day 11 的 create_test_cases() 有另一組 3 份 SRS,明天會用它檢查改進是否只對 Day 7 資料集有效

權衡與限制

  • 資料集很小:3 份 SRS、15 個標準答案,一筆錯誤就會讓某份 SRS 的 Recall 變動 0.17。今天的結論是「方向」,不是精確的數字
  • 特徵標記很粗:has_negation 只看有沒有「不、無、非」等字,「不同」也會被當成否定詞。目前只用來輔助人工判讀
  • 標準答案有 2 筆有爭議:今天選擇不動標準答案,只在分析中標記。修改標準答案會讓 Day 7-17 的所有數字失去可比性
  • 分析本身也要時間:每次分析約 56 秒。Day 19 若要比較多個 prompt 版本、每個跑多次,時間會快速累加

提交變更

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 的保留集上確認改進不是只對這份資料有效。


上一篇
Day 17:刷新令牌與會話管理系統
系列文
解決需求規格書矛盾:用 Claude Code × MCP 實作自律型文檔審查 Agent 共 18 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言