iT邦幫忙

2026 iThome 鐵人賽

DAY 4
0

前言

Day 3 我們替 Agent 加上了第一個工具 calculator

目前 Agent 已經可以根據任務呼叫工具,例如:

User Task
  -> LLM Client
  -> Tool Call
  -> Tool Result
  -> Final Answer

但現在還有一個問題:這些過程只會印在終端機上,程式執行完就消失。

如果 Agent 回答錯誤,我們很難回頭檢查:

  • 使用者原本輸入什麼?
  • LLM Client 回傳了什麼?
  • Agent 有沒有呼叫工具?
  • 工具輸入是什麼?
  • 工具輸出是什麼?
  • 最後答案是怎麼產生的?

所以 Day 4 要開始處理這個系列的第一個重點:Trace

Trace 要做的事很單純:

把一次 Agent 執行過程中的重要步驟記錄下來,讓 Agent 不再只是輸入與輸出的黑盒。


今天要完成什麼?

今天要設計一個最小可用的 Trace 格式,並讓 SimpleAgent 在執行時產生 trace。

會完成:

  1. 設計 TraceStep
  2. 設計 AgentTrace
  3. 修改 SimpleAgent,在執行過程中記錄 step。
  4. 修改 app.py,把 trace 印出來。
  5. 用一個計算任務展示完整 trace。

今天先不做:

  • SQLite 儲存。
  • Trace Viewer UI。
  • Dashboard。
  • Failure Analysis。

這些放到後面幾天。今天先把資料格式設計好。


Trace 要記錄什麼?

Trace 可以想成 Agent 的執行紀錄。

如果只看 final answer,我們只會知道:

Answer:
The result is 3780

但如果有 trace,我們可以看到:

Step 1: user_input
使用者要求計算 135 * 28

Step 2: llm_response
LLM Client 判斷需要呼叫 calculator

Step 3: tool_call
呼叫 calculator,輸入 135 * 28

Step 4: tool_result
calculator 回傳 3780

Step 5: final_answer
Agent 回答 The result is 3780

這些資訊對後面的除錯與評測很有用。

例如未來某次測試失敗時,如果只看到:

Expected: 3780
Actual: The result is 13528

我們不知道錯在哪裡。

但如果 trace 顯示:

tool_input: "13528"

就可以知道問題可能出在「LLM Client 抽取算式時把 135 * 28 解析錯了」,而不是 calculator 算錯。

這就是 Trace 派上用場的地方。


今天的專案結構

今天新增 tracing/ 資料夾,專門放 trace 相關資料模型。

agent-testing-platform/
  app.py
  agents/
    __init__.py
    simple_agent.py
    prompts.py
    fake_llm.py
  tools/
    __init__.py
    calculator.py
  tracing/
    __init__.py
    models.py

新增:

檔案 用途
tracing/__init__.py tracing 成為 Python package
tracing/models.py 定義 TraceStep 與 AgentTrace

修改:

檔案 修改內容
agents/simple_agent.py 在 Agent 執行過程中建立 trace
app.py 顯示 trace JSON

設計 TraceStep

新增 tracing/models.py

from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
from uuid import uuid4


@dataclass
class TraceStep:
    step_type: str
    name: str
    input_data: dict[str, Any] = field(default_factory=dict)
    output_data: dict[str, Any] = field(default_factory=dict)
    error: str | None = None
    timestamp: str = field(default_factory=lambda: datetime.now().isoformat())

TraceStep 代表 Agent 執行過程中的一個步驟。

這裡先放幾個欄位:

欄位 說明
step_type 步驟類型,例如 user_inputllm_responsetool_call
name 步驟名稱,方便閱讀
input_data 這一步的輸入資料
output_data 這一步的輸出資料
error 如果這一步失敗,記錄錯誤訊息
timestamp 這一步發生的時間

這個設計先保持簡單。

等後面接上 SQLite,這些欄位會變成資料表中的欄位。


設計 AgentTrace

繼續修改 tracing/models.py,在 TraceStep 下方新增 AgentTrace

@dataclass
class AgentTrace:
    session_id: str = field(default_factory=lambda: str(uuid4()))
    steps: list[TraceStep] = field(default_factory=list)

    def add_step(
        self,
        step_type: str,
        name: str,
        input_data: dict[str, Any] | None = None,
        output_data: dict[str, Any] | None = None,
        error: str | None = None,
    ) -> None:
        self.steps.append(
            TraceStep(
                step_type=step_type,
                name=name,
                input_data=input_data or {},
                output_data=output_data or {},
                error=error,
            )
        )

    def to_dict(self) -> dict[str, Any]:
        return {
            "session_id": self.session_id,
            "steps": [
                {
                    "step_type": step.step_type,
                    "name": step.name,
                    "input_data": step.input_data,
                    "output_data": step.output_data,
                    "error": step.error,
                    "timestamp": step.timestamp,
                }
                for step in self.steps
            ],
        }

