《互联网产品的 AI 架构·模型接入与能力边界》11.3 输出审查与内容安全接入

一次输出拦截没有申诉入口,让「回声」的内容安全接入暴露出规则与流程的双重缺口。本篇给出规则加分类器的双层审查、拦截与降级、只存哈希的审计记录与带时限的申诉状态机,用可运行的 TypeScript 测试固定决策顺序、降级行为与已知边界,并说明申诉推翻后为什么不能自动放行原文。

一次没有申诉入口的拦截

「回声」的私信助手曾把一条正常的售后回复判为违规:用户问“电池鼓包会不会有危险”,模型回答里出现了“起火”“爆炸”之类的词,被规则直接拦掉。用户看到的是“内容无法展示”,没有申诉入口;客服查不到命中哪条规则,也拿不到可复现的输入,只能让用户重发。第二次重发,模型换了措辞,又被另一条规则拦住。这次事故暴露的不是某条规则写错,而是三个缺口:只有规则层,没有分类器层;拦截只返回一句通用文案,没有审计记录;拦截没有申诉入口与时限。输出审查不是“加一个敏感词表”,而是一条从判定、降级、审计到申诉的链路。

规则层与分类器层,失败模式不同

规则层处理确定、可复现、可解释的模式:手机号、身份证号、邮箱、提示词泄漏标记。它的优点是命中即可解释,测试稳定;缺点是写不全,改写、隐喻、多语言都能绕开。分类器层处理语义风险:暴力、色情、自残、辱骂、垃圾营销。它的优点是覆盖面广,缺点是分数是概率,阈值需要校准,误判难以逐条解释。两层不是互相替代,而是互相补位。规则层负责“确定不能出”,分类器层负责“可能有问题”。任何一层单独上线,都会在另一层的盲区里出事。

规则层:硬拦截与复核分开

规则命中分两种动作:block 与 review。手机号、身份证号、邮箱和提示词泄漏标记直接拦截;自残、辱骂、垃圾营销和违规承诺进入复核。这样分不是降低标准,而是承认不同风险的处置方式不同:确定泄漏个人信息的输出不能展示,而涉及自残的内容需要人工判断语境,直接拦截可能让真正求助的人得不到帮助。规则在 NFKC 归一化后的文本上执行,避免全角数字、兼容字符绕过。命中记录包含 ruleId、类别、动作、权重和截断后的摘要。摘要是给人看的线索,不是完整原文;完整原文不应该进入普通日志。

分类器层:接口稳定,实现可替换

分类器被定义成一个接口:输入文本,输出每个类别的 0 到 100 分。本文用一个本地词表分类器做演示,它只按命中词数打分,不代表线上准确率,也不能替代真实内容安全服务。接口稳定的价值在于,接入真实分类器时,审查流程、阈值、审计字段都不用改。阈值按租户可覆盖:blockAt 默认 80,reviewAt 默认 50。阈值是案例假设值,必须用自己的语料回归,而不是照抄。分类器的 id 会写进审计,这样同一段文本在不同分类器版本下得到不同结论时,可以追溯。

决策顺序与降级

决策顺序固定:硬规则拦截 > 分类器拦截 > 规则复核 > 分类器复核 > 放行。顺序固定,才能保证同一输入在不同实现下得到同样的层级与原因。拦截时返回预设的安全文案,并标记 degraded: true;复核时保留原文,但标记为待复核;放行时原样返回。拦截绝不能返回原文再让前端“自己藏起来”,因为原文一旦进入响应,就已经离开了服务端边界。安全文案必须是产品预先审核过的模板,不能由模型生成。

审计:只存哈希,不存原文

审计记录保存请求 id、租户 id、动作、层级、类别、命中的规则 id、分类器 id、文本哈希、文本长度和时间。不保存原文,是为了避免审计表变成第二个泄漏面;保存哈希,是为了去重、比对和证明“这条记录对应哪段文本”。但只存哈希有一个明确的边界:人工复核时看不到原文,无法判断语境。工程上通常再需要一个受控的隔离证据库,按权限、按工单、按保留期访问原文;本文不实现它,但必须承认这个缺口,而不是把哈希当成完整审计。

申诉:入口、时限与“不自动放行”

