在接大模型的项目里,JSON 解析失败大概率不是偶发事件,而是日常事件。你可能已经见过这些现象:模型输出里多了一句“好的,结果如下”;花括号少了一个;字段名从user_name变成了username;说好的status字段整个消失;甚至直接在 JSON 外面包了一层代码块。每次遇到这种情况,第一反应基本都是改 Prompt,把“请务必输出 JSON”写得更用力一些。改了几版之后会发现,作用有,但根治不了。问题在于,Prompt 的本质是“提高输出符合预期的概率”,它没法变成一个文法和语法层面的硬约束。
这篇文章想和你聊的不是某个单点技巧,而是一整套稳定线上大模型 JSON 输出的方案:提示词设计、容错解析、Schema 校验、字段后处理、带诊断的重试。这五件事各管一段,组合起来才能把“JSON 偶发损坏”从线上事故变成可管理的小概率事件。
读完你会得到三个东西:一套适合直接落地的架构思路、一份可以直接改来用的 Python 示例代码、以及一份线上排查和重试策略的避坑清单。
1. 你真正要解决的问题:不是“偶发”,而是线上稳定性
先描述一个很典型的场景。你做了一个客服助手,大模型需要从用户问题里抽取意图、实体、参数,然后返回给后端去调用订单、售后等系统。后端拿到的是一个 JSON,格式大概是:
{ "intent": "query_order", "params": { "order_id": "20250101001" }, "confidence": 0.92 }这个 JSON 如果解析失败,后端就无法路由。于是线上表现是:大部分请求正常,但隔一段时间就有一条请求报错。用户侧看到的是“系统繁忙”,技术侧看到的是JSONDecodeError和Invalid JSON的告警。
这种问题让人头疼的点有三个:
- 它不固定。不是某个 Prompt 写了就永远不犯,而是概率性出现。
- 它随模型版本变化。同一个 Prompt,换了模型版本,错误率可能明显变化。
- 它会在下游放大。一个 JSON 解析失败,会直接导致业务流程中断。
从工程经验看,更稳妥的判断是:不要追求“让模型永远输出合法 JSON”,这是不可靠的。真正可落地的方案是把“模型可能输出非法 JSON”当成一个默认前提,在系统层面建立防护层。你不需要让大模型变得更确定,你需要让你的系统在模型不确定的情况下依然稳定。
这也是这篇文章的核心思路:跳出“只调 Prompt”的惯性,用程序化的方式兜住大模型输出的不确定性。
2. 为什么大模型的 JSON 输出会“飘忽不定”
先说结论:大模型本身是一个“按概率生成下一个 token”的系统,它没有编译器级别的约束来保证输出的 JSON 文法合法。你看到的花括号、引号、逗号,都是模型根据训练数据学出来的“最像样”的结构,而不是经过解析器验证过的结构。
有几个因素会让这个问题更明显:
- 解码策略。高 temperature、top_p 等采样参数越高,输出越随机,格式漂移的概率也会增加。
- Prompt 扰动。系统提示词后面拼接了动态内容、few-shot 示例数量变化、上下文长度变化,都会影响模型对输出格式的“注意力”。
- 输出长度和截断。max_tokens 设置过小,JSON 生成到一半被截断,必然产生不完整括号。
- 平台差异。不同大模型平台对 JSON 格式的支持程度不同,有的支持强约束,有的只支持弱约束,有的字段名和转义规则还不一样。
理解这点之后,你就知道为什么“重试一次”很多时候不是好方案。因为单纯重试只是再去撞一次同样的概率,没有把上一次失败的原因利用起来。
真正有效的重试,是“带诊断的重试”:明确告诉模型“你上一次输出解析失败了,失败原因是第几层出错、缺少什么字段、哪里不合法”,让模型在修正信息的基础上重新生成。这个思路会贯穿后面多个章节。
3. 稳定 JSON 的四层防线:先定架构,再写代码
我建议把稳定方案拆成四层,每一层解决一类问题,职责单一,容易测试,也方便单独调优。
| 层次 | 解决什么问题 | 失败时怎么办 | 核心手段 |
|---|---|---|---|
| 第一层:提示词 | 降低非法 JSON 出现概率 | 进入第二层 | 输出规范、few-shot、低温度 |
| 第二层:解析与修复 | 把“接近 JSON 但不是 JSON”的内容变成可解析 JSON | 进入第四层重试 | 提取代码块、括号修复、注释清理 |
| 第三层:校验与后处理 | 保证业务字段完整、类型正确、值合法 | 进入第四层重试 | Schema 校验、默认值补全、类型转换 |
| 第四层:重试 | 对可修复错误进行有节制、有诊断的重新调用 | 最终走降级方案 | 错误分类、指数退避、携带失败原因 |
一个很容易犯的错误是只做第一层和第四层,也就是“使劲改 Prompt,失败就重试”。这样做的结果是 Prompt 越来越长,重试次数越来越高,成本翻倍,问题却没有真正解决。原因是第二层和第三层承担了最关键的“兜底”职责——它们不依赖模型情绪,只依赖确定性代码。
在展开之前,我强烈建议你先把四层都装上,哪怕某些层现在看起来用不上。线上项目里,第一层只能覆盖 80% 的情况,第二层能再兜住 15%,第三层负责定义“什么叫成功”,第四层才是最后的保险。缺了任何一层,都会有漏洞。
4. 第一层:提示词设计,如何让模型“尽量听话”
提示词不能保证输出合法,但能明显降低出错概率。设计上有几个要点:
4.1 在 System Prompt 里明确输出约束
不要把“输出 JSON”的要求藏在最后一句。推荐的做法是在 system prompt 里反复强调以下几点:
- “只输出一个 JSON 对象,不要输出 markdown 代码块。”
- “不要输出任何解释、前言或后记。”
- “所有字段都必须包含在 JSON 中,即使值为空也要输出 null。”
- “确保输出可以被标准 JSON 解析器解析。”
一个可复用的 system prompt 模板可以是这样:
SYSTEM_PROMPT_TEMPLATE = """ 你是一个结构化数据抽取助手。你的任务是根据用户输入生成 JSON 数据。 必须遵守: 1. 只输出一个 JSON 对象,不要输出 markdown 代码块。 2. 不要输出任何解释、前言或后记。 3. JSON 必须包含以下字段,且字段名不要做任何改动: {field_descriptions} 4. 如果某字段的信息不存在,使用 null,不要省略该字段。 5. 确保所有字符串都使用双引号,确保输出可以被标准 JSON 解析器解析。 字段说明: {field_schema_text} 示例: 输入:帮我查一下订单 20250101001 到哪里了 输出:{{"intent": "query_order", "params": {{"order_id": "20250101001"}}, "confidence": 0.9}} """注意模板里的{{和}},这是因为如果用 Python 的format拼接,需要转义。这里真正容易踩坑的地方是字段描述太长,反而干扰模型对“只输出 JSON”的理解。实践经验是,few-shot 示例的质量和数量比冗长的描述更有效。两到三个高质量示例通常会比一大段“不要做什么”的说明效果好。
4.2 用 few-shot 固定结构
模型会模仿示例的格式。如果你的 few-shot 示例里输出了{"intent": "query_order", ...}这样的结构,模型大概率会照做。但示例里的字段顺序、缩进也会被模仿,所以示例要保持干净、统一。
4.3 对结构化任务把 temperature 调低
如果任务本身不要求创造性,比如抽取、分类、摘要,推荐把 temperature 调成 0 或接近 0。这样能显著减少随机性,虽然不能根治格式问题,但会明显降低错误率。你可以在调用大模型时设置:
response = model_call( messages=messages, temperature=0.0 )4.4 提示词层的局限性
即使做了以上所有事,模型仍可能在极端情况下输出非法 JSON。比如输入非常长、包含大量特殊字符、上下文里出现与 JSON 格式很像的文本,都会干扰输出。所以,提示词层只是降低成本,永远不要把它当成唯一防线。真正的兜底在下一层。
5. 第二层:鲁棒解析与 JSON 修复
当模型输出不合法时,第一件事不是重试,而是尝试“修复”。原因是即使 JSON 不合法,大部分情况下模型输出的内容离合法 JSON 只差一步:多了代码块标记、多了尾部逗号、括号没闭合、外层多了一段解释文字。
修复的基本思路是:先提取 JSON 片段,再做保守的语法修复,然后交给标准json.loads。
# json_repair.py import json import re class JSONParseError(Exception): """模型输出无法被修复为合法 JSON 时抛出。""" def extract_json_block(text: str) -> str: """从模型原始输出中提取最可能包含 JSON 的片段。 这里的修复策略是保守的:只去掉常见的代码块包裹和解释文字。 如果需要更激进地提取,务必增加日志,避免静默丢失业务信息。 """ text = re.sub(r"```json|```", "", text).strip() start = text.find("{") end = text.rfind("}") if start == -1 or end == -1 or end <= start: raise JSONParseError("no_json_object_found") return text[start:end + 1] def repair_and_parse(raw: str) -> dict: """将模型输出修复为合法 JSON。 修复步骤: 1. 提取 JSON 主体; 2. 清理 // 注释和尾随逗号; 3. 补齐未闭合的括号; 4. 交给标准 json.loads 解析。 """ body = extract_json_block(raw) # 清理 // 注释 body = re.sub(r"//[^\n]*", "", body) # 清理字符串值里的换行控制字符 body = re.sub(r"[\x00-\x1f\x7f]", "", body) # 去掉尾随逗号,例如 {"a": 1,} body = re.sub(r",\s*([}\]])", r"\1", body) try: return json.loads(body) except json.JSONDecodeError as e: # 尝试补齐未闭合的括号 open_braces = body.count("{") close_braces = body.count("}") if open_braces > close_braces: body += "}" * (open_braces - close_braces) try: return json.loads(body) except json.JSONDecodeError as final_error: raise JSONParseError(f"repair_failed: {final_error}")这段代码重点解决几种最常见的脏数据:
- 模型在 JSON 外层加了 ```json 代码块。
- 模型在 JSON 前面输出了解释性文本。
- JSON 里出现了
//注释。 - 数组或对象最后一个元素后带了尾随逗号。
- 输出被截断导致右括号缺失。
真正需要谨慎的是“修复动作的边界”。如果模型输出是{"name": "Alice", "name": "Bob"},重复键应该交给校验层处理,而不是在解析层静默取舍。如果模型输出的{出现在字符串值内部,比如{"text": "这是一个{测试}"},上面的find("{")和rfind("}")提取逻辑基本安全,因为{和}会正确配对,但如果字符串里包含不对称的大括号,提取就会出错。所以在修复层,宁可抛错进入重试,也不要靠粗暴的正则强行“修复”出错误的结果。
修复完成后,你会得到一个 Python dict。但解析成功不代表可以放心用,下一层的校验才是控制质量的关键。
6. 第三层:Schema 校验与字段后处理
很多项目只做json.loads,只要不报错就直接用。这在简单场景下没问题,但在业务字段比较多的场景很容易出事。最典型的例子是:模型把order_count输出成了字符串"3",下游代码却直接做整数运算;或者模型把amount输出成了"18.50",前端展示时需要数值类型。JSON 标准允许"3"这种字符串,业务不允许。
这一层的目标有两个:第一,校验字段是否存在、类型是否正确、枚举值是否合法;第二,对可以自动修正的字段做后处理,比如类型转换、默认值补全、日期格式化。
6.1 定义一个字段级 Schema
不引入复杂依赖,可以用 Python 字典定义 Schema,结构简单,也方便团队 review。
# schema.py from typing import Any, Dict, List def validate_and_normalize(data: Dict[str, Any], schema: Dict[str, Dict[str, Any]]) -> Dict[str, Any]: """校验并归一化模型输出。 schema 格式示例: { "intent": { "type": "string", "required": True, "enum": ["query_order", "cancel_order", "other"] }, "order_id": { "type": "string", "required": False, "default": None }, "count": { "type": "integer", "required": True, "default": 0 } } """ result: Dict[str, Any] = {} warnings: List[str] = [] for field, rule in schema.items(): required = rule.get("required", False) # 字段缺失 if field not in data or data[field] is None: if required: if "default" in rule: result[field] = rule["default"] warnings.append(f"missing_required_field:{field}:use_default") else: raise ValueError(f"missing_required_field:{field}") else: result[field] = rule.get("default") if data.get(field) is not None or field not in data: continue continue value = data[field] # 类型校验和宽松转换 expected_type = rule.get("type") if expected_type == "string" and not isinstance(value, str): value = str(value) warnings.append(f"coerce_to_string:{field}") elif expected_type == "integer": if isinstance(value, str) and value.lstrip("-").isdigit(): value = int(value) warnings.append(f"coerce_to_int:{field}") if not isinstance(value, int) or isinstance(value, bool): raise ValueError(f"invalid_type:{field}:expect_integer") # 枚举校验 if "enum" in rule and value not in rule["enum"]: raise ValueError(f"invalid_enum:{field}:{value}") result[field] = value return { "data": result, "warnings": warnings, }6.2 为什么“类型转换”要谨慎
"3"转成3通常没问题,但"3abc"转成整数就会出错。有风险的类型转换建议做成白名单,而不是无条件int(value)。上面的代码用了value.lstrip("-").isdigit()做保守判断,就是这个考虑。
另一个需要注意的点是,不要把模型输出直接当成数据库写入或权限判断的依据。模型可能输出一个看起来合法但语义完全错误的字段值,比如在枚举里输出了"query_order",但用户本意是取消订单。校验层只能验证“形式合法”,不负责“语义正确”。语义正确性要靠业务侧二次判断,这也是为什么我推荐在方案里保留warnings输出,让下游能感知哪些字段被修正过。
6.3 后处理:补默认值、清洗字段
除了类型转换,常见的后处理还包括:
- 把空字符串
""统一成None。 - 把日期字符串统一成
YYYY-MM-DD格式。 - 把前端和历史版本兼容的旧字段名映射到新字段名。
这些操作写在校验之后的“归一化”阶段,不要写进解析层。因为解析层职责是“得到合法 JSON”,归一化层才是“得到符合业务要求的字段”。职责分离会让每个模块更容易测试。
7. 第四层:重试策略与错误分类
重试是整个方案里最容易翻车的一层。很多线上事故不是模型输出导致,而是重试导致:重试次数太多、超时累加、成本翻倍、接口被拖垮。
7.1 先把错误分成两类:可重试和不可重试
不是所有错误都适合重试。我强烈建议在代码里显式区分两类异常:
- 可重试错误:JSON 解析失败、字段校验失败、临时限流、请求超时。
- 不可重试错误:提示词被内容策略拦截、鉴权失败、输入本身非法、模型服务明确返回业务错误码。
为什么内容策略拦截不能重试?因为它返回的往往不是 JSON 语法问题,而是“这个请求不适合继续处理”。无论重试多少次,结果大概率还是一样的。碰到这类错误,直接记录日志并返回给上层,比盲目重试更有意义。
7.2 指数退避 + 抖动
需要重试时,不要让每一次重试都紧接着上一次。推荐指数退避策略,并加入随机抖动,避免多个请求在同一时刻集中重试,打爆模型服务。
# retry.py import random import time class RetryableError(Exception): """可重试的错误,例如 JSON 解析失败、校验失败。""" class NonRetryableError(Exception): """不可重试的错误,例如内容策略拦截、鉴权失败。""" def backoff_delay(attempt: int, base_delay: float = 1.0, max_delay: float = 8.0) -> float: delay = min(base_delay * (2 ** (attempt - 1)), max_delay) return delay + random.uniform(0, delay * 0.3) def call_with_retry( func, max_attempts: int = 3, base_delay: float = 1.0, max_delay: float = 8.0, ): last_error = None for attempt in range(1, max_attempts + 1): try: return func() except NonRetryableError as e: raise e except (RetryableError, TimeoutError) as e: last_error = e if attempt == max_attempts: break delay = backoff_delay(attempt, base_delay, max_delay) time.sleep(delay) raise last_error这里有个细节:真正的线上系统里,重试不应该只在同步调用里做。如果请求链路很长,或者模型调用本身就要 3-5 秒,同步重试会让接口超时风险急剧上升。更好的做法是把这类任务丢进消息队列,在消费者侧做重试。重试次数可以放在消息头里,例如 Kafka 或 RabbitMQ 的 header 里存取retry_count,达到上限后进入死信队列或退避队列。这和常规中间件重试的思路是一致的,核心原则是“重试不能无限、不能瞬时、不能阻塞主链路”。
7.3 带诊断的重试:把失败原因带回下一次 Prompt
不带诊断的重试是“再撞一次运气”,带诊断的重试是“给模型第二次机会并告诉它哪里错了”。实践效果差别很明显。
在重试时,你可以构造一段补充提示:
RETRY_HINT_TEMPLATE = ( "注意:上一次输出解析失败,失败原因:{error}。" "请重新生成,确保输出是合法 JSON,只输出 JSON 对象,不要包含任何其他内容。" )然后把这段内容作为额外的 user message 追加到 messages 里。模型看到“解析失败”和具体原因后,通常会修正输出。这个技巧很基础,但也是我认为投入产出比最高的单点优化。
重试次数建议控制在 2-3 次。再多会导致单次请求耗时过长,而且边际收益很低。与其无限重试,不如让系统在重试耗尽后走“降级方案”:返回默认 JSON、使用上一次成功的缓存、或转人工处理。
8. 不要忽略平台原生能力:JSON Mode 与函数调用
前面讲的是纯应用层解决方案。这里必须补充一个角度:很多大模型平台已经提供了结构化输出能力,比如“JSON Mode”、函数调用(function calling)、结构化输出。这些能力能在模型层面把输出格式的约束做得更严格。
这些能力的价值和局限分别是:
优势是语法层面更可靠。平台会在解码过程中约束输出,很大概率避免缺括号、多解释文字这类基础问题。
局限是它不负责业务校验。JSON Mode 只保证输出是一个 JSON 对象,不保证字段名一定对、类型一定对、枚举值一定合法。函数调用也类似,平台能帮你把参数组织成 JSON,但参数值是否合理,仍然要业务自己判断。
所以更推荐的组合方式是:平台能力 + 应用层解析校验。先用 JSON Mode 或函数调用降低基础格式错误率,再在应用层做 Schema 校验和后处理。不要二选一。
需要提醒的是,不同平台对结构化输出的支持程度不同,有的要求messages里必须有示例,有的对字段名有长度和字符限制,有的平台对嵌套结构有深度限制。接入前先看官方文档,并在环境准备阶段用一个最小例子验证。不要因为某个平台支持 JSON Mode 就默认所有模型都能稳定输出。
9. 完整工程示例:把四层逻辑封装成一个 Pipeline
现在把前面四层串起来,做一个最小可用的封装。下面这个类模拟了一个稳定的 JSON 客户端,实际使用时只需要替换model_call为大模型官方 SDK 的调用即可。
# stable_json_client.py from typing import Any, Callable, Dict, Optional from json_repair import JSONParseError, repair_and_parse from schema import validate_and_normalize from retry import RetryableError, NonRetryableError, call_with_retry class StableJsonClient: """一个把四层防线集成在一起的客户端。 model_call 是一个可调用对象,负责调用大模型并返回原始字符串输出。 你需要根据自己使用的大模型 SDK 实现这个函数。 统一约定:model_call(messages: list, temperature: float) -> str """ def __init__( self, model_call: Callable[..., str], schema: Dict[str, Dict[str, Any]], max_attempts: int = 3, ): self.model_call = model_call self.schema = schema self.max_attempts = max_attempts def _build_messages( self, user_input: str, retry_hint: Optional[str] = None, ) -> list: field_schema_text = "\n".join( [f"- {name}: {rule}" for name, rule in self.schema.items()] ) system_prompt = ( "你是一个结构化数据抽取助手。只输出一个 JSON 对象。" "不要输出 markdown 代码块,不要输出任何解释。" "JSON 必须包含以下字段:\n" f"{field_schema_text}\n" "如果字段值不存在,使用 null,不要省略字段。" ) messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_input}, ] if retry_hint: messages.append( {"role": "user", "content": retry_hint} ) return messages def _call_once(self, user_input: str, retry_hint: Optional[str]) -> dict: messages = self._build_messages(user_input, retry_hint) # 这里是唯一需要对接真实 SDK 的地方 raw_output = self.model_call( messages=messages, temperature=0.0, ) # 第 2 层:解析与修复 candidate = repair_and_parse(raw_output) # 第 3 层:校验与归一化 checked = validate_and_normalize(candidate, self.schema) return checked["data"] def generate(self, user_input: str) -> dict: retry_hint = None def attempt(): nonlocal retry_hint try: return self._call_once(user_input, retry_hint) except JSONParseError as e: retry_hint = f"上一次输出解析失败,原因:{e}。请重新生成合法 JSON。" raise RetryableError(str(e)) except ValueError as e: retry_hint = f"上一次输出校验失败,原因:{e}。请重新生成包含完整字段的 JSON。" raise RetryableError(str(e)) try: return call_with_retry(attempt, max_attempts=self.max_attempts) except NonRetryableError as e: # 这里建议记录结构化日志,并返回给上层做降级处理 raise这个 Pipeline 的关键点在于,重试提示retry_hint是在循环外维护的。每次失败都会把上一次的具体错误原因拼接到下一次的 messages 里,这就是“带诊断的重试”。
运行一个最小验证,可以写一段简单的测试:
# demo.py def fake_model_call(messages: list, temperature: float) -> str: """模拟一个大模型输出,第一次返回带解释文本的脏 JSON,第二次返回合法 JSON。""" user_input = messages[-1]["content"] if "解析失败" in user_input: return '{"intent": "query_order", "order_id": "20250101001", "count": 3}' return ( '这是一个查询结果:\n' '```json\n' '{"intent": "query_order", "order_id": "20250101001", "count": "3",}\n' '```\n' ) schema = { "intent": {"type": "string", "required": True, "enum": ["query_order", "cancel_order", "other"]}, "order_id": {"type": "string", "required": True}, "count": {"type": "integer", "required": True, "default": 0}, } client = StableJsonClient(model_call=fake_model_call, schema=schema) result = client.generate("帮我查一下订单 20250101001 到哪里了") print(result)预期输出:
{'intent': 'query_order', 'order_id': '20250101001', 'count': 3}你可以观察到几个细节:
- 第一次调用返回了带 ```json 代码块的脏 JSON,被
repair_and_parse成功修复。 - 同时
count是字符串"3",被第三层转换成了整数3。 - 如果第一次返回的内容严重到无法修复,第二次 Prompt 会因为携带了解析失败原因而主动修正。
这段代码的核心思路,就是让你把对模型输出的信任从“盲信原始字符串”转成“层层验证后的数据”。
10. 常见问题与排查思路
线上接入这套方案后,你大概率会碰到下面这些问题。这里整理了一份排查表,按“现象 -> 原因 -> 解决方案”的结构记录。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 输出被内容策略拦截 | 输入或 Prompt 触发了平台安全策略 | 检查模型返回的原始错误码和错误信息 | 这种错误不可重试,记录日志并返回上游,检查 Prompt 用词 |
| 每次重试都解析失败 | 重试没有携带失败原因,模型不知道要修正什么 | 查看重试请求的 messages 里是否包含失败诊断信息 | 把解析失败信息拼接到重试 Prompt 中 |
| 字段总是缺失 | Prompt 中字段声明不够明确,或 few-shot 没覆盖该字段 | 对比失败样本和 Prompt 模板 | 把字段声明为 required,并在 few-shot 中展示所有字段 |
| 解析层把字段名改坏了 | 修复逻辑过于激进,比如正则替换了字符串内部内容 | 记录修复前后的原文和修复结果,人工抽检 | 修复策略保持保守,只处理确定的语法问题 |
| 并发高峰时重试导致调用成本翻倍 | 重试策略没有设置上限,或所有请求同步重试 | 查看重试次数分布和限流指标 | 限制最大重试次数,引入消息队列异步重试 |
| 模型升级后输出格式突变 | 新模型对 Prompt 的理解或生成习惯不同 | 建立回归测试集,在模型切换前跑一遍 | 使用固定版本的模型,或对 Prompt 做版本回归 |
| max_tokens 太小输出被截断 | 生成长度超过上限,JSON 不完整 | 检查输出是否包含截断标记或长度统计 | 调大 max_tokens,并把截断检测纳入校验层 |
需要特别强调“输出被内容策略拦截”这一类问题。如果你在重试循环里遇到它,应该直接归类为不可重试错误。从工程经验看,很多线上成本浪费和超时问题,都源于团队没有区分可重试错误和不可重试错误,把策略拦截、鉴权失败这类错误也拿去重试了。
11. 最佳实践与工程建议
最后补充几条在真实项目里验证过的工程建议,尤其适合你已经准备把这套方案落地到生产环境时参考。
11.1 为 Prompt 建立版本,并纳入回归测试
Prompt 不是一次性写好的。你可能会微调 few-shot、增加字段说明,这些改动都可能导致模型输出行为变化。建议给每个 Prompt 模板加version标识,在一批固定测试用例上做回归。测试用例不要只覆盖正常输入,还要覆盖空值、超长文本、特殊字符。只有回归通过,才能上线新 Prompt。
11.2 记录 raw_output 和修复日志
遇到解析失败时,不要只记录“失败”。建议把模型原始输出、修复动作、warnings、重试次数、耗时都记录下来。这样当线上出现问题时,你才能判断是模型问题、Prompt 问题还是修复逻辑问题。没有原始输出,排查会非常被动。
import logging logger = logging.getLogger("stable_json_client") logger.info( "model_output_raw=%s parse_result=%s warnings=%s attempt=%s", raw_output, result, warnings, attempt, )11.3 不要把模型输出直接用于敏感操作
这一点再强调都不为过。模型输出的 JSON 即使通过了解析和校验,也只代表“形式合法”,不代表“语义安全”。如果这个 JSON 要用于数据库写操作、权限判断、前端动态渲染,必须在业务侧再做一次白名单校验。尤其是涉及命令执行、代码生成、SQL 拼接的场景,必须把模型输出当作不可信输入处理。
11.4 先做兼容,再收紧校验
如果你接手的是一个历史项目,之前没有这套校验逻辑,不要第一天就把所有字段都设为 required。更稳妥的做法是:先记录缺失情况,运行一段时间的观测,确认哪些字段真的 100% 必填,再逐步收紧校验规则。否则线上会出现大量新报错,反而影响稳定性。
11.5 给调用链设置超时和熔断
大模型调用的延迟波动很大,高峰期可能从 2 秒涨到 10 秒。稳定方案里除了重试,还要有超时控制和熔断机制。比如单次调用超时 8 秒,连续失败 5 次后熔断 30 秒。熔断期间直接走降级方案,不再继续打模型接口。
11.6 落地优先级
如果你准备在一周内落地这套方案,建议按这个顺序推进:
- 先加解析与修复层,这是成本最低、收益最明显的一步。
- 再定义核心字段 Schema,增加校验与默认值。
- 然后重写重试逻辑,加入错误分类和带诊断的重试。
- 最后再考虑平台 JSON Mode 或函数调用,作为格式问题收敛后的额外优化。
反过来做最容易放弃:一上来就调 Prompt、接入 JSON Mode,语法问题减少了,但字段缺失和类型错误仍然在,依然不稳定。先把确定性代码兜底做扎实,再依赖模型能力提升上限,才是更稳妥的路径。