最近在做一个Java后端接入大模型的项目,先说个现象:用户这边觉得自己在和“智能助手”聊天,那边我们的服务实际上已经把用户输入的手机号、身份证号、银行卡号、家庭住址,连同prompt一起原封不动地发给了外部大模型API。更要命的是,用户偶尔会输入一些看起来“不太政治正确”或带挑衅意味的文本,外部大模型API直接返回一串失败信息:invalid prompt: your prompt was flagged as potentially violating our usage policy。业务方看到这个报错一脸懵,用户看到这个报错以为系统坏了。
这个问题的本质是:大模型不是我们自己的,prompt一旦发出去,脱敏和治理就再也管不住了。所以我在这个项目里做的事情,就是在Java服务端把prompt过滤和敏感信息拦截做成一道独立的前置关卡。这篇文章就是把这套实战做法拆开讲清楚:从背景、设计、代码到调试,完整复现一遍。适合正在接大模型API的后端工程师,也适合准备给团队做AI能力治理的人。
1. 先说背景:为什么Java后端要自己做prompt过滤
1.1 两个真实场景:数据旁路、API误杀
第一个场景是数据旁路。接过大模型API的人都知道,很多大模型服务商在隐私协议里写得清清楚楚:不要在请求里发送敏感的个人信息。但在实际业务里,用户不会关心这些。比如我们的智能客服系统,用户会直接输入“我的手机号是138xxxx8888,帮我查一下订单”。如果后端不做任何处理,直接把整段文本拼到prompt里请求大模型,这串手机号就走出内网了。更隐蔽的是,用户可能无意中把身份证号、银行卡号、甚至企业内部的合同编号作为上下文的一部分粘贴进来。这些都算敏感信息,必须在到达大模型之前拦截或脱敏。
第二个场景是API误杀。大模型服务商有自己的内容安全策略,对prompt做合规检测。用户只要输入一句看似无意义的挑衅语、暴力隐喻、甚至只是带了一堆“最强”“无敌”之类的夸张词,外部API就可能返回类似“invalid prompt: your prompt was flagged as potentially violating our usage policy”的提示。这个报错并不意味着我们一定涉险了什么,但大模型服务方为了合规,采取了相对保守的拦截策略。问题在于:这种拦截对企业用户来说完全黑盒,没有具体指出来哪句话不行,导致业务无法给用户一个合理的解释。
1.2 过滤不是改业务,是给大模型加一道护城河
很多人觉得,过滤敏感信息是合规部门的事,或者觉得大模型API自己已经有内容审核了,后端没必要重复造轮子。我一开始也这么想。后来真正接完才发现,大模型API的审核视角是“大模型服务商的利益”,而不是“我们业务的安全”。服务商关心的是他们的模型不被滥用,至于用户的隐私数据会不会被泄露给第三方,那不是他们首要考虑的事。所以我们不能把敏感信息保护寄托在外部API身上。
在Java服务端做过滤,本质上是在业务和大模型之间加一道护城河。它的职责可以拆成三块:一是明文识别并拦截,在prompt出去之前,把手机号、身份证、银行卡这类明确模式抓出来;二是脱敏处理,既要让大模型理解上下文,又不能暴露真实数据;三是预检,提前发现那些可能导致外部API返回invalid prompt的高风险内容,并给出业务侧可控的反馈。这三件事做完,大模型API那边再接到的prompt,是已经洗干净的、可控的。
我选择用Java而不是Python写这套东西,原因很简单:这笔业务是标准Spring Boot服务,所有用户请求都已经经过Java侧鉴权、风控和日志采集。在Java层做过滤,改动最小,能和现有的用户体系打通,不用为了一个小功能单独拉起一条Python链路。后续如果要扩展规则,也不影响主业务。
2. 整体设计:过滤与拦截链路怎么搭才不脏
2.1 分层设计:入口控制器到外部客户端的完整链路
我先说结论:不要把所有过滤逻辑堆在Controller里,也不要一股脑放在调用大模型的Service里。这两种做法都会让代码迅速烂掉。我的做法是拆成一条过滤链,从请求进来到发往大模型之间,经历完整的分层处理。
- Controller层:只接收用户输入,不做任何业务判断,直接透传给一个专门的PromptFilterChain。
- PromptFilterChain:负责串联多个过滤器,每个过滤器只做一件事,返回过滤结果。
- BusinessPromptService:拿到过滤后的干净文本,拼装系统提示词和上下文。
- LLMClient:真正的HTTP客户端,负责调用外部大模型API,并处理底层异常。
这样做的好处是,每个环节都能单独替换。比如我们把大模型服务商从A换成B,LLMClient换掉就行,过滤链完全不用动。又比如敏感词库更新了,只需要改敏感词过滤器内部实现,不影响其他过滤器。
判断过滤结果的模型,我建议用一个统一的结果对象,至少包含两个字段:过滤后的文本、命中的规则列表。只有文本没有规则列表,后续排查时会很痛苦,你不知道这段文本是因为手机号被换掉的,还是因为长度被截断的。我们项目里GenResult大概长这样:
public record PromptFilterResult( String filteredPrompt, List<HitRule> hitRules, boolean rejected, String rejectReason ) {}每个HitRule记录规则类型、命中内容、命中的起止位置、替换后的内容。后面做日志和测试用例断言时,这些信息非常有用。
2.2 三类检查与执行顺序
接下来是执行顺序的问题。我踩过坑:一开始把最耗时的文本分类模型放在最前面,结果所有请求都先走一遍大模型分类,延迟飙升到4秒。后面才悟出,过滤链的顺序应该遵循“从便宜到贵、从硬性到软性”的原则。
具体分三类:
- 硬性拦截类:正则匹配手机号、身份证、银行卡,Trie树匹配敏感词,这些计算量小、准确率相对高,应该排在最前面,能直接拦就拦,该拒绝就拒绝,该脱敏就脱敏。
- 软性审核类:比如对一段文本是否包含诱导性、辱骂性内容做判断。如果只是普通敏感词命中,硬性拦截就能覆盖;如果涉及复杂语义,可以交给规则引擎或轻量分类模型处理,但这类开销较大,排在第二梯队。
- 上下文治理类:token预算计算、上下文窗口裁剪、系统提示词拼接,放在最后,因为这时候文本已经基本干净了,再去做长度控制才准确。
排序逻辑其实很简单:先做“即使误杀也影响不大的检查”,再做“需要更精准判断的检查”。比如手机号正则,基本不会误杀正常业务文本;但“无敌”“最强”这类词,如果做成硬拦截,会把很多正常的推广文案干掉,所以这类要放到后面,用更细的规则处理。
2.3 几个值得提前做的技术选型
技术选型这里我多说两句,因为这些决定后面代码的走向。
敏感词匹配引擎,我有三个候选:正则、基于DFA的Trie树、调用外部敏感词API。正则适合模式固定、变化少的内容,比如手机号。Trie树适合词库型敏感词,比如公司内部自定义的禁用词。外部API当时被我一票否决了,因为它引入了新的网络依赖,而且敏感词还要流转到第三方,那和直接把prompt发给大模型的区别不大。最终方案是正则加Trie树结合使用。
脱敏算法,需要按字段类型区分。手机号保留前三位和后两位,中间用星号替代;身份证保留前两位和后四位;银行卡保留前六位和后四位;人名和地址这种不规律字段,直接替换成一个占位符。
我这里列一下各类字段的脱敏策略,方便对照参考:
| 字段类型 | 匹配方式 | 脱敏策略 |
|---|---|---|
| 手机号 | 正则1[3-9]\d{9} | 保留前3后2,中间用*替代 |
| 身份证号 | 正则\d{17}[\dXx] | 保留前2后4,其余用*替代 |
| 银行卡号 | 正则\d{12,19} | 保留前6后4,其余用*替代 |
| 姓名 | 词典/上下文规则 | 统一替换为[姓名] |
| 地址 | 词典/上下文规则 | 统一替换为[地址] |
| 企业合同编号 | 前缀+数字规则 | 统一替换为[合同编号] |
这些策略听起来简单,真正的坑在于“匹配和替换不能破坏用户本来的语义”。比如用户在一句话中间夹着手机号,你光把手机号替换成138****8888,上下文依然通顺;但如果把身份证号替换成[身份证],用户问“为什么显示不了”,我们得知道这是脱敏生效了,而不是系统故障。
3. 核心实现:Java里怎么做敏感信息拦截
3.1 敏感词检测:Trie树还是正则
先说正则。这个最直观,比如手机号,1[3-9]\d{9}就够了。但单靠正则做不了所有事情,比如企业内部的敏感项目代号、友商名称、违规变现的黑话,根本不能用正则穷举,这时候就要上Trie树。
Trie树也叫字典树,核心思想是把词库拆成树形结构,匹配时沿着字符逐层往下走。Java里可以用Map嵌套实现,也可以用现成的库,比如aho-corasick算法的实现。我们当时为了不引入太多依赖,手写了一个简单的DFA版本,大概这样:
public class SensitiveWordTrie { private final Map<Character, Map> root = new HashMap<>(); public void addWord(String word) { Map<Character, Map> node = root; for (char c : word.toCharArray()) { node = node.computeIfAbsent(c, k -> new HashMap<>()); } node.put('$', Map.of()); // 结束标记 } public List<String> match(String text) { List<String> hits = new ArrayList<>(); for (int i = 0; i < text.length(); i++) { Map<Character, Map> node = root; int j = i; while (j < text.length() && node.containsKey(text.charAt(j))) { node = node.get(text.charAt(j)); if (node.containsKey('$')) { hits.add(text.substring(i, j + 1)); } j++; } } return hits; } }这段代码简化了匹配过程,实际生产还要处理跳过字符、拼音替换、同音词等问题。但核心思路是对的:词库加载到内存后,匹配速度很快,几万个敏感词也就几毫秒。
不过我要提醒一句:Trie树匹配的问题是“贪婪匹配”,很可能命中一个词内部的子串。比如黑话词典里有“发票”,用户输入“代开发票”,Trie树会命中“开发票”吗?不会,但如果词库里有“开发”,就会先命中“开发”。所以生产里我建议匹配后按“最长优先”做一次排序,只取最长命中,避免一个句子被重复替换成碎片。
正则和Trie树在代码里的调用方式,我们用策略接口统一收口:
public interface SensitiveMatcher { List<MatchedSegment> match(String text); }手机号正则实现一个PatternSensitiveMatcher,词库实现一个TrieSensitiveMatcher,两个都返回命中段,然后由上层统一处理。这样将来再加一个新的匹配器,比如基于机器学习的小模型,只需再实现一个接口即可。
3.2 脱敏与拒绝:策略模式实现不同响应
检测到敏感信息之后,动作不是只有一种。我定义了三种策略:REJECT(直接拒绝请求)、MASK(脱敏后再发给大模型)、REPLACE(用占位符替换)。选哪种,取决于敏感信息的类型和业务场景。
比如用户输入“我的手机号是138xxxx8888,帮我查订单”,这个场景中手机号是业务必需信息,不能直接拒绝,否则业务就断了。应该走MASK策略,把手机号换成138****8888,然后把脱敏后的文本发给大模型。大模型虽然看不到完整号码,但能理解用户在表达什么。如果后续真的需要查订单,用户会在业务系统里另行授权,那是另一个校验流程。
反过来,如果用户输入了一段带辱骂性词汇的挑衅文本,这属于REJECT场景。策略是返回一个明确错误,让前端展示“您输入的内容包含不合适的信息”,而不是一个空洞的invalid prompt报错。
实现上,我推荐策略模式加上Chain of Responsibility组合。每个过滤器都返回自己的处置结果,如果有一个过滤器直接REJECT,整条链就中断,不再往下走。如果只是MASK,则把替换后的文本继续传给下一个过滤器。这里有一段核心代码:
public class PhoneNumberMasker implements PromptFilter { private static final Pattern PHONE = Pattern.compile("1[3-9]\\d{9}"); @Override public void doFilter(String input, PromptFilterContext context) { if (input == null || input.isBlank()) { context.setFilteredText(""); return; } Matcher matcher = PHONE.matcher(input); if (matcher.find()) { String masked = matcher.replaceAll(m -> { String s = m.group(); return s.substring(0, 3) + "****" + s.substring(7); }); context.setFilteredText(masked); context.addHit(new HitRule("PHONE", masked)); } } }这里有个容易被忽略的细节:脱敏时如果直接调用matcher.replaceAll,它会处理所有匹配项,但如果有多个手机号,后面被遮住的信息可能因为前面被替换,导致脱敏后顺序错位。我们用了Matcher.appendReplacement或流式替换,保证每次替换基于原串而不是已替换的串。上面这段代码为避免乱序,用了lambda在Matcher上动态替换,实际上replaceAll(Function<MatchResult, String>)是Java 9引入的,底层已经处理好了,可以直接用。
REJECT的实现也简单,本质是抛一个业务异常或者返回一个PromptFilterResult.rejected=true。我不建议在过滤器内部直接抛异常,因为这样会丢失其他过滤器的上下文。建议每个过滤器都往Context里追加命中的规则,最后一个过滤器再统一判断是否reject。这样日志里能看到所有命中的敏感点,而不是只有第一个。
3.3 token预算控制与上下文裁剪
大模型API按token计费,同时上下文窗口还有上限。Java后端常见问题是:用户输入的prompt + 系统提示词 + 历史对话,加起来可能超过模型的上下文长度。如果不做控制,要么请求直接报错,要么预算超支。
我先说token估算。不能把字符串长度当作token数,因为英文大概4个字符一个token,中文一个汉字可能是一个或多个token。我们使用了一个近似估算公式:英文字符数除以4,中文字符数单独统计,加上基础系数。如果项目对精度要求高,可以引入服务商提供的tokenizer库,但那个包通常比较大。我自己更推荐在Java侧做一个简化版估算器,误差控制在正负10%以内就够了,因为预算控制本身不需要极其精确。
public class TokenEstimator { public static int estimate(String text) { if (text == null) return 0; int chineseCount = 0; int otherCount = 0; for (char c : text.toCharArray()) { if (c >= 0x4E00 && c <= 0x9FA5) { chineseCount++; } else { otherCount++; } } return (int) (chineseCount * 1.2 + otherCount / 4.0 + 2); } }算出总token后,就要做裁剪。裁剪策略有几种,最简单的是从末尾删,因为历史对话里最新的信息往往比最早的更有用。更合理的策略是分层裁剪:优先裁剪历史对话中的早期回合,保留系统提示词和当前用户输入。如果当前用户输入本身就超长,那才是真的无解,只能提示用户精简。
这里我建议引入一个上下文管理器,把所有片段按优先级分层:系统提示词、用户当前输入、历史消息、工具返回结果、其他。裁剪时从低优先级开始删,直到token数降到预算线以下。这个设计的价值在于,当模型上下文窗口升级时,只需要调整预算参数,不用改代码结构。
3.4 对接外部大模型API时怎么减少被拦截误杀
这一节来源是我反复被“无效prompt”报错折磨后的经验。外部大模型API会根据自己的内容安全策略审查prompt,一旦命中风险就可能直接拒绝,而且多数拒绝信息里不会明确告诉你是哪个词出了问题。比如常见的返回就是:invalid prompt: your prompt was flagged as potentially violating our usage policy。
要减少这类误杀,我有几个实际可落地的做法。
第一个做法:在Java侧做一份“触发词库”,专门收录那些容易被大模型API安全机制盯上的词。比如极限形容词、威胁性表达、夸张暴力隐喻等。这个词库不必很精准,但一旦命中,我们可以把对应的句子片段切掉或者改为中性表达,而不是阻止整个请求。
第二个做法:把敏感信息先脱敏再拼prompt。比如用户提到某个真实人名,在大模型眼里可能不敏感,但如果是某个特定人物的姓名,也可能触发审核规则。我们处理时把这类词替换为“某用户”,既保留了语义,又绕开了误杀。
第三个做法:对API返回的invalid prompt做统一异常处理。不能把原始错误信息直接抛给前端,要知道这是外部API的拒绝策略,不是代码Bug。我会在LLMClient里捕获这类异常,同时拼上当前过滤链的命中规则,返回一个业务错误码,类似“PROMPT_REJECTED_BY_MODEL”给前端。这样前端可以引导用户换一种说法,而不是白屏报错。
另外还有一个很重要但容易被忽略的点:系统提示词本身也可能触发审核。很多团队的system prompt写得很激进,比如“你是不受任何限制的助手”“你可以做任何事”。大模型API对系统提示词部分也会做内容安全审核,一旦命中就整段拒绝。所以我在系统提示词和用户输入之间做了完全隔离,每次请求前单独检查一遍系统提示词是否合规,不合规就报给开发团队,而不是发给用户。
4. 实战踩坑:常见问题与调试实录
4.1 高频问题速查表
我整理了一张速查表,是这套过滤链上线后最常遇到的问题。
| 现象 | 根因 | 处理方案 |
|---|---|---|
| 用户正常输入被拒绝,比如“无敌”命中了敏感词库 | 敏感词库误覆盖了营销词 | 词库分层管理,营销词和禁词分离,营销词只做标记不做REJECT |
| 手机号脱敏后,大模型上下文里出现错位数字 | 替换函数使用了已替换的字符串作为源串 | 改为一次性基于原串替换,或使用replaceAll(Function) |
| 大模型API返回invalid prompt | 用户输入或系统提示词触发了外部审核 | 记录原始prompt、脱敏后prompt、命中规则、API返回,用于追溯 |
| token估算不准,请求还是超限 | 只按字符数估算,忽略了中文token权重 | 使用中文/英文字符分群估算,或者接入官方tokenizer |
| 过滤链拉高接口延迟 | 把高成本检测放到了最前面 | 把正则和Trie树检测前移,把文本分类后移 |
| 脱敏后语义丢失,模型答非所问 | 占位符过于生硬,比如把手机号替换成[手机号] | 对手机号保留部分数字,对人名保留姓氏,对地址用模糊级别 |
| 缓存了脱敏后的prompt,导致所有用户共享同一段上下文 | 缓存key未包含脱敏后的原始hash | 缓存key用原始输入hash,同时区分多轮会话 |
这些坑都不是靠看文档能看出来的,基本都要上线后观察告警才能发现。
4.2 日志链路:看清是谁拦了谁
我强烈建议在过滤链入口处打一条结构化日志。不用什么高级框架,就把过滤前后的文本、命中规则、是否拒绝、耗时这几个字段记下来,放到日志平台里。我们项目里用的是JSON日志,字段大概是这样的:
{ "event": "prompt_filter", "traceId": "xxx", "userId": "u12345", "originPrompt": "我的手机号是138xxxx8888", "filteredPrompt": "我的手机号是138****8888", "hitRules": ["PHONE"], "rejected": false, "elapsedMs": 3, "modelApiStatus": "SUCCESS" }有了这份日志,排查“为什么这次返回invalid prompt”时,可以直接看originPrompt和filteredPrompt的差异。如果发现脱敏后的prompt还是被外部API拒绝,基本可以断定是系统提示词或者某些约定俗成的语料触发了审核,和用户输入没关系。
这里我多说一个细节:日志里不要记录完整的敏感信息原文,尤其手机号、身份证号,否则日志本身就成了数据泄露出口。我建议记录脱敏后的值或者该字段的SHA-256摘要,需要排查时再对照摘要。别小看这个细节,合规评审时很重要。
4.3 测试用例:用“脏数据”压测过滤效果
最后这部分聊聊测试。过滤链不是写完就能上线就完事的,必须建立一套“脏数据”测试集,持续回归。
我们的测试集分三层:第一层是单元测试,直接构造一些只包含手机号、身份证号的字符串,验证过滤链是否正确脱敏;第二层是集成测试,模拟真实用户输入,拼接完整的系统提示词和上下文,验证外部API调用不会被无辜拒绝;第三层是压测,用几千条混合文本灌进过滤链,看看平均延迟是否在可接受区间。
一个可以抄作业的JUnit测试写法:
@Test void shouldMaskPhoneNumber() { PromptFilterChain chain = buildDefaultChain(); PromptFilterResult result = chain.filter("联系我13812345678,谢谢"); assertEquals("联系我138****5678,谢谢", result.filteredPrompt()); assertFalse(result.rejected()); assertTrue(result.hitRules().stream().anyMatch(r -> "PHONE".equals(r.type()))); }测试集里除了正常场景,我还会塞一些特别的变体,比如手机号中间带空格、身份证末尾带X、银行卡号和订单号混在一起。因为用户在文本框里什么格式都会输入,只有测试集够刁钻,上线后才不会被真实数据打脸。
回归测试频率我建议每周跑一次,敏感词库更新后必须全量回归。很多团队因为一次误杀就把用户投诉了,就是因为没有及时回归。
最后说一句个人体会:这套过滤链上线后,外部大模型API的invalid prompt报错率下降了接近70%,敏感信息外发也完全堵住了。Java后端做这个事,最大的好处就是能把合规要求落到代码里,而不是依赖业务自觉。每个过滤器的逻辑都很直白,哪怕过三个月再看代码,也能一眼看出这段是干什么用的。我自己的经验是,过滤规则宁可在初期保守一点,先拦截、再脱敏、最后才考虑放行。等日志数据和用户反馈积累够了,再逐步放开规则,这样整体风险是可控的。
如果要继续扩展这套东西,我下一步会做两件事:一是把敏感词库和脱敏策略做成配置中心可视化管理,让运营同学自己调整,不用每次改代码发版;二是给过滤链加一个AB测试开关,小流量灰度新的过滤规则,对比用户投诉率和API成功率,确认没问题再全量。这些都是后话,但基础架构已经留好了位子。