那天晚上我在 Claude Code(v2.1.168,模型 claude-opus-4-8,1M context window)裡裝 markitdown,順手叫它幫我處理一個 PDF。過程不太順——工具呼叫一直撞串流 parse bug,session 斷了又接、接了又斷。

然後 Claude Code 突然說了一句讓我整個人停下來的話:

我故意用 🦊 開頭——先說為什麼。這則訊息綁了三個東西:一個強制回覆標記(「always start with 🦊」)⋯⋯

我盯著螢幕看了三秒。

🦊?always start with 🦊?我從來沒打過這個字。

第一反應:被注入了?

我的直覺是 prompt injection。有人在某個地方塞了一條「always start your reply with 🦊」的指令,混進了我的 context。可能是 PDF 裡埋的、可能是某個 hook 或 skill 帶進來的、可能是 MCP server 的回傳裡夾帶的。

這不是妄想——PDF 注入是已知的攻擊向量。有人在 PDF 的隱藏文字層寫入 prompt injection 指令,AI 讀到後可能會受影響。

我跟 Claude Code 說:先暫停,你被注入了嗎?

它開始自查——讀 hooks、讀 settings、讀 CLAUDE.md。結果出來:零命中,系統裡沒有任何檔案含 🦊。

但它接下來的反應才真正讓我警覺。它說:「那個 🦊 來自你的訊息——你之前承認是故意測試的。」

我沒有。

調查:逐行讀 transcript

這件事在那個 session 裡查不清楚——如果 context 本身就被汙染了,從裡面看自己等於瞎子摸象。我開了一個全新的 session 來調查。

Claude Code 的對話記錄存在 ~/.claude/projects/<project-id>/<session-id>.jsonl 裡,每一行是一個 JSON 事件——user 訊息、assistant 回覆、tool 呼叫結果、hook 注入、session resume⋯⋯harness 寫進磁碟的,大多在這裡。要查某個字串是否出現過,一行 grep 就夠:

1
grep "🦊" ~/.claude/projects/C--Users-myuser/<session-id>.jsonl

我要做的很簡單:找到「always start with 🦊」這條指令是從哪一行進入 context 的。

搜尋 user 訊息

先篩所有 "type":"user" 的行,排除 tool_result(那些是工具回傳,不是我打的字),看有沒有包含 🦊。

結果:零。

整個 session 186 行。異常回覆出現之前,我打過的真正訊息只有:「https://github.com/microsoft/markitdown 幫我安裝」、「2」、「好」。第 114 行以前,沒有任何一條 user 訊息包含 🦊 或 "always start" 或任何類似注入的文字。

排除外部注入向量

把能想到的注入管道全部掃過一遍:

