新手接触 LLM 应用时,很容易忽略一个问题:你辛辛苦苦整理的用户信息、日志片段、对话上下文,可能正在被发送到一个你无法控制的远端服务。之前在做内部效率工具时,我们需要把大量工单摘要交给大型语言模型处理,但工单里往往带着用户手机号、邮箱、内部系统地址。直接发送既不安全,也可能违反公司的数据管理规定。于是我们做了一个非常小的本地文本清洗工具——也就是标题里说的 local scrubber,专门在文本进入 LLM 之前做最后一层“过滤”。这篇文章会把整个设计思路、完整代码、调试方法和工程化建议一次讲清楚。
本文适合正在做 LLM 应用、数据管道、自动化脚本的开发者,也适合想了解“在发送数据给外部模型前如何脱敏”的同学。读完以后,你可以获得一个可直接运行的 Python 版 local scrubber,可以自定义清洗规则,可以生成替换报告,并且知道如何接入现有项目。
1. 为什么要有一个 Local Scrubber?—— 从“把文本交给 LLM 之前”说起
1.1 你发出去的每一段 Prompt,都可能在远端被记录
大语言模型本身并不运行在你的电脑上。无论是调用云端 API,还是部署在公司私有化集群之外的服务,你的 Prompt 文本都要经过网络传输,并大概率被服务端暂存、记录甚至用于后续优化。很多团队的合规要求明确规定:用户手机号、邮箱、身份证号、内部 API Key 等敏感信息,不能直接出现在外部请求中。
但问题在于,Prompt 并不像数据库字段那样结构清晰。它可能是用户输入的一段自然语言,可能是一封邮件,可能是从网页抓取的内容,还有可能是带 Markdown 格式的文档片段。数据以“文本”的形式混在一起,你很难通过硬编码字段去截断它。传统做法是把敏感字段先遮掉再入库,但到了 LLM 场景,你面对的是“即将发送、还没发送”的文本,需要一种更通用、更即时的处理方式。
1.2 什么是 Local Scrubber?
Local Scrubber 可以理解为一个运行在本地环境的文本处理器。它接收一段原始文本,根据预设规则识别其中的敏感信息,然后把这些信息替换成占位符或普通文本。整个过程不需要调用外部服务,不依赖网络,也不把文本发送给任何第三方。
它解决的核心问题是:在数据离开你的设备之前,先用不可逆的方式“抹掉”敏感内容。注意这里强调的是“本地”这两个字,因为它最大的价值在于:敏感数据不需要先上传到某个服务器做清洗,再下载回来,而是直接在你自己的进程里完成变换。这也意味着,你可以在生产链路中非常低成本地嵌入它,不必担心额外的数据外泄风险。
1.3 本地清洗与常见脱敏方案的区别
很多人会想到数据库脱敏、日志脱敏、网关拦截脱敏,但 local scrubber 关注的是“文本层面”的动态清洗。数据库脱敏是针对已结构化数据的静态规则,日志脱敏通常只处理输出到日志系统的那一段,而 local scrubber 是面向 LLM 请求体的、发生在代码运行时的一种过滤器。
它可以作为一条独立的 Python 函数被调用,也可以做成命令行工具,配合 CI/CD、pre-commit、数据管道一起使用。相比在服务端 SDK 里加拦截器,本地 scrubber 的好处是你自己掌握全部规则,可以随时修改,不需要依赖某个框架的版本更新。
2. Local Scrubber 的设计目标与功能拆解
2.1 文本清洗的核心目标
设计一个 local scrubber 时,我们要明确它的四个目标。
第一是“识别”:能够自动发现文本中的敏感信息类型。不是每个字段都知道自己长什么样,所以需要用正则表达式或更复杂的规则去匹配。
第二是“替换”:识别出内容之后,用统一的占位符替代,比如[EMAIL]、[PHONE]、[IP]。替换不是简单的删除,因为删除会改变句子的结构,而占位符能保留文本的可读性。
第三是“可审计”:最好能知道文本中哪些位置被替换了、替换了哪些内容。尤其在生产环境里,审计信息可以帮助你判断规则是否过宽或者过窄。
第四是“可扩展”:不同团队、不同业务面对的敏感信息类型不一样。工具必须允许使用方自定义规则,而不是写死一套。
2.2 典型输入与输出
输入可以是一段对话:
请联系 alice@example.com 或拨打 13800138000 获取技术支持。
输出应该是:
请联系 [EMAIL] 或拨打 [PHONE] 获取技术支持。
如果你的输入是 Markdown 文档,那么还要考虑代码块、链接、表格这些格式。比如:
项目地址:https://example.com/project 联系邮箱:dev@example.com
输出可以是:
项目地址:https://example.com/project 联系邮箱:[EMAIL]
URL 中的域名不一定需要清洗,但邮箱肯定需要。这里就出现了一个难点:同一个正则,可能误伤 URL。所以规则设计必须小心,这也是后面我会重点讲的地方。
2.3 需要覆盖的敏感信息类型
常见的信息类型包括:
| 类型 | 示例 | 默认识别难度 |
|---|---|---|
| 邮箱 | alice@example.com | 中等,正则即可 |
| 手机号 | 13800138000 | 中等,注意边界 |
| 身份证号 | 11010119900307441X | 中高,需要校验位 |
| IP 地址 | 192.168.1.1 | 中等,但内网地址可能误伤 |
| API Key / Token | sk-xxxxx | 高,模式不固定 |
| 姓名/地址 | 张三、北京市朝阳区 | 高,通常需要 NER 模型 |
| 内部域名 | corp.local | 低,可用列表匹配 |
在设计第一个版本时,我建议先用正则解决前四类,因为它们有比较明确的结构。姓名和地址这类信息高度依赖上下文,正则很难做全,后续可以引入本地 NER 模型,但那是一个更大工程。文章后面给出的代码会覆盖邮箱、手机号、IP 和身份证号,并预留自定义规则入口。
3. 环境准备与项目结构
3.1 环境要求
这个项目设计得非常轻量,只依赖 Python 标准库,所以环境要求很低。
- 操作系统:Windows / macOS / Linux 均可
- Python:3.10 或更高版本
- 包管理:无需额外安装第三方包
- 命令行工具:任意终端
版本需要根据你的项目实际情况调整,本文示例以 Python 3.10 为基准,重点演示实现思路。如果你的环境是 Python 3.8,建议稍作语法调整,比如把list[dict]改成List[dict]。
3.2 初始化项目
我们创建一个名为local_scrubber的项目目录,里面包含一个 Python 包和一个示例配置文件。
mkdir local_scrubber cd local_scrubber mkdir local_scrubber mkdir examples项目结构如下:
local_scrubber/ ├── local_scrubber/ │ ├── __init__.py │ ├── scrubber.py │ └── cli.py ├── examples/ │ └── input.txt └── rules.json3.3 依赖说明
为了让新手更容易复现,我刻意没有使用第三方依赖。实现敏感信息识别主要靠 Python 的re标准库,命令行参数解析用argparse,如果要做 diff 预览,还能用到difflib。这样你不需要pip install任何包,粘贴代码就能运行。
如果后续想支持 YAML 规则文件,再安装一个PyYAML即可,但现阶段用 JSON 已经足够清晰。
4. 从零实现一个可用的 Local Scrubber
4.1 定义默认规则
我们先把内置规则写在一个 Python 文件里。规则结构是:name表示规则名,pattern是正则表达式,replacement是替换文本,enabled用于动态开关。
# local_scrubber/scrubber.py from __future__ import annotations import json import re from dataclasses import dataclass from pathlib import Path from typing import Any, Dict, List, Optional, Tuple DEFAULT_RULES: List[Dict[str, Any]] = [ { "name": "email", "pattern": r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", "replacement": "[EMAIL]", "enabled": True, }, { "name": "phone", "pattern": r"(?<!\d)(1[3-9]\d{9})(?!\d)", "replacement": "[PHONE]", "enabled": True, }, { "name": "ipv4", "pattern": r"(?<!\d)(?:\d{1,3}\.){3}\d{1,3}(?!\d)", "replacement": "[IP]", "enabled": True, }, { "name": "id_card", "pattern": r"(?<!\d)\d{17}[\dXx](?!\d)", "replacement": "[ID_CARD]", "enabled": False, }, ]这里有几个细节值得说明:
- 邮箱正则是比较经典的模式,能覆盖大多数标准邮箱格式,但不会处理 Unicode 邮箱。
- 手机号规则目前针对中国大陆 11 位手机号,前面用了
(?<!\d)、后面用了(?!\d)来防止匹配到身份证中的连续数字或更长数字串。 - 身份证号默认关闭,因为正则匹配到 18 位数字并不一定就是合法身份证号,建议按业务需要开启,并结合校验位算法。
- IP 地址规则同样容易误伤端口号或版本号,所以在实际使用时要小心。
4.2 编写核心清洗器
接下来实现Scrubber类,它是整个工具的核心。设计目标很简单:调用scrub()方法,输入原始文本,返回清洗后的文本和替换记录。
# local_scrubber/scrubber.py @dataclass class ReplaceRecord: """记录一次替换的上下文信息,便于审计和预览。""" rule_name: str start: int end: int matched: str replacement: str class Scrubber: """本地文本清洗器。 用法示例: scrubber = Scrubber() clean_text, records = scrubber.scrub("联系 alice@example.com") """ def __init__(self, rules: Optional[List[Dict[str, Any]]] = None) -> None: self.rules = self._compile_rules(rules or DEFAULT_RULES) @staticmethod def _compile_rules(rules: List[Dict[str, Any]]): compiled = [] for rule in rules: if not rule.get("enabled", True): continue compiled.append( { "name": rule["name"], "pattern": re.compile(rule["pattern"]), "replacement": rule["replacement"], } ) return compiled def scrub(self, text: str) -> Tuple[str, List[ReplaceRecord]]: """返回 (清洗后的文本, 替换记录列表)。""" records: List[ReplaceRecord] = [] result = text for rule in self.rules: def replacer(match, rule=rule): records.append( ReplaceRecord( rule_name=rule["name"], start=match.start(), end=match.end(), matched=match.group(0), replacement=rule["replacement"], ) ) return rule["replacement"] result = rule["pattern"].sub(replacer, result) return result, records这段代码的优点是直白,缺点是替换记录中的start、end位置不是原始文本位置,而是逐轮处理之后的位置。对于“知道哪些类型被替换了”这种场景完全够用;但如果需要精确 Diff,就要换一种实现方式。这个问题我会在进阶部分展开。
4.3 编写命令行入口
命令行入口的作用是让工具可以被python -m local_scrubber.cli调用,同时方便接入 shell 管道。
# local_scrubber/cli.py import argparse import json import sys from pathlib import Path from .scrubber import Scrubber def load_rules(path: str): if not path: return None p = Path(path) if not p.exists(): raise FileNotFoundError(f"rules file not found: {p}") with p.open("r", encoding="utf-8") as f: return json.load(f) def main(): parser = argparse.ArgumentParser( description="A local scrubber for text you're about to send to an LLM." ) parser.add_argument("--text", "-t", help="待清洗的文本") parser.add_argument("--file", "-f", help="从文件读取文本") parser.add_argument("--rules", "-r", help="自定义规则 JSON 文件") parser.add_argument("--report", action="store_true", help="输出替换报告") args = parser.parse_args() if args.text: raw_text = args.text elif args.file: raw_text = Path(args.file).read_text(encoding="utf-8") else: raw_text = sys.stdin.read() scrubber = Scrubber(load_rules(args.rules)) clean_text, records = scrubber.scrub(raw_text) sys.stdout.write(clean_text) if not clean_text.endswith("\n"): sys.stdout.write("\n") if args.report: report = [ { "rule": r.rule_name, "matched": r.matched, "replacement": r.replacement, "start": r.start, "end": r.end, } for r in records ] print("\n--- scrub report ---", file=sys.stderr) print(json.dumps(report, ensure_ascii=False, indent=2), file=sys.stderr) if __name__ == "__main__": main()命令行入口保留了三种输入方式:
--text:直接把字符串传给文本参数。--file:从一个文件读取文本。- 标准输入:没有指定前两种时,从管道读取,这样就能配合
echo或cat使用。
--report默认输出到标准错误,而不是标准输出。这是有意为之,因为清洗结果要作为管道数据输出,而报告只是给用户看的辅助信息。如果你把报告也写到标准输出,下游程序拿到的就不是纯净的清洗后文本了。
4.4 补齐包初始化文件
在local_scrubber/__init__.py中暴露核心类:
# local_scrubber/__init__.py from .scrubber import Scrubber, ReplaceRecord __all__ = ["Scrubber", "ReplaceRecord"]这个文件让from local_scrubber import Scrubber可以正常工作。
4.5 运行与验证
我们先创建一个简单的示例输入文件:
# examples/input.txt 您好,我的联系方式是 alice@example.com,电话 13800138000。 内部服务器地址:192.168.1.10。 如果需要远程调试,请联系 bob@company.com。然后运行 CLI:
python -m local_scrubber.cli --file examples/input.txt预期输出为:
您好,我的联系方式是 [EMAIL],电话 [PHONE]。 内部服务器地址:[IP]。 如果需要远程调试,请联系 [EMAIL]。再运行带报告的模式:
python -m local_scrubber.cli --file examples/input.txt --report标准输出还是清洗结果,标准错误里会出现类似下面的报告:
--- scrub report --- [ { "rule": "email", "matched": "alice@example.com", "replacement": "[EMAIL]", "start": 18, "end": 35 }, { "rule": "phone", "matched": "13800138000", "replacement": "[PHONE]", "start": 41, "end": 52 } ]这表示工具已经正确识别并替换了示例文本中的邮箱和手机号。
5. 进阶:增强上下文感知与自定义规则
5.1 避免破坏 Markdown 和代码块
很多情况下,你发送给 LLM 的文本不是干净的纯文字,而是带 Markdown 或代码块的文档。如果代码块里有一行const email = "test@example.com";,你直接清洗,可能会破坏代码的可执行性,也可能会让模型无法理解代码上下文。
一个比较实用的思路是:先把文本按 Markdown 的代码块标记切分,然后在非代码块区域执行清洗。
def scrub_preserve_code(text: str, scrubber: Scrubber): # 简单思路:用 ``` 切分,奇数段是代码块,跳过清洗 segments = text.split("```") result = [] for idx, segment in enumerate(segments): if idx % 2 == 0: clean, _ = scrubber.scrub(segment) result.append(clean) else: result.append(segment) return "```".join(result)这个函数并不完美,因为 Markdown 代码块可能有语言标记,比如python、shell,在切分时会多出一些内容。但它生动地展示了“上下文感知”的方向。更完整的实现建议使用markdown解析库或自行维护一个轻量状态机。
5.2 基于上下文的白名单机制
有时候,一段文本里的字符串长得像邮箱,但它实际上是某个占位符,比如user@example.com是文档示例,不是真实用户信息。如果规则太严格,会把示例也替换掉。这时可以引入白名单机制。
比如在规则里增加skip_if_contains或whitelist字段,在匹配前先判断上下文。
def should_skip(text: str, start: int, end: int, whitelist) -> bool: matched = text[start:end] for item in whitelist: if item in matched: return True return False但白名单的粒度其实不止是“整个匹配串是否包含”,还包括“匹配串是否出现在 URL 链接内部”。例如https://admin@example.com中,admin@example.com是一个 URL 的 userinfo 部分,你可能希望保留整个 URL。这种情况可以做前置的 URL 匹配,把 URL 先替换成占位符,再执行普通规则,最后恢复 URL。
URL_PATTERN = re.compile(r"https?://[^\s]+") def scrub_url_safe(text: str, scrubber: Scrubber): holder_map = {} def hold(match): idx = f"__URL_{len(holder_map)}__" holder_map[idx] = match.group(0) return idx held_text = URL_PATTERN.sub(hold, text) clean_text, _ = scrubber.scrub(held_text) for idx, url in holder_map.items(): clean_text = clean_text.replace(idx, url) return clean_text这个方法的关键点是:先保护、后清洗、再还原。它适合任何“不希望被清洗的局部结构”。
5.3 清洗报告与 Diff 预览
前面提到,逐轮替换的记录不能精确还原原始位置。如果你希望给用户展示“原文和清洗后的变化”,可以用difflib生成一个类似git diff的预览。
import difflib def show_diff(original: str, cleaned: str): diff = difflib.ndiff( original.splitlines(keepends=True), cleaned.splitlines(keepends=True), ) return "".join(diff)调用示例:
scrubber = Scrubber() original = "联系 alice@example.com" cleaned, _ = scrubber.scrub(original) print(show_diff(original, cleaned))输出中-开头的行表示原文,+开头的行表示清洗后内容。在集成到 Web 界面或命令行工具时,这种 Diff 预览能帮助用户确认清洗规则是否合适。
6. 常见问题与排查思路
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 手机号没被替换 | 正则边界写得不对 | 检查前后是否有数字或字母,使用(?<!\d)和(?!\d) |
| 邮箱被替换但 URL 也被破坏 | URL 中包含了邮箱格式 | 先做 URL 保护,再执行清洗规则 |
| 身份证号完全不匹配 | 没开启id_card规则 | 在配置文件中把enabled设为true |
| 替换报告里的位置不对 | 逐轮替换导致位置偏移 | 改用一次性匹配 + 倒序替换算法,或只把报告当审计日志 |
| 正则太宽,误伤正常文本 | 规则模式过于宽泛 | 增加上下文约束,补充白名单和前置保护 |
运行时找不到local_scrubber模块 | 当前目录没进入项目根目录 | 在项目根目录执行python -m local_scrubber.cli,并确认有__init__.py |
如果你遇到“清洗后文本为空”的问题,优先检查是不是--file路径写错,或者--rules加载到了一个空列表。因为Scrubber加载空规则会原样返回文本,正常情况不会出现全部为空的情况。
7. 最佳实践与工程建议
7.1 规则管理
不要把所有正则都塞在代码里。推荐的做法是把规则放在独立的配置文件,比如rules.json或rules.yaml。这样业务同学可以随时调整,不需要改动代码。规则文件应该纳入版本管理,并且所有变更要经过 review。
每个规则必须有明确的name,因为审计报告依赖规则名来定位问题。如果你发现一个规则导致误报率很高,不要直接删掉它,而是先禁用,观察一段时间,确认没有影响后再清理。
7.2 测试与回归
文本清洗是一种典型的“规则型逻辑”,非常容易改一个地方挂另一个地方。我建议为 scrubber 建立专门的测试集,至少覆盖:
- 普通文本。
- 带 Markdown 的文本。
- 带代码块的文本。
- 带 URL 的文本。
- 敏感信息密集出现的文本。
- 自定义白名单场景。
一个简单的 pytest 测试用例如下:
# tests/test_scrubber.py from local_scrubber import Scrubber def test_email_scrubbed(): scrubber = Scrubber() clean, records = scrubber.scrub("联系 alice@example.com") assert "alice@example.com" not in clean assert "alice@example.com" not in clean assert "[EMAIL]" in clean assert records[0].rule_name == "email"虽然这个测试项目不需要pytest也能跑,但把它纳入 CI 管道是对抗“规则越加越乱”的有效手段。
7.3 性能与并发
正则匹配在大多数场景下性能足够,但如果你的文本量非常大,或者希望在高并发 API 服务中嵌入 scrubber,有几个优化思路:
- 只对包含疑似敏感信息的文本执行清洗,先用一次粗筛正则判断。
- 复用
Scrubber实例,避免每次请求都重新编译正则。 - 将规则按优先级排列,先处理高置信度规则,后处理低置信度规则。
- 如果只是做“是否包含敏感信息”的预检,可以提前结束匹配,不必完整替换。
7.4 安全与合规边界
Local scrubber 并不能消除所有数据安全风险。它只能减少敏感文本外泄的概率,不能完全替代权限管理、传输加密和日志脱敏。建议在使用时注意以下几点:
- 敏感信息替换后,原始文本如果还残留在内存或日志中,仍然可能泄露。
- 不要在生产环境用同一个工具处理所有数据,先评估数据分类和合规要求。
- 对系统内已识别的敏感数据,仍然要遵守最小授权原则。
- 如果规则文件包含业务敏感的字典,不要把该文件随代码公开。
7.5 与现有 LLM 管道集成
一个很自然的接入位置是在“构建 Prompt 之前”。比如你有一个build_prompt(user_input, user_profile)函数,那么可以这样使用:
def build_prompt(user_input: str, user_profile: dict) -> str: scrubber = Scrubber() clean_input, _ = scrubber.scrub(user_input) prompt = f"用户说:{clean_input}\n请分析问题。" return prompt这种做法保证了发送给 LLM 的 Prompt 是清洗后的版本,而原始输入只是短暂存在于内存中。如果需要保留审计,可以把records写入结构化日志,而不是记录原始文本。
8. 总结与后续方向
本文从一个简单的问题出发:发送给 LLM 的文本里可能包含敏感信息,所以我们需要一个本地 scrubber。围绕这个目标,我们讨论了工具产生的背景、核心设计目标、常见敏感信息类型,并完整实现了一个基于 Python 标准库的 local scrubber,包含默认规则、命令行入口、报告输出和进阶的 Markdown 保护与 Diff 预览。
如果你想继续完善这个工具,可以往这几个方向扩展:支持 YAML 规则文件;引入基于本地模型或字典的实体识别;提供 HTTP 接口供其他服务调用;把清洗过程集成到 pre-commit 或 LLM 网关中。对于企业场景,最后的合规审计和规则版本管理往往比正则本身更重要。
建议你先在本地建立一个包含典型文本的测试集,然后逐步增加规则。等到这个 scrubber 能在你的真实业务文本上稳定运行,再考虑接入生产管道。每一步都留好审计,才能让“清洗”这件事可信、可控。