iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
AI Engineering

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

【Day 18】身分宣告放在哪一層:SOUL.md 佔掉 system prompt 的第一個位置

  • 分享至 

  • xImage
  •  

前兩天量的是記憶與技能,兩者都在 volatile 那一層,每次重建 prompt 時跟著換。身分宣告落在另一層。今天把 SOUL.md 這個檔案拆開,依序處理四件事:它在 prompt 裡佔哪個位置、檔案不存在時由誰補上、一份宣告的 byte 成本是多少、寫進去的規範實際改變了模型的哪些輸出。


身分是 stable 那一層的第一個位置

agent/system_prompt.py 開頭的 docstring 把三層的內容列得很清楚:

層 內容 重建時機
stable 身分(SOUL.md 或 DEFAULT_AGENT_IDENTITY)、工具指引、環境提示、平台提示 整個 session 固定
context 呼叫端給的 system_message、TERMINAL_CWD 底下找到的 AGENTS.md 與 .cursorrules 整個 session 固定
volatile 技能索引、記憶快照、USER.md、時間戳與 session 資訊 壓縮觸發時重建

組裝的順序寫在同一個檔案的 stable 區段,身分是第一個被放進 stable_parts 的元素,後面才接工具指引與各項行為規範。註解說明這個順序的用意是讓上游的前綴快取保持命中,整個 session 只建一次 prompt,只有 context 壓縮會觸發重建。

身分宣告與那些記憶條目走的是同一套注入機制,差別在所屬的層不同。 記憶放在 volatile,改一則就讓那一層重算,身分放在 stable,它一變整份快取的前綴就對不上。


檔案不存在時,Hermes 自己寫一份進去

實測的第一步是把一個乾淨的 HERMES_HOME 裡的 SOUL.md 刪掉,跑一次 hermes prompt-size,再回頭看那個目錄。檔案回來了,大小 667 B。

這個行為在 hermes_cli/config.py 的 _ensure_default_soul_md():

  • 檔案不存在:寫入 DEFAULT_SOUL_MD
  • 檔案存在且內容是已知的樣板:就地換成 DEFAULT_SOUL_MD
  • 其他情況:原樣保留

判定樣板的函式 is_legacy_template_soul() 拿內容去比對四個字串,比對前先統一換行、去掉 BOM 與前後空白:

已知樣板 來源
註解型骨架(含 Examples 區塊) 舊版 install.sh、install.ps1
註解型骨架(無 Examples 區塊) docker/SOUL.md 與部分歷史版本
前一代的 DEFAULT_SOUL_MD 上游改寫前自動寫入的那段文字
現行 DEFAULT_SOUL_MD 的 ASCII 破折號版 install.ps1 只能輸出純 ASCII,破折號寫成 --

函式的 docstring 把判定標準講死:使用者打了一個字進去,即使那個字在註解外面,這個函式就回 False。實測對得上,把內容改成單一個 X 之後再跑,檔案維持 1 B。

自動更新的範圍只涵蓋從未被編輯過的那幾份文字


一份 SOUL.md 的成本,就是它自己的大小

同一個乾淨的 HERMES_HOME、同一組工具與技能,只換 SOUL.md 的內容,量 stable 那一層:

SOUL.md 狀態 磁碟 byte stable 層 byte
檔案不存在,走 DEFAULT_AGENT_IDENTITY 0 8,600
前一代預設文字,被就地換回現行預設 513 → 667 8,600
現行預設文字 667 8,600
單一個字元 1 7,934
加了身分與語言兩節的版本 2,286 10,219

DEFAULT_AGENT_IDENTITY 這個常數本身是 663 字元、667 byte,與 DEFAULT_SOUL_MD 逐字相同。三組數字合起來只有一種解釋:

  • 8,600 減 667 等於 7,933,那是身分之外的 stable 內容
  • 7,933 加 1 等於 7,934,對上單一字元那一列
  • 7,933 加 2,286 等於 10,219,對上最後一列