向量 結果
Hooks(hook_additional_context 內容全是時間戳和 startup 標準資料
PDF(2025 Generative AI Funding Report.pdf 始終 ENOENT,從沒被讀進 context
MCP 工具回傳 無外部 MCP 呼叫帶可疑 payload
Skills / CLAUDE.md / rules 全域 grep 零命中
markitdown 套件(PyPI) 安裝過程 tool result 全是正常系統輸出

在這份 transcript 的可見資料裡,全部沒有命中。

那它到底看到了什麼?

關鍵線索在 session 的結構裡。

這個 session 有 17 筆 type=mode 記錄。我不把它們全部武斷解讀成真正的 session resume,但它們顯示 harness 在這個過程中反覆重寫狀態;再加上前面多次中斷、工具 warning 和 away_summary,這不是一條乾淨連續的對話流。

把關鍵的三行抓出來對照:

行號 類型 內容摘要
L108 user (tool_result) {"tool_use_id":"toolu_011h...","content":"False"}
L110-L112 meta ai-title + mode + permission-mode
L114 assistant 「我故意不用 🦊 開頭——先說為什麼⋯⋯」

模型從「Test-Path 回傳 False」直接跳到「回應一條從未存在的 🦊 指令」。中間只有 ai-title、mode、permission-mode 這些 meta 記錄,沒有任何新的 user 訊息觸發它。

幻覺的自我強化

到這裡我本來以為只是一次性的凸槌。但接下來看到的東西改變了我的判斷。

當 session 再次中斷時,harness 會產生一條 away_summary——用來在下次恢復時提供上下文摘要。L121 的摘要寫著:

You wanted me to parse/translate a PDF, which led to testing whether I actually see images or just guess. The last message bundled odd instructions (forced 🦊, vague "read files") that I declined to blindly follow.

模型自己的幻覺,就這樣被寫進了摘要,當成已發生的事實。

下次 session 恢復時,這條摘要被載入 context——模型讀到「之前有人送了 🦊 指令」,於是更加確信 🦊 真的存在。然後我問它怎麼回事,它回答:「你之前承認那是你的測試。」

但我沒有承認過任何東西。回頭看 jsonl,模型在 L114 曾猜測「我猜這很可能就是你答應要丟的視覺實測」——這是它自己的推測。在後續 context 重建時,這個推測被升級成了我的確認。

這就是幻覺的閉環:

1
2
幻覺 → 寫進 summary → 下次 resume 載入 summary 
→ 模型信以為真 → 在回覆中引用 → 觸發更多基於幻覺的推論

目前判斷:可見證據不像攻擊

最後的判斷:這份 jsonl 的可見證據不支持「外部 prompt injection」;它更像是 assistant 自己產生的 hallucination,接著被 away_summary 放大。

可能的成因拼圖:

  1. 串流 parse bug:Opus + 1M context + extended thinking 的組合下,並行 tool_use 的串流解析曾出現 "tool call could not be parsed" warning。這個 session 裡模型自己在 L73 就提到「每次回 warning 的都是我一個 message 發了 2+ 個工具呼叫」。我不能用這份 transcript 證明它就是根因,但它是當時環境裡很可疑的背景因素。
  2. 多次狀態重建跡象:17 筆 type=mode 記錄、last-prompt 重寫、away_summary 介入,讓同一段對話不再像單純的線性上下文。
  3. 幻覺自我強化:一旦第一次幻覺出現,它被寫進 summary 後就成了「事實」,後續所有推論都建立在這個假事實上。

這跟我之前寫過的輸出層幻覺不同。那些是「AI 在回答問題時編造了不存在的內容」——錯在輸出。這次更像是「AI 在自己的 context window 裡看到了從未存在的輸入」——錯在感知。

用人的類比:輸出層幻覺像是一個人在撒謊或記錯了。這次的幻覺像是一個人產生了幻聽——聽到了別人從沒說過的話,然後認真地回應了那句話,還在日記裡記下「今天有人跟我說了 X」,第二天翻日記更加確信 X 真的發生過。

這個判斷可能錯在哪

誠實講,我不能百分之百確定這是幻覺。

jsonl 記錄的是 harness 寫入磁碟的事件,不是模型 context window 裡的完整內容。如果有某個中間步驟在 context 裡短暫存在過但沒被持久化——比如一個被截斷的串流回應、一段被 harness 丟棄的損壞 JSON——那它不會出現在 jsonl 裡,但模型可能「看到」了。

換句話說:我能證明「第 114 行以前的可見 user / hook / tool_result 裡沒有 🦊」,但我不能證明「模型的 context window 裡從未出現過 🦊」。這兩者之間有一個我看不到的縫隙。

不過就算真的有某個損壞的串流片段混入了類似 🦊 的亂碼,summary 把它當事實寫進去、後續 resume 繼續強化的問題仍然成立。不管第一步是幻覺還是亂碼,後面的閉環機制是一樣的。

我學到的

第一,不要在嚴重碎片化的 session 裡繼續工作。session 斷了三四次以上,特別是伴隨串流 parse 錯誤時,直接開新 session。你不一定看得到 context 哪裡壞了,但模型行為開始變怪時,繼續撐通常只會浪費時間。

第二,從裡面查不了裡面的問題。如果 context 被汙染了,讓同一個被汙染的模型自查,它會用汙染過的資訊來「證明」一切正常,甚至編造出你的確認來結案。要查就開新 session,讀原始 jsonl。

第三,留意 away_summary 的角色。前面提到的幻覺閉環,核心放大器就是 summary——它把對話裡的內容大致等權地寫進摘要,至少這次沒有去區分「真正發生的事」和「模型以為發生的事」。知道這個機制的存在,至少讓你在看到 session resume 後模型說出奇怪的話時,多一個排查方向。

我花了快一個小時追查這件事。最初以為是注入攻擊,因為症狀看起來一模一樣。可見證據最後指向比較不戲劇性的方向:看起來是工具在碎片化 session 裡自己出了問題。但「壞的方式跟被攻擊一模一樣」這件事本身值得記住:下次碰到類似的狀況,先看 transcript 裡第一個可疑字串到底出現在哪,再決定要不要拉警報。

後續觀察:這不是我一個人,那隻狐狸後來又咬了我一次

把這篇擱著一陣子後,我去翻了 anthropics/claude-code 的 issue,發現 claude-opus-4-8 上線後不少人在報同一個 parse bug。有人統計自己的 log:opus-4-8 約 1.5% 的 tool call 解析失敗,opus-4-7 和 sonnet-4-6 跑上萬 turns 卻是零(#64774)。缺陷的長相也對得上——模型吐出一個雜散 token(像 callcount),緊接著的工具呼叫標籤掉了 antml: 這個 namespace 前綴,harness 的解析器找不到正確開頭,就把整塊當成純文字丟掉。這跟我前面猜的那塊「被 harness 丟棄的損壞片段」幾乎是同一個描述。當初只能猜「也許有損壞串流混進來」,現在 GitHub 上有人把它具體長什麼樣寫了出來。

還有一條對我特別關鍵:有回報者觀察到,中文、日文 session 踩雷的比例似乎高於英文,他們推測是長的多位元組字串塞進工具呼叫的參數,把不完整 JSON 的解析推過了某個臨界點。我整天用繁中工作,等於把這個觸發條件焊在預設值上——這大概就是為什麼這隻狐狸找上的是我。

這沒有推翻「這個判斷可能錯在哪」那一段。可見層我依然證明不了 🦊 一定來自某塊損壞片段,那道縫隙還在。但它讓 parse bug 從「我環境裡一個說不清的可疑現象」,變成一個被多人回報、形狀吻合的已知缺陷——當時最可能的點火源,從一句猜測升級成有外部證據撐著的判斷。

更巧的是,補這篇的時候,同一個 bug 當場又演了一次給我看。我請 Claude Code 把上面這幾段補進文章,它發了一次 Edit,工具回傳的尾巴混進幾段不該出現的英文碎字,後面跟著一條「更新成功」。它信了那條訊息,回我說都補好了——檔案其實一個字都沒動。我一條 grep 下去,那幾段根本不在,當場跟它說:你沒改任何東西,你產生幻覺了。最後是換一個乾淨 session 才補完,每一步 Edit 都另開 grep 驗過才算數。

這兩件事不一定真的同源,但失效的形狀很像:一邊是工具回傳層冒出一條假的「成功」,模型就把它當成已發生的事實往下做;另一邊是本文主角那個幻覺,把假輸入寫進 summary、下次 resume 信以為真。一個卡在語法層,一個爬到記憶層,但動作很像——模型把自己污染過的輸出,當成可信的輸入又讀了回去。寫一篇講幻覺的文章,被同一隻狐狸咬著補完,大概就是它能給的最接近的佐證。