
上个月我们客服工单自动回复系统正式接入 LLM结果三天之内生产环境出了两次事故。第一次是模型输出的 JSON 多了一个尾逗号下游工单写入服务直接全红第二次更麻烦一条自动回复里夹带了另一个用户的订单号隐私合规那边差点把项目叫停。经历过这种AI 生成内容炸掉生产的场景之后你会被迫正视一个问题大模型生成能力越强输出侧越需要一套完整的 Guardrail 防护体系。这篇文章就把我们最终落地的五层输出护栏拆开讲清楚从格式校验、敏感信息识别、内容合规审查、幻觉检测一直到熔断降级和人工兜底每一层解决什么故障、怎么实现、有哪些容易踩的坑都尽量写完整。1. 从炸掉生产的事故聊起LLM 输出失控的四种典型模式1.1 事故复盘尾逗号、订单号泄露和态度突变先说第一次事故。我们的客服机器人接了一个工单分类加回复生成的链路模型返回的是一段 JSON下游服务拿这段 JSON 去写数据库。某天高峰期模型返回的内容里多了一个尾逗号还把一个字段的值从大写改成了小写下游的强校验直接抛异常一万多条工单积压在队列里。当时排查了很久才发现不是网络问题不是数据库问题是模型输出的格式不符合约定。第二次事故更棘手。有用户在咨询物流进度时模型自动回复中出现了一段完整订单号——不是这个用户自己的而是另一位用户的。我们把日志翻出来发现上下文拼接时可能把历史会话里的数据混进去了而模型在生成时完全没有区分哪些信息属于当前对话哪些属于知识库或历史记录。这种数据泄露一旦发生在真实生产环境里后果比格式错误严重得多。这两次事故让我意识到LLM 输出链路和传统接口有一个本质区别传统接口的输出是确定性的参数类型、返回结构、取值范围都是写死的LLM 的输出是概率性的同样的输入可能得到完全不同的结果。所以不能把模型当作一个普通 REST API 来用必须在它和业务系统之间架一道闸门。1.2 提示词无法解决的概率性缺陷有人可能会问能不能靠写更详细的系统提示词来避免这些问题比如告诉模型必须返回合法 JSON不要泄露用户隐私。说实话我们最初也是这么做的提示词写了几百字效果还是不稳定。原因很简单提示词是软约束模型在绝大多数情况下会遵守但那 0.1% 的偏离正好就是生产事故的源头。还有一个容易被忽视的问题模型对指令的遵循能力本身会漂移。今天这个版本遵循得很好明天换了模型版本或者调整了温度参数行为就可能变化。生产系统不能把稳定性建立在模型听不听话上必须靠外部机制做硬约束。Guardrail 的作用就是把这些软约束变成硬校验不合法就不放行不合规就拦截宁可少答不可错答。1.3 五层防护栏的整体架构我们最终整理的五层结构是逐层递进的每一层解决不同层面的故障模式层与层之间保持独立便于单独升级和测试层级核心能力要拦截的问题第一层格式与结构校验JSON 解析失败、字段类型错误、枚举值非法第二层PII 识别与去标识化手机号、订单号、身份证等隐私数据外流第三层内容合规与恶意诱导检测辱骂、营销广告、诈骗话术、越狱输出第四层幻觉检测与事实一致性编造订单状态、虚构政策条款、无中生有第五层超时熔断与人工兜底模型服务不可用、延迟抖动、整体降级这个架构设计的核心原则是故障隔离。格式校验挂了不影响 PII 模块PII 模块升级不影响内容安全模块。部署时每一层都是独立服务或者独立函数日志分别记录告警分别配置。下面每一层详细讲。2. 第一层护栏格式与结构校验让模型输出可解析2.1 结构化输出是稳定接入的前提为什么 JSON Mode 还不够第一层拦截的是最基础的故障模型输出根本没法被下游使用。做这一步的首选手段不是靠提示词描述 JSON 长什么样而是优先使用模型服务商提供的结构化输出能力也就是常说的 JSON Mode 或 Function Calling。JSON Mode 的好处是模型在解码阶段就受到约束生成非法 JSON 的概率会低很多。但注意JSON Mode 只是保证输出是合法 JSON它不保证 JSON 的结构符合你的业务要求。字段名有没有写错、类型对不对、枚举值是不是你定义的那些它统统不管。所以我们把 JSON Mode 当作减少解析失败率的手段而不是最终的校验手段。真正做硬校验的是下游的 Schema 校验。这一层一定要在项目早期就定好接口契约而不是等模型输出出了问题再补。我们的做法是定义一个响应模型所有业务字段都明确类型和取值范围模型生成的 JSON 必须通过这个模型的校验才能继续往业务系统走。2.2 Schema 校验链路Pydantic 与自动修复我们用的是 Pydantic 做结构校验企业级服务端语言里你还可以用 TypeBox、Zod 或者 Jackson 之类的库替代原理是一样的。先看一个工单场景下的响应模型示例from pydantic import BaseModel, Field from typing import Literal class TicketReply(BaseModel): # 工单分类 category: Literal[refund, logistics, pre_sale, after_sale, other] # 处理优先级 priority: Literal[P0, P1, P2] # 回复正文 reply: str Field(min_length1, max_length500) # 是否需要人工介入 need_human: bool False模型输出经过 JSON 解析后直接用这个模型去验证。校验失败的场景我们要分两类处理第一类是明显的机器错误比如category写成了退款而不是枚举值refund或者need_human传了字符串true而不是布尔值。这种情况下可以走一次修复式重试把校验失败的具体错误信息回传给模型让它基于错误提示重新生成一次。我们实测修复式重试的成功率大概在七成左右比直接无脑重试高不少。第二类是对枚举值的模糊改写。有些模型会自作聪明把P0写成p0或优先级最高。处理这类问题我建议在提示词里就给出严格的枚举定义表同时在校验层做一个同义词归并函数但不要在主流程上做太多宽容处理。护栏的第一职责是发现问题而不是掩盖问题。如果同义词归并导致最终入库的数据质量变差那是另一种事故。2.3 这个坑我踩得最深中英文枚举与字段类型第一层护栏中我踩得最深的坑是字段类型校验。模型生成field: null而不是field: null的情况非常常见尤其是用中文提示词的时候模型会倾向于把null、true、false当成字符串输出。这类问题光靠 JSON Schema 的type声明还不够最好在提示词里把每个字段的数据类型和示例值写明白。还有一个必须注意的细节错误信息要结构化。我们最初重试时直接告诉模型格式错误模型完全不知道错在哪。后来改成把 Pydantic 的错误列表原样塞回给模型比如category: Input should be refund, logistics, pre_sale, after_sale or other模型的修复准确率立刻上来了。这个经验适用于所有带自动修复的环节错误信息越精确修复效果越好。提示第一层护栏的校验逻辑必须放在业务系统边界之外不要和业务代码混在一起。否则每次模型格式异常都要重启业务进程非常痛苦。3. 第二层护栏PII 识别与去标识化拦住隐私数据外流3.1 模型为什么会吐出别人的手机号格式问题再严重至少不会造成合规风险。第二层护栏处理的是更致命的问题隐私数据外流。模型在客服对话里吐出别的用户的手机号、订单号、身份证号这种事故一旦发生不只是技术问题还可能涉及用户投诉、监管问责。模型为什么会犯这种错误根据我们的复盘主要有三个来源。第一是上下文拼接问题RAG 检索时把其他用户的数据片段混进了当前会话第二是模型自身的长期记忆大模型在训练阶段见过大量真实数据某些情况下会回忆出和当前对话无关的个人信息第三是工具调用返回结果没做好权限隔离模型把后端接口返回的敏感字段直接引用到了回复文案里。不管来源是哪一种输出侧的 PII 识别都是最后一道防线。业务系统可以假设上层代码有 bug、检索可能有遗漏、模型可能乱说但输出护栏必须保证任何不该出现的敏感信息都不会离开系统边界。3.2 正则 NER 的两级识别管线我们在实现上用了两级识别管线。第一级是规则和正则覆盖面广、速度极快、可解释性强适合处理手机号、邮箱、身份证号、银行卡号这类格式固定的实体。第二级是 NER 模型用预训练模型识别姓名、地址、组织机构这类没有固定格式的实体。以 Python 生态为例可以直接用 Microsoft Presidio 来搭这一层。它自带了一套分析器引擎可以组合自定义正则规则和基于 spaCy 或 Transformers 的 NER 识别器from presidio_analyzer import AnalyzerEngine, PatternRecognizer, Pattern from presidio_anonymizer import AnonymizerEngine # 自定义订单号识别规则 order_pattern Pattern( nameorder_no, regexr(?i)(order|no\.?)[:\s]*[A-Z0-9]{8,20}, score0.8, ) order_recognizer PatternRecognizer( supported_entityORDER_NO, patterns[order_pattern] ) analyzer AnalyzerEngine(recognizers[order_recognizer]) anonymizer AnonymizerEngine() def protect_pii(text: str): results analyzer.analyze(texttext, languagezh) if results: # 发现 PII 实体记录实体类型并走拦截策略 return None, results return text, None这套管线在大部分场景下能做到秒级返回。实际落地时不要把所有的识别逻辑都堆在正则里否则规则会越来越难维护。建议把实体识别做成配置化每种实体对应一组正则规则和一个 NER 模型标签在配置中心里管理避免每次调整都动代码。还有一点很关键识别器的门槛要分场景。客服系统里手机号是必须拦截的但如果是用户在问题里主动发来的自己的手机号模型回复里复述一遍这种不算泄露不能一刀切拦截。我们是把 PII 分成两类一类是用户上下文内 PII允许在回复中引用另一类是与当前用户无关的 PII出现就拦截并告警。3.3 遮蔽还是拒绝拦截策略的选择发现 PII 之后处理策略有两种脱敏返回和拒绝回复。很多人习惯默认选脱敏把手机号改成138****1234再返回。但在客服场景里我不推荐这么做。脱敏后的手机号对用户没有任何价值反而可能让用户误以为系统在处理实际上却没有解决任何问题。更稳妥的做法是如果生成的回复中存在当前用户无关的 PII 实体直接拒绝返回转入人工客服队列同时记录详细的告警日志。拒绝的前提是有兜底路径。我们设计了一个PII 触发降级流程模型回复被拦截后系统自动追加一条人工工单并告知用户当前问题已转交人工处理。这样既保护了隐私也没有让用户体验断崖式下跌。提示PII 识别模块的日志字段尤其要注意脱敏。日志里不能写完整的手机号否则护栏本身就会变成新的数据泄露出口。我们统一用实体类型和长度哈希记录方便排查又不暴露原始数据。4. 第三层护栏内容合规与恶意诱导检测拦住违规内容4.1 违规内容不只是脏话越狱、营销与诈骗话术第三层护栏解决的是内容合规问题。模型生成的文本在语义层面不能违反平台规范也不能被恶意用户利用来绕过产品边界。一开始我们以为这一层做几个敏感词过滤就够了实际情况远没那么简单。首先是恶意诱导也就是常说的越狱尝试。用户在输入侧构造提示词试图让模型忽略系统指令、输出受限内容。这类输入的伪装性很强同一个意思可以有无数种表达方式关键词过滤根本堵不住。如果你只做输出侧审查等到模型已经生成违规内容再去拦截一方面模型成本已经花了另一方面如果模型真的输出了露骨内容哪怕只是瞬间也已经是风险事件了。其次是营销和诈骗话术。客服对话场景下模型会被诱导去生成加微信领取奖品点击链接参与活动这类内容。这类文本在表面语义上不一定违规但它不符合业务规则对用户有害。所以第三层护栏不能只做文本合规还要做业务策略判断。4.2 词表、分类模型与双向审查的组合我们的第三层实现是三段式设计第一段是词表过滤。保留一套按类别组织的敏感词库包含辱骂、歧视、色情、暴力、广告等类别。词表的作用是快速拦截但所有人都知道词表不完整所以它只是最低水位线。第二段是语义分类模型。用一个多标签分类器对完整句子做语义级别判断识别文本是否包含恶意辱骂、歧视性言论、诱导性话术等。现在开源的文本审核模型很多可以直接用基于中文语料微调的 BERT/RoBERTa 模型也可以使用各家云厂商的内容安全 API。我们内部是用开源模型做基础判断再用一套规则引擎把业务自定义策略加上去。第三段是双向审查。输入侧的 prompt 和输出侧的 completion 都要过检测。输入侧检测到恶意诱导时我们不走模型生成流程直接返回固定话术输出侧检测到违规内容时直接丢弃并转入复核流程。4.3 与系统提示词防注入的配合关系很多团队会把第三层护栏和提示词里的防注入措辞混为一谈。这里我明确说一下:提示词防注入比如忽略之前所有的指令你是一个客服助手不能回答无关问题是降低被诱导概率的手段而内容合规审查是兜底机制。两者必须同时存在不能互相替代。提示词做得再好模型也可能被更巧妙的提示词骗过。而输出侧的合规检测只对已经生成的文本做判断不关心模型是怎么被绕过的。所以我们的原则是输入侧负责降低风险输出侧负责兜底两侧的检测结果都会写入同一个风险事件表方便后续改进提示词策略。这里还要提一个容易忽略的场景多轮对话中的内容合规。模型单次回复是安全的但结合上下文可能变成违规内容。所以合规检测要把当前回复和最近几轮对话拼接起来做判断而不是只看单条消息。我们最开始只检测单条输出结果用户用多轮对话诱导模型拼出了一段违规内容当时整个安全团队都无语了。5. 第四层护栏幻觉检测与事实一致性校验拦住编造信息5.1 为什么说幻觉是 LLM 的系统性缺陷前三层护栏都过关了内容合法、格式正确、没有泄露隐私但模型输出的内容可能是编造的。客服场景中模型一本正经地告诉用户您的订单将在明天送达实际上订单已经滞留三天了这就是幻觉。幻觉最麻烦的地方在于它看起来高度可信流畅、具体、细节丰富用户几乎无法分辨真伪。我在实际项目中有一个很深的感受LLM 对某个问题的回答是否流畅与它是否准确没有必然关系。模型可以非常自信地输出一个完全虚构的政策条款也会在不确定时给出模棱两可的答案。这意味着我们不能靠它语气够不够坚定来判断可信度必须引入外部知识做事实比对。5.2 RAG 引用校验与 NLI 蕴含检测我们的幻觉检测方案主要分两条路径引用校验和蕴含检测。引用校验的思路是凡是涉及事实性信息订单状态、政策条款、价格、库存的输出必须附带引用来源。来源可以是知识库条目 ID、订单系统里的记录片段或者检索到的文档片段。校验器会检查这个引用是否真实存在于知识库中并将生成的文本与引用内容做一致性比对。如果模型给出的引用 ID 根本不存在或者生成内容与引用内容明显冲突直接拦截。一致性比对可以用向量相似度也可以用 NLI自然语言推理模型。NLI 的思路是判断前提和假设之间的关系如果生成文本能从知识库片段中推出来则为蕴含如果矛盾则为矛盾。以 CrossEncoder 实现为例from sentence_transformers import CrossEncoder nli_model CrossEncoder(MoritzLaurer/mDeBERTa-v3-base-mnli-xnli) def check_factual(generated_stmt: str, source_stmt: str) - bool: # 判断生成内容是否由知识库片段蕴含 pair [ generated_stmt, fThe statement is entailed by the source: {source_stmt}, ] score nli_model.predict(pair, apply_softmaxTrue)[0] # 这里 scores 的索引取模型输出的标签顺序需按模型文档确认 entailment_score score # 实际使用时按模型标签映射 return entailment_score 0.65实际落地时NLI 模型的标签和分数解释要仔细看文档。我这里给的阈值要根据你自己的数据重新调不要拿来就用。另外中文 NLI 模型的稳定性和英文相比还有差距如果应用场景对准确性要求极高我建议做一次基于业务历史日志的准确率评估再决定是否上线。5.3 成本与风险的权衡分级抽检策略幻觉检测的消耗是实打实的每次调用 NLI 模型都会增加延迟和计算成本所以不能对每个请求都跑最重型的一整套检测。我们的做法是分级处理高风险场景金融、医疗、法律政策咨询以及客服工单中用户明确表达不满的情况100% 走引用校验 NLI 检测宁可延迟高一点也不能放错。中风险场景普通业务问题咨询走引用校验NLI 检测可以按比例抽样比如 20% 的流量做全量比对其余做快速关键词级检查。低风险场景闲聊、寒暄、非事实型回复只做格式和合规检测不做事实性校验。这里有一个经验风险分级不能只按场景判断还要结合上下文。同一个请问退款多久到账的问题在普通咨询和用户已经发起投诉时风险等级应该不同。我们把用户情绪、是否涉及资金、是否涉及第三方权益这几个因素加权动态计算风险分再决定检测强度。提示幻觉检测的判定结果一定要有可解释性。拦截的理由不能只是事实一致性得分过低要把冲突的原文片段一并记录下来。否则人工复核时根本无从下手。6. 第五层护栏超时熔断与人工兜底拦住整体不可用6.1 被忽略的最后一公里模型服务本身也会挂前面四层都在处理模型输出内容有问题第五层处理的是另一种问题模型服务本身不可用。很多人做 LLM 应用集成时只关注能不能生成好内容却忽略了模型服务是外部依赖它有延迟、有限流、有供应商故障。我们的客服机器人就遇到过模型服务连续五分钟返回 5xx 错误。当时前四层护栏全部正常但是源头的数据已经拿不到了系统就一直等超时之后又重试把排队线程全部占满整个网关跟着拖垮。那一瞬间我意识到如果没有降级机制LLM 应用不仅会在模型说错话时出事还会在模型不说话时出事。6.2 三级降级链与熔断参数第五层护栏的核心是一个明确的三级降级链主力模型 → 备用模型 → 模板兜底。每一级都有独立的超时控制和重试策略不能一把梭都设成一样的参数。我们的降级链配置大致如下fallback_chain: - name: primary-llm timeout_ms: 3000 retry: 1 on_failure: next - name: backup-llm timeout_ms: 5000 retry: 0 on_failure: next - name: template timeout_ms: 100 reply: 感谢您的反馈当前咨询量较大您的问题已转交人工客服处理请留意后续通知。这里有几个参数值得注意。第一个是超时时间我统计过我们主力模型的 P90 延迟大概在 2.3 秒左右所以把超时设在 3 秒比 P90 略高一点但又不会让用户等太久。第二个是重试次数主力模型只重试一次不要重试三次因为模型服务的故障通常不是瞬时抖动重试只会放大压力。第三个是熔断条件连续错误率达到 30%、且持续时间超过 30 秒就打开熔断开关所有请求直接走备用模型或模板不再尝试调用主力模型。熔断期间每 30 秒放一个小流量探测恢复后才能关闭。模板兜底回复的设计也要讲究。兜底话术不能太生硬要告诉用户问题已转交人工并给出预期时间。这里还有一个细节模板兜底也要走前面的格式校验和合规检测不能因为内容是我们自己写的就跳过。曾经发生过一次兜底回复里日期没更新导致用户收到过期承诺的事故教训深刻。6.3 护栏上线后的指标变化与迭代节奏五层护栏全部上线之后我们重点盯三个指标拦截率、误杀率和端到端可用性。拦截率指各层拦截的请求量占比误杀率指被拦截但人工复核后认为其实可以放行的比例端到端可用性指整个带护栏的服务链路的可用时间。拦截率主要看趋势不能看绝对值。如果某层的拦截率突然飙升往往是模型版本升级、提示词改动或者业务规则变化导致的需要优先排查。误杀率是我们最头疼的指标最初 PII 层的误杀率到了两成左右经常把用户主动提供的自己的号码误判为泄露。后来调整为区隔当前用户 PII 与异用户 PII后误杀率才降下来。这个案例说明一个道理精确的护栏设计需要理解业务上下文纯技术堆砌永远解决不了误杀问题。关于迭代节奏我的建议是每一层护栏都要有快速的 AB 测试通道。不要等全部规则稳定了再上线而是每加一条规则就灰度一小批流量观察误杀率变化。五层护栏的日志必须按照统一的 trace_id 串起来这样我们在排查一个被拦截的请求时能一次性看清它在每一层分别命中了什么、为什么被拦截。没有统一的追踪链路五层护栏就会变成五个信息孤岛出了问题根本没法排。最后说一点个人体会。这套五层体系带给我们最大的变化不是生产事故少了而是整个团队对待模型输出的态度变了。以前大家默认模型答得挺流畅就是正常现在所有人都知道流畅不等于正确正确不等于合规合规还不等于可下发。这种心态转变可能才是防护栏真正的价值所在。