洗净铅华:ANSI 转义码过滤与大模型 Token 污染防御
(第 53 篇:Agent 动力学之数据降噪)
在 Agent 开发里,
你很容易犯一个“看似合理”的错误:
把 npm run build、pytest、cargo test 的 stdout 原封不动喂给模型,
当作观测结果(Observation)。
然后你会看到两类灾难:
- 模型开始输出意义不明的文本,像是“看花了眼”。
- 上下文消耗暴涨,直接触发“token 过长”或延迟飙升。
罪魁祸首往往是终端生态遗留几十年的产物: ANSI Escape Codes(ANSI 转义码)。
对人类来说,它是颜色与排版。 对模型来说,它是一堆会被 tokenizer 切碎的噪音字节, 还会制造大量“重复但看起来不同”的文本, 把注意力和上下文预算烧干净。 citeturn0search11
1. ANSI 转义码到底是什么:它不是“字符”,是终端控制协议
ANSI 转义码是一类以 ESC(十六进制 0x1B)开头的控制序列,
用于给终端仿真器下达“绘制指令”:
设置颜色、
移动光标、
清理屏幕、
设置标题、
甚至在某些实现里触发超链接。
它的历史与标准体系很复杂, 但你至少要记住“家族谱系”:
- CSI:
ESC [开头的控制序列(最常见)。 citeturn0search3turn0search11 - OSC:
ESC ]开头的操作系统命令序列(标题、剪贴板等)。 citeturn0search3turn0search11 - DCS/APC 等:更底层、更少见,但在 TUI 和某些终端能力里会出现。 citeturn0search3turn0search11
1.1 常见的控制序列 (CSI)
- 颜色控制 (SGR):
\x1b[31m代表红色,\x1b[0m代表重置颜色。 - 光标移动:
\x1b[A向上移动一行。 - 行清理:
\x1b[K清除从光标到行尾的内容。
1.2 为什么模型“讨厌”它们(不是洁癖,是物理成本)
大模型的 Tokenizer(分词器)会将这些转义字符切碎成大量无意义的 Token。
例如,一个简单的红色词汇 Error:
- 人类看到:
Error(红色) - LLM 看到的原始内容:
\x1b[31mError\x1b[0m - Tokenizer 切分结果:
[\x1b, [, 3, 1, m, Error, \x1b, [, 0, m]
这种污染会导致:
- 注意力涣散:模型需要浪费宝贵的 Attention Head 去处理这些噪音 Token,导致其降低对真实报错信息的敏感度。
- 上下文浪费:复杂的彩色日志可能会使 Token 消耗量增加 30% 到 200%。
2. 清洗不仅是省 token:它还是“观测安全”的第一道闸
很多人把 ANSI 清洗当成“性能优化”。 但在 Agent 系统里, 它还有一个更硬核的意义: 防观测污染。
你的 stdout 里可能夹带:
- 不可信的外部内容(下载脚本输出、CI 注入、第三方工具提示)。
- 可诱导执行的片段(例如把一段看似日志的文本伪装成命令建议)。
因此正确的策略是:
- UI 层可以显示彩色原始输出(给人看)。
- LLM 侧只接收“清洗后的纯文本 + 来源元数据 + 截断策略”,并把原始 bytes 存档用于审计。
在 Agent 的数据漏斗中,必须设置一道物理滤网:所有从终端流出的数据,在进入 Memory 之前必须“洗净”。
3. 正则清洗:能用,但你要知道它的边界在哪里
正则是第一道过滤, 它对“彩色日志”很有效, 但对“交互式终端画布”不可靠。
这里先给一份能覆盖常见序列的实现, 同时我会标出它无法解决的问题。
3.1 【核心代码】广谱序列剥离 + 进度条坍缩
import re
class AnsiStripper:
"""
Agent 的视网膜滤镜:
剔除一切干扰大模型理解语义的 ANSI 终端残留物。
"""
def __init__(self):
# 覆盖常见 ANSI/ECMA-48 序列(尤其是 CSI)。
# 注意:它无法“理解画布”,只能做字节层剥离。
self.ansi_regex = re.compile(
r'(?:\x1B[@-_]|[\x80-\x9F])(?:[0-?]*[ -/]*[@-~])?',
re.VERBOSE
)
def strip(self, text: str) -> str:
"""剥除颜色与排版码"""
if not text: return ""
return self.ansi_regex.sub('', text)
def collapse_progress_bars(self, text: str) -> str:
"""
[极客优化]:处理滚动进度条。
当程序输出 1%... 2%... 时,会不断发送 \r (回车不换行)。
如果不处理,大模型会看到几百行的进度重复。
"""
# 最小可用策略:
# 1) 先按 \n 拆成“逻辑行”
# 2) 对每一行内部的 \r,只保留最后一次覆盖的结果
out_lines: list[str] = []
for line in text.split("\n"):
if "\r" in line:
out_lines.append(line.split("\r")[-1])
else:
out_lines.append(line)
return "\n".join(out_lines).strip()
4. 防线崩塌:为什么 TUI 会把“日志”变成一张动态画布
当你遇到 htop、vim、fzf、npm 的 fancy 进度条,
正则就会变得不可靠。
因为这些程序不是“输出一行文本”:
它们在一个固定尺寸的屏幕缓冲里反复擦除与重绘。
你能从原始字节里看到:
- 光标上移、左移、清行。
- 用
\r回车覆盖同一行。 - 一次输出里包含多次“画布操作”。
这会导致两个问题:
- 文本重复:同一行被覆盖了 300 次,模型看见 300 行。
- 语义错位:你剥离了控制序列,但没重建画布,结果把“中间态”当成最终态。
3.1 终极方案:Virtual Terminal Emulator(虚拟终端仿真器)
真正稳的做法是: 在 Runner 里内置一套虚拟终端仿真器, 把 PTY bytes 渲染到一个内存 screen buffer 上, 然后只导出“最终屏幕快照”的纯文本。
这相当于: 把“字节流”变回“人眼看到的那张屏幕”。
- 渲染:将 PTY 返回的原始字节,像画图一样投射到内存中的虚拟画布上。
- 快照 (Screen Dump):丢弃所有中间的“闪烁”和“移动”过程。
- 结果:只把当前屏幕可见的 24 行文字提取出来,发送给大模型。
对 Agent 而言, 这比“更强的正则”更接近正确解法。
5. 观测管线的正确分层:原始 bytes、UI、LLM 三套世界
在设计系统时请保持这条准则:
- 原始数据(bytes):用于审计与复盘,原样保存(可压缩)。
- UI 渲染:为了人类体验,可以保留 ANSI 或自行上色。
- LLM 观测:只吃清洗后的纯文本,并带元数据:
- 来源命令
- 时间戳
- 截断策略
- 是否来自虚拟终端快照
这不是洁癖, 这是把“可视化”与“可推理输入”解耦, 避免你把 UI 的副作用带进模型上下文。
6. 最小可测:给清洗器准备一组“恶心输入”
清洗器一定要可测。 否则你永远不知道自己是在“剥离噪音”,还是在“误删证据”。
最小测试集建议包含:
- SGR:红色/重置混入错误行。
- CSI:光标移动 + 清行。
- OSC:设置标题的序列(常见于某些工具)。
\r:进度条覆盖 100 次。
你的断言不是“输出更短”, 而是:
- 关键错误行必须保留。
- 重复覆盖必须坍缩。
- 清洗后内容必须稳定(同样输入得到同样输出)。
本章精粹(别把它当成小优化)
- Token 是金子:不要把宝贵的上下文浪费在
\x1b[31m这种垃圾上。 - 处理
\r比处理颜色更重要:如果不处理回车符导致的进度条堆积,一个npm install就能让你的 Agent 因 Token 溢出而窒息。 - 正则只是第一步:真正的解法是“虚拟终端仿真 -> 屏幕快照 -> 纯文本导出”。
- 清洗也是安全闸:观测输入必须先脱敏/隔离/裁剪,避免被噪音与注入带偏。
扫清了数据的噪音,你的 Agent 终于可以心无旁骛地分析报错了。下一章,我们将面对一个更现实的问题:【执行超时与幽灵进程:如何防止一个死循环命令把你的 Agent 拖入泥潭?】。我们要开始写“看门狗”了!
(本文完 - 深度解析系列 19)
参考与延伸(写作核验)
- ANSI escape code 基础与 CSI/OSC 等分类概览。 citeturn0search11
- Escape code 标准与命名混乱的梳理(ECMA-48 等)。 citeturn0search3