Skip to content

Day 07:讓 agent 真的能動手做事

davidlei

Tool use 是 agent 第一次真正「動手做事」的瞬間,也是第一次有機會把事情搞砸的瞬間。chatbot 階段最壞就是回應內容不準,你笑一笑關掉視窗;agent 階段最壞是真的造成不可逆的副作用——模型寫程式錯了你笑笑修一下,模型 rm -rf 錯路徑你就完了。

而且這層最常見的失敗模式,你只要寫過幾天 agent 就會撞到:模型自信滿滿地 hallucinate 一個不存在的 function 名,參數還填得有模有樣;或者它拿到一個泛用 shell 工具,寫出能在你主機上 eval 的指令,而你的「危險指令字串比對」被一句 bash -c "..." 就繞掉。前者是契約問題,後者是隔離問題——兩個都不是 prompt engineering 能解決的。

這篇就是要拆 Hermes 怎麼讓「動手做事」這件事盡量不出意外:schema 驗證、工具白名單、七種沙箱、結果預算、迴圈護欄,還有一個我看到當下笑出來、名字叫「防說謊頁尾」的設計——全部都是為了同一件事,把模型可以闖的禍從「軟體層」往下壓到「結構上不可能」。

昨天我們講記憶——agent 怎麼「記住」昨天。今天我們講的是 agent 真正動手做事的那一刻。也是整套系統最容易出意外的一層。

Hermes 在這層花了相當多的力氣。我們今天會看七種沙箱、無狀態 shell 的假象、三層結果預算、結構化的失敗判定、並行安全證明、工具迴圈護欄,還有一個我看到當下笑出來的設計叫「防說謊頁尾」。


一、工具契約:模型看到什麼、看不到什麼

先把最基礎的講清楚。在 Hermes 裡,一個工具是這樣的一個結構:

ToolEntry(name, toolset, schema, handler, check_fn, requires_env, ...)

schema 是標準的 OpenAI function-calling schema,handler 的簽章統一是 handler(args: dict, **kwargs) -> str,而且必須回傳 JSON 字串

但這裡有個我覺得設計得很乾淨的地方,是分隔模型可見參數和執行期 context。args 是模型生成的——它要符合 schema、會被驗證。但 **kwargs 裡裝的是 task_idstoreparent_agent 這些東西,是派發器在執行的當下注入的。模型永遠看不到 task_id,也沒辦法假裝自己是另一個 task 的 parent agent。

Note:這個分隔很重要。如果你讓模型「可以填」任何欄位,那麼模型遲早會 hallucinate 一個 task_id="root" 進來。把 runtime context 完全藏在 kwargs 裡,等於從根上斷掉這條攻擊路徑。

工具註冊本身也有一道防線——叫做 shadowing protection。如果一個 toolset 註冊了 read_file,然後一個 MCP plugin 也想註冊一個叫 read_file 的工具,預設會被拒絕,除非顯式 override=True。這擋的是「供應鏈劫持」:一個惡意 MCP server 偷偷覆蓋掉內建 read_file,讓所有讀檔請求都流經它。烤進 registry 這個最底層的元件,而不是事後檢查。


二、七種沙箱,七種「身體」

整套工具系統最精彩的地方,在 tools/environments/ 這個目錄。同一個 agent core,它的「手腳」可以放在七種不同的執行環境:

後端隔離強度啟動成本Latency適用場景
local零隔離0最低開發、自用機
docker容器級中(秒級)CI、單機隔離
singularity容器級HPC 環境
ssh主機級連線成本跑在遠端 box
modal雲端容器高(冷啟動)中高短時爆量
daytona雲端 dev box中高長住式 workspace
vercel_sandbox雲端容器中高邊緣執行

每種都實作同一個 BaseEnvironment ABC,實際上只要實作 _run_bash()cleanup(),其他都由基底類別提供。trade-off 很直白:local 最快但完全沒有隔離,docker 隔離夠用但啟動秒級,雲端後端隔離最徹底但每呼叫一次都得付一次 latency。

Docker 的強化姿態值得單獨講一下:--cap-drop ALL 把所有 capability 砍光,再選擇性加回 DAC_OVERRIDE / CHOWN / FOWNER(只有 entrypoint 需要 gosu 降權時才加 SETUID/SETGID);--security-opt no-new-privileges--pids-limit 256 防 fork bomb、tmpfs 暫存目錄掛 nosuid--init 收殭屍程序。這是「最小能力 + 防越權」的 textbook 寫法。

