精華筆記

· @aihub.tw

AI Agent 開發

評估與除錯:agent 為什麼失敗

評估與除錯:agent 為什麼失敗

你的 agent 跑了十幾輪,最後輸出「I was unable to complete the task」——然後程式結束。你看著終端機,完全不知道它在中間做了什麼、在哪一步走偏了、是工具出了問題還是 prompt 根本沒說清楚。你再跑一次,得到不一樣的失敗。再跑一次,又不一樣。

這就是 agent 開發和傳統軟體開發最痛苦的差異:傳統程式壞了,你加一行 print 就知道在哪;agent 壞了,你面對的是模型的內部決策過程——看不見、不確定、每次不一樣。

評估和除錯不是開發完成後才加的補丁,是 agent 開發的核心工作方法。這堂課教你從第一行 code 就把觀察能力做進去,讓「agent 為什麼失敗」變成一個可以系統性回答的問題。

這堂課適合誰 適合:已經用 Claude Agent SDK 或自寫 loop 跑過 agent、但遇到失敗不知道怎麼除錯的工程師(本課程屬工程師專區)。需要基礎:能讀 Python、理解 agent loop 的 tool_use/tool_result 訊息格式、看過前 4 課至少到第 3 課。前置課:第 3 課(Claude Agent SDK 實戰)。

這堂學什麼

  • Trace 記錄系統:讓每一步工具呼叫、決策、輸入輸出都可以回放,不靠記憶猜
  • 四大失敗模式:工具誤用、迷路(lost in loop)、過早放棄、無限迴圈——辨識症狀與根因
  • 評估任務集:如何建立一組任務測量 agent 的真實能力,而不是跑一次看感覺
  • 迭代決策:面對失敗,怎麼判斷要改 prompt、改工具設計,還是動架構
  • 實戰:對自己的 agent 跑評估,修復兩個具體失敗案例

觀念一:沒有 trace 就沒有除錯

「你可以 debug 的,只有你看得見的東西。」這是 2026 年 agent 可觀察性的核心原則。

沒有刻意做觀察,你只能看到 agent 的最終輸出——但你看不見它做了幾次工具呼叫、每次傳了什麼參數、工具回傳了什麼、模型在哪一步改變決策方向。這跟開車時後照鏡完全黑掉是同一件事。

沒有 trace 就沒有除錯:黑箱 vs 逐步回放的對照

Trace 記錄要捕捉什麼?以下是 agent 除錯最有用的七個維度:

維度 記錄什麼 為什麼重要
Turn 序號 目前是第幾輪迴圈 知道 agent 跑了多少步
模型的文字輸出 模型在工具呼叫前說了什麼 看它的「想法」
Tool call 名稱 + 參數 呼叫了哪個工具、傳了什麼 找出工具誤用
工具回傳內容 工具回傳什麼給模型 判斷工具是否正常運作
stop_reason end_turn / tool_use / max_tokens 找出異常終止原因
token 用量 input/output tokens per turn 監控 context 壓力
執行時間 每一步花多久 找出異常慢的工具

以下是一個完整的 trace 記錄實作,插進任何自寫 loop 都能用:

import json
import time
from datetime import datetime
from pathlib import Path

