iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0

昨天我們替目前的 Memora 做了一次定位。它已經有 Conversation State、Long-term Memory,也能要求 Application 執行 count_english_words,但 Day 25 的工具流程最多只允許一次 Action:

Model
→ Tool Call
→ Tool Result
→ Final Answer

問題不在於 Tool 太少,而是模型看完 Observation 之後,不能再決定下一步。今天先不新增 Tool,也不更換 Agent Framework。我們會直接修改 Day 25 的 run_model_with_tools(),把固定的兩次 Request 改成一個有邊界的 Agent Loop:

Model 決定下一步
→ 執行 Action
→ 加入 Observation
→ 再次交給 Model 判斷

直到模型產生 Final Answer,或 Application 觸發停止條件。


一、今天到底要修改哪一段?

Day 25 的 Memory Retrieval、User Profile、Short-term Memory 與 Memory Write Path 都不需要重寫。

今天保留:

count_english_words()
TOOLS
TOOL_HANDLERS
execute_tool_call()
SYSTEM_PROMPT 的既有內容
Day 24 的 Memory System

主要修改:

run_model_with_tools()

另外補上三個控制資訊:

目前執行了幾個 Tool Steps
為什麼停止
整次 Agent Run 使用多少 Tokens

所以這不是另一套程式,而是把原本的單次 Tool Calling 擴充成可重複執行的 Runtime Loop。


二、先拿掉固定的 Final Response

Day 25 在 Tool 執行完之後,第二次 Request 使用:

tool_choice="none"

這行的效果是禁止模型再次使用 Tool,因此流程一定會在一次 Action 後進入 Final Answer。

今天每一次 Model Call 都改成:

tool_choice="auto"

於是模型每次看到目前的 Working Input 時,都可以選擇:

需要更多資料
→ 產生下一個 Function Call

資訊已經足夠
→ 產生 Final Answer

OpenAI 的 Agent Loop 說明也是相同概念:檢查模型輸出;如果有 Tool Call,就執行後繼續;如果沒有更多 Tool Work,才回傳最終結果。

不過,只把 tool_choice 改成 auto 還不夠。我們還需要保存每一步產生的新 State,並設定明確的停止上限。


三、加入 Agent Step Limit

先在原本的常數區新增:

MAX_AGENT_STEPS = 4

這裡的一個 Step 指的是:

模型要求並由 Application 實際執行的一次 Tool Call。

它不是 API Request 次數。

例如直接回答問題時:

Tool Steps = 0
Model Requests = 1

如果依序計算兩句英文:

Tool Steps = 2
Model Requests = 3

前兩次 Request 分別產生 Action,第三次 Request 根據兩個 Observation 產生 Final Answer。

MAX_AGENT_STEPS 限制的是 Agent 可以執行多少個 Action,而不是強迫它一定要把額度用完。


四、稍微補強 Tool Guidelines

Day 25 的 Tool Guidelines 繼續保留,再加入三條:

- After receiving a tool result, decide whether
  another tool call is necessary.
- Stop calling tools when the available results
  are sufficient to answer the user.
- Do not repeat a successful tool call with the
  same arguments unless it is necessary.

這些 Instructions 是在告訴模型如何使用 Loop,但它們不是安全邊界。

真正能保證最多執行四次的,仍然是 Python 裡的:

MAX_AGENT_STEPS = 4

也就是:

Prompt
→ 引導模型如何行動

Application Control
→ 限制模型實際能做多少事

不能只在 Prompt 寫「不要無限執行」,卻讓程式本身沒有上限。


五、建立這一次 Agent Run 的 Working Input

Function 一開始先複製既有 Context:

working_input = list(input_messages)

這份 input_messages 仍然來自前面已經完成的 Context Management,裡面可能包含:

Conversation Summary
Recent Messages
User Profile
Retrieved Memories
Current User Message

接著,每一次模型回傳結果後,都把完整的:

response.output

加入 working_input

如果其中有 Function Call,再接著加入對應的:

{
    "type": "function_call_output",
    "call_id": tool_call.call_id,
    "output": tool_output
}

於是下一次 Request 看到的內容會逐步增加:

原本的 Conversation Context
第一次 Function Call
第一次 Function Output
第二次 Function Call
第二次 Function Output
...

這些是目前這次 Agent Run 的 Working State,不需要另外建立一個全新的 Memory System。


