第 3 週:智能優化與增強
讀完這篇後,你會完成:
src/self_contradiction.py:每條需求問 LLM 一次,結果以 req_id_1 == req_id_2 表示src/ab_test.py:固定資料集,對多個設定各跑多次,比較指標、呼叫次數與穩定性Day 7 起的規則層對所有 i < j 的需求配對做檢查,Day 12 的批量驗證也是一題一對需求。自我矛盾的兩個主張寫在同一條需求裡:
我們推行 100% 彈性居家辦公,但所有員工每天早上八點必須準時到公司打卡進辦公室。
本產品完全免費提供給所有人使用,您只需每月支付新台幣 300 元的固定訂閱費用。
這類句子在使用者直接輸入的需求裡很常見,卻從來不會成為「一對」,所以前三層都看不到。
自我矛盾檢查的成本很明確:每條需求多 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 範圍、是否穩定、平均呼叫次數
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(修改)專案結構變化:
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 測試報告
今天不需要新的套件。
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 的原則:用的主題(停機維護、身分證字號、匿名留言、上傳上限)都不在測試範例中。
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 的修復重試。
建立 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 只做了一個小調整:兩個編號相同時只顯示一次需求。
建立 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。
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 裡的提示當成唯一的判斷依據。
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 資料集的 29 條需求跑第 4 層,只有一條被判為矛盾:
電商平台 REQ-4.4:不需要加密用戶的個人信息
→ 需求中明確指出不需要加密用戶的個人信息,但是未指出不需要加密其他敏感資料,因此可能存在隱私漏洞
模型指出的是安全風險,不是自我矛盾。巧合的是,這條需求正是 Day 18 那個「標準答案有爭議」的漏報(「多用戶註冊登入 vs 不需要加密個人信息」)。它確實有問題,只是不屬於「自相矛盾」這個類別。
耗時方面,improved_self 的第二次執行花了 3091 秒,第一次只有 146 秒。這段期間同一台機器正在建置 Docker 映像檔(Day 25-26),所以秒數再次不適合比較,Day 20 的結論依然成立:比較成本請看呼叫次數。
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。
| 天 | 做了什麼 | 結論 | 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,而是每一個改動都能說出它影響了哪幾筆結果:
第 3 週還沒解決的問題:驗證層放行的 2 個誤報(Day 19 證明加反例無效)、1 個有爭議的標準答案,以及資料集太小(15 + 8 個標準答案)。
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 判斷、需要人工複核的。