iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0

前言

Day 2 我們先建立了最小可用的 Agent Runner。

目前的流程是:

User Task -> Agent Runner -> LLM Client -> Final Answer

這個版本已經能接收任務並回傳答案,但它還比較像一個 Chatbot wrapper。真正的 Agent 通常不只產生文字,還會透過工具完成任務。

今天要替 Agent 加上第一個工具:calculator

完成後,流程會變成:

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

今天還不做完整 Trace,也不把 tool call 存進 SQLite。先讓工具呼叫流程跑起來,並在終端機印出基本 tool call 資訊。


今天要完成什麼?

今天的目標是:

讓 Agent 可以在需要計算時呼叫 calculator 工具,而不是只靠 LLM 自己回答。

會完成:

  1. 建立 tools/ 資料夾。
  2. 定義 tool 的基本輸入與輸出。
  3. 實作 calculator tool。
  4. 修改 SimpleAgent,讓它可以處理 tool call。
  5. 修改 FakeLLMClient,讓它在計算任務中產生 tool call request。
  6. 修改 app.py,顯示最後答案與工具呼叫資訊。

今天先不做:

  • 真正的 OpenAI / Claude / Gemini tool calling API。
  • 多工具選擇。
  • Tool call trace database。
  • Tool permission guardrail。

這些會留到後面幾天再逐步補上。


為什麼 Agent 需要工具?

LLM 很擅長理解語言與產生文字,但不是所有事情都適合交給它直接回答。

例如計算任務:

請計算 135 * 28

LLM 可能答對,也可能答錯。就算題目很簡單,也不應該把精確計算完全交給模型猜。

比較好的分工是:

LLM 負責判斷:這個任務需要計算
calculator tool 負責執行:135 * 28
Agent 負責整合:把工具結果整理成答案

這也是 Agent 和一般 Chatbot 的差異:

Chatbot 主要產生文字,Agent 可以透過工具對外部環境做事。


今天的專案結構

延續 Day 2 的結構,今天新增 tools/ 資料夾,並修改幾個既有檔案。

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

新增:

檔案 用途
tools/__init__.py tools 成為 Python package
tools/calculator.py 實作 calculator tool

修改:

檔案 修改內容
agents/simple_agent.py 加入 tool call 處理流程
agents/fake_llm.py 讓 fake LLM 可以要求呼叫 calculator
app.py 顯示工具呼叫紀錄

定義 calculator tool

新增 tools/calculator.py

import ast
import operator


ALLOWED_OPERATORS = {
    ast.Add: operator.add,
    ast.Sub: operator.sub,
    ast.Mult: operator.mul,
    ast.Div: operator.truediv,
}


def evaluate_expression(expression: str) -> float:
    node = ast.parse(expression, mode="eval")
    return _eval_node(node.body)


def _eval_node(node):
    if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
        return node.value

    if isinstance(node, ast.BinOp) and type(node.op) in ALLOWED_OPERATORS:
        left = _eval_node(node.left)
        right = _eval_node(node.right)
        return ALLOWED_OPERATORS[type(node.op)](left, right)

    raise ValueError("Unsupported expression")


def calculator(expression: str) -> str:
    result = evaluate_expression(expression)
    return str(result)

這個檔案提供一個 calculator(expression) 函式。

例如:

calculator("135 * 28")

會回傳:

3780

這裡沒有直接使用 Python 的 eval(),而是用 ast 解析算式,再只允許加、減、乘、除四種運算。

原因很直接:eval() 可以執行任意 Python 程式碼,對工具系統來說風險太高。即使今天只是練習,也不要讓工具變成任意程式執行入口。


建立 tools package

新增 tools/__init__.py

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

它的作用和 Day 2 的 agents/__init__.py 類似,讓 Python 把 tools 資料夾視為 package。


修改 FakeLLMClient:讓它可以要求使用工具

Day 2 的 FakeLLMClient 只會回傳文字。

今天先用一個簡單規則模擬 tool calling:

  • 如果 user task 包含 計算
  • 就回傳一個 tool call request
  • 否則回傳一般文字答案

修改 agents/fake_llm.py

import re


class FakeLLMClient:
    def chat(self, messages: list[dict]) -> dict:
        user_message = messages[-1]["content"]

        if "計算" in user_message:
            expression = self._extract_expression(user_message)
            return {
                "type": "tool_call",
                "tool_name": "calculator",
                "tool_input": expression,
            }

        return {
            "type": "final_answer",
            "content": f"Fake response for: {user_message}",
        }

    def _extract_expression(self, text: str) -> str:
        match = re.search(r"(\d+\s*[\+\-\*/]\s*\d+)", text)
        if not match:
            raise ValueError("No arithmetic expression found")
        return match.group(1)

這裡先讓 FakeLLMClient.chat() 回傳 dict,而不是 Day 2 的 str

原因是 Agent 現在不只會收到一般回答,也可能收到工具呼叫請求。回傳格式可以分成兩種:

一般回答:

{
    "type": "final_answer",
    "content": "..."
}