替換是一比一的,寫多少進去就付多少。 把 2,286 B 那份換算回去,身分佔掉 stable 層的 22%。


語言與用語規範寫在同一個檔案裡

量測用的 profile 在預設身分段之後接了一節 ## Language (strict),內容是一張二十一列的對照表,左欄台灣用語、右欄對應的對岸用語,另外要求以學術語體書寫。整份檔案 1,743 B,那一節佔 1,076 B。

實測的方式是同一個問題各跑三次,兩組的差別只有那一節在不在。問題以簡體中文提出,內含「接口」與「数据库」:

條件 SOUL.md 三次回覆裡的寫法
有 ## Language (strict) 1,743 B 三次都寫「API 介面」
只留預設身分段 667 B 三次都寫「API 接口」

第二組的第一次回覆另外出現「數據回傳」,以及一段把 API 比成餐廳服務生、資料庫比成倉庫的譬喻。第一組三次都沒有譬喻,語體維持在第一節要求的範圍內。

六次回覆全部是正體字,繁簡這一項兩組沒有差異,差異集中在術語與語體。這組量測能證明的範圍到此為止:對照表確實改掉了模型選的詞,繁簡的那一條禁令在這個模型上沒有被測到。

模型在無人指定時會依訓練資料的分布選詞,對照表把選擇權收回來


寫進去之前還會過兩道處理

agent/prompt_builder.py 的 load_soul_md() 讀完檔案之後接兩個函式,兩個都會改寫內容:

  • _scan_context_content():以 context 範圍的威脅樣式掃描,命中就把整份內容換成 [BLOCKED: SOUL.md contained potential prompt injection (...). Content not loaded.]
  • _truncate_content():超過上限時保留開頭 70% 與結尾 20%,中間換成一行提示,告訴模型完整內容要用 read_file 自己去讀

掃描的註解說明它套的是 context 範圍而非 strict 範圍,SSH 後門、常駐化與外傳網址那幾組樣式在這裡不套用,理由是 clone 下來的 repo 裡本來就會有安全研究與基礎設施文件。命中時的處置是整份擋掉,註解寫明原因是這份內容會逐字進入 system prompt,使用者沒有介入的機會。

截斷的上限依模型的 context window 算,公式在 _dynamic_context_file_max_chars():

項目 值
每 token 估算字元數 4
分給 context 檔的窗口比例 0.06
下限 20,000 字元
上限 500,000 字元

以 32K 窗口的模型算是 7,864,低於下限,取 20,000。百萬窗口的模型算出來 240,000。config.yaml 裡明寫 context_file_max_chars 時那個值優先,動態計算讓位。

兩件事合起來決定了一份 SOUL.md 該怎麼排:關鍵的宣告放開頭或結尾,超過上限時被丟掉的是中間那一段。


心得

那份 2,286 B 的檔案開頭第一段,是上游已經改掉的舊文字。上游改寫它的理由寫在常數旁邊的註解裡:舊的那段是一串形容詞,每個模型本來就這樣看待自己,寫了等於沒寫。

自動升級的判定只認完全相同的字串,因此它一路維持原樣。在後面加上身分與語言兩節的那一刻,整份檔案就從「未經編輯」變成「已自訂」,前面那段舊文字從此停在原地。

這件事沒有訊號提示。hermes doctor 查的是這個檔案在不在,輸出是「SOUL.md exists (persona configured)」,內容是新是舊不在它的範圍。量 byte 的時候本來只是想知道身分佔多少,順著 667 這個數字去比對常數,才發現檔案開頭那段與常數對不上。

一份檔案只要被編輯過一次,它就退出自動維護的範圍


明天

同一個檔案裡的身分宣告是為了修一個故障加上去的,那個故障是 bot 在群組頻道自稱使用者的名字。


上一篇
【Day 17】Curator 的異動紀錄:一次封存與回復留下三筆紀錄
系列文
打造具備記憶與執行能力的常駐 AI Agent:Hermes Agent × Gemini × MCP 的 Harness 設計 共 18 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言