第3讲:输出安全——PII 脱敏与有害内容过滤

一、为什么输出比输入更危险

复制代码
输入 vs 输出
────────────────────────────────────────────────────
输入攻击:用户主动发起,我们有充分的时间逐层检测
输出泄露:模型自主产生,一旦输出就覆水难收
典型案例:
Samsung 内部 ChatGPT 事故(2023)
├── 工程师将源代码粘贴到 ChatGPT 中排查错误
├── ChatGPT 将这些代码学习到了训练数据中
└── 后果:商业机密永久性泄露,无法撤回
核心矛盾:
模型不知道什么能说什么不能说。
它只知道"这看起来像答案",而不知道"这能不能说"。

二、PII 检测与脱敏

复制代码
# output/pii_detector.py
from typing import List, Dict, Set, Optional, Tuple
from dataclasses import dataclass, field
import re
import hashlib
@dataclass
class PIISpan:
"""PII 片段信息"""
type: str                    # 类型:phone, id_card, email, bank_card...
value: str                   # 原始值
start: int                   # 起始位置
end: int                     # 结束位置
confidence: float            # 置信度
masked_value: str = ""       # 脱敏后的值
class PIIDetector:
"""
PII 检测器
支持多种类型的个人敏感信息识别
"""

def __init__(self):
    # ─── 中国大陆 PII 模式 ───
    self.patterns = {
        "phone": re.compile(
            r'(?:(?:\+?86)?[-\s]?)?1[3-9]\d{9}'
        ),
        "id_card": re.compile(
            r'[1-9]\d{5}(?:19|20)\d{2}(?:0[1-9]|1[0-2])'
            r'(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx]'
        ),
        "email": re.compile(
            r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'
        ),
        "bank_card": re.compile(
            r'\d{16}|\d{19}'
        ),
        "ip_address": re.compile(
            r'(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}'
            r'(?:25[0-5]|2[0-4]\d|[01]?\d\d?)'
        ),
        "address": re.compile(
            r'(?:省|市|区|县|镇|乡|村|路|街|巷|号|栋|单元|室)'
        ),
        "passport": re.compile(
            r'[A-Za-z]\d{8}'
        ),
        "license_plate": re.compile(
            r'[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤川青藏琼宁][A-Z][A-HJ-NP-Z0-9]{4,5}[A-HJ-NP-Z0-9挂学警港澳]'
        ),
    }
    
    # ─── 上下文线索词 ───
    self.context_clues = {
        "phone": ["手机", "电话", "号码", "联系", "tel", "phone"],
        "id_card": ["身份证", "证件号", "ID", "公民身份"],
        "email": ["邮箱", "邮件", "email", "mail"],
        "bank_card": ["银行卡", "卡号", "信用卡", "借记卡"],
        "address": ["地址", "住址", "家庭住址", "通讯地址"],
        "password": ["密码", "口令", "password", "pwd"],
    }

def detect_all(self, text: str) -> List[PIISpan]:
    """
    检测文本中所有 PII
    
    Args:
        text: 待检测文本
    
    Returns:
        PIISpan 列表,按出现顺序排列
    """
    spans = []
    
    for pii_type, pattern in self.patterns.items():
        for match in pattern.finditer(text):
            value = match.group()
            
            # 计算置信度
            confidence = self._calculate_confidence(
                text, pii_type, value, match.start(), match.end()
            )
            
            if confidence >= 0.5:  # 置信度阈值
                span = PIISpan(
                    type=pii_type,
                    value=value,
                    start=match.start(),
                    end=match.end(),
                    confidence=confidence,
                )
                spans.append(span)
    
    # 按位置排序
    spans.sort(key=lambda s: s.start)
    
    # 合并重叠的 span
    spans = self._merge_overlapping(spans)
    
    return spans