class AgentTracer:
    """
    在 agent loop 裡插入這個 tracer,每步都有完整紀錄。
    輸出 JSONL 格式,每行一個事件,方便後續分析。
    """

    def __init__(self, run_id: str = None, output_dir: str = "./traces"):
        self.run_id = run_id or datetime.now().strftime("%Y%m%d_%H%M%S")
        self.output_dir = Path(output_dir)
        self.output_dir.mkdir(parents=True, exist_ok=True)
        self.trace_file = self.output_dir / f"{self.run_id}.jsonl"
        self.turn = 0
        self.start_time = time.time()

    def _write(self, event: dict):
        event["run_id"] = self.run_id
        event["elapsed_s"] = round(time.time() - self.start_time, 2)
        with open(self.trace_file, "a", encoding="utf-8") as f:
            f.write(json.dumps(event, ensure_ascii=False) + "\n")

    def log_task(self, task: str, system_prompt: str):
        self._write({
            "type": "task_start",
            "task": task,
            "system_prompt_len": len(system_prompt),
        })

    def log_turn_start(self, input_tokens_estimate: int = 0):
        self.turn += 1
        self._write({
            "type": "turn_start",
            "turn": self.turn,
            "input_tokens_estimate": input_tokens_estimate,
        })

    def log_model_output(self, stop_reason: str, text: str, usage: dict):
        self._write({
            "type": "model_output",
            "turn": self.turn,
            "stop_reason": stop_reason,
            "text_preview": text[:300] if text else "",  # 只存前 300 字,節省空間
            "usage": usage,
        })

    def log_tool_call(self, tool_name: str, tool_id: str, tool_input: dict):
        self._write({
            "type": "tool_call",
            "turn": self.turn,
            "tool_name": tool_name,
            "tool_id": tool_id,
            "tool_input": tool_input,
        })

    def log_tool_result(self, tool_id: str, tool_name: str, result: dict, duration_ms: int):
        self._write({
            "type": "tool_result",
            "turn": self.turn,
            "tool_id": tool_id,
            "tool_name": tool_name,
            # 只存 success 狀態和前 500 字,避免大結果塞爆 trace
            "result_success": result.get("success", True),
            "result_preview": json.dumps(result, ensure_ascii=False)[:500],
            "duration_ms": duration_ms,
        })

    def log_end(self, final_output: str, reason: str):
        self._write({
            "type": "task_end",
            "total_turns": self.turn,
            "reason": reason,  # "completed" / "max_turns" / "error"
            "final_output_preview": final_output[:300] if final_output else "",
        })
        print(f"[trace] 完整記錄存在 {self.trace_file}")

把 tracer 插進 agent loop:

import anthropic

client = anthropic.Anthropic()

def run_agent_with_trace(task: str, system: str, tools: list,
                          max_turns: int = 20) -> str:
    tracer = AgentTracer()
    tracer.log_task(task, system)

    messages = [{"role": "user", "content": task}]

    for _ in range(max_turns):
        tracer.log_turn_start()

        response = client.messages.create(
            model="claude-sonnet-4-5",
            max_tokens=4096,
            system=system,
            tools=tools,
            messages=messages
        )

        # 取出模型的文字輸出(如果有的話)
        text_output = ""
        for block in response.content:
            if block.type == "text":
                text_output += block.text

        tracer.log_model_output(
            stop_reason=response.stop_reason,
            text=text_output,
            usage={
                "input_tokens": response.usage.input_tokens,
                "output_tokens": response.usage.output_tokens,
            }
        )

        if response.stop_reason == "end_turn":
            tracer.log_end(text_output, "completed")
            return text_output

        if response.stop_reason == "tool_use":
            messages.append({"role": "assistant", "content": response.content})
            tool_results = []

            for block in response.content:
                if block.type == "tool_use":
                    tracer.log_tool_call(block.name, block.id, block.input)

                    t0 = time.time()
                    result = execute_tool(block.name, block.input)
                    duration_ms = int((time.time() - t0) * 1000)

                    tracer.log_tool_result(block.id, block.name, result, duration_ms)

                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": json.dumps(result, ensure_ascii=False),
                    })

            messages.append({"role": "user", "content": tool_results})

    tracer.log_end("", "max_turns")
    return "超過最大迴圈次數,任務未完成"

每次執行都會在 ./traces/ 目錄產出一個 JSONL 檔案。你可以用任何工具分析它——或是直接在除錯時打開來逐行讀。


觀念二:四大失敗模式

根據 2026 年 agent 社群的實際回報與研究,agent 的失敗有規律可循,可以分成四個清晰的類型。辨識出是哪種模式,才能對症下藥。

四大失敗模式:工具誤用、迷路、過早放棄、無限迴圈的症狀與 trace 特徵

失敗模式一:工具誤用(Tool Misuse)

症狀:agent 呼叫了工具,但傳了錯誤的參數——路徑格式錯、數字超出範圍、把 JSON 傳成字串。工具回傳錯誤,agent 可能嘗試幾次後放棄,也可能無視錯誤繼續跑而結果是錯的。

Trace 特徵:

[tool_call] tool_name=read_file, tool_input={"path": "data/report"}
[tool_result] result_success=false, result_preview={"error": "FILE_NOT_FOUND", ...}
[tool_call] tool_name=read_file, tool_input={"path": "data/report"}   ← 完全一樣的參數,重試
[tool_result] result_success=false, ...

