iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0

前言

Day 26 我們照實驗設計跑完四組 Gemini eval run。

資料收齊後,接著分析結果。

但這裡的分析不是只挑最高分的那一組,然後說它最好。

我們要回到這個系列一直在處理的問題:

Agent 的可靠性有沒有提升?
提升可靠性需要付出什麼代價?
剩下的失敗到底是 Agent 問題,還是 evaluator 問題?

Day 27 會一起看:

  • success rate
  • failure type
  • latency
  • LLM call count
  • token usage
  • retry 是否真的帶來價值
  • rule-based evaluator 的限制

這篇的目標是:

用 Day 26 的實驗結果,整理出哪些改善值得保留,哪些問題應該留到下一階段處理。


這篇要完成什麼?

分析分成幾個部分:

  1. 整理四組 final experiment 結果。
  2. 比較 success rate。
  3. 比較 failure type。
  4. 分析 JSON format error 是否被修正。
  5. 分析 retry 的效果與代價。
  6. 分析 latency 和 LLM call count。
  7. 說明 rule-based evaluator 的限制。
  8. 新增一個簡單分析腳本。
  9. 整理目前最值得保留的設定。

這次先不做:

  • 不重新跑實驗。
  • 不改 prompt。
  • 不改 evaluator。
  • 不把單次實驗結果講成絕對結論。
  • 不做複雜統計檢定。

分析時守住一個原則:

把現有結果解讀清楚。

回顧四組實驗結果

Day 26 得到四組可比較的 Gemini run。

experiment run id passed failed success rate LLM calls total latency
baseline / no retry eval_run_20260924_184555 10 5 66.7% 15 88262.05 ms
json_strict / no retry eval_run_20260924_185011 13 2 86.7% 15 80131.38 ms
baseline / retry eval_run_20260924_185518 11 4 73.3% 18 105118.41 ms
json_strict / retry eval_run_20260924_185945 12 3 80.0% 15 79742.16 ms

先看表面數字,json_strict / no retry 是這次表現最好的設定。

它的成功率最高:

13 / 15 = 86.7%

而且它沒有增加 LLM calls:

Total LLM calls = 15

它沒有靠 retry 補救,第一次回答就比較符合格式要求。


比較 success rate

先只看成功率。

experiment success rate
baseline / no retry 66.7%
json_strict / no retry 86.7%
baseline / retry 73.3%
json_strict / retry 80.0%

這裡有幾個觀察。

第一,json_strict / no retry 比 baseline 高:

86.7% - 66.7% = +20.0%

Day 18 的 prompt versioning 除了記錄版本,也驗證了 json_strict 確實讓 JSON output 類型的任務更穩定。

第二,baseline / retry 也比 baseline 高:

73.3% - 66.7% = +6.6%

但提升幅度比 json_strict 小。

第三,json_strict / retry 這次反而低於 json_strict / no retry。

這不代表 retry 讓系統變差。

因為這一組的 Total LLM calls 仍然是 15,表示沒有真的觸發 retry。

差異主要來自 LLM 回覆本身的非確定性。

這裡只能下保守的結論:

單次 eval run 可以提供線索,但不能被當成永久真理。


比較 failure type

接著不要只看 passed / failed。

要看失敗類型。

依照 Day 26 的輸出,可以整理成:

experiment wrong_answer format_error execution_error
baseline / no retry 2 3 0
json_strict / no retry 2 0 0
baseline / retry 4 0 0
json_strict / retry 3 0 0

比起 success rate,這張表更直接地指出:

baseline 的主要問題之一是 JSON format_error。

baseline / no retry 失敗的 5 題裡,有 3 題是 JSON 格式錯誤:

case_013
case_014
case_015

但到了 json_strict / no retry,format_error 變成 0。

這表示 json_strict prompt 對 JSON output 任務有效。

同樣地,baseline / retry 也把 format_error 變成 0。

只是它是透過多一次 LLM call 修正。


JSON format error 被怎麼修正?

現在可以比較兩種修正 JSON format error 的方式。

方法一:改善 prompt

json_strict / no retry 的結果:

format_error = 0
LLM calls = 15

它的意思是:

靠更明確的輸出規則,讓模型第一次就產生合法 JSON。

方法二:retry 補救

baseline / retry 的結果:

format_error = 0
LLM calls = 18

它的意思是:

第一次錯了沒關係,再送一次修正 prompt。

兩者都能修 format_error。

但代價不同。

方法 format_error LLM calls 說明
json_strict 0 15 事前降低錯誤
baseline + retry 0 18 事後補救錯誤

這次實驗裡,prompt 改善比 retry 更划算。

因為它同時做到:

更高成功率
沒有增加 LLM calls
總 latency 也沒有變高

retry 的效果與代價

retry 不是沒用。

從 baseline / retry 可以看到:

Total LLM calls: 18

而 baseline / no retry 是:

Total LLM calls: 15

所以 retry 多了:

18 - 15 = 3 calls

這 3 次剛好對應 JSON 題目:

case_013
case_014
case_015

retry 的效果是:

format_error 從 3 變成 0

但它的代價是:

LLM calls 增加 20%
latency 從 88262.05 ms 增加到 105118.41 ms

換算平均每個 case latency:

experiment avg latency
baseline / no retry 5884.14 ms
baseline / retry 7007.89 ms

多了大約:

1123.75 ms / case

所以 retry 的定位應該是:

當 prompt 還沒辦法穩定避免格式錯誤時,retry 是有用的補救機制。

但它不應該取代 prompt 改善。


為什麼 json_strict + retry 沒有最好?

直覺上可能會期待:

json_strict + retry

應該是最強組合。

但這次結果不是。

experiment passed LLM calls
json_strict / no retry 13 15
json_strict / retry 12 15

先看 LLM call 數量:

json_strict / retry 的 Total LLM calls 還是 15。

retry 實際上沒有發生。

既然沒有 retry,兩組差異只能歸因於 LLM 回覆本身的波動。

例如某次 run 裡,case_010 可能剛好包含關鍵字「可靠」。

另一次 run 裡,模型可能改用「穩定、安全、準確」這類語意接近但不包含指定關鍵字的說法。

對人來說,這可能是合理回答。

對目前的 rule-based evaluator 來說,這就是 failed。

這個現象也說明:

評測結果不只反映 Agent 能力,也反映 evaluator 的設計。


分析剩下的 wrong_answer

四組實驗的 format_error 都可以被 json_strict 或 retry 解掉。

剩下比較麻煩的是 wrong_answer。

以 json_strict / no retry 為例,失敗的是:

case_008
case_009

其中 case_008 期待輸出包含:

任務

但模型回答:

AI Agent 是一種能夠自主感知環境、進行思考決策,並採取行動以完成特定目標的智慧代理系統。

這句話其實語意上接近正確。

只是它用了「目標」而不是「任務」。

case_009 期待輸出包含:

紀錄

但模型回答:

Trace 是指記錄與追蹤一個請求在系統中穿梭於各元件間的完整執行路徑與歷程,用以分析系統效能並排查錯誤。

這裡其實有一個很細的問題。

如果 evaluator 期待的是「紀錄」,但模型輸出的是「記錄」,對人來說幾乎等價。

但對 simple keyword contains 來說,它們是不同字串。

所以這類失敗不能直接解讀成:

Agent 不懂 Trace。

更精準的說法是:

目前 evaluator 對同義詞、近義詞和繁簡用字差異不夠寬容。

case_001 的假失敗

baseline / retry 裡還有一個很適合拿來討論的案例。

case_001 期待:

3780

模型輸出:

135 * 28 = **3,780**

它數學上是對的。

但 evaluator 使用 contains 檢查:

Expected output to contain '3780'

模型輸出裡是:

3,780

中間多了一個逗號。

所以被判定 failed。

這不是 Agent 算錯。

這是 evaluator 太字面。

這個例子很適合說明 rule-based evaluator 的優缺點。

優點是:

  • 可解釋。
  • 可重現。
  • 不需要額外 LLM call。
  • 成本低。

缺點是:

  • 不理解語意等價。
  • 不處理數字格式差異。
  • 不處理同義詞。
  • 容易把合理答案判成錯。

新增一個簡單分析腳本

目前我們是手動整理表格。

但如果 eval run 變多,手動整理很容易出錯。

這裡可以新增一個小工具,用固定 run id 產生 summary。

新增 evals/analyze_final_runs.py:

import json
from pathlib import Path


EVAL_RUNS_DIR = Path("data/eval_runs")

FINAL_RUNS = [
    {
        "experiment": "baseline / no retry",
        "run_id": "eval_run_20260924_184555",
    },
    {
        "experiment": "json_strict / no retry",
        "run_id": "eval_run_20260924_185011",
    },
    {
        "experiment": "baseline / retry",
        "run_id": "eval_run_20260924_185518",
    },
    {
        "experiment": "json_strict / retry",
        "run_id": "eval_run_20260924_185945",
    },
]


def load_eval_run(run_id: str) -> dict:
    path = EVAL_RUNS_DIR / f"{run_id}.json"

    with path.open("r", encoding="utf-8") as file:
        return json.load(file)


def count_failure_types(results: list[dict]) -> dict[str, int]:
    counts: dict[str, int] = {}

    for result in results:
        if result.get("passed"):
            continue

        failure_type = result.get("failure_type") or "unknown"
        counts[failure_type] = counts.get(failure_type, 0) + 1

    return counts


def summarize_run(label: str, eval_run: dict) -> dict:
    results = eval_run.get("results", [])
    total_cases = eval_run.get("total_cases", len(results))
    passed = sum(1 for result in results if result.get("passed"))
    failed = total_cases - passed
    success_rate = passed / total_cases if total_cases else 0
    total_latency_ms = eval_run.get("total_latency_ms", 0)
    avg_latency_ms = total_latency_ms / total_cases if total_cases else 0

    return {
        "experiment": label,
        "run_id": eval_run.get("run_id"),
        "prompt_version": eval_run.get("prompt_version"),
        "retry_enabled": eval_run.get("retry_enabled"),
        "passed": passed,
        "failed": failed,
        "success_rate": success_rate,
        "failure_types": count_failure_types(results),
        "llm_calls": eval_run.get("total_llm_call_count", 0),
        "total_tokens": eval_run.get("total_tokens", 0),
        "total_latency_ms": total_latency_ms,
        "avg_latency_ms": avg_latency_ms,
        "estimated_cost": eval_run.get("total_estimated_cost", 0.0),
    }


