iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0

前言

Day 17 我們建立了第一版 Failure Dashboard。

現在我們可以從一份 eval run 裡看到:

success rate
failed cases
failure type distribution
task type failure rate
representative failures

這讓我們不只知道 Agent 有沒有失敗,也能知道失敗集中在哪裡。

但看完 dashboard 之後,下一個問題通常會是:

那我要怎麼改善?

最常見的第一個改善手段,就是改 prompt。

例如 Day 16 和 Day 17 都提到,Gemini 在 JSON 題可能會輸出:

```json
{
  "answer": 15
}
```

人類看得懂,但 json.loads() 不能直接解析,於是 evaluator 判定為 format_error。

直覺上,我們可能會想把 system prompt 改得更明確:

如果使用者要求 JSON,只能輸出原始 JSON,不要使用 markdown code block。

但這裡有一個工程問題:

改 prompt 之後,我們怎麼知道它真的變好了?

這一篇要做的就是 Prompt Versioning 與 Prompt A/B Testing。


今天要完成什麼?

這一篇要做到的是:

讓同一組 eval dataset 可以使用不同 prompt version 執行,並在 eval result 中記錄使用的 prompt version。

會完成幾件事:

  1. 建立 prompt version 的概念。
  2. 修改 agents/prompts.py,保留 baseline prompt 並新增 improved prompt。
  3. 新增 get_system_prompt(),根據版本取得 prompt。
  4. 修改 SimpleAgent,讓它可以指定 prompt version。
  5. 修改 evals/runner.py,把 PROMPT_VERSION 記錄進 eval run。
  6. 分別執行 baseline prompt 和 improved prompt。
  7. 用 Day 17 的 Failure Dashboard 比較 success rate 和 failure type。

先不做:

  • 自動搜尋最佳 prompt。
  • 多版本 prompt leaderboard。
  • prompt 文字 diff viewer。
  • LLM-as-a-Judge。
  • retry。
  • schema validation guardrail。

範圍先收斂在一件事:

讓 prompt 改動變成可追蹤、可重複、可比較的實驗。


為什麼需要 Prompt Versioning?

在 Agent 開發中,prompt 很容易被快速修改。

例如原本的 baseline prompt 是:

You are a helpful AI agent.
Answer the user's task clearly and concisely.
If the task cannot be completed, explain why.

後來你發現 JSON 題常常失敗,於是改成:

You are a helpful AI agent.
Answer clearly and concisely.
When the user asks for JSON, output only valid JSON.
Do not wrap JSON in markdown code fences.

這看起來是合理改善。

但如果沒有版本紀錄,幾天後你可能只剩下模糊印象:

我好像有改過 prompt。
成功率好像有變高。
JSON 題好像比較好了。

這不夠。

因為 prompt 改動可能同時帶來好處和壞處。

例如:

改動 可能好處 可能副作用
要求 JSON 不要包 code fence 降低 format_error 其他回答可能變得太短
要求答案簡短 降低廢話 開放式 QA 可能少掉 expected keyword
要求嚴格遵守使用者格式 改善 instruction following 有些題目可能不再補充背景

所以 prompt 不能只靠感覺修改。

我們需要讓每一次 eval run 都記錄:

{
  "prompt_version": "baseline"
}

或:

{
  "prompt_version": "json_strict"
}

這樣後面才能把結果和 prompt 版本對起來。


什麼是 Prompt A/B Testing?

Prompt A/B Testing 的意思是:

固定其他條件,只改 prompt,然後比較 eval 結果。

這次會使用兩個版本:

version 說明
baseline Day 2 建立的原始 system prompt
json_strict 加強 JSON 與格式遵循規則的 prompt

比較時要盡量固定:

條件 為什麼要固定
eval dataset 確保兩次測的是同一組題目
LLM provider 避免 fake 和 Gemini 混在一起比較
model 避免模型差異被誤認成 prompt 效果
evaluator 避免評分規則改變造成結果不可比
failure type classifier 避免分類邏輯改變造成分布不可比

這次的比較目標不是證明某個 prompt 一定最好。

先建立一個基本實驗流程:

prompt_version=baseline
  -> run eval
  -> save result

prompt_version=json_strict
  -> run eval
  -> save result

compare results

今天的專案結構

這次會修改三個檔案。

agent-testing-platform/
  app.py
  agents/
    __init__.py
    simple_agent.py
    prompts.py
    fake_llm.py
    gemini_llm.py
    client_factory.py
  evals/
    __init__.py
    cases.json
    runner.py
    evaluators.py
  ui/
    failure_dashboard.py
  data/
    eval_runs/
      eval_run_*.json

