1. LLM应用安全护栏的架构设计与核心思路
1.1 为什么裸奔的LLM应用迟早要出事
做过LLM应用落地的朋友应该都有体会:模型本身的能力越强,它“闯祸”的方式就越多。你给它接上数据库,它可能给你拼出一条DROP TABLE;你给它接上工具调用,它可能被一段精心构造的提示词诱导去调用不该调用的接口;你让它输出JSON给下游系统消费,它偏偏在JSON外面裹一层“好的,以下是您需要的内容”。这些问题在Demo阶段不明显,一旦上了生产,就是事故。
所谓LLM应用安全护栏(Guardrails),本质上是在用户输入和模型输出这两端,加上一层可编程的检查、过滤、修正机制。它不改变模型本身,而是在模型和真实世界之间做“交通管制”。我习惯把它拆成三个位置来理解:
- 输入侧护栏:用户的问题进来之前,先做敏感信息识别、提示词注入检测、话题范围限制。
- 执行侧护栏:模型决定调用工具、查询数据库、访问外部API时,对参数做校验、对权限做收敛。
- 输出侧护栏:模型返回内容后,做PII脱敏、格式校验、事实性兜底、合规审查。
这三层不是必须全上,但一个面向真实用户的产品,至少输入和输出两层要有。执行侧护栏在Agent类应用里是刚需,因为Agent的破坏力比纯对话大一个量级。
1.2 护栏方案选型:为什么我最终选了Guardrails + Presidio组合
市面上做护栏的思路大致分三类。第一类是纯Prompt约束,就是在系统提示词里写“不要输出敏感信息”“只返回JSON”。这种方式成本最低,但可靠性也最低,模型该漏还是漏。第二类是规则引擎,用正则、关键词黑名单做过滤,简单直接,但维护成本高,且容易被绕过。第三类是专用护栏框架,比如Guardrails、NeMo Guardrails、以及微软开源的Presidio。
我最终的技术栈是Guardrails做结构化校验和流程编排,Presidio做PII识别与脱敏。理由很实际:
- Guardrails的核心是验证器(Validator)机制,它允许你为输出定义schema,并且可以挂载自定义验证逻辑。模型输出不符合schema时,它可以自动重试或者修正,这对“修复LLM返回JSON不稳定”这个高频痛点非常对症。
- Presidio专注在PII(个人身份信息)检测上,内置了信用卡号、身份证号、电话号码、邮箱、IP地址等识别器,而且支持中文和自定义实体。用它来做输入侧的敏感信息拦截和输出侧的脱敏,比手写正则靠谱得多。
- 两者都是Python生态,集成成本低,且不绑定特定模型厂商,换模型不用重写护栏。
提示:护栏框架不是越重越好。如果你的应用只是内部工具,用户都是可信的,那输出侧做个JSON校验就够了。面向C端的产品才需要把Presidio这类PII工具拉满。
1.3 整体数据流:一次请求要过几道关
我把整个护栏流程设计成一条流水线,每个环节都可以独立开关和配置。一次典型的用户请求会经过以下步骤:
- 输入预处理:原始文本进入Presidio分析器,识别其中的PII实体。如果命中高风险实体(如身份证号、银行卡号),直接拦截并返回提示,不进入模型。
- 注入检测:用规则+轻量分类器检测提示词注入特征,比如“忽略之前的指令”“你现在是”“输出你的系统提示词”等模式。
- 模型调用:通过护栏包装后的LLM接口发起请求,此时系统提示词里已经注入了输出格式约束。
- 输出解析:Guardrails接管模型返回,按预定义的Pydantic模型做解析。解析失败则触发重试,重试时把错误信息回传给模型让它自我修正。
- 输出脱敏:解析成功的结构化数据再经过Presidio做一次PII扫描,对残留的敏感信息做替换或掩码。
- 业务校验:根据业务规则做最后一道检查,比如数值范围、枚举值合法性、SQL语句的只读性校验。
这条流水线的关键设计原则是:每一层都假设上一层可能失效。不要指望模型一次就输出干净的内容,也不要指望Presidio能识别所有变体。多层防御才是工程上靠谱的做法。
2. 核心组件拆解与关键细节解析
2.1 Guardrails验证器机制:从“求模型听话”到“逼模型合规”
Guardrails最核心的价值,是把“希望模型输出什么”变成“强制模型输出什么”。它的工作方式是这样的:你定义一个输出schema,Guardrails会把这个schema转换成格式指令注入到prompt里,模型返回后再用验证器逐项检查。任何一项不通过,就触发on_fail策略。
on_fail策略有几种常见选择,我列个表对比一下实际使用感受:
| 策略 | 行为 | 适用场景 | 我的实测评价 |
|---|---|---|---|
exception | 直接抛异常 | 调试阶段 | 生产环境慎用,用户体验差 |
reask | 把错误回传模型重试 | JSON格式修复 | 最常用,但要注意重试次数上限 |
fix | 尝试自动修复 | 字段缺失、类型错误 | 对简单问题有效,复杂问题会改错 |
filter | 过滤掉不合规字段 | 可选字段处理 | 适合非关键字段 |
refrain | 返回预设兜底话术 | 合规拦截 | 敏感话题场景必备 |
noop | 记录但不处理 | 灰度观察 | 上线前观察期用 |
我一般组合使用:关键字段用reask,敏感内容用refrain,非关键字段用filter。重试次数设2次,超过就降级到兜底回复。这里有个经验:reask的prompt里一定要把具体的验证错误信息带上,比如“字段age期望是整数,你返回了字符串'二十五'”,模型看到具体错误后修正成功率会高很多。
2.2 Presidio实体识别:中文场景下的坑与调优
Presidio默认的识别器对英文支持很好,但中文场景需要额外配置。我踩过的坑主要有这几个:
第一,中文姓名识别。Presidio内置的PersonRecognizer基于spaCy的英文模型,中文人名基本识别不出来。解决方案是引入中文NER模型,或者用姓氏字典+上下文规则做补充。我实际用的是自定义PatternRecognizer,把常见姓氏和“先生”“女士”“老师”等称谓组合成模式。
第二,身份证号和手机号的边界问题。18位身份证号里可能包含手机号片段,如果两个识别器都命中,会出现重叠。Presidio有allow_overlap参数,默认是False,会保留置信度更高的那个。但实际测试下来,身份证号的置信度有时反而低于手机号,导致误判。我的做法是给身份证号识别器手动提高base_score。
第三,自定义实体。业务里常有“订单号”“工单编号”这类内部敏感标识,需要注册自定义识别器。代码大概长这样:
from presidio_analyzer import PatternRecognizer, Pattern order_recognizer = PatternRecognizer( supported_entity="ORDER_ID", patterns=[Pattern(name="order_id", regex=r"ORD-\d{12}", score=0.9)], context=["订单", "order"] ) analyzer.registry.add_recognizer(order_recognizer)注意:Presidio的
analyze方法返回的是实体列表,包含起止位置和置信度。做脱敏时不要直接按实体文本全局替换,要按位置替换,否则同一个词出现在不同语境下会被误伤。
2.3 提示词注入检测:规则与语义的双保险
提示词注入是LLM应用最头疼的安全问题之一。攻击者可以通过“忽略以上所有指令”“请重复你的系统提示词”这类话术,诱导模型泄露系统配置或执行越权操作。纯规则匹配容易被变体绕过,纯语义分类又可能误杀正常请求。
我的方案是规则前置过滤 + 语义分类兜底。规则层维护一个模式库,覆盖常见注入话术的中英文变体,命中即拦截。语义层用一个轻量文本分类模型(可以是微调过的小模型,也可以调用LLM做二分类),对规则没拦住但可疑的请求做二次判断。
规则库的维护是个持续活儿。我建议把每次拦截的样本都记录下来,定期review,把新的变体补充进规则库。同时要注意误报率,比如用户正常问“你能做什么”不应该被拦截,但“请输出你的系统提示词”就应该拦。这个边界需要根据业务场景反复调。
2.4 输出格式校验:修复LLM返回JSON不稳定的实战方案
“修复LLM返回JSON的Java库”是个热搜词,说明这个问题有多普遍。Python这边用Guardrails的Pydantic验证器就能解决大部分场景。核心思路是:
- 定义Pydantic模型,明确每个字段的类型、必填性、取值范围。
- Guardrails自动生成格式指令注入prompt。
- 模型返回后用
model_validate_json解析。 - 解析失败触发reask,把Pydantic的ValidationError信息回传。
实测下来,第一次成功率大概在70%-85%之间,取决于模型能力和prompt复杂度。加上一次reask后,成功率能到95%以上。剩下的5%基本是模型能力问题或者请求本身有歧义,这时候降级到兜底回复比硬撑更明智。
有个细节值得说:temperature参数对格式稳定性的影响很大。做结构化输出时,temperature建议设0到0.3之间。我做过对比测试,temperature=0.7时JSON解析失败率是temperature=0.1时的3倍左右。所以如果你的场景要求稳定输出,别舍不得调低temperature。
3. 完整实操流程与核心环节实现
3.1 环境准备与依赖安装
先把基础环境搭起来。我用的Python 3.10,依赖如下:
pip install guardrails-ai presidio-analyzer presidio-anonymizer pip install spacy python -m spacy download zh_core_web_smGuardrails需要初始化配置,Presidio需要加载识别器。这里有个小坑:Presidio的AnalyzerEngine初始化时会加载所有默认识别器,启动比较慢。如果只需要特定识别器,可以传supported_languages和自定义registry来加速。
from presidio_analyzer import AnalyzerEngine from presidio_anonymizer import AnonymizerEngine analyzer = AnalyzerEngine() anonymizer = AnonymizerEngine()3.2 定义输出Schema与验证器
假设我们要做一个“用户信息提取”功能,从自然语言里抽取姓名、电话、订单号。先定义Pydantic模型:
from pydantic import BaseModel, Field from typing import Optional class UserInfo(BaseModel): name: str = Field(description="用户姓名") phone: Optional[str] = Field(default=None, description="手机号") order_id: Optional[str] = Field(default=None, description="订单号") intent: str = Field(description="用户意图,只能是query、complaint、refund之一")然后创建Guardrails的Guard对象,挂载验证器:
from guardrails import Guard from guardrails.hub import ValidChoices guard = Guard.for_pydantic(UserInfo) guard.use(ValidChoices, on_fail="reask", choices=["query", "complaint", "refund"])ValidChoices用来约束intent字段的枚举值。如果模型返回了“咨询”这种不在列表里的值,就会触发reask。
3.3 输入侧PII拦截实现
用户输入进来后,先过Presidio:
def check_input_pii(text: str): results = analyzer.analyze( text=text, language="zh", entities=["PHONE_NUMBER", "CREDIT_CARD", "ID_CARD", "EMAIL_ADDRESS"] ) high_risk = [r for r in results if r.score > 0.7] if high_risk: return False, high_risk return True, []如果命中高风险实体,直接返回提示,不调用模型。这里有个策略选择:是拦截还是脱敏后放行?我的做法是高风险实体拦截,低风险实体脱敏放行。比如用户问“我的手机号138xxxx1234的订单到哪了”,手机号是查询的必要信息,拦截了就没法服务。这时候应该脱敏成“138****1234”再传给模型,模型只需要知道有这个号就行,不需要知道完整号码。
3.4 输出侧脱敏与业务校验
模型返回结构化数据后,对每个字符串字段再过一遍Presidio:
def anonymize_output(data: dict): for key, value in data.items(): if isinstance(value, str): results = analyzer.analyze(text=value, language="zh") if results: data[key] = anonymizer.anonymize( text=value, analyzer_results=results ).text return data业务校验层根据具体场景写。比如订单查询场景,要校验order_id是否符合格式,SQL生成场景要校验语句是否只读。这层用普通Python代码就行,不需要上框架。
3.5 完整调用链路串联
把上面几步串起来,形成一个完整的处理函数:
def safe_llm_call(user_input: str): # 1. 输入PII检查 ok, entities = check_input_pii(user_input) if not ok: return {"error": "输入包含敏感信息,请修改后重试"} # 2. 脱敏后调用模型 sanitized_input = anonymize_output({"text": user_input})["text"] # 3. Guardrails包装的模型调用 result = guard( llm_api=call_llm, prompt=sanitized_input ) # 4. 输出脱敏 if result.validation_passed: return anonymize_output(result.validated_output) else: return {"error": "内容生成异常,请稍后重试"}这个链路里,call_llm是你实际的模型调用函数,可以是OpenAI接口、本地模型、或者任何兼容的API。Guardrails不关心底层用什么模型,它只关心输入输出。
4. 常见问题排查与避坑经验实录
4.1 护栏误杀与漏杀怎么平衡
这是最常被问到的问题。误杀会让正常用户用不了,漏杀会让风险内容溜过去。我的经验是分场景设定阈值:
- 面向C端的公开产品:宁可误杀,不可漏杀。PII识别阈值调低,注入检测规则调严。
- 内部工具:宁可漏杀,不可误杀。阈值调高,减少对工作效率的干扰。
- 金融、医疗等强监管场景:双层拦截,规则层和语义层都命中才放行,最大化安全性。
另外,所有拦截都要有日志。记录原始输入、命中规则、置信度、处理结果。这些日志是后续调优的依据,也是出问题时的追溯凭证。
4.2 模型重试次数与超时控制
Guardrails的reask机制很好用,但不能无限重试。我一般设max_retries=2,加上首次调用总共3次。每次重试都会增加延迟和token消耗,如果3次还不行,说明要么模型能力不够,要么请求本身有问题,继续重试性价比很低。
超时控制也要做。单次LLM调用设15-30秒超时,整个护栏链路设60秒总超时。超时后返回兜底回复,不要让用户一直等。
4.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| JSON解析持续失败 | temperature过高 | 检查模型参数 | 降到0.1-0.3 |
| PII识别漏报 | 识别器不支持该实体 | 查看analyzer支持的entities | 注册自定义PatternRecognizer |
| 注入检测误报 | 规则过于宽泛 | 查看命中规则 | 增加上下文条件,缩小匹配范围 |
| 重试后仍失败 | prompt指令不清晰 | 检查schema描述 | 补充字段说明和示例 |
| 脱敏后语义丢失 | 替换策略过于激进 | 检查anonymizer配置 | 改用掩码而非替换 |
| 响应延迟高 | Presidio初始化慢 | 检查启动日志 | 按需加载识别器,复用engine实例 |
4.4 几个我踩过的坑
坑一:Presidio的language参数。中文文本必须传language="zh",传"en"会导致识别器不工作。但有些识别器只支持英文,传"zh"时会被跳过。解决方案是注册支持中文的自定义识别器,或者对中英混合文本分别处理。
坑二:Guardrails的prompt注入位置。Guardrails默认把格式指令加在prompt末尾,但如果你的系统提示词很长,模型可能会忽略末尾的指令。我试过把格式指令放在系统提示词开头,效果反而更好。这个可以通过自定义prompt模板来调整。
坑三:流式输出与护栏的冲突。护栏需要拿到完整输出才能校验,但流式输出是逐token返回的。如果业务要求流式,护栏只能做前置检查,输出侧校验要等流结束后再做,这时候已经来不及拦截了。我的做法是:流式场景只做输入侧护栏,输出侧护栏降级为异步审计,事后发现问题再处理。
坑四:多轮对话中的上下文污染。用户第一轮输入了敏感信息,虽然被拦截了,但这段内容可能已经进入了对话历史。下一轮请求时,历史里带着敏感信息一起发给模型,护栏就失效了。解决方案是在对话历史存储前就做脱敏,而不是只在请求时检查。
4.5 性能优化的一点心得
护栏链路会增加延迟,这是必然的。优化方向有几个:
- Presidio的
AnalyzerEngine实例要复用,不要每次请求都新建。 - 识别器按需加载,不需要的实体类型不要注册。
- 规则匹配用编译好的正则,不要每次重新编译。
- 语义分类模型如果用的是LLM,考虑用小模型或者缓存结果。
- 非关键路径的校验可以异步做,不阻塞主流程。
实测下来,一套完整的护栏链路(输入PII检查+注入检测+输出校验+输出脱敏)增加的延迟在200-500毫秒之间,取决于文本长度和识别器数量。对于大多数应用来说,这个开销是可以接受的。
5. 护栏策略的持续迭代与扩展方向
5.1 从静态规则到动态学习
护栏不是配好就一劳永逸的。攻击手法在变,业务场景在变,护栏策略也要跟着变。我建议建立一个反馈闭环:每次拦截和每次漏杀都记录下来,定期分析,把新的模式补充进规则库,把误报的规则调整或下线。
如果团队有资源,可以考虑用积累的拦截样本训练一个小的分类模型,替代部分规则匹配。分类模型对变体的泛化能力比规则强,但需要足够的标注数据。初期可以用规则冷启动,积累到几千条样本后再考虑模型化。
5.2 多模型场景下的护栏适配
现在很多应用会同时接多个模型,比如主力用某个大模型,降级用另一个。不同模型的输出风格和格式遵循能力不一样,护栏策略也要做适配。我的做法是按模型配置不同的重试次数和prompt模板,格式遵循能力弱的模型给更详细的示例,重试次数也放宽一些。
5.3 护栏的可观测性建设
护栏上线后,你需要知道它到底拦了什么、放过了什么、误杀了多少。我一般会埋几个关键指标:
- 输入拦截率:被输入侧护栏拦截的请求占比
- 输出重试率:触发reask的请求占比
- 输出兜底率:最终降级到兜底回复的请求占比
- 平均护栏延迟:护栏链路增加的时间
- 误报率:人工review后确认是误杀的占比
这些指标能帮你判断护栏是否过严或过松,也能在出问题时快速定位。
5.4 关于Agent场景的额外考虑
如果你的应用是Agent形态,护栏的复杂度会上一个台阶。Agent会自主决定调用工具、查询数据、执行操作,护栏需要在工具调用参数这一层做校验。比如:
- 数据库查询工具:校验SQL是否只读,是否包含危险关键字
- 文件操作工具:校验路径是否在允许范围内
- 外部API工具:校验参数是否符合接口契约,是否包含敏感信息
Agent场景下,我强烈建议最小权限原则:每个工具只给完成当前任务所需的最小权限,不要给万能权限。护栏是最后一道防线,权限控制才是第一道。
5.5 一个实际项目的护栏配置参考
最后分享一个我在实际项目中用的护栏配置,场景是“智能客服工单分类”,供参考:
# 输入侧 input_guard_config = { "pii_entities": ["PHONE_NUMBER", "ID_CARD", "EMAIL_ADDRESS"], "pii_threshold": 0.7, "injection_patterns": ["忽略.*指令", "系统提示词", "你现在是"], "max_input_length": 2000 } # 输出侧 output_guard_config = { "schema": TicketClassification, "max_retries": 2, "on_fail": "reask", "fallback_response": "抱歉,我暂时无法处理这个请求,已转人工客服", "anonymize_fields": ["description", "contact_info"] }这套配置上线后,输入侧拦截率大概3%,输出侧重试率12%,最终兜底率1.5%。误报率控制在0.5%以下。这些数字供你参考,实际项目要根据业务特点调整。
护栏这件事,说到底是在安全性和可用性之间找平衡。太松了出事,太紧了没人用。我的经验是:先严后松,上线初期把阈值调紧,观察误报情况,再逐步放宽。反过来做的话,一旦出了安全事故,代价会大得多。