iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0

Day 20 加上 guardrail 之後,「驗證 v1 + 補充 v2 + guardrail」在 Day 7-10 共用資料集上達到 F1 0.897,保留集 0.933。第 3 週的最後一天要做兩件事:

  1. 補上一個至今沒涵蓋的盲點:Day 6 以來,所有檢測都是「兩條需求之間」的衝突。像「我們推行 100% 彈性居家辦公,但所有員工每天早上八點必須準時到公司打卡」這種一句話自己打架的需求,規則層兩兩配對永遠不會檢查到它,補充層也只找「兩個 req_id」的衝突
  2. 用 A/B 測試決定要不要採用:新功能不能只看它能找到什麼,還要看它在既有資料上會不會帶來誤報、要花多少成本。這也是 Day 18-20 一路的做法,今天把它做成可以重複執行的框架

這一天在系列中的位置

第 3 週:智能優化與增強

  • Day 18 → 失敗案例分析
  • Day 19 → Prompt 版本管理:H1 不成立、H2/H3 成立
  • Day 20 → 重試機制與輸出 guardrail:F1 0.897
  • Day 21 → 自我矛盾檢測、A/B 測試、第 3 週回顧 ← 今天

今日目標

讀完這篇後,你會完成:

  1. 第 4 層:單條需求自我矛盾檢查 src/self_contradiction.py:每條需求問 LLM 一次,結果以 req_id_1 == req_id_2 表示
  2. A/B 測試框架 src/ab_test.py:固定資料集,對多個設定各跑多次,比較指標、呼叫次數與穩定性
  3. 實際的 A/B 結果:Day 12 原版 vs Day 20 改進版 vs 加上第 4 層
  4. 決定第 4 層的預設值:API 與 CLI 分開決定
  5. 第 3 週回顧

問題背景

為什麼兩兩比較找不到自我矛盾

Day 7 起的規則層對所有 i < j 的需求配對做檢查,Day 12 的批量驗證也是一題一對需求。自我矛盾的兩個主張寫在同一條需求裡:

我們推行 100% 彈性居家辦公,但所有員工每天早上八點必須準時到公司打卡進辦公室。
本產品完全免費提供給所有人使用,您只需每月支付新台幣 300 元的固定訂閱費用。

這類句子在使用者直接輸入的需求裡很常見,卻從來不會成為「一對」,所以前三層都看不到。

為什麼需要 A/B 測試

自我矛盾檢查的成本很明確:每條需求多 1 次 LLM 呼叫。Day 7 資料集有 29 條需求,原本整套檢測只要 7 次呼叫,加上這一層就變成 36 次。

它的效益卻不確定:Day 7-10 的三份 SRS 裡並沒有自我矛盾的需求,加上去最好的情況是「什麼都沒變」,最壞的情況是帶來新的誤報。這種「成本確定、效益不確定」的改動,正是 A/B 測試要回答的問題。


實現方法

OptimizedDetector.detect_conflicts(..., enable_self_check=True)
  層 1:規則 → 層 2:批量驗證 → 層 3:LLM 補充
  層 4:SelfContradictionChecker.check(constraints)
          └─ 每條需求:查緩存(以原文為鍵)→ 未命中才問 LLM(可經 guardrail)
          └─ 判定為矛盾 → Conflict(req_id_1 = req_id_2, type = 自相矛盾, detection_method = self_check)

src/ab_test.py
  CONFIGS = baseline / improved / improved_self
  run_ab(configs, datasets, runs)  ← 底層呼叫 Day 18 的 failure_analysis.analyze()
  summarize() → 平均、F1 範圍、是否穩定、平均呼叫次數
  • 輸入:需求清單;A/B 測試另外需要資料集與設定名稱
  • 輸出:自我矛盾的 Conflict;A/B 測試報告 reports/day21_ab.md
  • 檔案:src/self_contradiction.py、src/ab_test.py(新增);src/prompts.py、src/guardrails.py、src/performance_optimizer.py、src/failure_analysis.py、src/api_main.py(修改)
  • 下游:Day 22 的審查報告會為自我矛盾加上獨立的來源標籤

