Day 23 我們替每次 eval run 加上了成本與延遲欄位。
現在每一筆 result 不只知道:
有沒有通過?
為什麼失敗?
也開始知道:
花了多久?
呼叫幾次 LLM?
用了多少 token?
估計成本是多少?
有沒有 retry?
這些欄位如果只放在 JSON 裡,仍然不太容易比較。
這一篇會把 Day 17 的 Failure Dashboard 擴充成第一版 Reliability Dashboard。
它不只看錯誤,也看可靠性背後的代價。
目標是:
在既有 Failure Dashboard 上加入 latency、cost、LLM call、retry 和 prompt version 比較。
實作分成幾個部分:
ui/failure_dashboard.py 的 results_to_dataframe()。這一版先不做:
先做出能用的 dashboard:
讓我們可以從一個畫面看出「成功率提升是否伴隨更多成本與延遲」。
Day 17 的 Failure Dashboard 主要回答:
哪裡失敗?
為什麼失敗?
哪一類任務最常失敗?
Day 24 的 Reliability Dashboard 則多回答幾個問題:
這次 run 整體成功率是多少?
平均 latency 是多少?
總共呼叫幾次 LLM?
retry 發生幾次?
估計成本是多少?
不同 prompt version 的結果差在哪?
這版不追求華麗的 dashboard,先讓資料能支持工程判斷。
例如:
| 設定 | success rate | avg latency | total LLM calls | estimated cost |
|---|---|---|---|---|
| baseline | 73.3% | 1200ms | 15 | 0.002 |
| json_strict | 86.7% | 1300ms | 15 | 0.0023 |
| retry enabled | 93.3% | 2400ms | 18 | 0.0038 |
這樣才看得出來:
可靠性提升了多少?
代價增加了多少?
這個代價能不能接受?
這次沿用 Day 17 建立的 dashboard。
agent-testing-platform/
failure_dashboard_app.py
ui/
failure_dashboard.py
data/
eval_runs/
eval_run_*.json
主要修改:
| 檔案 | 修改內容 |
|---|---|
ui/failure_dashboard.py |
加入 reliability metrics、成本延遲表格、prompt version 比較 |
不另外新增 Streamlit 入口。
仍然使用:
streamlit run failure_dashboard_app.py
原因是 Failure Dashboard 和 Reliability Dashboard 其實看的是同一份 eval run。
我們只是把觀察角度從「錯誤分析」擴充成「可靠性與代價分析」。
Day 23 後,每個 result 應該會有這些欄位:
{
"latency_ms": 12.34,
"llm_call_count": 1,
"input_tokens": 8,
"output_tokens": 12,
"total_tokens": 20,
"estimated_cost": 0.0,
"retry_count": 0
}
eval run level 也會有:
{
"total_latency_ms": 18420.32,
"total_llm_call_count": 18,
"total_input_tokens": 2048,
"total_output_tokens": 512,
"total_tokens": 2560,
"total_estimated_cost": 0.00123
}
如果你的舊 eval run 沒有這些欄位,dashboard 仍然要能打開。
所以 UI 會使用:
result.get("latency_ms", 0)
這種寫法讓舊資料不會讓 dashboard 壞掉。
Day 17 的 results_to_dataframe() 只處理錯誤分析需要的欄位。
接著把 Day 23 新增的 metrics 欄位放進 DataFrame。
修改 ui/failure_dashboard.py 的 results_to_dataframe():
def results_to_dataframe(eval_run: dict[str, Any]) -> pd.DataFrame:
results = eval_run.get("results", [])
rows = []
for result in results:
rows.append(
{
"run_id": eval_run.get("run_id"),
"created_at": eval_run.get("created_at"),
"llm_provider": eval_run.get("llm_provider"),
"prompt_version": eval_run.get("prompt_version"),
"retry_enabled": eval_run.get("retry_enabled", False),
"case_id": result.get("case_id"),
"task_type": result.get("task_type"),
"grading_method": result.get("grading_method"),
"status": result.get("status"),
"passed": result.get("passed", False),
"failure_type": result.get("failure_type"),
"failure_reason": result.get("failure_reason"),
"retry_count": result.get("retry_count", 0),
"latency_ms": result.get("latency_ms", 0),
"llm_call_count": result.get("llm_call_count", 0),
"input_tokens": result.get("input_tokens", 0),
"output_tokens": result.get("output_tokens", 0),
"total_tokens": result.get("total_tokens", 0),
"estimated_cost": result.get("estimated_cost", 0.0),
"input": result.get("input"),
"expected": json.dumps(
result.get("expected"),
ensure_ascii=False,
),
"actual": result.get("actual"),
"trace_session_id": result.get("trace_session_id"),
"error": result.get("error"),
}
)
return pd.DataFrame(rows)
這裡有兩個小重點。
第一,prompt_version 是 eval run level 的欄位。
但我們把它複製到每一列 result,這樣後面做 DataFrame 統計會比較方便。
第二,所有 metrics 都有預設值。
這可以讓舊的 eval run 仍然可以被 dashboard 讀取。
Dashboard 裡會顯示 latency 和 cost。
如果每個地方都手動格式化,程式會很散。
修改 ui/failure_dashboard.py,新增兩個小 helper:
def format_ms(value: float | int | None) -> str:
if value is None:
return "0 ms"
return f"{float(value):.2f} ms"
def format_cost(value: float | int | None) -> str:
if value is None:
return "0.00000000"
return f"{float(value):.8f}"
這裡先不加貨幣符號。
因為 Day 23 的 cost 是 estimated cost,而且價格由環境變數決定。
如果你之後確認單位是 USD,再顯示成 $0.00000000 也可以。
接著新增一個 summary 區塊。
Day 17 原本已經有 success rate。
今天補上:
修改 ui/failure_dashboard.py,新增 render_reliability_summary():
def render_reliability_summary(
eval_run: dict[str, Any],
results_df: pd.DataFrame,
) -> None:
st.subheader("Reliability Metrics")
if results_df.empty:
st.info("沒有結果可以統計。")
return
avg_latency_ms = results_df["latency_ms"].mean()
total_llm_calls = int(
eval_run.get(
"total_llm_call_count",
results_df["llm_call_count"].sum(),
)
)
total_tokens = int(
eval_run.get(
"total_tokens",
results_df["total_tokens"].sum(),
)
)
total_estimated_cost = float(
eval_run.get(
"total_estimated_cost",
results_df["estimated_cost"].sum(),
)
)
retry_cases = int((results_df["retry_count"] > 0).sum())
col1, col2, col3, col4, col5 = st.columns(5)
col1.metric("Avg latency", format_ms(avg_latency_ms))
col2.metric("LLM calls", total_llm_calls)
col3.metric("Total tokens", total_tokens)
col4.metric("Estimated cost", format_cost(total_estimated_cost))
col5.metric("Retry cases", retry_cases)
這裡的 total_llm_call_count 優先讀 eval run level。
如果舊資料沒有這個欄位,就改用每筆 result 的 llm_call_count 加總。
這是為了讓 dashboard 對新舊資料都比較有容錯性。
summary 只能看整體。
但真的要除錯時,通常需要看到是哪幾個 case 特別慢、特別貴,或發生 retry。
修改 ui/failure_dashboard.py,新增 render_case_metrics_table():
def render_case_metrics_table(results_df: pd.DataFrame) -> None:
st.subheader("Case Metrics")
if results_df.empty:
st.info("沒有 case metrics 可以顯示。")
return
display_df = results_df[
[
"case_id",
"task_type",
"passed",
"retry_count",
"latency_ms",
"llm_call_count",
"input_tokens",
"output_tokens",
"total_tokens",
"estimated_cost",
]
].copy()
display_df = display_df.sort_values(
by=["passed", "latency_ms"],
ascending=[True, False],
)
st.dataframe(
display_df,
use_container_width=True,
hide_index=True,
)
這裡排序時把失敗案例放前面,再依 latency 由高到低排列。
原因是 dashboard 的第一個任務仍然是幫助我們找到需要處理的問題。
如果某個 case:
failed + latency 高 + retry_count 高
它通常比單純的 pass case 更值得優先看。
接著把 metrics 依任務類型整理。
這可以回答:
哪一類任務最慢?
哪一類任務最容易 retry?
哪一類任務成本最高?
修改 ui/failure_dashboard.py,新增 build_task_type_reliability_summary():
def build_task_type_reliability_summary(
results_df: pd.DataFrame,
) -> pd.DataFrame:
if results_df.empty:
return pd.DataFrame()
summary_df = (
results_df.groupby("task_type")
.agg(
total=("case_id", "count"),
passed=("passed", "sum"),
avg_latency_ms=("latency_ms", "mean"),
total_llm_calls=("llm_call_count", "sum"),
total_tokens=("total_tokens", "sum"),
estimated_cost=("estimated_cost", "sum"),
retry_cases=("retry_count", lambda values: int((values > 0).sum())),
)
.reset_index()
)
summary_df["failed"] = summary_df["total"] - summary_df["passed"]
summary_df["success_rate"] = summary_df["passed"] / summary_df["total"]
return summary_df.sort_values(
by=["success_rate", "avg_latency_ms"],
ascending=[True, False],
)
再新增畫面 function:
def render_task_type_reliability(results_df: pd.DataFrame) -> None:
st.subheader("Task Type Reliability")
summary_df = build_task_type_reliability_summary(results_df)
if summary_df.empty:
st.info("沒有 task type 可以統計。")
return
display_df = summary_df.copy()
display_df["success_rate"] = display_df["success_rate"].map(
lambda value: f"{value:.1%}"
)
display_df["avg_latency_ms"] = display_df["avg_latency_ms"].map(format_ms)
display_df["estimated_cost"] = display_df["estimated_cost"].map(format_cost)
st.dataframe(
display_df,
use_container_width=True,
hide_index=True,
)
chart_df = summary_df.set_index("task_type")
st.bar_chart(chart_df[["avg_latency_ms", "total_llm_calls"]])
這裡表格比圖表更重要。
因為目前資料量很小,表格通常比圖表更容易看出細節。
圖表只是幫助快速看出哪一類 task 比較慢。
Day 18 我們把 prompt_version 寫進 eval run。
今天可以開始把它用起來。
這裡先做最小版本:
讀取
data/eval_runs/裡所有 eval run,整理成 run-level comparison table。
修改 ui/failure_dashboard.py,新增 eval_runs_to_summary_dataframe():
def eval_runs_to_summary_dataframe(
eval_run_files: list[Path],
) -> pd.DataFrame:
rows = []
for path in eval_run_files:
eval_run = load_eval_run(path)
results = eval_run.get("results", [])
total_cases = eval_run.get("total_cases", len(results))
passed_count = sum(1 for result in results if result.get("passed"))
success_rate = passed_count / total_cases if total_cases else 0
total_latency_ms = eval_run.get("total_latency_ms")
if total_latency_ms is None:
total_latency_ms = sum(
result.get("latency_ms", 0) for result in results
)
avg_latency_ms = (
total_latency_ms / total_cases if total_cases else 0
)
rows.append(
{
"run_id": eval_run.get("run_id", path.stem),
"created_at": eval_run.get("created_at"),
"llm_provider": eval_run.get("llm_provider"),
"prompt_version": eval_run.get("prompt_version", "unknown"),
"retry_enabled": eval_run.get("retry_enabled", False),
"total_cases": total_cases,
"passed": passed_count,
"success_rate": success_rate,
"avg_latency_ms": avg_latency_ms,
"total_llm_calls": eval_run.get(
"total_llm_call_count",
sum(result.get("llm_call_count", 0) for result in results),
),
"total_tokens": eval_run.get(
"total_tokens",
sum(result.get("total_tokens", 0) for result in results),
),
"estimated_cost": eval_run.get(
"total_estimated_cost",
sum(result.get("estimated_cost", 0.0) for result in results),
),
}
)
return pd.DataFrame(rows)
接著新增 render_prompt_version_comparison():
def render_prompt_version_comparison(eval_run_files: list[Path]) -> None:
st.subheader("Prompt Version Comparison")
summary_df = eval_runs_to_summary_dataframe(eval_run_files)
if summary_df.empty:
st.info("沒有 eval run 可以比較。")
return
display_df = summary_df.copy()
display_df["success_rate"] = display_df["success_rate"].map(
lambda value: f"{value:.1%}"
)
display_df["avg_latency_ms"] = display_df["avg_latency_ms"].map(format_ms)
display_df["estimated_cost"] = display_df["estimated_cost"].map(format_cost)
st.dataframe(
display_df,
use_container_width=True,
hide_index=True,
)
version_df = (
summary_df.groupby("prompt_version")
.agg(
runs=("run_id", "count"),
avg_success_rate=("success_rate", "mean"),
avg_latency_ms=("avg_latency_ms", "mean"),
avg_cost=("estimated_cost", "mean"),
)
.reset_index()
)
if len(version_df) <= 1:
st.info("目前只有一種 prompt version,還無法做版本比較。")
return
st.bar_chart(
version_df,
x="prompt_version",
y="avg_success_rate",
)
這個比較還不能當成正式實驗結論。
因為不同 run 可能有不同條件:
所以 Day 24 的 prompt version comparison 只是探索用。
真正要下結論,要等 Day 25 設計固定實驗條件。
最後修改 render_failure_dashboard()。
Day 17 原本大致是:
render_summary(eval_run, results_df)
st.divider()
render_failed_cases(results_df)
st.divider()
col1, col2 = st.columns(2)
with col1:
render_failure_type_chart(results_df)
with col2:
render_task_type_summary(results_df)
st.divider()
render_representative_failures(results_df)
把新的 reliability 區塊插進去。
修改 ui/failure_dashboard.py 的 render_failure_dashboard():
def render_failure_dashboard() -> None:
st.set_page_config(
page_title="Agent Reliability Dashboard",
layout="wide",
)
st.title("Agent Reliability Dashboard")
st.caption("分析 eval run 的成功率、錯誤類型、延遲、成本與 prompt version")
eval_run_files = list_eval_run_files()
if not eval_run_files:
st.info("目前沒有 eval run。請先執行 python3 -m evals.runner。")
return
selected_file = st.selectbox(
"選擇 eval run",
options=eval_run_files,
format_func=lambda path: path.name,
)
eval_run = load_eval_run(selected_file)
results_df = results_to_dataframe(eval_run)
render_summary(eval_run, results_df)
render_reliability_summary(eval_run, results_df)
st.divider()
render_case_metrics_table(results_df)
st.divider()
col1, col2 = st.columns(2)
with col1:
render_failure_type_chart(results_df)
with col2:
render_task_type_summary(results_df)
st.divider()
render_task_type_reliability(results_df)
st.divider()
render_failed_cases(results_df)
st.divider()
render_representative_failures(results_df)
st.divider()
render_prompt_version_comparison(eval_run_files)
這樣原本的 failure analysis 還在。
只是畫面多了三個新視角:
Reliability Metrics
Case Metrics
Prompt Version Comparison
先產生一份 eval run。
如果 Gemini 目前容易遇到 429,可以先用 fake provider:
RETRY_ENABLED=0 LLM_PROVIDER=fake python3 -m evals.runner
如果想看 retry 對 LLM call count 的影響,可以再跑:
RETRY_ENABLED=1 LLM_PROVIDER=fake python3 -m evals.runner
接著啟動 dashboard:
streamlit run failure_dashboard_app.py
畫面中應該會看到:
fake provider 的結果通常會長這樣:
Total cases: 15
Passed: 5
Failed: 10
Total LLM calls: 15
Estimated cost: 0.00000000
這不表示系統有問題,因為 fake provider 本來就不是為了答對所有題目。
它的用途是驗證:
runner 可以跑完
eval run 可以寫出
dashboard 可以讀取欄位
metrics 可以顯示
retry count 可以被統計
Day 24 使用 fake provider,是要確認 dashboard 的資料管線是否完整,不是評估成功率。
如果 Gemini quota 穩定,可以執行:
RETRY_ENABLED=0 PROMPT_VERSION=baseline LLM_PROVIDER=gemini python3 -m evals.runner
或:
RETRY_ENABLED=0 PROMPT_VERSION=json_strict LLM_PROVIDER=gemini python3 -m evals.runner
再回 dashboard 看比較表。
你要觀察的不是單一數字,而是幾個指標一起看:
| 問題 | 看哪個欄位 |
|---|---|
| prompt 是否讓更多 case 通過? | success_rate |
| prompt 是否變得比較慢? | avg_latency_ms |
| retry 是否增加呼叫次數? | total_llm_calls |
| 成本是否增加? | estimated_cost |
| 錯誤類型是否改變? | failure_type |
如果 json_strict 讓 JSON case 通過率提高,而且 latency 和 cost 沒有明顯增加,那它就是很值得保留的改善。
如果 retry 讓成功率提高,但 LLM calls 和 latency 大幅上升,就要看產品情境是否能接受。
因為 dashboard 只是觀察工具。
它讓我們比較容易看資料,但它不會自動保證實驗公平。
例如這兩次 run:
baseline: 早上跑,Gemini quota 正常
json_strict: 晚上跑,Gemini 開始 429
如果第二次失敗比較多,不能直接說 json_strict 比較差。
也可能只是 API 狀態不同。
Day 24 的範圍很明確:
建立觀察介面,不急著下最終結論。
Day 25 會開始設計比較嚴謹的最終實驗。
這一篇把 Day 17 的 Failure Dashboard 擴充成 Reliability Dashboard。
完成內容包含:
results_to_dataframe() 加入 Day 23 的 metrics 欄位。format_ms() 和 format_cost()。render_reliability_summary()。render_case_metrics_table()。現在 dashboard 不只回答:
哪裡失敗?
也開始回答:
失敗或成功的代價是多少?
哪一種改善策略比較划算?
Day 25 會進入最終實驗設計。
我們會固定幾個實驗條件,例如:
接著定義要比較的指標。
有了 Reliability Dashboard,後面可以用同一組畫面檢查實驗結果,不必每次手動翻 JSON。