发送给LLM前,用本地Scrubber给文本做脱敏清洗

发送给LLM前,用本地Scrubber给文本做脱敏清洗 第一次意识到“发给 LLM 的文本也需要先洗一遍”是在一次很普通的调试场景里。我准备把一段带着完整报错的日志贴给模型分析粘贴前扫了一眼日志里有内网 IP、数据库连接串片段、还有一线内部目录结构。如果就这么发出去模型能不能分析先不说敏感信息已经准备离开本机了。后来我把这件事固化成固定动作任何要发给模型的文本先在本地过一遍 scrubber把该换成占位符的内容换掉再进入模型。这就是标题里 “A local scrubber for text youre 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 清洗后的输出仍要能被模型读得懂清洗不是把一段文本变成满屏星号。如果把敏感信息全部替换成***模型可能丢掉大量上下文甚至没法理解这段文本在说什么。正确做法是“占位化”。比如原文用户 userexample.com 在 192.168.1.10 上报了一个连接错误。清洗后更合理的输出是用户 [EMAIL] 在 [IP] 上报了一个连接错误。模型仍然能理解“有一个用户、一个 IP、一个连接错误”只是不知道具体是谁、具体是哪个地址。对大多数分析类任务来说这种信息完整度已经够了。如果文本是 JSON 结构占位化还要保证 JSON 依然合法。例如{ user_email: userexample.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 aexample.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 { publicexample.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 fsend 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(encodingutf-8) redacted_text scrub(text) out_path out_dir / src_path.name out_path.write_text(redacted_text, encodingutf-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, encodingutf-8) as f: f.write(json.dumps(record, ensure_asciiFalse) \n)哈希值不能还原原文但可以用于确认某次发送对应的原始文本是否和预期一致。不要把未清洗的原文写进日志否则你的日志文件本身也会成为泄露源。5.2 回归样本把规则变成一组可验证用例规则写完之后最容易被忽视的是“改了规则会不会误伤正常文本”。你可以准备一组样本用例把清洗前后的对应关系写下来。以后改动规则时先跑一遍用例。CASES [ (联系人 userexample.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: userexample.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 就是这条边界上很小但很关键的一块砖。