會修改:

檔案 修改內容
agents/prompts.py 定義多個 prompt versions,並提供 get_system_prompt()
agents/simple_agent.py 讓 SimpleAgent 可以接收 prompt_version
evals/runner.py 從環境變數讀取 PROMPT_VERSION,並寫入 eval run metadata

可以選擇性修改:

檔案 修改內容
app.py 手動執行 Agent 時也讀取 PROMPT_VERSION

如果你只想做 batch evaluation,app.py 可以先不改。

但為了讓手動測試和 eval runner 行為一致,這裡會一起示範。


設計 prompt versions

Day 2 的 agents/prompts.py 目前大致是:

SYSTEM_PROMPT = """
You are a helpful AI agent.
Answer the user's task clearly and concisely.
If the task cannot be completed, explain why.
"""

這種寫法在只有一個 prompt 時很簡單。

但要做 A/B testing,就需要能根據版本取出 prompt。

先用一個 dict 管理:

SYSTEM_PROMPTS = {
    "baseline": "...",
    "json_strict": "...",
}

這不是最完整的 prompt management system。

但對 MVP 來說夠用,理由很直接:

  • 實作簡單。
  • 版本名稱清楚。
  • 不需要額外資料庫。
  • 容易從環境變數切換。
  • eval result 可以直接記錄版本名稱。

修改 agents/prompts.py

修改 agents/prompts.py:

SYSTEM_PROMPTS = {
    "baseline": """
You are a helpful AI agent.
Answer the user's task clearly and concisely.
If the task cannot be completed, explain why.
""".strip(),
    "json_strict": """
You are a helpful AI agent.
Answer the user's task clearly and concisely.

Follow the user's output format requirements exactly.
If the user asks for JSON, output only valid JSON.
Do not wrap JSON in markdown code fences.
Do not add explanations before or after JSON output.
If the user asks for a single exact word or phrase, output only that word or phrase.
""".strip(),
}


DEFAULT_PROMPT_VERSION = "baseline"


def get_system_prompt(prompt_version: str = DEFAULT_PROMPT_VERSION) -> str:
    if prompt_version not in SYSTEM_PROMPTS:
        available_versions = ", ".join(sorted(SYSTEM_PROMPTS))
        raise ValueError(
            f"Unknown prompt version: {prompt_version}. "
            f"Available versions: {available_versions}"
        )

    return SYSTEM_PROMPTS[prompt_version]

這裡有三個地方要留意。

第一,baseline 保留原本 Day 2 的 prompt。

A/B testing 需要對照組。

如果我們直接覆蓋原本 prompt,就會失去比較基準。

第二,json_strict 加入幾條格式要求:

If the user asks for JSON, output only valid JSON.
Do not wrap JSON in markdown code fences.
Do not add explanations before or after JSON output.

這是針對 Day 16 和 Day 17 觀察到的 format_error。

第三,get_system_prompt() 會檢查版本是否存在。

如果使用者設定:

export PROMPT_VERSION=abc

但 abc 不存在,程式會直接丟出清楚錯誤:

Unknown prompt version: abc. Available versions: baseline, json_strict

這比默默 fallback 到 baseline 更適合做實驗。

因為實驗最怕的是你以為自己跑了新 prompt,但其實沒有。


修改 SimpleAgent:接收 prompt_version

接著修改 Agent Runner。

Day 2 的 SimpleAgent 原本直接匯入 SYSTEM_PROMPT:

from agents.prompts import SYSTEM_PROMPT

今天要改成透過 get_system_prompt() 取得 prompt。

修改 agents/simple_agent.py 的 import:

from agents.prompts import DEFAULT_PROMPT_VERSION, get_system_prompt

如果原本有:

from agents.prompts import SYSTEM_PROMPT

就把它換掉。

接著修改 SimpleAgent.__init__(),加入 prompt_version:

class SimpleAgent:
    def __init__(
        self,
        llm_client: LLMClient,
        prompt_version: str = DEFAULT_PROMPT_VERSION,
    ):
        self.llm_client = llm_client
        self.prompt_version = prompt_version
        self.system_prompt = get_system_prompt(prompt_version)

如果你的 __init__() 裡原本還有工具設定,例如:

self.tools = {
    "calculator": calculator,
}

要保留它。

完整概念會像這樣:

class SimpleAgent:
    def __init__(
        self,
        llm_client: LLMClient,
        prompt_version: str = DEFAULT_PROMPT_VERSION,
    ):
        self.llm_client = llm_client
        self.prompt_version = prompt_version
        self.system_prompt = get_system_prompt(prompt_version)
        self.tools = {
            "calculator": calculator,
        }

