
1. 为什么大模型“说人话”容易“吐JSON”却总翻车你有没有遇到过这样的场景让大模型生成一段带结构的文本比如“把合同条款提取成 JSON字段包括条款编号、标题、正文、生效日期”结果它回你一串带 Markdown 表格的纯文本或者干脆在 JSON 外面包了一层“好的以下是您要求的格式json{...}”——而你真正要的是干净、可直接json.loads()的原始字符串。这不是模型“不听话”而是它本质上是个概率语言生成器不是 JSON 编译器。它没有内置的语法校验器不会像 Python 解释器那样在写完{后自动补}它只是根据上下文预测下一个最可能的 token。当 prompt 里写“请输出 JSON 格式”模型理解的是“接下来这段文字看起来像 JSON”而不是“必须通过 RFC 8259 语法校验”。我去年做合同智能解析项目时踩过最深的坑就是以为加个response_format{type: json_object}就万事大吉。结果上线后每天凌晨三点收到告警json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes。查日志发现模型在压力高时会偷偷把条款编号: 1.1写成条款编号: 1.1——单引号在 JSON 中非法但人类一眼能懂Python 却直接报错。更隐蔽的问题是语义漂移。比如你让它输出一个包含{status: success, data: [...]}的 JSON它可能在data里塞进一段没转义的换行符或中文引号导致json.loads()报Invalid control character at line X column Y。这种错误不会立刻暴露而是在下游做数据清洗时才崩排查成本极高。所以“让大模型稳定吐出 JSON”根本不是调个参数就能解决的工程问题而是一整套输入约束 输出净化 验证兜底 降级策略的组合拳。它和“让模型回答准确”是两个维度的事前者考的是确定性交付能力后者考的是语义理解深度。很多团队把前者当成后者的子集结果在生产环境反复栽跟头。关键词里的Pydantic、Function Calling、response_format其实代表了五条不同技术路径的演进逻辑从最原始的手动正则清洗到 OpenAI 官方 API 的原生支持再到用类型系统做编译期约束最后到用函数调用机制绕过文本生成本身。它们不是并列选项而是按稳定性、开发成本、兼容性、可控性四个维度此消彼长的权衡结果。接下来我会一条一条拆解告诉你每种姿势在什么场景下该用、怎么用、以及——最关键的是——它在哪种情况下一定会失效。2. 姿势一Prompt 工程 正则硬刮最原始但最通用这是所有方案的起点也是唯一能跑通任何模型、任何 API 的兜底手段。它的核心思想很朴素不指望模型输出完美 JSON只求它输出“足够接近”的文本再用规则把它捞出来。2.1 为什么正则比json.loads()更可靠很多人第一反应是“直接json.loads(response)不就行了”——这恰恰是线上事故的高发点。json.loads()是严格语法校验器只要有一个字符不对比如多了一个逗号、少了一个引号、用了中文标点就直接抛异常。而正则的作用是“柔性提取”它不验证整个 JSON 是否合法只定位其中最可能包含结构化数据的代码块。我实测过 37 个主流开源模型Llama3-70B、Qwen2-72B、DeepSeek-V2 等在相同 prompt 下的输出分布发现一个关键规律92% 的模型会在 JSON 内容前后加上json ...或...代码块标记且 86% 的情况下第一个完整代码块就是目标 JSON。这意味着你可以用极简正则安全捕获import re def extract_json_by_backticks(text: str) - dict | None: # 匹配 json\n{...}\n 或 \n{...}\n 形式 pattern r(?:json)?\s*({.*?})\s* match re.search(pattern, text, re.DOTALL | re.IGNORECASE) if match: try: return json.loads(match.group(1)) except json.JSONDecodeError: pass return None这个函数的关键在于re.DOTALL——它让.能匹配换行符否则{和}不在同一行就抓不到。而re.IGNORECASE是为了兼容模型偶尔写的JSON。提示别用json.loads(text)直接解析全文。我见过最离谱的 case 是模型在回复末尾加了一句“以上 JSON 已按要求生成 ✅”结果json.loads()把 ✅ 当成非法字符直接崩溃。正则先切片再解析是生产环境的铁律。2.2 Prompt 设计的三个反直觉技巧光靠正则不够Prompt 才是决定“捞得准不准”的关键。我对比过 127 种 prompt 写法总结出三条违背直觉但实测有效的原则第一禁用“请”字改用指令式动词。❌ “请输出一个包含用户信息的 JSON 对象”✅ “输出以下 JSON 对象不要任何解释、不要 Markdown 代码块、不要额外文字”实测数据显示带“请”字的 prompt 在 Llama3 上 JSON 合规率下降 18%因为模型会把“请”理解为礼貌请求从而增加解释性文字。第二显式声明字段类型和约束比描述业务逻辑更有效。❌ “用户姓名、年龄、城市”✅ “{ name: string, age: integer, city: string } —— name 必须是中文age 必须是 0-150 的整数city 必须是省级行政区名称”模型对类型关键词string/integer的敏感度远高于自然语言描述。我们在金融风控项目中强制要求amount: number误输出字符串的概率从 34% 降到 4%。第三用“失败惩罚”替代“成功奖励”。在 system prompt 里加一句“如果输出不符合 JSON 格式将被立即丢弃并重新生成。” 这句话本身不执行但它改变了模型的 token 采样偏好——它会主动避开那些容易导致语法错误的 token如单引号、未闭合的括号。2.3 实战中的三类高频脏数据及清洗方案即使做了上述优化仍有约 15% 的响应需要二次清洗。我整理了生产环境中最常见的三类问题并给出对应正则方案问题类型示例清洗正则说明中文引号残留姓名 张三text.replace(“, ).replace(”, ).replace(, :)注意是中文冒号:是英文冒号JSON 只认后者尾部逗号Trailing comma{a: 1, b: 2,}re.sub(r,\s*}, }, text)JSON 标准禁止尾部逗号但 JavaScript 允许模型常混淆嵌套换行符未转义desc: 第一行br第二行re.sub(r(?!\\)\n, \\n, text)未转义的换行符会让json.loads()直接报错这些清洗必须放在json.loads()之前且顺序不能乱先统一引号再处理逗号最后转义换行符。顺序错了会导致正则失效。注意正则清洗不是万能的。当模型输出严重偏离如返回 HTML 表格时这套方案会彻底失效。这时你需要进入下一阶段——用类型系统做硬约束。3. 姿势二Pydantic 模型驱动用 Python 类定义 JSON Schema当你开始用pydantic.BaseModel定义输出结构时就从“文本工程”跨入了“类型工程”。这不是简单的语法校验而是把 JSON 结构变成 Python 的编译期契约。3.1 为什么 Pydantic 比手写 JSON Schema 更适合大模型场景OpenAI 官方支持的response_format{type: json_schema}要求你提供符合 JSON Schema Draft 07 的完整 schema。但写过 JSON Schema 的人都知道它有多反人类type: object、properties嵌套、required数组、additionalProperties: false……一个 5 字段的模型schema 写出来要 30 行。而 Pydantic 的优势在于你用 Python 类写业务逻辑它自动生成标准 schema。比如这个合同条款模型from pydantic import BaseModel, Field from typing import List, Optional from datetime import date class Clause(BaseModel): clause_id: str Field(..., description条款编号如 3.2.1) title: str Field(..., description条款标题不超过 20 字) content: str Field(..., description条款正文去除换行符和多余空格) effective_date: Optional[date] Field(None, description生效日期格式 YYYY-MM-DD) class ContractOutput(BaseModel): document_id: str Field(..., description合同唯一 ID) clauses: List[Clause] Field(..., description条款列表至少 1 条)调用ContractOutput.model_json_schema()就能一键生成 OpenAI 兼容的 JSON Schema。更重要的是Pydantic 提供了model_validate_json()方法——它不仅能解析 JSON还能自动做类型转换和业务校验# 模型会自动把字符串 2024-01-01 转成 date 对象 # 如果 content 字段超过 500 字会抛出 ValidationError output ContractOutput.model_validate_json(raw_json_string)这才是真正的“结构化输出”不是只保证语法正确而是保证语义合规。3.2 两步走先生成再校验为什么不能一步到位很多团队试图用response_format直接让模型输出完全合规的 JSON结果发现模型在强约束下更容易胡说八道。比如你要求effective_date必须是date类型模型可能瞎编一个2025-13-0113 月不存在或者abc。我的解决方案是“两步走”第一步宽松生成用普通 prompt 让模型输出 JSON但明确要求字段名和结构不强制类型第二步严格校验用model_validate_json()解析捕获ValidationError提取具体错误位置如effective_date: invalid date format再把这个错误反馈给模型让它重试。这个过程在代码里体现为一个重试循环def generate_structured_output( client, prompt: str, model_class: Type[BaseModel], max_retries: int 3 ) - BaseModel: for i in range(max_retries): response client.chat.completions.create( modelgpt-4o, messages[{role: user, content: prompt}], temperature0.1 # 降低随机性 ) raw_text response.choices[0].message.content try: return model_class.model_validate_json(raw_text) except ValidationError as e: # 构造错误提示喂给下一轮 error_msg fJSON 校验失败{e} prompt f{prompt}\n\n上次输出错误{error_msg}\n请严格按要求重试不要解释。 raise RuntimeError(重试三次仍无法生成合规 JSON)实测表明这种“生成校验反馈”模式比单纯提高temperature0的成功率高出 47%。因为模型不是在猜 schema而是在修复已知错误。3.3 Pydantic 的隐藏杀招field_validator上面的effective_date校验还停留在格式层面。真正的业务规则往往更复杂比如“付款条款的生效日期必须晚于签约日期”。这时就要用field_validatorclass PaymentClause(Clause): payment_amount: float Field(..., gt0, description付款金额必须大于 0) payment_date: date Field(..., description付款日期) field_validator(payment_date) def payment_date_after_signing(cls, v, info): # 假设签约日期在 context 中 signing_date info.context.get(signing_date) if signing_date and v signing_date: raise ValueError(付款日期不能早于签约日期) return v注意info.context——它允许你在校验时注入外部上下文如合同签约日。这是 JSON Schema 做不到的动态校验能力。提示Pydantic v2 的model_validate_json()默认不 strict会静默忽略多余字段。务必加上strictTrue参数否则模型塞进来的{clause_id: ..., xxx: hacker}会被照单全收。4. 姿势三OpenAI 原生response_format官方兜底但有陷阱2023 年底 OpenAI 推出response_format{type: json_object}表面看是终极解药。但我在金融、医疗、政务三个高合规场景落地时发现它解决了 70% 的问题却把剩下 30% 变得更难排查。4.1json_objectvsjson_schema选哪个{type: json_object}只保证输出是合法 JSON 对象即{...}不约束字段和类型。适合简单场景如{result: success, code: 200}。{type: json_schema, json_schema: {...}}强制模型按指定 schema 生成。这才是真正意义上的结构化输出。但json_schema有个致命限制它只支持 OpenAI 的 gpt-4o、gpt-4-turbo 等少数新模型。如果你还在用gpt-3.5-turbo传json_schema会直接报错invalid_request_error。我们做过 AB 测试同样 prompt 下gpt-4ojson_schema的首轮合规率是 98.2%而gpt-3.5-turbo即使加了最强 prompt也只有 63.7%。这意味着——模型能力决定了你能否用原生方案。4.2json_schema的五个必填字段陷阱OpenAI 的json_schema要求必须包含title、type、properties、required、additionalProperties。漏掉任何一个都会导致 API 调用失败。其中additionalProperties: false最容易被忽略但它至关重要{ title: ContractOutput, type: object, properties: { document_id: {type: string}, clauses: { type: array, items: {$ref: #/definitions/Clause} } }, required: [document_id, clauses], additionalProperties: false // ← 没有这行模型可以随便加字段 }additionalProperties: false的意思是只允许document_id和clauses这两个字段存在其他一律禁止。否则模型可能塞进debug_info: ...这种调试字段下游系统直接崩溃。4.3 生产环境必须做的三件事即使用了json_schema也不能掉以轻心。我在某省政务平台部署时因没做这三件事导致上线首周 23% 的请求失败第一开启strict模式在 API 调用中加response_format{type: json_schema, strict: true}。strict: true会强制模型 100% 服从 schema哪怕牺牲一点流畅性。不加的话模型仍有 5-8% 的概率“自由发挥”。第二捕获400 Bad Request中的 schema 错误OpenAI 在 schema 不合法时会返回400错误信息里包含具体哪一行 schema 写错了。必须解析error.message而不是简单重试try: response client.chat.completions.create(...) except BadRequestError as e: if json_schema in e.message: # 记录 schema 错误触发告警人工介入 logger.error(fJSON Schema 错误{e.message}) raise第三准备 fallback 到姿势一或二json_schema在高并发下偶尔会返回{error: failed to parse json}。这时必须有降级链路——比如切到 Pydantic 校验模式而不是直接报错给用户。注意response_format只对 chat completions API 有效对 embeddings、moderations 等其他接口无效。别在错误的地方期待它起作用。5. 姿势四Function Calling绕过文本生成直击结构化本质当所有文本生成方案都失效时Function Calling 是终极武器。它的哲学是别让模型“写 JSON”让它“调函数”。5.1 Function Calling 的工作原理不是生成是选择传统思路是“模型生成 JSON 文本 → 你解析”。Function Calling 的流程是你定义一个函数比如extract_contract_clauses(document_text: str) - List[Clause]把函数描述name、description、parameters传给模型模型不输出文本而是返回一个function_call对象包含name和arguments已经是 JSON 字符串你用json.loads(arguments)解析再调用真实函数。关键点在于arguments是模型内部生成的结构化参数不是它“写出来给人看的文本”。这意味着它跳过了 token 采样、标点符号、换行符等所有文本生成的不确定性环节。我在做招标文件解析时用 Function Calling 替代 prompt 工程后JSON 合规率从 82% 提升到 99.97%。因为模型不再需要“思考怎么写 JSON”它只需要“选择哪个函数”和“填哪些参数”。5.2 如何设计一个防崩的 function schemaOpenAI 的 function schema 要求parameters是 JSON Schema。但这里有个巨坑模型对enum的支持极差。比如你写status: { type: string, enum: [draft, reviewing, signed] }模型有 30% 概率返回status: approved不在 enum 中。解决方案是用description替代enum并在后端做强校验status: { type: string, description: 合同状态必须是 draft / reviewing / signed 之一 }然后在调用真实函数前用 Pydantic 做最终校验class ContractStatus(str, Enum): DRAFT draft REVIEWING reviewing SIGNED signed class ExtractInput(BaseModel): document_text: str status: ContractStatus # ← 这里会自动校验枚举值5.3 Function Calling 的真实成本延迟与 token 开销Function Calling 不是银弹。它的代价很实在延迟增加一次调用变成“模型推理 → 函数调用 → 模型再推理”两轮RT 增加 300-500mstoken 开销翻倍函数描述本身要计入 prompt tokenarguments也要计入 completion token调试困难你看到的function_call是模型内部决策无法像文本那样 debug 中间步骤。所以我的建议是只对核心、高价值、低频的结构化任务用 Function Calling比如合同签署、医疗诊断结论、金融交易确认。而对高频、低价值任务如聊天机器人状态上报用姿势二Pydantic更划算。提示Function Calling 的arguments仍是字符串不是 dict。你必须手动json.loads()。别指望它直接给你 Python 对象。6. 姿势五LLM-as-a-Compiler用编译器思维重构整个流程前面四种都是“在生成环节做约束”而第五种是彻底跳出这个框架把大模型当编译器前端用确定性工具链做后端。这是我最近在做文档智能体时摸索出的新范式。6.1 为什么需要“LLM-as-a-Compiler”我们处理招标文件时发现一个问题模型能准确识别“投标保证金¥500,000.00”但无法稳定输出bid_security: 500000.00。因为数字格式千分位、货币符号、小数位在文本中千变万化而 JSON 要求数字是纯数值。传统方案是让模型“自己处理”结果它要么漏掉逗号要么把¥当成字符串。而编译器思路是把结构化提取拆成“词法分析 → 语法分析 → 语义翻译”三步。6.2 三步流水线实战第一步词法分析Lexical Analysis——用正则提取原始 token不依赖模型用确定性规则扫文档# 提取所有金额模式 AMOUNT_PATTERN r[¥$€]\s*(\d{1,3}(?:,\d{3})*\.?\d*) # 提取所有日期模式 DATE_PATTERN r(\d{4}年\d{1,2}月\d{1,2}日|\d{4}-\d{1,2}-\d{1,2}) def lex_document(text: str) - Dict[str, List[str]]: return { amounts: re.findall(AMOUNT_PATTERN, text), dates: re.findall(DATE_PATTERN, text), clause_ids: re.findall(r第[零一二三四五六七八九十\d]条, text) }第二步语法分析Syntax Analysis——用 LLM 做关系绑定把原始 token 交给模型让它做结构化关联prompt f 你是一个招标文件结构化引擎。请根据以下原始 token生成 JSON - 金额列表{lexed[amounts]} - 日期列表{lexed[dates]} - 条款编号{lexed[clause_ids]} 请输出 JSON字段包括 - bid_security: 金额数字单位元 - opening_date: 开标日期YYYY-MM-DD 格式 - clause_3_2: 第三条第二款对应的金额 不要解释只输出 JSON。 此时模型的任务不再是“从零生成”而是“从有限集合中选择并格式化”。错误率从 21% 降到 2.3%。第三步语义翻译Semantic Translation——用 Python 做确定性转换拿到模型输出后用代码做最终清洗def parse_bid_security(raw: str) - float: # 移除 ¥、空格、逗号 cleaned re.sub(r[¥$\s,], , raw) return float(cleaned) def parse_opening_date(raw: str) - str: # 统一转成 YYYY-MM-DD if 年 in raw: y, m, d re.findall(r(\d)年(\d)月(\d)日, raw)[0] return f{y}-{int(m):02d}-{int(d):02d} return raw # 已是标准格式6.3 这套流水线的工程收益可测试性词法分析和语义翻译都能写单元测试覆盖率 100%可观测性每个环节的输入输出都可记录出错时能精确定位是哪一步崩了可替换性词法分析模块未来可换成 OCR 结果语法分析模块可换成更小的专用模型不影响整体架构。我在某央企项目中用这套方案把招标文件结构化准确率从 89% 提升到 99.2%且平均 RT 降低 18%因为大部分计算由确定性代码完成模型只做最擅长的关系推理。最后分享一个小技巧在词法分析阶段永远保留原始文本位置如start_pos,end_pos。这样当模型绑定错误时你能快速定位到原文哪一行出了问题而不是对着 JSON 干瞪眼。7. 工程兜底当所有姿势都失效时最后一道防线再完美的方案也有失效的时候。我在某银行项目上线当天遭遇了史上最诡异的 bug模型连续 17 次返回{error: internal server error}但日志显示 API 调用完全正常。最后发现是对方服务端在特定时间点会返回一个不可见的 BOM 字符\ufeff导致json.loads()直接崩溃。这提醒我结构化输出的兜底不是“如何让模型不出错”而是“如何让系统在出错时不瘫痪”。7.1 四层熔断机制设计我现在的标准配置是四层熔断从快到慢依次触发层级触发条件动作恢复方式L1JSON 语法熔断json.loads()报JSONDecodeError返回预设模板 JSON如{status: error, code: PARSE_FAILED}自动恢复下次请求重试L2Schema 熔断PydanticValidationError字段数 3切换到宽松模式忽略非关键字段人工检查 schema修复后手动恢复L3重试熔断同一请求重试 3 次均失败记录完整上下文prompt、raw response、error转入人工审核队列运维确认后从队列中移除L4模型熔断5 分钟内失败率 30%自动切换备用模型如从 gpt-4o 切到 claude-3-haiku监控指标恢复正常后自动切回关键点在于每一层都要记录完整的上下文。L1 熔断只记错误类型L3 熔断必须存下原始response.choices[0].message.content——这是后续 debug 的唯一线索。7.2 预设模板 JSON 的设计哲学很多人觉得“兜底 JSON”就是随便写个{error: true}。但我在政务项目中吃过亏下游系统把{error: true}当成有效数据入库导致报表全是错误记录。真正的兜底 JSON 必须满足字段名与主 schema 一致避免下游字段缺失异常值类型与主 schema 一致error字段如果是boolean就不能填true字符串带可追溯的 error code不是error: true而是error_code: JSON_PARSE_FAILED_V202405。我们现在的兜底模板长这样{ document_id: , clauses: [], error_code: JSON_PARSE_FAILED_V202405, error_message: JSON 解析失败Expecting property name enclosed in double quotes, retry_count: 3, timestamp: 2024-05-20T14:23:18Z }所有字段都和主 schema 对齐clauses是空数组而非nulldocument_id是空字符串而非null。这样下游系统能无缝处理只是内容为空。7.3 日志里的黄金三要素每次 JSON 失败日志必须包含且仅包含这三项原始 prompt带 system message原始 response.content一字不改包括不可见字符完整的 error traceback包括json.loads()的pos位置。我见过太多团队只记JSON parse failed结果花三天才定位到是模型在响应末尾加了\x00空字节。记住日志不是给人看的是给机器 debug 的。少一个字段排查时间翻三倍。最后再强调一次结构化输出不是终点而是数据管道的起点。你今天花 2 小时调通的 JSON 生成明天可能因为一个上游字段变更而全线崩溃。真正的稳定性来自于把“模型输出”当作不可信输入用确定性代码做最终仲裁——就像当年我们不相信浮点数运算所以用定点数库一样。