根因:工具的 description 不夠清楚,或參數的 description 沒有說明格式(例如是不是需要副檔名、相對路徑還是絕對路徑)。

修法:回到工具 schema,在 path 參數的 description 裡加範例:"路徑要包含副檔名,例如 './data/report.txt' 或 '/tmp/logs/app.log'"

失敗模式二:迷路(Lost in Loop)

症狀:agent 沒有錯誤,只是原地繞圈——它做了 A,做了 B,又做一次 A,又做一次 B。每次工具都成功回傳,但任務毫無進展。或者更極端的版本:agent 一直說「讓我寫這份報告」,但從來沒有真的呼叫 write_file

Trace 特徵:

[turn 3] tool_call=search_web, query="AI agent frameworks 2026"
[turn 4] tool_call=search_web, query="top AI agent frameworks"   ← 幾乎一樣的查詢
[turn 5] tool_call=search_web, query="best AI agent tools 2026"  ← 再一次
[turn 6] model_output: "Let me search for more information..."    ← 說要做但不做

根因:system prompt 沒有明確定義「完成的標準」,或任務太模糊,讓 agent 陷入「再多查一點更好」的循環。另一個常見原因是搜尋結果不夠好,agent 不知道下一步可以做什麼,就繼續搜。

修法:在 system prompt 裡加入「完成標準」:"當你已經有足夠資訊回答問題時,直接輸出答案——不要為了讓答案更好而無限搜尋。"。同時設定硬性的工具呼叫上限。

失敗模式三:過早放棄(Early Termination)

症狀:agent 還沒完成任務就輸出最終答案。有時是工具報了一個不嚴重的錯誤,agent 就決定整個任務失敗;有時是 code 處理 stop_reason 的邏輯有 bug。

Trace 特徵:

[turn 2] tool_result: result_success=false, result_preview={"error":"PARTIAL_RESULT",...}
[turn 3] stop_reason=end_turn
[task_end] total_turns=3, reason="completed"   ← 只跑了 3 輪就結束,任務根本沒完成

或是 harness 層的 bug:

# 常見的過早放棄 bug:response.content[0] 可能是 text block,不是 tool_use
if response.content[0].type == "text":
    return response.content[0].text   # ← 太早結束!可能 text 後面還有 tool_use block

根因:有兩個方向:①模型判斷失誤(工具錯誤被認為是「任務失敗」);②你寫的 harness 邏輯有 bug,在不該結束的時候結束了。

修法:模型判斷的問題用 system prompt 處理:"遇到工具錯誤時,根據 hint 修正後重試,最多重試 2 次再放棄單一工具——除非確認整個任務目標無法達成,否則繼續執行其他步驟。" harness bug 則要修 code,正確的終止判斷是 stop_reason == "end_turn" 加上確認 content 裡沒有 tool_use block。

失敗模式四:無限迴圈(Infinite Loop)

症狀:agent 就是不停,token 一直燒。2026 年有真實案例是 5 分鐘內燒掉 400 萬 token——一次執行的費用可以超過 50 美元。典型模式是 agent 卡在一個失敗的指令,不斷重試同樣的動作,沒有任何修正。

Trace 特徵:

[turn 8]  tool_call=bash, command="npm run build"
[turn 9]  tool_call=bash, command="npm run build"   ← 完全一樣
[turn 10] tool_call=bash, command="npm run build"   ← 還是一樣
[turn 11] tool_call=bash, command="npm run build"   ← ...

根因:工具回傳的錯誤格式讓 agent 無法理解它在做同樣的事而且每次都失敗;或者缺少重複檢測機制,讓 agent 的迴圈沒有任何中斷條件。

修法:harness 層要加重複工具呼叫偵測:

from collections import Counter

def run_agent_with_loop_detection(task: str, ...) -> str:
    recent_tool_calls = []   # 存最近 N 次的 (tool_name, key_param)

    for turn in range(max_turns):
        # ... 正常的 loop 邏輯 ...

        for block in response.content:
            if block.type == "tool_use":
                # 把這次呼叫的特徵記錄下來
                call_signature = (block.name, json.dumps(block.input, sort_keys=True))
                recent_tool_calls.append(call_signature)

                # 如果最近 5 次中,同一個呼叫出現 3 次以上,強制中斷
                if len(recent_tool_calls) >= 5:
                    recent = recent_tool_calls[-5:]
                    counts = Counter(recent)
                    if counts.most_common(1)[0][1] >= 3:
                        return (
                            "Agent 偵測到重複迴圈,強制終止。"
                            f"最後一個重複呼叫:{call_signature[0]}。"
                            "請檢查工具回傳的錯誤訊息。"
                        )