最後修改 run() 裡建立 messages 的地方。

原本可能是:

messages = [
    {"role": "system", "content": SYSTEM_PROMPT},
    {"role": "user", "content": user_task},
]

改成:

messages = [
    {"role": "system", "content": self.system_prompt},
    {"role": "user", "content": user_task},
]

這段示範的重點是:

self.prompt_version = prompt_version
self.system_prompt = get_system_prompt(prompt_version)

換成白話說,SimpleAgent 建立時就決定使用哪個 prompt version。

如果沒有指定,就使用:

DEFAULT_PROMPT_VERSION = "baseline"

這樣可以維持向後相容。

原本建立 Agent 的地方如果還是寫:

agent = SimpleAgent(llm_client=create_llm_client())

它仍然會使用 baseline prompt。

差別是現在你也可以寫:

agent = SimpleAgent(
    llm_client=create_llm_client(),
    prompt_version="json_strict",
)

而 AgentResult.trace 仍然會正常存在。


注意:不要在 run 裡每次讀環境變數

有一個常見寫法是直接在 run() 裡讀:

os.getenv("PROMPT_VERSION", "baseline")

今天不建議這樣做。

原因是 SimpleAgent 的 prompt version 應該是這次 Agent instance 的設定。

如果每次 run() 都去讀環境變數,會讓行為比較難追蹤。

比較清楚的邊界是:

runner
  -> 讀取 PROMPT_VERSION
  -> 建立 SimpleAgent(prompt_version=...)
  -> 執行整個 eval run

這樣一整次 eval run 都使用同一個 prompt version。


修改 evals/runner.py:讀取 PROMPT_VERSION

接著修改 batch evaluation runner。

它要負責兩件事:

  1. 從環境變數讀取 prompt version。
  2. 把 prompt version 寫進 eval run JSON。

修改 evals/runner.py,在建立 Agent 的地方讀取環境變數:

import os

from agents.client_factory import create_llm_client
from agents.prompts import DEFAULT_PROMPT_VERSION
from agents.simple_agent import SimpleAgent

接著在 run_evaluation() 裡加入:

def run_evaluation() -> dict:
    prompt_version = os.getenv(
        "PROMPT_VERSION",
        DEFAULT_PROMPT_VERSION,
    )

    agent = SimpleAgent(
        llm_client=create_llm_client(),
        prompt_version=prompt_version,
    )

    # 後面維持原本讀取 cases、逐筆執行、evaluate 的流程

這裡讓 evals/runner.py 成為實驗設定的入口。

要跑不同 prompt,不需要改程式碼,只要改環境變數:

PROMPT_VERSION=json_strict LLM_PROVIDER=gemini python3 -m evals.runner

把 prompt_version 寫入 eval run

只讓 runner 使用不同 prompt 還不夠。

我們還要把版本寫入輸出的 JSON。

否則幾天後看到兩份檔案:

eval_run_20260906_052327.json
eval_run_20260906_053112.json

你可能不知道哪一份是 baseline,哪一份是 json_strict。

修改 evals/runner.py,建立 eval run dict 時加入 prompt_version:

eval_run = {
    "run_id": run_id,
    "created_at": datetime.now().isoformat(),
    "llm_provider": os.getenv("LLM_PROVIDER", "fake"),
    "prompt_version": prompt_version,
    "total_cases": len(cases),
    "results": results,
}

這裡要沿用你原本 evals/runner.py 裡的變數名稱。

如果原本程式是:

eval_run = {
    "run_id": run_id,
    "created_at": datetime.now().isoformat(),
    "total_cases": len(cases),
    "results": results,
}

那今天只需要新增兩個欄位:

"llm_provider": os.getenv("LLM_PROVIDER", "fake"),
"prompt_version": prompt_version,

這樣輸出的 JSON 會包含:

{
  "run_id": "eval_run_20260906_053112",
  "llm_provider": "gemini",
  "prompt_version": "json_strict",
  "total_cases": 15,
  "results": []
}

這個欄位不能省。

後面 dashboard、報告和實驗比較都會依賴它。


讓每一筆 result 也記錄 prompt_version 嗎?

這裡有一個設計問題:

prompt_version 要放在 eval run level,還是每一筆 result level?

這次先放在 eval run level。

原因是我們現在一次 eval run 只使用一個 prompt version。

換成資料結構來看:

eval_run.prompt_version = json_strict

就足以表示這次所有 cases 都是用 json_strict 跑的。

如果未來要做更複雜的實驗,例如同一次 run 中不同 case 使用不同 prompt,才需要把 prompt_version 放到每一筆 result。

