第一次意识到“发给 LLM 的文本也需要先洗一遍”,是在一次很普通的调试场景里。我准备把一段带着完整报错的日志贴给模型分析,粘贴前扫了一眼,日志里有内网 IP、数据库连接串片段、还有一线内部目录结构。如果就这么发出去,模型能不能分析先不说,敏感信息已经准备离开本机了。后来我把这件事固化成固定动作:任何要发给模型的文本,先在本地过一遍 scrubber,把该换成占位符的内容换掉,再进入模型。这就是标题里 “A local scrubber for text you're about to send to an LLM” 这类项目想解决的问题。
我理解的 scrubber 不是“提示词优化器”,也不是“内容过滤器”。它更像一道装在发送边界上的本地闸门:输入端是你准备粘贴或通过 API 发送的原始文本,输出端是经过脱敏、占位化、保留结构之后的干净文本。它不负责判断模型回答好不好,只负责确保“真正要发出去的文本”是你能控制的。
这篇文章我会从为什么需要本地清洗开始,拆出一个最小可用的 scrubbing 流程,然后结合真实场景讲怎么把它放进手动粘贴、API 调用和批量文档处理里。最后会讲清楚它的边界:哪些事它擅长,哪些事它一定做不到。
1. 在按下发送键之前,你缺的不是提示词,而是一道本地闸门
很多人对 LLM 工作流的理解,是从“写好提示词”开始的。提示词确实重要,但如果你把一段包含密钥、用户手机号、内部服务地址的文本直接送进模型,那么无论提示词写得再好,数据边界也已经破了。
1.1 大多数人是在什么时候意识到要清洗文本的
最常见的触发点,是一次“本来不该发生”的粘贴。
比如一个人把线上故障的日志复制下来,准备让模型帮忙定位。日志里通常包含大量附带的敏感信息:出口 IP、内网主机名、用户邮箱、请求路径、Token 片段。这些东西对排查问题的模型来说不一定都有用,但它们确实已经出现在文本里了。
另一个常见场景是给模型喂项目文档。很多项目文档会写清目录结构、数据库表名、内部服务名,甚至包含云厂商的 AccessKey 片段。文档本身可能是给团队看的,可一旦整份文档被发送到外部模型,权限边界就完全失效了。
还有一种情况是团队里加入 Agent 化工作流。现在很多人会把“项目说明、代码规范、工具调用方式”整理成一份说明文档,再让 Agent 按文档执行。这个思路很好用,但很多人忽略了:说明文档里一旦写入内部信息,Agent 每次调用外部模型时,这些信息都会被一起带到模型端。
所以真正的问题不是“我应不应该用模型处理这些数据”,而是“我在发送前有没有对文本做主权的控制”。本地 scrubber 不改变你对模型的使用方式,它只在你和模型之间增加了一道可控的、可审计的闸门。
1.2 这个项目真正做的,是在“文本流向模型”之间加一道可审计闸门
如果只做一次手动清理,当然不需要写工具。手动清理的问题是:今天你可能会记得删除 IP,明天你可能就忘了把 Token 换掉;今天你会把邮箱改成占位符,明天你可能会把整段 SQL 直接发出去。
这类 local scrubber 项目的核心价值,是把“记得清理”从随机动作变成固定流程。它有几个关键特征:
- 在本地运行,文本离开本机之前已经完成检查;
- 基于规则和配置,结果可预期、可复现;
- 输出不是把敏感信息简单删除,而是替换成模型能理解的占位符;
- 同一个清洗函数可以被命令行、API 调用和批量任务复用。
一句话概括主判断:本地 scrubber 真正解决的不是“模型会不会泄露文本”的问题,而是“每次发送给模型的内容能否被控制、审计和重建信任”的问题。
2. 先拆开 scrubber:它到底清洗什么,又该保留什么
很多初学者会以为 scrubber 就是“把敏感词替换掉”。真正落地时你会发现,“什么需要清洗”和“什么需要保留”同样重要。
2.1 需要处理的四类信息,不是所有敏感词
从常见实践看,需要清洗的信息可以分成四类。我先用表格列出,后面再解释为什么不能只按“敏感词”来处理。
| 信息类别 | 常见例子 | 推荐处理方式 | 说明 |
|---|---|---|---|
| 个人身份信息 | 姓名、邮箱、手机号、住址、身份证号 | 替换为[EMAIL]、[PHONE]等占位符 | 这类信息有相对明显的结构,适合用正则规则捕获 |
| 密钥与令牌 | API Key、JWT、私钥、数据库连接串 | 替换为[API_KEY]、[JWT]、[PRIVATE_KEY] | 这类内容一旦泄露影响最大,必须优先处理 |
| 内部环境线索 | 内网 IP、内部域名、绝对路径、用户名 | 替换为[IP]、[INTERNAL_PATH] | 单个字段单独看可能不敏感,组合起来就能还原网络拓扑 |
| 业务上下文 | 客户名、合同号、未公开指标、项目代号 | 按配置中的 blocklist 替换 | 这类内容没有固定规则,通常需要维护一份自定义名单 |
注意,这里没有使用“敏感词”这个概念。原因是:很多真正敏感的信息并没有固定关键词。比如一个内部项目代号project-hydra,把它放进关键词列表里,它就是一个业务敏感词;如果只靠通用规则,它永远匹配不到。
所以常见 scrubber 的规则体系通常是两层:
- 通用正则规则:负责识别邮箱、手机号、IP、JWT、私钥等有结构的信息。
- 自定义业务规则:由使用者维护,负责识别团队内部才知道的代号、服务名、合同号等。
2.2 清洗后的输出仍要能被模型读得懂
清洗不是把一段文本变成满屏星号。如果把敏感信息全部替换成***,模型可能丢掉大量上下文,甚至没法理解这段文本在说什么。
正确做法是“占位化”。
比如原文:
用户 user@example.com 在 192.168.1.10 上报了一个连接错误。清洗后更合理的输出是:
用户 [EMAIL] 在 [IP] 上报了一个连接错误。模型仍然能理解“有一个用户、一个 IP、一个连接错误”,只是不知道具体是谁、具体是哪个地址。对大多数分析类任务来说,这种信息完整度已经够了。
如果文本是 JSON 结构,占位化还要保证 JSON 依然合法。例如:
{ "user_email": "user@example.com" }清洗后应该是:
{ "user_email": "[EMAIL]" }而不是破坏引号和缩进。这一点后面在排查部分会再提到。
3. 一个最小可用的本地 scrubber 怎么写
下面给的实现不是某个官方的完整项目,而是这类工具最常见的骨架。你完全可以用它作为起点,再根据自己场景扩展。
3.1 环境与设计:先跑通,再扩展
建议用 Python 3.8 以上版本,不需要安装第三方库。先保证“一条命令能跑通”,再去加配置、日志和测试。
核心流程可以很直接:
- 把
\r\n统一成\n,避免 Windows 和 Linux 换行差异影响匹配; - 先处理自定义业务规则,再跑通用正则规则;
- 对命中的内容做占位替换;
- 输出清洗后的文本,同时可输出一份统计信息。
不要一开始就做太复杂的配置。先写一个最简函数,把它放进实际发送链路里,发现问题后再迭代。
3.2 核心代码:用正则把 PII 和令牌占位化
下面是一份可以立刻运行的示例。先定义不同信息的正则规则:
import re PII_PATTERNS = { "EMAIL": re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+"), "PHONE": re.compile(r"(?<!\d)1[3-9]\d{9}(?!\d)"), "IP": re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b"), "JWT": re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"), "API_KEY": re.compile(r"\b(?:sk|pk|ghp|AKIA)[A-Za-z0-9_-]{10,}\b"), "PRIVATE_KEY": re.compile( r"-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----.*?-----END (?:RSA |EC |DSA )?PRIVATE KEY-----", re.S ), "PATH": re.compile(r"(?<=[\s\"'])(?:/home|/Users|/var|/etc|/opt|C:\\)[\w./\\-]+"), }然后写一个占位化函数:
def normalize_text(text: str) -> str: return text.replace("\r\n", "\n") def scrub(text: str) -> str: text = normalize_text(text) # 第一步:按业务规则替代表达 # 比如: # text = text.replace("project-hydra", "[PROJECT_NAME]") # text = text.replace("internal-service", "[INTERNAL_SERVICE]") # 第二步:按通用正则规则占位化 for name, pattern in PII_PATTERNS.items(): placeholder = f"[{name}]" text = pattern.sub(placeholder, text) return text if __name__ == "__main__": sample = "user a@example.com from 192.168.1.10, token sk-1234567890abcdef" print(scrub(sample))这段代码的运行结果是:
user [EMAIL] from [IP], token [API_KEY]这里有几个细节值得注意:
- 占位符用的
[EMAIL]、[IP]而不是<EMAIL>,是为了避免被 Markdown 渲染成 HTML 标签。 PRIVATE_KEY用了re.S,因为多行私钥内容需要跨行匹配。- 正则规则命中后直接替换成统一占位符,模型失去原始值,但仍能看到信息类别。
3.3 放行与回填:不要把所有信息都抹成黑洞
有时候,一段文本里的某个邮箱其实是公司对外公开的公共邮箱,IP 也是公网固定 IP,不需要清洗。这时你需要一个“放行名单”或“业务替换表”。
比如下面这种写法:
SAFE_REPLACEMENTS = { "public@example.com": "[PUBLIC_EMAIL]", "1.2.3.4": "[PUBLIC_IP]", } def scrub_with_config(text: str, replacements: dict[str, str] | None = None) -> str: text = normalize_text(text) replacements = replacements or SAFE_REPLACEMENTS # 先做业务替换,再做通用规则 for raw, placeholder in replacements.items(): text = text.replace(raw, placeholder) for name, pattern in PII_PATTERNS.items(): placeholder = f"[{name}]" text = pattern.sub(placeholder, text) return text这里的“放行”不是说原样保留,而是把它变成一个明确的公共占位符。这样你在审计日志里仍然能知道这里有信息被处理过,只是它没有被当成敏感字段暴露出去。
4. 三个真实落地场景:从手动粘贴到 API 中间层
同一个 scrubber 在不同场景里用法不一样。我给几个常见落地路径。
4.1 交互式使用:先清洗,再复制到模型对话框
最简单的方式是把它做成命令行工具:
python scrubber.py input.txt output.txt如果你只是临时粘贴一段文本,也可以做成一个简单交互脚本:
import sys def main(): text = sys.stdin.read() print(scrub(text)) if __name__ == "__main__": main()然后在终端里:
cat raw_log.txt | python scrubber.py这样你复制到模型对话框的就是清洗后的版本。
这个场景适合个人开发者、日常学习和临时调试。它解决的问题不是“阻止你发送敏感信息”,而是让你在复制之前多了一道检查。
4.2 API 调用:用装饰器统一包一层清洗逻辑
如果你是通过 API 调用模型,手动复制的方式就不够用了。更合理的做法是把清洗逻辑放在 API 调用函数里。
示例结构:
import hashlib import json def send_to_llm(prompt: str, system: str = "") -> str: clean_prompt = scrub(prompt) clean_system = scrub(system) # 这里才是真正的模型调用 # response = your_llm_client.chat(clean_prompt, clean_system) # return response # 在真实项目中不要直接返回这个示例值 return f"send clean prompt: {clean_prompt[:50]}..."这样整个项目里所有调用模型的入口都会经过同一道清洗逻辑。不会出现“这个接口忘了清洗”的情况。
如果项目里已经有多个调用入口,可以用装饰器统一包装:
def with_scrub(func): def wrapper(prompt, *args, **kwargs): return func(scrub(prompt), *args, **kwargs) return wrapper @with_scrub def call_model(prompt: str): # 这里拿到的是已经清洗过的 prompt pass直接给函数加装饰器,比在每次调用前手动scrub()更可控。
4.3 批量文档处理:脱敏后的副本才能进入下游任务
另一个常见场景是批量处理文档。比如你有一批 Markdown、TXT 或 JSON 文件,需要提取摘要、分类或抽取结构化信息。
不要直接把这些文件原样发给模型。正确做法是:先批量生成清洗后的副本,再让下游任务读取副本。
from pathlib import Path def process_file(src_path: Path, out_dir: Path) -> None: text = src_path.read_text(encoding="utf-8") redacted_text = scrub(text) out_path = out_dir / src_path.name out_path.write_text(redacted_text, encoding="utf-8")批量处理时,可以先用dry_run模式洗一遍,只输出统计信息:
def scrub_report(text: str) -> dict: count = {} clean_text = normalize_text(text) for name, pattern in PII_PATTERNS.items(): hits = pattern.findall(clean_text) if hits: count[name] = len(hits) placeholder = f"[{name}]" clean_text = pattern.sub(placeholder, clean_text) return {"clean_text": clean_text, "count": count}先看报告,再决定要不要真的进入下一步模型调用。这个步骤能避免你把一份文档清洗后才发现“原来里面还有一类没覆盖到的敏感信息”。
5. 单次跑通只是开始,工程化还差这几块拼图
一段脚本在本地跑通不难。但如果你打算把它放进一个长期维护的项目里,下面几块拼图不能少。
5.1 输出前预览,发送后留审计日志
本地 scrubber 的一个好处是“可审计”。但可审计不是自动发生的,你需要主动记录。
一个比较稳妥的做法是:不记录原始文本,也不记录清洗后的全文,而是记录哈希值和命中规则数。
import hashlib import json def log_scrub_record(original: str, clean_text: str, **extra): record = { "original_hash": hashlib.sha256(original.encode("utf-8")).hexdigest(), "clean_hash": hashlib.sha256(clean_text.encode("utf-8")).hexdigest(), **extra, } # 写入本地日志文件,注意按项目要求做权限控制 with open("scrubber-audit.log", "a", encoding="utf-8") as f: f.write(json.dumps(record, ensure_ascii=False) + "\n")哈希值不能还原原文,但可以用于确认某次发送对应的原始文本是否和预期一致。不要把未清洗的原文写进日志,否则你的日志文件本身也会成为泄露源。
5.2 回归样本:把规则变成一组可验证用例
规则写完之后,最容易被忽视的是“改了规则会不会误伤正常文本”。
你可以准备一组样本用例,把清洗前后的对应关系写下来。以后改动规则时,先跑一遍用例。
CASES = [ ("联系人 user@example.com", "联系人 [EMAIL]"), ("当前 IP 192.168.1.10", "当前 IP [IP]"), ("token sk-1234567890abcdef", "token [API_KEY]"), ] def test_scrub(): for raw, expected in CASES: result = scrub(raw) assert result == expected, f"{raw!r} -> {result!r}, expected {expected!r}" if __name__ == "__main__": test_scrub() print("all cases passed")这里真正有价值的是:每次更新正则,你都能看到哪些样本被影响。这比只在真实场景里人工发现误伤要高效得多。
5.3 批量任务中的并发、超时和失败重试
批量调用模型时,很多人会把并发数直接拉满。这里要提醒:scrubber 本身是本地计算,速度很快,但模型接口往往有并发和配额限制。清洗流程再快,也不代表可以无限并发。
建议从很小的批处理量开始验证。比如先处理 10 条,检查:
- 清洗结果是否正确;
- 模型接口是否稳定;
- 响应时间是否在预期范围内;
- 有没有触发限流或超时。
之后再逐渐增加批次大小。真正稳定之后,再考虑重试机制。重试时要注意:重试的输入应该是已经清洗过的文本,而不是再次清洗原始文本,否则哈希值和审计记录会混乱。
5.4 不要指望规则能解决所有敏感信息
这也是一道重要的边界。
正则规则能处理“有结构的信息”,但它处理不了“需要语义判断的信息”。例如,一段话里的“客户王总”可能是一个敏感业务信息,但正则不可能知道“王总”是谁,只有你的业务配置能决定是否要替换。
因此,local scrubber 更适合作为第一道防线,而不是唯一防线。如果数据合规等级很高,你还得考虑企业级脱敏方案、权限管理、数据分级和人工审核流程。
6. 常见报错与排查顺序:不是清洗器坏了,而是链路被动过了
实际使用中,很多问题并不是 scrubber 本身报错,而是清洗之后模型端收到了“看起来奇怪”的文本。
6.1 先看现象,再看清洗输出
遇到问题,先不要急着改正则。先用两个问题定位:
- 是清洗前出了问题,还是清洗后出了问题?
- 是模型端报错,还是下游解析报错?
如果是模型端报警,比如provider rejected the request schema or tool payload,通常意味着你清洗后的文本不符合接口期望的结构。最常见的原因是:你在 JSON 字段名里使用了正则替换,把结构给破坏了。
比如:
{ "user_email": "user@example.com" }如果正则把user_email也当成邮箱替换掉,就可能变成:
{ "user_[EMAIL]": "[EMAIL]" }这显然不是合法业务结构。所以清洗时,最好只对“值”做处理,不要对“键”做处理。
6.2 从输入到输出的逐层排查顺序
可以固定一套排查顺序:
- 先看原始文本:确认输入里有什么,文件编码是否是 UTF-8,是否存在
\r\n混用。 - 再看清洗函数:把清洗后的文本单独打印出来,确认占位符是否正确。
- 再看格式:如果原始文本是 JSON,清洗后先用
json.loads验证一次。 - 再看日志:确认审计日志里记录的内容是否只包含哈希值,没有原文。
- 再看模型接口:确认调用时传入的是清洗后的文本,而不是清洗前的原始变量。
这套顺序能覆盖绝大多数问题。
6.3 可疑场景和应对策略
| 现象 | 可能原因 | 检查方向 |
|---|---|---|
| 清洗后少了大量内容 | 正则过于宽泛,把普通单词也替换了 | 检查正则是否过于激进,加入回归用例 |
| 占位符出现在 JSON 键名里 | 清洗逻辑作用在了字段名上 | 只对值做替换,或先解析 JSON 再清洗 |
| 模型返回超时 | 不是清洗问题,可能是调用频率太高 | 检查并发、重试、模型接口配额 |
| 模型说看不懂清洗后的文本 | 占位符信息太模糊,丢失了上下文 | 改用更具体的占位符,如[EMAIL]比[REDACTED]更友好 |
| 同一段文本每次清洗结果一样,但实际发送内容不同 | 调用链路上某处还在使用原始文本 | 检查是否在send_to_llm之前重新拼接了 prompt |
这里没有万能答案。大多数时候,把清洗结果打印出来,问题就已经解决一半了。
7. 适合谁、不适合谁,以及最后的落地建议
这套 local scrubber 不是银弹。它有自己的适用边界。
| 情况 | 是否适合 | 原因 |
|---|---|---|
| 个人开发者使用外部模型,希望降低敏感信息外泄风险 | 适合 | 实现成本低,能覆盖绝大多数有结构的信息 |
| 团队先用标准接口调用模型,需要统一出入口 | 适合 | 在 API 层统一清洗,维护成本可控 |
| 已有严格数据合规要求,需要完整脱敏方案 | 不适合单独使用 | 正则规则不够全面,还需要权限审计、数据分级、企业级脱敏工具 |
| 需要处理非结构化业务敏感信息,如客户意向 | 不适合直接依赖规则 | 需要人工审核或依赖语义理解模型辅助判断 |
| 已经跑通 Agent 或知识库工作流,但还没加发送边界 | 很适合 | 无论文档管理方法多完善,发送边界始终是最后一道闸门 |
如果你也在做类似工作,我建议按这个顺序落地:
- 先写一个最简
scrub()函数,把需要马上处理的邮箱、IP、密钥这类规则加进去。 - 把它放在所有外部模型调用的统一入口处,不要散落着手动清洗。
- 准备 5 到 10 条回归用例,防止正则更新误伤正常文本。
- 加上审计日志,记录原文哈希和清洗后哈希。
- 最后再考虑批量任务中的并发、超时与重试。
一个真正好的 local scrubber,不是把“看起来敏感”的词全部删掉,而是让你知道:每次发给模型的内容,都经过了你能理解的规则,并且这个规则是可验证、可修改、可追溯的。
在外部模型越来越深入地参与日常开发的前提下,真正值得长期积累的能力,不是记住更多提示词模板,而是把每一次出站请求都变成有边界、有记录、可信任的操作。本地 scrubber 就是这条边界上很小但很关键的一块砖。