到昨天為止,Memora已經有一套可以維護 Long-term Memory 的 Application Flow:
找回相關 Memory
→ 建立回答 Context
→ 產生回答
→ 抽取新的 Memory
→ Deduplication、Update 或 Create
但這些動作都是 Python 程式按照固定順序執行。LLM 的工作仍然只有:
接收 Input
→ 產生 Output
如果希望它不只回傳文字,而是能要求 Application 執行某個功能,就需要加入:
Tool Calling
今天先不把 search()、update() 或 delete() 開放成 Memory Tools,因為那會直接跳到我原本規劃在 Day28的內容。這一篇只在昨天的 Memora 中加入一個簡單、唯讀的英文工具,完整看懂一次 Tool Call 如何發生。
假設使用者說:
請精確計算這句話有幾個英文單字:
I study English every day.
加入 Tool Calling 後,模型不是直接執行 Python Function。真正的流程是:
1. Application 把 Tool Definition 提供給模型
2. 模型回傳 Function Call Request
3. Application 解析 Tool Name 與 Arguments
4. Application 自己執行 Python Function
5. Application 把 Function Result 回傳模型
6. 模型根據結果產生 Final Response
所以 Tool Calling 比較像:
LLM 提出一個結構化的動作要求,Application 決定是否執行,並把執行結果交還給模型。
LLM 本身不會因為知道 Function 名稱,就自動取得執行程式或存取系統的權限。
今天新增:
count_english_words
選擇這個功能有三個原因:
和 Memora 的英文學習角色有關
結果可以由 Python 明確計算
不會改動 Memory 或外部資料
這裡計算的是一般英文單字,不是 Day 2 提過的 Model Token。
例如:
I study English every day.
會得到五個 Words;但送進模型後實際切成多少 Tokens,是另一件事。
在既有 Imports 中加入:
import re
接著新增:
COUNT_WORDS_MAX_CHARS = 2000
def count_english_words(
text: str
) -> dict:
if not isinstance(text, str):
raise ValueError(
"text must be a string"
)
if not text.strip():
raise ValueError(
"text must not be empty"
)
if len(text) > COUNT_WORDS_MAX_CHARS:
raise ValueError(
"text is too long"
)
words = re.findall(
r"[A-Za-z]+(?:['’][A-Za-z]+)?",
text
)
return {
"word_count": len(words),
"counting_rule": (
"English letter sequences; "
"contractions count as one word"
)
}
這個 Function 才是真正執行計算的地方。模型只能決定是否請求它,不能改變裡面的計算規則。
長度限制也放在 Python Function,而不是只相信模型一定會傳入合理資料。
模型看不到前面的 Python Source Code,因此還要提供一份 Tool Definition:
TOOLS = [
{
"type": "function",
"name": "count_english_words",
"description": (
"Count the English words in a piece "
"of text. Use this when the user "
"asks for an exact word count."
),
"parameters": {
"type": "object",
"properties": {
"text": {
"type": "string",
"description": (
"The English text to count."
)
}
},
"required": ["text"],
"additionalProperties": False
},
"strict": True
}
]
這份 Definition 告訴模型:
| 欄位 | 用途 |
|---|---|
type |
這是一個 Function Tool |
name |
模型要回傳的 Tool Name |
description |
什麼情況適合使用 |
parameters |
Arguments 的 JSON Schema |
strict |
要求 Arguments 符合 Schema |
使用 strict=True 時,Object Schema 要把可接受的欄位列入 properties 與 required,並設定 additionalProperties=False。這能限制輸出格式,但 Application 仍然要驗證資料並處理執行錯誤。
Day 13 已經使用過 Structured Output。兩者都會用到 Schema,但目的不同:
| 機制 | 模型回傳什麼 | Application 接下來做什麼 |
|---|---|---|
| Structured Output | 符合格式的資料 | 驗證並使用資料 |
| Tool Calling | Function Name 與 Arguments | 執行對應 Function,再回傳結果 |
Day 24 的:
MemoryReconciliationDecision
只是讓模型輸出 create、skip 或 update 等 Decision。
今天的:
count_english_words(text=...)
則會真正進入 Application 的 Function Dispatcher。
Day 24 的 SYSTEM_PROMPT 全部保留,只在 Guidelines 後增加:
Tool guidelines:
- Use an available tool when it can provide a
more reliable result than guessing.
- Never claim that a tool was executed unless
the application returned a tool result.
- Explain the final result clearly and briefly.
Tool 的 description 負責說明單一工具的用途;System Prompt 則提供所有 Tools 共用的行為原則。
原本主迴圈只呼叫一次:
response = client.responses.create(
model=MODEL,
instructions=SYSTEM_PROMPT,
input=memory_stats[
"context_messages"
]
)
現在先加入 tools:
response = client.responses.create(
model=MODEL,
instructions=SYSTEM_PROMPT,
input=input_messages,
tools=TOOLS,
tool_choice="auto",
parallel_tool_calls=False
)
tool_choice="auto" 表示模型可以:
直接回答
或
選擇使用 Tool
今天把 parallel_tool_calls 設為 False,讓第一次 Response 最多只有一個 Function Call。多個 Tools 與重複執行的情況會留到 Day 27 的 Agent Loop。
如果模型決定使用 Tool,response.output_text 不一定已經是可以顯示給使用者的最終答案。Application 要先檢查:
response.output
模型可能回傳概念上類似:
{
"type": "function_call",
"call_id": "call_abc123",
"name": "count_english_words",
"arguments": "{\"text\":\"I study English every day.\"}"
}
其中:
name
→ 想呼叫哪個 Function
arguments
→ JSON 編碼後的字串
call_id
→ 之後用來對應這次 Tool Result
所以不能把 arguments 當成 Python Dictionary 直接使用,而是要先執行:
arguments = json.loads(
tool_call.arguments
)
不要使用:
globals()[tool_call.name](**arguments)
因為 Tool Name 是模型產生的資料。Application 應該維護可執行 Function 的 Allowlist。
新增:
TOOL_HANDLERS = {
"count_english_words": (
count_english_words
)
}
再加入執行 Helper:
def execute_tool_call(
tool_call
) -> str:
try:
arguments = json.loads(
tool_call.arguments
)
if not isinstance(arguments, dict):
raise ValueError(
"arguments must be an object"
)
handler = TOOL_HANDLERS.get(
tool_call.name
)
if handler is None:
raise ValueError(
"unknown tool"
)
result = handler(**arguments)
payload = {
"ok": True,
"result": result
}
except (
json.JSONDecodeError,
KeyError,
TypeError,
ValueError
) as error:
payload = {
"ok": False,
"error": str(error)
}
return json.dumps(
payload,
ensure_ascii=False
)
這個 Helper 做了三件事:
解析 Arguments
只允許已登記的 Function
把成功或失敗結果統一轉成 JSON String
Tool 執行失敗時,也要把明確結果回傳模型,不能讓模型自行假設 Function 已經成功。
執行 Function 後,Application 會建立:
{
"type": "function_call_output",
"call_id": tool_call.call_id,
"output": tool_output
}
call_id 必須和模型原本的 Function Call 相同,否則 API 不知道這個結果屬於哪一次呼叫。
另外,不要只保存 Function Call Item。官方的 Responses API 流程會先把第一次的完整:
response.output
加入下一次 Input,再附上 function_call_output。這樣 Function Call,以及模型可能同時產生的其他 Output Items,都能正確延續到下一個 Request。
現在把第一次 Request、Tool Execution 與 Final Response 包成同一個 Function:
def run_model_with_tools(
input_messages: list
) -> tuple[str, int]:
response = client.responses.create(
model=MODEL,
instructions=SYSTEM_PROMPT,
input=input_messages,
tools=TOOLS,
tool_choice="auto",
parallel_tool_calls=False
)
total_tokens = (
response.usage.total_tokens
)
function_calls = [
item
for item in response.output
if item.type == "function_call"
]
if not function_calls:
return (
response.output_text,
total_tokens
)
tool_call = function_calls[0]
print(
"Tool called:",
tool_call.name
)
tool_output = execute_tool_call(
tool_call
)
next_input = list(input_messages)
next_input += response.output
next_input.append(
{
"type": "function_call_output",
"call_id": tool_call.call_id,
"output": tool_output
}
)
final_response = (
client.responses.create(
model=MODEL,
instructions=SYSTEM_PROMPT,
input=next_input,
tools=TOOLS,
tool_choice="none",
parallel_tool_calls=False
)
)
total_tokens += (
final_response.usage.total_tokens
)
return (
final_response.output_text,
total_tokens
)
第二次 Request 使用:
tool_choice="none"
是因為 Day 25 只示範一次 Tool Call。Application 取得 Tool Result 後,就要求模型整理成 Final Response,不允許它再要求下一個 Tool。
如果任務需要:
Tool A
→ 觀察結果
→ Tool B
→ 再觀察結果
就不能把流程固定成兩次 Request,而需要 Day 27 的 Agent Loop。
response.output 全部帶回去?這裡沒有改用 previous_response_id,因為 Memora 從 Day 6 開始就是由 Application 自己管理 Context。
因此第二次 Request 使用:
next_input = list(input_messages)
next_input += response.output
next_input.append(
function_call_output
)
而不是只建立:
[
function_call_output
]
這能讓第二次 Request 看見:
原本的 Conversation Context
第一次 Response 的 Output Items
Application 回傳的 Tool Result
尤其使用 Reasoning Model 時,第一次 Response 中和 Tool Call 一起回傳的相關 Items 也必須被保留。直接附加完整 response.output,比手動挑選其中一部分安全。
Day 24 回答使用者前,仍然會先完成:
Memory Retrieval
User Profile Context
Short-term Context Management
這些都不需要改。
只把原本單次呼叫模型的部分:
response = client.responses.create(
model=MODEL,
instructions=SYSTEM_PROMPT,
input=memory_stats[
"context_messages"
]
)
assistant_reply = response.output_text
替換成:
(
assistant_reply,
response_tokens
) = run_model_with_tools(
input_messages=(
memory_stats["context_messages"]
)
)
接著保留 Day 23 的 touch():
retrieved_memory_ids = [
memory_item.memory_id
for memory_item in retrieved_memories
]
try:
long_term_memory.touch(
retrieved_memory_ids
)
except Exception as error:
print(
"Could not update memory access time:",
error
)
最後只修改 finish_turn() 的 Token 來源:
memory.finish_turn(
assistant_reply=assistant_reply,
context_messages=(
memory_stats["context_messages"]
),
response_tokens=response_tokens
)
原本使用:
response.usage.total_tokens
現在改成兩次 Responses 的 Token 總和,避免漏算 Tool Calling 的第二個 Request。
Day 24 回答後的 Memory Extraction 與 store_accepted_memories() 完全保留,並且只使用:
Current User Message
Final Assistant Reply
不需要把中間的 Function Call JSON 當成一段新的 Conversation Message 存進 Short-term Memory。
Day 10 的 prepare_context() 目前只計算 Message Context。加入 Tool Calling 後,Tool Schema、Function Call 與 Tool Output 也會占用 Context。
今天先替這個小型 Tool 預留固定空間:
TOOL_TURN_TOKEN_RESERVE = 1200
把 prepare_context() 的 Signature 擴充為:
def prepare_context(
self,
background_messages=None,
reserved_input_tokens=0
):
原本判斷:
if input_tokens <= self.max_input_tokens:
改成:
estimated_input_tokens = (
input_tokens
+ reserved_input_tokens
)
if (
estimated_input_tokens
<= self.max_input_tokens
):
return {
"context_messages": (
context_messages
),
"input_tokens": input_tokens,
"reserved_input_tokens": (
reserved_input_tokens
),
"estimated_input_tokens": (
estimated_input_tokens
),
"summary_token_usage": (
summary_token_usage
),
"newly_summarized_count": (
newly_summarized_count
)
}
主迴圈則改成:
memory_stats = memory.prepare_context(
background_messages=(
background_messages
),
reserved_input_tokens=(
TOOL_TURN_TOKEN_RESERVE
)
)
Day 19 已經把 User Profile 與 Retrieved Memory 一起整理成 background_messages,所以這個參數名稱繼續沿用。
1200 只是目前單一小型 Tool 的安全預留值,不是精確 Token 數。未來 Tool 變多或 Output 變大時,還要根據實際 Usage 與 Tool Schema 調整;同時也應限制 Tool Output 大小,不能只提高 Reserve。
先測試不需要 Tool 的問題:
You:
請給我一個使用 present perfect 的例句。
模型可以直接回答,不需要執行 Function:
Memora:
I have finished my homework.
接著輸入:
You:
請精確計算這句英文有幾個單字:
I have studied English for three years.
Terminal 可能顯示:
Tool called: count_english_words
Memora:
這句英文共有 7 個單字。
這次的 7 不是模型直接猜測,而是:
模型選擇 count_english_words
→ Python Function 計算
→ Tool Result 回到模型
→ 模型整理成回答
最後也要測試 Contraction:
I don't know the answer.
按照今天的 Regex,don't 會算成一個 Word,因此結果是五個。
現在的 run_model_with_tools() 最多只會走:
Model
→ 一次 Function Call
→ Tool Result
→ Final Response
它還不能:
根據第一次結果決定再呼叫另一個 Tool
重複 Action 與 Observation
判斷任務何時完成
避免無限執行
所以 Memora 已經能使用一個 Tool,但還沒有完成 Agent Loop。
另外,今天的 Tool 是唯讀計算,不會修改 Long-term Memory。Day 24 的 Memory Write Path 仍然由 Application 固定執行;LLM 還不能主動呼叫:
search_memory
remember_memory
update_memory
forget_memory
這些會留到後面再和 Agent 架構整合。
今天沒有重寫 Day 24 的 Memory System,而是把原本單次的模型呼叫擴充成:
Context
→ Responses API + Tool Definitions
→ function_call
→ Application 執行 Function
→ function_call_output
→ Responses API
→ Final Assistant Reply
新增的是:
count_english_words()
TOOLS
TOOL_HANDLERS
execute_tool_call()
run_model_with_tools()
TOOL_TURN_TOKEN_RESERVE
而原本的:
User Profile
Short-term Memory
Long-term Memory Retrieval
Recency 與 touch()
Memory Extraction
Memory Reconciliation
都繼續保留。
今天最重要的觀念是:
Tool Calling 不是 LLM 自己執行 Function,而是模型提出結構化的 Function Call,由 Application 驗證與執行,再把結果交還給模型。
現在 Memora 已經不只會回答,也可以請 Application 幫它完成一個明確動作。但「會使用 Tool」和「已經是 Agent」仍然不是同一件事。
下一篇先不急著增加更多程式。我們會從今天的 Tool Calling Flow 出發,分清楚:
Chatbot
Tool-using Chatbot
Workflow
Agent
並檢查目前的 Memora 已經具備哪些能力、還缺少哪些控制結構。等界線釐清後,Day 27 才會把今天固定的一次 Tool Call 改造成真正的 Agent Loop。