專案結構變化:

srs-review-agent/
├── src/
│   ├── prompts.py                 ← 修改:build_self_check_prompt
│   ├── guardrails.py              ← 修改:SelfCheckItem、check_self_check_response
│   ├── self_contradiction.py      ← 【新增】SelfContradictionChecker、14 句範例
│   ├── performance_optimizer.py   ← 修改:第 4 層、ConflictType.SELF_CONTRADICTION
│   ├── failure_analysis.py        ← 修改:enable_self_check、FP_SELF_CHECK
│   ├── ab_test.py                 ← 【新增】A/B 測試框架
│   └── api_main.py                ← 修改:SRS_SELF_CHECK
├── tests/
│   ├── test_day21_self_contradiction.py  ← 【新增】8 個測試
│   └── test_day21_ab_test.py             ← 【新增】5 個測試
└── reports/
    └── day21_ab.md                ← 【新增】A/B 測試報告

今天不需要新的套件。


代碼示例

1. 一次只問一條

src/prompts.py 新增自我矛盾的 prompt:

# 一次只問一條:Mistral 7B 在批量版本中只回答「有矛盾」的題目,其他題直接略過;
# 改成「只列出有矛盾的題號」後,14 題只列出 1 題(8 題有矛盾)
SELF_CHECK_HEADER = (
    "你是需求工程師。請判斷以下這一條軟體需求本身是否自相矛盾。\n"
    "\n"
    "判斷原則:\n"
    "1. 先找出句子中的每一個主張,再檢查它們能不能同時成立。\n"
    "2. 注意「完全」「100%」「所有」「絕對」「全程」等絕對用語:句子的其他部分只要違反它,就是自相矛盾。\n"
    "3. 有例外、有條件、有前後步驟,但邏輯上可以同時成立的,不是自相矛盾。\n"
    "\n"
    "例子(與題目無關):\n"
    "- 「系統全年無休不停機,每晚 12 點停機維護一小時」→ 自相矛盾:不停機與每晚停機無法同時成立。\n"
    "- 「本服務不收集任何個人資料,註冊時需填寫身分證字號」→ 自相矛盾:身分證字號就是個人資料。\n"
    "- 「匿名用戶可以瀏覽文章,發表留言時需要登入」→ 不是自相矛盾:兩個要求針對不同操作。\n"
    "- 「檔案上傳上限 10 MB,付費用戶放寬為 100 MB」→ 不是自相矛盾:這是有條件的例外。\n"
)

JSON_SELF_CHECK_FORMAT = '{"is_contradiction": true, "reason": "簡短理由"}'

最上面的註解記錄了這個設計的由來:Day 12 用批量驗證省下大量呼叫,自然會想對自我矛盾做同樣的事。但先前的嘗試發現,Mistral 7B 在批量版本中只回答「有矛盾」的題目、其他題直接略過;改成「只列出有矛盾的題號」,14 題裡實際有 8 題矛盾,卻只列出 1 題。Day 20 的 guardrail 能要求補答缺題,但當模型系統性地略過大部分題目時,重試的成本會比逐條問更高。所以這一層回到最簡單的做法:一次一條。

例子同樣遵守 Day 19 的原則:用的主題(停機維護、身分證字號、匿名留言、上傳上限)都不在測試範例中。

2. 檢查回應

src/guardrails.py 為這一層新增 schema 與檢查函數,沿用 Day 20 的 (answer, errors) 慣例:

class SelfCheckItem(BaseModel):
    """Day 21:單條需求的自我矛盾判斷"""
    is_contradiction: bool
    reason: str = ""