四種失敗模式總結:

失敗模式 最快辨識方式 先改哪裡
工具誤用 trace 裡看 tool_input 格式 工具 description/schema
迷路 相似工具呼叫重複出現 system prompt:加完成標準
過早放棄 total_turns 異常少 harness 邏輯 or system prompt
無限迴圈 同一 tool_call 重複 3+ 次 harness:加重複偵測

觀念三:建立評估任務集

「跑一次看感覺」不算評估。你需要一個任務集(task set)——一組代表真實使用情境的任務,每次修改 agent 後都能快速測量有沒有進步。

評估任務集運作流程:任務集到並行執行、trace、評分、指標看板的迭代循環

怎麼設計任務集

一個好的任務集有三個特性:

1. 覆蓋主要使用情境:不是隨機找幾個任務,而是系統性地覆蓋你的 agent 要做的事。如果你的 agent 是「研究報告 agent」,任務集裡要有:簡單單步查詢、需要多來源整合的查詢、資訊模糊需要判斷的查詢,以及你已知的「邊界案例」(例如查不到資料怎麼辦)。

2. 有明確的期望輸出或通過標準:不是「輸出看起來有道理就算過」。每個任務要有評分標準,例如:「輸出必須包含至少 3 個具體的框架名稱」、「執行步驟不超過 10 輪」、「沒有呼叫任何工具超過 3 次」。

3. 數量夠做統計:研究表明,50 到 100 個任務是一個實際可操作的起點。太少(5-10 個)很容易因為模型的隨機性讓你得到誤導的數字。

以下是一個實際的任務集結構:

# eval_tasks.py
EVAL_TASKS = [
    {
        "id": "T01",
        "category": "simple_search",
        "difficulty": "easy",
        "task": "找出 LangGraph 的 GitHub 倉庫網址和當前 star 數(2026 年)。",
        "pass_criteria": {
            "must_contain": ["github.com/langchain-ai/langgraph"],
            "max_turns": 5,
            "max_tool_calls": 3,
        }
    },
    {
        "id": "T02",
        "category": "multi_source",
        "difficulty": "medium",
        "task": "比較 CrewAI 和 Claude Agent SDK 的主要差異:適合場景、學習曲線、費用模式。用 Markdown 表格整理。",
        "pass_criteria": {
            "must_contain": ["CrewAI", "Claude Agent SDK"],
            "output_format": "markdown_table",
            "max_turns": 12,
        }
    },
    {
        "id": "T03",
        "category": "edge_case",
        "difficulty": "hard",
        "task": "找出『AgentForge Pro』這個框架的官方文件網址。",
        # 這個框架不存在——測試 agent 遇到查不到資料時的行為
        "pass_criteria": {
            "must_acknowledge_not_found": True,
            "must_not_hallucinate_url": True,
            "max_turns": 5,
        }
    },
    {
        "id": "T04",
        "category": "file_operation",
        "difficulty": "medium",
        "task": "讀取 ./data/sample.txt,統計裡面有幾個獨立的英文單字,把結果寫到 ./output/wordcount.txt。",
        "pass_criteria": {
            "file_created": "./output/wordcount.txt",
            "max_turns": 8,
        }
    },
]

自動評分

有些標準可以機械性地驗證;有些需要「判斷」——可以用另一個 LLM 當評審:

import anthropic
import json

judge_client = anthropic.Anthropic()

