iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0

前言

Day 23 我們替每次 eval run 加上了成本與延遲欄位。

現在每一筆 result 不只知道:

有沒有通過?
為什麼失敗?

也開始知道:

花了多久?
呼叫幾次 LLM?
用了多少 token?
估計成本是多少?
有沒有 retry?

這些欄位如果只放在 JSON 裡,仍然不太容易比較。

這一篇會把 Day 17 的 Failure Dashboard 擴充成第一版 Reliability Dashboard。

它不只看錯誤,也看可靠性背後的代價。


這篇要完成什麼?

目標是:

在既有 Failure Dashboard 上加入 latency、cost、LLM call、retry 和 prompt version 比較。

實作分成幾個部分:

  1. 擴充 ui/failure_dashboard.py 的 results_to_dataframe()。
  2. 新增 reliability summary metrics。
  3. 顯示每個 case 的 latency / token / cost。
  4. 依 task type 統計平均 latency、總成本與 retry 次數。
  5. 加入 prompt version 比較表。
  6. 保留 Day 17 原本的 failed cases 與 failure distribution。

這一版先不做:

  • 即時重新執行 eval。
  • 互動式 trace drill-down。
  • 真實 billing 串接。
  • 多模型正式實驗結論。
  • 複雜圖表套件。

先做出能用的 dashboard:

讓我們可以從一個畫面看出「成功率提升是否伴隨更多成本與延遲」。


為什麼叫 Reliability 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。

我們只是把觀察角度從「錯誤分析」擴充成「可靠性與代價分析」。


確認 eval run 已經有 metrics 欄位

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 壞掉。


擴充 results_to_dataframe

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 讀取。


新增格式化 helper

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 也可以。


顯示 reliability summary

接著新增一個 summary 區塊。

Day 17 原本已經有 success rate。

今天補上:

  • average latency
  • total LLM calls
  • total tokens
  • estimated cost
  • retry cases

修改 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 對新舊資料都比較有容錯性。


顯示每個 case 的成本與延遲

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 更值得優先看。


依 task type 統計可靠性與成本

接著把 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 比較慢。


加入 prompt version 比較

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 可能有不同條件:

  • provider 不同。
  • retry 設定不同。
  • eval dataset 版本不同。
  • Gemini quota 或 429 狀況不同。

所以 Day 24 的 prompt version comparison 只是探索用。

真正要下結論,要等 Day 25 設計固定實驗條件。


把新區塊放進 render_failure_dashboard

最後修改 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

執行 dashboard

先產生一份 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

畫面中應該會看到:

  • 原本的 summary。
  • 新增的 reliability metrics。
  • 每個 case 的 latency / token / cost。
  • task type reliability table。
  • prompt version comparison。

用 fake provider 的結果怎麼看?

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 結果怎麼看?

如果 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()。
  • 新增 task type reliability 統計。
  • 新增 prompt version comparison。
  • 保留原本 failure type distribution 和 failed cases table。

現在 dashboard 不只回答:

哪裡失敗?

也開始回答:

失敗或成功的代價是多少?
哪一種改善策略比較划算?

下一步

Day 25 會進入最終實驗設計。

我們會固定幾個實驗條件,例如:

  • baseline prompt
  • json_strict prompt
  • retry disabled
  • retry enabled
  • fake provider local validation
  • Gemini provider real run

接著定義要比較的指標。

有了 Reliability Dashboard,後面可以用同一組畫面檢查實驗結果,不必每次手動翻 JSON。


上一篇
Day 23|成本與延遲紀錄
系列文
從黑盒到可驗證:30 天打造 AI Agent 的 Trace、Eval 與 Guardrails 系統 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言