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 自己回答。
會完成:
tools/ 資料夾。calculator tool。SimpleAgent,讓它可以處理 tool call。FakeLLMClient,讓它在計算任務中產生 tool call request。app.py,顯示最後答案與工具呼叫資訊。今天先不做:
這些會留到後面幾天再逐步補上。
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 |
顯示工具呼叫紀錄 |
新增 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/__init__.py:
這個檔案今天可以先留空,不需要放任何程式碼。
它的作用和 Day 2 的 agents/__init__.py 類似,讓 Python 把 tools 資料夾視為 package。
Day 2 的 FakeLLMClient 只會回傳文字。
今天先用一個簡單規則模擬 tool calling:
計算
修改 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 內部的工具流程設計清楚。
接著修改 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_tool 或 mock_search,可以繼續擴充這個 dictionary。
第四,run() 會根據 response type 決定下一步:
final_answer -> 直接回傳答案
tool_call -> 呼叫對應工具,再整理答案
這就是今天的最小版 tool calling 流程。
最後修改 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。
現在很多 LLM API 都支援 function calling 或 tool calling,可以讓模型用比較正式的格式選擇工具。
但今天先不使用那些 API,原因有兩個。
第一,不同 provider 的 tool calling 格式不同。如果一開始就綁死某個 SDK,文章會變成特定 API 教學,而不是 Agent 架構設計。
第二,今天要先弄清楚 Agent 內部需要哪些元件:
只要這些概念清楚,之後要換成真正的 LLM function calling API,主要就是替換 FakeLLMClient 產生 tool call request 的方式。
今天完成後,系統具備:
calculator toolFakeLLMClient
SimpleAgent
app.py
目前還沒有:
這些會在後面的 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 的執行過程存起來,而不是只在終端機看一次就消失。