def judge_output(task: dict, agent_output: str, trace_path: str) -> dict:
    """
    用 claude-haiku-4-5 當 judge:便宜、夠用。
    只在需要語意判斷的指標上用 LLM,其他用規則。
    """
    criteria = task["pass_criteria"]
    results = {}

    # 規則型評分
    if "must_contain" in criteria:
        for keyword in criteria["must_contain"]:
            results[f"contains_{keyword}"] = keyword.lower() in agent_output.lower()

    if "file_created" in criteria:
        from pathlib import Path
        results["file_created"] = Path(criteria["file_created"]).exists()

    # 讀 trace 取得 turn 數和 tool call 數
    turns = 0
    tool_call_count = 0
    with open(trace_path, "r", encoding="utf-8") as f:
        for line in f:
            event = json.loads(line)
            if event["type"] == "task_end":
                turns = event["total_turns"]
            if event["type"] == "tool_call":
                tool_call_count += 1

    if "max_turns" in criteria:
        results["within_turn_limit"] = turns <= criteria["max_turns"]
        results["actual_turns"] = turns

    if "max_tool_calls" in criteria:
        results["within_tool_limit"] = tool_call_count <= criteria["max_tool_calls"]
        results["actual_tool_calls"] = tool_call_count

    # LLM judge:語意判斷(只用在需要的任務)
    if criteria.get("must_not_hallucinate_url"):
        judge_prompt = f"""
以下是 agent 的輸出。請判斷 agent 是否捏造了一個不存在的 URL 或網址。
只回答 JSON:{{"hallucinated_url": true/false, "reason": "..."}}

Agent 輸出:
{agent_output[:1000]}
"""
        judge_response = judge_client.messages.create(
            model="claude-haiku-4-5",
            max_tokens=200,
            messages=[{"role": "user", "content": judge_prompt}]
        )
        try:
            judge_result = json.loads(judge_response.content[0].text)
            results["hallucinated_url"] = judge_result["hallucinated_url"]
        except Exception:
            results["hallucinated_url"] = "parse_error"

    # 計算通過率
    binary_results = {k: v for k, v in results.items()
                      if isinstance(v, bool)}
    if binary_results:
        results["pass_rate"] = sum(binary_results.values()) / len(binary_results)
        results["passed"] = results["pass_rate"] == 1.0

    return results


def run_eval_suite(tasks: list, agent_fn) -> dict:
    """跑完整任務集,輸出總體指標"""
    all_results = []

    for task in tasks:
        print(f"\n[eval] 跑任務 {task['id']}: {task['task'][:50]}...")
        output, trace_path = agent_fn(task["task"])
        result = judge_output(task, output, trace_path)
        result["task_id"] = task["id"]
        result["category"] = task["category"]
        all_results.append(result)
        print(f"  → passed={result.get('passed')}, turns={result.get('actual_turns')}")

    # 彙整指標
    passed = [r for r in all_results if r.get("passed")]
    summary = {
        "total": len(all_results),
        "passed": len(passed),
        "pass_rate": len(passed) / len(all_results),
        "by_category": {},
        "all_results": all_results,
    }

    for cat in set(r["category"] for r in all_results):
        cat_results = [r for r in all_results if r["category"] == cat]
        cat_passed = [r for r in cat_results if r.get("passed")]
        summary["by_category"][cat] = {
            "pass_rate": len(cat_passed) / len(cat_results),
            "count": len(cat_results),
        }

    return summary

觀念四:改 prompt、改工具,還是改架構?

這是 agent 迭代最核心的判斷問題。你跑完評估,拿到一堆失敗案例——現在要做什麼?亂改只會讓你更混亂。這裡給你一個系統性的決策流程:

迭代決策樹:從失敗案例逐層排查,改工具、改 prompt、改架構的判斷與成本

判斷規則很清楚:

先改 prompt:成本最低,改一段文字就好,10 分鐘可以驗證。適合情況:模型有正確的工具可以用,但選擇時機不對、任務理解有偏差、完成標準不清楚。Prompt 能解決的問題占大多數——先確認是不是 prompt 的問題再往下。

# prompt 層的改法範例:加入完成標準和失敗處理規則
SYSTEM_PROMPT_V2 = """你是一個研究報告 agent。

完成標準:
- 找到至少 3 個有具體數據佐證的事實
- 已使用 write_file 工具把報告存到指定路徑
- 確認存檔後才輸出「完成」

錯誤處理:
- 工具失敗時,根據 hint 最多重試 2 次
- 搜尋結果不理想時,換不同關鍵字試一次,而不是重複同樣搜尋
- 如果主要任務遇到無法解決的阻礙,完成其他可以完成的部分後,說明無法完成的原因
"""

再改工具:成本中等,要改 schema、description,甚至改工具實作。適合情況:trace 顯示模型傳了錯誤參數(工具誤用)、工具回傳的格式讓模型困惑、缺少一個 agent 一直試著手動做但做不好的操作。工具改完一定要重新跑完整任務集,確認沒有副作用。