def _calculate_confidence(self, text: str, pii_type: str,
                           value: str, start: int, end: int) -> float:
    """计算检测置信度"""
    confidence = 0.7  # 基础置信度
    
    # 1. 校验和验证(提高置信度)
    if pii_type == "id_card":
        if self._verify_id_card(value):
            confidence += 0.2
        else:
            confidence -= 0.3
    
    elif pii_type == "bank_card":
        if self._luhn_check(value):
            confidence += 0.2
        else:
            confidence -= 0.2
    
    elif pii_type == "phone":
        # 手机号前 3 位运营商号段验证
        valid_prefixes = [
            "130","131","132","133","134","135","136","137","138","139",
            "150","151","152","153","155","156","157","158","159",
            "170","171","173","175","176","177","178",
            "180","181","182","183","184","185","186","187","188","189",
            "190","191","193","195","196","197","198","199",
        ]
        if value[:3] in valid_prefixes:
            confidence += 0.1
    
    # 2. 上下文线索(提高置信度)
    context_before = text[max(0, start-30):start].lower()
    context_after = text[end:min(len(text), end+30)].lower()
    context = context_before + context_after
    
    clues = self.context_clues.get(pii_type, [])
    for clue in clues:
        if clue in context:
            confidence += 0.05
            break
    
    # 3. 长度异常惩罚
    if pii_type == "id_card" and len(value) != 18:
        confidence -= 0.2
    if pii_type == "phone" and len(value) != 11:
        confidence -= 0.2
    
    return min(max(confidence, 0.0), 1.0)

def _verify_id_card(self, id_num: str) -> bool:
    """身份证校验码验证"""
    if len(id_num) != 18:
        return False
    
    weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
    check_codes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']
    
    try:
        total = sum(int(id_num[i]) * weights[i] for i in range(17))
        expected = check_codes[total % 11]
        return id_num[17].upper() == expected
    except (ValueError, IndexError):
        return False

def _luhn_check(self, card_num: str) -> bool:
    """Luhn 算法校验银行卡号"""
    digits = [int(d) for d in card_num if d.isdigit()]
    if len(digits) < 13:
        return False
    
    # 从右向左,偶数位翻倍
    for i in range(len(digits)-2, -1, -2):
        digits[i] *= 2
        if digits[i] > 9:
            digits[i] -= 9
    
    return sum(digits) % 10 == 0

def _merge_overlapping(self, spans: List[PIISpan]) -> List[PIISpan]:
    """合并重叠的 PII 片段"""
    if not spans:
        return []
    
    merged = [spans[0]]
    for span in spans[1:]:
        prev = merged[-1]
        if span.start <= prev.end:
            # 保留置信度更高的类型
            if span.confidence > prev.confidence:
                merged[-1] = span
        else:
            merged.append(span)
    
    return merged
class PIISanitizer:
"""
PII 脱敏器
支持多种脱敏策略
"""

def __init__(self):
    self.detector = PIIDetector()

def sanitize(self, text: str, strategy: str = "mask") -> Tuple[str, List[PIISpan]]:
    """
    对文本进行 PII 脱敏
    
    Args:
        text: 原始文本
        strategy: 脱敏策略
                  mask    - 掩码显示(138****8000)
                  hash    - 哈希替换
                  redact  - 完全移除
                  replace - 泛化替换([手机号])
    
    Returns:
        (sanitized_text, detected_spans)
    """
    spans = self.detector.detect_all(text)
    
    if not spans:
        return text, []
    
    # 从后往前替换,避免位置偏移
    result = list(text)
    for span in reversed(spans):
        masked = self._apply_strategy(span, strategy)
        result[span.start:span.end] = masked
        span.masked_value = masked
    
    return ''.join(result), spans

def _apply_strategy(self, span: PIISpan, strategy: str) -> str:
    """应用脱敏策略"""
    value = span.value
    
    if strategy == "mask":
        return self._mask_value(value, span.type)
    elif strategy == "hash":
        return hashlib.sha256(value.encode()).hexdigest()[:16]
    elif strategy == "redact":
        return ""
    elif strategy == "replace":
        return f"[{span.type.upper()}]"
    else:
        return self._mask_value(value, span.type)