六、把固定流程改成 Agent Loop

以下直接取代 Day 25 的 run_model_with_tools()

def run_model_with_tools(
    input_messages: list
) -> dict:
    working_input = list(input_messages)
    total_tokens = 0
    tool_steps = 0

    while True:
        try:
            response = client.responses.create(
                model=MODEL,
                instructions=SYSTEM_PROMPT,
                input=working_input,
                tools=TOOLS,
                tool_choice="auto",
                parallel_tool_calls=False
            )

        except Exception as error:
            print(
                "Agent API error:",
                error
            )

            return {
                "reply": (
                    "目前無法完成這個任務,"
                    "請稍後再試。"
                ),
                "response_tokens": total_tokens,
                "tool_steps": tool_steps,
                "stop_reason": "api_error"
            }

        total_tokens += (
            response.usage.total_tokens
        )

        function_calls = [
            item
            for item in response.output
            if item.type == "function_call"
        ]

        if not function_calls:
            final_answer = (
                response.output_text.strip()
            )

            if not final_answer:
                return {
                    "reply": (
                        "這次沒有取得可顯示的回答。"
                    ),
                    "response_tokens": total_tokens,
                    "tool_steps": tool_steps,
                    "stop_reason": (
                        "empty_response"
                    )
                }

            return {
                "reply": final_answer,
                "response_tokens": total_tokens,
                "tool_steps": tool_steps,
                "stop_reason": "final_answer"
            }

        if tool_steps >= MAX_AGENT_STEPS:
            return {
                "reply": (
                    "我已達到這次任務的步數上限,"
                    "因此先停止執行。"
                ),
                "response_tokens": total_tokens,
                "tool_steps": tool_steps,
                "stop_reason": "max_steps"
            }

        working_input += response.output

        tool_call = function_calls[0]
        tool_steps += 1

        print(
            f"[Agent step {tool_steps}] "
            f"Action: {tool_call.name}"
        )

        tool_output = execute_tool_call(
            tool_call
        )

        print(
            f"[Agent step {tool_steps}] "
            f"Observation: {tool_output}"
        )

        working_input.append(
            {
                "type": (
                    "function_call_output"
                ),
                "call_id": tool_call.call_id,
                "output": tool_output
            }
        )

Day 25 是把第一次 Response 與 Tool Output 組成 next_input,再進行固定的第二次 Request。

今天沒有改變資料格式,只是把同一件事放進 Loop,讓新的 Observation 可以繼續影響下一次 Decision。


七、這個 Loop 如何判斷 Action?

每次取得 Response 後,仍然檢查:

function_calls = [
    item
    for item in response.output
    if item.type == "function_call"
]

今天仍然保留:

parallel_tool_calls=False

因此一次 Model Call 最多只會產生一個 Function Call,我們可以安全地取:

tool_call = function_calls[0]

這不代表 Agent 整次只能使用一個 Tool,而是:

每一個 Decision 最多選擇一個 Action
整次 Run 可以重複多個 Steps

OpenAI 的 Function Calling 文件也說明,將 parallel_tool_calls 設為 false,可限制一次輸出為零個或一個 Tool Call。

等未來真的需要平行執行多個 Actions,再修改這一層;今天先讓控制流程保持容易觀察。


八、Observation 不只是成功結果

Day 25 的 execute_tool_call() 已經會統一回傳 JSON String。

成功時可能是:

{
  "ok": true,
  "result": {
    "word_count": 5,
    "counting_rule": "..."
  }
}

失敗時可能是:

{
  "ok": false,
  "error": "text must not be empty"
}

兩者都會成為 Observation。

這裡沒有規定「只要 Tool Error 就立刻結束」,因為有些錯誤可以修正。例如模型傳入空字串後,看見錯誤結果,可以重新提供正確 Arguments。

如果它持續產生無效 Action,最後仍會被:

MAX_AGENT_STEPS

停止。

至於 API Request 本身失敗,程式無法取得新的 Decision,因此直接回傳:

stop_reason = api_error

實際產品中可以再針對 Rate Limit、Timeout 或 Server Error 設計不同的 Retry Policy;今天先把 Agent Loop 的邊界做清楚。


九、Stop 不一定代表任務成功

目前 run_model_with_tools() 有三種重要結果:

stop_reason 代表意義 是否正常完成
final_answer 模型沒有要求更多 Tool,並產生回答
max_steps 模型仍想執行 Action,但已達上限
api_error 無法取得下一個 Model Response
empty_response 沒有 Tool Call,也沒有可顯示文字

這個差別很重要。

如果達到 Step Limit,程式不能假裝任務已完成,也不能把最後一次尚未執行的 Function Call 當作結果。

所以判斷順序是:

if not function_calls:
    # 正常產生 Final Answer
    pass

if tool_steps >= MAX_AGENT_STEPS:
    # 不再執行新的 Action
    pass

假設上限是四步,Agent 可以執行四次 Tool;之後還會有一次 Model Call,讓模型有機會根據第四個 Observation 產生 Final Answer。

只有當模型在那次 Request 又要求第五個 Action,Application 才以 max_steps 停止,而且第五個 Tool 不會被執行。


十、把 Agent Result 接回原本的主迴圈

Day 25 的主迴圈原本接收:

(
    assistant_reply,
    response_tokens
) = run_model_with_tools(
    input_messages=(
        memory_stats["context_messages"]
    )
)

因為今天多了 tool_stepsstop_reason,改成:

agent_result = run_model_with_tools(
    input_messages=(
        memory_stats["context_messages"]
    )
)

assistant_reply = agent_result["reply"]
response_tokens = agent_result[
    "response_tokens"
]

print(
    "Agent stopped:",
    agent_result["stop_reason"],
    f"({agent_result['tool_steps']} tool steps)"
)

後面的程式繼續使用同一個:

assistant_reply

因此 Day 23 的 Memory Access 更新仍然保留:

long_term_memory.touch(
    retrieved_memory_ids
)

finish_turn() 也只是沿用新的 Token 總數:

memory.finish_turn(
    assistant_reply=assistant_reply,
    context_messages=(
        memory_stats["context_messages"]
    ),
    response_tokens=response_tokens
)

回答後的 Memory Extraction、Reconciliation 與 Store 流程同樣不動。

Agent Run 中間的 Function Call 與 Function Output,只存在 working_input;寫入 Conversation History 的仍然是:

Current User Message
Final Assistant Reply

這樣不會把 Tool JSON 當成一般對話文字存進 Short-term Memory。


十一、Token Usage 必須累積整個 Loop

Day 25 只有兩次 Model Request,所以我們手動相加兩次:

total_tokens += (
    final_response.usage.total_tokens
)

今天 Request 次數不固定,因此每一次 Response 一回來就累積:

total_tokens += (
    response.usage.total_tokens
)

最後回傳:

return {
    # 其他欄位保持不變
    "response_tokens": total_tokens
}

這個數字是整次 Agent Run 的 Usage 總和,不是最後一個 Request 的 Context 大小。

兩者不要混在一起:

Context Limit
→ 限制單次 Request 可以放多少內容

Total Usage
→ 累積這次 Agent Run 所有 Requests 的 Token 用量

Agent 多執行一步,不只多出 Tool Output,也會多一次 Model Request,因此成本與延遲都會增加。這也是 Step Limit 除了安全之外,還具有成本控制的作用。


十二、調整 Agent Run 的 Context Reserve

Day 25 只預留一次 Tool Trace:

TOOL_TURN_TOKEN_RESERVE = 1200

現在最多可能累積四個 Tool Steps,因此改成:

TOOL_STEP_TOKEN_RESERVE = 1200

AGENT_TURN_TOKEN_RESERVE = (
    TOOL_STEP_TOKEN_RESERVE
    * MAX_AGENT_STEPS
)

主迴圈原本是:

memory_stats = memory.prepare_context(
    background_messages=(
        background_messages
    ),
    reserved_input_tokens=(
        TOOL_TURN_TOKEN_RESERVE
    )
)

現在只替換常數:

memory_stats = memory.prepare_context(
    background_messages=(
        background_messages
    ),
    reserved_input_tokens=(
        AGENT_TURN_TOKEN_RESERVE
    )
)

這仍然是安全預留,不是 Tool Trace 的精確 Token 計算。因為實際大小會受到 Function Arguments、Tool Output 與模型產生的其他 Output Items 影響。

今天的重點是:Agent Loop 會讓同一輪 Input 繼續成長,所以 Context Management 也必須知道這一輪可能需要更多空間。