最後改架構:成本最高。適合情況:一個 agent loop 解決不了任務複雜度(第 4 課教的多代理模式在這裡發揮)、需要 checkpoint 和 human-in-the-loop、context 管理問題是根本性的。改架構前要能清楚說出「為什麼 prompt 和工具都解決不了這個問題」。

迭代節奏 每次只改一個變數。如果你同時改了 prompt 又改了工具,跑完評估你不知道是哪個改動讓成績變好(或變差)。就算很急,也要忍住,一次只動一個層面,跑評估,記錄結果,再決定下一步。

手把手實戰

實戰目標:對一個研究報告 agent 跑評估,找出並修復兩個真實的失敗案例。

準備被評估的 agent

先用第 3 課的研究 agent 當起點。把 tracer 整合進去:

# research_agent_v1.py
import asyncio
import json
import time
from pathlib import Path
from claude_agent_sdk import query, ClaudeAgentOptions
from agent_tracer import AgentTracer  # 上面實作的 tracer

SYSTEM_PROMPT = """你是一個技術研究助理。
步驟:先搜尋,再抓頁面,整理成 Markdown,用 Write 存到指定路徑,確認後回報完成。
回報時要包含資料來源網址。"""


async def run_research(task: str, run_id: str = None) -> tuple[str, str]:
    """回傳 (最終輸出, trace 路徑)"""
    tracer = AgentTracer(run_id=run_id)
    tracer.log_task(task, SYSTEM_PROMPT)

    options = ClaudeAgentOptions(
        system_prompt=SYSTEM_PROMPT,
        allowed_tools=["WebSearch", "WebFetch", "Write"],
        model="claude-sonnet-4-5",
        max_turns=20,
    )

    result_text = ""
    turn = 0

    async for event in query(task, options=options):
        if event.type == "assistant":
            turn += 1
            tracer.log_turn_start()
            text = ""
            for block in event.message.content:
                if hasattr(block, "text"):
                    text += block.text
            tracer.log_model_output(
                stop_reason="in_progress",
                text=text,
                usage={}
            )
        elif event.type == "tool_use":
            tracer.log_tool_call(
                event.name, event.id,
                event.input if hasattr(event, "input") else {}
            )
        elif event.type == "tool_result":
            tracer.log_tool_result(
                event.tool_use_id, "", {"content": str(event.content)[:200]}, 0
            )
        elif event.type == "result":
            result_text = event.result or ""

    tracer.log_end(result_text, "completed")
    return result_text, str(tracer.trace_file)

跑評估,找出失敗案例

用前面的 run_eval_suiteEVAL_TASKS 跑一遍:

# run_eval.py
import asyncio
from research_agent_v1 import run_research
from eval_tasks import EVAL_TASKS
from evaluator import run_eval_suite


def agent_fn(task: str):
    """包裝 async agent 為同步函式"""
    return asyncio.run(run_research(task))


if __name__ == "__main__":
    summary = run_eval_suite(EVAL_TASKS, agent_fn)
    print(f"\n=== 評估結果 ===")
    print(f"通過率:{summary['pass_rate']:.1%} ({summary['passed']}/{summary['total']})")
    print("\n按類型分類:")
    for cat, data in summary["by_category"].items():
        print(f"  {cat}: {data['pass_rate']:.1%} ({data['count']} 個任務)")

假設跑完結果是:

  • simple_search:通過 4/5
  • multi_source:通過 2/5
  • edge_case:通過 1/3

修復失敗案例一:多來源整合任務的迷路問題

打開 multi_source 類型的失敗任務的 trace,看到這樣的模式:

[turn 3] tool_call=WebSearch, query="CrewAI vs Claude Agent SDK"
[turn 4] tool_call=WebSearch, query="CrewAI Claude Agent SDK comparison"
[turn 5] tool_call=WebSearch, query="Claude Agent SDK CrewAI difference"
[turn 6] tool_call=WebSearch, query="compare CrewAI and Claude Agent SDK 2026"
[turn 7] model_output: "Let me search for more specific information..."

這是「迷路」模式:search 結果每次都有一點用但不夠,agent 繼續搜,沒有整合既有資訊的動作。

修法:在 system prompt 加上搜尋策略約束:

