文章

LLM 内容安全生产实战:用分层 Moderation、阈值灰度与人工复核降低误杀

本文深入讲解如何在生成式AI应用中落地内容安全分类器,围绕输入输出分层检测、阈值灰度策略、误杀复核闭环、日志审计与上线门禁,构建可运营的 Moderation 生产体系,帮助团队平衡安全与用户体验。

背景:内容安全不是一个 if flagged then block 问题

大模型应用上线后,内容安全很快会从”接一个 Moderation API”变成一个生产治理问题。原因并不复杂:用户输入可能包含违规请求、擦边表达、引用材料、投诉文本、新闻片段或教育语境;模型输出也可能在拒答、解释、总结、翻译、客服回复、工具结果复述时触发安全分类器。

如果系统只按 flagged=true 直接阻断,结果往往是两个极端:要么拦不住真正有风险的隐晦表达,要么误杀大量正常内容。OpenAI 的 Moderation 文档也明确提示,category_scores 应当被当作应用策略信号,而不是自动阻断的唯一依据——拒答或安全解释本身也可能因为讨论有害内容而触发标记。

生产级内容安全系统需要处理四件事:

  1. 在哪里检测:输入、输出、工具结果、上传文件和多模态内容。
  2. 如何解释分数:类别、置信度、阈值和策略版本。
  3. 如何降低误杀:灰度、人工复核、申诉和白名单。
  4. 如何审计演进:策略变更、样本回放、阈值校准和模型升级后的再评估。

核心原理:把 Moderation 拆成分类器、策略和执行动作

Moderation 分类器本质上不是业务规则引擎。它负责给输入或输出打出一组风险信号,例如是否命中某类风险、每个类别的置信度分数、适用的输入类型,以及可能的错误状态。主流服务的能力对比如下:

服务覆盖模态核心机制特色能力
OpenAI Moderation文本、图像category_scores (0-1)生成请求中同步返回输入输出结果
Azure AI Content Safety文本、图像多类 harm category + severity level可配置 severity threshold
Google Cloud Model Armor文本prompt/response 检查 + 阈值模板inspect-only / inspect-and-block 模式
Meta Llama Guard 3文本LLM-based 安全分类开源可定制,支持多语言

生产系统应把链路拆成三层:

  • 分类层:调用模型或安全服务,输出标准化结果。
  • 策略层:把分类结果映射成动作——允许、提示改写、人工复核、阻断、账号限流、隐藏输出、仅记录审计。
  • 执行层:真正改变业务流程,例如不展示输出、进入复核队列、返回替代提示、打审计日志或触发风控规则。

这种拆分的好处是,分类模型可以升级,业务策略可以灰度,执行动作可以按场景区分。比如同样命中 violence 类别,新闻总结、医学急救、游戏剧情和现实伤害指令的处理方式不应完全相同。

推荐架构:输入、输出、工具结果三段式检测

1. 输入侧:先做轻量准入,再进入模型

输入侧检测的目标不是理解所有上下文,而是尽早发现明显不应进入模型的请求。常见流程:

user_input → normalize / language detect / PII precheck
  → moderation_input → policy_decision
  → allow | rewrite hint | human review | block
  → llm_request

输入检测适合处理明确违规、恶意骚扰、自伤风险、成人内容、仇恨攻击、危险行为指导、Prompt Injection 和高风险敏感信息。对于业务客服、教育、医疗、法律、金融等场景,还需要增加业务自定义类别,例如”诱导退款欺诈""泄露保单隐私""绕过风控规则”。

但输入侧不能过度自信。很多请求单看输入是正常的,结合上下文才有风险;也有很多输入看起来敏感,但属于投诉、举报、新闻、历史、学术或安全教育场景。因此输入侧更适合做明显风险拦截高风险复核分流

2. 输出侧:展示前最后一道门