def check_self_check_response(raw: str) -> Tuple[Optional[Tuple[bool, str]], List[str]]:
    """檢查自我矛盾回應;判定為矛盾時必須附上理由(前端要顯示給使用者)"""
    data, errors = _load_json(raw)
    if data is None:
        return None, errors
    try:
        parsed = SelfCheckItem.model_validate(data)
    except ValidationError as e:
        return None, [_format_validation_error("回應", e)]
    if parsed.is_contradiction and not parsed.reason.strip():
        return (True, ""), ["判定為自相矛盾時,reason 不可為空"]
    return (parsed.is_contradiction, parsed.reason), []

「判定為矛盾時理由不可為空」是這一層特有的規則:一句話被標成自相矛盾,使用者一定會問「哪裡矛盾?」,沒有理由的判斷沒有用處。啟用 guardrail 時,缺理由會觸發 Day 20 的修復重試。

3. 第 4 層

建立 src/self_contradiction.py:

class SelfContradictionChecker:
    def __init__(self, llm, cache=None, use_guardrail: bool = False, max_retries: int = 2):
        self.llm = llm
        self.cache = cache
        self.guard = GuardedCaller(llm, max_retries=max_retries) if use_guardrail else None
        self.llm_call_count = 0
        self.decisions: List[Dict] = []

    @staticmethod
    def _cache_key(text: str) -> str:
        # 加上前綴,不會與批量驗證(兩條需求)的鍵相撞
        return hashlib.sha256(f"self_check\n{text}".encode("utf-8")).hexdigest()

    def _ask(self, text: str) -> Tuple[bool, str]:
        prompt = build_self_check_prompt(text)
        if self.guard is not None:
            calls_before = self.guard.stats.llm_calls
            answer, _ = self.guard.call(prompt, check_self_check_response)
            self.llm_call_count += self.guard.stats.llm_calls - calls_before
        else:
            self.llm_call_count += 1
            answer, _ = check_self_check_response(self.llm.invoke(prompt))
        # 無法解析時視為沒有矛盾:這一層是「找新問題」,不因格式問題多報
        return answer if answer is not None else (False, "LLM 回應無法解析")

注意最後一行的策略和 Day 12 的驗證層相反:

層 格式錯誤時 理由
批量驗證(Day 12) 保留候選 候選是規則找到的,丟掉可能漏報
自我矛盾(Day 21) 視為沒有矛盾 這一層是在「找新問題」,不確定時不該憑空多報

check() 逐條檢查需求,並把每一條的判斷記在 decisions,和 Day 18 的 BatchLLMVerifier.decisions 一樣,供失敗分析使用。緩存以需求原文為鍵:同一句話出現在不同 SRS、或同一份 SRS 重跑時,不會重複呼叫 LLM。

src/performance_optimizer.py 在補充層之後加上第 4 層:

# 第 4 層:單條需求自我矛盾(Day 21)
if enable_self_check and self.self_checker is not None:
    calls_before = self.self_checker.llm_call_count
    for req_id, reason in self.self_checker.check(constraints):
        conflicts.append(Conflict(
            req_id_1=req_id, req_id_2=req_id,
            conflict_type=ConflictType.SELF_CONTRADICTION,
            severity=Severity.HIGH,
            description=reason,
            confidence=0.75,
            reasoning=f"LLM 自我矛盾檢查:{reason}",
            verified=True,
            verification_result="LLM 發現",
            detection_method="self_check",
        ))
    self.batch_verifier.llm_call_count += self.self_checker.llm_call_count - calls_before

req_id_1 == req_id_2 是自我矛盾的表示法:API、前端表格、資料庫都不必改格式。前端的 ConflictTable 只做了一個小調整:兩個編號相同時只顯示一次需求。

4. A/B 測試框架

建立 src/ab_test.py。每個設定對應系列中的一個版本:

CONFIGS: Dict[str, Dict] = {
    # Day 12 / Day 18:v1 prompt、無 guardrail
    "baseline": dict(prompt_version="v1", use_guardrail=False, enable_self_check=False),
    # Day 20:驗證 v1 + 補充 v2 + guardrail
    "improved": dict(verify_prompt_version="v1", supplement_prompt_version="v2",
                     use_guardrail=True, enable_self_check=False),
    # Day 21:再加上第 4 層單條需求自我矛盾檢查
    "improved_self": dict(verify_prompt_version="v1", supplement_prompt_version="v2",
                          use_guardrail=True, enable_self_check=True),
}

設定就是 Day 18 analyze() 的關鍵字參數,所以 A/B 測試與失敗分析用的是完全相同的評分邏輯。為此,src/failure_analysis.py 加上了 enable_self_check 參數,自我矛盾結果若不在標準答案中,歸類為新的「自我矛盾檢查誤報」。

執行與統計:

def run_ab(configs, datasets, runs, llm_factory) -> List[RunResult]:
    unknown = [c for c in configs if c not in CONFIGS]
    if unknown:
        raise ValueError(f"未知的設定:{', '.join(unknown)}(可用:{', '.join(CONFIGS)})")
    results = []
    for dataset, test_cases in datasets.items():
        for config in configs:
            for run in range(1, runs + 1):
                results.append(run_once(config, dataset, run, test_cases, llm_factory()))
    return results

每次執行都建立新的 LLM 客戶端,避免狀態跨執行殘留。summarize() 依(資料集、設定)分組,算出平均 Precision / Recall / F1、F1 的最小與最大值、平均呼叫次數;Summary.stable 在多次執行的 F1 完全相同時為真。Day 18 發現 Mistral 的說明文字在不同次執行間會變,「穩定」這一欄就是用來確認指標本身沒有跟著變。


驗證結果

測試(離線)

cd srs-review-agent
python3 -m pytest tests/test_day21_self_contradiction.py tests/test_day21_ab_test.py -v
tests/test_day21_self_contradiction.py::test_check_response PASSED       [  7%]
tests/test_day21_self_contradiction.py::test_prompt_contains_only_one_requirement PASSED [ 15%]
tests/test_day21_self_contradiction.py::test_checker_finds_contradictions PASSED [ 23%]
tests/test_day21_self_contradiction.py::test_unparseable_response_is_not_reported PASSED [ 30%]
tests/test_day21_self_contradiction.py::test_guardrail_repairs_missing_reason PASSED [ 38%]
tests/test_day21_self_contradiction.py::test_cache_by_text PASSED        [ 46%]
tests/test_day21_self_contradiction.py::test_detector_integration PASSED [ 53%]
tests/test_day21_self_contradiction.py::test_disabled_without_llm_or_flag PASSED [ 61%]
tests/test_day21_ab_test.py::test_run_counts_and_stability PASSED        [ 69%]
tests/test_day21_ab_test.py::test_self_check_cost_and_classification PASSED [ 76%]
tests/test_day21_ab_test.py::test_compare PASSED                         [ 84%]
tests/test_day21_ab_test.py::test_unknown_config PASSED                  [ 92%]
tests/test_day21_ab_test.py::test_render_markdown PASSED                 [100%]

============================== 13 passed in 0.05s ==============================

A/B 框架的測試用一個依 prompt 類型動態回答的假 LLM:批量驗證每題都判為衝突、補充層不回報、自我矛盾只在需求含「100%」時成立。test_self_check_cost_and_classification 確認加上第 4 層後呼叫次數正好多「需求條數」次,且「100% 居家辦公」這條不在標準答案中時算 FP、在標準答案中(以 ("REQ-3", "REQ-3") 表示)時算 TP。

自我矛盾檢查:14 句範例

src/self_contradiction.py 內建 14 句範例:前 8 句是自我矛盾的句子,後 6 句是「有條件、有例外,但不矛盾」的正常需求:

python3 -m src.self_contradiction
🔍 Day 21 自我矛盾檢測(14 條,46.3 秒,LLM 呼叫 14 次)
   TP=7  FP=0  FN=1  Precision=1.000  Recall=0.875

連續跑兩次,每一句的判斷完全相同。6 句正常需求全部正確判為「正常」,例如「頁面載入時間需低於 2 秒,尖峰時段允許放寬至 3 秒」的理由是「有條件的例外,不是自相矛盾」,用的正是 prompt 原則 3 的說法。

唯一漏判的是:

這家店是本地人私藏的隱藏版美食,每天門口都排滿了長達數公里的排隊人潮。
→ 正常:這是一個描述性的詞句,不包含任何絕對用語,因此不是自相矛盾。

「私藏、隱藏版」與「排隊數公里」的矛盾需要常識推理,句子裡沒有「完全」「100%」這類字。Mistral 的理由直接引用了 prompt 原則 2(絕對用語),說明它把這條原則當成了必要條件:沒有絕對用語,就不是矛盾。這和 Day 19 看到的「照抄 prompt 文字」是同一個現象:小模型很容易把 prompt 裡的提示當成唯一的判斷依據。

A/B 測試

python3 -m src.ab_test --ollama --runs 2 --out reports/day21_ab.md

3 個設定 × 2 套資料集 × 各 2 次,共 12 次執行,每一組的兩次結果完全相同:

資料集 設定 Precision Recall F1 TP / FP / FN LLM 呼叫
Day 7 baseline(Day 12) 0.905 0.722 0.767 11 / 2 / 4 6
Day 7 improved(Day 20) 0.905 0.917 0.897 14 / 2 / 1 7
Day 7 improved_self(Day 21) 0.821 0.917 0.861 14 / 3 / 1 36
Day 11 baseline 1.000 0.778 0.867 6 / 0 / 2 6
Day 11 improved 1.000 0.889 0.933 7 / 0 / 1 6
Day 11 improved_self 1.000 0.889 0.933 7 / 0 / 1 22

前兩列重現了 Day 18 與 Day 20 的數字,確認框架與先前的實驗一致。加上第 4 層:

  • Day 7 資料集多了 1 個誤報,F1 0.897 → 0.861
  • 保留集沒有任何變化,F1 維持 0.933
  • 兩套資料集都沒有多找到任何真衝突(這兩套資料本來就沒有自我矛盾的標準答案)
  • LLM 呼叫增加 5.1 倍與 3.7 倍(7 → 36、6 → 22),正好是需求條數

多出來的誤報是哪一條?單獨對 Day 7 資料集的 29 條需求跑第 4 層,只有一條被判為矛盾:

電商平台 REQ-4.4:不需要加密用戶的個人信息
→ 需求中明確指出不需要加密用戶的個人信息,但是未指出不需要加密其他敏感資料,因此可能存在隱私漏洞

模型指出的是安全風險,不是自我矛盾。巧合的是,這條需求正是 Day 18 那個「標準答案有爭議」的漏報(「多用戶註冊登入 vs 不需要加密個人信息」)。它確實有問題,只是不屬於「自相矛盾」這個類別。

耗時方面,improved_self 的第二次執行花了 3091 秒,第一次只有 146 秒。這段期間同一台機器正在建置 Docker 映像檔(Day 25-26),所以秒數再次不適合比較,Day 20 的結論依然成立:比較成本請看呼叫次數。


決定:第 4 層要不要預設開啟

A/B 的結論很清楚:對完整的規格書,第 4 層沒有帶來好處,卻帶來 1 個誤報與 3.7-5.1 倍的呼叫。但它在 14 句範例上找到 8 句中的 7 句,這是前三層完全做不到的。

差別在輸入的型態:

使用情境 輸入 第 4 層
網站(API) 使用者手動輸入的少量短句 預設開啟:條數少、成本低,正是自我矛盾常出現的地方
整份規格書(Day 24 的 CLI) 數十條、上百條需求 預設關閉:加上 --self-check 才執行