SYSTEM_PROMPT_V2 = """你是一個技術研究助理。

搜尋策略:
- 每個主題最多搜尋 2 次——如果第一次結果不夠,換不同關鍵字試一次
- 搜尋 2 次後不管結果如何,都要整合現有資訊繼續下一步
- 不要反覆搜尋幾乎相同的關鍵字

輸出格式:有明確要求格式(如 Markdown 表格)時,一定要按格式輸出

完成標準:
- 已整合至少 2 個來源的資訊
- 已用 Write 存到指定路徑
- 回報「完成」並列出來源網址
"""

用修改後的 prompt 重跑 multi_source 任務集,通過率從 2/5 提升到 4/5。

修復失敗案例二:邊界案例的幻覺問題

edge_case 任務是查一個不存在的框架。打開失敗的 trace:

[turn 2] tool_call=WebSearch, query="AgentForge Pro framework documentation"
[turn 3] tool_result: {content: "沒有找到相關結果..."}
[turn 4] tool_call=WebFetch, url="https://agentforge.pro/docs"  ← 幻覺 URL!
[turn 5] tool_result: {error: "fetch failed, connection refused"}
[turn 6] model_output: "I found that AgentForge Pro documentation is available at..."  ← 還是幻覺

問題很明確:搜尋沒結果後,agent 捏造了一個 URL 去嘗試,失敗後卻仍輸出幻覺資訊。

修法:在 system prompt 裡加入明確的「查無結果」處理規則,同時在工具的 WebFetch 說明裡加入警告:

SYSTEM_PROMPT_V3 = SYSTEM_PROMPT_V2 + """

重要規則:
- 如果搜尋 2 次都找不到相關結果,直接說明「找不到關於 X 的可靠資訊」
- 絕對不要捏造或猜測 URL——只使用搜尋結果中出現的真實網址
- 不確定資訊的存在時,誠實說明限制,不要試圖用 WebFetch 去猜測網址
"""

重跑 edge_case 任務:通過率從 1/3 提升到 3/3——agent 現在會正確說「找不到這個框架的可靠資訊」而不是捏造 URL。

記錄每次迭代的指標

把每次修改前後的評估結果記錄下來——這是 agent 開發的「版本歷史」,讓你知道哪個修改有效、哪個沒用甚至帶來副作用:

# eval_log.jsonl 格式,每次評估後 append 一行
{
  "version": "v1",
  "date": "2026-07-04",
  "change": "baseline",
  "pass_rate": 0.583,
  "by_category": {
    "simple_search": 0.800,
    "multi_source": 0.400,
    "edge_case": 0.333
  }
}
{
  "version": "v2",
  "date": "2026-07-04",
  "change": "system prompt: 加搜尋策略約束 + 完成標準",
  "pass_rate": 0.750,
  "by_category": {
    "simple_search": 0.800,
    "multi_source": 0.800,
    "edge_case": 0.333
  }
}
{
  "version": "v3",
  "date": "2026-07-04",
  "change": "system prompt: 加查無結果處理規則",
  "pass_rate": 0.917,
  "by_category": {
    "simple_search": 0.800,
    "multi_source": 0.800,
    "edge_case": 1.000
  }
}

三個版本,兩個修改,整體通過率從 58% 提升到 92%——而且你清楚知道每個提升是哪個改動帶來的。

評估迭代進度:v1 到 v3 通過率 58% 到 92% 的版本演進與分類別變化


常見坑

坑 1:trace 檔案本身也有 bug——工具回傳沒被記錄

症狀:trace 裡有 tool_call 事件,但後面沒有對應的 tool_result 事件。你以為工具沒執行到,但其實工具執行了,只是你的 tracer 放在 try-catch 外面,工具拋出例外時 tracer 那行沒跑到。

具體症狀:

[turn 3] type=tool_call, tool_name=read_file
# ← 這裡應該有 tool_result,但沒有
[turn 4] type=turn_start   ← 跳到下一輪了

解法:把 tracer.log_tool_result 放在 finally 塊裡,確保不管工具成不成功都記錄:

t0 = time.time()
try:
    result = execute_tool(block.name, block.input)
except Exception as e:
    result = {"success": False, "error": str(e)}
finally:
    tracer.log_tool_result(
        block.id, block.name, result,
        int((time.time() - t0) * 1000)
    )

坑 2:評估任務集只測試「快樂路徑」,上線後才發現邊界案例

