iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0

前言

Day 22 我們加入了 Tool Guardrail。

到目前為止,平台已經不只會看 final answer。

它也開始檢查:

輸出格式是否符合 schema
工具呼叫是否被允許
失敗時是否可以 retry

這些策略能提高可靠性,但也會帶來額外代價。

例如 Day 19 的 retry:

第一次 LLM call 失敗
  -> 第二次 LLM call 修正

它可能讓成功率變高,但也會增加:

  • latency
  • token usage
  • API cost

接下來要把「可靠性」和「代價」放在同一張表裡看。

這篇的目標是:

在 eval result 中記錄每個 case 的 latency、LLM call count、token usage 和 estimated cost。


這篇要完成什麼?

實作內容包括:

  1. 說明為什麼 reliability 需要搭配成本與延遲一起看。
  2. 在 evals/runner.py 記錄每個 case 的 latency。
  3. 記錄每個 case 的 llm_call_count。
  4. 新增簡單 token estimation function。
  5. 新增 cost estimation function。
  6. 在每筆 eval result 中寫入 latency / token / cost 欄位。
  7. 在 eval run level 彙總 total latency、total tokens、total estimated cost。
  8. 說明 Gemini 429 時如何先用 fake provider 驗證欄位。

這一版先不處理:

  • 精準串接 Gemini billing API。
  • 完整 token accounting。
  • 不同模型的正式價格表。
  • dashboard 視覺化。
  • latency percentile。
  • 平行執行 evaluation。

先從 MVP 開始:

讓每次 eval run 開始留下成本與延遲欄位,後面 Day 24 才能做 Reliability Dashboard。


為什麼要記錄成本與延遲?

如果只看 success rate,很容易做出不完整的判斷。

例如:

設定 success rate
baseline 80%
retry enabled 93%

看起來 retry enabled 比較好。

但如果加上延遲:

設定 success rate avg latency
baseline 80% 1.2s
retry enabled 93% 3.8s

解讀就不一樣了。

如果這是離線 batch evaluation,3.8 秒可能可以接受。

但如果這是即時產品,使用者每次多等 2 秒以上,就可能不能接受。

再加上成本:

設定 success rate avg latency estimated cost
baseline 80% 1.2s $0.03
retry enabled 93% 3.8s $0.08

這時候問題會變成:

多 13% 成功率,是否值得多 2.6 秒和額外成本?

這就是 reliability engineering 需要回答的問題。


要記錄哪些欄位?

先從 case level 開始。

每一筆 result 新增:

欄位 說明
latency_ms 這個 case 從開始到結束花多少毫秒
llm_call_count 這個 case 呼叫 LLM 幾次
input_tokens 估算 input token 數
output_tokens 估算 output token 數
total_tokens input + output
estimated_cost 估算成本

run level 則新增:

欄位 說明
total_latency_ms 整次 eval run 花費時間
total_llm_call_count 總 LLM call 次數
total_input_tokens 總 input tokens
total_output_tokens 總 output tokens
total_tokens 總 tokens
total_estimated_cost 總估算成本

這一版的 token 和 cost 都先用估算。

原因是不同 provider 回傳 token usage 的方式不同。

而且你目前遇到 Gemini 429,不能每次都依賴真實 API。

因此先建立統一欄位;之後如果 Gemini client 能拿到真實 usage,再替換估算邏輯。


專案結構

這次主要修改 evals/runner.py,另外新增一個小工具檔。

agent-testing-platform/
  evals/
    runner.py
    evaluators.py
    cases.json
  metrics/
    __init__.py
    cost.py
  data/
    eval_runs/
      eval_run_*.json

新增檔案:

檔案 用途
metrics/__init__.py 讓 metrics 成為 Python package
metrics/cost.py 放 token 和 cost estimation helper

修改檔案:

檔案 修改內容
evals/runner.py 記錄 latency、LLM call count、token usage、estimated cost

以下檔案不會動到:

  • agents/simple_agent.py
  • agents/gemini_llm.py
  • guardrails/json_schema.py
  • guardrails/tool_permissions.py

這版先在 runner 層完成 MVP。

後面如果要精準記錄 provider usage,再回頭修改 Gemini client。


建立 metrics package

新增 metrics/__init__.py:

這個檔案可以先留空。

它讓 Python 把 metrics/ 視為 package。


