iT邦幫忙

2026 iThome 鐵人賽

DAY 2
1
AI Engineering

《30 天從零拆解 AI Agent:從 Tool Calling 到多 Agent 協作》系列 第 2

【Day 2】Agent 為什麼能一直持續運行? 實作最小的 Agent Loop

  • 分享至 

  • xImage
  •  

昨天我們先建立了一個基本觀念:

模型負責判斷,Harness 負責讓判斷可以被執行、觀察與限制。

但還有一個問題沒有回答:

為什麼一般聊天模型回答一次就停止,Agent 卻可以讀檔案、執行工具、看到結果,再繼續下一步?

答案通常沒有想像中複雜。

Agent 最核心的控制流程,往往只是一個 while 迴圈。

真正重要的不是迴圈本身,而是 Harness 在每一輪完成三件事:

  1. 保存目前的 messages
  2. 根據 stop_reason 判斷模型想結束還是使用工具
  3. 把工具結果放回下一輪 Context

今天我們先拿掉 Memory、Planning、Subagent、Retry 和 Permission,只留下最小 Agent Loop。


一次模型呼叫為什麼不夠?

一般模型呼叫是單向的:

使用者輸入
    ↓
模型
    ↓
文字輸出

例如:

response = model.generate(
    "請計算 23 乘以 19"
)

print(response.text)

模型可以直接回答,也可能表示它想使用計算工具。

但模型回傳 Tool Call 之後,事情不會自動繼續。

Harness 還要:

  1. 找到模型指定的工具
  2. 執行工具
  3. 保存工具結果
  4. 再次呼叫模型
  5. 重複流程,直到模型真的結束

因此,Agent Loop 比較像:

messages[]
    ↓
呼叫模型
    ↓
檢查 stop_reason
    ├── end_turn → 回傳最終答案
    └── tool_use → 執行工具
                         ↓
                  把結果加入 messages[]
                         ↓
                      下一輪

Agent Loop 的三個核心狀態

1. messages[]

messages 是目前任務的工作紀錄。

它可能包含:

messages = [
    {
        "role": "user",
        "content": "請計算 23 乘以 19"
    }
]

模型要求使用工具後,Harness 會加入模型的 Tool Call:

messages.append({
    "role": "assistant",
    "tool_calls": [
        {
            "id": "call_1",
            "name": "multiply",
            "arguments": {
                "a": 23,
                "b": 19
            }
        }
    ]
})

工具執行完成後,再加入結果:

messages.append({
    "role": "tool",
    "tool_call_id": "call_1",
    "content": "437"
})

下一輪模型看到的不只是原始問題,而是完整過程:

使用者要求計算
模型決定呼叫 multiply
工具回傳 437

模型才能根據新觀察產生最終答案。

所以 messages[] 不只是聊天紀錄。

在最小 Agent Loop 裡,它同時扮演:

  • 短期工作記憶
  • Tool Call 紀錄
  • Tool Result 紀錄
  • 下一輪模型的 Context

2. stop_reason

Harness 必須知道模型為什麼停止輸出。

最簡化後,可以先分成兩種:

tool_use
end_turn

tool_use 表示:

我現在還沒有完成任務,需要 Harness 幫我執行一個行動。

end_turn 表示:

我不需要更多工具,可以把目前輸出當作最終答案。

因此,Harness 不應該只檢查模型有沒有回傳文字。

模型可能同時產生一段說明和一個 Tool Call。

真正控制流程的是結構化狀態:

if response.stop_reason == "end_turn":
    return response.text

if response.stop_reason == "tool_use":
    ...

3. Tool Result

Tool Result 是 Agent 對環境的觀察。

例如模型呼叫:

{
  "name": "multiply",
  "arguments": {
    "a": 23,
    "b": 19
  }
}

Harness 執行:

result = multiply(a=23, b=19)

得到:

437

如果這個結果沒有被加入 messages[],模型就不知道工具是否成功,也看不到計算結果。

因此,Agent Loop 的基本循環其實是:

Reason
  ↓
Act
  ↓
Observe
  ↓
Reason again

模型負責 Reason。

工具負責 Act。

Harness 負責把 Observation 送回下一輪。


最小 Agent Loop

先看一段與 Provider 無關的核心程式碼:

def run_agent(model, user_input, tools):
    messages = [
        {
            "role": "user",
            "content": user_input,
        }
    ]

    while True:
        response = model.generate(
            messages=messages,
            tools=list(tools.values()),
        )

        messages.append(response.to_message())

        if response.stop_reason == "end_turn":
            return response.text

        if response.stop_reason != "tool_use":
            raise RuntimeError(
                f"Unknown stop reason: {response.stop_reason}"
            )

        for tool_call in response.tool_calls:
            tool = tools.get(tool_call.name)

            if tool is None:
                result = {
                    "ok": False,
                    "error": f"Unknown tool: {tool_call.name}",
                }
            else:
                try:
                    value = tool(**tool_call.arguments)
                    result = {
                        "ok": True,
                        "value": value,
                    }
                except Exception as exc:
                    result = {
                        "ok": False,
                        "error": str(exc),
                    }

            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "content": result,
            })

