iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0

Day 18 把 Day 12 版本的 6 個錯誤逐筆拆開,得到三個改進假設:

編號 假設 對應案例
H1 驗證 prompt 加入「方向相同」「對象不同」的反例,Mistral 會丟棄這兩類誤報 誤報 2 筆
H2 補充 prompt 定義「同一屬性給出不同數值」是衝突,Mistral 會找到數值衝突 漏報 2 筆
H3 補充 prompt 要求「一律使用繁體中文」,英文說明會消失 英文說明 7 筆

今天把 prompt 改成有版本的設定,實際驗證這三個假設。先說結論:只有一半的假設成立,而且「改好的 prompt」在主要資料集上讓 F1 從 0.767 掉到 0.602。我們用 Day 18 的工具找出原因。

這一天在系列中的位置

第 3 週:智能優化與增強

  • Day 18 → 失敗案例分析,提出 H1-H3
  • Day 19 → Prompt 版本管理與改進實驗 ← 今天
  • Day 20 → 重試機制與輸出 guardrail

今日目標

讀完這篇後,你會完成:

  1. Prompt 版本庫 src/prompts.py:v1(Day 12 原版)與 v2(H1-H3),並用測試確認 v1 一字未改
  2. 版本參數:OptimizedDetector(prompt_version=...),也能分別指定驗證層與補充層的版本
  3. 依版本區分的緩存鍵:v2 不會讀到 v1 的舊答案
  4. 實驗流程:兩套資料集(主要資料集 + 保留集)× 每個設定跑 2 次
  5. 分層對照實驗:拆開驗證層與補充層,分別判斷每個假設

問題背景

為什麼要版本化

到 Day 18 為止,prompt 直接寫在程式裡:批量驗證在 src/performance_optimizer.py,補充層在 src/llm_verifier.py。想比較「改之前」和「改之後」,只能改完程式再跑一次,舊版本就被覆蓋了。

版本化之後,兩個版本並存,只要換一個參數就能重跑任何一個版本。實驗結果也能對應到明確的版本號。

三個實驗原則

  • v1 必須一字不改:v1 是基準。抽到 src/prompts.py 時如果不小心改了一個換行,後面所有比較就都不公平了
  • 需要保留集:只在 Day 7 資料集上調 prompt,很容易變成「對這 3 份 SRS 特別有效」。Day 11 的 create_test_cases() 是另外 3 份 SRS,今天只用來檢查,不用來調整
  • 每個設定跑 2 次:Day 18 看到同樣的資料,說明文字的語言在不同次執行間會變。只跑一次的話,無法區分「改進」和「波動」

實現方法

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
  • 輸入:prompt 版本、資料集
  • 輸出:Day 18 格式的失敗案例報告
  • 檔案:src/prompts.py(新增);src/performance_optimizer.py、src/llm_verifier.py、src/failure_analysis.py(修改)
  • 下游:Day 20 會用今天選出的版本組合,加上 guardrail 再驗證一次

專案結構變化:

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 個測試(離線)

今天不需要新的套件。


代碼示例

1. v1:原封不動搬過來

建立 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 把這個比對固定成測試。

2. v2:依 H1-H3 修改

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 格式則要能被既有的解析程式讀取。

3. 接上檢測器

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。

4. 緩存鍵要包含版本

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 文章對緩存鍵的描述也仍然正確。

5. 分析工具加上版本選項

修改 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 ===============================

第一輪實驗:v1 vs v2

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 在保留集上變好,在主要資料集上卻大幅變差,誤報和漏報都增加了。


分析: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 次。以下四個問題逐一看。

問題 1:醫療系統的補充層回應是壞掉的 JSON

