ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

本地文本清洗器:在发送给 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 ScrubberLocal 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 典型输入与输出输入可以是一段对话请联系 aliceexample.com 或拨打 13800138000 获取技术支持。输出应该是请联系 [EMAIL] 或拨打 [PHONE] 获取技术支持。如果你的输入是 Markdown 文档那么还要考虑代码块、链接、表格这些格式。比如项目地址https://example.com/project 联系邮箱devexample.com输出可以是项目地址https://example.com/project 联系邮箱[EMAIL]URL 中的域名不一定需要清洗但邮箱肯定需要。这里就出现了一个难点同一个正则可能误伤 URL。所以规则设计必须小心这也是后面我会重点讲的地方。2.3 需要覆盖的敏感信息类型常见的信息类型包括类型示例默认识别难度邮箱aliceexample.com中等正则即可手机号13800138000中等注意边界身份证号11010119900307441X中高需要校验位IP 地址192.168.1.1中等但内网地址可能误伤API Key / Tokensk-xxxxx高模式不固定姓名/地址张三、北京市朝阳区高通常需要 NER 模型内部域名corp.local低可用列表匹配在设计第一个版本时我建议先用正则解决前四类因为它们有比较明确的结构。姓名和地址这类信息高度依赖上下文正则很难做全后续可以引入本地 NER 模型但那是一个更大工程。文章后面给出的代码会覆盖邮箱、手机号、IP 和身份证号并预留自定义规则入口。3. 环境准备与项目结构3.1 环境要求这个项目设计得非常轻量只依赖 Python 标准库所以环境要求很低。操作系统Windows / macOS / Linux 均可Python3.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 Scrubber4.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(联系 aliceexample.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, rulerule): records.append( ReplaceRecord( rule_namerule[name], startmatch.start(), endmatch.end(), matchedmatch.group(0), replacementrule[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(frules file not found: {p}) with p.open(r, encodingutf-8) as f: return json.load(f) def main(): parser argparse.ArgumentParser( descriptionA local scrubber for text youre 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, actionstore_true, help输出替换报告) args parser.parse_args() if args.text: raw_text args.text elif args.file: raw_text Path(args.file).read_text(encodingutf-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 ---, filesys.stderr) print(json.dumps(report, ensure_asciiFalse, indent2), filesys.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 您好我的联系方式是 aliceexample.com电话 13800138000。 内部服务器地址192.168.1.10。 如果需要远程调试请联系 bobcompany.com。然后运行 CLIpython -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: aliceexample.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 testexample.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 基于上下文的白名单机制有时候一段文本里的字符串长得像邮箱但它实际上是某个占位符比如userexample.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://adminexample.com中adminexample.com是一个 URL 的 userinfo 部分你可能希望保留整个 URL。这种情况可以做前置的 URL 匹配把 URL 先替换成占位符再执行普通规则最后恢复 URL。URL_PATTERN re.compile(rhttps?://[^\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(keependsTrue), cleaned.splitlines(keependsTrue), ) return .join(diff)调用示例scrubber Scrubber() original 联系 aliceexample.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(联系 aliceexample.com) assert aliceexample.com not in clean assert aliceexample.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 能在你的真实业务文本上稳定运行再考虑接入生产管道。每一步都留好审计才能让“清洗”这件事可信、可控。
返回列表