| 編號 | 假設 | 對應案例 |
|---|---|---|
| H1 | 驗證 prompt 加入「方向相同」「對象不同」的反例,Mistral 會丟棄這兩類誤報 | 誤報 2 筆 |
| H2 | 補充 prompt 定義「同一屬性給出不同數值」是衝突,Mistral 會找到數值衝突 | 漏報 2 筆 |
| H3 | 補充 prompt 要求「一律使用繁體中文」,英文說明會消失 | 英文說明 7 筆 |
今天把 prompt 改成有版本的設定,實際驗證這三個假設。先說結論:只有一半的假設成立,而且「改好的 prompt」在主要資料集上讓 F1 從 0.767 掉到 0.602。我們用 Day 18 的工具找出原因。
第 3 週:智能優化與增強
讀完這篇後,你會完成:
src/prompts.py:v1(Day 12 原版)與 v2(H1-H3),並用測試確認 v1 一字未改OptimizedDetector(prompt_version=...),也能分別指定驗證層與補充層的版本到 Day 18 為止,prompt 直接寫在程式裡:批量驗證在 src/performance_optimizer.py,補充層在 src/llm_verifier.py。想比較「改之前」和「改之後」,只能改完程式再跑一次,舊版本就被覆蓋了。
版本化之後,兩個版本並存,只要換一個參數就能重跑任何一個版本。實驗結果也能對應到明確的版本號。
src/prompts.py 時如果不小心改了一個換行,後面所有比較就都不公平了create_test_cases() 是另外 3 份 SRS,今天只用來檢查,不用來調整src/prompts.py
build_verify_prompt(pairs, version) ← 批量驗證
build_supplement_prompt(constraints, version) ← LLM 補充
↑ ↑
BatchLLMVerifier(prompt_version) LLMVerifier(prompt_version)
↑ ↑
OptimizedDetector(prompt_version, verify_prompt_version, supplement_prompt_version)
↑
src/failure_analysis.py --prompt / --verify-prompt / --supplement-prompt / --dataset
src/prompts.py(新增);src/performance_optimizer.py、src/llm_verifier.py、src/failure_analysis.py(修改)專案結構變化:
srs-review-agent/
├── src/
│ ├── prompts.py ← 【新增】v1 / v2 prompt
│ ├── llm_verifier.py ← 修改:補充層使用 build_supplement_prompt
│ ├── performance_optimizer.py ← 修改:批量驗證使用 build_verify_prompt、緩存鍵含版本
│ └── failure_analysis.py ← 修改:--prompt、--verify-prompt、--supplement-prompt、--dataset
└── tests/
└── test_day19_prompts.py ← 【新增】7 個測試(離線)
今天不需要新的套件。
建立 src/prompts.py。v1 的批量驗證開頭,就是 Day 12 寫在 _llm_verify_batch() 裡的那兩行:
VERIFY_HEADER = {
"v1": (
"你是需求工程師。以下每一題有兩條軟體需求,請逐題判斷兩者是否無法同時成立。\n"
"注意否定詞(如「不需要」「不支持」)與描述對象是否相同。\n"
),
...
}
def build_verify_prompt(pairs: List[Tuple[str, str]], version: str = DEFAULT_PROMPT_VERSION) -> str:
lines = [f"{i}. 需求 A:{t1}\n 需求 B:{t2}" for i, (t1, t2) in enumerate(pairs, start=1)]
body = "\n".join(lines)
if version == "v2":
body = "題目:\n" + body
return (VERIFY_HEADER[version] + "\n" + body
+ "\n\n只返回 JSON,不要其他文字,格式:\n" + JSON_VERIFY_FORMAT)
搬家時我先寫了一段比對:把 Day 12 程式裡原本的 prompt 字串,和 build_verify_prompt(pairs, "v1")、build_supplement_prompt(constraints, "v1") 的輸出逐字比較,兩個都是 True 才改用新函數。tests/test_day19_prompts.py 的 test_v1_matches_day12 把這個比對固定成測試。
H1,在驗證 prompt 加入判斷原則與反例:
"v2": (
"你是需求工程師。以下每一題有兩條軟體需求,請逐題判斷兩者是否無法同時成立。\n"
"\n"
"判斷原則:\n"
"1. 只有「照其中一條實作,就一定違反另一條」才是衝突。\n"
"2. 兩條需求方向相同(例如都不做某件事),不是衝突。\n"
"3. 兩條需求描述的對象不同(例如一條講 A 資料、一條講 B 資料),不是衝突。\n"
"\n"
"例子(與題目無關):\n"
"- 「日誌以純文字保存」vs「日誌不做壓縮」→ 不是衝突:兩者都沒有要求額外處理,方向相同。\n"
"- 「訂單資料保存 5 年」vs「購物車資料 7 天後清除」→ 不是衝突:描述的是不同資料。\n"
"- 「管理後台必須雙重驗證」vs「管理後台只需密碼登入」→ 衝突:同一對象,要求相反。\n"
),
H2、H3,在補充 prompt 加入衝突定義與語言要求:
SUPPLEMENT_RULES = {
"v1": "只列出兩個需求無法同時成立的情況,req_id 必須來自上面的列表。\n",
"v2": (
"衝突的定義:\n"
"1. 兩個需求無法同時成立。\n"
"2. 兩個需求對同一個屬性給出不同的數值或上限,也是衝突。"
"例如「單次最多上傳 10 個檔案」vs「單次最多上傳 3 個檔案」。\n"
"不要回報只是「難以同時達成」、但邏輯上可以並存的需求。\n"
"req_id 必須來自上面的列表。reason 一律使用繁體中文。\n"
),
}
「不要回報只是難以同時達成的需求」對應 Day 18 標記為「標準答案有爭議」的性能取捨案例:我們不打算讓模型去找它,也不希望模型因為 H2 的定義開始亂報。
例子全部用資料集裡沒有的主題(日誌、訂單、管理後台、上傳)。test_examples_not_leaked 會掃過兩套資料集共 45 條需求,確認這些關鍵字都沒有出現。
兩個版本都保留「逐題判斷」「找出潛在的衝突」這兩句話,以及相同的 JSON 格式:Day 11 的 MockOllamaLLM 與 Day 18 測試用的假 LLM 都靠這兩句分辨 prompt 類型,JSON 格式則要能被既有的解析程式讀取。
src/performance_optimizer.py 的 _llm_verify_batch() 原本十幾行的 prompt 組裝,現在只剩一行:
prompt = build_verify_prompt(pairs, self.prompt_version)
src/llm_verifier.py 的 find_missing_conflicts() 同樣改成:
prompt = build_supplement_prompt(constraints, self.prompt_version)
OptimizedDetector 可以一次設定兩層,也能個別覆寫:
def __init__(self, llm=None, cache_path: Optional[str] = None,
prompt_version: str = DEFAULT_PROMPT_VERSION,
verify_prompt_version: Optional[str] = None,
supplement_prompt_version: Optional[str] = None, ...):
"""prompt_version 同時設定兩層;verify_/supplement_prompt_version 可個別覆寫(Day 19 分層對照)"""
verify_version = check_version(verify_prompt_version or prompt_version)
self.prompt_version = check_version(supplement_prompt_version or prompt_version)
self.batch_verifier = BatchLLMVerifier(llm=llm, cache_path=cache_path,
prompt_version=verify_version, ...)
self.supplementer = LLMVerifier(llm=llm, use_cache=False,
prompt_version=self.prompt_version, ...) if llm else None
個別覆寫一開始並不在計畫裡,是看到第一輪實驗結果後才加的,後面會說明原因。check_version() 遇到未知版本會直接丟出 ValueError,不會默默退回 v1。
Day 12 的磁碟緩存以需求內容為鍵。如果不改,換成 v2 之後,所有 v1 已經問過的需求對都會直接讀到 v1 的答案,實驗就白做了:
def _generate_cache_key(self, text_1: str, text_2: str) -> str:
key = "\n".join(sorted([text_1, text_2]))
# v1 以外的版本加上前綴:v2 不會讀到 v1 的舊答案(v1 的鍵維持 Day 12 的格式)
if self.prompt_version != "v1":
key = f"{self.prompt_version}\n{key}"
return hashlib.sha256(key.encode("utf-8")).hexdigest()
補充層的緩存鍵(整份約束的 SHA-256)也用同樣方式加上版本。v1 不加前綴,是為了讓 Day 12 建立的磁碟緩存繼續有效,Day 12 文章對緩存鍵的描述也仍然正確。
修改 src/failure_analysis.py 的 CLI:
parser.add_argument("--prompt", default="v1", choices=["v1", "v2"], help="prompt 版本(Day 19)")
parser.add_argument("--verify-prompt", choices=["v1", "v2"], help="只覆寫驗證層的版本")
parser.add_argument("--supplement-prompt", choices=["v1", "v2"], help="只覆寫補充層的版本")
parser.add_argument("--dataset", default="day7", choices=["day7", "day11"],
help="day7 = Day 7-10 共用資料集;day11 = Day 11 資料集(Day 19 的保留集)")
cd srs-review-agent
python3 -m pytest tests/test_day19_prompts.py -v
tests/test_day19_prompts.py::test_v1_matches_day12 PASSED [ 14%]
tests/test_day19_prompts.py::test_v2_content PASSED [ 28%]
tests/test_day19_prompts.py::test_examples_not_leaked PASSED [ 42%]
tests/test_day19_prompts.py::test_unknown_version PASSED [ 57%]
tests/test_day19_prompts.py::test_cache_key_per_version PASSED [ 71%]
tests/test_day19_prompts.py::test_version_reaches_both_layers PASSED [ 85%]
tests/test_day19_prompts.py::test_per_layer_override PASSED [100%]
============================== 7 passed in 0.03s ===============================
python3 -m src.failure_analysis --ollama --prompt v1 --dataset day7
python3 -m src.failure_analysis --ollama --prompt v2 --dataset day7
python3 -m src.failure_analysis --ollama --prompt v1 --dataset day11
python3 -m src.failure_analysis --ollama --prompt v2 --dataset day11
每個設定跑 2 次,兩次的 Precision、Recall、F1、TP、FP、FN 完全相同:
| 資料集 | 版本 | Precision | Recall | F1 | TP / FP / FN |
|---|---|---|---|---|---|
| Day 7(主要) | v1 | 0.905 | 0.722 | 0.767 | 11 / 2 / 4 |
| Day 7(主要) | v2 | 0.792 | 0.639 | 0.602 | 9 / 4 / 6 |
| Day 11(保留) | v1 | 1.000 | 0.778 | 0.867 | 6 / 0 / 2 |
| Day 11(保留) | v2 | 1.000 | 0.889 | 0.933 | 7 / 0 / 1 |
v2 在保留集上變好,在主要資料集上卻大幅變差,誤報和漏報都增加了。
用 Day 18 的報告看 v2 在 Day 7 資料集上的細節:
| 測試集 | Precision | Recall | F1 | TP | FP | FN |
|---|---|---|---|---|---|---|
| 電商平台 | 0.750 | 0.750 | 0.750 | 3 | 1 | 1 |
| 醫療系統 | 1.000 | 0.167 | 0.286 | 1 | 0 | 5 |
| 社交媒體 | 0.625 | 1.000 | 0.769 | 5 | 3 | 0 |
「其他觀察」多了一行:補充層 JSON 解析失敗:1 次。以下四個問題逐一看。
把醫療系統的補充層原始回應印出來:
{
"req_id_1": "REQ-2.2.1",
"req_id_2": "REQ-2.2.2",
"type": "衝突類型",
"reason": "所有病歷必須加密存儲與病歷數據採用明文存儲以提高查詢速度是衝突,因為兩個需求對同一個屬性給出不同的數值或上限,也是衝突。"
],
{
"req_id_1": "REQ-2.3.2",
"req_id_2": "REQ-2.3.3",
"type": "衝突類型",
"reason": "每個時間段最多 50 名患者預約與每個時間段最多 1 名患者預約是衝突,…"
},
第三筆的結尾 } 被寫成了 ],。json.loads 在這裡失敗,Day 11 的解析程式就回傳空清單,整份回應裡的 5 個衝突全部被丟掉,醫療系統的 Recall 從 0.667 掉到 0.167。
而同一份回應裡,第四筆正是 H2 想找的「最多 50 名 vs 最多 1 名」。H2 其實有效,只是結果被一個語法錯誤吃掉了。
電商平台多出的誤報,說明欄是「LLM 未回答此題,保留」。原始回應:
{"results": [{"index": 1, "is_conflict": true, "reason": "需求 A 要求所有支付數據加密傳輸,而需求 B 要求支持明文顯示訂單細節,這兩條需求的方向相反,因此衝突"}]}
一共 2 題,只回答了第 1 題。Day 12 設計的是「缺答就保留」,所以第 2 題「支持明文顯示訂單細節 vs 不需要加密用戶的個人信息」變成誤報,而它在 v1 時是被正確丟棄的。
社交媒體的誤報從 2 個變成 3 個。多出來的是「帖子內容採用明文存儲 vs 支持端到端加密」,v1 時被正確丟棄,v2 卻放行了,理由是:
帖子內容採用明文存儲與支持端到端加密是衝突,因為端到端加密需要在儲存前將內容加密。
Day 18 想解決的兩個誤報也都還在。加了反例之後,Mistral 7B 並沒有變得更會分辨「方向相同」與「對象不同」。
v2 的補充層說明沒有任何一筆是英文,H3 成立。但同時出現了新的現象:模型把 prompt 裡的文字照抄進回答。
"type" 都是 "衝突類型",也就是 JSON 格式範例裡的佔位文字小模型傾向複製 prompt 中出現過的句子。這不影響 F1,但說明文字的品質變差了。
問題 1 和 2 是格式問題,問題 3 和 4 是內容問題,而 v2 同時改了兩層。要分別判斷每個假設,必須把兩層拆開,這就是 verify_prompt_version、supplement_prompt_version 的由來:
python3 -m src.failure_analysis --ollama --verify-prompt v1 --supplement-prompt v2 --dataset day7
python3 -m src.failure_analysis --ollama --verify-prompt v2 --supplement-prompt v1 --dataset day7
# 另外兩個指令加上 --dataset day11
同樣每個設定跑 2 次,兩次結果完全相同:
| 驗證層 | 補充層 | Day 7 F1 | TP / FP / FN | 解析失敗 | Day 11 F1 |
|---|---|---|---|---|---|
| v1 | v1 | 0.767 | 11 / 2 / 4 | 0 | 0.867 |
| v2 | v1 | 0.714 | 11 / 4 / 4 | 0 | 0.867 |
| v1 | v2 | 0.659 | 9 / 2 / 6 | 1 | 0.933 |
| v2 | v2 | 0.602 | 9 / 4 / 6 | 1 | 0.933 |
每一列只改一層,結論如下:
| 層 | 採用版本 | 理由 |
|---|---|---|
| 驗證層 | v1 | v2 的反例讓誤報增加,保留集也沒有改善 |
| 補充層 | v2 | 內容確實變好(數值衝突、繁體中文、保留集 +0.066),但必須先解決格式問題 |
「驗證 v1 + 補充 v2」在主要資料集上目前是 0.659,比 v1 的 0.767 還差。如果今天就把它上線,使用者會看到更差的結果。所以這個決定有前提:格式錯誤必須先被處理。
回頭看,Day 12 的解析程式有兩個會放大錯誤的設計:
這兩點都不是 prompt 能根治的:prompt 越長、要求越多,小模型寫錯格式的機會就越高。明天 Day 20 會在 LLM 與解析程式之間加一層 guardrail。
git add src/prompts.py src/performance_optimizer.py src/llm_verifier.py \
src/failure_analysis.py tests/test_day19_prompts.py
git commit -m "Day 19: prompt 版本管理(v1/v2)、分層覆寫、版本化緩存鍵"
今天確認了補充層 v2 的內容比較好,卻被一個 ], 抵銷。明天 Day 20 我們用 Pydantic 為 LLM 的輸出定義 schema:題目缺答、JSON 壞掉、編號不存在時,把錯誤清單附在 prompt 後面請模型修正,最多重試 2 次;連線錯誤則用指數退避重試。最後用「驗證 v1 + 補充 v2 + guardrail」重跑兩套資料集,看 F1 能不能超過 0.85。