输出检测应放在模型生成之后、内容展示之前。OpenAI 文档中提到,可以在生成请求中同时拿到输入和输出的 moderation scores;Google Model Armor 也强调 prompt 和 response 都可被检查。输出侧检测可以防止模型在复杂对话、长上下文、工具结果、检索内容或多轮追问中生成不适合展示的结果。

输出策略不能简单地”命中就删除”。更合理的决策链:

风险等级动作说明
低风险直接展示分数远低于阈值
中风险展示安全改写 / 追问用户意图可能有擦边内容
高风险阻断输出,返回拒答明显违规
不确定进入复核队列,暂时隐藏需人工判断

尤其是拒答文本、风险解释、政策说明、新闻摘要、医学急救建议等内容,可能出现”内容本身讨论风险,但意图是安全”的情况。此时需要结合模型响应类型、用户意图、业务场景和阈值策略做二次判断。

3. 工具结果和上传内容:不要只检测用户原始输入

Agent 应用里,风险内容常常来自工具结果,而不是用户输入。例如:

  • Web 搜索返回了不适合展示的文本
  • 数据库查出了敏感字段
  • 代码执行器输出了密钥片段
  • 文件解析器读取了违规图文

OpenAI 文档说明,工具调用参数和工具输出进入 conversation content 时可以被 moderation 覆盖,但工具名称、工具描述、工具 schema 或 response-format schema 不在覆盖范围内。

生产系统应把工具结果也纳入内容安全链路:

tool_result → redact / truncate / classify
  → safe_summary_or_block → llm_context
  → output_moderation

这样可以避免模型把外部系统中的风险内容原样复述给用户,也可以给审计日志留下”风险来自哪个工具、哪次调用、哪个字段”的依据。

阈值治理:从 inspect-only 到 block,而不是一次性上线

阈值是内容安全系统的核心配置。OpenAI 的 category_scores 是 0 到 1 的置信度信号;Google Model Armor 的模板支持不同 confidence level 和 enforcement type;Azure AI Content Safety 则提供类别和 severity level 的分层表达。不同供应商的分数语义并不完全等价,因此不能把一个平台的阈值直接复制到另一个平台。

建议用四阶段上线:

阶段一:影子检测

所有请求照常处理,只记录分类结果,不改变用户体验。目标是看真实流量中的命中率、类别分布、语言分布、业务场景分布和高分样本。

阶段二:人工抽样复核

按类别和分数段抽样,例如 0.3-0.5、0.5-0.7、0.7-0.9、0.9+。每个分数段都要标注”应拦截、应放行、应改写、应复核”。这一步会暴露误杀集中在哪些类别和语境。

阶段三:低风险动作灰度

先上线低破坏动作——提示用户改写、隐藏部分内容、进入人工复核、对新账号限流——而不是直接永久封禁。对高置信度、高危类别再启用阻断。

阶段四:策略版本化

每一次阈值、类别映射、动作规则调整都要生成策略版本。日志里必须记录:

{
  "policy_version": "safety-policy-2026-07-07-01",
  "classifier": "omni-moderation-latest",
  "category": "violence",
  "score": 0.82,
  "threshold": 0.75,
  "decision": "human_review",
  "surface": "output",
  "request_id": "req_..."
}

没有策略版本,就无法解释”为什么昨天放行、今天阻断”,也无法在误杀率升高时快速回滚。

误杀治理:内容安全系统也需要 SLO

内容安全系统的指标不能只看”拦截了多少违规内容”。它至少要同时观察四类指标:

维度关键指标
拦截质量违规样本拦截率、漏放率、人工复核命中率
误杀成本正常内容被阻断比例、申诉成功率、复核推翻率、重点客户误伤数
体验影响被阻断后转化率、对话中断率、改写后继续率、客服转人工率
系统稳定性分类器超时率、降级率、延迟 P95、策略服务错误率