這段程式只做四件事:

  1. 建立 messages
  2. 呼叫模型
  3. 根據 stop_reason 分流
  4. 執行工具並把結果放回 messages

Agent 能夠持續工作的原因,就藏在這個 while True 裡。


一個可以直接執行的版本

為了把控制流程看清楚,下面不連接任何 LLM API,而是使用一個簡單的 DemoModel 模擬模型兩輪行為。

第一輪要求呼叫 multiply

第二輪讀到工具結果後,回傳最終答案。

from dataclasses import dataclass
from typing import Any, Callable, Literal


@dataclass
class ToolCall:
    id: str
    name: str
    arguments: dict[str, Any]


@dataclass
class ModelResponse:
    stop_reason: Literal["tool_use", "end_turn"]
    text: str | None = None
    tool_calls: list[ToolCall] | None = None

    def to_message(self) -> dict[str, Any]:
        message: dict[str, Any] = {
            "role": "assistant",
            "content": self.text,
        }

        if self.tool_calls:
            message["tool_calls"] = [
                {
                    "id": call.id,
                    "name": call.name,
                    "arguments": call.arguments,
                }
                for call in self.tool_calls
            ]

        return message


class DemoModel:
    def generate(
        self,
        messages: list[dict[str, Any]],
        tools: list[str],
    ) -> ModelResponse:
        tool_messages = [
            message
            for message in messages
            if message["role"] == "tool"
        ]

        if not tool_messages:
            return ModelResponse(
                stop_reason="tool_use",
                tool_calls=[
                    ToolCall(
                        id="call_1",
                        name="multiply",
                        arguments={"a": 23, "b": 19},
                    )
                ],
            )

        result = tool_messages[-1]["content"]

        return ModelResponse(
            stop_reason="end_turn",
            text=f"23 × 19 = {result['value']}",
        )


def multiply(a: int, b: int) -> int:
    return a * b


def run_agent(
    model: DemoModel,
    user_input: str,
    tools: dict[str, Callable[..., Any]],
    max_turns: int = 8,
) -> str:
    messages: list[dict[str, Any]] = [
        {
            "role": "user",
            "content": user_input,
        }
    ]

    for _ in range(max_turns):
        response = model.generate(
            messages=messages,
            tools=list(tools.keys()),
        )

        messages.append(response.to_message())

        if response.stop_reason == "end_turn":
            if response.text is None:
                raise RuntimeError(
                    "Model ended without a final answer."
                )

            return response.text

        if response.stop_reason != "tool_use":
            raise RuntimeError(
                f"Unknown stop reason: {response.stop_reason}"
            )

        for tool_call in response.tool_calls or []:
            tool = tools.get(tool_call.name)

            if tool is None:
                result = {
                    "ok": False,
                    "error": f"Unknown tool: {tool_call.name}",
                }
            else:
                try:
                    result = {
                        "ok": True,
                        "value": tool(**tool_call.arguments),
                    }
                except Exception as exc:
                    result = {
                        "ok": False,
                        "error": str(exc),
                    }

            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "content": result,
            })

    raise RuntimeError(
        f"Agent exceeded the maximum of {max_turns} turns."
    )


if __name__ == "__main__":
    answer = run_agent(
        model=DemoModel(),
        user_input="請計算 23 乘以 19",
        tools={
            "multiply": multiply,
        },
    )

    print(answer)

執行結果:

23 × 19 = 437

雖然這個 Demo 沒有真的連接 LLM,但它保留了 Agent Loop 最重要的控制流程:

第一輪
模型要求使用 multiply
        ↓
Harness 執行 multiply
        ↓
Harness 保存 Tool Result

第二輪
模型讀取 Tool Result
        ↓
模型回傳最終答案
        ↓
Harness 結束 Loop

DemoModel 換成真實模型 Provider 後,Loop 的核心結構不需要大幅改變。


為什麼不能真的只寫 while True

前面的核心版本使用:

while True:

但 Production Agent 不能無限制執行。

模型可能:

  • 重複呼叫同一個工具
  • 在兩個工具之間循環
  • 不斷收到錯誤後重新嘗試
  • 一直認為自己還沒有完成
  • 因為格式錯誤無法進入 end_turn

因此,即使是最小範例,也應該加入 Turn Budget:

for turn in range(max_turns):
    ...

超過限制後直接停止:

raise RuntimeError(
    f"Agent exceeded the maximum of {max_turns} turns."
)

這是第一個非常簡單的 Harness Control。

它不會讓模型更聰明,但可以避免 Agent 永遠執行。


end_turn 真的代表任務完成嗎?

現在的 Loop 只要收到:

stop_reason = end_turn

就直接回傳答案。

但這裡必須區分兩件事:

模型停止
任務完成

兩者不一定相同。

模型可能因為以下原因停止:

  • 它認為任務已完成
  • 它沒有發現還需要其他工具
  • Context 不足
  • 工具結果被誤解
  • 它過早產生結論