MVP 階段先不要過度設計。


修改 app.py:手動測試也使用 prompt version

如果你希望手動執行 Agent 時也能切換 prompt,可以順手修改 app.py。

修改 app.py:

import os

from agents.client_factory import create_llm_client
from agents.prompts import DEFAULT_PROMPT_VERSION
from agents.simple_agent import SimpleAgent


def main() -> None:
    user_task = input("請輸入任務:")
    prompt_version = os.getenv(
        "PROMPT_VERSION",
        DEFAULT_PROMPT_VERSION,
    )

    agent = SimpleAgent(
        llm_client=create_llm_client(),
        prompt_version=prompt_version,
    )

    result = agent.run(user_task)

    print(f"Prompt version: {prompt_version}")
    print(result.answer)

這樣就可以用同一個方式切換 prompt:

PROMPT_VERSION=json_strict LLM_PROVIDER=gemini python3 app.py

手動測試可以幫你快速確認 prompt 行為。

但正式比較時,還是要跑完整 eval dataset。

因為單題測試很容易誤判。


執行 baseline prompt

現在可以開始做第一組實驗。

先跑 baseline prompt。

在專案根目錄執行:

PROMPT_VERSION=baseline LLM_PROVIDER=gemini python3 -m evals.runner

如果你還沒有設定 Gemini API key,先設定:

export GEMINI_API_KEY="你的 Gemini API key"
export GEMINI_MODEL="gemini-3.6-flash"

執行完成後,會產生一份 eval run JSON。

例如:

data/eval_runs/eval_run_20260906_061000.json

打開後應該能看到:

{
  "llm_provider": "gemini",
  "prompt_version": "baseline"
}

執行 improved prompt

接著跑 json_strict prompt。

PROMPT_VERSION=json_strict LLM_PROVIDER=gemini python3 -m evals.runner

這次會再產生另一份 eval run JSON。

例如:

data/eval_runs/eval_run_20260906_061430.json

打開後應該能看到:

{
  "llm_provider": "gemini",
  "prompt_version": "json_strict"
}

注意,這兩次 run 應該只改 PROMPT_VERSION。

不要同時改 dataset、model、evaluator 或 failure classifier。

否則結果變化就不能單純歸因於 prompt。


用 Failure Dashboard 比較結果

啟動 Day 17 做的 dashboard:

streamlit run failure_dashboard_app.py

先選 baseline 那份 eval run,看幾個指標:

  • success rate
  • failed count
  • failure type distribution
  • task type failure rate
  • representative failures

接著切到 json_strict 那份 eval run,再看同樣指標。

理想情況下,我們希望看到:

format_error 下降
json_output failure_rate 下降
success rate 上升

但也要注意副作用。

例如:

instruction_error 是否增加?
wrong_answer 是否增加?
general_qa 是否變差?

這也是我們需要 dashboard 的原因。

Prompt 改動不是只看一個成功率,而是要看 failure distribution 是否真的往正確方向移動。


建立簡單比較表

今天還不做完整 comparison dashboard,但可以先手動整理成表格。

假設 baseline 結果是:

prompt_version total passed failed success_rate
baseline 15 10 5 66.7%

而 json_strict 結果是:

prompt_version total passed failed success_rate
json_strict 15 12 3 80.0%

這表示整體成功率提高。

但還不夠。

我們還要看 failure type。

例如 baseline:

failure_type count
format_error 3
wrong_answer 2

json_strict:

failure_type count
format_error 1
wrong_answer 2

這樣才比較能說:

json_strict prompt 主要改善了 JSON 格式錯誤,沒有明顯增加 wrong_answer。

如果結果變成:

failure_type count
format_error 1
wrong_answer 5

那就要小心。

這表示 prompt 可能讓 JSON 題變好,但讓其他任務變差。


怎麼判斷 prompt 改動是否有效?

先用三個問題判斷。

1. 成功率是否提高?

這是最直覺的指標。

例如:

baseline: 66.7%
json_strict: 80.0%

看起來 json_strict 比較好。

但成功率不是唯一指標。

如果 dataset 很小,多通過一題就可能讓百分比變化很大。

所以要搭配後兩個問題。

2. 原本目標 failure type 是否下降?

json_strict 的目標是改善 JSON 格式。

所以它最該改善的是:

format_error

如果 success rate 變高,但 format_error 沒有下降,那可能不是這次 prompt 的主要效果。

例如有可能只是某些開放式 QA 剛好通過了。

這時候要回頭看 failed cases。

3. 是否造成其他 task type 退步?

Prompt 改動常見的副作用是過度約束。

例如你要求:

Answer with minimal text.