安全分类器本身也可能出错。Llama Guard 3 的模型卡明确提醒,部署安全模型可能提升安全性,但也可能增加对良性提示的拒绝;并且 LLM 型 guard 模型会受到训练数据、政策覆盖、多语言能力和对抗攻击的限制。近年的 moderation API 审计研究也指出,商业内容审核 API 可能同时存在 over-moderation 和 under-moderation,尤其在群体相关表达、反击性言论、隐晦仇恨表达和语言变体上更容易出现偏差。

因此,生产系统需要一个误杀复核闭环:被拦截内容进入队列 → 审核员标注真实结果 → 标注结果回灌到阈值校准、规则例外、评测集和回归测试中。

工程落地:一个可运行的策略判断骨架

下面的 TypeScript 示例展示了如何把分类结果转换成业务动作。重点不是具体阈值,而是把分类、策略和执行动作拆开:

type SafetySurface = "input" | "output" | "tool_result";
type ModerationCategory =
  | "hate" | "harassment" | "self_harm" | "sexual"
  | "violence" | "illicit" | "prompt_injection" | "pii";

type ModerationSignal = {
  category: ModerationCategory;
  score: number;
  flagged: boolean;
};

type SafetyDecision = {
  action: "allow" | "rewrite" | "review" | "block";
  reason: string;
  policyVersion: string;
  auditRequired: boolean;
};

const POLICY_VERSION = "safety-policy-2026-07-07-01";

const thresholds: Record<SafetySurface, Partial<Record<ModerationCategory, number>>> = {
  input: {
    self_harm: 0.55, violence: 0.75, harassment: 0.80,
    prompt_injection: 0.65, pii: 0.60,
  },
  output: {
    self_harm: 0.45, violence: 0.70, harassment: 0.75,
    sexual: 0.70, illicit: 0.65,
  },
  tool_result: {
    pii: 0.50, illicit: 0.70, prompt_injection: 0.60, violence: 0.75,
  },
};

export function decideSafetyAction(
  surface: SafetySurface,
  signals: ModerationSignal[],
  accountRisk: "low" | "normal" | "high"
): SafetyDecision {
  const policy = thresholds[surface];
  const hits = signals
    .filter((s) => s.score >= (policy[s.category] ?? 0.95))
    .sort((a, b) => b.score - a.score);

  if (hits.length === 0) {
    return { action: "allow", reason: "no_policy_threshold_hit",
      policyVersion: POLICY_VERSION, auditRequired: false };
  }

  const top = hits[0];

  if (top.category === "self_harm" && top.score >= 0.75) {
    return { action: "review",
      reason: "high_confidence_self_harm_needs_safe_response_or_review",
      policyVersion: POLICY_VERSION, auditRequired: true };
  }

  if (surface === "output" && top.score >= 0.9) {
    return { action: "block",
      reason: `high_confidence_${top.category}_in_output`,
      policyVersion: POLICY_VERSION, auditRequired: true };
  }

  if (accountRisk === "high" && top.score >= 0.7) {
    return { action: "review",
      reason: `risk_adjusted_review_for_${top.category}`,
      policyVersion: POLICY_VERSION, auditRequired: true };
  }

  return { action: "rewrite",
    reason: `medium_confidence_${top.category}_requires_safer_response`,
    policyVersion: POLICY_VERSION, auditRequired: true };
}

这个骨架体现了三个原则:

  • 不同检测面有不同阈值——输入、输出、工具结果各有侧重。
  • 高风险类别不一定直接封禁——自伤类进入更谨慎的响应或复核。
  • 策略版本始终跟随决策写入日志——可追溯、可回滚。

适用场景

内容安全 Moderation 体系适用于以下场景:

  1. C 端对话产品:面向公众的聊天机器人、客服机器人、社区问答、教育助手和内容生成工具。
  2. UGC / 外部内容:支持用户上传图片、文档、网页链接、聊天记录、客服工单等外部内容的应用。
  3. 高合规行业:涉及未成年人、医疗、金融、保险、法律、招聘、政务等强监管业务。
  4. Agent 复杂系统:支持工具调用、联网搜索、数据库查询、代码执行或知识库检索的智能体应用。
  5. 企业级 LLM 平台:需要给运营、合规、安全团队提供完整审计证据链的内部平台。

