08 - 内容护栏:拦不住的那一类
本篇回答:怎么防止模型被诱导说出违规内容、产出违规产物;以及为什么这件事和前两篇的性质完全不同。
会用到的词:
- 越狱(jailbreak):诱导模型输出它被训练成不该输出的内容。注意它和提示注入不是一回事 —— 注入是让模型执行不该执行的操作,越狱是让模型说不该说的话
- 精确率 / 召回率:拦截时有多少是拦对的(precision)、该拦的里拦住了多少(recall)。这两个数在护栏里永远互相拉扯
- 误杀(false positive):正常内容被拦。这是护栏最主要的成本,而且成本落在正常用户身上
- TTFT:首个 token 送达用户的时间。流式护栏的取舍全部围绕这个指标
一、这一类问题为什么没有确定性方案
前两篇每一处有效的防线,判据都是确定性的:这个域名在不在白名单里、这枚令牌的 aud 是不是我、这个字段是不是身份字段。判据是布尔的,实现是代码,结果可复现。
内容护栏没有这种判据。「这段话算不算教人做危险品」是一个语义判断 ,而语义判断没有精确边界。
这个差别决定了整篇的组织方式:下面讲的每一种手段都要同时说清它拦住了什么、误杀了什么、代价多少。只讲拦截率的护栏方案在工程上是不可评估的。
二、模型自身的对齐不是边界
第一反应通常是「模型自己就会拒绝」。这个判断在大部分普通请求上成立,在被针对的请求上不成立,原因和 04 篇讲六道关卡时给的结论一样:模型的拒绝是它生成出来的一个结果,不是一道执行在它之外的检查。
能被生成的东西就能被条件改变。常见手法按机制分类:
| 手法 | 机制 | 为什么关键词过滤抓不到 |
|---|---|---|
| 角色扮演 | 给模型一个「在这个身份下这么说是合理的」的框架 | 请求本身用词完全正常 |
| 虚构包装 | 把内容包在小说、剧本、教学案例里 | 「写一个小说场景」是绝对正当的请求 |
| 分步拆解 | 单独看每一步都无害,拼起来才是完整的 | 每一轮都过检,问题在多轮的累积 |
| 编码绕过 | base64、拼音、火星文、Unicode 变体 | 关键词表匹配的是明文 |
| 低资源语言 | 用训练数据稀少的语言提问 | 护栏的词表和分类器往往只覆盖主流语言 |
| 多轮爬坡 | 先建立一个无害的上下文,逐轮加码 | 单轮检查看不到轨迹 |
中文场景额外多一层。 后面第四节会拆的那份开源规则表,识别词全是英文(pretend、roleplay、developer mode)。同样语义的中文请求根本不会命中任何一条。这不是那个项目的疏忽 —— 中文的分词、同音替换(用拼音或谐音字规避)、以及「同一个意思有十几种说法」的特性,让关键词方案在中文上的性价比比英文低得多。
结论不是「所以不做」,而是:中文场景里,关键词层的定位只能是「拦掉最粗糙的那一批」,主力必须是分类器。
三、两道分类器,各看一半
护栏至少要有两道,位置分别在模型前和模型后。这不是为了保险,是因为两侧能看到的信息本来就不一样。
两侧的动作也应该不同:入站更适合拒绝,出站更适合改写。 入站拦下来时用户还没等待,直接给拒绝理由体验尚可;出站已经生成完了,整段丢弃会让用户觉得系统坏了,替换成安全版本或者只遮蔽越界片段更合理。
四、拆一份真实的关键词方案
关键词过滤在讨论里常被一句「没用」带过。但真实的生产实现远不是一张词表 —— 拆开看能理解它到底能做到什么程度、代价在哪。
LiteLLM 内置了一套完整的内容过滤,在 litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/:主实现 content_filter.py 有 90KB,规则 数据 patterns.json 有 38KB,另有 categories/(三十多份分类规则)、policy_templates/、guardrail_benchmarks/。
4.1 条件共现,而不是单词命中
看它的越狱检测规则 categories/prompt_injection_jailbreak.yaml:
# 注释里第一句就说明了它的设计:
# "Uses conditional logic: BLOCK if an identifier word + block word appear in same sentence"
category_name: "prompt_injection_jailbreak"
default_action: "BLOCK"
# 第一张表:角色扮演类的动作词。单独出现完全正常 ——
# 「pretend you are a translator」是一个正当请求。
identifier_words:
- "pretend"
- "roleplay"
- "act as"
- "imagine you are"
- "you are now"
- "developer mode" # 这一条例外,它本身就足够可疑
- "simulate"
- "impersonate"
# 第二张表:解除限制类的词。同样地,单独出现也可能正常 ——
# 「a story without limits on length」并不是越狱。
additional_block_words:
- "no restrictions"
- "no filters"
- "bypass"
- "unrestricted"
- "uncensored"
- "god mode"
- "without limits"
为什么要用「同一句里共现」而不是任一命中:两张表里的词单独出现时误杀率高得没法用。要求共现,等于要求「既在构造一个身份、又在要求解除限制」这两个特征同时出现 —— 这才接近越狱的实际形态。
代价是它同样可以被拆开绕过:把身份构造放在第一轮、解除限制放在第二轮,同句共现的条件就不成立了。这是所有基于单条消息的规则的共同上限,它看不见轨迹。
4.2 四层匹配和那张「例外表」
同一目录下 guardrail_benchmarks/results/BENCHMARKS.md 描述了另一份规则(拦截投资建议)的完整结构,四层依次是:
- 绝对拦截词 —— 「investment advice」「stock tips」这类不可能有正当用法的短语,直接子串匹配
- 条件匹配 —— 识别词(
stock、bitcoin、401k)+ 拦截词(buy、should i、best)同句共现 - 短语模式 —— 正则,抓那些绕开了金融词汇的说法,文档里给的例子是「put my money to make it grow」「park my cash」
- 例外表 —— 命中之后再放行的短语
第四层最值得看,因为它暴露了这类方案的真实工作量。文档里给的例外包括 gold medal、trading card、return policy、emirates flight。
翻译一下:这不是设计出来的,是被误杀报障一条条打回来的补丁。「gold」命中了贵金属识别词,「trading card」命中了交易识别词,「return policy」命中了「return(回报)」。每一条例外的背后都是一次线上误杀。
这条经验可以直接搬走:上关键词规则时,例外表和规则表要同时设计,并且预留一个「误杀反馈直接进例外表」的运维通道。 没有这个通道,规则只会越加越紧,误杀越积越多。
4.3 内置了哪些类别
patterns.json 里有 82 条正则模式,分成几组,而且带 action 字段区分处置方式:
| 组 | 例子 | 处置 |
|---|---|---|
| PII | us_ssn、email、us_phone、十国护照号 | 通常 MASK |
| 支付卡 | visa、mastercard、amex、discover | MASK |
| 凭证 | aws_access_key、github_token、slack_token | 呼应 06 篇的出口脱敏 |
| 受保护特征 | 种族、宗教、年龄、残障、婚姻家庭、军人身份 | 用于借贷等合规场景 |
| 危险内容 | weapons_firearms → MASK;explosives、terrorism、violence_threats → BLOCK | 分级 |
action 分成 MASK 和 BLOCK 这件事本身是个好设计:枪支相关内容遮蔽即可,爆炸物必须整条拒绝。 一刀切成「命中就拒绝」会把大量可以降级处理的场景变成误杀。
categories/ 下还能看到这套东西的实际用途 —— 除了 harmful_*、prompt_injection_* 这类通用项,更多的是行业合规项:claims_medical_advice、claims_phi_disclosure、claims_prior_auth_gaming、denied_financial_advice、denied_legal_advice,以及 bias_gender、bias_racial、age_discrimination 这些反歧视规则。policy_templates/ 下直接按法规组织:eu_ai_act_art5_*(欧盟 AI 法案第 5 条的禁止性实践,含法语版)、sg_mas_*(新加坡金管局的模型治理要求)。
这说明了内容护栏在生产里的真实需求分布:绝大部分不是防越狱,是合规。 「不能给出医疗建议」「不能表现出年龄歧视」这类要求的数量远超「不能教人做危险品」,而且它们是有法律后果的。做方案时如果只盯着越狱,会发现上线后需求全在另一边。