但有一件事必須老實說。LocalEnvironment 完全沒有隔離。 整套沙箱安全模型假設你用容器或遠端後端,可是 local 是預設值。也就是說,從 source clone 下來、一句 config 都沒改的人,模型寫的 bash 是直接在你的主機上 eval。唯一的圍欄是「危險指令需要核可」那一層字串比對。

(這點 Day 14 會正面開砲,先記住。)


三、無狀態 shell 的假象

這是我第一眼看到、心裡默默說「靠這招漂亮」的設計。

如果你寫過 shell 整合,你會知道兩條路都會痛:

Hermes 兩個都不要,它做了第三條路——session snapshot

init_session() 在 session 開始時,把當前的環境變數、函式、別名 dump 到一個 /tmp/hermes-snap-*.sh 檔。之後每一條指令在跑之前,_wrap_command() 會 prepend 一句 source /tmp/hermes-snap-XXX.sh,把那份 snapshot 重新載入。指令跑完之後,如果環境有變,再把新狀態寫回 snapshot 檔。

CWD 怎麼辦?本機後端用一個暫存檔記 CWD;遠端後端因為沒有可共享的檔案系統,改用 in-band 的 stdout 標記——指令結尾印一個 printf '\n__HERMES_CWD_<session_id>__/some/path__HERMES_CWD_<session_id>__\n'(其中 <session_id> 是運行期值,不是字面字串 session),母程序在 stdout 裡 grep 那個標記抓出 CWD。

用生活比喻:這就像每次你進辦公室,門口都重新發一張一模一樣的識別證、座位也重新指給你;但因為內容一致,你感覺自己「上次留下的東西都還在」。其實每一次都是新的進程,只是裝得很像。

有一個漏洞要特別處理:如果模型寫 cd /foo && ls,中間的 && 串接讓 cdls 在同一個 bash -c 裡跑,CWD 標記能抓到結束時的目錄,沒問題。但如果它寫成兩個獨立的 tool call(一個 cd、一個 ls),cd 的效果其實會被 snapshot 接住——前提是 wrap 過的指令在結束時把 pwd 寫回去。Hermes 確實這樣做了,所以這個假象大部分時候撐得起來。

為什麼這個假象重要?prompt cache。如果模型在 turn 1 看到 pwd=/home/x,turn 3 又看到 pwd=/home/x,中間沒有「我現在的環境是新的」這種莫名其妙的飄移,模型生成的 reasoning 就會穩,context 也比較容易命中 cache。這是把 Day 03 的 prompt cache 鐵律延伸到工具層的一個應用


四、三層結果預算:不讓一個 cat 撐爆 context

cat 一個 200MB 的 log 檔——你的 context window 瞬間爆炸。Hermes 三層防禦:

  1. 每個工具自己截斷:工具內部先做一次 truncation,通常是「保留前 N 行 + 後 M 行」這種。
  2. 每個結果的持久化:超過某個門檻的輸出,被寫進沙箱的暫存目錄;context 裡留下一個 <persisted-output> 預覽片段 + 一個檔案路徑,模型可以用 read_file 去翻原本的完整內容。用持久化取代純截斷——資料沒丟,模型能分頁讀。
  3. 每一輪的總預算:一輪裡所有工具結果加起來超過 200K 字元,把最大的那幾個溢寫到磁碟。

兩個漂亮的小細節:

有個契約面的關鍵:tool_result_classification.py 裡有一個 file_mutation_result_landed(tool_name, result)。對於 write_file / patch 這類「會改檔案」的工具,它的 result 必須是結構化的——write_file 要回 bytes_written 欄位;patch 要回 success: true(兩個工具用不同欄位,各自由 tool_result_classification.py 判斷)。不可以只回一個字串「OK」。原因下個小節說。


五、誠實一點:錯誤分類其實是字串比對

我讀到這段的時候皺了一下眉。

classify_tool_failure 對「generic」類別的工具,失敗判定是這樣寫的(tool_guardrails.py:217–218,完整條件):

lower = result[:500].lower()
'"error"' in lower or '"failed"' in lower or result.startswith("Error")

三條 OR——也就是說,任何工具回的 JSON 字串裡只要包含 "error""failed" 這兩個 token,或回應以 Error 開頭,就會被判定為失敗

這意味著什麼?舉個例子:你寫了一個 lint 工具,正常情況回 {"errors_found": 0, "issues": []}。它沒失敗,但 '"error"' 在前 500 字內,classifier 直接判它失敗。然後這個誤判會餵進下一節要講的工具迴圈護欄,讓計數器加一。連續幾次「成功但被誤判」之後,模型會被「擋住」,被告知不要再呼叫這個工具。