工具呼叫:

{
    "type": "tool_call",
    "tool_name": "calculator",
    "tool_input": "135 * 28"
}

這還不是真正的 LLM tool calling API,但可以先把 Agent 內部的工具流程設計清楚。


修改 SimpleAgent:處理 tool call

接著修改 agents/simple_agent.py

from dataclasses import dataclass, field
from typing import Protocol

from agents.prompts import SYSTEM_PROMPT
from tools.calculator import calculator


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)


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

    def run(self, user_task: str) -> AgentResult:
        messages = [
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": user_task},
        ]

        response = self.llm_client.chat(messages)

        if response["type"] == "final_answer":
            return AgentResult(answer=response["content"])

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

        raise ValueError(f"Unknown response type: {response['type']}")

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

        if tool_name not in self.tools:
            raise ValueError(f"Unknown tool: {tool_name}")

        tool_output = self.tools[tool_name](tool_input)

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

        answer = f"The result is {tool_output}"

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

這次 SimpleAgent 多了幾個重點。

第一,新增 ToolCallRecord,用來記錄工具名稱、工具輸入與工具輸出。

目前這些資料只會回傳給 app.py 顯示。到了 Day 4 和 Day 5,我們會把類似資訊整理成 Trace,再存進 SQLite。

第二,AgentResult 多了 tool_calls 欄位。

這表示一次 Agent 執行除了 final answer,也可以包含工具呼叫紀錄。

第三,SimpleAgent 裡面新增 self.tools

self.tools = {
    "calculator": calculator,
}

這是一個最小版 tool registry。之後如果要加入其他工具,例如 date_toolmock_search,可以繼續擴充這個 dictionary。

第四,run() 會根據 response type 決定下一步:

final_answer -> 直接回傳答案
tool_call -> 呼叫對應工具,再整理答案

這就是今天的最小版 tool calling 流程。


修改 app.py:顯示工具呼叫資訊

最後修改 app.py

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


if __name__ == "__main__":
    main()

這裡保留 Day 2 的輸入與輸出方式,只是多顯示 tool_calls

如果任務沒有用到工具,tool_calls 會是空 list,就不會印出工具資訊。


執行看看

在專案根目錄執行:

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

看到這個輸出,就表示 Agent 已經不只是回傳文字,也能根據任務呼叫工具。

也可以再測一個不需要工具的輸入:

請簡單介紹 AI Agent

預期會看到:

Answer:
Fake response for: 請簡單介紹 AI Agent

這次不會顯示 Tool Calls,因為任務沒有觸發 calculator。


今天先不做真正的 function calling

現在很多 LLM API 都支援 function calling 或 tool calling,可以讓模型用比較正式的格式選擇工具。

但今天先不使用那些 API,原因有兩個。

第一,不同 provider 的 tool calling 格式不同。如果一開始就綁死某個 SDK,文章會變成特定 API 教學,而不是 Agent 架構設計。

第二,今天要先弄清楚 Agent 內部需要哪些元件:

  • tool interface
  • tool registry
  • tool call request
  • tool execution
  • tool call record

只要這些概念清楚,之後要換成真正的 LLM function calling API,主要就是替換 FakeLLMClient 產生 tool call request 的方式。


今天完成後的系統狀態

今天完成後,系統具備:

  • 一個 calculator tool
  • 一個最小版 tool registry
  • 一個可以回傳 tool call request 的 FakeLLMClient
  • 一個可以執行工具的 SimpleAgent
  • 一個會顯示工具呼叫資訊的 app.py

目前還沒有:

  • 多輪工具呼叫
  • 工具權限檢查
  • tool call 錯誤分類
  • trace database
  • evaluation runner

這些會在後面的 Trace、Eval 與 Guardrails 階段逐步加進來。


今天的重點整理

今天重點不是 calculator 本身,而是建立 Agent 使用工具的基本流程。

流程可以整理成:

User Task
  -> LLM Client 判斷需要工具
  -> 回傳 tool call request
  -> Agent 查找 tool registry
  -> 執行 calculator
  -> 記錄 tool name / input / output
  -> 回傳 final answer

這個流程很重要。後面要做 Trace 時,我們要記錄的不只 final answer,還包含中間的 tool call。

如果工具呼叫沒有被記錄,Agent 還是很像黑盒。工具呼叫被完整記錄後,才有辦法進一步測試與除錯。


下一步

Day 4 會開始設計 Trace。

今天已經有了 tool call 資訊,但目前只是印在終端機上。下一步要思考的是:

一次 Agent 執行中,哪些資訊應該被記錄下來?

Day 4 會定義 session、step、tool call 等資料格式,讓後面可以把 Agent 的執行過程存起來,而不是只在終端機看一次就消失。


上一篇
Day 2|建立最小可用 Agent Runner
下一篇
Day 4:設計 Trace:記錄 Agent 每一步
系列文
從黑盒到可驗證:30 天打造 AI Agent 的 Trace、Eval 與 Guardrails 系統8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言