iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0

到昨天為止,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 如何發生。


一、Tool Calling 並不是模型直接執行 Python

假設使用者說:

請精確計算這句話有幾個英文單字:
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 名稱,就自動取得執行程式或存取系統的權限。


二、今天只加入一個簡單 Tool

今天新增:

count_english_words

選擇這個功能有三個原因:

和 Memora 的英文學習角色有關
結果可以由 Python 明確計算
不會改動 Memory 或外部資料

這裡計算的是一般英文單字,不是 Day 2 提過的 Model Token。

例如:

I study English every day.

會得到五個 Words;但送進模型後實際切成多少 Tokens,是另一件事。


三、先建立真正會執行的 Python Function

在既有 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,而不是只相信模型一定會傳入合理資料。


四、再把 Function 描述成 Tool Definition

模型看不到前面的 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 要把可接受的欄位列入 propertiesrequired,並設定 additionalProperties=False。這能限制輸出格式,但 Application 仍然要驗證資料並處理執行錯誤。


五、Tool Definition 和 Structured Output 不一樣

Day 13 已經使用過 Structured Output。兩者都會用到 Schema,但目的不同:

機制 模型回傳什麼 Application 接下來做什麼
Structured Output 符合格式的資料 驗證並使用資料
Tool Calling Function Name 與 Arguments 執行對應 Function,再回傳結果

Day 24 的:

MemoryReconciliationDecision

只是讓模型輸出 createskipupdate 等 Decision。

今天的:

count_english_words(text=...)

則會真正進入 Application 的 Function Dispatcher。


六、稍微擴充原本的 System Prompt

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 共用的行為原則。


七、第一次 Request 可能不會直接得到答案

原本主迴圈只呼叫一次:

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

八、Function Call 是一筆結構化 Output Item

模型可能回傳概念上類似:

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

九、建立明確的 Tool Dispatcher

不要使用:

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 已經成功。


十、把 Tool Result 送回模型

執行 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。


十一、完成一次 Tool Calling Helper

現在把第一次 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 的主迴圈

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。


十四、替 Tool Trace 預留 Context 空間

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 Call

先測試不需要 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,因此結果是五個。


十六、目前的 Tool Calling 還不是 Agent Loop

現在的 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 25 小結

今天沒有重寫 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」仍然不是同一件事。

Day 26|Agent 到底和 Chatbot 差在哪?

下一篇先不急著增加更多程式。我們會從今天的 Tool Calling Flow 出發,分清楚:

Chatbot
Tool-using Chatbot
Workflow
Agent

並檢查目前的 Memora 已經具備哪些能力、還缺少哪些控制結構。等界線釐清後,Day 27 才會把今天固定的一次 Tool Call 改造成真正的 Agent Loop。


參考資料


上一篇
Day 24|AI 記錯了怎麼辦?Deduplication、Update 與 Contradiction
下一篇
Day 26|Agent 到底和 Chatbot 差在哪?
系列文
從 Stateless LLM 到 Agentic Memory:30 天打造會記憶的 AI Agent30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言