申诉对 block 和 review 都开放,放行结果不需要申诉。openAppeal 要求申诉理由有最小长度,并给出 24 小时 SLA;resolveAppeal 只允许处理 pending 状态,防止重复结案。推翻只代表“原判断有误”,不等于自动放行原文。业务方必须用新的规则或策略重跑一次,审计链才完整。这个约束看起来麻烦,但它避免了“申诉通过后原文绕过所有检查直接上线”的二次事故。

代码:从判定到申诉的完整链路

下面的模块把规则、分类器接口、决策顺序、审计与申诉放在一起。分类器是本地 mock,不调用外部服务。

// file: src/security/output-guard.ts
// tier: L1
// verify: node --test test/security/output-guard.test.ts

import { createHash } from "node:crypto";

import { AiError } from "../core/errors.ts";

export type GuardCategory =
  "pii" | "prompt-leak" | "self-harm" | "abuse" | "sexual" | "violence" | "spam" | "legal";

export type GuardAction = "allow" | "review" | "block";
export type GuardLayer = "rule" | "classifier" | "none";

export interface RuleHit {
  ruleId: string;
  category: GuardCategory;
  action: "block" | "review";
  weight: number;
  excerpt: string;
}

export interface ClassifierScore {
  category: GuardCategory;
  score: number;
}

export interface OutputClassifier {
  readonly id: string;
  classify(text: string): ClassifierScore[];
}

export interface GuardThresholds {
  blockAt: number;
  reviewAt: number;
}

export const DEFAULT_THRESHOLDS: GuardThresholds = { blockAt: 80, reviewAt: 50 };

interface RuleSpec {
  id: string;
  category: GuardCategory;
  action: "block" | "review";
  weight: number;
  pattern: RegExp;
}

/** 规则层只处理“确定、可复现、可解释”的模式;模糊判断交给分类器层。 */
const RULES: RuleSpec[] = [
  { id: "pii.phone-cn", category: "pii", action: "block", weight: 100, pattern: /\b1[3-9]\d{9}\b/u },
  { id: "pii.id-card-cn", category: "pii", action: "block", weight: 100, pattern: /\b\d{17}[\dXx]\b/u },
  {
    id: "pii.email",
    category: "pii",
    action: "block",
    weight: 90,
    pattern: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/u,
  },
  {
    id: "prompt-leak.marker",
    category: "prompt-leak",
    action: "block",
    weight: 100,
    pattern: /系统提示词|system\s+prompt|初始指令|internal\s+instructions/iu,
  },
  {
    id: "self-harm.phrase",
    category: "self-harm",
    action: "review",
    weight: 70,
    pattern: /自杀|自残|结束生命|suicide|self[- ]harm/iu,
  },
  { id: "abuse.phrase", category: "abuse", action: "review", weight: 60, pattern: /去死|废物|kill\s+yourself/iu },
  {
    id: "spam.contact",
    category: "spam",
    action: "review",
    weight: 55,
    pattern: /加微信|私聊我|点击链接领取|free\s+money/iu,
  },
  {
    id: "legal.promise",
    category: "legal",
    action: "review",
    weight: 60,
    pattern: /保证收益|稳赚不赔|包治百病/iu,
  },
];

export function scanRules(text: string): RuleHit[] {
  const hits: RuleHit[] = [];
  for (const rule of RULES) {
    const match = rule.pattern.exec(text);
    if (match === null) continue;
    hits.push({
      ruleId: rule.id,
      category: rule.category,
      action: rule.action,
      weight: rule.weight,
      excerpt: match[0].slice(0, 32),
    });
  }
  return hits;
}

const LEXICON: Record<GuardCategory, string[]> = {
  pii: ["身份证号", "家庭住址", "手机号码"],
  "prompt-leak": ["系统提示", "内部指令", "开发者消息"],
  "self-harm": ["不想活", "伤害自己", "轻生"],
  abuse: ["蠢货", "滚开", "垃圾东西"],
  sexual: ["裸照", "色情"],
  violence: ["砍死", "炸毁", "血洗"],
  spam: ["博彩", "刷单", "代开发票"],
  legal: ["保本", "高额回报", "稳赚"],
};

