一次没有申诉入口的拦截
「回声」的私信助手曾把一条正常的售后回复判为违规:用户问“电池鼓包会不会有危险”,模型回答里出现了“起火”“爆炸”之类的词,被规则直接拦掉。用户看到的是“内容无法展示”,没有申诉入口;客服查不到命中哪条规则,也拿不到可复现的输入,只能让用户重发。第二次重发,模型换了措辞,又被另一条规则拦住。这次事故暴露的不是某条规则写错,而是三个缺口:只有规则层,没有分类器层;拦截只返回一句通用文案,没有审计记录;拦截没有申诉入口与时限。输出审查不是“加一个敏感词表”,而是一条从判定、降级、审计到申诉的链路。
规则层与分类器层,失败模式不同
规则层处理确定、可复现、可解释的模式:手机号、身份证号、邮箱、提示词泄漏标记。它的优点是命中即可解释,测试稳定;缺点是写不全,改写、隐喻、多语言都能绕开。分类器层处理语义风险:暴力、色情、自残、辱骂、垃圾营销。它的优点是覆盖面广,缺点是分数是概率,阈值需要校准,误判难以逐条解释。两层不是互相替代,而是互相补位。规则层负责“确定不能出”,分类器层负责“可能有问题”。任何一层单独上线,都会在另一层的盲区里出事。
规则层:硬拦截与复核分开
规则命中分两种动作: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。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。