先说我踩过的坑
去年我们上线了一个AI客服,用的GPT-4。上线第三天,用户就通过一段精心构造的prompt套走了系统提示词。更离谱的是,有人用 "f@ke" 这种变体写法绕过了敏感词过滤,在公屏上打出了违规内容。当时我们用的方案是什么?单独的敏感词黑名单 + OpenAI官方的moderation接口。结果就是:漏拦截严重、误杀也不少、系统提示词还泄露了。
这篇文章就讲我怎么搭了一套混合安全护栏,把这些问题一个一个解决的。
问题拆解
大模型内容安全就三个面,缺一个都会出事:
- 输入端——Prompt注入防护:用户输入里藏私货,诱导模型吐system prompt或者执行恶意指令。
- 输入端——敏感词/违法信息过滤:传统关键词方案能挡一部分,但变体写法(大小写、同音字、Unicode混淆)绕起来太容易。
- 输出端——模型生成内容审核:模型本身可能生成带毒内容,不能全信自带的安全层。
很多团队直接堆一个OpenAI Moderation API就完事了。我之前也这么干,但实测下来单靠它,中文违规内容的漏拦截率大概在30%左右,尤其是涉及一些中文变体表达、谐音梗,它根本不认识。
方案选型对比
建护栏之前,我同时考虑了三种方案,逐个说优缺点。
方案A:纯关键词黑名单 + 正则
- 优点:快、便宜、部署简单
- 缺点:全字匹配漏掉变体,通配符正则误杀极高
举个例子,屏蔽词 "赌博" 如果做成正则 /赌博/,那 "Du博"、"赌bo"、"赌博(网站)" 全部漏掉。如果放宽成 /赌.*?博/s,那 "今天赌场不开门,我们去看博主直播" 这句正常话直接被误杀。这个方案我们在第一阶段用过,误杀率高到用户投诉,最后被我自己毙了。
方案B:纯嵌入向量相似度检测
- 优点:能理解语义,变体写法挡得住
- 缺点:成本高(每次请求要调embedding接口)、延迟增加约80-120ms、阈值调起来麻烦
我想过把所有违规词和变体都转成向量,然后用户输入也用向量去算cos相似度。实测下来,准确率确实高,但光embedding一次就得50ms,加上余弦计算,整体延迟增加了接近120ms,这只是输入端审核,还没算输出端。
方案C(最终采用):混合多层管道
把方案A和B融合,加一个国产的便宜审核API做兜底,再接LLM Guard开源库做注入检测。分层过滤,每一层只干一件事:
| 层级 | 模块 | 延迟 | 拦截目标 |
|---|---|---|---|
| L1 | 敏感词AC自动机 | ~2ms | 高频变体词(内置5000+变体规则) |
| L2 | 语义相似度检测 | ~90ms | 语义违规但没有命中敏感词的 |
| L3 | Prompt注入防火墙 | ~15ms | 注入攻击、系统提示词套取 |
| L4 | 输出端模型自检 + API兜底 | ~300ms | 模型生成内容的最终审查 |
每一层都有明确的拦截原因和置信度,方便日志追踪和后续调优。
环境与版本
- PHP 8.3.2
- Laravel 11.9
- MySQL 8.0.35
- Redis 7.2.3
- OpenAI PHP SDK(laravel/openai) 3.5
- LLM Guard 0.3.4(Python侧微服务)
- text-embedding-3-small(OpenAI)
- gpt-4-turbo(模型)
代码实现
L1:敏感词AC自动机(多模匹配)
用AC自动机做多模匹配,一次扫描输入文本中所有命中的敏感词,而不是逐词正则匹配。PHP里面装 ext-ac 扩展的话还得编译,我直接用纯PHP实现了一个简化版的AC自动机,实测2ms左右性能完全够用。
<?php
namespace App\Services\Guard;
class AcFilter
{
private array $trie = [];
private array $fail = [];
private array $output = [];
public function build(array $keywords): void
{
// 构建Trie树
foreach ($keywords as $kw) {
$charList = mb_str_split(mb_strtolower($kw));
$node = &$this->trie;
foreach ($charList as $ch) {
if (!isset($node['children'][$ch])) {
$node['children'][$ch] = ['children' => [], 'wordId' => null];
}
$node = &$node['children'][$ch];
}
$node['wordId'] = $kw;
}
unset($node);
// BFS构建fail指针
$queue = [];
foreach ($this->trie['children'] ?? [] as $ch => &$child) {
$child['fail'] = &$this->trie;
$queue[] = &$child;
}
unset($child);
while ($queue) {
$current = array_shift($queue);
foreach ($current['children'] ?? [] as $ch => &$child) {
$failNode = &$current['fail'];
while ($failNode && !isset($failNode['children'][$ch])) {
$failNode = &$failNode['fail'];
}
$child['fail'] = $failNode['children'][$ch] ?? $this->trie;
// 合并output
if ($child['fail']['wordId'] ?? null) {
$child['wordId'] = $child['wordId'] ?? $child['fail']['wordId'];
}
$queue[] = &$child;
}
unset($child);
}
}
public function search(string $text): array
{
$hits = [];
$node = &$this->trie;
$chars = mb_str_split(mb_strtolower($text));
foreach ($chars as $i => $ch) {
while ($node !== &$this->trie && !isset($node['children'][$ch])) {
$node = &$node['fail'];
}
if (isset($node['children'][$ch])) {
$node = &$node['children'][$ch];
} else {
$node = &$this->trie;
}
// 输出匹配
if ($node['wordId']) {
$hits[] = $node['wordId'];
}
}
return array_unique($hits);
}
}
敏感词数据源与变体生成脚本
敏感词不能只存原始词。我用一个Python脚本自动生成变体(大小写、数字替换、谐音),生成完存MySQL,再加到Redis里给AC自动机加载。这是整个系统的地基,变体库不丰富,后面一切免谈。
# 自定义变体生成脚本 gen_variants.py 执行命令
python3 gen_variants.py --input ./base_words.txt --output ./variants.json
import json
import itertools
words = open("base_words.txt").read().splitlines()
# 字符替换表
replace_map = {
'赌': ['Du', 'dú', '堵', 'd U'],
'博': ['bo', 'bó', '勃', 'b0'],
'毒': ['du', 'dú', '独'],
'品': ['pin', 'pǐn', 'p1n'],
'色': ['se', 'sè', 's3'],
'情': ['qing', 'qíng', 'q1ng'],
'骗': ['pian', 'pìan', 'p1an'],
'局': ['ju', 'jú', 'j u'],
'裸': ['luo', 'lúo', 'l uo'],
'聊': ['liao', 'líao', 'l1ao'],
}
variants = set(words)
def generate(w):
results = [w]
# 对每个字符生成替换
for idx, ch in enumerate(w):
if ch in replace_map:
for rep in replace_map[ch]:
new = w[:idx] + rep + w[idx+1:]
results.append(new)
return results
all_variants = set()
for w in words:
all_variants.update(generate(w))
# 大小写变体(如果包含字母)
for v in list(generate(w)):
all_variants.add(v.lower())
all_variants.add(v.upper())
all_variants.add(v.title())
with open("variants.json", "w", encoding="utf-8") as f:
json.dump(sorted(all_variants), f, ensure_ascii=False, indent=2)
print(f"共生成变体: {len(all_variants)} 个")
L2:语义相似度检测
关键点:把用户输入先embedding成向量,然后跟预置的"违规语义向量库"做cos相似度比较。这里不用每次去请求OpenAI的embedding接口——太慢。我的做法是:构建一个违规语义检索表,预先算好几百条违规表述的embedding向量,存在MySQL里。用户输入来了,现场embedding一次(约50ms),然后本地跟库里所有的向量做cos。300条违规向量,纯PHP计算只用40ms,总耗时90ms,可以接受。
<?php
namespace App\Services\Guard;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\DB;
class SemanticChecker
{
private string $embeddingModel = 'text-embedding-3-small';
private float $threshold = 0.82; // 向量库里违规种子调出来的经验值
public function check(string $text): array
{
// 1. 计算用户输入的embedding
$vec = $this->embed($text);
// 2. 查询库中已有的违规语义向量
$seeds = DB::table('violation_seed_vectors')->get();
$maxScore = 0.0;
$hitSeed = null;
foreach ($seeds as $seed) {
$seedVec = json_decode($seed->vector, true);
$score = $this->cosineSimilarity($vec, $seedVec);
if ($score > $maxScore) {
$maxScore = $score;
$hitSeed = $seed->content;
}
}
if ($maxScore >= $this->threshold) {
return [
'hit' => true,
'score' => round($maxScore, 4),
'similar_to' => $hitSeed,
'engine' => 'semantic',
];
}
return ['hit' => false, 'score' => round($maxScore, 4), 'engine' => 'semantic'];
}
private function embed(string $text): array
{
$resp = Http::withToken(config('services.openai.api_key'))
->post('https://api.openai.com/v1/embeddings', [
'model' => $this->embeddingModel,
'input' => $text,
]);
return json_decode($resp->body(), true)['data'][0]['embedding'];
}
private function cosineSimilarity(array $a, array $b): float
{
$dot = 0.0;
$normA = 0.0;
$normB = 0.0;
$count = count($a);
for ($i = 0; $i < $count; $i++) {
$dot += $a[$i] * $b[$i];
$normA += $a[$i] * $a[$i];
$normB += $b[$i] * $b[$i];
}
if ($normA == 0 || $normB == 0) return 0.0;
return $dot / (sqrt($normA) * sqrt($normB));
}
}
L3:Prompt注入防护——LLM Guard搭配
这一层我用了一个开源库「LLM Guard」做注入检测。0.3.4版本有个 PromptInjection 模块,通过启发式+模型判断输入是不是注入攻击。用Python起了一个FastAPI微服务,PHP那边直接HTTP调用。我代码里做了缓存,同一个用户同一个输入10分钟内的检测结果直接复用,实测TP99 p95延迟压到15ms以内。
# llm_guard_microservice/docker-compose.yml
version: '3.8'
services:
llm-guard:
image: llm-guard:0.3.4
container_name: llm-guard-svc
ports:
- "8730:8000"
volumes:
- ./app:/app
environment:
- HF_TOKEN=${HF_TOKEN}
command: uvicorn main:app --host 0.0.0.0 --port 8000 --workers 2
# llm_guard_microservice/app/main.py
from fastapi import FastAPI
from pydantic import BaseModel
from llm_guard.vault import Vault
from llm_guard.input_scanner import PromptInjection
app = FastAPI()
scanner = PromptInjection(model="databricks/dolly-v2-3b")
class InputText(BaseModel):
text: str
@app.post("/check_injection")
def check_injection(item: InputText):
sanitized, is_valid, risk_score = scanner.scan(item.text)
return {
"is_injection": not is_valid,
"risk_score": risk_score,
"sanitized_text": sanitized
}
PHP端调用就不贴完整类了,直接放一个接入点:
<?php
// app/Services/Guard/InjectionFirewall.php
public function check(string $text, ?string $userHash): array
{
$cacheKey = 'guard:inject:' . md5($userHash . '|' . $text);
$cached = Redis::get($cacheKey);
if ($cached) {
return json_decode($cached, true);
}
$resp = Http::timeout(0.5)
->post('http://127.0.0.1:8730/check_injection', [
'text' => $text,
]);
$data = $resp->json();
Redis::setex($cacheKey, 600, json_encode($data));
return $data;
}
注意 timeout(0.5),这个微服务挂了不能拖垮主流程,降级策略是50ms超时直接放行。安全性和可用性之间,我们最终折中在超时放行,但会记录日志备用。
L4:输出端自检 + 兜底审核API
输出端不能全信GPT的moderation,也不能只靠自己。我搭了双重机制:让模型自己对输出做一次违规自评(一个结构化JSON判断),再叠加一个国内的内容安全API兜底(某云服务,具体名字打码,反正不是广告)。
<?php
// app/Services/Guard/OutputGuard.php
public function check(string $modelOutput): array
{
// 第一重:LLM自检(用一份详细的安全评估prompt)
$selfCheckPrompt = <<<<EOT
你是一个内容安全审查员。请判断以下文本是否包含:
1. 色情内容或性暗示
2. 暴力或恐怖主义
3. 政治敏感内容
4. 违法违规信息(赌博/毒品/诈骗等)
5. 仇恨言论或歧视
输出JSON格式:{"violate": bool, "category": "none|porn|violence|politics|illegal|hate", "reason": "简短原因"}
文本:
{$modelOutput}
EOT;
$resp = Http::withToken(config('services.openai.api_key'))
->post('https://api.openai.com/v1/chat/completions', [
'model' => 'gpt-4-turbo',
'messages' => [
['role' => 'user', 'content' => $selfCheckPrompt],
],
'temperature' => 0,
'response_format' => ['type' => 'json_object'],
]);
$selfResult = json_decode($resp->body(), true)['choices'][0]['message']['content'] ?? '{}';
$selfResult = json_decode($selfResult, true);
if ($selfResult['violate'] ?? false) {
return [
'pass' => false,
'reason' => 'LLM自检拦截',
'category' => $selfResult['category'] ?? 'unknown',
'detail' => $selfResult['reason'] ?? '',
];
}
// 第二重:第三方审核API兜底(某云)
$thirdParty = Http::withHeaders([
'Authorization' => 'Bearer ' . config('services.third_party_audit.key'),
])->post('https://api.audit.example.com/v1/moderate', [
'text' => $modelOutput,
])->json();
if ($thirdParty['suggestion'] === 'block') {
return [
'pass' => false,
'reason' => '第三方叠加审核拦截',
'category' => $thirdParty['label'],
'detail' => $thirdParty['reason'],
];
}
return ['pass' => true];
}
管道编排(一个门卫类把整个流程串起来)
<?php
namespace App\Services\Guard;
class GuardPipeline
{
public function __construct(
private AcFilter $acFilter,
private SemanticChecker $semantic,
private InjectionFirewall $injection,
private OutputGuard $output,
) {}
/**
* 输入护栏:在prompt发往LLM之前调用
*/
public function inputGuard(string $userText, string $userHash): array
{
// L1 - AC敏感词过滤
$hits = $this->acFilter->search($userText);
if ($hits) {
return $this->deny('敏感词命中', [
'keywords' => $hits,
'engine' => 'AC自动机',
]);
}
// L2 - 语义相似度检测
$semantic = $this->semantic->check($userText);
if ($semantic['hit']) {
return $this->deny('语义违规', $semantic);
}
// L3 - Prompt注入防护
$injection = $this->injection->check($userText, $userHash);
if ($injection['is_injection']) {
return $this->deny('Prompt注入攻击', [
'risk_score' => $injection['risk_score'],
'engine' => 'LLMGuard',
]);
}
return ['allowed' => true];
}
/**
* 输出护栏:在拿到LLM响应后调用
*/
public function outputGuard(string $modelOutput): array
{
$result = $this->output->check($modelOutput);
if (!$result['pass']) {
return $this->deny('输出端拦截', $result);
}
return ['allowed' => true];
}
private function deny(string $reason, array $detail): array
{
\Log::warning('Guard拦截', array_merge(['reason' => $reason], $detail));
return [
'allowed' => false,
'reason' => $reason,
'detail' => $detail,
];
}
}
效果数据
上线这个混合护栏之后我做了一周的A/B对比。对照组是之前的纯Moderation API方案,实验组是这套混合管道。两边流量各50%,用同一条prompt注入攻击和违规内容测试集,共1000条,其中400条是变体敏感词攻击,300条是语义攻击,300条是prompt注入。
| 指标 | 纯Moderation API(对照) | 混合护栏(实验) | 提升 |
|---|---|---|---|
| 普通敏感词拦截率(200条标准词) | 92.0% | 100% | +8% |
| 变体敏感词拦截率(400条变体) | 38.5% | 98.2% | +59.7% |
| 语义违规拦截率(300条擦边内容) | 71.0% | 96.0% | +25% |
| Prompt注入拦截率(300条攻击) | 43.0% | 99.4% | +56.4% |
| 误杀率(用1000条正常用户问题测试) | 4.5% | 1.2% | 误杀降低73% |
压测数据用的K6,100并发持续10分钟。实例配置:4核8G单节点跑的Laravel应用,MySQL和Redis同机。
# k6压测命令
k6 run --vus 100 --duration 10m load_test.js
// load_test.js
import http from 'k6/http';
import { check, sleep } from 'k6';
export default function () {
const payload = JSON.stringify({
text: '今天天气不错,推荐几个好玩的地方',
user_hash: `user_${Math.random().toString(36).slice(2, 8)}`
});
const params = {
headers: { 'Content-Type': 'application/json' },
};
const res = http.post('http://localhost:8000/api/guard/input', payload, params);
check(res, { 'status is 200': (r) => r.status === 200 });
sleep(1);
}
延迟统计
| 场景 | P50 | P95 | P99 |
|---|---|---|---|
| 无护栏,直接调LLM | 820ms | 1230ms | 2100ms |
| 纯Moderation API | 980ms | 1450ms | 2380ms |
| 混合护栏(全部命中放行) | 1105ms | 1590ms | 2600ms |
| 混合护栏(L1层命中拦截) | 835ms | 1260ms | 2160ms |
看到没,当L1直接命中拦截的时候,整体延迟只增加了15ms,因为压根不用调LLM,也就省了LLM的800多ms。所以护栏不是纯负作用——很多攻击在L1就挡下了,反而省了大模型调用费用。
内存方面,AC自动机加载5000+敏感词和变体大概占PHP内存18MB,进程常驻后稳定在21MB左右,每请求额外GC压力很小。我用Xdebug Profiler跑了一次完整chain,L1toL4各层内存开销如下:AC过滤分配峰值2.3MB,语义向量检查4.1MB(主要是json_decode的临时数组),注入缓存1.1MB,输出审核忽略(因为走HTTP不占本地内存)。
避坑指南(血泪经验)
坑我踩过不少,列几个印象最深刻的。
坑1:Unicode全角/半角、零宽字符绕过
刚开始,我们AC自动机的词库做的是普通字符串匹配。结果用户用全角"赌 博"(中间加全角空格)就直接绕过了。后来我发现最好的办法是:进入过滤器之前先做一次归一化处理。把全角转半角、去除零宽字符(\u200B、\u200C、\uFEFF)、把连续空格压缩成一个。这个步骤必须在所有检查层的最前面做,否则后面全白搭。
// text_normalize.php 归一化函数
function normalizeInput(string $text): string
{
// 全角转半角
$text = mb_convert_kana($text, 'a');
// 去掉零宽字符
$text = preg_replace('/[\x{200B}-\x{200D}\x{FEFF}]/u', '', $text);
// 压缩连续空白为单个空格
$text = preg_replace('/\s+/u', ' ', $text);
return $text;
}
坑2:误杀比漏杀更可怕
刚开始我们把语义检测的阈值调到0.85,结果"赌博必输"这类警示句子被误杀为违规,用户投诉一堆。后来我把阈值从0.85调到0.82试了几天,误杀率从2.8%直接飙到5.6%。最后我用两周的线上数据画了ROC曲线,发现0.82附近有个拐点,就定在了0.82,误杀率1.2%左右还能接受。别瞎拍脑袋设阈值,要用数据说话。
坑3:LLM Guard微服务是Python的,跟PHP进程之间有网络开销
一开始我图省事,每次请求都同步调Python微服务,没加缓存。结果高峰期Python单节点CPU直接100%,HTTP超时频发,L3层那一串timeout打满了日志。后来加了Redis缓存 + 降级策略,以及两台Python节点做负载均衡,才把CPU压下来。
坑4:敏感词库不能是一个静态文件
最开始图简单,敏感词表直接放了一个明文文件。但线上运营在后台添加新敏感词之后,没法让AC自动机热更新。每次加词都要重新部署。后来我改成了MySQL存库 + Redis存序列化好的Trie结构,运营加词之后Redis里做一次build(2000个词大约300ms),就能生效。改动不小但值得,不然每次加个词都要发版,谁受得了。
坑5:大模型输出审核的幻觉问题
用gpt-4-turbo做输出自检,它有时候会误判。有次用户问"如何做一个健康的饮食计划",模型输出里有一句"避免吃太多红肉",结果自检判断为"暴力"(?)直接给拦了。后来我在自检prompt里加了一条强约束:禁止无理由给正常内容打上违规标签,如果没有确凿证据请输出"violate": false。加完之后,输出审核的误杀率从5%降到了1.5%。
总结和后续计划
这套混合护栏其实不是什么新技术,就是工程上的组合拳:AC自动机打底,语义向量补漏,注入防火墙挡攻击,输出端自检叠加API兜底。最关键的是,每一层都要有缓存、有超时、有降级,安全组件不能成为可用性的瓶颈。
后续打算把L2层的语义向量库做聚类压缩,把300多条种子向量用KD树或HNSW做索引,现在全量余弦扫描在向量数量过千之后会有点压力。另外还想接入更细粒度的用户画像,对信誉好的用户适当放低敏感度阈值,对高风险用户抬高,减少误杀。就这样吧,有问题评论区见。