十三、測試需要兩個 Actions 的任務

現在重新使用 Day 26 的例子:

You:
請比較下面兩句話的英文單字數量,
告訴我哪一句比較長,以及相差幾個單字。

A: I study English every day.
B: I have studied English for three years.

Terminal 可能顯示:

[Agent step 1] Action: count_english_words
[Agent step 1] Observation: {"ok": true, "result": {"word_count": 5, ...}}

[Agent step 2] Action: count_english_words
[Agent step 2] Observation: {"ok": true, "result": {"word_count": 7, ...}}

Agent stopped: final_answer (2 tool steps)

Memora:
B 共有 7 個單字,A 共有 5 個單字,
所以 B 比 A 多 2 個單字。

實際的 Tool Call 順序由模型決定,所以它也可能先計算 B,再計算 A。

重要的不是順序,而是它可以在取得第一個結果後,判斷資料還不夠,再選擇下一個 Action。

這正是 Day 25 無法完成、而今天的 Loop 新增的能力。


十四、測試 Step Limit 是否真的有效

要驗證停止條件,可以暫時把:

MAX_AGENT_STEPS = 1

再執行剛才需要計算兩句英文的任務。

Agent 最多只會執行一次 count_english_words。如果下一次 Model Response 仍然要求另一個 Tool Call,程式會回傳:

Agent stopped: max_steps (1 tool steps)

Memora:
我已達到這次任務的步數上限,因此先停止執行。

這個測試不是在測模型是否聽話,而是在確認 Application 的硬限制確實生效。

測試完成後,再把設定改回:

MAX_AGENT_STEPS = 4

十五、現在的 Memora 為什麼可以稱為 Agent?

回到 Day 26 的最小元件:

Agent 元件 今天的實作
Goal Current User Message
State working_input
Decision 每次 Responses API 的輸出
Action function_call
Executor execute_tool_call()
Observation function_call_output
Loop while True 內重複決策
Stop final_answermax_stepsapi_errorempty_response

因此,Memora 現在已經不只是固定執行一次 Tool 的 Chatbot,而是有了第一個受控的 Agent Loop。

不過,它的 Action Space 目前仍然只有:

count_english_words

而且 Long-term Memory 的流程仍由 Application 固定安排:

回答前自動 Retrieval
回答後自動 Extraction 與 Reconciliation

換句話說:

今天完成的是 Agentic Control Flow,但 Memory 還沒有成為 Agent 可以主動選擇的 Action。

這個差別正好會接到 Day 28。


Day 27 小結

今天沒有增加新的 Tool,也沒有重寫既有 Memory System。

我們只把 Day 25 的固定流程:

一次 Tool Call
→ 強制 Final Answer

改成:

Decision
→ Action
→ Observation
→ 再次 Decision

並加入:

MAX_AGENT_STEPS
working_input
total_tokens
tool_steps
stop_reason

現在模型每次取得 Observation 後,都能重新判斷:

還需要執行 Tool 嗎?
還是已經可以回答?

Application 則繼續掌握:

有哪些 Tools 可以使用
Arguments 如何驗證
Action 最多執行幾次
API 失敗時如何停止
哪些內容會寫回 Conversation Memory

今天最重要的觀念是:

Agent Loop 不是單純加上一個 while True,而是讓每一次 Observation 都能成為下一次 Decision 的輸入,同時由 Application 定義明確的停止邊界。

Day 28|Memory × Tool × Agent:第一次走向 Agentic Memory

下一篇會保留今天的 Agent Loop,開始把前面建立的 Memory System 接進 Agent 的 Action Space。

目前 Memora 的記憶流程仍然由 Application 固定觸發。Day 28 會開始拆開:

哪些 Memory 動作適合變成 Tool?
Agent 何時需要主動尋找記憶?
哪些寫入仍然應該受 Memory Policy 限制?
如何避免 Agent 任意記住或刪除資料?

今天我們先完成「能反覆行動」的 Runtime。下一步才是讓這個 Runtime 開始和 Memory 真正互相作用。


參考資料


上一篇
Day 26|Agent 到底和 Chatbot 差在哪?
下一篇
Day 28|Memory × Tool × Agent:第一次走向 Agentic Memory
系列文
從 Stateless LLM 到 Agentic Memory:30 天打造會記憶的 AI Agent30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言