把醫療系統的補充層原始回應印出來:

        {
            "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 其實有效,只是結果被一個語法錯誤吃掉了。

問題 2:批量驗證只回答了一半

電商平台多出的誤報,說明欄是「LLM 未回答此題,保留」。原始回應:

{"results": [{"index": 1, "is_conflict": true, "reason": "需求 A 要求所有支付數據加密傳輸,而需求 B 要求支持明文顯示訂單細節,這兩條需求的方向相反,因此衝突"}]}

一共 2 題,只回答了第 1 題。Day 12 設計的是「缺答就保留」,所以第 2 題「支持明文顯示訂單細節 vs 不需要加密用戶的個人信息」變成誤報,而它在 v1 時是被正確丟棄的。

問題 3:H1 的反例沒有幫助

社交媒體的誤報從 2 個變成 3 個。多出來的是「帖子內容採用明文存儲 vs 支持端到端加密」,v1 時被正確丟棄,v2 卻放行了,理由是:

帖子內容採用明文存儲與支持端到端加密是衝突,因為端到端加密需要在儲存前將內容加密。

Day 18 想解決的兩個誤報也都還在。加了反例之後,Mistral 7B 並沒有變得更會分辨「方向相同」與「對象不同」。

問題 4:H3 有效,但出現了照抄

v2 的補充層說明沒有任何一筆是英文,H3 成立。但同時出現了新的現象:模型把 prompt 裡的文字照抄進回答。

  • 每一筆的 "type" 都是 "衝突類型",也就是 JSON 格式範例裡的佔位文字
  • 「加密存儲 vs 明文存儲」這種跟數值無關的衝突,理由也寫成「因為兩個需求對同一個屬性給出不同的數值或上限」

小模型傾向複製 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

每一列只改一層,結論如下:

  • H1(驗證層 v2)不成立:主要資料集的誤報從 2 個變成 4 個,保留集沒有任何變化。驗證層維持 v1
  • H2、H3(補充層 v2)在保留集有效:F1 0.867 → 0.933。多找到的是「刪除的帖子永久移除 vs 所有帖子無限期保存」,這是 v1 漏掉的
  • 補充層 v2 在主要資料集的退步,全部來自那一次 JSON 語法錯誤:誤報維持 2 個,漏報從 4 個變成 6 個。醫療系統在固定的 prompt 下每次都寫出同樣壞掉的 JSON,所以兩次實驗結果一樣

今天的決定

層 採用版本 理由
驗證層 v1 v2 的反例讓誤報增加,保留集也沒有改善
補充層 v2 內容確實變好(數值衝突、繁體中文、保留集 +0.066),但必須先解決格式問題

「驗證 v1 + 補充 v2」在主要資料集上目前是 0.659,比 v1 的 0.767 還差。如果今天就把它上線,使用者會看到更差的結果。所以這個決定有前提:格式錯誤必須先被處理。

回頭看,Day 12 的解析程式有兩個會放大錯誤的設計:

  1. 補充層只要 JSON 有一個語法錯誤,整份結果都丟掉
  2. 批量驗證缺答時直接保留,不會要求模型補答

這兩點都不是 prompt 能根治的:prompt 越長、要求越多,小模型寫錯格式的機會就越高。明天 Day 20 會在 LLM 與解析程式之間加一層 guardrail。


權衡與限制

  • 兩套資料集都很小:主要資料集 15 個標準答案、保留集 8 個,一筆結果就會讓某份 SRS 的指標大幅變動
  • 只測了一組反例:H1 不成立,是指「這組反例對 Mistral 7B 無效」,不代表反例這個方法無效。換更大的模型或不同的例子,結果可能不同
  • 重複執行都一模一樣,不代表結果穩定:在固定的 prompt 下,Mistral 每次都寫出同樣壞掉的 JSON。這次的「穩定」是模型的確定性,不是 prompt 的可靠性
  • prompt 變長了:v2 的驗證 prompt 多了 231 字、補充 prompt 多了 113 字。在批量只有 2-5 題的情況下,對耗時的影響不明顯,但題數多時要重新量測

提交變更

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。


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

尚未有邦友留言

立即登入留言