src/api_main.py 改成可以用環境變數關閉:

if USE_OLLAMA:
    # 網站上的輸入多為少量短句,自我矛盾檢查預設開啟;
    # 每條需求多 1 次 LLM 呼叫(Day 21 A/B:29 條需求 7 → 36 次),可用 SRS_SELF_CHECK=0 關閉
    return dict(enable_verification=verify, batch_size=batch_size,
                verify_strategy="all", enable_supplement=True,
                enable_self_check=os.getenv("SRS_SELF_CHECK", "1") == "1")

而 F1 的正式數字,仍以不含第 4 層的 Day 20 設定為準:Day 7 資料集 0.897、保留集 0.933。


第 3 週回顧

天 做了什麼 結論 Day 7 F1
Day 18 把 6 個錯誤逐筆歸類 誤報全來自驗證層放行,漏報全是兩層都沒找到 0.767
Day 19 prompt 版本化,分層對照 H1(反例)無效;H2、H3 有效但被格式錯誤抵銷 0.659
Day 20 Pydantic guardrail、修復重試 修好 1 個 JSON 錯誤,H2 的效果顯現 0.897
Day 21 第 4 層、A/B 框架 對規格書無益,對短句有用;依情境決定預設值 0.897

這一週最重要的不是 F1 提高了 0.130,而是每一個改動都能說出它影響了哪幾筆結果:

  • 沒有 Day 18 的分類,就不會知道驗證層從沒誤刪過、補充層從沒誤報過,改進方向會完全不同
  • 沒有 Day 19 的分層對照,看到 v2 讓 F1 變成 0.602 就會放棄,錯過 H2
  • 沒有 Day 21 的 A/B,第 4 層會因為「多一種檢查聽起來更完整」而預設開啟,在規格書上默默增加誤報與成本

第 3 週還沒解決的問題:驗證層放行的 2 個誤報(Day 19 證明加反例無效)、1 個有爭議的標準答案,以及資料集太小(15 + 8 個標準答案)。


權衡與限制

  • 自我矛盾的評測只有 14 句:Precision 1.000、Recall 0.875 是 14 句上的結果,而且這 14 句的作者也是 prompt 的設計者,可能有偏差
  • 兩套資料集都沒有自我矛盾的標準答案:A/B 只能衡量第 4 層的「副作用」,無法衡量它在規格書上的「效益」
  • 逐條呼叫的成本隨需求數線性增加:一份 300 條需求的規格書,第 4 層就要 300 次呼叫。Day 27 的性能基準會量測這一點
  • A/B 只比較平均與範圍:每組只跑 2 次,而且兩次完全相同,談不上統計檢定。如果之後換成輸出不固定的模型,需要更多次執行

提交變更

git add src/self_contradiction.py src/ab_test.py src/prompts.py src/guardrails.py \
        src/performance_optimizer.py src/failure_analysis.py src/api_main.py \
        frontend/components/ConflictTable.jsx \
        tests/test_day21_self_contradiction.py tests/test_day21_ab_test.py reports/day21_ab.md
git commit -m "Day 21: 單條需求自我矛盾檢查、A/B 測試框架與第 3 週結果"

明天預告

第 3 週把檢測品質從 0.767 推到 0.897,但所有結果都還是 JSON,或是前端一次只能看一個任務的表格。第 4 週的主題是「讓系統可以交付」。明天 Day 22 我們把檢測結果整理成給寫規格書的人閱讀的審查報告:依嚴重度統計、找出牽涉最多衝突的熱點需求,並明確標出哪些結果是 LLM 判斷、需要人工複核的。


上一篇
Day 20:重試機制與輸出 Guardrail
下一篇
Day 22:審查報告生成與統計分析
系列文
解決需求規格書矛盾:用 Claude Code × MCP 實作自律型文檔審查 Agent 共 25 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言