发送给LLM前,用本地Scrubber给文本做脱敏清洗
2026/9/12 1:14:49 网站建设 项目流程

第一次意识到“发给 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 的规则体系通常是两层:

  1. 通用正则规则:负责识别邮箱、手机号、IP、JWT、私钥等有结构的信息。
  2. 自定义业务规则:由使用者维护,负责识别团队内部才知道的代号、服务名、合同号等。

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 以上版本,不需要安装第三方库。先保证“一条命令能跑通”,再去加配置、日志和测试。

核心流程可以很直接:

  1. \r\n统一成\n,避免 Windows 和 Linux 换行差异影响匹配;
  2. 先处理自定义业务规则,再跑通用正则规则;
  3. 对命中的内容做占位替换;
  4. 输出清洗后的文本,同时可输出一份统计信息。

不要一开始就做太复杂的配置。先写一个最简函数,把它放进实际发送链路里,发现问题后再迭代。

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 先看现象,再看清洗输出

遇到问题,先不要急着改正则。先用两个问题定位:

  1. 是清洗前出了问题,还是清洗后出了问题?
  2. 是模型端报错,还是下游解析报错?

如果是模型端报警,比如provider rejected the request schema or tool payload,通常意味着你清洗后的文本不符合接口期望的结构。最常见的原因是:你在 JSON 字段名里使用了正则替换,把结构给破坏了。

比如:

{ "user_email": "user@example.com" }

如果正则把user_email也当成邮箱替换掉,就可能变成:

{ "user_[EMAIL]": "[EMAIL]" }

这显然不是合法业务结构。所以清洗时,最好只对“值”做处理,不要对“键”做处理。

6.2 从输入到输出的逐层排查顺序

可以固定一套排查顺序:

  1. 先看原始文本:确认输入里有什么,文件编码是否是 UTF-8,是否存在\r\n混用。
  2. 再看清洗函数:把清洗后的文本单独打印出来,确认占位符是否正确。
  3. 再看格式:如果原始文本是 JSON,清洗后先用json.loads验证一次。
  4. 再看日志:确认审计日志里记录的内容是否只包含哈希值,没有原文。
  5. 再看模型接口:确认调用时传入的是清洗后的文本,而不是清洗前的原始变量。

这套顺序能覆盖绝大多数问题。

6.3 可疑场景和应对策略

现象可能原因检查方向
清洗后少了大量内容正则过于宽泛,把普通单词也替换了检查正则是否过于激进,加入回归用例
占位符出现在 JSON 键名里清洗逻辑作用在了字段名上只对值做替换,或先解析 JSON 再清洗
模型返回超时不是清洗问题,可能是调用频率太高检查并发、重试、模型接口配额
模型说看不懂清洗后的文本占位符信息太模糊,丢失了上下文改用更具体的占位符,如[EMAIL][REDACTED]更友好
同一段文本每次清洗结果一样,但实际发送内容不同调用链路上某处还在使用原始文本检查是否在send_to_llm之前重新拼接了 prompt

这里没有万能答案。大多数时候,把清洗结果打印出来,问题就已经解决一半了。

7. 适合谁、不适合谁,以及最后的落地建议

这套 local scrubber 不是银弹。它有自己的适用边界。

情况是否适合原因
个人开发者使用外部模型,希望降低敏感信息外泄风险适合实现成本低,能覆盖绝大多数有结构的信息
团队先用标准接口调用模型,需要统一出入口适合在 API 层统一清洗,维护成本可控
已有严格数据合规要求,需要完整脱敏方案不适合单独使用正则规则不够全面,还需要权限审计、数据分级、企业级脱敏工具
需要处理非结构化业务敏感信息,如客户意向不适合直接依赖规则需要人工审核或依赖语义理解模型辅助判断
已经跑通 Agent 或知识库工作流,但还没加发送边界很适合无论文档管理方法多完善,发送边界始终是最后一道闸门

如果你也在做类似工作,我建议按这个顺序落地:

  1. 先写一个最简scrub()函数,把需要马上处理的邮箱、IP、密钥这类规则加进去。
  2. 把它放在所有外部模型调用的统一入口处,不要散落着手动清洗。
  3. 准备 5 到 10 条回归用例,防止正则更新误伤正常文本。
  4. 加上审计日志,记录原文哈希和清洗后哈希。
  5. 最后再考虑批量任务中的并发、超时与重试。

一个真正好的 local scrubber,不是把“看起来敏感”的词全部删掉,而是让你知道:每次发给模型的内容,都经过了你能理解的规则,并且这个规则是可验证、可修改、可追溯的。

在外部模型越来越深入地参与日常开发的前提下,真正值得长期积累的能力,不是记住更多提示词模板,而是把每一次出站请求都变成有边界、有记录、可信任的操作。本地 scrubber 就是这条边界上很小但很关键的一块砖。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询