news 2026/9/2 15:13:43

本地文本清洗器:在发送给 LLM 前脱敏敏感信息的工程实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
本地文本清洗器:在发送给 LLM 前脱敏敏感信息的工程实践

新手接触 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 / Tokensk-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.json

3.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

这段代码的优点是直白,缺点是替换记录中的startend位置不是原始文本位置,而是逐轮处理之后的位置。对于“知道哪些类型被替换了”这种场景完全够用;但如果需要精确 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:从一个文件读取文本。
  • 标准输入:没有指定前两种时,从管道读取,这样就能配合echocat使用。

--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 代码块可能有语言标记,比如pythonshell,在切分时会多出一些内容。但它生动地展示了“上下文感知”的方向。更完整的实现建议使用markdown解析库或自行维护一个轻量状态机。

5.2 基于上下文的白名单机制

有时候,一段文本里的字符串长得像邮箱,但它实际上是某个占位符,比如user@example.com是文档示例,不是真实用户信息。如果规则太严格,会把示例也替换掉。这时可以引入白名单机制。

比如在规则里增加skip_if_containswhitelist字段,在匹配前先判断上下文。

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.jsonrules.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 能在你的真实业务文本上稳定运行,再考虑接入生产管道。每一步都留好审计,才能让“清洗”这件事可信、可控。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/2 15:10:58

三星HBM4良率突破80%:下一代AI硬件内存技术进展解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/2 15:07:03

HOMIE Gen2 实战:从经验数据到 Scaling Law 的落地路径

最近在做多模态交互模型的相关调研时&#xff0c;刚好看到 HOMIE Gen2 全新发布的消息。这个版本最核心的变化&#xff0c;是把研究重心从“模型参数规模”转向了“人类经验数据的规模化利用”&#xff0c;并且第一次系统性地提出了“经验 Scaling Law”的落地框架。 这篇文章…

作者头像 李华
网站建设 2026/9/2 15:05:50

用C#开发Hex转Bin工具:Intel Hex解析与固件转换实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/2 15:05:46

windows 安装 docker

文章目录Docker 需要 Linux 环境&#xff08;WSL2 或 Hyper‑V&#xff09;才能正常运行Windows 安装 Docker 的一些先决条件查询 Windows 版本(确保是Windows 10 或 Windows 11&#xff0c;的64位系统检查 CPU 是否支持虚拟化系统功能是否可用&#xff08;不用勾选&#xff0c…

作者头像 李华
网站建设 2026/9/2 15:01:51

Revit建筑设计思维课堂:从标高轴网入门到BIM体系构建

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华