症狀:評估通過率 95%,上線後使用者回報各種奇怪的失敗。你回去看評估任務集,發現全部都是「正常輸入、清楚需求」的任務,完全沒有測試「查不到」、「輸入格式錯」、「任務矛盾」等邊界情況。

解法:建任務集時刻意加入三類邊界案例:

  • 不存在的資源:查一個不存在的工具、不存在的 API 端點
  • 模糊需求:任務說「整理一下最近的新聞」(最近是多久?什麼主題?)
  • 有衝突的指示:任務和 system prompt 有矛盾的要求

這些案例不需要比例很高,但每個類型至少要有 2-3 個代表。

坑 3:用 LLM 當 judge 但 judge 不穩定

症狀:同一個 agent 輸出,有時 LLM judge 說 pass、有時說 fail。評估結果每次跑都不一樣,讓你不知道改動是否真的有效。

具體錯誤表現:今天 pass_rate 是 78%,明天跑同樣的版本是 71%,差異來自 judge 的不一致性。

解法有三個方向:

  • 降低 judge 的使用範圍:能用規則判斷的(關鍵字包含、檔案是否建立、turn 數)就用規則,只在非用語意判斷不可時才用 LLM。
  • 固定 judge model 的 temperature:judge 呼叫時加 temperature=0,降低隨機性。
  • 多跑幾次取多數決:對同一個輸出跑 3 次 judge,取多數結果。這會增加成本但大幅提升穩定性。
# 穩定的 judge 呼叫
judge_response = judge_client.messages.create(
    model="claude-haiku-4-5",
    max_tokens=200,
    temperature=0,  # 加這個
    messages=[{"role": "user", "content": judge_prompt}]
)

坑 4:修了 prompt 通過率反而下降

症狀:你針對失敗的 edge_case 任務加了一條規則,跑完評估後 edge_case 通過率確實提升了——但 simple_search 的通過率從 80% 掉到 60%。

根本原因:你加的規則太嚴格或有副作用。例如「搜尋只能做 2 次」這個規則修好了迷路問題,卻讓某些需要 3 次搜尋的任務無法完成。

解法:這就是為什麼要跑完整任務集而不是只看目標類型的通過率。每次修改都要看所有類別的變化。如果改了 A 修壞了 B,就要找一個更精確的規則——或改用 few-shot example 來示範預期行為,而不是用強制規則限制:

# 不好:強制規則(可能有副作用)
"搜尋只能做 2 次"

# 更好:示範預期行為(更靈活)
"""範例:
任務:比較 LangGraph 和 CrewAI
步驟:
  1. 搜尋 "LangGraph 2026 features"
  2. 搜尋 "CrewAI 2026 overview"  
  (兩次搜尋後整合,不再繼續搜尋)
  3. 整合成比較表格...
"""

作業

  1. 為你的 agent 建立 trace 記錄:把本課的 AgentTracer 整合進你第 3 課做的 agent,執行 3 次不同任務,打開 JSONL 檔案讀每個事件,找出它的決策流程。目標:能夠從 trace 裡說出「agent 在第幾輪做了什麼決定、為什麼」。

  2. 設計 10 個評估任務:針對你的 agent 實際要解決的問題,設計 10 個任務——至少包含 2 個邊界案例(查不到的資源、模糊需求)。為每個任務寫通過標準。

  3. 修復一個失敗案例:從 10 個任務裡找出一個失敗的,用本課的判斷流程決定「改 prompt、改工具,還是改架構」,修改後重跑整個 10 個任務集,確認整體通過率沒有下降。

  4. 選做:實作 run_eval_suite 的完整版本,加上把每次評估結果 append 到 eval_log.jsonl 的功能,讓你有完整的迭代歷史。

下一課預告

agent 能穩定完成任務了,但開發環境能跑不代表生產環境能跑。第 6 課《部署:成本、延遲、安全、監控》會直面真實的部署問題:一次 agent 執行要花多少錢、怎麼設上限避免失控;長任務的延遲怎麼對使用者呈現;哪些工具在生產環境絕對不能開放;以及怎麼把這堂課的 trace 系統接上 OpenTelemetry 做長期監控。從「能跑」到「能賺錢地跑」,是這門課最後兩個章節的核心主題。

#AI Agent#評估#除錯#trace#失敗模式#工程師

← 回所有文章