
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 MapString, String piiDictionary; private final MapString, String maskToOriginal new ConcurrentHashMap(); Override public ChatResponse chat(ChatRequest request) { ListChatMessage 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.EntryString, 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 ListPattern 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) { ListChatMessage 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表示全量开启语义兜底。还原策略只还原业务白名单字段还是全量还原还是完全禁止还原。正则白名单前缀哪些业务编码前缀可以跳过敏感规则。占位符保留时间映射条目默认有效期。语义兜底的采样率是每一条都过还是只对判定为高风险的文本过。这些配置都支持热更新运营和算法同学可以按业务场景动态调节而不用每次改代码发版。在真实项目中这套方案让我在保证对话可用性的同时把隐私风险的暴露面控制在了可接受的范围内。当然它并不是完美的。语义兜底模型的误判、映射表被淘汰后的还原失败、以及占位符被模型改写这些问题依然存在。但我可以明显感觉到问题从敏感信息裸奔变成了个别占位符还原失败这种可容忍、可追踪的工程缺陷问题的性质完全不同了。最后再说一个我个人用得比较多的技巧尽量让过滤行为可观测。我给每一层过滤都加了计数器和采样日志记录哪条文本被哪条规则命中、被替换成了什么占位符、最终有没有成功还原。这些数据不仅能帮助你实时发现误杀和漏网还能在跟业务方对齐需求的时候拿出实实在在的证据减少很多无意义的争论。敏感信息过滤这件事永远没有做完的那一天它需要不停地随业务形态、数据样本和模型能力一起演化。