實作 token 估算

新增 metrics/cost.py。

先實作一個很簡單的 token 估算。

新增 metrics/cost.py:

import os


def estimate_token_count(text: str | None) -> int:
    if not text:
        return 0

    return max(1, len(text) // 4)

這不是精準 tokenizer。

它只是粗略估算:

大約 4 個字元算 1 token

這對英文不算精準,對中文也只是近似。

眼前要解決的不是精準計費,而是讓 eval result 先有可比較的欄位。

如果未來要更準,可以替換成:

  • provider 回傳的 token usage。
  • tokenizer library。
  • 模型專屬 tokenizer。

實作 cost 估算

接著在 metrics/cost.py 加入 cost estimation。

繼續修改 metrics/cost.py:

def get_float_env(name: str, default: float) -> float:
    raw_value = os.getenv(name)

    if raw_value is None:
        return default

    return float(raw_value)


def estimate_cost(
    input_tokens: int,
    output_tokens: int,
) -> float:
    input_cost_per_1k = get_float_env(
        "INPUT_COST_PER_1K_TOKENS",
        0.0,
    )
    output_cost_per_1k = get_float_env(
        "OUTPUT_COST_PER_1K_TOKENS",
        0.0,
    )

    input_cost = input_tokens / 1000 * input_cost_per_1k
    output_cost = output_tokens / 1000 * output_cost_per_1k

    return input_cost + output_cost

這裡不把 Gemini 價格寫死在程式碼裡。

原因有兩個。

第一,模型價格可能變動。

第二,不同帳號、地區、模型、方案可能不同。

所以今天改用環境變數:

INPUT_COST_PER_1K_TOKENS
OUTPUT_COST_PER_1K_TOKENS

如果沒有設定,預設成本是 0。

這樣用 fake provider 跑 eval 時也不會出錯。

執行時可以這樣設定:

INPUT_COST_PER_1K_TOKENS=0.0001 \
OUTPUT_COST_PER_1K_TOKENS=0.0004 \
LLM_PROVIDER=fake \
python3 -m evals.runner

上面的數字只是示範,不代表實際 Gemini 價格。

實際價格要以你使用的模型與官方文件為準。


metrics/cost.py 完整版本

整理後,metrics/cost.py 會長這樣。

新增 metrics/cost.py:

import os


def estimate_token_count(text: str | None) -> int:
    if not text:
        return 0

    return max(1, len(text) // 4)


def get_float_env(name: str, default: float) -> float:
    raw_value = os.getenv(name)

    if raw_value is None:
        return default

    return float(raw_value)


def estimate_cost(
    input_tokens: int,
    output_tokens: int,
) -> float:
    input_cost_per_1k = get_float_env(
        "INPUT_COST_PER_1K_TOKENS",
        0.0,
    )
    output_cost_per_1k = get_float_env(
        "OUTPUT_COST_PER_1K_TOKENS",
        0.0,
    )

    input_cost = input_tokens / 1000 * input_cost_per_1k
    output_cost = output_tokens / 1000 * output_cost_per_1k

    return input_cost + output_cost

修改 runner:記錄 case latency

接著修改 evals/runner.py。

先加入 import。

修改 evals/runner.py:

import time

接著在每一筆 case 開始時記錄時間。

修改 evals/runner.py 的 case loop:

for test_case in cases:
    case_started_at = time.perf_counter()

    try:
        # 原本執行 agent、guardrail、evaluate 的流程

在 append result 前計算 latency:

latency_ms = round(
    (time.perf_counter() - case_started_at) * 1000,
    2,
)

time.perf_counter() 適合用來量時間差。

它比 datetime.now() 更適合測 latency。


記錄 llm_call_count

目前每個 case 正常會呼叫一次 LLM。

如果 Day 19 retry 發生,就會多一次。

所以可以先用:

llm_call_count = 1 + retry_count

這行不要獨立散落在 runner 的每個 try / except 區塊裡。

它會集中放進後面新增的 build_case_metrics() helper。

llm_call_count 最後會在這個位置產生:

return {
    "latency_ms": latency_ms,
    "llm_call_count": 1 + retry_count,
    "input_tokens": input_tokens,
    "output_tokens": output_tokens,
    "total_tokens": total_tokens,
    "estimated_cost": estimate_cost(
        input_tokens=input_tokens,
        output_tokens=output_tokens,
    ),
}

runner 在每個 case 結束時只要呼叫:

metrics = build_case_metrics(...)

然後把 metrics 展開放進 result:

results.append(
    {
        "case_id": test_case["id"],
        ...
        **metrics,
        "error": None,
    }
)

這一節先定義計算規則:

llm_call_count = 1 + retry_count

真正寫進程式的位置,是後面的 build_case_metrics()。

如果 Agent 在執行前就被 tool guardrail 擋下,理論上已經呼叫過一次 LLM 才知道它想用工具。

所以仍然可以算 1 次。

如果 exception 發生在建立 agent 或 API 呼叫前,這個估算就不完全準。

MVP 階段先接受這個近似。

之後如果要更精準,可以讓 SimpleAgent 或 LLM client 回傳實際 call count。


記錄 token 和 cost

先在 evals/runner.py 加入 import:

from metrics.cost import estimate_cost, estimate_token_count

接下來不要在 try 區塊裡到處手動計算 input_tokens、output_tokens 和 estimated_cost。

原因是 runner 現在同時有:

  • 正常完成的 case。
  • retry 後完成的 case。
  • tool guardrail 擋下來的 case。
  • API error 或其他 exception。

如果每個分支都各自計算 token 和 cost,很容易出現變數沒有初始化的問題。

例如 RETRY_ENABLED=0 時,retry 分支不會執行。

這時如果後面還使用 retry_input_tokens,就會出現:

UnboundLocalError: cannot access local variable 'retry_input_tokens'

因此改成集中處理:

所有 case metrics 都集中交給 build_case_metrics() 計算。

runner 只需要準備幾個輸入:

變數 說明
test_case["input"] 原始任務
result.answer 最後輸出
retry_task retry 時送出的修正任務,沒有 retry 就是 None
initial_actual retry 前第一次失敗輸出,沒有 retry 就是 None
retry_count retry 次數
latency_ms case 花費時間

接著統一呼叫:

metrics = build_case_metrics(
    original_input=test_case["input"],
    final_output=result.answer,
    retry_input=retry_task if retry_count else None,
    initial_output=initial_actual,
    retry_count=retry_count,
    latency_ms=latency_ms,
)

這樣 retry 有沒有開,都會走同一套 metrics 計算流程。

如果沒有 retry,retry_input 是 None,estimate_token_count(None) 會回傳 0。

如果有 retry,retry_input 和 initial_output 就會一起被算進 token usage。


建立 metrics helper

為了不要讓 runner 裡太亂,可以新增一個 helper。

修改 evals/runner.py,新增:

def build_case_metrics(
    original_input: str,
    final_output: str | None,
    retry_input: str | None = None,
    initial_output: str | None = None,
    retry_count: int = 0,
    latency_ms: float = 0.0,
) -> dict[str, float | int]:
    input_tokens = estimate_token_count(original_input)
    input_tokens += estimate_token_count(retry_input)

    output_tokens = estimate_token_count(final_output)
    output_tokens += estimate_token_count(initial_output)

    total_tokens = input_tokens + output_tokens

    return {
        "latency_ms": latency_ms,
        "llm_call_count": 1 + retry_count,
        "input_tokens": input_tokens,
        "output_tokens": output_tokens,
        "total_tokens": total_tokens,
        "estimated_cost": estimate_cost(
            input_tokens=input_tokens,
            output_tokens=output_tokens,
        ),
    }

這個 helper 接收:

參數 說明
original_input 原本 test case input
final_output 最後 Agent output
retry_input retry task,沒有 retry 就是 None
initial_output 第一次失敗 output,沒有 retry 就是 None
retry_count retry 次數
latency_ms case latency

回傳一組可以直接塞進 result dict 的 metrics。


把 metrics 寫入 result

在完成 evaluation 後,建立 metrics:

latency_ms = round(
    (time.perf_counter() - case_started_at) * 1000,
    2,
)

metrics = build_case_metrics(
    original_input=test_case["input"],
    final_output=result.answer,
    retry_input=retry_task if retry_count else None,
    initial_output=initial_actual,
    retry_count=retry_count,
    latency_ms=latency_ms,
)

接著在 result dict 裡加入:

**metrics,

例如:

results.append(
    {
        "case_id": test_case["id"],
        "input": test_case["input"],
        "expected": test_case["expected"],
        "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_count": retry_count,
        **metrics,
        "trace_session_id": result.trace.session_id,
        "error": None,
    }
)

這樣每筆 result 都會多出:

{
  "latency_ms": 1234.56,
  "llm_call_count": 1,
  "input_tokens": 12,
  "output_tokens": 8,
  "total_tokens": 20,
  "estimated_cost": 0.0
}

exception result 也要記錄 latency

如果某個 case 發生 exception,也應該記錄 latency。

例如 Gemini 429:

API call 開始
  -> 等待
  -> 回傳 429

這段時間也是 latency。

修改 except 區塊:

except Exception as exc:
    latency_ms = round(
        (time.perf_counter() - case_started_at) * 1000,
        2,
    )

    metrics = build_case_metrics(
        original_input=test_case["input"],
        final_output=None,
        retry_count=0,
        latency_ms=latency_ms,
    )

    results.append(
        {
            "case_id": test_case["id"],
            "input": test_case["input"],
            "expected": test_case["expected"],
            "task_type": test_case["task_type"],
            "status": "error",
            "actual": None,
            "passed": False,
            "failure_type": "execution_error",
            "failure_reason": "Agent execution error",
            **metrics,
            "trace_session_id": None,
            "error": str(exc),
        }
    )

這樣即使 case 失敗,也會知道它花了多久。


run level 彙總

接著在 eval_run 裡加入整體統計。

新增 helper:

def summarize_metrics(results: list[dict]) -> dict[str, float | int]:
    return {
        "total_latency_ms": round(
            sum(result.get("latency_ms", 0) for result in results),
            2,
        ),
        "total_llm_call_count": sum(
            result.get("llm_call_count", 0) for result in results
        ),
        "total_input_tokens": sum(
            result.get("input_tokens", 0) for result in results
        ),
        "total_output_tokens": sum(
            result.get("output_tokens", 0) for result in results
        ),
        "total_tokens": sum(
            result.get("total_tokens", 0) for result in results
        ),
        "total_estimated_cost": round(
            sum(result.get("estimated_cost", 0.0) for result in results),
            8,
        ),
    }

建立 eval_run 時使用:

metrics_summary = summarize_metrics(results)

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),
    **metrics_summary,
    "results": results,
}

