Day 19 的分層對照實驗得到一個尷尬的結論:補充層的 v2 prompt 內容確實比較好(保留集 F1 0.867 → 0.933,也找到了「最多 50 名 vs 最多 1 名」這類數值衝突),但在主要資料集上,Mistral 7B 把醫療系統回應裡的一個 } 寫成 ],,整份 JSON 解析失敗,5 個正確結果全部丟掉,F1 反而從 0.767 掉到 0.659。
另外,批量驗證有一次 2 題只回答了 1 題,缺答的那題被預設保留,變成誤報。
這兩個都不是「模型不懂」,而是「模型寫錯格式」。今天在 LLM 與解析程式之間加一層 guardrail:檢查輸出、把錯誤告訴模型、請它重寫。
第 3 週:智能優化與增強
讀完這篇後,你會完成:
src/guardrails.py:用 Pydantic 定義批量驗證與補充層的回應格式,並檢查題號完整、需求編號存在FaultInjectingLLM 故意截斷回應,量測 guardrail 的修復能力與成本| 類型 | 例子 | 該怎麼處理 |
|---|---|---|
| 格式錯誤 | JSON 語法錯誤、缺題、欄位型別錯誤、編造需求編號 | 告訴模型錯在哪,請它重寫 |
| 連線錯誤 | Ollama 還沒啟動、模型載入中、逾時 | 等一下再試同一個請求 |
最直覺的重試是把同一個 prompt 再送一次。但 Day 11 起我們一直用 temperature: 0,Day 18 也實測過:同一個 prompt 連續問 3 次,輸出一模一樣。Day 19 醫療系統那份壞掉的 JSON,重跑幾次都會壞在同一個位置。
所以格式錯誤的重試一定要改變 prompt:把錯誤清單附在後面,模型看到的輸入不同,輸出才會不同。
連線錯誤則相反:問題不在 prompt,而在服務暫時不可用,所以要等一下、送出同一個請求。等待時間逐次加倍(指數退避),避免服務剛恢復時被大量重試壓垮。
BatchLLMVerifier._llm_verify_batch() LLMVerifier.find_missing_conflicts()
│ │
└───────────────┬───────────────────────────┘
▼
GuardedCaller.call(prompt, check)
├─ llm.invoke(prompt)
│ └─ ConnectionError → sleep(1s, 2s) → 同一個 prompt 重試
├─ check(raw) → (result, errors)
│ ├─ check_verify_response:每題都要回答、題號不重複不超出
│ └─ check_supplement_response:逐筆驗證、編號必須存在
├─ errors 為空 → 返回
└─ errors 不為空 → repair_prompt(prompt, errors) → 重試(最多 2 次)
用完次數 → 返回最後一次「部分有效」的結果
GuardrailStats 統計src/guardrails.py(新增);src/llm_verifier.py、src/performance_optimizer.py、src/failure_analysis.py、src/api_main.py(修改)專案結構變化:
srs-review-agent/
├── src/
│ ├── guardrails.py ← 【新增】schema、檢查函數、GuardedCaller、FaultInjectingLLM
│ ├── llm_verifier.py ← 修改:補充層 use_guardrail
│ ├── performance_optimizer.py ← 修改:批量驗證 use_guardrail、統計 guardrail
│ ├── failure_analysis.py ← 修改:--guardrail、--inject-faults、耗時
│ └── api_main.py ← 修改:Ollama 模式採用 Day 19-20 的設定
├── tests/
│ └── test_day20_guardrails.py ← 【新增】7 個測試(離線)
└── reports/
├── day20_guardrail_day7.md ← 【新增】最終設定的報告
└── day20_fault_injection.md ← 【新增】故障注入 + guardrail 的報告
Pydantic 已經在 Day 13 隨 FastAPI 安裝,今天不需要新的套件。
建立 src/guardrails.py:
from pydantic import BaseModel, Field, ValidationError
class VerifyItem(BaseModel):
"""批量驗證中的一題"""
index: int = Field(ge=1)
is_conflict: bool
reason: str = ""
class SupplementItem(BaseModel):
"""補充層回報的一個衝突"""
req_id_1: str = Field(min_length=1)
req_id_2: str = Field(min_length=1)
type: str = ""
reason: str = Field(min_length=1)
Schema 只管「單筆長什麼樣子」。「每一題都要回答」「編號必須存在於這份 SRS」這類跨筆、需要上下文的規則,寫在檢查函數裡。
def check_verify_response(raw: str, n_questions: int) -> Tuple[Dict[int, Tuple[bool, str]], List[str]]:
data, errors = _load_json(raw)
if data is None:
return {}, errors
results = data.get("results")
if not isinstance(results, list):
return {}, ['缺少 "results" 陣列']
answers: Dict[int, Tuple[bool, str]] = {}
for k, item in enumerate(results, start=1):
try:
parsed = VerifyItem.model_validate(item)
except ValidationError as e:
errors.append(_format_validation_error(f"results 第 {k} 筆", e))
continue
if parsed.index > n_questions:
errors.append(f"題號 {parsed.index} 超出範圍(共 {n_questions} 題)")
elif parsed.index in answers:
errors.append(f"題號 {parsed.index} 重複")
else:
answers[parsed.index] = (parsed.is_conflict, parsed.reason)
missing = [i for i in range(1, n_questions + 1) if i not in answers]
if missing:
errors.append(f"缺少第 {'、'.join(map(str, missing))} 題的答案,每一題都必須回答")
return answers, errors
回傳值是 (answers, errors):即使有錯,通過檢查的題目仍然保留在 answers 裡。錯誤訊息用中文寫成完整句子,因為它會原封不動地附到下一次的 prompt 裡給模型看。
_load_json() 在 JSON 語法錯誤時回傳 JSON 格式錯誤:Expecting ',' delimiter(第 20 行第 9 欄) 這種訊息,把 Python 的錯誤位置也告訴模型。
Day 11 的解析程式把整個 for 迴圈包在一個 try 裡,任何一筆少了 reason,整份結果就被丟掉。現在逐筆驗證:
def check_supplement_response(raw, valid_ids):
valid_ids = set(valid_ids)
data, errors = _load_json(raw)
if data is None:
return [], errors, []
conflicts = data.get("conflicts")
if not isinstance(conflicts, list):
return [], ['缺少 "conflicts" 陣列'], []
items, rejected = [], []
for k, item in enumerate(conflicts, start=1):
try:
parsed = SupplementItem.model_validate(item)
except ValidationError as e:
errors.append(_format_validation_error(f"conflicts 第 {k} 筆", e))
continue
unknown = [r for r in (parsed.req_id_1, parsed.req_id_2) if r not in valid_ids]
if unknown:
errors.append(f"conflicts 第 {k} 筆的 req_id {'、'.join(unknown)} 不在需求列表中")
rejected.append(item)
elif parsed.req_id_1 == parsed.req_id_2:
errors.append(f"conflicts 第 {k} 筆的兩個 req_id 相同")
rejected.append(item)
else:
items.append(parsed)
return items, errors, rejected
JSON 語法錯誤這一類,逐筆驗證也救不了:整份字串連 json.loads 都過不去。這種情況要靠下一節的重試。
def repair_prompt(prompt: str, errors: List[str]) -> str:
"""在原 prompt 後附上錯誤清單,請模型修正"""
return (prompt
+ "\n\n你上一次的回答有以下問題:\n"
+ "\n".join(f"- {e}" for e in errors)
+ "\n請修正這些問題後重新回答,只返回 JSON,不要其他文字。")
class GuardedCaller:
def __init__(self, llm, max_retries: int = 2, backoff_base: float = 1.0,
sleep: Callable[[float], None] = time.sleep):
self.llm = llm
self.max_retries = max_retries
self.backoff_base = backoff_base
self.sleep = sleep # 測試時可換成不真的等待的函數
self.stats = GuardrailStats()
self.last_raw = "" # 最後一次收到的原始回應(失敗分析用)
主迴圈:
def call(self, prompt, check):
self.stats.calls += 1
current, result, errors = prompt, None, []
connection_failures = 0
attempt = 0
while attempt <= self.max_retries:
try:
self.stats.llm_calls += 1
raw = self.llm.invoke(current)
except ConnectionError:
if connection_failures >= self.max_retries:
raise
self.sleep(self.backoff_base * (2 ** connection_failures))
connection_failures += 1
self.stats.connection_retries += 1
continue
self.last_raw = raw
result, errors = check(raw)
if not errors:
if attempt == 0:
self.stats.first_try_valid += 1
else:
self.stats.repaired += 1
return result, []
self.stats.error_log.extend(errors)
if attempt < self.max_retries:
self.stats.format_retries += 1
current = repair_prompt(prompt, errors)
attempt += 1
self.stats.gave_up += 1
return result, errors
幾個設計決定:
max_retries 就往上拋,由 API 的背景任務把任務標成 failed
sleep 可注入:測試指數退避時傳入 sleeps.append,不必真的等 3 秒GuardrailStats 另外提供 valid_rate(第一次通過 + 修復成功的比例)與 first_try_rate,完整代碼見 src/guardrails.py。
修改 src/performance_optimizer.py 的 _llm_verify_batch():
prompt = build_verify_prompt(pairs, self.prompt_version)
if self.guard is not None:
calls_before = self.guard.stats.llm_calls
answers, _ = self.guard.call(prompt, lambda raw: check_verify_response(raw, len(pairs)))
self.llm_call_count += self.guard.stats.llm_calls - calls_before
# 重試後仍缺的題目,與 Day 12 相同:保留(寧可多報,不因格式問題漏報)
return [answers.get(i, (True, "LLM 未回答此題,保留"))
for i in range(1, len(pairs) + 1)]
src/llm_verifier.py 的 find_missing_conflicts() 在 self.guard 存在時改走 _find_missing_guarded(),把 check_supplement_response 包成 check(raw) -> ((items, rejected), errors) 交給 GuardedCaller,再把通過檢查的 items 轉成 Conflict。
兩處都把重試產生的額外呼叫算進 llm_call_count,Day 18 報告裡的「LLM 呼叫」欄才會反映真實成本。
OptimizedDetector 新增 use_guardrail 參數,預設關閉:Day 11-19 的所有數字都是在沒有 guardrail 的情況下量到的,預設開啟會讓那些實驗無法重現。src/failure_analysis.py 加上 --guardrail;src/api_main.py 的 Ollama 模式則預設開啟:
if USE_OLLAMA:
detector = OptimizedDetector(
llm=OllamaLLM(),
cache_path=os.getenv("SRS_CACHE_PATH", ".cache/verify_cache.json"),
# Day 19-20:驗證層 v1、補充層 v2,並啟用輸出 guardrail(格式錯誤帶著錯誤重試)
verify_prompt_version=os.getenv("SRS_VERIFY_PROMPT", "v1"),
supplement_prompt_version=os.getenv("SRS_SUPPLEMENT_PROMPT", "v2"),
use_guardrail=os.getenv("SRS_GUARDRAIL", "1") == "1",
)
/api/v1/metrics 也多了 guardrail 欄位,可以看到正式環境的第一次通過率與修復次數。
真實的 Mistral 在這幾份資料上很少出錯,只靠自然發生的錯誤,無法充分量測 guardrail。所以在 src/guardrails.py 加一個包裝器,故意把回應截斷:
class FaultInjectingLLM:
"""包住真實 LLM,每 every 個回應就截掉結尾 cut 個字元,模擬被截斷的輸出"""
def __init__(self, llm, every: int = 2, cut: int = 40):
self.llm, self.every, self.cut = llm, every, cut
self.model_name = getattr(llm, "model_name", "unknown")
self.responses = 0
self.injected = 0
def invoke(self, prompt: str) -> str:
response = self.llm.invoke(prompt)
self.responses += 1
if self.every and self.responses % self.every == 0:
self.injected += 1
return response[:-self.cut] if len(response) > self.cut else ""
return response
src/failure_analysis.py 的 --inject-faults N 會用它包住 OllamaLLM。截掉結尾 40 個字元,JSON 的收尾括號一定不見,保證會觸發格式錯誤。
cd srs-review-agent
python3 -m pytest tests/test_day20_guardrails.py -v
tests/test_day20_guardrails.py::test_verify_check PASSED [ 14%]
tests/test_day20_guardrails.py::test_supplement_check PASSED [ 28%]
tests/test_day20_guardrails.py::test_repair_retry PASSED [ 42%]
tests/test_day20_guardrails.py::test_give_up_keeps_partial PASSED [ 57%]
tests/test_day20_guardrails.py::test_connection_backoff PASSED [ 71%]
tests/test_day20_guardrails.py::test_detector_recovers_with_guardrail PASSED [ 85%]
tests/test_day20_guardrails.py::test_fault_injection PASSED [100%]
============================== 7 passed in 0.09s ===============================
測試資料直接用 Day 19 實際收到的兩個錯誤回應:醫療系統那份 } 寫成 ], 的補充層 JSON,以及 2 題只答 1 題的批量驗證回應。
test_supplement_check:壞掉的 JSON 被檢出;另一份 4 筆的回應中,缺 reason、編號不存在、兩個編號相同的 3 筆被丟掉,正確的 1 筆保留test_connection_backoff:兩次 ConnectionError 後成功,等待時間是 [1.0, 2.0];連續 3 次失敗則往上拋test_detector_recovers_with_guardrail:沒有 guardrail 時,缺答的題目變成誤報、補充層整份失效;有 guardrail 時兩者都被修復,多花 2 次呼叫撰寫這個測試時,我一開始抄錯了 Day 19 的壞 JSON(寫成 } 再接 ],),錯誤類型就變成 Expecting property name。改成跟真實回應一樣「少了 }、直接接 ],」,才得到和 Day 19 相同的 Expecting ','。用真實樣本測試,就是為了避免這種「測到的不是實際會發生的錯誤」。
# A:最終設定(Day 7 資料集)
python3 -m src.failure_analysis --ollama --verify-prompt v1 --supplement-prompt v2 --guardrail
# B:最終設定(Day 11 保留集)
python3 -m src.failure_analysis --ollama --verify-prompt v1 --supplement-prompt v2 --guardrail --dataset day11
# C:v1 + guardrail
python3 -m src.failure_analysis --ollama --guardrail
# D、E:故障注入(每 2 個回應截斷 1 個),不啟用 / 啟用 guardrail
python3 -m src.failure_analysis --ollama --inject-faults 2
python3 -m src.failure_analysis --ollama --inject-faults 2 --guardrail
A、B 各跑 2 次,兩次的指標與 guardrail 統計完全相同。
| 組 | 設定 | 資料集 | Precision | Recall | F1 | LLM 呼叫 | guardrail |
|---|---|---|---|---|---|---|---|
| — | v1(Day 18 基準) | Day 7 | 0.905 | 0.722 | 0.767 | 6 | — |
| — | 驗證 v1 + 補充 v2(Day 19) | Day 7 | 0.905 | 0.639 | 0.659 | 6 | — |
| A | 驗證 v1 + 補充 v2 + guardrail | Day 7 | 0.905 | 0.917 | 0.897 | 7 | 6 次請求:第一次通過 5、修復 1 |
| B | 驗證 v1 + 補充 v2 + guardrail | Day 11 | 1.000 | 0.889 | 0.933 | 6 | 6 次請求:全部第一次通過 |
| C | v1 + guardrail | Day 7 | 0.905 | 0.722 | 0.767 | 6 | 6 次請求:全部第一次通過 |
| D | v1 + 故障注入 | Day 7 | 0.833 | 0.272 | 0.377 | 6 | — |
| E | v1 + 故障注入 + guardrail | Day 7 | 0.905 | 0.722 | 0.767 | 11 | 6 次請求:第一次通過 1、修復 5 |
guardrail 只修了 1 次,就是 Day 19 醫療系統那份壞掉的 JSON。修好之後,補充層 v2 的內容改進終於顯現出來:
| 測試集 | Day 18(v1) | A | 變化 |
|---|---|---|---|
| 電商平台 | 0.667 | 0.857 | 多找到「100ms vs 10000 req/s」 |
| 醫療系統 | 0.800 | 1.000 | 多找到「50 vs 1 名患者」「100 vs 1000 並行用戶」 |
| 社交媒體 | 0.833 | 0.833 | 不變 |
平均 F1 0.897,第一次超過 Day 7 基線 0.789,也超過第 3 週 0.85 的目標。補充層 10 個正確結果全部是繁體中文說明。
剩下的 3 個錯誤,正好是 Day 18 已經分析過、今天沒有處理的:
電商多找到的「頁面加載 < 100ms vs 吞吐量 > 10000 req/s」,Day 18 判斷為「性能取捨,標準答案有爭議」,v2 的 prompt 也寫了「不要回報只是難以同時達成的需求」,但 Mistral 還是回報了。因為它在標準答案裡,所以算 TP,但這一分的含金量要打折扣。
B(保留集)與 C(v1)的 12 次請求全部第一次就通過,指標與沒有 guardrail 時完全相同(保留集 0.933、v1 0.767)。guardrail 只在出錯時介入,不會改變正常的結果。
每 2 個回應截斷 1 個,是很極端的故障率:
A 的兩次執行分別花了 127.4 秒與 187.7 秒,設定完全相同。C 完全沒有重試,花了 82.1 秒,而 Day 18 同樣的設定是 56 秒。同一台機器在不同時間的耗時差異,比 guardrail 本身的成本還大,所以秒數不適合拿來比較。
比較穩定的指標是 LLM 呼叫次數:
| 天 | 做了什麼 | Day 7 資料集 F1 |
|---|---|---|
| Day 12 | 批量驗證 + 緩存(v1) | 0.767 |
| Day 18 | 失敗案例分析 | 0.767(不變,只分析) |
| Day 19 | 補充 prompt v2 | 0.659(被格式錯誤抵銷) |
| Day 20 | + guardrail | 0.897 |
三天下來 F1 提升 0.130,而且每一步都能指出是哪個改動、修好了哪幾筆。如果 Day 19 只看「v2 讓 F1 變差」就放棄,就不會發現 H2 其實有效。
use_guardrail 預設為 False,只有 API 的 Ollama 模式與 --guardrail 會啟用。新的使用端要記得打開git add src/guardrails.py src/llm_verifier.py src/performance_optimizer.py \
src/failure_analysis.py src/api_main.py tests/test_day20_guardrails.py \
reports/day20_guardrail_day7.md reports/day20_fault_injection.md
git commit -m "Day 20: Pydantic 輸出 guardrail、修復重試、指數退避、故障注入"
到目前為止,所有檢測都是「兩條需求之間」的衝突;「100% 居家辦公,但每天必須進辦公室」這種一句話自己打架的需求,四天來從沒被檢查過。第 3 週的最後一天 Day 21,我們加上第 4 層的單條需求自我矛盾檢查,並建立 A/B 測試框架:把 Day 12 原版、今天的改進版、加上第 4 層的版本放在同樣的資料集上各跑多次,用數據決定第 4 層要不要預設開啟,最後回顧這一週「從失敗案例出發」的改進流程。