一張只會講話的嘴

原始的模型呼叫,說穿了很無聊。你把一串訊息送進去,它吐一段文字回來,然後就沒了。你問它「台北今天要不要帶傘」,它可以回你一整段條理分明的「我得先查一下天氣預報、再看看降雨機率」,可是它連一次查詢都發不出去。它是一張很會講話的嘴,沒有手,也沒有記性。

上一篇把一台 agent 拆成兩半:model 負責推理和決策,harness 是包在模型呼叫外圍那一圈程式碼,負責遞工具、幫忙記事、必要時踩煞車。那篇講的是分工的觀念。這一篇要把手伸進去,看 harness 的心臟,也就是迴圈本身,到底怎麼一圈一圈轉起來。

我想先講一件容易被忽略的事:核心迴圈的四個零件,不是誰坐下來一次設計出來的。它是一個痛點逼出下一個零件,這樣累積出來的。看懂這個「一塊一塊長出來」的順序,比背下四個名詞有用得多。所以這篇的順序,跟著它們一個逼出一個的先後走。

先把四個名字報一下,等一下你會看到它們是怎麼接力出場的:

零件 它補上的能力 一句話類比
agent loop 反覆問模型「下一步做什麼」,直到收工 一顆會跳的心臟
tool runtime 把模型講的工具名字接到真正的程式碼 一張查號台的電話簿
permission / sandbox 有副作用的動作出手前先攔一下 打 sudo 前那句「你確定嗎」
hooks 在迴圈的固定縫隙插進你自己的規矩 門口那位認人的保全

第一塊:先讓它有心跳

要讓那張嘴從「會講」變成「會做」,第一件要補的東西是心跳。

把 agent loop 想成一個 while 迴圈,每一圈都問同一句話:「根據你目前看到的一切,下一步要做什麼?」模型的回答只有兩種。一種是「我要用某個工具」,那你就去執行、把結果塞回去、再問一次;另一種是「我講完了,這是答案」,那你就跳出迴圈把答案交出去。整個判斷的核心就這麼一件事:分辨這次回應是「還要動作」還是「已經是最終答案」。

判斷依據藏在回應的 stop_reason 欄位裡:值是 tool_use 代表模型要求動作,end_turn(或任何不是 tool_use 的值)代表這一輪答完了。這裡有個很多人第一次都會愣住的事實:同一個使用者問題,模型往往被呼叫好幾次。 回到帶傘那個例子,模型可能先查氣溫,拿到結果再查降雨機率,最後才整合成一句「帶吧」。從你發問到它答完,中間穿插了好幾次模型呼叫和工具執行,這一整段叫做內層迴圈。

外層迴圈則是把一輪又一輪串成整段對話。你接著問「那明天呢」,這是新的一輪。兩層迴圈共用同一份 messages[],每一輪、每一次工具結果都往裡面 append,所以模型回答「明天」時,看得到你剛剛問過台北,不用重講一次背景。這份 messages[] 就是這個 session 的全部記憶,由呼叫端(也就是 harness)拿著,不是藏在模型肚子裡。

用程式碼看最乾淨。這是內層迴圈的最小骨架,跑完一個輪次:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 最小可跑的 agent loop:跑完「一個」使用者輪次
def run_turn(messages, model, tools, max_steps=10):
for _ in range(max_steps): # 安全上限,擋住跑不停的情況
response = model(messages) # 呼叫一次模型
messages.append({ # 先把模型這次回應存進共享狀態
"role": "assistant",
"content": response.content,
})

if response.stop_reason != "tool_use": # 不是要用工具,就是最終答案
return final_text(response) # 收工,把文字交回使用者

# 走到這裡代表模型要用工具,逐一執行、收集結果
results = []
for block in response.content:
if block.type == "tool_use":
output = run_tool(tools, block.name, block.input)
results.append({
"type": "tool_result",
"tool_use_id": block.id, # 標明這是回應哪一次呼叫
"content": output,
})
messages.append({"role": "user", "content": results}) # 結果塞回對話,再轉下一圈

raise RuntimeError("跑到 max_steps 都還沒收工")

外層迴圈更短,負責把一輪接一輪串起來:

1
2
3
4
messages = []                                        # 整段對話的共享狀態,由 harness 持有
for user_text in turns: # 外層迴圈:一則使用者訊息就是一輪
messages.append({"role": "user", "content": user_text})
reply = run_turn(messages, model, tools) # 第 N 輪看得到 1..N-1 輪留下的內容

這裡有兩個很容易踩、踩到又超難查的雷。第一,模型發出的工具呼叫(assistant 那則)和你回填的 tool_result,兩個都得 append,缺一不可:少了 assistant 那則,transcript 就對不上;少了 tool_result,模型會一直癡等一個永遠不來的結果。第二,就算工具執行失敗,也要回一個 tool_result,把錯在哪寫清楚,別讓迴圈當場崩掉。模型看得到失敗,才有機會換個方式爬起來。

迴圈一定要有上限。 那個 max_steps 不是裝飾。模型可能因為一個 bug,或工具回傳的某段內容,陷入「呼叫工具、看結果、再呼叫同一個工具」的鬼打牆;沒有步數或 token 上限,這個迴圈會很安靜地把你的額度燒到見底。另一面是 messages[] 只會越長越長,總有一天塞爆 context window,那是後面章節 context 管理的事,但你現在就該知道它一定會發生。

真實的 Claude Code 就是這個迴圈的加強版:它的 query/ 模組是一個 async generator,會在模型吐 token、工具回傳結果的當下逐一 yield,所以你看到答案是一個字一個字冒出來的,而且同一輪的多個工具呼叫還能平行跑。但撥開這些花俏,核心分支跟上面那段骨架一模一樣:要用工具就繼續轉,給出答案就跳出去。

第二塊:讓它真的碰得到世界

心跳有了,可是迴圈裡那行 run_tool(tools, name, input) 還是空的。模型現在只會喊「我要讀檔案」,喊完呢?得有人真的去把檔案打開。這就是第二塊零件,tool runtime,要補的事。

模型不會直接呼叫你的函式,它只會吐出一個結構化區塊,像這樣:{"name": "read_file", "input": {"path": "..."}}。harness 收到這個名字,查出對應的 handler,驗一下輸入格式,執行,再把回傳值包成 tool_result 送回去。說白了,dispatch 的本質就是一次查表,跟查電話簿沒兩樣:拿名字,找到號碼,撥出去。

所以你需要一個地方登記「名字對到哪段程式碼」,也需要一份清單告訴模型「現在有哪些工具、各自吃什麼參數」。一個工具就是一個小物件,registry 就是一張 name -> tool 的字典:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from dataclasses import dataclass, field
from typing import Callable

@dataclass
class Tool:
name: str # 模型用這個名字指名呼叫
run: Callable[[dict], str] # 真正做事的 handler
description: str = "" # 公告給模型看,它靠這句決定要不要用
input_schema: dict = field(default_factory=dict) # 輸入格式,dispatch 前拿來驗
is_concurrency_safe: bool = False # 能不能平行跑(會寫入的一律預設 False)

class Registry:
def __init__(self):
self._tools = {}
def register(self, tool): # 加一個新能力,就是註冊一個 handler
self._tools[tool.name] = tool
def get(self, name): # dispatch 其實就是這一行查表
return self._tools.get(name)

registry = Registry()
registry.register(Tool(
name="read_file",
run=lambda i: open(i["path"]).read(),
description="讀取指定路徑的檔案內容",
))

有了這層抽象,「幫 agent 加一個新能力」就被降級成「註冊一個 handler」,迴圈本體一行都不用動。這也回答了一個常見的偷懶念頭:為什麼不乾脆丟一個萬能的 bash 給模型,讓它想幹嘛就送字串進去?因為那樣一來,你就沒辦法對「讀檔」和「刪檔」做不同的參數檢查,更沒辦法對它們套不同的權限規則。下一塊零件的把關會直接失去著力點。工具分得越細,你能攔的地方就越多。

那個 is_concurrency_safe 標記也值得多看一眼。讀檔、搜尋這類唯讀操作,同時跑好幾個沒問題,可以批次平行、省下乾等的時間;但兩個同時寫入同一個檔案的操作若平行跑,就可能互相蓋掉、把檔案寫壞。所以預設是保守的依序執行,只有明確標記為安全的工具才進平行批次。Claude Code 的內建工具就照這原則分組,它甚至因為工具實在太多,會先只把工具名字給模型看,等真的要用時才用 ToolSearchTool 載入完整 schema,免得幾百份 schema 一次擠爆 prompt。