/**
 * 本地 mock 分类器:按词表命中数打分,只用于演示分层与阈值,
 * 不代表线上分类器的准确率,也不能替代真实内容安全服务。
 */
export class KeywordClassifier implements OutputClassifier {
  readonly id: string;
  readonly lexicon: Record<GuardCategory, string[]>;

  constructor(id: string, lexicon: Record<GuardCategory, string[]>) {
    this.id = id;
    this.lexicon = lexicon;
  }

  classify(text: string): ClassifierScore[] {
    const scores: ClassifierScore[] = [];
    const categories = Object.keys(this.lexicon).sort() as GuardCategory[];
    for (const category of categories) {
      const hits = this.lexicon[category].filter((word) => text.includes(word)).length;
      if (hits > 0) scores.push({ category, score: Math.min(100, hits * 40) });
    }
    return scores;
  }
}

export const mockClassifier: OutputClassifier = new KeywordClassifier("mock-lexicon-v1", LEXICON);

export interface GuardContext {
  requestId: string;
  tenantId: string;
  /** 拦截时返回的安全文案,必须是产品预先审核过的模板。 */
  fallback: string;
  now: number;
  thresholds?: Partial<GuardThresholds>;
}

export interface GuardVerdict {
  action: GuardAction;
  layer: GuardLayer;
  reason: "hard-rule" | "classifier" | "review-band" | "clean";
  safeText: string;
  degraded: boolean;
  categories: GuardCategory[];
  ruleHits: RuleHit[];
  classifier: ClassifierScore[];
}

export interface OutputAudit {
  id: string;
  requestId: string;
  tenantId: string;
  action: GuardAction;
  layer: GuardLayer;
  categories: GuardCategory[];
  ruleIds: string[];
  classifierId: string;
  /** 只存哈希,不把被拦截的原文写进审计表。 */
  textSha256: string;
  textLength: number;
  at: number;
  appealable: boolean;
}

export interface GuardResult {
  verdict: GuardVerdict;
  audit: OutputAudit;
}

function sortCategories(values: Iterable<GuardCategory>): GuardCategory[] {
  return [...new Set(values)].sort();
}

/**
 * 双层审查:规则层可解释、可回归;分类器层覆盖规则写不全的语义风险。
 * 决策顺序固定:硬规则拦截 > 分类器拦截 > 规则复核 > 分类器复核 > 放行。
 */
export function guardOutput(
  text: string,
  context: GuardContext,
  classifier: OutputClassifier = mockClassifier,
): GuardResult {
  const thresholds: GuardThresholds = { ...DEFAULT_THRESHOLDS, ...context.thresholds };
  const normalized = text.normalize("NFKC");
  const ruleHits = scanRules(normalized);
  const classifierScores = classifier
    .classify(normalized)
    .slice()
    .sort((a, b) => b.score - a.score || (a.category < b.category ? -1 : 1));
  const top = classifierScores[0];
  const hardBlock = ruleHits.some((hit) => hit.action === "block");
  const reviewHit = ruleHits.some((hit) => hit.action === "review");

  let action: GuardAction = "allow";
  let layer: GuardLayer = "none";
  let reason: GuardVerdict["reason"] = "clean";
  if (hardBlock) {
    action = "block";
    layer = "rule";
    reason = "hard-rule";
  } else if (top !== undefined && top.score >= thresholds.blockAt) {
    action = "block";
    layer = "classifier";
    reason = "classifier";
  } else if (reviewHit) {
    action = "review";
    layer = "rule";
    reason = "review-band";
  } else if (top !== undefined && top.score >= thresholds.reviewAt) {
    action = "review";
    layer = "classifier";
    reason = "review-band";
  }

  const categories = sortCategories([
    ...ruleHits.map((hit) => hit.category),
    ...classifierScores.filter((score) => score.score >= thresholds.reviewAt).map((score) => score.category),
  ]);
  const textSha256 = createHash("sha256").update(text, "utf8").digest("hex");
  const verdict: GuardVerdict = {
    action,
    layer,
    reason,
    safeText: action === "block" ? context.fallback : text,
    degraded: action === "block",
    categories,
    ruleHits,
    classifier: classifierScores,
  };
  const audit: OutputAudit = {
    id: `audit-${textSha256.slice(0, 12)}-${action}`,
    requestId: context.requestId,
    tenantId: context.tenantId,
    action,
    layer,
    categories,
    ruleIds: ruleHits.map((hit) => hit.ruleId).sort(),
    classifierId: classifier.id,
    textSha256,
    textLength: text.length,
    at: context.now,
    appealable: action !== "allow",
  };
  return { verdict, audit };
}