常见误区

误区一:把 flagged 当作唯一判断

flagged 适合快速发现风险,但不能表达业务语境、用户意图、历史行为和合规差异。生产策略应同时查看类别、分数、检测位置、输入类型、账号风险和场景标签。

误区二:一个阈值覆盖所有业务

同一内容在客服投诉、新闻摘要、学术研究、娱乐创作和未成年人产品中的处理方式不同。阈值应按业务线、用户年龄段、地区法规、内容类型和风险等级拆分。

误区三:只检测用户输入,不检测模型输出

模型可能在长上下文、工具结果、检索片段或多轮诱导中生成不适合展示的内容。只做输入检测会漏掉大量输出侧风险。

误区四:没有人工复核闭环

没有复核样本,阈值就只能靠感觉调。误杀和漏放都需要样本、标签、复盘和回归测试,否则策略升级很容易引入新的业务损伤。

误区五:忽略分类器失败和超时

安全服务本身也可能超时或返回错误。高风险场景可以 fail-closed,低风险场景可以 fail-open + 审计;关键是策略必须显式定义降级行为,而不是让调用异常随机影响用户体验。

上线检查清单

上线前至少检查以下项目:

  • 明确了输入、输出、工具结果、上传文件和多模态内容的检测边界。
  • 为每个业务场景定义了类别阈值、动作映射和策略版本。
  • 支持 inspect-only 灰度,能够在不影响用户的情况下采集真实命中率。
  • 有人工复核队列,并能把复核结果回灌到评测集。
  • 日志记录 request_iduser_idsurfacecategoryscorethresholddecisionpolicy_version
  • 有误杀率、复核推翻率、申诉成功率、阻断率、分类器延迟和错误率看板。
  • 为分类器升级、阈值调整和策略发布准备了回放测试。
  • 定义了安全服务超时、失败、限流时的降级策略。
  • 对敏感日志做了脱敏、最小化保留和访问控制。

总结

LLM 内容安全生产不是接一个 API 就完事的单点方案,而是一个需要分层检测、阈值治理、误杀闭环和持续审计的系统工程。将 Moderation 拆成分类层、策略层和执行层,配合从影子检测到策略版本化的四阶段灰度上线,再辅以人工复核和 SLO 监控,才能在安全与用户体验之间找到可持续的平衡点。

参考资料

  1. OpenAI Moderation API Documentation
  2. Azure AI Content Safety: Harm categories
  3. Google Cloud Model Armor Overview
  4. Meta Llama Guard 3 8B Model Card
  5. Lost in Moderation: How Commercial Content Moderation APIs Over- and Under-Moderate Group-Targeted Hate Speech and Linguistic Variations

常见问题

Moderation 分类器是否应该直接决定封禁用户?
不建议。分类器分数应作为策略信号,用于阻断、降级、人工复核、限流或记录审计;账号处罚需要结合历史行为、业务规则和人工审核,自动封禁容易放大误杀且不利于后续解释。
为什么内容安全系统需要阈值灰度?
不同业务、语言、地区和用户群体的误杀成本不同。阈值灰度可以先在 inspect-only 模式下观察命中率、复核结果和投诉率,再逐步从低破坏动作切换到 block,避免一次性上线造成大面积误伤。
输入检测和输出检测哪个更重要?
两者都需要。输入检测拦截明显违规请求和高风险注入,输出检测防止模型在复杂上下文、工具结果或长链路中生成不适合展示的内容。只做其一会留下严重盲区。
如何避免阈值越调越保守?
需要同时监控漏放率和误杀率。只看违规拦截数会让系统越来越保守;必须加入正常内容误杀率、人工复核推翻率、用户申诉成功率和业务转化影响,阈值调整应通过历史样本回放而非直接全量发布。