Agent = Model + Harness 把 Model 放在可替換的位置,可替換到什麼程度要動手換一次才知道。以下依序釐清三件事:端點替換之後哪幾層維持原狀、組態指令的兩個副作用,以及把另一個 agent 填進 base_url 造成的問題。
替換的對象是同一組金鑰、同一個模型、兩條不同的傳輸路徑,只改端點,其餘一律維持原狀:
provider: gemini
provider: custom
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 與工具迴圈,那個範圍留待後續驗證。
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 也照常運作,讀得到:
RuntimeError: Request timed out
推論那一步失敗被記下來了,記錄機制照常運作。
端點斷線時仍讀得回來的狀態,證明它們保存在 harness 這一側
可替換性有個界線,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
機制分五步:
tool_calls 連同 content 一併寫進 OpenAI 格式的回應tool_calls 讀成該由自己執行的請求Unknown tool,把錯誤回給模型要求改正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 的形式對外提供工具,由外層呼叫。兩種做法的共同點是回傳最終結果。
| 段落 | 內容 |
|---|---|
| 現象 | 外層 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 的做法。