這就是為什麼 file_mutation_result_landed 要走「結構化欄位」那條路而不是字串比對——對 file write 這種事情,Hermes 老老實實 parse JSON、檢查 bytes_written、檢查 success: true。但對「generic」這層,它退化成 '"error"' in result[:500]

這是一個結構性的技術債。正解應該是讓所有工具都回一個 {ok: bool, ...} 的 envelope,Hermes 沒做到。Day 14 會回來算這筆帳——現在先把它記在心上,因為這條串著好幾個下游問題。


六、並行 vs 序列:由「安全證明」決定,不是由「希望」

tool_executor.py 用一個 ThreadPoolExecutor(max_workers=8) 跑並行工具呼叫。但不是每次都並行——_should_parallelize_tool_batch 會先檢查整批 tool call,只有在能「證明安全」時才並行。

證明標準很嚴:

  1. 批次裡每個工具都在 _PARALLEL_SAFE_TOOLS 這個 frozenset 裡(它是 read-only 工具白名單——read_filelist_filesgrep 這類)。
  2. 或者,是 path-scoped 的 file tool 且 路徑不重疊(用 Path.parts 做 prefix 比對)。
  3. 任何 parse 失敗 / 解不出來路徑 → 整批退回序列執行。

重點來了:這是 opt-in by safety proof,不是 opt-in by hope

差別在哪?多數系統的並行邏輯是「除非我知道你不安全,否則就並行」(opt-out by detection)。Hermes 反過來,「除非我能證明你安全,否則就序列」。在一個你完全不信任輸入的場景(模型生成的 tool call),這個方向的選擇正確得多。

實作上還有兩個小巧思:contextvars.copy_context() 複製到每個 worker,讓 thread-local 的 task_idapproval_callback 都跟著走;每 5 秒 heartbeat 一次去敲 gateway 的活性監視器,免得長時間跑的工具被誤判 idle 砍掉。worker 進去之前還要把 approval callback 裝好(否則子代理 prompt 會 deadlock 在 input())——這種細節是踩過坑才會寫的。


七、工具迴圈護欄:讓模型不要鬼打牆

agent 最常見的失敗模式之一,是鬼打牆。同一個工具用同樣的參數連 call 八次,每次失敗、每次又改一點點細節重試,token 燒光。

agent/tool_guardrails.py 處理這件事。它有三個計數器,key 全部用 ToolCallSignature——也就是 tool name + SHA-256(canonical-JSON args)(注意是雜湊,不是把原始 args 寫進去,免得敏感資訊洩漏到日誌):

計數器警告閾值硬阻擋閾值意思
exact_failure25完全一樣的 call 連續失敗
same_tool_failure38同一個工具(任意參數)連續失敗
idempotent_no_progress25冪等工具呼叫了但 state 沒前進

實作上有一個我特別欣賞的設計選擇:ToolCallGuardrailController 是純函式式的——它只回傳決定(allow / warn / block / halt),自己不動手。要不要真的把 warning 塞進 prompt、要不要 break 出 loop,全部由 runtime 決定

為什麼這個分離很好?

Note:順帶一提,「純函式 controller + 有副作用 runtime 分離」這個拆法不是我自創——它是 FP / classical AI agent 教科書(Russell & Norvig)的標準模式。但在 LLM agent 圈,主流寫法是 graph / orchestrator(LangGraph、AutoGen),很少這樣明確分離。Hermes 把 FP 視角搬進來這件事本身比較少見,值得指出來。

預設 warning 永遠開,但 hard stop 是 opt-in 的——這個保守是對的,因為前一小節講過,錯誤判定本身有字串比對的偽陽性,你不會想預設就讓「正常但被誤判失敗」的工具直接觸發 halt。


八、防說謊頁尾:讓「謊報完成」在結構上不可能

Note:「防說謊頁尾」這個名字是我自己取的——業界目標一致但沒有統一命名(Cleanlab 叫「trustworthiness scoring」、Anthropic constitutional AI 走訓練層、有些人在 model-level 做 internal representation detection)。Hermes 的做法是 prompt-level footer:純工程選擇,不依賴模型訓練,任何 framework 都能照抄,這也是我覺得它值得推廣的原因。

這是我看完全身起雞皮疙瘩的設計。

問題是這樣的:模型很愛過度宣稱。你叫它「改三個檔案」,它在 final response 裡寫「我已經把三個檔案都改好了」——但實際上 write_file 對第二個檔案回了 error、它沒注意到,還是寫了那句話。使用者讀到那句話,以為事情做完了,結果出 bug 才發現第二個檔案根本沒動。

