1. 敏感信息过滤:为什么Java开发者不能只盯着Prompt
我最早接触LangChain4j的时候,跟大多数人一样,满脑子想的都是怎么构造好Prompt、怎么接上模型、怎么把对话记忆串起来。直到有一次,一个客服问答系统上线没几天,运营同事在后台看到模型返回的内容里赫然出现了用户的完整手机号,而且这个对话内容还进了日志系统,我才意识到一个严重的问题:在我们把用户问题原样抛给模型接口的那一刻,用户的身份证号、银行卡号、家庭住址这些信息,早就跟着请求一起离开我们的服务器了。
这不是危言耸听。很多人觉得"敏感信息过滤"就是LLM应用里的一个加分项,实际上它是必须解决的工程底线。如果你做的系统只处理一些疑问句、天气查询、趣味问答,那确实无所谓;但只要你让模型看过用户的订单、工单、报销单,甚至只是让模型帮忙润色一段包含手机号的通知文案,敏感信息就已经进入了模型上下文。一旦模型输出被截取、日志被采集、或者Prompt注入导致模型把上下文里的隐私内容原样吐出来,问题就远比"答错了"严重得多。
敏感信息过滤的本质,不是把敏感字段藏起来不让模型看到,而是在"模型需要理解语义"和"数据不能明文暴露"之间找到一个可靠的平衡点。LangChain4j作为Java生态里少见的成熟LLM编排框架,本身并没有一套开箱即用的"一键脱敏"组件,但它给了我们足够的扩展点:你可以在消息进入模型之前做预处理,可以在模型输出之后做后处理,也可以在构建对话记忆的时候做清洗。这篇文章我就从实战角度,把我自己在一个模拟项目X里落地敏感信息过滤的完整思路、代码片段和踩坑过程梳理一遍。
这套内容适合已经在用LangChain4j做应用、但还没认真处理过数据隐私的Java开发者,也适合那些正准备从零给ChatBot加"防泄露"能力的团队。我会从一个具体的交互链路说起,一步步讲清楚过滤点应该放在哪里、用什么策略过滤、怎么做到脱敏又能还原,以及最后那些极其容易翻车的小细节。
2. 过滤点放在哪个环节:入站、出站还是存储
2.1 先画清楚LLM应用的数据流向
任何一个基于LangChain4j的对话应用,数据流动基本逃不出这条链路:用户输入"请求消息",我们组装成模型需要的格式,连同历史记忆一起发给模型接口,模型返回"响应消息",我们再把响应交给用户或者下游系统。这中间其实藏着三个可以做过滤的节点。
第一个节点是入站,也就是用户消息还没进模型上下文之前。这里过滤的意义在于:不让真实敏感数据出现在请求体中,这样即使模型接口方的日志、审计链路、第三方数据处理出现问题,我们也不会把用户隐私直接交出去。第二个节点是出站,也就是模型返回结果到用户的路上。这里过滤的意义在于:防止模型把上下文里其他用户的信息、内部知识库里的敏感内容、或者自身记忆里残留的隐私数据拼接出来。第三个节点是存储,也就是对话记忆、历史消息、日志这些持久化数据。这里过滤的意义在于:避免脱敏后的数据在存储阶段重新变回明文。
我见过不少团队只做了入站过滤,觉得"只要发给模型的是脱敏内容就万事大吉",结果一查日志,原始消息早就被某一条本不该记录的拦截器写进文件里了。所以我建议在系统设计阶段就把这三道防线一起规划,而不是等出了问题再补。
2.2 三类过滤点的职责划分与取舍
入站过滤通常用"替换"而不是"删除"。直接删掉敏感字段会让模型完全失去上下文,比如用户问"我上周跟张三的退款申请处理到哪一步了",如果你把"张三"删掉,模型根本不知道说的是谁;如果把"张三"替换成"联系人A",语义就保住了。出站过滤则需要做"还原"或者"再次脱敏",取决于业务场景。如果是给客服人员看的对话记录,还原成真实姓名反而方便;如果是给普通用户看的内容,一旦模型幻觉导致串号,就绝对不能还原。
存储层面的过滤最容易被忽略。LangChain4j里常用的对话记忆,比如MessageWindowChatMemory,底层可以把历史消息存到某个Store里。如果你把入站脱敏后的数据存进去,那读取出来的历史消息也是脱敏过的,模型第二次见面时依然能看到"联系人A",这就造成了一个很尴尬的局面:同一个会话里,第一次对话说的是真实姓名,第二次对话就变成了代号,模型会一头雾水。
我的做法是,在存储层单独保存两份副本:一份给模型用的"脱敏历史",一份给业务审计用的"加密原文"。模型看到的历史永远是干净的,业务方需要追溯的时候,再通过加密映射还原。这种设计的代价是复杂了一点,但换来的安全收益非常明显。
3. 从"名单替换"到"语义识别":LangChain4j里三种过滤策略的实际效果
3.1 名单式屏蔽:最直接但最依赖维护
所谓名单式屏蔽,就是维护一个敏感词列表,凡是命中的词,一律替换成占位符。在LangChain4j里,你可以通过给ChatLanguageModel构建请求之前增加一个处理步骤来实现,或者更优雅一点,在自定义的ChatLanguageModel实现里做包装。我最早做的版本大概长这样:
public class PiiMaskingModel implements ChatLanguageModel { private final ChatLanguageModel delegate; private final Map<String, String> piiDictionary; private final Map<String, String> maskToOriginal = new ConcurrentHashMap<>(); @Override public ChatResponse chat(ChatRequest request) { List<ChatMessage> maskedMessages = request.messages().stream() .map(this::maskMessage) .toList(); // 用脱敏后的消息重新构造请求 ChatRequest maskedRequest = ChatRequest.builder() .messages(maskedMessages) .build(); ChatResponse response = delegate.chat(maskedRequest); return unmaskResponse(response); } private ChatMessage maskMessage(ChatMessage message) { String originalText = message.text(); String maskedText = originalText; for (Map.Entry<String, String> entry : piiDictionary.entrySet()) { if (originalText.contains(entry.getKey())) { String placeholder = entry.getValue(); maskedText = maskedText.replace(entry.getKey(), placeholder); maskToOriginal.put(placeholder, entry.getKey()); } } return message.copy().withText(maskedText); } }名单式屏蔽的优势是精准、无误差、性能极好,而且实现起来几乎没有学习成本。但它有个非常现实的问题:你永远不知道用户会用什么方式表达敏感信息。电话号码可能被写成"幺三九零零八八",银行卡号可能被人为拆成几段,中文里的谐音、错别字、拼音缩写,名单根本覆盖不全。所以名单只能作为第一道基础防线,真正兜底的还得靠后面两种。
3.2 正则兜底:用模式匹配捕获常见隐私格式
正则覆盖的是"格式敏感信息",比如身份证号、手机号、邮箱、银行卡号这类有固定模式的数据。在我做的模拟项目X里,我预编译了一批Pattern,放在一个独立的FilterRegistry里统一管理:
public class SensitivePatternRegistry { private final List<Pattern> patterns = List.of( Pattern.compile("\\b1[3-9]\\d{9}\\b"), // 手机号 Pattern.compile("\\d{17}[0-9Xx]"), // 身份证号 Pattern.compile("[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}"), Pattern.compile("\\b\\d{16,19}\\b") // 银行卡号 ); public String mask(String text) { String masked = text; for (Pattern pattern : patterns) { masked = pattern.matcher(masked).replaceAll(match -> { String token = match.group(); String placeholder = "[" + resolveType(token) + "]"; // 记录映射,用于出站还原 maskingContext.put(placeholder, token); return placeholder; }); } return masked; } }正则的补充价值在于:即使名单里没有这个词,只要格式匹配,一样能兜住。代价就是误伤率高。我实际测试的时候,发现订单号"20250824001"会被银行卡正则命中,"139开头的座机号"会被手机号正则命中,更离谱的是,一段随机生成的验证码"dX8kP"被邮箱正则拦下来。误伤的后果不仅仅是占位符错乱,更麻烦的是模型对占位符的理解会因为数量过多而下降,整段上下文变得支离破碎。
所以我在实践里给正则加了几道护栏:第一,类型优先,同一个文本先匹配最长的、最明确的模式,避免身份证正则抢在银行卡前面;第二,业务白名单,某些字段(比如订单号、工单号)如果命中规则但前缀是业务编码,则直接放行;第三,最小替换原则,一次只替换一个最可疑的片段,而不是把所有数字都打码。
3.3 语义识别:用模型自己判断哪些内容不该外泄
名单和正则说到底都是"机械匹配",它们无法理解语境。比如这样一句话:"用户A的申请单编号是2025001,但请注意这条信息不能泄露给客服。" 机械匹配会把"2025001"当作敏感信息,而语义识别则会判断出这里的安全等级其实取决于上下文。LangChain4j里实现语义识别过滤,通常是用一个小型分类模型或主模型做一个前置判断:
String prompt = """ 判断以下文本中是否包含个人敏感信息:手机号、身份证号、银行卡号、住址、真实姓名。 如果包含,输出所有敏感片段及其类型;如果不包含,输出EMPTY。 文本:%s """.formatted(userInput); ChatResponse response = judgeModel.chat(ChatRequest.builder() .messages(List.of(SystemMessage.from(prompt), UserMessage.from(userInput))) .build());我这里专门用一个便宜的小型号来做判定,而不是用业务主模型,这样能省不少钱,判定结果也比较稳定。语义识别的准确率依赖模型能力,但它的优势是能捕捉到"虚拟号码段""谐音""口语化表达"这类正则抓不到的东西。实际落地时我不会单独依赖它,而是把它放在正则和名单之后,作为第三道网。
三种策略的正确关系不是互斥,而是层层递进。名单保证100%精确命中,正则覆盖大部分格式固定场景,语义识别兜底那些"人一眼能看出来、机器却难以用规则描述"的情况。我在项目里给出的最终配置是:名单优先,正则随后,语义识别最后,并且三种策略产出的占位符都走同一套还原机制。
4. 脱敏与还原:一次完整的入站-出站过滤链路
4.1 入站阶段的脱敏实现与映射管理
脱敏的核心是"替换"和"可还原"。我在LangChain4j项目中搭建了一个DualStageFilter,专门负责串联整个过滤过程。入站时,对UserMessage做脱敏;出站时,对AiMessage做还原。但这里有个陷阱:如果你无条件把所有占位符都还原,相当于把模型上下文里的敏感信息原样送回用户。所以我把还原策略拆成了两种模式。
第一种模式是"业务可还原"。比如客服内部工作台,坐席需要看到用户的真实手机号以便联系,这种情况下模型输出里的[PHONE]占位符要还原成真实号码。第二种模式是"绝对不可还原"。比如面向C端用户的自动回复,模型一旦生成了一条包含[ID_CARD]占位符的消息,说明模型在尝试把身份证信息输出给用户,这时候不仅不应该还原,还应该直接拦截整条消息,返回默认的"该问题暂时无法回答"。
实现这两种模式,关键点在于占位符本身要有明确的类型标识。我用的占位符格式是"{type:hash}",例如"{PHONE:3f9a}",type标识类型,hash是原文的短哈希。这样在出站阶段,我能根据占位符类型快速决定是否还原,而不需要额外查询映射表。
public class DualStageFilter { private final MaskingService maskingService; // 入站:用户消息 → 模型请求 public ChatRequest filterRequest(ChatRequest request) { List<ChatMessage> filtered = request.messages().stream() .map(msg -> { if (msg instanceof UserMessage userMessage) { String filteredText = maskingService.mask(userMessage.text()); return userMessage.copy().withText(filteredText); } // 历史消息已经是脱敏后的,不需要再处理 return msg; }) .toList(); return ChatRequest.builder().messages(filtered).build(); } // 出站:模型响应 → 用户 public ChatResponse filterResponse(ChatResponse response) { // 先判断是否含敏感占位符,再决定还原或拦截 AiMessage aiMessage = response.aiMessage(); String text = aiMessage.text(); if (maskingService.containsSensitivePlaceholder(text)) { if (securityPolicy.isAllowReveal()) { String restored = maskingService.restore(text); return ChatResponse.builder() .aiMessage(AiMessage.from(restored)) .build(); } else { // 阻止敏感信息外泄 return ChatResponse.builder() .aiMessage(AiMessage.from(securityPolicy.getSafeFallback())) .build(); } } return response; } }4.2 还原映射表的生命周期管理
在实现还原功能时,我犯过一个印象深刻的错误:把所有占位符-原文映射都放在一个ConcurrentHashMap里,结果内存越涨越高。原因很简单,用户每说一次手机号,都会生成一个新的随机哈希,映射表里堆积了大量永远用不到的记录。后来我加入了三个清理机制,才算把问题治住。
首先是会话级生命周期:映射表跟会话绑定,会话结束或者超过24小时无活跃,整张表直接清空。其次是条目级过期:每一条映射记录都带上时间戳,超过30分钟后自动移出映射表,因为模型对话很少在半个小时后还继续被问到同一个占位符。最后是数量上限:单会话最多保留200条映射,超过之后按照LRU策略淘汰最久未使用的条目。
这个清理策略带来的一个小副作用是,如果用户隔了很久又提到同一个占位符,映射表已经被淘汰,出站阶段就还原不了。我的处理方案是,在入站阶段遇到"看起来很像占位符"的文本时,主动查一遍映射表,查不到就说明是用户自己打的文本,继续按正常文本处理,而不是强行还原。这一条看起来不起眼,但能避免很多诡异的问题。
映射表本身也是敏感数据,我强烈建议不要以明文形式放在内存或者数据库里。实际落地时,我把它加密后存到Redis,并设置TTL。即使Redis被拖库,攻击者拿到的也只是一堆密文,没有密钥根本无法还原原文。
4.3 对话记忆里的脱敏也要同步处理
如果你的应用用了LangChain4j的ChatMemory,出现了一个很容易被忽略的细节:消息在入站时被脱敏了,但ChatMemory保存的是脱敏后的消息,那么模型第二次、第三次对话时,看到的历史上下文全部是占位符版本。表面上这没问题,实际问题在于,如果脱敏后的占位符不够语义化,比如用"[PHONE1]""[PHONE2]"这种无意义编号,模型会很难记住"这个占位符对应的是用户刚说过的那个手机号"。
我在实测中用过两套占位符风格,效果差异非常明显。第一套是纯编号:"[1]""[2]",模型基本一头雾水。第二套是类型语义化:"[用户手机号]""[用户身份证号]",模型能比较自然地理解这段文本是在说"某个人的手机号和身份证号",在后续追问中表现明显更好。所以我的建议是:占位符不要刻意搞得毫无语义,保留类型信息比彻底抹掉更有助于模型理解上下文。
当然,占位符语义化也有代价。有些模型接口方可能会把"[用户手机号]"当作某种特殊指令来处理,或者Prompt注入攻击者可以利用这个格式来混淆系统。我加了双重保险:占位符统一用不易冲突的特殊前缀包裹,比如"{{TYPE:HASH}}",平时这个格式几乎不可能出现在正常对话里;同时在后处理阶段,任何不符合格式的"疑似占位符"都视为普通文本,不参与还原。
4.4 完整链路的一次走读
为了让你更直观地理解整条链路,我模拟一个实际请求走一遍。用户输入:"我上次买的手机号是13912345678,能帮我查一下退款到账没有吗?"
入站过滤后,发给模型的文本变成:"我上次买的手机号是{{PHONE:3f9a}},能帮我查一下退款到账没有吗?" 模型返回:"您的退款已经到账,请注意查收。另外,您提到的手机号{{PHONE:3f9a}}确认无误。"
出站过滤时,系统检测到{{PHONE:3f9a}}这个占位符,查询映射表发现原文是13912345678。如果当前会话是客服内部工作台,安全策略允许还原,那么用户最终看到的就是真实手机号。如果当前会话是普通用户端,安全策略禁止还原,那么这条回复会进入二次校验,发现模型主动输出手机号,系统直接拦截,并回复一句"关于退款进度,请您到订单页面查看最新状态"。
这个走读过程看起来顺理成章,但我在开发测试里踩过好几个坑。比如模型偶尔会修改占位符的格式,把"{{PHONE:3f9a}}"缩写成了"{{PHONE}}"甚至"手机号3f9a",导致出站匹配不到标准占位符,还原失败。解决方法是入站时不光替换原文,还给消息末尾加一句系统级提示:"敏感的占位符请原样保留,不要修改。" 同时在出站阶段增加模糊匹配,对"疑似残缺占位符"做一次轻量校准。
5. 实测中的误杀、遗漏与性能:一份可复用的调优清单
5.1 误杀案例:订单号被银行卡正则吞了
我在压测时发现一个高频问题:系统里生成的订单号是纯数字18位,恰好被银行卡正则命中,导致每次查询订单都会把订单号替换成占位符,模型无法理解用户在查什么。这属于典型的"格式撞车"。
我的解决思路并不复杂:给正则规则加上业务上下文前提。具体做法是,先在文本中找到业务实体词(比如"订单号""申请单号"),如果敏感正则命中的数字刚好跟这些业务实体词相邻,那么优先识别为业务编号而不是敏感信息。这个规则听起来简单,但在真实文本里经常出现"订单号13912345678"这种歧义,手机号正则照样可能命中。所以我加了第二条规则:如果该数字片段在业务系统的主键索引中存在,则直接放行。这需要业务系统提供一个只读的查询接口,代价不大,但误杀率显著下降。
5.2 漏网案例:谐音、拆号、口语化表达怎么办
正规则漏掉的不是无规律信息,而是"有规律的变形"。实测中最难处理的是用户有意或无意地拆开敏感信息,比如"我的卡号是6222 0000 1234 5678",空格一加,银行卡正则立刻失效。还有更极端的:"幺三九五六七八九一二",这是典型的方言谐音。
针对拆号,我做了预处理归一化:在入站前先把文本中的空格、横杠、括号等分隔符做一轮标准替换,去除干扰符后,再跑正则。对于谐音,则把常见的方言数字映射表(幺→1、两→2、洞→0)预先做一遍转写,生成一个"候选文本",在候选文本上跑正则,再把命中位置映射回原文本。这套方案不是100%覆盖,但能把漏网率从肉眼可见降到很低的水准。
需要特别提醒的是,归一化不能改用户的原始文本。我是在内存中生成候选件用于匹配,原始消息仍然原样保存,这样既不影响正则命中率,也不会把用户消息改得面目全非。
5.3 性能问题:正则越多,响应越慢
敏感信息过滤是每一条消息都要走一遍的,如果正则规则太多,性能问题会直接体现在接口延迟上。我实测过一版20条正则规则跑在一段500字的文本上,耗时大概在8到12毫秒,看起来不多,但如果是并发500的QPS,这就是纯纯的额外CPU开销。后来我做了三层优化,效果非常明显。
第一层是"短路过滤":先判断文本中是否包含数字、@、手机号特征字符,如果连这些都没有,直接跳过所有正则,裁剪掉约60%的常规文本。第二层是"预编译加线程安全":所有Pattern在类加载时一次性编译成常量,不要每次方法调用都compile一遍,同时用ThreadLocal缓存Matcher复用对象,减少底层开销。第三层是"正则合并":把能合并的规则合并成一个正则,用命名分组区分类型,减少遍历次数。优化之后,同样500字的文本过滤耗时降到了1到2毫秒。
5.4 最终的推荐架构与配置项清单
经过几轮迭代,我最终落地的过滤架构大概分四层:第一层是快速短路,纯字符级判断;第二层是名单屏蔽,维护一份环境隔离的关键词表;第三层是归一化加正则识别,处理格式明确的信息;第四层是语义兜底,用一个廉价模型对高分可疑文本做二次确认。这四层互相配合,既不是每次调用都四层全过,也不是只靠一层硬扛。
我在配置中心里暴露了这样几个开关,方便不同业务线按需调整:
- 过滤级别:0表示关闭,1表示仅名单,2表示名单加正则,3表示全量开启语义兜底。
- 还原策略:只还原业务白名单字段,还是全量还原,还是完全禁止还原。
- 正则白名单前缀:哪些业务编码前缀可以跳过敏感规则。
- 占位符保留时间:映射条目默认有效期。
- 语义兜底的采样率:是每一条都过,还是只对判定为高风险的文本过。
这些配置都支持热更新,运营和算法同学可以按业务场景动态调节,而不用每次改代码发版。
在真实项目中,这套方案让我在保证对话可用性的同时,把隐私风险的暴露面控制在了可接受的范围内。当然,它并不是完美的。语义兜底模型的误判、映射表被淘汰后的还原失败、以及占位符被模型改写,这些问题依然存在。但我可以明显感觉到,问题从"敏感信息裸奔"变成了"个别占位符还原失败"这种可容忍、可追踪的工程缺陷,问题的性质完全不同了。
最后再说一个我个人用得比较多的技巧:尽量让过滤行为可观测。我给每一层过滤都加了计数器和采样日志,记录哪条文本被哪条规则命中、被替换成了什么占位符、最终有没有成功还原。这些数据不仅能帮助你实时发现误杀和漏网,还能在跟业务方对齐需求的时候拿出实实在在的证据,减少很多无意义的争论。敏感信息过滤这件事,永远没有"做完"的那一天,它需要不停地随业务形态、数据样本和模型能力一起演化。