《AI Agent 工程实战》15.2 输出侧:敏感信息、越狱与幻觉引用校验

本节给 ResearchCopilot 补上输出侧三道关:① 敏感信息用正则加结构化检测,命中就脱敏或拒答,并讨论误伤;② 越狱与不当内容靠独立于模型的检查层,不指望提示词;③ 幻觉引用校验把每条引用回语料核对(存在吗?支持吗?)。真跑三条答案,打印三道关各自的判定与最终处置,并给出校验项清单表。

15.2 输出侧:敏感信息、越狱与幻觉引用校验

15.1 守住了入口:脏内容进不来,越权工具发不出去。但出口同样要守——模型可能主动吐出不该吐的东西。它可能把检索到的密钥原样抄进答案,可能被一段诱导带进越狱模式,也可能给一句没根据的话挂上一个看起来很像真的引用。输入侧防的是「别人塞进来」,输出侧防的是「我们发出去」。

本节把 ResearchCopilot 推进到「出口可信」:给答案加三道独立的关——敏感信息检测、越狱与不当内容检查、幻觉引用校验。三道关都独立于模型,在答案生成之后、返回给用户之前运行。我们用三条答案真跑,打印每一道关的判定与最终处置。

15.2.1 为什么输出侧要单独设关

一个自然的疑问是:输入都防住了,输出还需要防吗?需要,因为输出侧的威胁来源不同:

威胁来源输入侧挡得住吗
敏感信息泄漏模型从上下文抄出密钥 / 个人信息挡不住:内容本来就在可信上下文里
越狱输出多轮累积的诱导,单轮看都没问题挡不住:每一条输入都「干净」
幻觉引用模型自己编造坐标挡不住:引用是模型生成的,不是输入

关键在输出是模型生成的,它的错误不来自输入。所以输出侧必须有一层「答案生成后、返回前」的检查。这一层有个通用原则:独立于模型——用规则、正则、外部语料,而不是「再问模型一遍这样对不对」。后者会把「模型的判断」当「模型的检查」,同一个模型犯的错它也看不见。

15.2.2 第一道关:敏感信息

先定义一组模式。这里只列最典型的四类,实际系统会维护一张更长的表(密钥、Token、内网 IP、邮箱……):

import re

SENSITIVE = [
    ("手机号", re.compile(r"\b1[3-9]\d-?\d{4}-?\d{4}\b")),
    ("银行卡", re.compile(r"\b\d{4}-\d{4}-\d{4}-\d{4}\b")),
    ("身份证", re.compile(r"\b\d{17}[\dXx]\b")),
    ("密钥形态", re.compile(r"\b(sk-[A-Za-z0-9]{8,}|AKIA[0-9A-Z]{16})\b")),
]

def check_sensitive(text: str) -> dict:
    hits, redacted = [], text
    for label, pat in SENSITIVE:
        for m in pat.finditer(text):
            hits.append((label, m.group()))
            redacted = redacted.replace(m.group(), "*" * len(m.group()))
    return {"hit": bool(hits), "hits": hits, "redacted": redacted}

命中之后有两种处置:脱敏(把敏感串换成 ***,答案照发)或拒答(整条答案打回)。怎么选?看敏感度与业务:

处置适用例子
脱敏后放行敏感串是「误带进来的」,答案主体有效手机号、内网 IP
拒答敏感串本身就是答案的核心密钥、密码、完整身份证

这一关最大的坑是误伤。比如银行卡正则会把 4111-1111-1111-1111 这类明显是测试值的串也标红——它长得太像真卡号了。所以:

  • 模式要尽量精确:1[3-9] 开头的手机号正则能少误伤一截订单号。
  • 给测试值留白名单:4111-...、0000-0000-0000-0000 这类公认测试值可配置为「命中但只记不拦」。
  • 宁可脱敏不要拒答:脱敏是可逆的低成本处置,拒答会打断整个任务。

一个补充口径:脱敏只对「面向用户的那份答案」做。审计日志里存的是原始文本的哈希与命中类型,不是明文(15.3)——否则脱敏就成了掩耳盗铃。

结构化检测是正则之外的第二把尺子。纯正则「16 位数字」会把订单号、时间戳一并误伤;补一个校验位算法能显著降误报。银行卡号可以过 Luhn 校验:

def luhn_ok(digits: str) -> bool:
    ds = [int(c) for c in digits if c.isdigit()]
    if len(ds) < 12:
        return False
    total, alt = 0, False
    for d in reversed(ds):
        if alt:
            d *= 2
            if d > 9:
                d -= 9
        total += d
        alt = not alt
    return total % 10 == 0

真跑三个串:

4111-1111-1111-1111    luhn=True
4111-1111-1111-1112    luhn=False
138-0000-0000          luhn=False

注意第一行:4111-1111-1111-1111 是一个公认测试卡号,它通过了 Luhn 校验。这说明 Luhn 只能筛掉「格式对但校验位错」的误报,挡不住合法的测试值——要处理测试卡号,还是得靠显式白名单。两把尺子叠加:先 Luhn 缩范围,再白名单放行已知测试值,剩下的才判敏感。