這樣 eval run 會有:

{
  "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
}

修改 print_summary

最後把 summary 印出來。

修改 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(f"Total latency: {eval_run.get('total_latency_ms', 0)} ms")
    print(f"Total LLM calls: {eval_run.get('total_llm_call_count', 0)}")
    print(f"Total tokens: {eval_run.get('total_tokens', 0)}")
    print(f"Estimated cost: {eval_run.get('total_estimated_cost', 0.0):.8f}")
    print()

    for result in eval_run["results"]:
        label = "PASS" if result["passed"] else "FAIL"
        retry_count = result.get("retry_count", 0)
        latency_ms = result.get("latency_ms", 0)

        print(
            f"[{label}] {result['case_id']} - "
            f"{result['task_type']} - "
            f"retry={retry_count} - "
            f"latency={latency_ms}ms"
        )

        if result["failure_type"]:
            print(f"  type: {result['failure_type']}")

        if result["failure_reason"]:
            print(f"  reason: {result['failure_reason']}")

這樣終端機就能看到:

Total latency: 18420.32 ms
Total LLM calls: 18
Total tokens: 2560
Estimated cost: 0.00123000

用 fake provider 驗證

Gemini API 可能遇到 429,可以先用 fake provider 驗證欄位。

執行:

RETRY_ENABLED=0 LLM_PROVIDER=fake python3 -m evals.runner

