當 LLM 遇到 Unicode Escape — 為什麼 AI 寫不出 \u200B?
你有沒有想過,請 LLM 幫你寫一個包含 \u200B 的 regex,它到底能不能寫對?
答案是:不能,至少不能直覺地寫對。這篇文章記錄了一個看似簡單的需求——在 JavaScript 裡寫一個過濾不可見 Unicode 字元的 regex——如何因為 LLM 的輸出機制而變成一場除錯之旅。
需求很單純
在 utils/string.ts 裡加兩個 function:一個移除不可見字元(Zero-Width Space、方向控制字元等),一個將非標準空白正規化為半形空白。
Regex 本身不難,就是一個 character class 塞滿 Unicode code point:
// 期望的寫法,跟專案裡既有的 regex 風格一致
const pattern = /[\u200B\u200C\u200D\u200E\u200F]/g
這裡的 \u200B 是 JavaScript regex 支援的 Unicode escape 語法1,在 source code 裡就是字面的六個字元:反斜線、u、2、0、0、B。Runtime 才會被 regex engine 解析成對應的 Unicode 字元。
看起來很直覺,但問題出在:讓 LLM 輸出這六個字元,比想像中困難得多。
問題本質:LLM 的輸出不是「文字」
當你請 LLM 輸出 \u200B 時,它的 token 序列在送達工具(檔案寫入、shell 命令)之前,會先經過一層序列化處理。在這個過程中,\u200B 被解讀為一個 Unicode escape sequence,然後被解析成實際的 U+200B 字元——也就是一個 Zero-Width Space。
寫進檔案的不是你要的六個可見字元 \u200B,而是一個肉眼看不見的零寬空白。
用 hex dump 檢查就會發現:
# 期望看到的(字面文字)
5c 75 32 30 30 42 → \u200B
# 實際寫入的(不可見字元)
e2 80 8b → (UTF-8 encoding of U+200B)
這就是為什麼不管怎麼看 source code,那些 regex 裡面的引號之間看起來都是「空的」——字元確實在那裡,但它是不可見的。
踩過的每一個坑
嘗試 1:直接用 Edit / Write 工具
最直覺的做法——直接把 code 貼進去:
const INVISIBLE_CHARS_PATTERN = /[\u200B\u200C\u200D]/g
結果:寫入檔案的是不可見字元。人打開檔案看到的是 /[]/g,完全無法理解這個 regex 在幹嘛。
嘗試 2:Bash heredoc
想說繞過工具,直接用 shell 寫:
cat >> file.ts << 'EOF'
const pattern = /[\u200B]/g
EOF
即使用了 'EOF'(single-quoted heredoc delimiter,shell 不會做變數展開),\u200B 在到達 shell 之前就已經被解析了。Heredoc 拿到的 input 裡面已經是不可見字元。
嘗試 3:Python raw string
Python 的 raw string r'...' 不處理 escape sequence2,理論上 r'\u200B' 會保留字面文字:
code = r'const pattern = /[\u200B]/g'
同樣的問題——raw string 的「不處理 escape」是 Python 層面的行為。但 \u200B 在 LLM 輸出送進 Python interpreter 之前就已經被解析了。Python 拿到的 input 裡,那個位置已經是一個不可見字元,raw string 自然也無法保留一個從未收到的反斜線。
嘗試 4:Regex literal 換行寫註解
在確認 escape 會被吃掉後,也試過把不可見字元直接放進 regex,改用換行 + 註解來補充可讀性:
// prettier-ignore
const pattern = /[
<U+200B> // Zero-Width Space
<U+200C> // Zero-Width Non-Joiner
]/g
這踩到另一個坑:JavaScript regex 沒有 verbose mode3。不像 Python 的 re.VERBOSE 或其他語言的 x flag,JS 的 [...] character class 裡面的空白、換行、/ 都會被當成要匹配的字面字元。這個 regex 會去匹配空格、換行、斜線、字母等一堆不該匹配的東西。
解法:讓 Python 自己組裝反斜線
既然問題出在 LLM 輸出層會解析 \uXXXX 格式的文字,解法就是不要讓這個 pattern 出現在 LLM 的輸出裡。
用 Python 的 chr() 函式取得反斜線字元,再用普通字串拼接組出 \u200B:
bs = chr(0x5C) # 0x5C = backslash character
def u(code):
"""組出像 \u200B 的字串,但不觸發任何 escape 解析"""
return f"{bs}u{code}"
# u("200B") → "\u200B" (六個字面字元)
LLM 輸出的是 chr(0x5C) 和字串 "200B" 的拼接指令,完全沒有 \uXXXX 這個 escape sequence 出現在輸出流裡,自然不會被任何一層解析。Python 執行拼接後,寫入檔案的就是你要的字面文字。
完整的寫入邏輯:
bs = chr(0x5C)
def u(code):
return f"{bs}u{code}"
invisible = (
f"{u('200B')}{u('200C')}{u('200D')}{u('180E')}"
f"{u('200E')}{u('200F')}"
f"{u('202A')}-{u('202E')}"
# ...
)
regex_line = f"const INVISIBLE_CHARS_PATTERN = /[{invisible}]/g"
最終寫入檔案的內容,每個 code point 都是可讀的 \uXXXX 格式:
const INVISIBLE_CHARS_PATTERN = /[\u200B\u200C\u200D\u180E\u200E\u200F\u202A-\u202E]/g
為什麼只有反斜線有這個問題?
嚴格來說,這不是反斜線的問題,而是 \uXXXX 這個 pattern 在多個層級都會被辨識為 escape sequence:
- JSON 序列化(LLM tool call 的 input 是 JSON 格式)
- JavaScript / TypeScript 引擎
- 某些 terminal emulator
只要輸出流經過任何一個會處理 Unicode escape 的層級,字面的 \u200B 就會被轉換成實際字元。chr(0x5C) 的技巧之所以有效,是因為它把「反斜線」這個字元的產生延遲到了 Python runtime——一個 LLM 輸出層碰不到的地方。
結論
這個問題的本質是 LLM 的輸出管線會對特定 pattern 做 escape 處理,而 Unicode escape sequence 恰好是其中之一。當你需要 LLM 在檔案中寫入 \uXXXX 這種字面文字時,直覺的方法全部會失敗。
解法不複雜,但需要理解問題發生在哪一層。一旦意識到 LLM 的輸出在到達任何工具之前就已經被處理過了,答案就很清楚:不要讓目標 pattern 出現在 LLM 的輸出裡,改用程式碼在 runtime 組裝它。
這大概也是少數你會請 AI 寫 code,結果反而是用 code 去生出 code 的情境。
環境資訊
- Claude Code(Claude Opus 4.6)
- TypeScript(JavaScript regex engine)
- Python 3(用於檔案寫入)
本文撰寫時間:2026 年 4 月,技術版本可能隨時間更新,請以官方文件為準。
Reference
- MDN — Character escape:
\n,\u{...}(JavaScript Regular Expressions) ↩ - Python Documentation — Lexical Analysis: String and Bytes Literals — raw string literal 將反斜線視為字面字元,不處理 escape sequence ↩
- MDN — RegExp (JavaScript Reference) — JavaScript regex 支援的 flags 為
d,g,i,m,s,u,v,y,不包含 verbose mode 的xflag ↩