AgentTrace 代表一次 Agent 執行。

它有兩個主要欄位:

  • session_id:每次執行都有一個唯一 ID。
  • steps:這次執行中的所有步驟。

add_step() 是輔助方法,讓我們可以用比較乾淨的方式加入步驟。

例如:

trace.add_step(
    step_type="user_input",
    name="User Task",
    input_data={"content": user_task},
)

to_dict() 則留給後面輸出 JSON 或存進資料庫時使用。


建立 tracing package

新增 tracing/__init__.py

這個檔案今天可以先留空,不需要放任何程式碼。

它的用途是讓 Python 把 tracing 資料夾視為 package,這樣其他檔案才能匯入:

from tracing.models import AgentTrace

修改 SimpleAgent:加入 trace

接著修改 agents/simple_agent.py

from dataclasses import dataclass, field
from typing import Protocol

from agents.prompts import SYSTEM_PROMPT
from tools.calculator import calculator
from tracing.models import AgentTrace


class LLMClient(Protocol):
    def chat(self, messages: list[dict]) -> dict:
        ...


@dataclass
class ToolCallRecord:
    tool_name: str
    tool_input: str
    tool_output: str


@dataclass
class AgentResult:
    answer: str
    tool_calls: list[ToolCallRecord] = field(default_factory=list)
    trace: AgentTrace = field(default_factory=AgentTrace)


class SimpleAgent:
    def __init__(self, llm_client: LLMClient):
        self.llm_client = llm_client
        self.tools = {
            "calculator": calculator,
        }

    def run(self, user_task: str) -> AgentResult:
        trace = AgentTrace()
        trace.add_step(
            step_type="user_input",
            name="User Task",
            input_data={"content": user_task},
        )

        messages = [
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": user_task},
        ]

        response = self.llm_client.chat(messages)
        trace.add_step(
            step_type="llm_response",
            name="LLM Response",
            input_data={"messages": messages},
            output_data={"response": response},
        )

        if response["type"] == "final_answer":
            answer = response["content"]
            trace.add_step(
                step_type="final_answer",
                name="Final Answer",
                output_data={"answer": answer},
            )
            return AgentResult(answer=answer, trace=trace)

        if response["type"] == "tool_call":
            return self._handle_tool_call(response, trace)

        trace.add_step(
            step_type="error",
            name="Unknown Response Type",
            input_data={"response": response},
            error=f"Unknown response type: {response['type']}",
        )
        raise ValueError(f"Unknown response type: {response['type']}")

    def _handle_tool_call(self, response: dict, trace: AgentTrace) -> AgentResult:
        tool_name = response["tool_name"]
        tool_input = response["tool_input"]

        trace.add_step(
            step_type="tool_call",
            name="Tool Call",
            input_data={
                "tool_name": tool_name,
                "tool_input": tool_input,
            },
        )

        if tool_name not in self.tools:
            trace.add_step(
                step_type="error",
                name="Unknown Tool",
                input_data={"tool_name": tool_name},
                error=f"Unknown tool: {tool_name}",
            )
            raise ValueError(f"Unknown tool: {tool_name}")

        tool_output = self.tools[tool_name](tool_input)

        trace.add_step(
            step_type="tool_result",
            name="Tool Result",
            input_data={"tool_name": tool_name},
            output_data={"tool_output": tool_output},
        )

        tool_call = ToolCallRecord(
            tool_name=tool_name,
            tool_input=tool_input,
            tool_output=tool_output,
        )

        answer = f"The result is {tool_output}"
        trace.add_step(
            step_type="final_answer",
            name="Final Answer",
            output_data={"answer": answer},
        )

        return AgentResult(
            answer=answer,
            tool_calls=[tool_call],
            trace=trace,
        )

這次主要改了幾個地方。

第一,AgentResult 多了一個 trace 欄位:

trace: AgentTrace = field(default_factory=AgentTrace)

也就是說,每次 Agent 執行完成後,不只會回傳答案,也會回傳執行紀錄。

第二,在 run() 一開始建立 trace:

trace = AgentTrace()

然後立刻記錄使用者輸入:

trace.add_step(
    step_type="user_input",
    name="User Task",
    input_data={"content": user_task},
)

第三,呼叫 LLM Client 後,也把 response 記錄起來。

Agent 後續行為是根據 LLM response 決定的。如果沒有記錄這一步,之後就很難知道 Agent 為什麼會呼叫某個工具。

第四,工具呼叫被拆成兩個 step:

  • tool_call:記錄工具名稱與工具輸入。
  • tool_result:記錄工具執行後的輸出。

未來如果工具結果錯誤,我們可以分辨是「工具輸入錯」還是「工具執行錯」。


修改 app.py:印出 trace JSON

最後修改 app.py

import json

from agents.fake_llm import FakeLLMClient
from agents.simple_agent import SimpleAgent