因此,今天的版本只能表示:

模型決定停止這一輪 Agent Loop。

它還不能證明:

任務已經被正確完成。

例如 Coding Agent 說:

已經修正問題。

真正完成可能還需要:

測試通過
Lint 通過
預期檔案存在
輸出 Schema 合法
沒有修改禁止的檔案

這些外部驗證會在後面的文章逐步加入。


Tool Error 應該終止 Loop 嗎?

在範例中,工具失敗時不會立刻讓整個程式 Crash。

Harness 會把錯誤包成 Tool Result:

result = {
    "ok": False,
    "error": str(exc),
}

再交回模型。

原因是:

工具失敗也是一種 Observation。

模型可能根據錯誤決定:

  • 修正參數
  • 改用另一個工具
  • 向使用者要求資訊
  • 放棄目前方法
  • 說明任務無法完成

如果 Harness 在每次工具失敗時都立刻終止,模型就沒有恢復機會。

但反過來說,也不能允許無限重試。

之後還需要加入:

  • Retry Budget
  • Timeout
  • Error Classification
  • Fallback
  • Human Handoff

今天先保留最小原則:

把工具錯誤轉換成模型可以觀察的結構化結果。


最小 Loop 已經有哪些 Failure Modes?

即使只有幾十行,問題已經開始出現。

1. 無限循環

模型不斷呼叫工具,永遠不回傳 end_turn

目前使用 max_turns 限制。

2. 未知工具

模型可能產生不存在的工具名稱。

目前把錯誤送回模型,而不是直接 Crash。

3. 參數錯誤

工具參數缺少欄位或型別錯誤。

目前由 try/except 接住,但還沒有正式 Schema Validation。

4. 工具結果太大

如果工具回傳幾萬行 Log,全部加入 messages[] 可能快速耗盡 Context。

之後需要截斷、摘要或外部儲存。

5. 過早完成

模型回傳 end_turn,但實際結果還沒有通過驗證。

之後需要 Completion Gate。

6. 多個 Tool Call 的執行順序

模型可能一次要求多個工具。

目前依序執行,但有些工具可以平行,有些則有相依關係。

之後需要更明確的執行策略。

這些問題說明一件事:

Agent Loop 本身很小,Production Engineering 幾乎都發生在 Loop 周圍。


不要把所有邏輯都交給模型

假設 Agent 已經執行八輪,還沒有完成。

可以有兩種做法。

第一種是在 Prompt 裡寫:

請不要執行太久,最多使用八輪。

第二種是在 Harness 裡寫:

for _ in range(8):
    ...

第二種更可靠。

因為「最多八輪」是一個明確、可計算、可強制執行的規則,不需要模型判斷。

這也是後面 Graph Engineering 會持續討論的核心:

已經確定的控制流程,通常應該寫進程式碼,而不是每一輪都交給模型決定。

模型適合處理:

  • 下一步應該採取什麼行動
  • 工具結果代表什麼
  • 是否需要改變策略

程式碼適合處理:

  • 最多可以執行幾輪
  • 哪些工具存在
  • 結構是否合法
  • 哪些操作必須禁止
  • 超過預算後一定停止

今天新增了什麼能力?

昨天的系統只有:

輸入 → 模型 → 輸出

今天加入:

messages[]
stop_reason
tool execution
tool result
turn budget

系統因此可以:

  1. 讓模型選擇工具
  2. 執行工具
  3. 把結果送回模型
  4. 讓模型根據結果繼續工作
  5. 在模型結束或超過預算時停止

這就是最小 Agent Loop。


今天的結論

Agent 能夠持續執行,不是因為模型內部藏著一個神祕的 Agent 系統。

最小版本可能只是一個迴圈:

呼叫模型
    ↓
檢查 stop_reason
    ↓
執行 Tool Call
    ↓
保存 Tool Result
    ↓
再次呼叫模型

其中三個最重要的概念是:

  • messages[] 保存工作狀態
  • stop_reason 控制流程分支
  • Tool Result 提供下一輪 Observation

但這個版本還非常不安全。

模型現在可以呼叫任何註冊工具,而 Harness 還沒有處理:

  • Tool Schema
  • 參數驗證
  • Tool Registry
  • Permission
  • Approval
  • Sandbox

下一篇我們會繼續拆解:

Tool Calling 和 Tool Runtime 到底有什麼差別?

模型產生一段 Tool Call,不代表工具已經被正確、安全地執行。

完整程式碼與系列內容:https://github.com/hardness1020/awesome-agent-architecture


上一篇
【Day 1】AI Agent 的架構到底在哪裡?模型之外的 Harness 才是關鍵
下一篇
【Day 3】Agent 選了工具,誰負責真的執行?
系列文
《30 天從零拆解 AI Agent:從 Tool Calling 到多 Agent 協作》3
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

1
qwert002
iT邦新手 5 級 ‧ 2026-08-04 10:57:36

整理得很清楚,也很有實務價值,感謝分享!

我要留言

立即登入留言