工具出錯要回結果,不要讓迴圈崩。 模型指名一個不存在的工具、參數對不上 schema、或工具吐出一坨塞爆 context 的巨大輸出,這三種都很常見。正確的處理是回一個內容為錯誤訊息的 tool_result(大輸出就截斷成預覽,附一個檔案路徑),讓模型知道發生什麼、自己調整,而不是拋一個例外把整個迴圈打斷。

第三塊:出手前按一下暫停鍵

到這裡 agent 已經會自己找工具來跑了。而這,正好也是危險的起點。

一個沒有把關的工具執行環境,本質上就是一台沒人看管的遠端 shell:模型一個判斷失誤,就可能刪掉檔案、把機密印進 log、或把錯的程式碼推上線。這裡我想講一句最該記住的話:相信模型不會亂來,不是安全邊界,只是心理安慰。真正的邊界,必須是程式碼在動手之前做的那道檢查。

這道檢查最好的類比就是 sudo:你打一個危險指令,系統會在真的執行前跳一句「你確定嗎」,把最後的決定權交還給你。permission 層做的是同一件事,在每個工具呼叫執行前先看一眼,回傳三個決定之一:allow(放行)、ask(暫停、問人)、deny(不准)。

漂亮的地方在於,這整個決策可以寫成一個沒有任何 I/O 的純函式,好測到不行:

1
2
3
4
5
6
7
8
9
10
def decide(tool, mode, allow_rules) -> str:          # 只回傳 allow / ask / deny 其中一個
if mode == "bypass": # 操作者自己明確關掉了把關
return "allow"
if mode == "plan": # 只在探索、還不真動手的階段
if tool.is_read_only:
return "allow" # 唯讀操作放行,看資料沒關係
return "deny" # 計畫核准前,一律不准有副作用
if tool.is_read_only or tool.name in allow_rules:
return "allow" # 唯讀、或已預先核准過的,放行
return "ask" # 其餘一律問人,拿不準時最保守的選擇

這個 gate 放在 run_tool 執行前那一刻。決定是 deny、或還沒獲得核准的 ask,工具根本不會執行,但你仍然要回一個 tool_result 告訴模型「這次被擋了、為什麼」,它才好換做法。這條規矩很重要:每一次工具呼叫都會生出一則結果訊息,就算真正的動作根本沒跑。

那個 mode 參數改變的是預設行為,讓你一次調整一整類操作的鬆緊:

Mode 唯讀工具 會改東西的工具 什麼時候用
default 放行 每次都問你 日常最安全的預設值
plan 放行 一律 deny 只想讓它調查、規劃,還不准動手
acceptEdits 放行 編輯類預先放行 你已信任它改檔,不想一直被問
bypass 放行 全部放行 你很清楚在幹嘛、而且環境可拋棄時

Claude Code 就是這套的完整版:QueryEngine 對每次工具使用呼叫 canUseTool,解析出 allow、deny 或 ask;規則會依 user、project、local、policy、session 等來源照優先序合併;你核准過的決定可以記進 session 或寫進 settings,下次同類操作就不再問你。至於 sandbox,那是第二道防線:就算某個 bash 指令被允許了,也還能把它關進沙箱裡跑,限制它碰得到的範圍。

核准疲勞跟開太大的規則一樣危險。 每件芝麻小事都跳出來問,使用者很快會養成「不看內容、閉眼按同意」的習慣,那道關卡等於形同虛設;正解是把低風險類別(讀檔、搜尋)預先放行,破壞性動作(刪除、推送、對外送資料)才留在明確詢問。反過來,一條開太大的 allow 規則、或長期掛著的 bypass mode,會讓後面所有高風險呼叫悄悄溜過去。bypass 的範圍要壓到最小,而且當下是什麼 mode,一定要讓使用者看得見。

第四塊:把你自己的規矩插進縫隙