def main():
    agent = SimpleAgent(llm_client=FakeLLMClient())

    user_task = input("Task: ")
    result = agent.run(user_task)

    print("\nAnswer:")
    print(result.answer)

    if result.tool_calls:
        print("\nTool Calls:")
        for tool_call in result.tool_calls:
            print(f"- tool_name: {tool_call.tool_name}")
            print(f"  tool_input: {tool_call.tool_input}")
            print(f"  tool_output: {tool_call.tool_output}")

    print("\nTrace:")
    print(json.dumps(result.trace.to_dict(), ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()

這次 app.py 多做一件事:

print(json.dumps(result.trace.to_dict(), ensure_ascii=False, indent=2))

ensure_ascii=False 是為了讓中文正常顯示,不會被轉成 Unicode escape。

indent=2 則是讓 JSON 比較好閱讀。


執行看看

在專案根目錄執行:

python3 app.py

輸入:

請計算 135 * 28

預期會看到類似結果:

Task: 請計算 135 * 28

Answer:
The result is 3780

Tool Calls:
- tool_name: calculator
  tool_input: 135 * 28
  tool_output: 3780

Trace:
{
  "session_id": "e76471be-7f4c-460c-80c9-f42dddfb9c18",
  "steps": [
    {
      "step_type": "user_input",
      "name": "User Task",
      "input_data": {
        "content": "請計算 135 * 28"
      },
      "output_data": {},
      "error": null,
      "timestamp": "2026-09-05T10:30:00.000000"
    },
    {
      "step_type": "llm_response",
      "name": "LLM Response",
      "input_data": {
        "messages": [
          {
            "role": "system",
            "content": "..."
          },
          {
            "role": "user",
            "content": "請計算 135 * 28"
          }
        ]
      },
      "output_data": {
        "response": {
          "type": "tool_call",
          "tool_name": "calculator",
          "tool_input": "135 * 28"
        }
      },
      "error": null,
      "timestamp": "2026-09-05T10:30:00.000000"
    }
  ]
}

實際輸出的 session_idtimestamp 每次都會不同,這是正常的。

後面還會有 tool_calltool_resultfinal_answer 等 step。上面的範例只截取前幾段,避免文章太長。


Trace 如何幫助除錯?

假設今天輸入:

請計算 135 28

這不是一個合法算式,因為中間沒有 +-*/

依照 Day 3 的 FakeLLMClient,它會嘗試抽取算式,但找不到符合格式的內容,最後丟出錯誤。

如果沒有 trace,我們可能只看到:

ValueError: No arithmetic expression found

有了 trace 設計後,我們就知道接下來應該把錯誤也記錄成 step。

目前 Day 4 的錯誤紀錄還不完整,這是刻意留下來的範圍。後面做 Failure Analysis 時,我們會把錯誤整理成更穩定的分類,例如:

  • format_error
  • tool_error
  • wrong_answer
  • instruction_error

今天先把正常執行流程記錄起來。


今天完成後的系統狀態

今天完成後,系統具備:

  • TraceStep 資料模型。
  • AgentTrace 資料模型。
  • 每次 Agent 執行都有 session_id
  • Agent 可以記錄 user_input
  • Agent 可以記錄 llm_response
  • Agent 可以記錄 tool_call
  • Agent 可以記錄 tool_result
  • Agent 可以記錄 final_answer
  • app.py 可以把 trace 以 JSON 形式印出。

目前還沒有:

  • 把 trace 存進 SQLite。
  • 查詢歷史 trace。
  • Trace Viewer UI。
  • 錯誤分類。
  • 評測資料集。

今天的重點整理

今天開始把 Agent 從黑盒往可觀察的系統推進。

Day 2 的 Agent 只能回傳答案。

Day 3 的 Agent 可以呼叫工具。

Day 4 的 Agent 開始能說清楚自己執行過程中發生了哪些步驟。

目前最重要的設計是:

AgentTrace
  -> session_id
  -> steps[]

TraceStep
  -> step_type
  -> name
  -> input_data
  -> output_data
  -> error
  -> timestamp

有了這個結構,後面才有辦法做:

  • SQLite 儲存。
  • Trace Viewer。
  • Evaluation。
  • Failure Analysis。
  • Reliability Dashboard。

下一步

Day 5 會把今天的 Trace 寫進 SQLite。

目前 trace 只存在記憶體中,程式執行結束就會消失。下一篇會建立最小資料庫結構,包含:

  • sessions table
  • steps table

讓每次 Agent 執行都能被保存下來。之後才可以查詢歷史紀錄、做 Trace Viewer,並和 Eval 結果連結。


上一篇
Day 3|替 Agent 加上第一個工具
下一篇
Day 5|把 Trace 存進 SQLite
系列文
從黑盒到可驗證:30 天打造 AI Agent 的 Trace、Eval 與 Guardrails 系統8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言