大模型安全护栏实战:内容审核与防注入
发布日期: 2026/08/13 阅读总量: 1

先说我踩过的坑

去年我们上线了一个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语义违规但没有命中敏感词的
L3Prompt注入防火墙~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);
}

延迟统计

场景P50P95P99
无护栏,直接调LLM820ms1230ms2100ms
纯Moderation API980ms1450ms2380ms
混合护栏(全部命中放行)1105ms1590ms2600ms
混合护栏(L1层命中拦截)835ms1260ms2160ms

看到没,当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做索引,现在全量余弦扫描在向量数量过千之后会有点压力。另外还想接入更细粒度的用户画像,对信誉好的用户适当放低敏感度阈值,对高风险用户抬高,减少误杀。就这样吧,有问题评论区见。