前兩天量的是記憶與技能,兩者都在 volatile 那一層,每次重建 prompt 時跟著換。身分宣告落在另一層。今天把 SOUL.md 這個檔案拆開,依序處理四件事:它在 prompt 裡佔哪個位置、檔案不存在時由誰補上、一份宣告的 byte 成本是多少、寫進去的規範實際改變了模型的哪些輸出。
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_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。
自動更新的範圍只涵蓋從未被編輯過的那幾份文字
同一個乾淨的 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 逐字相同。三組數字合起來只有一種解釋:
替換是一比一的,寫多少進去就付多少。 把 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 在群組頻道自稱使用者的名字。