LLM 输出 Guardrail 工程实践:5 层防护栏让 AI 生成的内容不会炸掉生产
你的 LLM 应用上线了,模型 99% 的时候输出正常------但那 1% 的异常输出,足以让一个客户投诉毁掉整个产品的信任。Guardrail 不是可选的"安全网",它是生产级 LLM 应用的标配基础设施。
你的客服 AI 昨天回了什么?
"作为一个人工智能,我无法提供医疗建议------但我建议你试试 竞品名称,他们更专业。"
这不是段子。某医疗问诊平台的 LLM 在一次模型版本升级后,开始"好心"地在免责声明后面推荐竞品。没人设计过这个行为,模型自己"推理"出来的。等运营团队发现,这条回复已经发出了 2000 多次。
还有更常见的:JSON 少了个括号、回复超了 4000 token 把数据库字段撑爆、本该只回答保险问题的 AI 突然开始聊政治、模型把内部系统 prompt 里的 API Key 吐了出来......
这些问题统称 输出失控 。解决它的工程手段叫 Guardrail------输出防护栏。
什么是 LLM 输出 Guardrail
LLM 输出 Guardrail 是一套串联的校验与修正管线,在模型输出到达用户之前,逐层拦截不安全、不合规、不合法、不合格的内容。它不是单一的正则过滤,而是从格式、长度、语义、安全、合规五个维度对输出做结构化约束,任何一层不通过就走修正或降级路径。
我踩过的 3 个坑
坑 1:以为 JSON Schema 校验就够了
最初我们只做了 JSON Schema 校验。模型输出 {"answer": "xxx"},校验 answer 字段存在且为 string------看起来没毛病。
直到有一天模型输出了:
json
{"answer": "<script>alert('xss')</script>"}
Schema 校验通过了------它确实是 string。前端直接 innerHTML 渲染,XSS 执行了。
教训:格式正确 ≠ 内容安全。Guardrail 必须同时做格式和语义两层。
坑 2:只做输入过滤,不做输出过滤
"我们在输入端加了敏感词过滤,应该够了吧。"
不够。模型不是查表机,它是推理引擎。你输入"请解释高血压的成因",输入端没有敏感词。但模型可能输出:"高血压的主要成因包括......如果你正在服用 某处方药,需要注意......"------它自己"推理"出了处方药建议,这在合规上是不允许的。
教训:输入过滤管不住模型的推理发散,输出 Guardrail 才是最后一道门。
坑 3:Guardrail 失败了直接抛异常
第一版 Guardrail 做得比较暴力------任何一层不通过,直接 throw Error,给用户返回 500。
用户看到的是什么?"系统异常,请稍后重试。"
然后他们真的重试了。还是一样的 500。因为他们问的问题本身触发了 Guardrail(比如问了医疗建议),重试一百次也过不了。
教训:Guardrail 不通过 ≠ 系统故障。应该走降级路径(拒绝回答+引导),不是抛异常。
5 层 Guardrail 架构
从内到外,5 层防护栏各有分工:
| 层级 | 名称 | 拦截什么 | 典型实现 | 延迟 |
|---|---|---|---|---|
| L1 | 格式校验 | JSON 结构错误、字段缺失、类型不对 | JSON Schema / Pydantic | <1ms |
| L2 | 长度与截断 | 输出超长、token 超限、截断丢失 | Token 计数 + 截断+后缀补全 | <1ms |
| L3 | 内容安全 | XSS、注入攻击、隐私泄露 | 正则 + 模式匹配 + PII 检测 | 1-5ms |
| L4 | 语义约束 | 话题越界、幻觉事实、指令违背 | 分类模型 / Embedding 相似度 | 50-200ms |
| L5 | 合规与审计 | 行业法规、品牌规范、内容策略 | 规则引擎 + 合规评分 | 10-50ms |
五层串联,任何一层不通过就触发对应的修正策略。不是"拦截了就返回错误",而是"拦截了就修正,修正不了就降级,降级不了才拒绝"。
L1:格式校验------最便宜也最容易被低估
格式校验是 Guardrail 的第一层,成本几乎为零,但能拦截 40-60% 的异常输出。
JSON Schema 校验的实现
python
import json
from jsonschema import validate, ValidationError
# 定义输出格式约束
RESPONSE_SCHEMA = {
"type": "object",
"required": ["answer", "confidence", "sources"],
"properties": {
"answer": {
"type": "string",
"minLength": 1,
"maxLength": 2000 # 答案最长 2000 字
},
"confidence": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"sources": {
"type": "array",
"items": {"type": "string"},
"maxItems": 5 # 最多 5 个来源
}
},
"additionalProperties": False # 不允许额外字段
}
def validate_format(raw_output: str) -> dict:
"""L1 格式校验:解析 JSON + Schema 校验"""
try:
parsed = json.loads(raw_output)
except json.JSONDecodeError as e:
# JSON 解析失败------尝试修复常见问题
fixed = attempt_json_repair(raw_output, e)
if fixed:
parsed = fixed
else:
return {"valid": False, "error": "json_parse_failed", "layer": "L1"}
try:
validate(instance=parsed, schema=RESPONSE_SCHEMA)
return {"valid": True, "data": parsed, "layer": "L1"}
except ValidationError as e:
return {"valid": False, "error": f"schema_violation:{e.message}", "layer": "L1"}
JSON 自动修复
模型输出 JSON 时最常见的 3 个问题:尾逗号、未闭合括号、字符串内引号转义缺失。
python
import re
def attempt_json_repair(raw: str, error: json.JSONDecodeError) -> dict | None:
"""尝试修复常见的 JSON 格式错误"""
# 策略 1:移除尾逗号 }, → }
repaired = re.sub(r',\s*([}\]])', r'\1', raw)
# 策略 2:补全未闭合的括号
open_brackets = repaired.count('{') + repaired.count('[')
close_brackets = repaired.count('}') + repaired.count(']')
if open_brackets > close_brackets:
diff = open_brackets - close_brackets
# 按开括号顺序补闭括号
for char in reversed(repaired):
if char == '{':
repaired += '}'
elif char == '[':
repaired += ']'
diff -= 1
if diff <= 0:
break
try:
return json.loads(repaired)
except json.JSONDecodeError:
return None
格式校验的通过率在生产中大约 85-92%,剩下的 8-15% 主要靠 JSON 自动修复挽回,真正完全无法解析的不到 2%。
L2:长度与截断------超长输出的优雅处理
模型输出的最大不确定性就在长度。你设了 max_tokens=1024,但有些场景下 1024 根本不够说完,模型会在句子中间硬切;另一些场景下模型"废话连篇"输出 900 token 还没到重点。
三种截断策略
| 策略 | 做法 | 适用场景 | 缺点 |
|---|---|---|---|
| 硬截断 | output[:max_len] |
实时性要求极高 | 可能截断在句子中间 |
| 句子级截断 | 找到最后一个句号/换行 | 大多数场景 | 可能丢失最后一句话的信息 |
| 摘要截断 | 截断后用小模型生成摘要 | 需要保留信息完整性 | 多一次模型调用,延迟+成本 |
python
def truncate_output(text: str, max_tokens: int, strategy: str = "sentence") -> str:
"""L2 长度控制与截断"""
token_count = count_tokens(text)
if token_count <= max_tokens:
return text # 不超限,直接返回
# 估算截断的字符位置(粗估:1 token ≈ 1.5 中文字符)
max_chars = int(max_tokens * 1.5)
if strategy == "hard":
return text[:max_chars]
elif strategy == "sentence":
# 找到最后一个完整句子
truncated = text[:max_chars]
last_period = max(
truncated.rfind("。"),
truncated.rfind("!"),
truncated.rfind("?"),
truncated.rfind("\n")
)
if last_period > max_chars * 0.8: # 至少保留 80%
return truncated[:last_period + 1]
return truncated # 找不到合适断点,硬截断
elif strategy == "suffix":
# 截断后补后缀提示
base = truncate_output(text, max_tokens - 20, "sentence")
return base + "\n\n[内容过长已截断,如需完整回答请缩小问题范围]"
return text
关键原则:截断后必须补后缀。 用户看到"......已截断"比看到一句莫名其妙断在中间的话体验好 10 倍。
L3:内容安全------XSS、注入与隐私泄露
这一层解决的是"格式正确但内容有毒"的问题。
3 类常见内容风险
python
import re
from typing import List
class ContentSafetyGuard:
"""L3 内容安全防护栏"""
# XSS 模式
XSS_PATTERNS = [
r'<script[^>]*>.*?</script>',
r'javascript\s*:',
r'on\w+\s*=', # onclick=, onerror= 等
r'<iframe[^>]*>',
r'<img[^>]+onerror\s*=',
]
# PII 模式(个人身份信息)
PII_PATTERNS = {
"phone": r'1[3-9]\d{9}',
"id_card": r'\d{17}[\dXx]',
"email": r'[\w.+-]+@[\w-]+\.[\w.]+',
"api_key": r'(sk-|pk-|key_)[a-zA-Z0-9]{20,}',
}
# 注入攻击模式
INJECTION_PATTERNS = [
r'ignore\s+previous\s+instructions',
r'system\s*:\s*',
r'<\|im_start\|>',
r'```system\n',
]
def check(self, text: str) -> dict:
findings = []
# 检查 XSS
for pattern in self.XSS_PATTERNS:
if re.search(pattern, text, re.IGNORECASE | re.DOTALL):
findings.append({"type": "xss", "pattern": pattern})
# 检查 PII 泄露
for pii_type, pattern in self.PII_PATTERNS.items():
matches = re.findall(pattern, text)
if matches:
findings.append({
"type": "pii_leak",
"pii_type": pii_type,
"count": len(matches)
})
# 检查注入
for pattern in self.INJECTION_PATTERNS:
if re.search(pattern, text, re.IGNORECASE):
findings.append({"type": "injection", "pattern": pattern})
return {
"safe": len(findings) == 0,
"findings": findings,
"layer": "L3"
}
def sanitize(self, text: str) -> str:
"""修正:移除/替换危险内容"""
# 移除 XSS 标签
for pattern in self.XSS_PATTERNS:
text = re.sub(pattern, '[内容已过滤]', text, flags=re.IGNORECASE | re.DOTALL)
# 脱敏 PII
for pii_type, pattern in self.PII_PATTERNS.items():
text = re.sub(pattern, f'[{pii_type}已脱敏]', text)
return text
有个容易被忽略的点:PII 检测要做两次------输入和输出。 模型可能在输出中复述用户输入里的手机号、身份证号,也可能自己"推理"出用户的敏感信息。两种情况都要拦。
L4:语义约束------话题越界与指令违背检测
前三层都是基于规则和模式匹配,L4 开始涉及语义理解------这一层的核心问题是:模型输出的内容,是不是在它被允许回答的范围内?
场景举例
- 保险咨询 AI 突然开始推荐理财产品(话题越界)
- 客服 AI 回答了只有管理员才能看到的内部政策(权限越界)
- 翻译 AI 在翻译技术文档时夹带了政治评论(指令违背)
基于 Embedding 相似度的话题越界检测
python
import numpy as np
class SemanticGuard:
"""L4 语义约束防护栏"""
def __init__(self, allowed_topics: list[str], threshold: float = 0.72):
self.allowed_embeddings = [embed(t) for t in allowed_topics]
self.threshold = threshold
def check_topic_boundary(self, response: str) -> dict:
"""检测输出是否越界"""
resp_embedding = embed(response)
# 计算与每个允许话题的相似度
similarities = [
cosine_sim(resp_embedding, topic_emb)
for topic_emb in self.allowed_embeddings
]
max_similarity = max(similarities)
is_on_topic = max_similarity >= self.threshold
return {
"on_topic": is_on_topic,
"max_similarity": max_similarity,
"closest_topic_idx": np.argmax(similarities),
"layer": "L4"
}
def fallback_response(self) -> str:
"""话题越界时的降级回复"""
return (
"抱歉,这个问题超出了我的服务范围。"
"我可以为您解答保险相关的问题,"
"如需其他服务请联系对应部门。"
)
指令违背检测------用小模型做快速判定
完整语义检测延迟 50-200ms,不是所有请求都需要。一个实用的策略是分级检测:
| 风险等级 | 检测策略 | 延迟 | 成本 |
|---|---|---|---|
| 低风险(日常问答) | 只做 L1-L3 | <5ms | ≈0 |
| 中风险(用户可见回复) | L1-L3 + L4 Embedding | 50-100ms | 低 |
| 高风险(金融/医疗/法律) | L1-L4 + L5 全链路 | 100-300ms | 中 |
怎么判断风险等级?看用户身份和请求的 prompt 类别。付费用户、高敏感领域的请求,走完整链路;普通闲聊走 L1-L3 就够了。
L5:合规与审计------行业法规和品牌规范
最后一层是业务逻辑层面的约束。每个行业都不一样,但有三类是通用的:
1. 禁止性内容清单
python
COMPLIANCE_RULES = {
"finance": {
"forbidden_claims": [
"保证收益", "稳赚不赔", "零风险",
"年化收益.*%", # 不得承诺具体收益率
],
"required_disclaimers": [
"投资有风险,过往业绩不代表未来表现"
]
},
"medical": {
"forbidden_claims": [
"可以替代.*医", "治愈", "根治",
"处方", "用药建议",
],
"required_disclaimers": [
"本内容仅供参考,不构成医疗建议"
]
}
}
def check_compliance(text: str, domain: str) -> dict:
"""L5 合规检查"""
rules = COMPLIANCE_RULES.get(domain, {})
violations = []
for pattern in rules.get("forbidden_claims", []):
if re.search(pattern, text):
violations.append({"type": "forbidden_claim", "pattern": pattern})
missing_disclaimers = []
for disclaimer in rules.get("required_disclaimers", []):
if disclaimer not in text:
missing_disclaimers.append(disclaimer)
return {
"compliant": len(violations) == 0 and len(missing_disclaimers) == 0,
"violations": violations,
"missing_disclaimers": missing_disclaimers,
"layer": "L5"
}
def enforce_compliance(text: str, domain: str) -> str:
"""合规修正:移除违规内容 + 补免责声明"""
result = check_compliance(text, domain)
# 移除违规内容
for v in result["violations"]:
text = re.sub(v["pattern"], "[该内容不符合合规要求,已移除]", text)
# 补充缺失的免责声明
if result["missing_disclaimers"]:
text += "\n\n" + "\n".join(result["missing_disclaimers"])
return text
2. 品牌语调约束
"我们品牌的客服不说'亲',不说'家人们',不说'宝宝'。"
这不是矫情。每个品牌有自己的人设,AI 输出偏离人设,轻则违和,重则公关危机。
python
BRAND_VOICE_RULES = {
"forbidden_expressions": ["亲", "家人们", "宝宝", "宝子", "集美"],
"tone": "professional_friendly", # 专业但不冷漠
"max_exclamation_marks": 1, # 最多 1 个感叹号
}
def check_brand_voice(text: str) -> dict:
violations = []
for expr in BRAND_VOICE_RULES["forbidden_expressions"]:
if expr in text:
violations.append(f"使用了非品牌用语:{expr}")
if text.count("!") + text.count("!") > BRAND_VOICE_RULES["max_exclamation_marks"]:
violations.append("感叹号过多,语调过于激动")
return {"on_brand": len(violations) == 0, "violations": violations}
3. 审计日志
Guardrail 的每一次拦截和修正都要留痕。不是为了追责,是为了持续改进------哪些规则触发最多?哪些是误杀?哪些是漏放?
python
import time
import json
def log_guardrail_event(
request_id: str,
layer: str,
event_type: str, # "pass" | "block" | "repair"
detail: dict,
latency_ms: float
):
"""审计日志:每次 Guardrail 事件都记录"""
log_entry = {
"timestamp": time.time(),
"request_id": request_id,
"layer": layer,
"event_type": event_type,
"detail": detail,
"latency_ms": latency_ms
}
# 写入异步日志队列(不阻塞主链路)
audit_queue.put(json.dumps(log_entry, ensure_ascii=False))
串联 5 层的完整管线
python
class GuardrailPipeline:
"""5 层 Guardrail 串联管线"""
def __init__(self, domain: str = "general", risk_level: str = "medium"):
self.domain = domain
self.risk_level = risk_level
self.l3 = ContentSafetyGuard()
self.l4 = SemanticGuard(
allowed_topics=DOMAIN_TOPICS.get(domain, []),
threshold=0.72
)
def run(self, raw_output: str, request_id: str) -> dict:
"""串联执行 5 层 Guardrail"""
result = {"original": raw_output, "output": raw_output, "layers": {}}
output = raw_output
# L1: 格式校验
t0 = time.time()
fmt = validate_format(output)
result["layers"]["L1"] = fmt
if not fmt.get("valid"):
if fmt.get("data"): # JSON 修复成功
output = json.dumps(fmt["data"], ensure_ascii=False)
result["layers"]["L1"]["action"] = "repaired"
else:
result["blocked"] = True
result["block_reason"] = "L1_format_unrecoverable"
result["output"] = self.fallback_response()
return result
else:
output = json.dumps(fmt["data"], ensure_ascii=False)
log_guardrail_event(request_id, "L1", "pass" if fmt["valid"] else "repair", fmt, (time.time()-t0)*1000)
# L2: 长度控制
t0 = time.time()
before_truncate = output
output = truncate_output(output, max_tokens=1024, strategy="suffix")
result["layers"]["L2"] = {"truncated": output != before_truncate}
log_guardrail_event(request_id, "L2", "pass" if output == before_truncate else "repair", {}, (time.time()-t0)*1000)
# L3: 内容安全
t0 = time.time()
safety = self.l3.check(output)
result["layers"]["L3"] = safety
if not safety["safe"]:
output = self.l3.sanitize(output)
result["layers"]["L3"]["action"] = "sanitized"
log_guardrail_event(request_id, "L3", "pass" if safety["safe"] else "repair", safety, (time.time()-t0)*1000)
# L4: 语义约束(中高风险才执行)
if self.risk_level in ("medium", "high"):
t0 = time.time()
semantic = self.l4.check_topic_boundary(output)
result["layers"]["L4"] = semantic
if not semantic["on_topic"]:
output = self.l4.fallback_response()
result["layers"]["L4"]["action"] = "fallback"
log_guardrail_event(request_id, "L4", "pass" if semantic["on_topic"] else "block", semantic, (time.time()-t0)*1000)
# L5: 合规检查(高风险才执行)
if self.risk_level == "high":
t0 = time.time()
compliance = check_compliance(output, self.domain)
result["layers"]["L5"] = compliance
if not compliance["compliant"]:
output = enforce_compliance(output, self.domain)
result["layers"]["L5"]["action"] = "enforced"
log_guardrail_event(request_id, "L5", "pass" if compliance["compliant"] else "repair", compliance, (time.time()-t0)*1000)
result["output"] = output
result["blocked"] = False
return result
def fallback_response(self) -> str:
return "抱歉,当前无法生成有效回复,请尝试换个方式提问。"
生产数据:5 层各拦截了多少
这是我们上线 3 个月后统计的数据(日均 5000 次 LLM 调用,保险客服场景):
| 层级 | 触发次数/天 | 占比 | 最常见触发原因 |
|---|---|---|---|
| L1 格式 | 320 | 6.4% | JSON 尾逗号、缺闭合括号 |
| L2 长度 | 85 | 1.7% | max_tokens 用完截断 |
| L3 安全 | 12 | 0.24% | PII 泄露(用户手机号复述) |
| L4 语义 | 28 | 0.56% | 话题越界(从保险聊到理财) |
| L5 合规 | 5 | 0.1% | 违规承诺收益 |
几个反直觉的发现:
1. L1 格式校验的拦截量远超其他层。 很多人以为"模型都支持 JSON mode 了,格式不会出问题"------错。JSON mode 保证的是最外层结构,嵌套对象里的字段缺失、类型错误、额外字段,JSON mode 管不了。
2. L3 的主要威胁不是 XSS,是 PII 复述。 模型特别爱把用户输入里的手机号、身份证号原样复述一遍,"您输入的手机号 138xxxx1234 对吗?"------这比 XSS 常见 10 倍。
3. L4 的误杀率需要持续调优。 Embedding 相似度阈值设 0.72,刚开始误杀率 8%(把正常的保险续保问题误判为理财越界)。降到 0.65 后误杀率 2%,但漏放率从 1% 升到 3%。这是一个工程权衡,不是有标准答案的参数。
Guardrail 和重试的关系
一个常见的错误模式:Guardrail 检测到输出不合格 → 触发重试 → 重试结果还是不合格 → 再重试 → 循环。
Guardrail 检测到问题,第一选择不是重试,是修正。
javascript
L1 格式错误 → JSON 自动修复(成功率 85%)→ 修复失败才重试
L3 内容不安全 → 移除危险内容 → 移除后语义变了才重试
L4 话题越界 → 降级回复 → 不重试(越界是 prompt 的问题,不是重试能解决的)
L5 合规不通过 → 补免责声明+移除违规 → 不重试
重试只在"格式错误且无法自动修复"这一个场景下使用,而且最多重试 1 次。其他场景走修正或降级。
防护栏之间的顺序很重要
5 层的顺序不是随便排的。原则是从快到慢、从局部到全局:
- L1 格式校验(<1ms)先执行,如果 JSON 都解析不了,后面 4 层全没意义
- L2 长度控制(<1ms)在 L3 之前,因为截断可能改变内容安全检测结果
- L3 内容安全(1-5ms)在 L4 之前,因为 XSS/PII 是硬约束,必须先拦
- L4 语义约束(50-200ms)在 L5 之前,因为话题越界检测比合规检查便宜
- L5 合规审计(10-50ms)最后执行,因为它是业务逻辑层,成本最高
如果你把 L4 放 L1 前面,每次请求都得多 100ms------包括那些 JSON 都解析不了的请求。纯粹浪费。
常见问题
Q: Guardrail 会显著增加延迟吗?
A: 低风险请求走 L1-L3,总延迟 <5ms,用户完全无感。中风险加 L4(Embedding),50-100ms,在 LLM 调用本身 1-3 秒的延迟面前可以忽略。高风险全链路最慢 300ms,仍 <LLM 调用延迟的 15%。
Q: Embedding 相似度阈值怎么设?
A: 从 0.70 开始,上线后看两个指标:误杀率(拦了不该拦的)和漏放率(没拦住该拦的)。误杀率 >5% 就降阈值,漏放率 >2% 就升阈值。每调整 0.05 大约能改变误杀/漏放 2-3 个百分点。
Q: Guardrail 和 Moderation API 有什么区别?
A: Moderation API(如各平台的内容审核接口)只做安全审核,相当于 L3 层。Guardrail 是 5 层管线,覆盖格式、长度、安全、语义、合规五个维度。Moderation API 是 Guardrail 的一个子集。
Q: 每次模型升级都要重新调 Guardrail 参数吗?
A: L1-L3 通常不需要,它们基于规则而非模型特性。L4 的阈值可能需要微调,因为新模型的输出分布可能变了。建议每次模型升级后跑一轮 Guardrail 的回放测试:拿过去 1 周的真实输出过一遍新模型,看拦截率和误杀率的变化。