一个真实到近乎日常的场景:接口返回 400,日志里写着 Unexpected token in JSON at position 1。打开请求体,肉眼看格式完全正确,缩进也对,花括号配对。把它粘进在线 JSON 校验器,同样报错,同样指着第 1 个位置。
第 1 个位置在 { 之前。那里什么都没有——除了一个 U+FEFF。这段 JSON 是从某个文档里复制出来的,编辑器保存时加了 BOM,JSON.parse 不认。
这类问题的共同特征是症状和肉眼观察对不上。你看到的和程序看到的不是同一串字节。搞清楚这些字符从哪来,排查会快很多。
污染源清单

从网页复制 最脏的来源。几类情况:
- 防复制/反爬脚本刻意在文字中间插入零宽字符,让抓取到的内容与页面显示不一致,或者让搜索引擎去重时区分开
- 前端框架为控制换行插入的 U+200B,尤其是长 URL、长代码串的展示区域
- 内容管理系统的富文本编辑器留下的历史残留,某些编辑器每次保存都会累积一层
- 复制的是 HTML 富文本,粘贴时携带了结构信息,转成纯文本过程中产生 U+00A0
从 PDF 复制 PDF 里的文字是按位置摆放的字形,不是连续文本流。复制时由阅读器重建成字符串,重建质量参差:
- 断词处的 U+00AD 软连字符被一并带出,
inter-national变成inter+U+00AD+national,搜索匹配不到 - 排版用的各种宽度空格:U+2002 en 空格、U+2003 em 空格、U+2009 窄空格、U+200A 极窄空格。它们看起来都是空格,但和 U+0020 不是一个字符
- 连字(fi、fl 这类合字字符)原样带出,
file里的fi是单个码点 U+FB01,搜file搜不到 - 行尾被硬换行截断,粘出来一行一断
从 Word / Office 复制 最典型的是 U+00A0 不换行空格。Word 在多种情况下自动使用它:数字和单位之间、某些标点前后、中英混排处。它显示为空格,宽度也差不多,但:
text.split(' ')切不开strip()默认不去掉它(Python 的str.strip()实际上会去掉,因为\xa0属于 Unicode 空白,但很多语言的 trim 只处理 ASCII 空白)- 用作字典 key 或做等值比较时,和普通空格版本永不相等
还有智能引号:Word 的自动更正把 " 换成 U+201C/U+201D,' 换成 U+2018/U+2019。粘进代码里,语法直接错,而且报错信息经常指不到点上。
从聊天软件复制 消息里的格式标记、emoji 的组合序列(内部含 U+200D)、某些客户端为对齐插入的字符。跨平台粘贴时更容易出问题,因为两端的富文本处理逻辑不一样。
从 AI 对话框复制 网页版的聊天界面同样是网页,前面网页那一档的问题它都有:Markdown 渲染层、代码块高亮、软换行控制。值得强调的是,这跟"模型在输出里嵌了水印"是两个不同的判断,中间隔着渲染层和复制行为,不能直接划等号(这一篇专门拆过这个推论)。
从终端 / 日志复制 带 ANSI 颜色转义序列的输出,复制到某些编辑器里转义序列会被吃掉一部分,剩下残片。
它们造成的真实故障

按现象归类,遇到下面这些先怀疑隐形字符:
代码类
- JSON 解析失败在 position 0 或 1 —— 大概率是 BOM
- Python
IndentationError或TabError,但缩进看起来完全一致 —— 可能是某行的缩进里混了 U+00A0,或者制表符和空格混用 SyntaxError: invalid character '"' (U+201C)—— 智能引号- Shell 脚本报
command not found,命令名看着没错 —— 命令名里有零宽字符,或者首行 shebang 前有 BOM - YAML 解析出诡异的键名 —— 键里带了不可见字符
- 变量名两次定义看起来一样却互不识别 —— 其中一个含零宽字符。这在 JavaScript 里尤其阴险,因为零宽连字符在某些位置是合法标识符字符
- Git diff 显示整行变更但看不出差别 —— 行尾或行内有不可见字符变动
数据类
- SQL
WHERE name = '张三'查不到,肉眼确认数据库里就是这三个字 —— 值里有 U+00A0 或零宽字符。用LENGTH()和HEX()对比一下就明白 - Excel 里数字右对齐变成左对齐、SUM 结果为 0 —— 单元格里混了 U+00A0 或零宽字符,被判定为文本。
ISNUMBER()返回 FALSE,LEN()比可见字符数多 - VLOOKUP 匹配不上,两列内容看起来完全相同 —— 同上
- 去重逻辑失效,同一条记录出现多次 —— 不可见字符让哈希不同
表单和账号类
- 表单校验说邮箱格式错误,看着完全正常 —— 地址里混了字符,正则不匹配
- 密码输入总是错 —— 从密码管理器或文档复制时带出了尾部的不可见字符或 U+00A0。这个尤其折磨人,因为输入框里是圆点,什么都看不出来
- 用户名注册显示已被占用,但搜索找不到 —— 已有账号名里有零宽字符
- 搜索框搜不到明明存在的内容 —— 索引里或查询里带了软连字符
其他
- 字符串长度与预期不符、字数统计对不上
- 短信/推送截断在奇怪位置,因为不可见字符占了配额
- 文件名在某些系统上无法创建或显示异常 —— 尤其是含双向控制符的文件名,显示出来的顺序和实际字节序不一致
排查顺序
遇到"肉眼没问题但程序报错"的情况,按这个顺序走通常几分钟就能定位。
第一步:看长度
最快的判据。len(s) 或 LEN() 和你数出来的可见字符数对不上,基本就锁定了。Excel 里用 =LEN(A1),SQL 里用 LENGTH()(注意有的数据库区分字节长度和字符长度,MySQL 里 LENGTH() 是字节、CHAR_LENGTH() 是字符,两个都看更有信息量)。
第二步:看字节
printf '%s' "$s" | hexdump -C
记几个常见的 UTF-8 序列,看一眼就认得出来:
| 字符 | UTF-8 字节 |
|---|---|
| U+FEFF BOM | ef bb bf |
| U+00A0 不换行空格 | c2 a0 |
| U+00AD 软连字符 | c2 ad |
| U+200B 零宽空格 | e2 80 8b |
| U+200D 零宽连字 | e2 80 8d |
| U+2019 右单引号 | e2 80 99 |
| U+201C 左双引号 | e2 80 9c |
Python 里更省事:s.encode('unicode_escape') 直接把非 ASCII 显示成转义形式。
第三步:定位是哪个码点
[(i, c, hex(ord(c))) for i, c in enumerate(s) if ord(c) > 127]
或者按 Unicode 类别筛:unicodedata.category(c) == 'Cf' 能一网打尽格式控制类字符(零宽系列、双向控制符、软连字符、标签字符都在里面)。
第四步:确认来源 知道是哪个字符之后,往回推来源基本是确定的。U+00A0 大概率来自 Word 或网页,U+00AD 来自 PDF,U+FEFF 来自编辑器保存,零宽空格来自网页或某个中间工具。找到源头才能避免下次再来一遍。
第五步:清理 分两档处理:
- 删除:零宽系列、BOM、软连字符、标签字符、双向控制符
- 归一化:各种宽度的空格统一成 U+0020,智能引号还原成直引号,全角标点按需转半角,连字拆开(U+FB01 →
fi)。这一档不能删只能换,删了会丢内容
需要注意的例外和第二篇里说的一样:阿拉伯语、波斯语、印地语文本里的 ZWJ/ZWNJ 是正字法需要,emoji 序列里的 ZWJ 和 U+FE0F 是内容的一部分,这些位置要保留。
处理少量文本,扔进扫描器看一眼再清是最快的路子:
从源头减少污染
几条成本很低的习惯:
- 往编辑器粘贴时用"粘贴为纯文本"(多数编辑器是 Ctrl+Shift+V),能挡掉富文本转换引入的一大批字符
- 编辑器统一保存为 UTF-8 无 BOM。VS Code 的状态栏能直接切换,Notepad++ 在"编码"菜单里
- 代码仓库里加一条 CI 检查,禁止 Cf 类字符出现在源文件中。用一条 grep 就够,命中直接失败。双向控制符尤其应该零容忍,因为它能让代码"看起来"和"实际执行"不一致
- 数据入库前做一次归一化:trim 掉两端所有 Unicode 空白(不只是 ASCII 空格)、剥离 Cf 类字符、空格统一。放在校验之前,能省掉大量后续的匹配失败
- 密码和密钥这类不能出错的字符串,手输或用密码管理器的填充功能,不要走剪贴板
这类问题烦在隐蔽,不烦在难解决。一旦养成"看不出毛病就先量长度"的习惯,大部分情况几十秒就能收尾。真正需要谨慎的反而是清理这一步——判断哪些字符是垃圾、哪些是内容的一部分,比找到它们更需要动脑子。