你應該會看到 summary 多出:

Total latency
Total LLM calls
Total tokens
Estimated cost

也可以打開最新 eval run JSON:

python3 -m json.tool data/eval_runs/eval_run_你的檔名.json

檢查每筆 result 是否都有:

{
  "latency_ms": 1.23,
  "llm_call_count": 1,
  "input_tokens": 10,
  "output_tokens": 12,
  "total_tokens": 22,
  "estimated_cost": 0.0
}

fake provider 的 latency 通常很低。

這沒關係。

這裡要驗證的是欄位能不能正常產生。

真正的 latency 比較,要等 Gemini quota 穩定後再跑。


如果要估算成本

如果想看到非 0 cost,可以先用示範價格。

INPUT_COST_PER_1K_TOKENS=0.0001 \
OUTPUT_COST_PER_1K_TOKENS=0.0004 \
RETRY_ENABLED=0 \
LLM_PROVIDER=fake \
python3 -m evals.runner

再次提醒:

這些價格只是示範,不代表 Gemini 實際價格。

實際寫報告時,要把它稱為:

estimated cost

不要稱為真實 billing cost。

如果要換成 Gemini 實際價格,建議用環境變數設定,而不是寫死在程式裡。


retry 對成本的影響

Day 19 的 retry 會影響三個欄位:

llm_call_count
input_tokens
output_tokens

如果沒有 retry:

{
  "retry_count": 0,
  "llm_call_count": 1
}

如果 retry 一次:

{
  "retry_count": 1,
  "llm_call_count": 2
}

而 retry task 通常比原始 input 更長。

因為它會包含:

  • 原始任務。
  • 前一次輸出。
  • 錯誤原因。
  • 重新輸出要求。

所以 retry 不只多一次 call,也可能增加 input tokens。

這也是 Day 23 要記錄成本的原因。Day 19 如果只看 success rate,很容易漏掉 retry 的代價。


latency 要怎麼解讀?

這裡的 latency_ms 是 case level latency。

它包含:

  • LLM call。
  • tool execution。
  • guardrail validation。
  • evaluator。
  • retry。

它不是純粹的模型 latency。

這樣設計是刻意的。

因為從使用者角度來看,他在意的是整個 case 花多久,不是只有模型生成花多久。

但如果未來要更細,可以再拆成:

欄位 說明
llm_latency_ms 只算 LLM call
tool_latency_ms 只算工具
guardrail_latency_ms 只算 guardrail
evaluation_latency_ms 只算 evaluator

這一版先不拆。

MVP 階段先記錄總 latency 就夠。


目前設計的限制

目前的成本與延遲紀錄還是 MVP。

1. Token 是估算,不是精準值

len(text) // 4 只是粗估。

它不等於模型實際 token 數。

所以今天的欄位叫:

estimated cost

不是:

actual cost

2. Cost 需要手動設定

目前使用:

INPUT_COST_PER_1K_TOKENS
OUTPUT_COST_PER_1K_TOKENS

如果沒有設定,成本就是 0。

這是為了避免把價格寫死。

3. LLM call count 是估算

目前使用:

1 + retry_count

這對目前 agent 足夠,但如果未來有多輪 tool calling、多次模型思考,就要改成真實計數。

4. 429 仍然需要另外處理

這一篇記錄 latency 和 execution error,但不處理 Gemini 429。

API rate limit 應該另外處理,例如:

  • case 間隔時間。
  • API-level exponential backoff。
  • resume failed cases。
  • 只跑指定 case ids。

這些功能先留到後面。


重點整理

這一篇替 eval result 加入成本與延遲欄位。

完成內容包含:

  • 新增 metrics/__init__.py。
  • 新增 metrics/cost.py。
  • 實作 estimate_token_count()。
  • 實作 estimate_cost()。
  • 在 evals/runner.py 使用 time.perf_counter() 記錄 case latency。
  • 記錄 llm_call_count。
  • 在每筆 result 中加入 latency_ms、input_tokens、output_tokens、total_tokens、estimated_cost。
  • 在 eval run level 加入 total latency、total LLM calls、total tokens、total estimated cost。
  • 說明 retry 對成本與延遲的影響。

完成後,eval run 不只回答:

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

也開始回答:

花了多久?
用了多少 token?
估計成本是多少?
retry 多花了多少?

下一步

Day 24 會把這些欄位放進 Reliability Dashboard。

目前 Failure Dashboard 已經能看:

  • success rate
  • failed cases
  • failure type distribution
  • task type failure rate

Day 24 會加入:

  • average latency
  • total estimated cost
  • LLM call count
  • retry count
  • prompt version 比較

這樣 dashboard 就不只看可靠性,也能看可靠性背後的代價。


上一篇
Day 22|Tool Guardrail:限制 Agent 可用工具
下一篇
Day 24|建立 Reliability Dashboard
系列文
從黑盒到可驗證:30 天打造 AI Agent 的 Trace、Eval 與 Guardrails 系統 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言