首页 / 资讯中心 / 文章详情

LangChain4j敏感信息过滤实战:三层方案守护LLM数据安全

LangChain4j敏感信息过滤实战:三层方案守护LLM数据安全 ★ FEATURED ARTICLE
做过LangChain4j项目的同学大概率都遇到过这么个情况用户输入里带了手机号、身份证号、一段包含内部接口地址的文本你还没反应过来这些东西已经跟着prompt一起被拼好发给外部模型服务了。敏感信息过滤在LLM应用里不是可选项是必选项尤其是走云上模型API的时候数据出境和合规问题谁碰谁头疼。这道2025年8月24日的高级Java面试题问的是LangChain4j里怎么做敏感信息过滤但本质上考的是你对整个prompt生命周期有没有完整的掌控力。这篇文章我结合自己在实际项目里的落地经验把从模板层、运行时到模型层的三层过滤方案完整拆开讲一遍包含可复用的代码和排查实录给准备面试或者正在做AI应用的同学做个参考。LangChain4j是Java生态里对接LLM的框架封装了聊天模型、嵌入模型、记忆管理、RAG这些能力和Python生态的LangChain定位类似。敏感信息过滤这件事在LangChain4j里没有直接给出一个开箱即用的过滤器但它提供了足够灵活的扩展点你可以从多个层级介入。下面完整梳理一下我在多个项目里验证过的方案。1. 问题场景为什么LLM应用里敏感信息过滤是个真问题1.1 LLM应用里的敏感信息到底指什么很多刚接触LLM开发的同事会有一个误区觉得敏感信息就是用户输入的身份证号、银行卡号、手机号这些个人隐私数据。实际做下去你会发现在LLM应用上下文里敏感信息的范围要宽得多。我在某客服系统项目里就处理过这样的问题用户粘贴了一段企业内部系统的报错日志里面带着内网IP、数据库表名、甚至一段SQL查询条件这些信息对于大模型完成帮我分析报错原因这个任务确实有帮助但它同时也在不被允许的情况下传到了外部模型服务。敏感信息按来源可以分成三类。第一类是用户主动输入的隐私数据比如客服场景里的姓名、电话、地址金融场景里的卡号、身份证号第二类是系统上下文中自动带入的数据比如通过RAG检索出来的内部文档片段、数据库查询出来的业务字段、代码仓库里的配置信息第三类是模型生成的输出中可能泄漏的敏感内容比如模型在回答时把检索到的内部信息原样复述了出来。面试题里只问如何过滤但实际项目中你要三路同时堵。1.2 敏感信息泄漏的典型路径在LangChain4j的架构里消息流向大致是这样的你的业务代码组装ChatMessage交给ChatLanguageModel模型返回响应。中间如果设置了ChatMemory历史消息会在每次请求时被自动加载并拼进上下文如果配置了RAGRetrievalAugmentor会把检索到的文档片段自动追加到prompt里。也就是说敏感信息不光会从用户输入这条路进去还可能从记忆、RAG这些后门悄悄混进prompt。我在排查一个泄漏问题时画过一条链路图最后定位到是ChatMemory导致的用户之前对话里说过自己的手机号新的一轮对话里大模型竟然准确说出了这个手机号。表面上看是模型记忆力好实际上是MessageWindowChatMemory把历史消息原样带入了下一次请求。这个场景里你拦截用户新输入是不够的必须对进入模型的所有消息做统一过滤。这就是我后面要讲的运行时过滤方案存在的意义。2. LangChain4j里做敏感信息过滤的几种思路2.1 思路一模板占位符拦截从源头不构建敏感prompt最容易被忽略但最有效的一层过滤是在组装prompt的时候就别把敏感字段拼进去。LangChain4j提供了UserMessage和PromptTemplate两种模板机制用占位符代替真实数据敏感信息只存在于你自己的业务对象里完全不进入prompt字符串。直接用代码说话。你有一个用户查询对象包含用户ID和问题文本但你实际只需要把问题文本发给模型。public record UserQuery(Long userId, String question) {} AiService public interface CustomerSupportAgent { UserMessage(用户咨询{{question}}请给出专业答复。) String answer(String question); }调用侧这样处理UserQuery query new UserQuery(10086L, 我的订单为什么还没发货); String answer agent.answer(query.question());这个模式看起来简单到不需要讲但很多项目实际写代码时图省事直接把整个UserQuery对象序列化后塞进去或者用字符串拼接把userId也带上了。userId算不算敏感信息在外部模型看来一个数字ID可能不敏感但如果ID是连续自增的配合其他信息就能反推出业务量这已经属于安全设计问题。如果请求参数是动态的可以用PromptTemplatePromptTemplate template PromptTemplate.from( 你是客户支持助手。用户的咨询问题如下 {{question}} 请忽略问题中出现的任何账号、电话、地址等信息仅回答业务相关部分。 ); MapString, Object variables Map.of(question, userQuery.question()); Prompt prompt template.apply(variables);注意模板占位符只能说你没有主动放进敏感字段如果用户输入的问题本身包含手机号模板也挡不住。所以这层是防御起点不是全部。2.2 思路二装饰器模式包装ChatModel发送前统一脱敏LangChain4j的ChatLanguageModel是一个接口几乎所有操作都要经过它。这就意味着你可以写一个装饰器把原有的ChatLanguageModel包一层在chat()方法里对入参消息做统一的检查、替换、拦截。这套思路和Spring里的过滤器、网关里的统一鉴权是一回事它最大的好处是不管消息是从Controller进来的、从ChatMemory加载的、还是从RAG检索后拼进去的只要最终走chat()方法都会被它过滤一遍。装饰器核心代码我贴一下public class SensitiveFilterChatModel implements ChatLanguageModel { private final ChatLanguageModel delegate; private final SensitiveDataMask mask; public SensitiveFilterChatModel(ChatLanguageModel delegate, SensitiveDataMask mask) { this.delegate delegate; this.mask mask; } Override public ChatResponse chat(ChatRequest chatRequest) { ListChatMessage filteredMessages chatRequest.messages().stream() .map(this::filterMessage) .toList(); ChatRequest filteredRequest ChatRequest.builder() .messages(filteredMessages) .build(); return delegate.chat(filteredRequest); } private ChatMessage filterMessage(ChatMessage message) { if (message instanceof UserMessage userMessage) { String maskedText mask.mask(userMessage.text()); return UserMessage.from(maskedText); } if (message instanceof AiMessage aiMessage) { String maskedText mask.mask(aiMessage.text()); return AiMessage.from(maskedText); } // SystemMessage 一般由开发人员编写但保险起见也过一遍 if (message instanceof SystemMessage systemMessage) { String maskedText mask.mask(systemMessage.text()); return SystemMessage.from(maskedText); } return message; } Override public String generate(String prompt) { return delegate.generate(mask.mask(prompt)); } }注意这里有个细节ChatLanguageModel接口除了chat(ChatRequest)之外还有一堆generate相关的方法。如果你只重写chat()那些直接调用generate(String)的代码就绕过了过滤。所以我上面把generate(String)也重写了。实践中有同事只包了chat()结果RAG组件内部调的是generate(String)直接漏了个大洞这个问题我印象很深。2.3 思路三模型层兜底接内容审核模型前两层层只能过滤你已知的敏感模式模型输出里那些隐晦的、变形的、间接描述性的内容规则很难覆盖。比如用户不说银行卡号而是说出卡号是6217开头的那张卡这种语义级的敏感信息正则拿它没办法。LangChain4j提供了ContentModerationModel接口专门用来做内容审核。你可以把它接在ChatModel后面对模型返回的内容再做一次判断如果判定为敏感内容就丢弃或者返回兜底文案。public class ModeratedChatModel implements ChatLanguageModel { private final ChatLanguageModel delegate; private final ContentModerationModel moderationModel; Override public ChatResponse chat(ChatRequest chatRequest) { ChatResponse response delegate.chat(chatRequest); String content response.aiMessage().text(); ModerationResult result moderationModel.moderate(content); if (result.isFlagged()) { return ChatResponse.builder() .aiMessage(AiMessage.from(抱歉我无法提供该内容。)) .build(); } return response; } }这层解决的问题主要是两个场景一是大模型被诱导输出企业内部敏感信息二是对抗prompt注入。我通常建议把内容审核接在输出侧因为输入侧你已经做了模板过滤和规则过滤输出侧是最后一道闸门。当然接内容审核模型有成本和延迟代价所以这个方案我一般只对核心业务链路启用不是所有接口都挂着。3. 实操手写一个可用的敏感信息过滤器3.1 设计统一的SensitiveDataMask接口三套方案里最核心的一个组件是脱敏器——一个能识别敏感信息并替换它的组件。我把它抽象成SensitiveDataMask接口这样不同的脱敏策略可以自由切换。public interface SensitiveDataMask { String mask(String text); }接口很简单真正的工作在实现类里。一个完整的实现要包含三类规则正则规则、词库规则、组合规则。public class DefaultSensitiveDataMask implements SensitiveDataMask { private final ListMaskRule rules; public DefaultSensitiveDataMask() { this.rules List.of( new RegexMaskRule(手机号, 1[3-9]\\d{9}, 手机号已隐藏), new RegexMaskRule(身份证号, \\d{17}[\\dXx], 身份证号已隐藏), new RegexMaskRule(银行卡号, \\d{16,19}, 银行卡号已隐藏), new RegexMaskRule(IPv4地址, \\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b, IP地址已隐藏), new RegexMaskRule(邮箱地址, [A-Za-z0-9._%-][A-Za-z0-9.-]\\.[A-Za-z]{2,}, 邮箱已隐藏), new KeywordMaskRule(内部关键词, List.of(内部接口, 测试环境, 生产密钥), 内部信息已隐藏) ); } Override public String mask(String text) { String result text; for (MaskRule rule : rules) { result rule.apply(result); } return result; } }3.2 基于正则和词库的敏感内容识别实现正则规则和词库规则看起来不复杂但落地的时候容易踩坑。我写一下这两个实现类里需要注意的细节。正则规则的核心逻辑长这样public class RegexMaskRule implements MaskRule { private final String name; private final Pattern pattern; private final String replacement; public RegexMaskRule(String name, String regex, String replacement) { this.name name; this.pattern Pattern.compile(regex); this.replacement replacement; } Override public String apply(String text) { Matcher matcher pattern.matcher(text); // 注意这里不能用 replaceAll(replacement) 直接替换 // 因为不同匹配位置可能对应不同的业务含义统一替换会误伤 StringBuffer sb new StringBuffer(); while (matcher.find()) { String matched matcher.group(); matcher.appendReplacement(sb, buildReplacement(matched)); } matcher.appendTail(sb); return sb.toString(); } private String buildReplacement(String matched) { // 保留前三位和后四位中间用星号代替方便人工核对 if (matched.length() 11) { return matched.substring(0, 3) **** matched.substring(matched.length() - 4); } return replacement; } }这里有两个实用的细节。第一银行卡号的\d{16,19}这个正则很容易误伤订单号、发票号这些同样由数字组成的业务字段所以实际项目中我建议加上前置校验比如必须包含银行名关键词才触发。第二脱敏不一定非得全部替换成***保留部分位数可以让业务方在日志里排查时有线索我在客服项目里保留前3后4手机号11位客服同事反馈好用很多。词库规则更直接public class KeywordMaskRule implements MaskRule { private final String name; private final ListString keywords; private final String replacement; Override public String apply(String text) { String masked text; for (String keyword : keywords) { masked masked.replace(keyword, replacement); } return masked; } }词库规则看起来简单但它有一个关键问题关键词匹配会把内部接口这个词在正常文档里出现的地方也替换掉造成上下文语义破坏。比如你想让模型分析内部接口报错频率高怎么排查结果词被替换了模型根本看不清问题。所以词库规则我建议只在用户输入入口层启用在ChatModel装饰器层尽量少用或者只在系统高敏感词场景用。3.3 把过滤器挂载到你的AiService上LangChain4j里推荐的方式是使用AiServices来构建带AI能力的服务。要把我们上面写的SensitiveFilterChatModel挂载进去只需要在构建时传入这个装饰器即可。ChatLanguageModel baseModel OpenAiChatModel.builder() .apiKey(System.getenv(OPENAI_API_KEY)) .modelName(gpt-4o-mini) .build(); SensitiveDataMask mask new DefaultSensitiveDataMask(); ChatLanguageModel filteredModel new SensitiveFilterChatModel(baseModel, mask); CustomerSupportAgent agent AiServices.builder(CustomerSupportAgent.class) .chatLanguageModel(filteredModel) .build();这样就完成了接入。你的CustomerSupportAgent接口的所有调用在发送给底层模型之前消息里的手机号、身份证号、IP地址都会被自动脱敏。在实际项目里我还踩过一个小坑如果业务代码里直接持有ChatLanguageModel的引用绕过agent接口直接调用model.chat()过滤器就失效了。所以我在团队里定了一个规矩访问外部模型必须通过AiService接口不允许业务代码里直接调用ChatLanguageModel。这个约束比任何代码都有效。3.4 处理RAG检索内容的敏感过滤RAG场景比普通对话要复杂。普通对话你只需要处理用户消息和系统消息RAG则在底层偷偷做了一次文档检索把命中的内容自动拼进上下文。那个内容你可能根本没看过。LangChain4j里RetrievalAugmentor是RAG的核心编排器它内部包含ContentRetriever和ContentAggregator。要对检索结果做敏感信息过滤可以自定义ContentRetriever在返回结果前把敏感内容脱敏掉。public class SensitiveContentRetriever implements ContentRetriever { private final ContentRetriever delegate; private final SensitiveDataMask mask; Override public ListContent retrieve(Query query) { ListContent contents delegate.retrieve(query); return contents.stream() .map(content - Content.from( mask.mask(content.text()), content.metadata() )) .toList(); } }挂载方式RetrievalAugmentor augmentor RetrievalAugmentor.builder() .contentRetriever(new SensitiveContentRetriever(embeddingStoreRetriever)) .build();还有一个更省事的方案直接从检索源头上控制。如果敏感信息存进了向量数据库你检索出来再脱敏信息已经出去一回了。更好的做法是在写入向量数据库前就做脱敏让敏感信息不进向量库。我在实际项目里是两条腿走路入库前脱敏保证源头干净检索后脱敏保证链路兜底。4. 常见坑点与排查技巧实录4.1 只过滤不替换导致上下文语义断裂在我经手的最早一个版本里敏感信息是直接删除的比如把手机号从文本里抠掉。结果模型回答质量肉眼可见地下降。后来想明白了删除操作让句子结构被破坏模型看不懂上下文。比如原文本是我的手机号是13812345678请帮我查一下订单删除后变成我的手机号是请帮我查一下订单模型大概率会疑惑这个是后面跟了什么。而替换成手机号已隐藏这个占位短语句子结构就保住了模型能够理解语义。所以我在所有脱敏场景里都遵循一个原则替换而非删除用固定占位符保持文本结构。占位符可以设计成【隐私】、手机号已隐藏之类不影响语义理解。4.2 日志和持久化里的敏感信息泄漏有一类泄漏是模型层面看不到的但比模型泄漏更常见日志系统。很多同学把用户原始输入直接打进日志或者把prompt内容打印出来做调试敏感信息就这样从日志通道流出去了。LangChain4j本身支持ChatModelListener接口可以监听请求和响应很多人用这个做日志采集但采集的时候没有做脱敏。我在项目里定义了一个规范凡是记录包含用户输入或模型输出的日志必须过SensitiveDataMask凡是往消息队列发数据必须过SensitiveDataMask。代码层面可以用AOP做统一处理也可以直接在logger外面包一层。这块我提供一个简单的实现思路。定义一个脱敏Loggerpublic class SensitiveLogger { private final Logger logger; private final SensitiveDataMask mask; public void info(String message, Object... args) { Object[] maskedArgs Arrays.stream(args) .map(arg - arg instanceof String s ? mask.mask(s) : arg) .toArray(); logger.info(message, maskedArgs); } }虽然看起来简单但它解决了90%的日志泄漏问题。团队所有成员都通过这个类输出日志敏感信息在落盘前就被处理掉了。4.3 性能优化别在主链路里做重正则正则匹配是有性能开销的尤其像银行卡号、身份证号这种长正则在高并发下会成为性能瓶颈。我做过一次压测在未优化的情况下每个请求平均多耗了37毫秒这在QPS过千的接口上是不可接受的。优化的思路有两条。第一条是执行顺序优化先做低开销的检测比如contains()判断是否包含数字或字符不包含就直接跳过正则。第二条是规则分级把高开销的规则放到判断命中后才执行而不是每个文本都跑一遍全部规则。Override public String mask(String text) { if (!hasPotentialSensitiveData(text)) { return text; } String result text; for (MaskRule rule : rules) { result rule.apply(result); } return result; } private boolean hasPotentialSensitiveData(String text) { // 快速判断没有数字、没有、没有常见敏感词就不需要走完整规则 return text.chars().anyMatch(Character::isDigit) || text.contains() || internalKeywords.stream().anyMatch(text::contains); }这个简单的预判逻辑把压测耗时从37毫秒降到了4毫秒以内效果非常明显。4.4 敏感信息过滤后的模型输出一致性处理还有一个容易被忽视的问题你过滤了输入侧模型在回答里仍然可能引用原始信息。我在某个项目里遇到过这样的情况用户问帮我查一下这个手机号13812345678的订单输入被脱敏成手机号已隐藏但模型在回答里说您的手机号13812345678对应的订单是...。模型为什么会知道手机号因为这个手机号在系统提示词里出现过或者它在知识库里存在。这就是为什么我坚持在输出侧也做一次过滤。把模型的回答再过一遍SensitiveDataMask把包含敏感信息的回复替换成相关信息已脱敏请前往人工客服核实。同时我还会在SystemMessage里加一句不得重复用户的手机号、身份证号等敏感信息。这属于提示层面的约束能把误复述的概率降一半以上。4.5 敏感词库的动态更新机制敏感词不是一个静态集合。业务上线新功能后新的敏感词随时可能出现。比如某项目上线了一个新活动内部代号X工程立刻成了敏感词如果在它入库前就有人用它调接口规则完全拦不住。我在项目里做了一套动态更新机制敏感词库放在配置中心应用启动时加载配置中心变更后自动刷新。DefaultSensitiveDataMask里不写死词库而是用一个可变的SensitiveWordStore接口public interface SensitiveWordStore { SetString getKeywords(); ListMaskRule getRegexRules(); }生产环境里这个接口的实现在配置中心读写运维同学不需要改代码、发版本直接调整配置就能让敏感词生效。这个机制上线后敏感词更新从发版2小时缩短到改配置30秒体感提升很明显。4.6 脱敏过滤器在测试环境怎么验证最后说一下测试。敏感信息过滤这类功能很多人觉得逻辑简单写完就不测了。但它的bug通常藏在不常见输入里比如中文混排、全角半角、零宽字符拼接等。我建议在测试集里至少包含这些用例手机号和文字黏在一起我的手机号是13812345678麻烦查一下多个手机号在一句话里身份证号末位是X的情况含小写x文本里包含零宽字符干扰数字中间夹全角空格用这些用例跑一遍DefaultSensitiveDataMask的单元测试大部分实现都会暴露问题。我自己就遇到过零宽字符绕过正则的情况后来在预处理阶段增加了零宽字符清理才解决。这个细节建议面试的时候如果被问到可以主动提一下面试官通常会比较认可你有真实落地的经验。5. 工具选型与实现对比这一节聊聊市面上的现成方案和我为什么最终选择手写。做Java LLM应用的团队敏感信息过滤有两种现成的选择一是用Spring Boot框架里的过滤器机制在HTTP入口统一处理二是用云厂商提供的脱敏服务。它们各有优势但我最终选择了在LangChain4j内部做装饰器模式原因有三个。第一HTTP入口过滤只覆盖了进入Controller的数据覆盖不到ChatMemory回放的消息和RAG自动检索到的内容。敏感信息过滤必须放在prompt组装的最末端才最保险而LangChain4j的ChatLanguageModel正是这个最末端。第二外部脱敏服务会有额外的网络开销和调用成本对于响应时延敏感的业务来说不划算。第三手写的规则引擎完全可控可以随时调整正则和词库而外部服务在策略定制上通常比较受限。我并不是说现成方案不好。如果团队对合规要求极其严格或者敏感数据的种类远超常见类型比如医疗数据、法律文书等需要行业级脱敏能力的场景引入专门的脱敏中台或者云服务更有价值。但对于大多数Java LLM应用来说自研一版SensitiveFilterChatModel配合规则引擎性价比是最高的。6. 扩展思考把敏感信息过滤抽象成架构能力走到这一步敏感信息过滤已经不只是面试题里那个你用了什么正则的问题了。我做完这套方案后有一个体会真正稳固的实现是把过滤能力抽象成架构的一部分而不是散落在各个业务代码里。LangChain4j的架构天然适合这种抽象。ChatLanguageModel是统一出口ChatModelListener是统一监听点ContentRetriever是统一检索入口。你在这些关键节点上挂上过滤逻辑整个应用就等于被自动套上了一层安全网。新来的同事写业务代码不需要理解过滤规则他只要遵循所有外部模型访问走AiService接口这一条约定安全性就有保障。我在团队内推广这套方案时还总结了一条心得敏感信息过滤不是一个功能是一种默认行为。就像数据库连接必须用连接池、对外接口必须做鉴权一样凡是发往外部模型的content默认就要过过滤。没有例外。把这条原则写进团队开发规范之后新项目从一开始就会带上过滤逻辑而不是在安全审计发现问题后才补。最后再分享一个小技巧。敏感信息过滤这套方案不只是高级Java面试题里的一个考点它是你在做LLM应用时必须具备的工程底线。面试官问这道题真正想听的往往不是你背了哪些正则而是你有没有从架构层面思考过数据安全有没有在真实项目里处理过这些琐碎但关键的问题。能讲清楚三层过滤、装饰器模式、RAG和日志的覆盖范围再补一两个你踩过的坑这个回答就已经超过绝大多数候选人了。
阅读完成 · 觉得有帮助?
咨询建站