當 LLM 遇到 Unicode Escape — 為什麼 AI 寫不出 \u200B?

· updated 2026-07-03·8 min read

你有沒有想過,請 LLM 幫你寫一個包含 \u200B 的 regex,它到底能不能寫對?

答案是:不能,至少不能直覺地寫對。這篇文章記錄了一個看似簡單的需求——在 JavaScript 裡寫一個過濾不可見 Unicode 字元的 regex——如何因為 LLM 的輸出機制而變成一場除錯之旅。

需求很單純

utils/string.ts 裡加兩個 function:一個移除不可見字元(Zero-Width Space、方向控制字元等),一個將非標準空白正規化為半形空白。

Regex 本身不難,就是一個 character class 塞滿 Unicode code point:

typescript
// 期望的寫法,跟專案裡既有的 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 貼進去:

typescript
const INVISIBLE_CHARS_PATTERN = /[\u200B\u200C\u200D]/g

結果:寫入檔案的是不可見字元。人打開檔案看到的是 /[]/g,完全無法理解這個 regex 在幹嘛。

嘗試 2:Bash heredoc

想說繞過工具,直接用 shell 寫:

bash
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' 會保留字面文字:

python
code = r'const pattern = /[\u200B]/g'

同樣的問題——raw string 的「不處理 escape」是 Python 層面的行為。但 \u200B 在 LLM 輸出送進 Python interpreter 之前就已經被解析了。Python 拿到的 input 裡,那個位置已經是一個不可見字元,raw string 自然也無法保留一個從未收到的反斜線。

嘗試 4:Regex literal 換行寫註解

在確認 escape 會被吃掉後,也試過把不可見字元直接放進 regex,改用換行 + 註解來補充可讀性:

javascript
// 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

python
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 執行拼接後,寫入檔案的就是你要的字面文字。

完整的寫入邏輯:

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 格式:

typescript
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

  1. MDN — Character escape: \n, \u{...} (JavaScript Regular Expressions)
  2. Python Documentation — Lexical Analysis: String and Bytes Literals — raw string literal 將反斜線視為字面字元,不處理 escape sequence
  3. MDN — RegExp (JavaScript Reference) — JavaScript regex 支援的 flags 為 d, g, i, m, s, u, v, y,不包含 verbose mode 的 x flag