Hermes 怎麼解這個?在 conversation_loop 裡有一個 per-turn 的 dict 叫 _turn_failed_file_mutations。每次 write_file / patch 跑完,如果 file_mutation_result_landed 判定沒成功落地(記得,這裡用結構化欄位判,不是字串),就把 (tool_name, path) 記進去。如果同一個 path 之後一次成功的 mutation,就把它從 dict 裡刪掉。

turn 結束、要把 final response 回給使用者之前,看那個 dict 裡還剩什麼。如果還有東西沒被後續成功覆蓋,直接把警示頁尾附在 response 後面:

⚠️ The following file mutations failed this turn and were not retried:
- write_file: /path/to/foo.py
- patch: /path/to/bar.py

模型蓋不掉這個頁尾——它是在 final response 生成之後才被 append 上去的,不在模型可控的 token 流裡。

一句話:讓謊報完成在結構上不可能。

這是我覺得整個 Hermes 裡最有「品味」的一個設計。不是「教模型不要說謊」(prompt engineering 的死胡同),而是把信任的真相源(file_mutation_result_landed 的結構化判定)直接拼到輸出層,讓模型的自白和系統的事實並列在使用者眼前。


暗線 C:模組分得開的部分,跟分不開的部分

我想在這裡稍微岔題。

如果你跟著我們從 Day 01 讀到這裡,你會發現一個有意思的對照:工具系統本身做得相當乾淨tool_executor.pytool_dispatch_helpers.pytool_guardrails.pytool_result_classification.py——每個檔案職責清楚、彼此邊界明確、可以獨立讀懂。連 tools/environments/ 都是一個漂亮的 ABC + 多後端的展開,新增一個後端只要實作兩個方法。

但這跟我們 Day 12 / Day 14 會看到的巨石檔案形成強烈對比——browser_tool.py 160KB、mcp_tool.py 147KB、skills_hub.py 120KB、delegate_tool.py 118KB、conversation_loop.pyrun_conversation() 接近 3,900 行……這些檔案各自做太多事,模組邊界已經糊掉。

記住這個對照。Hermes 不是「整套都好」或「整套都爛」,它是有些子系統的抽象做得很漂亮、有些子系統長到塞不進腦袋裡。後面我們會回來看,為什麼有些地方守得住、有些地方守不住——而那個分界本身,就是讀這個 codebase 最有營養的地方。

(這是我們的主線 C:抽取程式碼 ≠ 分解系統。)


小結

讓 agent 真的動手做事,要過七關:契約、沙箱、無狀態 shell 假象、結果預算、錯誤分類、並行安全證明、迴圈護欄、防說謊頁尾。其中三個設計我覺得是任何 agent framework 都該偷的:ToolCallGuardrailController 的純函式式決定file_mutation 結構化驗證 + 防說謊頁尾並行 by safety proof 而不是 by hope。一個技術債也老實寫出來:錯誤分類退化成字串比對,Day 14 會回來算帳。

但你寫到第三個工具就會冒出一個想法:「天啊,我能不能用別人已經寫好的工具?搜尋、地圖、Notion、GitHub、自家公司的內部 API——我不想每一個都自己包。」

2024 年 11 月,Anthropic 推出 MCP(Model Context Protocol),就是要解這個問題。明天的主題就是 MCP——它怎麼設計、Hermes 怎麼同時當 MCP 的 client 和 server,還有一個 MCP server 死掉時 Hermes 怎麼讓模型本身停止重試。


想自己翻原始碼?

檔案在幹嘛
agent/tool_executor.py並行工具執行、ThreadPoolExecutor、context 複製
agent/tool_dispatch_helpers.py_PARALLEL_SAFE_TOOLS_should_parallelize_tool_batch
agent/tool_guardrails.pyToolCallGuardrailController、三個計數器、ToolCallSignature
agent/tool_result_classification.pyfile_mutation_result_landedclassify_tool_failure(就是那段字串比對)
tools/registry.py工具自我註冊、AST 把關、shadowing protection
tools/schema_sanitizer.py對不同 provider 做 schema 消毒
tools/environments/base.py無狀態 shell + session snapshot、CWD 標記
tools/environments/local.py預設後端(零隔離,Day 14 要罵的就是它)
tools/environments/docker.py強化過的容器設定

入口建議:從 agent/tool_executor.py 進去,看一次 execute_tool_calls 怎麼把一批 tool call 餵給 dispatch helper 判斷並行性,再追到 tool_guardrails.py 看 controller 怎麼回 decision,最後翻到 tools/environments/base.py_wrap_command 那段 session snapshot 怎麼撐起無狀態 shell 的假象。

Edit this post
Previous
Day 08:MCP 是什麼,為什麼大家都在喊
Next
Day 06:Agent 怎麼「記住」昨天聊過什麼