Day 18 我們加入了 Prompt Versioning 與 Prompt A/B Testing。
現在同一組 eval dataset 可以分別用不同 prompt version 執行:
PROMPT_VERSION=baseline LLM_PROVIDER=gemini python3 -m evals.runner
PROMPT_VERSION=json_strict LLM_PROVIDER=gemini python3 -m evals.runner
這讓 prompt 改動不再只是憑感覺,而是可以透過 eval run 比較。
在目前這組 15 筆 eval cases 裡,json_strict 很可能已經讓 JSON 題全數通過。
換句話說,Day 18 的 prompt 改善對目前 dataset 是有效的。
那 Day 19 還需要做 retry 嗎?
我認為需要。
原因不是「Day 18 沒解掉 JSON 題」,而是:
Prompt improvement 可以降低第一次失敗的機率,但 retry 是失敗發生後的 recovery path。
這兩者處理的是不同層次的問題。
Day 18 做的是預防:
讓模型第一次就比較可能輸出正確格式。
Day 19 做的是復原:
如果第一次仍然輸出錯誤格式,系統要有機會自動修正。
這是 Agent reliability 裡很值得分清楚的差別。
Prompt 可以改善模型行為,但它不是硬性保證。
即使我們在 system prompt 裡寫得很清楚:
If the user asks for JSON, output only valid JSON.
Do not wrap JSON in markdown code fences.
真實 LLM 仍然可能偶爾輸出:
```json
{
"answer": 15
}
```
這種錯誤很有趣。
模型其實知道答案是 15,也知道欄位名稱是 answer。
它不是不會做這題,而是輸出多了一層 markdown code fence,導致 json.loads() 不能直接解析。
Day 16 已經能把這類錯誤分類成:
format_error
這一篇要往下一步走:
當 evaluator 偵測到明確的格式錯誤時,把這個錯誤變成可復原事件,讓 Agent 有一次修正機會。
這就是 retry。
這一篇要做到的是:
在 batch evaluation runner 中加入最小 retry 策略,先針對
format_error重試一次。
會完成幾件事:
evals/runner.py 新增 should_retry()。evals/runner.py 新增 build_retry_task()。run_evaluation(),讓失敗案例可以重跑一次。retry_count。initial_actual 和 initial_failure_reason。先不做:
範圍先收斂在一件事:
對明確可修正的格式錯誤,給 Agent 一次重新輸出的機會。
這裡需要先講清楚。
如果你照 Day 18 實作 json_strict prompt,然後跑:
PROMPT_VERSION=json_strict LLM_PROVIDER=gemini python3 -m evals.runner
可能會看到 3 題 JSON cases 全部通過。
這表示 prompt 改善有效。
但它不代表 JSON 輸出已經變成工程上可保證的行為。
原因有三個。
第一,目前 JSON cases 很少。
3 / 3 通過
只能說這三題通過,不能代表未來所有 JSON 任務都穩定。
第二,LLM 輸出可能會因為任務內容、上下文、模型版本或溫度設定而變動。
今天這三題通過,不代表之後更複雜的 JSON schema 也一定通過。
第三,prompt 是 soft constraint。
換句話說,它是給模型的指令,不是程式層級的保證。
Retry 補的是 recovery path:
prompt 讓第一次更容易成功
retry 讓第一次失敗時還有修正機會
所以 Day 19 不是否定 Day 18。
Day 19 是把 Day 18 的 prompt improvement 往可靠性工程再推一步。
Retry 聽起來很簡單:
失敗了就再跑一次。
但實務上不能這樣做。
因為不同錯誤適合不同處理方式。
例如:
| failure_type | 適不適合這次 retry | 原因 |
|---|---|---|
format_error |
適合 | 錯誤明確,模型通常有機會修正格式 |
instruction_error |
可以,但這次先不做 | 可能可修正,但需要更小心設計 retry prompt |
wrong_answer |
不一定 | 可能是模型真的不知道,也可能是 evaluator 太嚴格 |
tool_error |
不一定 | 可能需要 tool guardrail,不只是重問 |
execution_error |
不適合 | API key、程式錯誤、trace 資料流問題不能靠 prompt 修 |
unknown_failure |
不適合 | 連原因都不清楚,先不要自動重試 |
這次先只 retry:
failure_type == "format_error"
而且再加一個限制:
grading_method == "json_exact"
換句話說,這版 retry 專門處理 JSON 格式輸出失敗。
這樣範圍很清楚,也比較容易驗證效果。
要實作的流程如下:
讀取 test case
-> 第一次執行 Agent
-> evaluate(actual)
-> 如果通過,直接記錄結果
-> 如果失敗且 failure_type 是 format_error
-> 建立 retry task
-> 第二次執行 Agent
-> 再 evaluate 一次
-> 記錄最後結果與 retry_count
換成更接近程式的形式:
result = agent.run(test_case["input"])
evaluation = evaluate(test_case, result.answer)
if should_retry(test_case, evaluation):
retry_task = build_retry_task(test_case, result.answer, evaluation.failure_reason)
result = agent.run(retry_task)
evaluation = evaluate(test_case, result.answer)
這裡的 retry 不是讓 evaluator 放寬標準。
評分規則不變。
差別只是當第一次輸出不合格時,我們把錯誤原因回饋給 Agent,請它重新輸出。
這次主要修改一個檔案。
agent-testing-platform/
evals/
runner.py
evaluators.py
cases.json
agents/
simple_agent.py
prompts.py
client_factory.py
data/
eval_runs/
eval_run_*.json
會修改:
| 檔案 | 修改內容 |
|---|---|
evals/runner.py |
加入 retry 條件、retry task,以及 retry result metadata |
不修改:
evals/evaluators.py
agents/simple_agent.py
agents/prompts.py
evals/cases.json
原因是 Day 16 已經能產生 failure_type。
Day 18 也已經能切換 prompt version。
這次要做的是在 runner 裡使用這些資訊。
為了方便比較 retry 前後,先用環境變數控制 retry 是否啟用。
我們會使用:
RETRY_ENABLED
執行時可以這樣關閉 retry:
RETRY_ENABLED=0 PROMPT_VERSION=baseline LLM_PROVIDER=gemini python3 -m evals.runner
也可以這樣開啟 retry:
RETRY_ENABLED=1 PROMPT_VERSION=baseline LLM_PROVIDER=gemini python3 -m evals.runner
這樣同一份程式就能跑兩種實驗:
retry disabled
retry enabled
後面就能用 Failure Dashboard 比較差異。
這裡先用 baseline prompt,是為了比較容易觸發 JSON format error,方便驗證 retry 機制。
如果要用接近產品設定的版本,也可以改成:
RETRY_ENABLED=1 PROMPT_VERSION=json_strict LLM_PROVIDER=gemini python3 -m evals.runner
只是當 json_strict 已經讓目前 JSON 題第一次就通過時,retry 不會被觸發,retry_count 會維持 0。
先修改 evals/runner.py 的 import。
修改 evals/runner.py:
import json
import os
from datetime import datetime
from pathlib import Path
from typing import Any
如果 Day 18 已經加入 os,這裡就不需要重複改。
再新增一個 helper function。
修改 evals/runner.py,在 create_run_id() 下方新增:
def is_retry_enabled() -> bool:
return os.getenv("RETRY_ENABLED", "0") == "1"
這裡刻意把預設值設成關閉:
RETRY_ENABLED=0
因為我們要保留 baseline。
如果預設就開 retry,後面很容易忘記某份 eval run 到底有沒有使用 retry。
接著定義 retry 條件。
修改 evals/runner.py,新增 should_retry():
def should_retry(
test_case: dict[str, Any],
evaluation: Any,
retry_count: int,
max_retries: int,
) -> bool:
if retry_count >= max_retries:
return False
if evaluation.passed:
return False
if evaluation.failure_type != "format_error":
return False
if test_case["grading_method"] != "json_exact":
return False
return True
這個 function 會做四個檢查。
第一,確認沒有超過最大 retry 次數。
這次會設定:
max_retries = 1
第二,如果已經通過,就不需要 retry。
第三,只 retry format_error。
第四,只 retry json_exact 題目。
這樣可以避免 retry 範圍太大。
例如 wrong_answer 今天不 retry,因為它可能是 evaluator 太嚴格,也可能是模型真的答錯。
接著要把第一次錯誤轉成第二次任務。
修改 evals/runner.py,新增 build_retry_task():
def build_retry_task(
test_case: dict[str, Any],
actual: str | None,
failure_reason: str | None,
) -> str:
expected_json = json.dumps(
test_case["expected"],
ensure_ascii=False,
)
return f"""
前一次輸出沒有通過 JSON 格式驗證。
原始任務:
{test_case["input"]}
預期 JSON:
{expected_json}
前一次輸出:
{actual or ""}
錯誤原因:
{failure_reason or ""}
請重新輸出結果。
只能輸出合法 JSON。
不要使用 markdown code block。
不要加入任何解釋文字。
""".strip()
這個 retry task 會提供四種資訊:
| 資訊 | 用途 |
|---|---|
| 原始任務 | 讓 Agent 知道使用者原本要什麼 |
| 預期 JSON | 讓 Agent 知道格式和欄位 |
| 前一次輸出 | 讓 Agent 知道自己哪裡可能錯 |
| 錯誤原因 | 把 evaluator 的錯誤訊息回饋給 Agent |
最後幾句是這版 retry 最重要的限制:
只能輸出合法 JSON。
不要使用 markdown code block。
不要加入任何解釋文字。
這些限制是針對 Day 16 看到的 format_error。
這裡可能會有一個疑問:
把 expected JSON 放進 retry task,會不會太像洩漏答案?
在正式 benchmark 中,確實不能隨便把 expected answer 給模型。
但這裡的情境是教學用的可靠性平台,而且 retry 目標是修正格式,不是測知識能力。
我們要解決的是:
模型已經接近答對,但輸出格式不符合 evaluator。
例如:
actual: ```json
{ "answer": 15 }
```
這時把 expected JSON 結構放進 retry prompt,可以讓教學更直覺。
如果你想避免洩漏答案,可以改成只給 schema,不給 expected value。
例如:
{
"answer": "number"
}
這個更嚴謹,但需要 Day 20 的 schema validation。
所以這次先用 expected JSON,並在文章中明確說明這是 MVP 取捨。
接著修改 run_evaluation()。
Day 16 和 Day 18 後,runner 裡應該已經有:
evaluation = evaluate(test_case, result.answer)
要在第一次 evaluate 後插入 retry。
修改 evals/runner.py,在 run_evaluation() 內建立 agent 和 cases 後,加入:
retry_enabled = is_retry_enabled()
max_retries = 1 if retry_enabled else 0
接著在每一筆 case 的 try 區塊中,將原本的流程整理成:
result = agent.run(test_case["input"])
save_trace(result.trace)
evaluation = evaluate(test_case, result.answer)
retry_count = 0
initial_actual = None
initial_failure_type = None
initial_failure_reason = None
trace_session_ids = [result.trace.session_id]
if should_retry(
test_case=test_case,
evaluation=evaluation,
retry_count=retry_count,
max_retries=max_retries,
):
initial_actual = result.answer
initial_failure_type = evaluation.failure_type
initial_failure_reason = evaluation.failure_reason
retry_count += 1
retry_task = build_retry_task(
test_case=test_case,
actual=result.answer,
failure_reason=evaluation.failure_reason,
)
result = agent.run(retry_task)
save_trace(result.trace)
trace_session_ids.append(result.trace.session_id)
evaluation = evaluate(test_case, result.answer)
這段邏輯做了幾件事。
第一,先正常跑第一次。
第二,先用 evaluator 評分。
第三,如果符合 retry 條件,才建立 retry task。
第四,第二次執行後,重新 evaluate。
第五,最後保存的是 retry 後的結果。
如果第二次修正成功,最後的 passed 會是:
true
但 eval result 仍然會記錄:
"retry_count": 1
這樣就能知道這題是靠 retry 通過的。
接著修改 append result 的地方。
修改 evals/runner.py,讓成功執行的 result dict 包含 retry 欄位:
results.append(
{
"case_id": test_case["id"],
"input": test_case["input"],
"expected": test_case["expected"],
"grading_method": test_case["grading_method"],
"task_type": test_case["task_type"],
"status": "completed",
"actual": result.answer,
"passed": evaluation.passed,
"failure_type": evaluation.failure_type,
"failure_reason": evaluation.failure_reason,
"retry_enabled": retry_enabled,
"retry_count": retry_count,
"initial_actual": initial_actual,
"initial_failure_type": initial_failure_type,
"initial_failure_reason": initial_failure_reason,
"trace_session_id": result.trace.session_id,
"trace_session_ids": trace_session_ids,
"error": None,
}
)
新增的欄位有:
| 欄位 | 說明 |
|---|---|
retry_enabled |
這次 runner 是否啟用 retry |
retry_count |
這個 case 實際 retry 幾次 |
initial_actual |
第一次失敗時的輸出 |
initial_failure_type |
第一次失敗時的 failure type |
initial_failure_reason |
第一次失敗時的 failure reason |
trace_session_ids |
包含第一次與 retry 的 trace session id |
其中 trace_session_id 保留為最後一次執行的 trace。
這樣 Day 17 的 Failure Dashboard 不需要馬上修改,也仍然能顯示一個主要 trace id。
如果要看 retry 前後兩次 trace,可以用 trace_session_ids。
如果 Agent 執行時發生 exception,還是會進入 except。
這類錯誤今天不 retry。
但為了讓 result schema 一致,except 裡也可以加入 retry 欄位。
修改 evals/runner.py 的 except 區塊:
except Exception as exc:
results.append(
{
"case_id": test_case["id"],
"input": test_case["input"],
"expected": test_case["expected"],
"grading_method": test_case["grading_method"],
"task_type": test_case["task_type"],
"status": "error",
"actual": None,
"passed": False,
"failure_type": "execution_error",
"failure_reason": "Agent execution error",
"retry_enabled": retry_enabled,
"retry_count": 0,
"initial_actual": None,
"initial_failure_type": None,
"initial_failure_reason": None,
"trace_session_id": None,
"trace_session_ids": [],
"error": str(exc),
}
)
這裡的重點是:
"failure_type": "execution_error"
如果是 API key、程式錯誤或 trace 資料流錯誤,retry prompt 沒有幫助。
要先修執行流程。
Day 18 已經讓 eval run 記錄:
{
"llm_provider": "gemini",
"prompt_version": "json_strict"
}
這次要再加入:
{
"retry_enabled": true
}
修改 evals/runner.py,建立 eval_run 時加入:
eval_run = {
"run_id": run_id,
"created_at": datetime.now().isoformat(),
"llm_provider": os.getenv("LLM_PROVIDER", "fake"),
"prompt_version": prompt_version,
"retry_enabled": retry_enabled,
"total_cases": len(cases),
"results": results,
}
這樣後面看到 eval run JSON 時,就能知道這次是否啟用了 retry。
為了讓終端機輸出更清楚,可以把 retry count 也印出來。
修改 evals/runner.py 的 print_summary():
def print_summary(eval_run: dict[str, Any]) -> None:
total_cases = eval_run["total_cases"]
passed_count = sum(1 for result in eval_run["results"] if result["passed"])
failed_count = total_cases - passed_count
print(f"Run ID: {eval_run['run_id']}")
print(f"Provider: {eval_run.get('llm_provider', 'unknown')}")
print(f"Prompt version: {eval_run.get('prompt_version', 'unknown')}")
print(f"Retry enabled: {eval_run.get('retry_enabled', False)}")
print(f"Total cases: {total_cases}")
print(f"Passed: {passed_count}")
print(f"Failed: {failed_count}")
print()
for result in eval_run["results"]:
label = "PASS" if result["passed"] else "FAIL"
retry_count = result.get("retry_count", 0)
print(
f"[{label}] {result['case_id']} - "
f"{result['task_type']} - retry={retry_count}"
)
if result["failure_type"]:
print(f" type: {result['failure_type']}")
if result["failure_reason"]:
print(f" reason: {result['failure_reason']}")
執行後可能會看到:
[PASS] case_013 - json_output - retry=1
[PASS] case_014 - json_output - retry=1
[FAIL] case_015 - json_output - retry=1
type: format_error
reason: Output is not valid JSON: Expecting value
這讓我們可以區分:
第一次就通過
和:
retry 後才通過
先跑一份不啟用 retry 的結果。
如果 Day 18 的 json_strict 已經讓 JSON 題全部通過,這裡可以先用 baseline prompt 觸發比較容易出現的格式錯誤。
RETRY_ENABLED=0 PROMPT_VERSION=baseline LLM_PROVIDER=gemini python3 -m evals.runner
這份結果是對照組。
輸出的 eval run JSON 會包含:
{
"llm_provider": "gemini",
"prompt_version": "baseline",
"retry_enabled": false
}
這時 JSON 題如果輸出 markdown code fence,就會維持失敗。
例如:
{
"case_id": "case_013",
"passed": false,
"failure_type": "format_error",
"retry_count": 0
}
接著開啟 retry。
RETRY_ENABLED=1 PROMPT_VERSION=baseline LLM_PROVIDER=gemini python3 -m evals.runner
這份結果是 retry 版本。
輸出的 eval run JSON 會包含:
{
"llm_provider": "gemini",
"prompt_version": "baseline",
"retry_enabled": true
}
如果 retry 成功,某筆 JSON 題可能會長這樣:
{
"case_id": "case_013",
"task_type": "json_output",
"passed": true,
"failure_type": null,
"failure_reason": null,
"retry_enabled": true,
"retry_count": 1,
"initial_actual": "```json\n{\"answer\": 15}\n```",
"initial_failure_type": "format_error",
"initial_failure_reason": "Output is not valid JSON: Expecting value",
"actual": "{\"answer\": 15}"
}
這表示:
第一次失敗
retry 後通過
這是 retry 策略想要改善的情境。
如果你想用比較接近產品設定的方式測試,也可以改用 Day 18 的 improved prompt:
RETRY_ENABLED=1 PROMPT_VERSION=json_strict LLM_PROVIDER=gemini python3 -m evals.runner
只是如果 json_strict 已經讓所有 JSON 題第一次就通過,這次 run 可能會看到:
{
"retry_enabled": true,
"retry_count": 0
}
這也是正常結果。
它代表 retry 機制有開啟,但這次沒有被觸發。
今天先用三個指標比較。
比較時要先確認兩份 eval run 只有 RETRY_ENABLED 不同。
例如:
PROMPT_VERSION=baseline, RETRY_ENABLED=0
PROMPT_VERSION=baseline, RETRY_ENABLED=1
如果你拿 baseline + retry disabled 去比 json_strict + retry enabled,結果就會混在一起。
那樣無法分辨改善來自 prompt,還是來自 retry。
例如 retry 前:
Passed: 12 / 15
Success rate: 80.0%
retry 後:
Passed: 14 / 15
Success rate: 93.3%
這表示 retry 有提高整體通過率。
但仍然不能只看這個數字。
Retry 的目標是改善 format_error。
所以最重要的是看:
Failure Type Distribution
如果 retry 前:
| failure_type | count |
|---|---|
format_error |
3 |
wrong_answer |
1 |
retry 後:
| failure_type | count |
|---|---|
format_error |
1 |
wrong_answer |
1 |
這比較能說明 retry 有打到目標。
如果你用 json_strict 測試,並且 retry 前後的 format_error 都是 0,這不代表 retry 沒有價值。
它只代表:
這次 dataset 沒有觸發 retry。
這種情況下,Day 19 的驗證重點會變成:
retry_count 都維持 0。今天的 retry 條件只針對:
grading_method == json_exact
failure_type == format_error
所以如果你看到 retry_count 出現在大量非 JSON 題,就代表條件寫錯了。
可以檢查 eval run JSON:
python3 -m json.tool data/eval_runs/eval_run_你的檔名.json
確認 retry 發生在預期的 cases。
Day 17 的 Failure Dashboard 目前還沒有專門的 retry 圖表。
但它仍然可以用來比較兩份 eval run。
啟動 dashboard:
streamlit run failure_dashboard_app.py
先選擇 retry disabled 那份 run,看:
再選擇 retry enabled 那份 run,看同樣指標。
如果 retry 有效,通常會看到:
json_output failure_rate 下降
format_error count 下降
success rate 上升
但今天 dashboard 還不會直接顯示 retry_count。
如果想先看 retry count,可以直接查 JSON。
後面 Day 24 做 Reliability Dashboard 時,再把 retry count、latency 和 cost 一起放進去。
Retry 很有用,但不能濫用。
今天先記住幾個限制。
每 retry 一次,就是多一次 LLM call。
如果有 15 題,其中 3 題 retry,總呼叫次數就從:
15
變成:
18
目前我們還沒有記錄 token usage 和 cost。
Day 23 會補這件事。
如果單次 LLM call 需要 2 秒,retry 一次可能讓該 case 多花 2 秒。
在 batch evaluation 中還好。
但如果是即時產品,retry 會直接影響使用者等待時間。
所以 retry 不應該無限制開啟。
有時候第二次輸出可能修好了格式,但改壞內容。
例如第一次:
{
"answer": 15
}
外面多了 code fence。
第二次可能變成:
{
"answer": "15"
}
格式是合法 JSON,但值的型別或內容可能不符合 expected。
所以 retry 後仍然要跑 evaluator。
如果錯誤是:
GEMINI_API_KEY is not set
或:
'NoneType' object has no attribute 'session_id'
retry 沒有幫助。
這些是 execution error,要修設定或程式。
如果 Day 18 的 json_strict 已經讓 JSON 題全數通過,那今天不一定會看到大量成功率提升。
這是正常的。
Day 19 真正要驗證的是平台是否具備這個能力:
當可偵測、可修正的格式錯誤發生時,
runner 可以自動產生修正任務,
讓 Agent retry 一次,
並把 retry 前後的結果記錄下來。
換句話說,這次建立的是可靠性機制,不只是為了修目前這三題 JSON cases。
如果目前 dataset 沒有觸發 retry,可以用兩種方式驗證。
第一,用 baseline prompt 跑。
RETRY_ENABLED=1 PROMPT_VERSION=baseline LLM_PROVIDER=gemini python3 -m evals.runner
這比較容易看到 JSON 題第一次失敗、retry 後修正的案例。
第二,之後加入更複雜的 JSON cases。
例如:
請用 JSON 回傳一個包含 status、items、total_count 的物件。
items 必須是陣列,每個 item 包含 name 和 score。
這類題目比目前三題更容易暴露格式不穩定。
到那時候,Day 19 的 retry 機制就能直接派上用場。
這次在 batch evaluation runner 中加入第一版 retry 策略。
完成內容包含:
RETRY_ENABLED 控制是否啟用 retry。is_retry_enabled()。should_retry()。build_retry_task()。format_error 和 json_exact 重試。retry_count。initial_actual、initial_failure_type、initial_failure_reason。retry_enabled。做完後,eval pipeline 從:
Agent answer
-> evaluate
-> save result
變成:
Agent answer
-> evaluate
-> if format_error: retry once
-> evaluate again
-> save result with retry metadata
這表示平台不只會發現錯誤,也開始有第一個自動改善策略。
Day 20 會把 JSON validation 往 Guardrail 的方向推進。
這次 retry 的判斷依賴 evaluator 的錯誤訊息。
下一篇會更明確地定義:
什麼叫符合 schema?
哪些欄位是 required?
欄位型別是否正確?
不符合時要怎麼阻擋?
也就是把 JSON 格式驗證從單純的 evaluator,提升成 Agent 輸出進入後續流程前的 guardrail。