def main() -> None:
    summaries = []

    for item in FINAL_RUNS:
        eval_run = load_eval_run(item["run_id"])
        summaries.append(summarize_run(item["experiment"], eval_run))

    for summary in summaries:
        print(f"Experiment: {summary['experiment']}")
        print(f"  run_id: {summary['run_id']}")
        print(f"  prompt_version: {summary['prompt_version']}")
        print(f"  retry_enabled: {summary['retry_enabled']}")
        print(f"  passed: {summary['passed']}")
        print(f"  failed: {summary['failed']}")
        print(f"  success_rate: {summary['success_rate']:.1%}")
        print(f"  failure_types: {summary['failure_types']}")
        print(f"  llm_calls: {summary['llm_calls']}")
        print(f"  total_tokens: {summary['total_tokens']}")
        print(f"  total_latency_ms: {summary['total_latency_ms']:.2f}")
        print(f"  avg_latency_ms: {summary['avg_latency_ms']:.2f}")
        print(f"  estimated_cost: {summary['estimated_cost']:.8f}")
        print()


if __name__ == "__main__":
    main()

執行:

python3 -m evals.analyze_final_runs

這個腳本會印出每組實驗的 summary。

它沒有取代 dashboard。

它只是讓 Day 27 的分析過程可以重現。


對目前結果的工程判斷

綜合來看,這次最值得保留的設定是:

PROMPT_VERSION=json_strict
RETRY_ENABLED=0

理由如下。

第一,它的 success rate 最高:

86.7%

第二,它把 JSON format_error 降到 0。

第三,它沒有增加 LLM calls。

它是在錯誤發生前改善輸出,不是等錯誤出現後再補救。

retry 則應該保留,但不一定預設開啟。

比較合理的定位是:

當 prompt 改善後仍偶爾出現格式錯誤,再開 retry 作為備援。

而不是一開始就靠 retry 撐成功率。


剩下問題應該怎麼處理?

這次實驗後,主要剩下兩類問題。

1. evaluator 太字面

例如:

3780 vs 3,780
紀錄 vs 記錄
任務 vs 目標
可靠 vs 穩定、安全、準確

這些都不是單純 prompt 能完全解決的問題。

可以考慮幾個改善方向:

  • 對數字答案做 normalization。
  • 對中文同義詞建立可接受關鍵字列表。
  • 對 general QA 改用多關鍵字或 rubric。
  • 未來加入 LLM-as-a-Judge。

但這些都不應該在 Day 27 直接硬塞進去。

它們比較適合放到 Day 29 的限制與未來工作。

2. eval dataset 還太小

目前只有 15 題。

這對 MVP 很適合。

但如果要做更穩定的結論,應該擴充到更多題目,並讓每個 task type 有更多樣本。

例如:

task type 目前問題
calculation 題目太少,且格式差異會影響評分
general_qa keyword evaluator 太嚴格
json_output 適合 schema validation,但樣本仍少
instruction_following 目前只測簡單 exact match

所以這次結論要保守。

我們可以說:

在目前這組 eval dataset 上,json_strict 是最有效的改善。

不要說:

json_strict 對所有 Agent 任務都最好。

分析整理

Day 26 的四組 final experiment 分析完成。

得到幾個結論:

  • json_strict / no retry 在這次實驗中表現最好。
  • json_strict 把 JSON format_error 從 3 降到 0。
  • baseline + retry 也能修正 format_error,但多了 3 次 LLM call。
  • retry 有價值,但它是補救策略,不應該取代 prompt 改善。
  • 剩下的 wrong_answer 有不少其實是 evaluator 太字面。
  • rule-based evaluator 可解釋、便宜、穩定,但不擅長判斷語意等價。
  • 目前 dataset 很小,所以結論要保守。

這次實驗最務實的建議是:

預設使用 json_strict prompt。
保留 retry 作為格式錯誤的備援。
接下來改善 evaluator,而不是繼續只調 prompt。

下一步

Day 28 會開始整理整個專案。

到目前為止,我們已經有:

  • Agent Runner。
  • Tool。
  • Trace。
  • SQLite storage。
  • Trace Viewer。
  • Eval dataset。
  • Batch runner。
  • Evaluator。
  • Failure Dashboard。
  • Prompt versioning。
  • Retry。
  • JSON schema guardrail。
  • Tool guardrail。
  • Cost / latency metrics。
  • Reliability Dashboard。
  • Final experiment。

接下來要把這些整理成一個可以展示的專案。

Day 28 會補 README、專案架構說明、執行方式,以及系統流程圖。


上一篇
Day 26|執行最終實驗
系列文
從黑盒到可驗證:30 天打造 AI Agent 的 Trace、Eval 與 Guardrails 系統 共 27 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言