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。
會完成幾件事:
agents/prompts.py,保留 baseline prompt 並新增 improved prompt。get_system_prompt(),根據版本取得 prompt。SimpleAgent,讓它可以指定 prompt version。evals/runner.py,把 PROMPT_VERSION 記錄進 eval run。先不做:
範圍先收斂在一件事:
讓 prompt 改動變成可追蹤、可重複、可比較的實驗。
在 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,然後比較 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 行為一致,這裡會一起示範。
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 來說夠用,理由很直接:
修改 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,但其實沒有。
接著修改 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() 裡讀:
os.getenv("PROMPT_VERSION", "baseline")
今天不建議這樣做。
原因是 SimpleAgent 的 prompt version 應該是這次 Agent instance 的設定。
如果每次 run() 都去讀環境變數,會讓行為比較難追蹤。
比較清楚的邊界是:
runner
-> 讀取 PROMPT_VERSION
-> 建立 SimpleAgent(prompt_version=...)
-> 執行整個 eval run
這樣一整次 eval run 都使用同一個 prompt version。
接著修改 batch evaluation runner。
它要負責兩件事:
修改 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
只讓 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、報告和實驗比較都會依賴它。
這裡有一個設計問題:
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 階段先不要過度設計。
如果你希望手動執行 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。
在專案根目錄執行:
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"
}
接著跑 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。
啟動 Day 17 做的 dashboard:
streamlit run failure_dashboard_app.py
先選 baseline 那份 eval run,看幾個指標:
接著切到 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_strictprompt 主要改善了 JSON 格式錯誤,沒有明顯增加 wrong_answer。
如果結果變成:
| failure_type | count |
|---|---|
format_error |
1 |
wrong_answer |
5 |
那就要小心。
這表示 prompt 可能讓 JSON 題變好,但讓其他任務變差。
先用三個問題判斷。
這是最直覺的指標。
例如:
baseline: 66.7%
json_strict: 80.0%
看起來 json_strict 比較好。
但成功率不是唯一指標。
如果 dataset 很小,多通過一題就可能讓百分比變化很大。
所以要搭配後兩個問題。
json_strict 的目標是改善 JSON 格式。
所以它最該改善的是:
format_error
如果 success rate 變高,但 format_error 沒有下降,那可能不是這次 prompt 的主要效果。
例如有可能只是某些開放式 QA 剛好通過了。
這時候要回頭看 failed cases。
Prompt 改動常見的副作用是過度約束。
例如你要求:
Answer with minimal text.
它可能讓 JSON 題變乾淨,但也可能讓一般 QA 回答太短,少掉 expected keyword。
所以要看:
task type failure rate
如果 json_output 變好,但 general_qa 和 keyword_qa 變差,就需要重新調整 prompt。
Prompt A/B testing 很容易失真。
先建立幾個基本規則。
如果今天同時改:
那就很難知道結果變化來自哪裡。
要比較 prompt,就只改 PROMPT_VERSION。
不要覆蓋原本 prompt。
把新的 prompt 放成新版本:
SYSTEM_PROMPTS = {
"baseline": "...",
"json_strict": "...",
}
這樣隨時可以重跑 baseline。
每份 eval run 都要有:
{
"prompt_version": "json_strict"
}
沒有這個欄位,後面做報告時很容易混淆。
單題測試適合 debug,不適合評估 prompt。
正式比較要跑完整 eval dataset。
即使目前只有 15 題,也比只看一題可靠。
Prompt 改動後,就算成功率變高,也要看失敗案例。
因為有時候成功率提升,可能只是 evaluator 的偶然結果。
要確認 actual output 是否真的符合期待。
這次的 prompt versioning 還很簡單。
目前限制有幾個。
這次把 prompt 寫在 agents/prompts.py。
這對教學和 MVP 很方便。
但如果 prompt 變多,可能會想改成:
目前先不做,因為會增加太多管理成本。
目前只能知道這次 run 使用哪個版本。
還不能在 dashboard 裡直接看到兩個 prompt 差異。
不過目前 prompt 很短,直接看 agents/prompts.py 就夠了。
LLM 輸出可能有隨機性。
理想上,A/B testing 應該每個 prompt 跑多次,再看平均表現。
例如:
baseline run 5 次
json_strict run 5 次
比較平均 success rate 和 failure distribution
這件事先不做。
因為本系列目前還在建立 MVP 平台,先讓單次可比較流程跑通。
目前 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。prompt_version。做完後,實驗流程變成:
固定 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 帶來的結果。