昨天我們替目前的 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。
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,並設定明確的停止上限。
先在原本的常數區新增:
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,而不是強迫它一定要把額度用完。
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 寫「不要無限執行」,卻讓程式本身沒有上限。
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。
以下直接取代 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。
每次取得 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,再修改這一層;今天先讓控制流程保持容易觀察。
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 的邊界做清楚。
目前 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 不會被執行。
Day 25 的主迴圈原本接收:
(
assistant_reply,
response_tokens
) = run_model_with_tools(
input_messages=(
memory_stats["context_messages"]
)
)
因為今天多了 tool_steps 與 stop_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。
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 除了安全之外,還具有成本控制的作用。
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 也必須知道這一輪可能需要更多空間。
現在重新使用 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 新增的能力。
要驗證停止條件,可以暫時把:
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
回到 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_answer、max_steps、api_error、empty_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。
今天沒有增加新的 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 定義明確的停止邊界。
下一篇會保留今天的 Agent Loop,開始把前面建立的 Memory System 接進 Agent 的 Action Space。
目前 Memora 的記憶流程仍然由 Application 固定觸發。Day 28 會開始拆開:
哪些 Memory 動作適合變成 Tool?
Agent 何時需要主動尋找記憶?
哪些寫入仍然應該受 Memory Policy 限制?
如何避免 Agent 任意記住或刪除資料?
今天我們先完成「能反覆行動」的 Runtime。下一步才是讓這個 Runtime 開始和 Memory 真正互相作用。