def _mask_value(self, value: str, pii_type: str) -> str:
    """掩码处理"""
    length = len(value)
    
    if pii_type == "phone":
        # 138****8000
        return value[:3] + "****" + value[-4:]
    
    elif pii_type == "id_card":
        # 110101****1234
        return value[:6] + "********" + value[-4:]
    
    elif pii_type == "email":
        # u***@example.com
        local, domain = value.split('@', 1)
        return local[0] + "***@" + domain
    
    elif pii_type == "bank_card":
        # **** **** **** 1234
        return "*" * (length - 4) + value[-4:]
    
    elif pii_type == "ip_address":
        # 192.168.*.*
        parts = value.split('.')
        return '.'.join(parts[:2] + ['*', '*'])
    
    else:
        # 通用:保留首尾各1/4
        keep_front = max(1, length // 4)
        keep_back = max(1, length // 4)
        return value[:keep_front] + "*" * (length - keep_front - keep_back) + value[-keep_back:]
─── 测试 ───
def test_pii_detection():
sanitizer = PIISanitizer()
test_cases = [
    "请联系张经理,他的手机是13800138000",
    "我的身份证号是110101199001011234",
    "邮箱地址是zhangsan@company.com",
    "银行卡号6222021234567890123,密码是abc123",
    "我家住在北京市海淀区中关村大街1号院2号楼3单元401室",
]

for case in test_cases:
    cleaned, spans = sanitizer.sanitize(case, strategy="mask")
    print(f"原文: {case}")
    print(f"脱敏: {cleaned}")
    if spans:
        for s in spans:
            print(f"  发现 {s.type}: {s.value} -> {s.masked_value} (置信度: {s.confidence:.2f})")
    print()</code></pre>
三、有害内容过滤
# output/toxic_filter.py
from typing import List, Dict, Set, Optional, Tuple
from dataclasses import dataclass
import re
@dataclass
class ToxicSpan:
"""有害内容片段"""
category: str              # 类别
content: str               # 原文
start: int
end: int
severity: float            # 严重程度 0-1
rule_id: str               # 触发的规则 ID
class ToxicContentFilter:
"""
有害内容过滤器
多层过滤:关键词 + 正则 + 模式匹配
"""

def __init__(self):
    # ─── 一级过滤:绝对禁止的关键词 ───
    # 说明:这里只保留类别占位,真实词表应放在独立的配置文件中,
    # 由安全团队维护,避免把具体违规词写死在代码里。
    self.blocklist_exact: Set[str] = set()

    # ─── 二级过滤:正则模式 ───
    # 说明:模式同样从配置加载,这里只演示结构。
    self.blocklist_patterns: List[Tuple[str, str, float]] = []

    # ─── 三级过滤:敏感话题检测 ───
    # 说明:话题词表由合规团队维护,代码只负责匹配逻辑。
    self.sensitive_topics: Dict[str, List[str]] = {}

def load_rules(self, rules: Dict) -&gt; None:
    """
    从外部配置加载过滤规则

    Args:
        rules: 包含 blocklist_exact / blocklist_patterns / sensitive_topics 的字典
    """
    self.blocklist_exact = set(rules.get("blocklist_exact", []))
    self.blocklist_patterns = [
        (item["pattern"], item["category"], item["severity"])
        for item in rules.get("blocklist_patterns", [])
    ]
    self.sensitive_topics = rules.get("sensitive_topics", {})

def filter(self, text: str) -&gt; Tuple[bool, List[ToxicSpan], str]:
    """
    过滤有害内容

    Args:
        text: 待过滤文本

    Returns:
        (is_toxic, toxic_spans, filtered_text)
    """
    toxic_spans = []
    filtered = text

    # 一级过滤:精确匹配
    for keyword in self.blocklist_exact:
        pos = filtered.find(keyword)
        while pos != -1:
            span = ToxicSpan(
                category="blocklist_exact",
                content=keyword,
                start=pos,
                end=pos + len(keyword),
                severity=1.0,
                rule_id=f"exact_{keyword}",
            )
            toxic_spans.append(span)
            pos = filtered.find(keyword, pos + 1)

    # 二级过滤:正则匹配
    for pattern, category, severity in self.blocklist_patterns:
        for match in re.finditer(pattern, filtered, re.IGNORECASE):
            span = ToxicSpan(
                category=category,
                content=match.group(),
                start=match.start(),
                end=match.end(),
                severity=severity,
                rule_id=f"pattern_{category}",
            )
            toxic_spans.append(span)

    # 三级过滤:敏感话题
    for topic, keywords in self.sensitive_topics.items():
        for keyword in keywords:
            pos = filtered.find(keyword)
            while pos != -1:
                span = ToxicSpan(
                    category=topic,
                    content=keyword,
                    start=pos,
                    end=pos + len(keyword),
                    severity=0.8,
                    rule_id=f"topic_{topic}",
                )
                toxic_spans.append(span)
                pos = filtered.find(keyword, pos + 1)

    # 去重并排序
    toxic_spans.sort(key=lambda s: s.start)

    # 判断是否有害
    is_toxic = len(toxic_spans) &gt; 0

    if is_toxic:
        # 从后往前替换有害内容
        result = list(filtered)
        for span in reversed(toxic_spans):
            replacement = "[内容已过滤]"
            result[span.start:span.end] = replacement
        filtered = ''.join(result)

    return is_toxic, toxic_spans, filtered
class LLMJudgeSafetyChecker:
"""
LLM-as-Judge 安全审核
对于规则难以判定的边界情况,用另一个 LLM 来做审核
"""

def __init__(self, judge_model=None):
    self.judge_model = judge_model
    self.rule_filter = ToxicContentFilter()

def check_with_rules(self, text: str) -&gt; Dict:
    """先用规则过滤"""
    is_toxic, spans, filtered = self.rule_filter.filter(text)

    return {
        "passed_rules": not is_toxic,
        "toxic_spans": spans,
        "filtered_text": filtered,
        "needs_llm_review": is_toxic and any(s.severity &lt; 0.8 for s in spans),
    }

def check_with_llm(self, text: str, context: str = "") -&gt; Dict:
    """
    用 LLM 做深度审核

    适用场景:
    - 规则判定为边界情况
    - 隐含的歧视或偏见
    - 经过伪装的违规内容
    """
    # 这里假设 judge_model 是一个可调用的 LLM
    # 实际使用时需要对接具体的模型 API

    judge_prompt = f"""
你是一个内容安全审核员。请判断以下内容是否包含:
暴力、色情、仇恨言论
违法信息或行为指导
欺诈或钓鱼内容
侵犯隐私的信息
内容:
{text}
上下文:
{context}
请输出 JSON 格式的判断结果:
{{
"is_safe": true/false,
"risk_level": "low/medium/high",
"categories": [],
"reason": "简要说明"
}}
"""
    # 模拟 LLM 判断(实际应调用模型)
    mock_result = {
        "is_safe": True,
        "risk_level": "low",
        "categories": [],
        "reason": "未检测到明显违规内容",
    }

    return mock_result

def comprehensive_check(self, text: str, context: str = "") -&gt; Dict:
    """
    综合安全检查:规则 + LLM
    """
    # 第一步:规则过滤
    rule_result = self.check_with_rules(text)

    # 第二步:如果需要,走 LLM 审核
    llm_result = None
    if rule_result["needs_llm_review"]:
        llm_result = self.check_with_llm(text, context)

    # 综合判定
    is_safe = rule_result["passed_rules"]
    if llm_result:
        is_safe = is_safe and llm_result["is_safe"]

    return {
        "is_safe": is_safe,
        "rule_result": rule_result,
        "llm_result": llm_result,
        "final_action": "pass" if is_safe else "block",
    }</code></pre>
四、输出护栏流水线
# output/output_guardrail.py
from typing import Dict, List, Optional, Callable
from dataclasses import dataclass, field
import time
import uuid
@dataclass
class OutputCheckResult:
"""输出检查结果"""
check_name: str
passed: bool
details: str
latency_ms: float
class OutputGuardrailPipeline:
"""
输出护栏流水线
在模型输出返回给用户之前,逐层检查
"""

def __init__(self):
    self.pii_sanitizer = PIISanitizer()
    self.toxic_filter = LLMJudgeSafetyChecker()
    self.checks: List[Callable] = []
    self.stats = {
        "total_outputs": 0,
        "pii_masked": 0,
        "toxic_blocked": 0,
        "avg_latency_ms": 0,
    }

def build_default_pipeline(self):
    """构建默认的输出检查流水线"""
    
    # 检查1:PII 检测
    def check_pii(output: str, user_id: str) -&gt; OutputCheckResult:
        start = time.time()
        cleaned, spans = self.pii_sanitizer.sanitize(output)
        latency = (time.time() - start) * 1000
        
        if spans:
            self.stats["pii_masked"] += 1
            return OutputCheckResult(
                check_name="pii_detection",
                passed=False,
                details=f"发现 {len(spans)} 处 PII: {[s.type for s in spans]}",
                latency_ms=round(latency, 2),
            )
        return OutputCheckResult(
            check_name="pii_detection",
            passed=True,
            details="未发现 PII",
            latency_ms=round(latency, 2),
        )
    
    # 检查2:有害内容
    def check_toxic(output: str, user_id: str) -&gt; OutputCheckResult:
        start = time.time()
        result = self.toxic_filter.comprehensive_check(output)
        latency = (time.time() - start) * 1000
        
        if not result["is_safe"]:
            self.stats["toxic_blocked"] += 1
            return OutputCheckResult(
                check_name="toxic_content",
                passed=False,
                details=f"检测到违规内容",
                latency_ms=round(latency, 2),
            )
        return OutputCheckResult(
            check_name="toxic_content",
            passed=True,
            details="内容安全",
            latency_ms=round(latency, 2),
        )
    
    # 检查3:幻觉检测(简单版------检测不确定表述)
    def check_hallucination(output: str, user_id: str) -&gt; OutputCheckResult:
        start = time.time()
        
        uncertainty_markers = [
            "我不确定", "可能是", "大概", "也许",
            "我记得", "好像", "应该是",
        ]
        
        found = [m for m in uncertainty_markers if m in output]
        latency = (time.time() - start) * 1000
        
        if found:
            return OutputCheckResult(
                check_name="hallucination_check",
                passed=False,
                details=f"存在不确定性表述: {found}",
                latency_ms=round(latency, 2),
            )
        return OutputCheckResult(
            check_name="hallucination_check",
            passed=True,
            details="表述确定",
            latency_ms=round(latency, 2),
        )
    
    self.checks = [check_pii, check_toxic, check_hallucination]

def process_output(self, output: str, user_id: str = "anonymous") -&gt; Dict:
    """
    处理模型输出
    
    Returns:
        {
            "original": str,
            "processed": str,
            "is_safe": bool,
            "checks": List[OutputCheckResult],
            "action": str,  # pass / mask / block
        }
    """
    self.stats["total_outputs"] += 1
    results = []
    current_output = output
    
    for check_fn in self.checks:
        result = check_fn(current_output, user_id)
        results.append(result)
        
        if not result.passed and "PII" in result.check_name:
            # PII 问题:脱敏后继续
            cleaned, _ = self.pii_sanitizer.sanitize(current_output)
            current_output = cleaned
        elif not result.passed and "toxic" in result.check_name:
            # 有害内容:拦截
            return {
                "original": output,
                "processed": "[内容因安全策略被拦截]",
                "is_safe": False,
                "checks": results,
                "action": "block",
            }
    
    # 更新平均延迟
    total_latency = sum(r.latency_ms for r in results)
    n = self.stats["total_outputs"]
    self.stats["avg_latency_ms"] = (
        (self.stats["avg_latency_ms"] * (n - 1) + total_latency) / n
    )
    
    return {
        "original": output,
        "processed": current_output,
        "is_safe": all(r.passed for r in results),
        "checks": results,
        "action": "pass" if all(r.passed for r in results) else "mask",
    }

def get_stats(self) -&gt; Dict:
    """获取统计信息"""
    return {
        **self.stats,
        "block_rate": round(
            self.stats["toxic_blocked"] / max(self.stats["total_outputs"], 1) * 100,
            2
        ),
        "pii_rate": round(
            self.stats["pii_masked"] / max(self.stats["total_outputs"], 1) * 100,
            2
        ),
    }</code></pre>
五、输出护栏的误杀与漏放
两类错误的权衡
────────────────────────────────────────────────────
误杀(False Positive):正常内容被拦截
代价:用户体验下降,用户觉得"这个 AI 怎么什么都不让说"
容忍度:可以接受少量误杀(目标 < 0.5%)
漏放(False Negative):有害内容通过了
代价:法律风险、品牌损害、用户安全
容忍度:零容忍
策略:
宁可误杀一千,不可漏放一个。
被误杀的用户可以申诉,被漏放的用户不会再来。
优化方向:
分级处理:高风险直接拦截,低风险标记后放行
人工复核:标记的内容定期抽样人工审核
A/B 测试:不同过滤策略的效果对比
六、本讲坑位
#
坑位
现象
解决方案
1
正则性能灾难​
复杂正则导致输出延迟从 200ms 飙升到 2s
先做简单的关键词匹配,再跑复杂的正则
2
PII 脱敏破坏语义​
"138****8000" 用户看不懂是谁的电话
在脱敏的同时保留部分可识别信息
3
LLM 审核循环​
用 LLM 审核 LLM 的输出,延迟和成本双高
规则能解决的不用 LLM,LLM 只处理边界情况
4
编码绕过​
攻击者用 Unicode 变体绕过关键词匹配
归一化后再检测(NFKC 标准化)
5
上下文丢失​
"他是混蛋" 单独看是辱骂,但在朋友间是玩笑
结合对话历史判断,但注意隐私
6
规则膨胀​
过滤规则越来越多,最终误杀率飙升
定期清理规则,用 A/B 测试验证有效性
相关推荐
冯智勇Geo18 小时前
看书不如聊书:分享一种新的 AI 阅读方式
chatgpt·ai阅读
叼烟扛炮1 天前
[LangChain] 学习笔记(04):手搓 ChatGPT 打字机——流式输出(SSE)与模型初始化选型
人工智能·笔记·chatgpt·langchain
xiaoye-duck3 天前
《C++ 项目:手写大模型接入 SDK》ChatGPT接口设计(下):流式响应实现和测试
网络·c++·语言模型·chatgpt
奈落244 天前
Codex 并进 ChatGPT 之后,“编程智能体“是变浅了还是变顺了:从官方概览页的功能分工到五家公司开发者反馈,读一次产品化合并的得与失
人工智能·chatgpt·工作流·ai 编程
xiaoye-duck4 天前
《C++ 项目:手写大模型接入 SDK》ChatGPT接口设计(中):全量响应实现和测试
网络·c++·语言模型·chatgpt
孤狼GPT4 天前
ChatGPT、Codex数据库实战:MyBatis-Plus第一页查询正常,翻到第10000页为什么越来越慢?
mysql·chatgpt·mybatis-plus·codex·深分页
jerrywus5 天前
普通人怎么用 Codex:从表格、文件和小工具开始
chatgpt·agent·mcp
仙逆GPT6 天前
ChatGPT、Codex工程实战:Agent接入企业知识库,文档明明更新了,为什么回答还是旧版本?
chatgpt·codex·rag·企业知识库·ai agent
lucas_AI6 天前
「无限 Codex」白嫖项目终结?OpenAI 12 月起把 ChatGPT 与 Codex 额度合成一个池子
chatgpt·openai