它可能讓 JSON 題變乾淨,但也可能讓一般 QA 回答太短,少掉 expected keyword。

所以要看:

task type failure rate

如果 json_output 變好,但 general_qa 和 keyword_qa 變差,就需要重新調整 prompt。


Prompt 實驗的紀律

Prompt A/B testing 很容易失真。

先建立幾個基本規則。

1. 一次只改一件事

如果今天同時改:

  • prompt
  • model
  • dataset
  • evaluator
  • failure classifier

那就很難知道結果變化來自哪裡。

要比較 prompt,就只改 PROMPT_VERSION。

2. 保留 baseline

不要覆蓋原本 prompt。

把新的 prompt 放成新版本:

SYSTEM_PROMPTS = {
    "baseline": "...",
    "json_strict": "...",
}

這樣隨時可以重跑 baseline。

3. 把版本寫進結果

每份 eval run 都要有:

{
  "prompt_version": "json_strict"
}

沒有這個欄位,後面做報告時很容易混淆。

4. 不要只看單題

單題測試適合 debug,不適合評估 prompt。

正式比較要跑完整 eval dataset。

即使目前只有 15 題,也比只看一題可靠。

5. 保留失敗案例

Prompt 改動後,就算成功率變高,也要看失敗案例。

因為有時候成功率提升,可能只是 evaluator 的偶然結果。

要確認 actual output 是否真的符合期待。


目前設計的限制

這次的 prompt versioning 還很簡單。

目前限制有幾個。

1. Prompt 只存在 Python 檔案裡

這次把 prompt 寫在 agents/prompts.py。

這對教學和 MVP 很方便。

但如果 prompt 變多,可能會想改成:

  • YAML。
  • JSON。
  • Markdown。
  • SQLite。
  • 外部 prompt registry。

目前先不做,因為會增加太多管理成本。

2. 還沒有 prompt diff

目前只能知道這次 run 使用哪個版本。

還不能在 dashboard 裡直接看到兩個 prompt 差異。

不過目前 prompt 很短,直接看 agents/prompts.py 就夠了。

3. 還沒有多 run 統計

LLM 輸出可能有隨機性。

理想上,A/B testing 應該每個 prompt 跑多次,再看平均表現。

例如:

baseline run 5 次
json_strict run 5 次
比較平均 success rate 和 failure distribution

這件事先不做。

因為本系列目前還在建立 MVP 平台,先讓單次可比較流程跑通。

4. 沒有統計顯著性

目前 dataset 只有 15 題。

如果成功率從 66.7% 變成 73.3%,其實只差一題。

這不一定代表 prompt 真正變好。

先把這點記下來。

後面如果 dataset 擴大,才適合談更嚴謹的統計比較。


重點整理

這次把 prompt 從單一字串,改成可以被版本化管理的設定。

完成的內容包含:

  • 在 agents/prompts.py 定義 SYSTEM_PROMPTS。
  • 保留 baseline prompt。
  • 新增 json_strict prompt。
  • 新增 get_system_prompt()。
  • 讓 SimpleAgent 可以接收 prompt_version。
  • 讓 evals/runner.py 從 PROMPT_VERSION 讀取 prompt version。
  • 讓 eval run JSON 記錄 prompt_version。
  • 用同一組 dataset 分別跑 baseline 和 json_strict。
  • 用 Failure Dashboard 比較 success rate、failure type 和 task type failure rate。

做完後,實驗流程變成:

固定 eval dataset
  -> 選擇 prompt version
  -> 執行 eval run
  -> 記錄 prompt_version
  -> dashboard 分析結果
  -> 決定 prompt 是否值得保留

這比單純「改 prompt 看起來比較好」可靠得多。


下一步

Day 19 會開始做第一個可靠性改善策略:retry。

現在已經能比較不同 prompt version,但 prompt 仍然不能保證模型每次都輸出合法格式。

所以下一篇會針對明確可偵測的錯誤,例如:

format_error

讓 Agent 有一次修正機會。

流程會變成:

Agent 第一次回答
  -> evaluator 判定 format_error
  -> runner 產生 retry task
  -> Agent 第二次回答
  -> 再評分一次
  -> 記錄 retry_count

Day 20 會接著把 JSON validation 推進成 schema guardrail。

Day 21 再回顧整個第三週,整理 Gemini baseline、failure analysis、prompt A/B testing、retry 和 schema validation 帶來的結果。


上一篇
Day 17|建立錯誤分析表格與 Failure Dashboard
系列文
從黑盒到可驗證:30 天打造 AI Agent 的 Trace、Eval 與 Guardrails 系統 共 18 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言