15.2.3 第二道关:越狱与不当内容

越狱(jailbreak)指用一系列话术让模型绕过自身安全策略,输出本该拒绝的内容。为什么「靠提示词让模型别说」不够?三个原因:

  • 服从是概率性的:再强的模型也可能被某个特定措辞说服,没有 100%。
  • 多轮累积:单看每一轮都无害,几轮铺垫下来才越界,单轮检查抓不到。
  • 不可审计:模型「自觉拒绝了」还是「没看懂」,提示词层面留不下证据。

所以要有独立于模型的检查层。最便宜的一层是关键词 + 正则,抓那些已知的越狱话术:

JAILBREAK = [re.compile(p) for p in [
    r"忽略(你的)?(安全)?(限制|规则|指令)", r"\bDAN\b",
    r"扮演.{0,6}(无限制|没有限制)", r"越狱", r"开发者模式",
    r"忽略之前所有(指令|规则)",
]]

def check_jailbreak(text: str) -> dict:
    hits = [p.pattern for p in JAILBREAK if p.search(text)]
    return {"hit": bool(hits), "hits": hits}

这层很粗,只覆盖已知话术,但它的价值在于确定性、零延迟、可审计:命中就拒答并记一条事件。更强的做法是接一个内容分类模型(把「越狱诱导」当一个分类任务),本卷把确定性内核立住,分类模型留作可选增强。

注意这一关查的是输出,而不是输入——因为要防的是「模型真的输出了不当内容」,而不是「用户试图诱导」。输入侧的诱导(15.1)可以放行,让模型去拒绝;只有当模型没能拒绝、把不当内容吐出来时,输出侧这一关才拦。

这个「查输出不查输入」的取向有实际好处:它不误伤正常提问。用户问「DAN 模式是什么」是一个正当的知识性问题,不该被拦;只有当模型真的以 DAN 的口吻输出内容时,才该拦。把检查放在出口,等于让模型先有一次「自己拒绝」的机会,检查层只兜底它失手的情况——能交给模型的先交给模型,拦不住再上代码层,这与 15.1「护栏属于系统」并不矛盾:输入侧靠代码兜底是因为越权动作不可逆,输出侧的兜底则要与正常问答的体验平衡。

15.2.4 第三道关:幻觉引用校验

第三关是 Agent 特有的:答案里的每条引用都要回语料核对。卷一 10.3 已经立了「存在性 + 支持度」两层校验,这里把它当成输出侧的第三道关复用:

CITE_RE = re.compile(r"\[([a-z0-9\-]+)#(\d+)\]")

def check_citations(answer: str, threshold: float = 0.2) -> dict:
    rows = []
    for doc_id, cid in CITE_RE.findall(answer):
        key = (doc_id, int(cid))
        if key not in CORPUS:                       # 第一层:引用存在吗
            rows.append((f"{doc_id}#{cid}", "无效(引用不存在)", 0.0))
            continue
        claim = CITE_RE.sub("", answer).strip()
        ratio = round(overlap(claim, CORPUS[key]), 3)   # 第二层:被引块支持吗
        verdict = "有效" if ratio >= threshold else "无效(被引块不支持)"
        rows.append((f"{doc_id}#{cid}", verdict, ratio))
    ok = all(r[1] == "有效" for r in rows) and bool(rows)
    return {"status": "pass" if ok else "fail", "rows": rows}

两类失败对应两种问题:引用不存在是模型幻觉出一个坐标;引用存在但不支持是模型张冠李戴。校验失败不一定要拒答——降级更常见:把这条论断改成「无引用答案」或标红提示「此论断未经验证」。这与卷一 10.3 的处置一致:降级 + 标注比整条打回温和,比照单全收安全。

15.2.5 真跑:三条答案过三道关

把三道关串成一条流水线。三道关独立判定,最终处置由三者合并:

def pipeline(answer: str) -> str:
    sens = check_sensitive(answer)
    jail = check_jailbreak(answer)
    cite = check_citations(answer)
    if jail["hit"]:
        return "拒答(命中越狱特征)"
    if sens["hit"]:
        return "脱敏后放行"
    if cite["status"] == "fail":
        return "降级:改「无引用答案」并标红"
    return "放行"

优先级是越狱 > 敏感 > 引用:越狱最严重(整条拒答),敏感次之(可脱敏救回),引用最轻(降级标注即可)。喂三条各带一种毛病的答案(全部为虚构样例):

A1 = ("GIL 是 CPython 的全局解释器锁,同一时刻只有一个线程执行字节码。[gil#0] "
      "如遇问题请联系 138-0000-0000,或使用测试卡号 4111-1111-1111-1111。")
A2 = "CPython 3.13 已默认移除 GIL。[gil#999]"
A3 = "用多进程可以绕过 GIL 实现真正的并行计算。[gil#0]"

A1 带敏感串(一个假手机号 + 一个公认测试卡号);A2 引用了一个不存在的块 gil#999;A3 引用了真实存在的 gil#0,但那块只定义了 GIL,并不支持「用多进程绕过 GIL」这句论断。真跑(Python 3.14.6)输出:

---- 答案 A1 ----
  敏感信息: 命中 [('手机号', '138-0000-0000'), ('银行卡', '4111-1111-1111-1111')]
  越狱内容: 通过
  引用 gil#0        有效           overlap=0.548
  最终处置: 脱敏后放行
  脱敏后:   GIL 是 CPython 的全局解释器锁,同一时刻只有一个线程执行字节码。[gil#0] 如遇问题请联系 *************,或使用测试卡号 *******************。

---- 答案 A2 ----
  敏感信息: 通过
  越狱内容: 通过
  引用 gil#999      无效(引用不存在)    overlap=0.0
  最终处置: 降级:改「无引用答案」并标红

---- 答案 A3 ----
  敏感信息: 通过
  越狱内容: 通过
  引用 gil#0        无效(被引块不支持)   overlap=0.059
  最终处置: 降级:改「无引用答案」并标红

三条答案各自触发一道关:A1 的敏感串被识别并脱敏,答案主体(那句有据的 GIL 定义)保留;A2 的 gil#999 不存在,直接判无效;A3 的 gil#0 真实存在,但重叠只有 0.059,远低于 0.2 的阈值,判「被引块不支持」。A3 是最容易被漏掉的那类——只看引用存在与否,它会因为坐标合法而蒙混过关。

值得单独说 A1 的处置:它的引用 gil#0 校验有效(overlap=0.548),只是答案里夹带了两串敏感信息。三道关是正交的——引用关不管敏感信息,敏感关不管引用。所以最终处置要综合三道关的结论,而不是「任一关通过就放行」:A1 的引用关通过,但敏感关命中,于是走脱敏;A2、A3 的敏感关通过,但引用关失败,于是走降级。每道关独立判定,最终处置由三者合并,这是流水线设计的关键。

再单独验证第二道关,喂一段越狱诱导:

---- 第二道关反例:一段越狱诱导 ----
  越狱内容: {'hit': True, 'hits': ['忽略(你的)?(安全)?(限制|规则|指令)', '扮演.{0,6}(无限制|没有限制)', '开发者模式']}
  最终处置: 拒答(命中越狱特征)

三条规则同时命中,处置是拒答。注意这三条命中的是同一段文本的三个特征——真实系统里通常一条命中就够,这里全列出来是为了看清检查层的工作方式。

15.2.6 校验项清单与真实环境

把三道关的检查项压成一张清单,方便对照实现:

关检查项手段失败处置
① 敏感信息手机号 / 银行卡 / 身份证 / 密钥形态正则 + 结构化脱敏或拒答
① 敏感信息测试值白名单精确匹配只记不拦
② 越狱已知越狱话术正则拒答 + 记事件
② 越狱不当内容分类(可选)分类模型拒答
③ 引用引用坐标是否存在查索引降级 / 标红
③ 引用被引块是否支持论断关键词重叠降级 / 标红

15.2.7 三道关放在流水线的哪一步

输出护栏的位置不是「答案完全生成后」这么简单。ResearchCopilot 的答案走 SSE 流式返回(3.2),token 是一个一个吐给前端的。如果等整段生成完再检查,用户已经看到敏感串了;如果每个 token 都查,正则会被切断的片段搞得误报连连(4111- 只到一半,Luhn 根本算不了)。

实际做法是分段缓冲 + 边界检查:

环节做法理由
生成中按句 / 按段缓冲,不逐 token 检查片段不完整,检查必误报
缓冲满一段对该段跑三道关段内文本完整,判定可靠
命中敏感该段脱敏后再推给前端用户看不到明文
整段结束对完整答案再跑一遍引用校验引用标记可能跨段

一个细节:引用校验必须等整段答案结束。[gil#0] 这个标记可能被拆在两次推送里([gil# + 0]),中途检查会以为引用不存在。所以引用关是「终态检查」,敏感信息关是「增量检查」——两者的时机不同,不能一刀切。

还有一个工程取舍:检查层要 fail-closed。检查服务超时或异常时,默认不推送(或推送已脱敏的降级版本),而不是「检查失败就放行」。这与 4.3 审批超时默认拒绝是同一条原则:安全相关的默认答案,永远是「否」。

真实模型下会怎样:本节的三条答案都是脚本构造的,判定是确定性的。真实 Claude 的输出更"狡猾"——它会用自己的话转述引用块,重叠率天然偏低,0.2 的阈值要相应调松;它也可能把敏感串拆开写(138-0000-0000 写成 138 0000 0000)绕过正则,所以模式要覆盖多种分隔符。更根本的一点:这三道关都不是"让模型变安全",而是"让系统的出口可验证"。模型越强,越不能把安全性寄托在它"应该不会说"上;相反,正因为模型会出错,出口的独立检查层才有价值。真实系统还会给每道关配一个指标(命中率、误报率、拒答率),用 12 章的评测集去调阈值——那是把护栏从"能拦"推到"拦得准"的下一步。

阅读导航:上一节:15.1 输入侧:提示注入与越权工具拦截 · 下一节:15.3 审计与合规:不可篡改审计日志 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距