iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
AI Engineering

打造具備記憶與執行能力的常駐 AI Agent:Hermes Agent × Gemini × MCP 的 Harness 設計系列 第 10 篇

【Day 10】可替換性實測:一次端點替換與一次兩層 agent 的衝突

  • 分享至 

  • xImage
  •  

Agent = Model + Harness 把 Model 放在可替換的位置,可替換到什麼程度要動手換一次才知道。以下依序釐清三件事:端點替換之後哪幾層維持原狀、組態指令的兩個副作用,以及把另一個 agent 填進 base_url 造成的問題。


實測設計

替換的對象是同一組金鑰、同一個模型、兩條不同的傳輸路徑,只改端點,其餘一律維持原狀:

  • 基準線:Gemini 原生端點,provider: gemini
  • 替換對象:Google 的 OpenAI 相容端點,provider: custom
  • 驗證指令:同一句 one-shot,hermes -z "Reply with exactly one word: OK"
  • 隔離方式:用 hermes profile create <名稱> --clone --no-alias 複製一份拋棄式 profile,測完刪掉,正在運作的 profile 全程維持原狀

--clone 帶走組態、.env、SOUL.md 與記憶索引,測試環境與來源一致。


替換前後的組態差異

替換前讀出來的模型區段與執行結果:

hermes -p <profile 名稱> config get model
base_url: https://generativelanguage.googleapis.com/v1beta
default: gemini-3.5-flash-lite
provider: gemini
context_length: 131072

hermes -p <profile 名稱> -z "Reply with exactly one word: OK"
OK

改走相容端點只需要三行,模型名稱沿用:

hermes -p <profile 名稱> config set model.provider custom
hermes -p <profile 名稱> config set model.base_url https://generativelanguage.googleapis.com/v1beta/openai/
hermes -p <profile 名稱> config set model.api_key '${GOOGLE_API_KEY}'

hermes -p <profile 名稱> -z "Reply with exactly one word: OK"
OK

比對前後兩份 config.yaml,差異在三個鍵:

鍵 替換前 替換後
model.provider gemini custom
model.base_url 結尾是 /v1beta 結尾是 /v1beta/openai/
model.api_key 無此鍵 ${GOOGLE_API_KEY}

model.default 與 model.context_length 維持原值,改動範圍止於 model: 這個區段。

原始碼這一側對得上同一個範圍。hermes_cli/model_setup_flows.py 的自訂端點流程寫入的是 model 這個對映底下的 provider、base_url、api_key、api_mode 與模型名稱,加上一次憑證池的清理,寫入位置就這些。

金鑰另有一個設計。互動流程存自訂端點的金鑰時,環境變數名稱由端點的主機與連接埠產生:

HERMES_CUSTOM_<主機>_<埠>_API_KEY

hermes_cli/config.py 的註解列了兩個理由,一是以端點身分為鍵而非只用主機名,同一台主機上的兩個端點各自持有獨立的憑證,二是固定前綴讓結果維持合法的 POSIX 變數名,因為 IP 形式的端點會產生數字開頭的字串。

換端點動到的範圍止於 model: 這個區段

這次測到的範圍只有單輪問答,兩條路徑都回得出結果。原生轉譯層的註解指出相容層的不穩定出現在多輪 agent 與工具迴圈,那個範圍留待後續驗證。


config set 移除註解,config get 印出明文金鑰

hermes config set 會把 config.yaml 重寫成解析後的結果。 替換前 46 行,三次 config set 之後剩 9 行,消失的是檔案裡那些列出可用 provider、fallback 設定與 security 選項的註解區塊。設定值完全正確,那份隨附文件則就此消失。

hermes config get model 會把 ${VAR} 展開再印出。 讀回來的是解析後的明文金鑰:

hermes -p <profile 名稱> config get model
...
api_key: AQ.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

寫入時 CLI 判定它是密鑰並遮蔽回顯,讀取時直接輸出明文。這個指令的輸出要先遮蔽,再貼進文件或截圖。


推論端點斷線時,其餘狀態仍讀得回來

另一個 profile 指向一台地端推論伺服器,那個端點目前 TCP 層斷線,hermes profile list 仍然列得出它綁定的模型名稱。cron list 也照常運作,讀得到:

  • 每一個排程的 cron 運算式與重複次數
  • 投遞目標與失敗投遞目標
  • 最近一次執行的結果,內容是連續四次 RuntimeError: Request timed out

推論那一步失敗被記下來了,記錄機制照常運作。

端點斷線時仍讀得回來的狀態,證明它們保存在 harness 這一側


內層已執行的 tool_calls 被外層當成待執行的請求

可替換性有個界線,base_url 後面必須是一個模型。把另一套 agent 框架(cagent,以 serve chat 模式啟動的 OpenAI 相容端點)填進去之後,執行的完整錯誤是:

Unknown tool 'get_environment_status' - sending error to model for agent-correction (1/3)
Unknown tool 'get_environment_status' - sending error to model for agent-correction (2/3)
Unknown tool 'get_environment_status' - sending error to model for agent-correction (3/3)
Max retries (3) for invalid tool calls exceeded. Stopping as partial.
Error: Model generated invalid tool call: get_environment_status

機制分五步:

  1. 內層 agent 收到請求,自己執行完該執行的工具,也產出了正確答案
  2. 它把這些已執行的 tool_calls 連同 content 一併寫進 OpenAI 格式的回應
  3. 外層的 Hermes 帶著自己的工具集跑 agent 迴圈,把 tool_calls 讀成該由自己執行的請求
  4. 那個工具名稱在 Hermes 的工具表裡查無對應,於是 Unknown tool,把錯誤回給模型要求改正
  5. 內層照樣再跑一次、回一樣的 tool_calls,三次改正用完,任務停在 partial

最後一行是最麻煩的地方,任務的狀態是做了一半。


排除掉的假設

這個現象花掉的時間大半在錯的方向上:

假設 怎麼驗 結果
內層框架的工具呼叫能力有問題 直接對地端推論端點送帶工具定義的請求 回傳合法的 tool call,能力正常
串流抖動導致 tool call 被切壞 忠實重現連跑 10 次,唯讀情境再連跑 10 次 20 次全部正常
工具名稱在傳輸過程中損毀 比對外層日誌與內層回應的實際內容 名稱完整,外層的工具表裡查無這個名稱

轉向的關鍵是改看外層的日誌與組態,三項發現指向同一件事:

  • 外層對同一輪連發三次請求給內層那個端點,代表跑的是自己的工具迴圈
  • 外層組態帶著 toolsets: [hermes-cli] 與 tool_use_enforcement: auto,期待回傳的工具名對應到自己的工具表
  • 內層的 serve chat 一律輸出內部 tool_calls,旗標清單裡查無抑制選項

決定性證據是介面之間的差異

同一組設定換三種介面執行,結果不同:

介面 結果
hermes -z(一次性) 正常,取到第一份回覆內容就結束
hermes chat(互動、多輪) Unknown tool 重試到上限後 Error
Desktop 與 TUI 同上

差別在於是否跑完整的工具迴圈。 一次性模式停在回應輸出,工具迴圈在它的範圍之外。

根本原因是兩端對角色的認定互相牴觸。chat completions 的契約假設對面是一個模型,而工具由呼叫端執行,回傳的 tool_calls 一律是待執行的請求。內層 agent 已經執行過,仍照這個格式回報,外層只能照契約解讀。

協定相容涵蓋的是格式,角色的分工另行約定

要讓兩個 agent 相接,介接點必須換成為此設計的協定,A2A 處理 agent 對 agent 的任務委派,或讓內層以 MCP server 的形式對外提供工具,由外層呼叫。兩種做法的共同點是回傳最終結果。


除錯紀錄:Unknown tool 與只做一半的任務

段落 內容
現象 外層 agent 回報 Unknown tool '<工具名>',重試三次後 Stopping as partial,畫面上同時出現內容正確的回覆
根本原因 內層 agent 以 OpenAI 相容端點的形式被當成模型呼叫,它把自己已執行的 tool_calls 一併寫回回應,外層依 chat completions 的契約解讀成待執行請求,在自己的工具表中查無此名
修正方式 拿掉中間那一層 agent,改由外層直接掛 MCP server,全系統只留一個工具迴圈
迴歸測試 重跑會觸發工具呼叫的同一句指令,確認工具呼叫完成,且回報的執行結果與設備實際狀態一致

心得

那 20 次串流重現全部正常,是整段排查裡產出最少的一段。

當時的假設是偶發的串流抖動,於是把同一個情境連跑 10 次,再換唯讀情境連跑 10 次,全部通過。跑完之後掌握的資訊跟跑之前一樣多,成果是排除了一個假設。

問題在於那 20 次都是直接對內層端點發請求、把回應原樣印出來,而真正會出事的動作是外層拿到回應之後去執行裡面的 tool_calls。重現腳本的範圍停在前一步,現象因此始終在腳本的範圍之外。

現象能否重現,取決於腳本是否走到出事的那一步


明天

Context engineering 與 prompt engineering 的分野,以及讓 agent 自己更新 context 的做法。


上一篇
【Day 9】Provider 組態:原生 API 與 OpenAI 相容層的差別
下一篇
【Day 11】Context Engineering:把 context 當成會演化的 playbook
系列文
打造具備記憶與執行能力的常駐 AI Agent:Hermes Agent × Gemini × MCP 的 Harness 設計 共 13 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言