export interface AppealCase {
  id: string;
  auditId: string;
  status: "pending" | "upheld" | "overturned";
  reason: string;
  openedAt: number;
  dueAt: number;
  resolvedAt?: number;
  resolutionNote?: string;
}

/** 申诉入口对 block 与 review 都开放;放行结果不需要申诉。 */
export function openAppeal(
  audit: OutputAudit,
  input: { reason: string; now: number; slaMs?: number },
): AppealCase {
  if (!audit.appealable) throw new AiError("invalid-request", "allow decisions are not appealable");
  const reason = input.reason.trim();
  if (Array.from(reason).length < 4) throw new AiError("invalid-request", "appeal reason is too short");
  const slaMs = input.slaMs ?? 24 * 60 * 60 * 1_000;
  if (slaMs <= 0) throw new AiError("invalid-request", "appeal SLA must be positive");
  return {
    id: `appeal-${audit.id.slice("audit-".length)}`,
    auditId: audit.id,
    status: "pending",
    reason,
    openedAt: input.now,
    dueAt: input.now + slaMs,
  };
}

/**
 * 推翻只代表“原判断有误”,不等于自动放行原文。
 * 业务方必须用新的规则或策略重跑一次,审计链才完整。
 */
export function resolveAppeal(
  current: AppealCase,
  input: { status: "upheld" | "overturned"; note: string; now: number },
): AppealCase {
  if (current.status !== "pending") throw new AiError("invalid-request", `appeal ${current.id} is already ${current.status}`);
  const note = input.note.trim();
  if (Array.from(note).length < 4) throw new AiError("invalid-request", "resolution note is too short");
  return { ...current, status: input.status, resolvedAt: input.now, resolutionNote: note };
}

guardOutput 的返回值分成 verdict 与 audit 两部分:前者给业务方决定展示什么,后者给审计与客服定位。两者分开,避免业务代码直接把审计字段拼进用户响应。

测试与真实输出

测试覆盖 12 个场景:放行、手机号硬拦截、提示词泄漏、分类器拦截、规则优先、复核区间、阈值覆盖、类别排序、审计哈希稳定、申诉与重复结案、放行不可申诉、上下文保留。执行:

node --test test/security/output-guard.test.ts

本机一次运行的真实输出如下:

ℹ tests 12
ℹ pass 12
ℹ fail 0
ℹ duration_ms 149.429542

复盘:三个缺口分别怎么补

第一个缺口用双层审查补:规则拦住确定项,分类器覆盖语义项。第二个缺口用审计补:每次拦截都有 id、哈希、规则与分类器版本。第三个缺口用申诉补:用户能提交理由,客服能按请求 id 找到记录,系统有 24 小时时限。补完之后,同样的“电池鼓包”回答仍可能被复核,但不会再无声消失。

小结

输出审查要同时回答四个问题:什么不能出、由谁判定、出了事怎么查、用户不服怎么办。规则与分类器回答第一个,固定决策顺序回答第二个,审计回答第三个,申诉状态机回答第四个。少任何一个,链路都会在事故里断掉。

阅读导航:上一节:11.2 提示注入与输入信任边界 · 下一节:12.1 一次 AI 请求的最小观测字段 。

常见错误与边界

  • 只用敏感词表,认为规则覆盖不到的内容就是安全的。
  • 规则命中后直接删除原文,导致无法复核与申诉。
  • 拦截时返回原文,让前端自行隐藏。
  • 用模型生成的安全文案替代产品审核过的降级模板。
  • 分类器没有版本号,结论变化时无法追溯。
  • 审计表保存完整原文,把日志变成第二个泄漏面。
  • 只存哈希却不建受控证据库,人工复核时看不到语境。
  • 申诉通过后自动放行原文,绕过规则重跑。
  • 申诉没有时限与状态机,工单长期停在 pending。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查