授权与合规声明
本文为技术实践笔记,示例均基于公开文档与自建环境中的实验,不涉及任何未获授权的系统。文中结论仅代表个人实践小结,与所涉厂商无利益关系。转载请注明出处。
1. 为什么模型文本不能直接当作程序输入
1.1 模型输出是"自然语言",不是"数据"
把大模型的回复直接喂给后续代码,是很多刚接入 LLM 的应用会踩的第一个坑。模型在训练目标上优化的是"下一个 token 的概率分布",它并不保证输出是一个能被json.loads解析的合法结构,也不保证字段含义符合你的业务预期。换句话说,模型吐出来的永远是"一段文本",而你的程序需要的是"一份契约"。两者之间的鸿沟,就是护栏要补的地方。
1.2 不验真就用的三类事故
第一类是格式事故:模型在 JSON 外面多聊了两句"好的,这是结果:“,或者把单引号当成双引号,导致解析直接抛JSONDecodeError。第二类是内容事故:字段类型对、结构对,但值越界或自相矛盾,比如"开始时间晚于结束时间”。第三类是幻觉事故:模型编造了一个并不存在的枚举值或对象,下游按约定取值时拿到None。这三类事故的共同点是——它们都发生在"模型已经生成完文本、但还没被消费"的窗口里。
1.3 本文护栏的边界:只管模型文本
需要先划清一条线:本文讨论的护栏,针对的是模型生成的文本(尤其是你打算拿来驱动逻辑的那段输出)。它不负责校验外部工具、API、数据库的返回,那些属于"工具调用链路"的课题。把这两件事混在一起,会让你的校验逻辑既臃肿又难维护。下表先给出三层护栏的总览。
| 护栏层 | 校验对象 | 失败处置 | 是否阻断主流程 |
|---|---|---|---|
| 格式护栏 | 能否解析为约定结构 | 拒绝并重试 | 是 |
| 内容护栏 | 字段值是否合法、合规 | 拒绝或打回修正 | 是 |
| 降级层 | 前两层都不可用时的兜底 | 走规则路径 / 安全默认 | 否(替身) |
2. 第一层:格式护栏
2.1 优先用结构化输出 / JSON Mode
最省心的格式护栏,是别让模型"自由发挥"。主流模型服务商都提供了"结构化输出"(Structured Output)或"JSON Mode"能力:你声明一个 schema,模型被约束只产出符合该 schema 的 JSON,不能随意加字段、改类型。这相当于把格式校验的重心从"事后解析"提前到"事前约束",失败率显著下降。具体是否开启、开启到什么程度,取决于你用的模型与 SDK 版本,建议查阅对应官方文档确认。
2.2 严格解析 + 失败重试
即便开了 JSON Mode,也不能假设永远成功。任何从模型拿到的文本,第一步都应该是"严格解析"。解析失败的,进入有限次数的重试(retry),每次重试都显式要求"只返回 JSON,不要任何解释文字"。下面是一段最小可用的护栏代码,注意它区分了"解析失败"和"最终放弃"两种状态。
⚠️代码待验证
importjsonimportredef_strip_code_fence(text:str)->str:# 去掉常见的 ```json ... ```包裹m=re.search(r"```(?:json)?\s*(.*?)```",text,re.DOTALL)returnm.group(1).strip()ifmelsetext.strip()defparse_model_json(raw:str,max_retry:int=2):last_err=Noneforattemptinrange(max_retry+1):try:cleaned=_strip_code_fence(raw)returnjson.loads(cleaned),Noneexceptjson.JSONDecodeErrorase:last_err=e# 真实场景里这里应再次调用模型并重写 promptraw=""# 占位:触发外层重试逻辑returnNone,f"parse_failed_after_{max_retry}_retries:{last_err}"2.3 给模型"少自由"的提示结构
格式护栏的另一半在 prompt。一个有用的经验是:把"输出格式"写得越具体,解析失败越少。下面是个请求体的示例片段,明确告诉模型输出契约,减少它在 JSON 外啰嗦的概率。
⚠️代码待验证
{"messages":[{"role":"system","content":"你只输出 JSON,字段为 {\"label\": string, \"score\": number}。不要任何解释。"},{"role":"user","content":"判断这条评论的情感"}],"response_format":{"type":"json_object"}}| 格式策略 | 适用场景 | 额外成本 |
|---|---|---|
| 自由文本 + 后处理解析 | 模型不支持结构化输出 | 重试次数多 |
| JSON Mode | 只需合法 JSON | 低 |
| Structured Output | 字段名/类型强约束 | 最低失败率 |
3. 第二层:内容护栏
3.1 用 JSON Schema 做字段级校验
格式对了,不代表内容对。内容护栏的第一件事,是用一份 schema 约束字段的"语义合法性"。Python 里常用pydantic或jsonschema来做这件事:pydantic适合把 JSON 直接映射成带类型的对象,jsonschema则能直接吃一份 JSON Schema 文档做通用校验。下面用一个pydantic模型示范字段级约束。
⚠️代码待验证
frompydanticimportBaseModel,Field,ValidationErrorclassSentiment(BaseModel):label:str=Field(pattern="^(positive|negative|neutral)$")score:float=Field(ge=0.0,le=1.0)defvalidate_content(data:dict):try:returnSentiment(**data),NoneexceptValidationErrorase:returnNone,e.errors()3.2 业务规则与违禁内容检查
schema 只能管"形状",管不了"业务含义"。比如"结束时间必须晚于开始时间"“推荐理由不能为空”“不能出现违禁词”,这些要靠自定义规则。违禁内容检查建议做成可配置的词表 + 正则,便于随合规要求更新,而不是硬编码散落在各处。
⚠️代码待验证
BANNED=["内部接口","root 密码"]# 示例词表,按业务补充defcheck_policy(obj:dict)->list[str]:issues=[]reason=obj.get("reason","")ifnotreason.strip():issues.append("reason 为空")forwinBANNED:ifwinreason:issues.append(f"命中违禁词:{w}")returnissues3.3 校验失败的处置:retag 还是 reject
内容校验失败后要决定"修"还是"扔"。如果失败原因只是字段越界(例如 score 超出 [0,1]),可以尝试让模型基于原输出做最小修正(retag),这通常比整段重写更省 token。如果失败涉及语义自相矛盾或违禁内容,则应直接 reject 并进入下一层降级。处置策略本身也应当可配置,避免把"能修"和"不能修"混在一起。
| 校验维度 | 工具 | 典型失败 |
|---|---|---|
| 类型/范围 | pydantic / jsonschema | score 越界、字段缺失 |
| 枚举约束 | pattern / enum | label 出现未知值 |
| 业务一致性 | 自定义规则 | 时间区间倒挂 |
| 合规 | 词表 + 正则 | 命中违禁词 |
4. 第三层:降级
4.1 规则兜底路径
前两层都拦不住、或模型完全不可用时,不能让主流程崩溃,也不能把脏数据写进下游。降级的第一种形态是"规则兜底":用一套确定性的、不依赖模型的代码去产出尽量合理的结果。例如情感分类模型挂了,就退回基于情感词典的简单打分;摘要模型挂了,就退回取前 N 句。
⚠️代码待验证
defrule_based_fallback(text:str)->dict:# 不依赖模型的最简兜底:词典命中即 positivepos_hits=sum(wintextforwin["好","赞","喜欢"])neg_hits=sum(wintextforwin["差","烂","讨厌"])label="positive"ifpos_hits>neg_hitselse"neutral"return{"label":label,"score":0.5,"fallback":True}4.2 安全默认值
第二种形态是"安全默认":当连规则兜底都给不出有意义结果时,返回一个明确标注fallback=True、且对下游明确可控、可被识别为兜底的结构。关键在于这个默认值必须"可被下游识别为不可信",而不是伪装成模型正常输出,否则会把不确定性悄悄传下去。
4.3 降级的可观测与开关
降级发生后必须打点:哪一层触发了降级、原因是什么、兜底结果是什么。没有可观测的降级等于"静默失败"。同时建议把降级做成一个开关——在模型服务抖动时快速切到规则路径,在恢复后切回,避免人工反复改代码。
| 降级策略 | 返回可信度 | 适用 |
|---|---|---|
| 规则兜底 | 中(受限场景可用) | 模型不可用但规则可覆盖 |
| 安全默认 | 低(明确不可信) | 完全无可靠结果 |
| 人工队列 | 高(待处理) | 强一致业务 |
5. 三层组合成一条 Pipeline
5.1 顺序与短路
三层不是并列的,而是有顺序的:先格式、再内容、最后降级。每层失败就短路到下一层,但降级层失败(连兜底都没有)才真正向上抛错。这样的顺序保证"能解析的先解析,能校验的先校验,实在不行再兜底",避免一上来就走降级浪费模型能力。
5.2 一个可复用的 Runner 骨架
把三层串起来,可以抽象成一个GuardRunner:它接收原始模型文本,依次执行 parse → validate → fallback,并返回统一的结果结构(含ok、data、source三个字段,标记结果来自模型还是兜底)。
⚠️代码待验证
defguard_run(raw:str,max_retry:int=2):data,err=parse_model_json(raw,max_retry)ifdataisNone:return{"ok":False,"source":"fallback",**rule_based_fallback("")}obj,verr=validate_content(data)ifobjisNone:return{"ok":False,"source":"fallback",**rule_based_fallback("")}ifcheck_policy(obj.model_dump()):return{"ok":False,"source":"fallback",**rule_based_fallback("")}return{"ok":True,"source":"model","data":obj.model_dump()}5.3 配置驱动的分层开关
在真实项目里,这三层最好由一份配置描述:哪层开启、重试几次、降级走哪条路径。这样不同业务线能复用同一套 Runner,只改配置不改代码。护栏本身也应支持"灰度关闭",便于在模型升级后重新评估哪层还能省。
配套护栏 Runner 骨架:我把上面这套
GuardRunner抽成了可直接拷进项目的模块,含解析、校验、降级三个可插拔函数。放在资料包里,扫码即可获取:
| Pipeline 阶段 | 输入 | 输出 | 失败去向 |
|---|---|---|---|
| 格式护栏 | 原始文本 | 字典 / 报错 | 重试→降级 |
| 内容护栏 | 字典 | 校验对象 / 报错 | 降级 |
| 降级层 | 任意 | 安全结果 | 向上抛错 |
6. 与"工具返回校验"不是一回事
6.1 校验对象不同
这是本文最想强调的一点:模型输出护栏,校验的是模型自己生成的文本;工具返回校验,校验的是外部系统(API、数据库、命令行)回传的数据。两者来源不同、信任假设不同。把模型的 JSON 和工具的 JSON 用同一套校验糊弄过去,是工程上常见的偷懒,也是 bug 温床。
6.2 失败处置不同
模型输出失败,通常可以"换种说法再问一次"(重试/降级都围绕模型);工具返回失败,则要排查的是网络、权限、接口契约,重试策略完全不同,且往往不能简单用"规则兜底"代替。两套校验应各自独立、各自可观测。
6.3 何时需要两层都上
当你的应用同时"让模型产出结构化结果"且"调用外部工具"时,两层都要有。典型链路是:模型决定调用哪个工具(模型输出护栏管这一段)→ 工具返回结果(工具返回校验管这一段)→ 模型再综合生成最终回复(又回到模型输出护栏)。不要因为某一层做得好,就省略另一层。
| 维度 | 模型输出护栏 | 工具返回校验 |
|---|---|---|
| 校验对象 | 模型生成文本 | 外部系统返回 |
| 信任假设 | 不可信、会幻觉 | 不可信、会超时/越界 |
| 失败处置 | 重试/降级/规则兜底 | 重试/熔断/上游告警 |
| 关注重点 | 格式+语义+合规 | 契约+可用性+边界 |
7. 落地清单与度量
7.1 上线前检查清单
落地时建议先过一遍清单:① 是否所有模型输出入口都走了格式护栏;② 是否定义了最小够用的 JSON Schema;③ 业务规则与违禁词是否可配置;④ 降级路径是否一定返回fallback标记;⑤ 降级是否被监控打点。这五条缺任何一条,护栏都不算闭环。
7.2 用哪些指标判断护栏够用
判断护栏是否够用,看三个比率即可:解析成功率(格式护栏生效后仍失败的比例)、内容校验是否通过、降级触发率。三者随模型版本、prompt 调整会变化,建议做成看板持续观察,而不是上线一次就不管。具体阈值因业务而异,本文不给出统一数字,避免误导。
7.3 常见误用
常见误用有三种:其一把模型输出当真理直接落库,没有fallback标记;其二只在成功分支写逻辑,降级分支返回空对象导致下游KeyError;其三把工具返回和模型输出用同一份 schema 校验,导致一方变更连累另一方。这些都不是"模型不够强"的问题,而是护栏设计缺位。
配套落地检查清单:我把第 7 章的清单和指标看板模板整理成了可勾选的清单文档,对照着改你的项目就能补齐护栏闭环。放在资料包里,扫码即可获取:
附表 A:本文引用事实与出处对照表
| 事实 | 出处 | 本文位置 |
|---|---|---|
| 主流模型服务商提供结构化输出 / JSON Mode 能力 | 各模型厂商官方文档(建议按所用模型查阅) | 2.1 节 |
| pydantic / jsonschema 可用于字段级校验 | pydantic、jsonschema 开源项目文档 | 3.1 节 |
| 模型输出失败可重试、可降级 | 通用工程实践,无单一权威出处 | 2.2、4 节 |
| 模型输出校验与工具返回校验应分离 | 本文基于工程经验提出的结论 | 6 节 |
| 具体某模型版本的结构化输出开启方式 | 无一手出处,待验证 | 2.1 节 |
附表 B:术语速查表
| 术语 | 含义 |
|---|---|
| 输出护栏 | 在消费模型文本前,对其做格式/内容/降级校验的防护层 |
| 格式护栏 | 第一层,确保模型输出能被解析为约定结构 |
| 内容护栏 | 第二层,校验字段语义、业务规则与合规 |
| 降级 | 第三层,前两层不可用时走规则兜底或安全默认 |
| 结构化输出 | 模型被约束只产出符合 schema 的 JSON 的能力 |
| JSON Schema | 描述 JSON 结构、类型、约束的声明式规范 |
| 幻觉 | 模型生成与事实或约定不符的内容 |
| fallback 标记 | 降级结果中标识"结果不可信、来自兜底"的字段 |
写在最后:这篇用到的资料
写这篇文章时,我把"模型输出怎么验真再用"这条线从头到尾在自建环境里跑了一遍,顺手也整理了几份配套的东西:
- 《输出护栏三层设计速查卡》:把格式/内容/降级每一层的校验点和代码骨架压缩成一页,方便对着改。
- 《GuardRunner 可运行模块》:文中那段 Runner 抽成的独立文件,解析、校验、降级三个函数都可插拔。
- 《落地检查清单与指标模板》:第 7 章清单的勾选版,加上降级触发率看板的字段建议。
资料是我自己整理的,放在下面这个码上,扫码即可获取:
资料较多,建议先看「全套 AGI 大模型学习路线」,再挑一个实战项目跟练。