Day 22 我們加入了 Tool Guardrail。
到目前為止,平台已經不只會看 final answer。
它也開始檢查:
輸出格式是否符合 schema
工具呼叫是否被允許
失敗時是否可以 retry
這些策略能提高可靠性,但也會帶來額外代價。
例如 Day 19 的 retry:
第一次 LLM call 失敗
-> 第二次 LLM call 修正
它可能讓成功率變高,但也會增加:
接下來要把「可靠性」和「代價」放在同一張表裡看。
這篇的目標是:
在 eval result 中記錄每個 case 的 latency、LLM call count、token usage 和 estimated cost。
實作內容包括:
evals/runner.py 記錄每個 case 的 latency。llm_call_count。這一版先不處理:
先從 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/__init__.py:
這個檔案可以先留空。
它讓 Python 把 metrics/ 視為 package。
新增 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 先有可比較的欄位。
如果未來要更準,可以替換成:
接著在 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:
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
接著修改 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。
目前每個 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。
先在 evals/runner.py 加入 import:
from metrics.cost import estimate_cost, estimate_token_count
接下來不要在 try 區塊裡到處手動計算 input_tokens、output_tokens 和 estimated_cost。
原因是 runner 現在同時有:
如果每個分支都各自計算 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。
為了不要讓 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。
在完成 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
}
如果某個 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 失敗,也會知道它花了多久。
接著在 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
}
最後把 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
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 實際價格,建議用環境變數設定,而不是寫死在程式裡。
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_ms 是 case level latency。
它包含:
它不是純粹的模型 latency。
這樣設計是刻意的。
因為從使用者角度來看,他在意的是整個 case 花多久,不是只有模型生成花多久。
但如果未來要更細,可以再拆成:
| 欄位 | 說明 |
|---|---|
llm_latency_ms |
只算 LLM call |
tool_latency_ms |
只算工具 |
guardrail_latency_ms |
只算 guardrail |
evaluation_latency_ms |
只算 evaluator |
這一版先不拆。
MVP 階段先記錄總 latency 就夠。
目前的成本與延遲紀錄還是 MVP。
len(text) // 4 只是粗估。
它不等於模型實際 token 數。
所以今天的欄位叫:
estimated cost
不是:
actual cost
目前使用:
INPUT_COST_PER_1K_TOKENS
OUTPUT_COST_PER_1K_TOKENS
如果沒有設定,成本就是 0。
這是為了避免把價格寫死。
目前使用:
1 + retry_count
這對目前 agent 足夠,但如果未來有多輪 tool calling、多次模型思考,就要改成真實計數。
這一篇記錄 latency 和 execution error,但不處理 Gemini 429。
API rate limit 應該另外處理,例如:
這些功能先留到後面。
這一篇替 eval result 加入成本與延遲欄位。
完成內容包含:
metrics/__init__.py。metrics/cost.py。estimate_token_count()。estimate_cost()。evals/runner.py 使用 time.perf_counter() 記錄 case latency。llm_call_count。latency_ms、input_tokens、output_tokens、total_tokens、estimated_cost。完成後,eval run 不只回答:
有沒有通過?
為什麼失敗?
也開始回答:
花了多久?
用了多少 token?
估計成本是多少?
retry 多花了多少?
Day 24 會把這些欄位放進 Reliability Dashboard。
目前 Failure Dashboard 已經能看:
Day 24 會加入:
這樣 dashboard 就不只看可靠性,也能看可靠性背後的代價。