前面三塊拼起來,agent 已經能跑、能動手、動手前也會被把關。可是每個團隊的規矩都不一樣:有人要把每次工具呼叫記進稽核 log,有人要在送出前自動把程式碼格式化,有人要在某類指令一出現就直接封鎖。如果每加一條規矩都得改迴圈本體,那份 dispatch 遲早腫成一坨沒人敢碰的義大利麵。

hooks 就是解法,精神跟 Git hooks、或 web 框架的 middleware 一樣:系統在生命週期的固定幾個點對外開孔,你把自己的 callback 掛上去,迴圈本體維持精簡、動都不動。在工具執行這條路上,兩個最重要的孔是:

  • PreToolUse:在 permission gate 之前跑。它能擋下這次呼叫,也能改寫輸入參數。
  • PostToolUse:在工具成功執行之後跑。它負責觀察結果,例如記 log、送通知。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Hooks:
def __init__(self):
self._pre = [] # 存放 PreToolUse 的 callback
def on_pre(self, fn):
self._pre.append(fn)
def fire_pre(self, name, args): # 在工具執行前、permission 之前觸發
for fn in self._pre:
out = fn(name, args) or {}
if out.get("updated_args"): # hook 可以改寫要傳給工具的參數
args = out["updated_args"]
if out.get("deny"): # hook 可以直接擋下這次呼叫
return True, args, out.get("message", "")
return False, args, "" # 沒被擋,放行,交給 permission gate

hooks = Hooks()
# 即使目前是 bypass mode,也用 hook 硬擋帶 rm -rf 的 bash 呼叫
hooks.on_pre(lambda name, args:
{"deny": True, "message": "rm -rf 一律封鎖"}
if name == "bash" and "rm -rf" in args.get("command", "") else None)

放進迴圈時的順序是:dispatch 先觸發 PreToolUse,被擋下就直接回結果、連 gate 都不進;沒被擋才走 permission 判斷;通過了才真的執行工具;執行成功後再觸發 PostToolUse 去觀察。上面那個 rm -rf 的例子最能說明 hook 的價值:它是一條你自己定義、獨立於 permission mode 的硬規則,就算有人手滑開了 bypass,它照樣把毀滅性指令攔在門外。

Claude Code 定義了多達 27 個生命週期事件,涵蓋 tool、prompt、session、stop、subagent、compact 等時機,全部從 .claude/settings.json 載入。它還會在啟動時對當下生效的 hook 集合拍一張快照,避免某個程序在 session 中途偷改 settings、造成行為漂移。

hook 只能收緊,不能放寬。 這是最關鍵的原則:hook 可以把本來會 allow 的呼叫改成 deny,但不該把已經被 permission 拒絕的動作硬改成放行,否則 hook 就成了繞過安全邊界的後門。另外兩個坑:Stop hook 若在停止時觸發自我修正、又再次觸發自己,會變成無限迴圈,得記住它是不是已經在跑;hook 常常 shell out 去做慢工,記得加 timeout,別讓一個慢 hook 卡死整個迴圈。

四塊拼起來,就是一台能動的最小骨架

回頭看這一圈是怎麼長出來的。一開始只有一張會講話的嘴。你先給它一顆心臟,agent loop 靠 stop_reason 決定繼續或收工,用 messages[] 記住一切、max_steps 踩煞車。接著給它一雙手,tool runtime 把「加能力」變成「註冊 handler」。手一長出來就危險,於是加一道 permission gate,在每個副作用出手前按暫停鍵。最後開幾個孔,讓你能把自己的規矩用 hooks 插進固定的縫隙。

四塊環環相扣的交會點,就在 dispatch 那一行:依序疊著 hook、permission、tool 三層檢查。過了這三關,動作才真的發生。你手上這台 agent,已經能安全地跑起一個真實任務了。

但它還很短視。它一次只知道眼前這一步要做什麼,碰到需要拆解、需要規劃、需要調度別的分身來幫忙的大任務,就力不從心。下一篇要看的,是 harness 怎麼讓 agent 扛住複雜任務:planning 把大目標拆成步驟、subagents 分頭作業、skills 把可重用的專長封裝起來,還有 context 怎麼在這一切之間被有效管理。心臟會跳了,接下來要教它動腦。