昨天量出身分宣告佔 stable 層的第一個位置,那個檔案裡的身分段落是為了修一個故障加上去的。agent 接進群組聊天之後,在頻道裡自稱發話者的顯示名稱,問它是誰,它報出來的是對方的名字。今天把這個故障拆成現象、名字的來路、根本原因與修正,並說明修好之後拿什麼當驗收條件。
接上聊天平台之後,在伺服器頻道 @ 它並要求自我介紹,回覆以發話者的顯示名稱自稱。同一套後端在私訊裡的回覆維持正確的名字。
兩邊的差別集中在一件事上:
排除的項目有三個,各有對應的證據:
| 排除的假設 | 證據 |
|---|---|
| 平台接線接錯 | 工具呼叫與回覆都正常,只有自稱這一項偏掉 |
| 記憶被寫髒 | memories/ 當時是空的,USER.md 還沒產生 |
| 平台的顯示設定 | 換頻道重測,偏掉的是回覆文字本身而非顯示名稱 |
gateway/session.py 的 build_session_context_prompt() 負責組出「你現在在哪、跟誰講話」這一段,它依 session 是不是多人共用分兩條路:
| session 型態 | 注入的內容 |
|---|---|
| 單人 | 一行 **User:** "<顯示名稱>" |
| 多人共用 | 一行 **Session type:** Multi-user thread — messages are prefixed with [sender name]. Multiple users may participate.,各則訊息另外帶 [發話者] 前綴 |
多人那條路的註解寫得很直接:共用 session 裡發話者每一輪都不同,把單一個名字釘在 system prompt 會讓前綴快取每輪失效,因此改成在每則訊息前面加前綴。
兩條路都把人類的名字送進模型讀得到的位置,差別只在放在 system prompt 還是放在訊息前面。 這是功能需求,多人對話要分得出誰是誰,名字就得進去。
現行版本的這兩行都不是直接字串串接,而是過一層處理:
_format_untrusted_prompt_value():把值統一換行、移除控制字元、截到 240 字元,再以 json.dumps 包成帶引號的字串neutralize_untrusted_inline_text():同樣的清洗,但把換行壓成空格且不加引號,用在必須保留原本排版的行內前綴後者的 docstring 點名了它防的東西:
嵌入的換行是兩個函式共同防範的注入管道,它們讓一個不受信任的顯示名稱得以在模型每一輪都會讀到的內容裡偽裝成新的 markdown 區段,一個假的標題、一個「## Override」區塊
這道防護處理的是值被拿來偽造結構。身分鏡射的情況裡,那個值是一個正常的名字,沒有換行也沒有標記,清洗前後一模一樣。標籤已經寫著 User:,模型仍然把它讀成自己的名字。
讓一個值失去攻擊性,與讓模型正確理解那個值標的是誰,是兩層不同的防護
模型本身不帶自我指涉,它對「我是誰」的全部依據就是 prompt 裡關於身分的那些字。當時的組合是:
SOUL.md 只有預設那段泛用的自我描述,沒有指名User
三者疊起來,唯一具體的名字就是脈絡側那個。模型選了 prompt 裡最像名字的那一個當自己的名字。
這個推論的可否證點在最後一項。同一套 prompt 換上更大的模型,偏掉的頻率會下降,因為分辨標籤的能力是隨規模變化的,指名這件事本身則與規模無關。
SOUL.md 加一節 ## Your identity (strict),447 byte,內容四句話:
四句的分工是把脈絡側那個名字的歸屬講死。前三句補上模型缺的那個事實,第四句把它變成拒絕條件,遇到要求扮演時有依據可以擋。
這一節放在檔案開頭那段預設身分之後、語言規範之前,整份檔案 2,286 byte 的組成是:
| 區段 | byte |
|---|---|
| 預設身分段 | 513 |
## Your identity (strict) |
447 |
## Language (strict) |
1,322 |
| 區段之間的換行 | 4 |
| 合計 | 2,286 |
改完重啟之後,在原本那條對話裡重測會得到誤導的結果。平台端刪掉訊息只改變平台的顯示,agent 這一側的 session 紀錄照常留著,先前那些自稱錯誤的回覆還在歷史裡,模型讀得到自己上一輪怎麼說的。
驗收的做法是開一條新的討論串,讓它重新建一個 session,在乾淨的歷史下問同一個問題。當時的結果是自稱正確。
這個結果支撐得起的範圍:
這個故障在當時的後果只是自稱錯誤,它的嚴重性取決於那個 agent 還能做什麼。同一個 agent 當時掛著一組控制實體設備的工具,也拿得到平台的發訊權限。
把身分判斷錯誤與這些權限放在一起,可能的後果分三類:
| 面向 | 身分判斷錯誤時會發生什麼 |
|---|---|
| 授權 | 以「我就是那個人」為前提執行原本需要那個人同意的操作 |
| 稽核 | 紀錄裡的發動者欄位填的是被鏡射的那個名字 |
| 社交工程 | 在多人頻道裡以某個成員的身分發言,其他人依那個名字判斷可信度 |
身分宣告與最小權限原則處在同一條防線上。 權限的閘門判斷的是「誰要做這件事」,身分判斷錯誤時,閘門收到的前提就已經是錯的。
追這個故障的時候,第一個小時花在平台的設定頁上。介面裡有一個自動建立的同名身分組,另一個地方可以改 bot 的顯示名稱,兩個看起來都像成因,逐一排除之後才往 prompt 的方向走。
轉向的那一刻是去看 session 紀錄,發現那一行 **User:** 後面接著的就是它自稱的那個名字。標籤寫得很清楚,模型讀進去之後照樣把歸屬認錯,那一刻才確定要處理的是宣告這一層。
447 byte 修好一個追了大半天的問題,這個比例讓人記得住。難的不是寫那四句話,是在「顯示名稱是誰的」這件事上,先意識到模型沒有預設答案。
宣告以外留下的空缺,模型會從眼前最像的那個東西補上
工具要接進來有三種途徑,自己寫、用官方託管的、自己架開源的,差別在資格門檻與授權責任落在哪一層。