
我做模型接口这块的时候最常被问到的问题不是“怎么调用”而是“返回值拿到之后怎么变成能用的数据”。模型本身很擅长聊天但你需要的是字段、数组、可入库的对象不是一段带解释的散文。有人习惯在提示词里加一句“请输出JSON”然后靠正则去抠花括号偶尔能成但数据一多、字段一复杂整个链路就开始到处漏。这篇文章把这件事完整讲透一次模型请求从组装消息、配置参数到接收响应、清洗文本、解析JSON、字段校验、失败重试最后拿到一个干净且类型正确的结构体。整个过程适合正在接大模型API做信息抽取、意图识别、资料录入这类需求的开发者也适合那些被模型“看起来成功了实际上没法用”坑过的人。1. 先把链路拆清楚单次模型请求从文本到字段要过多少道关很多人以为模型请求的链路就是“发一段文本过去模型返回JSON解析一下结束”。真正跑过生产环境的人都知道这个链条上每一个环节都可能让数据变形。所以第一步不是写代码而是先把这条链路从头到尾画出来搞清楚到底有哪些节点、每个节点在做什么、失败长什么样。1.1 一次请求的七个环节缺一个整体就不稳我习惯把一次模型请求拆成七个环节业务数据准备把原始文本、文档、用户输入做必要截断或清洗确保不超出模型上下文。消息组装把系统提示词、用户内容、参考示例按照顺序拼装成 messages。请求参数配置选模型、温度、max_tokens、输出格式约束可能还包括随机种子。发起调用与响应接收走 HTTP 或 SDK 请求得到原始 completion。原始文本清理去掉模型可能会加上的 json 代码块标记、多余前缀、空白字符。结构化解析把清理后的字符串用 json.loads 或解析器转成对象。校验与兜底校验字段是否存在、类型是否正确失败时决定重试还是标记异常。这七个环节里前三个属于请求侧后四个属于响应侧。很多人只在第2环节下功夫比如提示词写得特别长但第5、第6、第7环节完全靠侥幸一旦模型输出稍微不合规整条链路就断了。用一个不太准确但好记的比喻整条链路像寄快递提示词是打包参数是选择快递服务HTTP调用是运输清洗和解析是收件时验货最后校验是入库前复核。包裹运输再快收件时你没检查里面是不是你要的东西出了问题只能后面返工。模型调用也是这个道理。1.2 结构化输出有两条路线约束生成与后置解析现在做结构化输出业界基本分成两条路线适合不同场景。约束生成是指在请求参数层面直接告诉API“你必须返回JSON”。很多大模型服务提供了response_format参数你把类型指定成json_object或者json_schema模型在解码阶段就被约束成只会产生合法JSON结构。这条路线的优点是出错率低返回内容干净几乎不需要做复杂的清洗缺点是对接口能力有要求不是所有模型和平台都支持。后置解析是指你只在提示词里提一句“请输出JSON”拿到完整文本后自己想办法从里面提取JSON片段。这条路线兼容性最好任何能聊天的模型都能用代价是输出不稳定经常出现多余的说明文字、Markdown代码块、甚至字段被模型自作主张改掉的情况。我自己实践下来单次请求的成功率差距明显。用约束生成方式只要能调用成功解析出合法JSON的比例会非常高纯靠提示词加后置解析在字段数量超过八个或者文本内容很长时失败率会明显上升常见问题包括缺字段、多出描述、数组元素缺失。但是这里有个现实约束约束生成不是万能的很多自建模型或者第三方网关并不支持json_schema这种严格模式即便你传了response_format对方也可能直接忽略。所以更稳的做法不是二选一而是两层都做请求侧尽量打开约束响应侧依然保留清洗和校验逻辑。两边都有保障才叫完整链路。1.3 先想清楚失败语义再开始写代码链路里还有一个经常被忽视的点就是失败之后系统应该怎么表现。我在接第一个业务时犯过这样的错解析成功就返回结果解析失败就直接抛异常结果线上跑了一天有几十条任务卡在中间没有任何提示业务方完全不知道发生了什么。真正靠谱的设计是在动手前先定失败语义。通常有三类处理策略。第一类是解析失败重试适合模型偶尔抽风的情况成本可控效果直观。第二类是动态修复把模型上一次返回的非法JSON和报错信息重新发给模型让它“只输出修复后的JSON”适合偶尔的截断或格式问题。第三类是降级人工处理连续重试两次仍然失败就把原始请求存到一个待处理队列里不阻塞主流程记录好上下文方便人工或后续重放。这三者的选择标准其实很简单看成本。调用一次模型有token成本和时间成本如果重试能显著提升成功率那一次重试是值得的但如果模型本身不支持约束输出重试一百次也大概率还是同一个毛病这时就该走降级路径。把失败语义想清楚了链路才是完整的否则只是在“正常路径”上拼命优化异常路径依然裸奔。2. 请求这头怎么配提示词、参数、输出约束三位一体请求侧的准备工作决定了模型输出质量的七十成。很多人觉得让模型输出结构化数据就是提示词里加一句“不要废话输出JSON”实际上远远不够。提示词只是其中一块真正的稳定性要靠提示词、参数、接口约束三个变量一起控制。2.1 提示词要当成接口契约来写不是当成愿望来写先看一个反例。很多人这样写系统提示词“你是一个信息提取助手请提取合同中的甲方、乙方、金额并返回JSON格式。”这句话看着没问题实际运行里模型可能给你输出一段解释“好的以下是提取结果”然后才接JSON甚至JSON外面再包一层Markdown代码块。我推荐把提示词当成一份接口契约。至少包含三块内容角色与任务、JSON结构说明、严禁行为。举个例子在合同信息抽取场景里我会在系统提示词里写你是合同信息抽取引擎。用户会提供合同文本你需要从中抽取指定字段并按照给定JSON结构返回。 JSON结构示例 { contract_code: 字符串合同编号找不到则填 null, sign_date: 字符串格式YYYY-MM-DD找不到则填 null, party_a: 字符串甲方全称, party_b: 字符串乙方全称, total_amount: 数字合同总金额保留两位小数找不到则填 null, items: [字符串数组交付物或服务内容] } 硬性要求 1. 只输出JSON不要输出任何解释性文字。 2. 不要使用Markdown代码块包裹JSON。 3. 找不到的字段填null不要自己编造。 4. 保持字段名完全一致不要增加或减少字段。这样写的价值在于把“长什么样”这个信息直接塞进上下文。模型是概率模型它更有可能照着你给的模板生成而不是自己发挥。用户文本只是变量结构模板才是稳定约束。如果你希望模型输出的字段非常多建议在提示词里用一个压缩版JSON示例然后把更详细的字段描述放到schema参数里而不是全堆在提示词里烧token。2.2 参数配置背后是有逻辑的结构化任务不该给模型留即兴空间请求侧第二步是参数。在结构化输出这个场景里我的默认配置很简单temperature设置为0或尽量接近0top_p保持默认或设为1必要时固定seed。为什么这么做因为temperature控制的是采样的随机性结构化抽取任务需要的是稳定复现不是创意发挥。temperature越高同一个输入可能产生完全不同的表达方式字段顺序变了甚至类型都变了这对下游解析就是灾难。有朋友会问temperature设成0就一定不会错吗不确定但概率会显著降低。模型仍然是概率模型设置了0只是让采样每次都选最高概率的token不等于确定性算法。不过对于抽取类任务已经足够。如果接口不接受0可以设0.01或0.1但别超过0.3超过之后输出质量很容易劣化。另一个容易出问题的是max_tokens。很多人不设或设得很小结果长文本抽取的时候JSON被截断在半路解析必失败。计算方式要看场景如果抽取的字段少几百个token足够如果包含大段摘要或多数组字段就要预留更多空间。我的习惯是在估算输出长度的基础上增加百分之五十到一百的余量宁可多一点成本也不要截断后重试一次后者成本更高。还有一个细节非流式请求和流式请求对链路的影响不一样。在单个请求做结构化输出的场景我会优先用非流式接口也就是一次性拿完整completion。流式接口适合对话体验但对结构化解析来说你还要自己维护累积文本的拼接逻辑中间可能插入格式控制字符链路复杂度提升。如果业务没有“边生成边展示”的需求不要为了科技感而用流式。2.3 尽量打开接口层的强约束而不是自己造轮子如果平台支持结构化输出参数一定要用。很多兼容OpenAI协议的模型服务其实都支持response_format只是参数格式略有差异。最基础的是这样from openai import OpenAI client OpenAI( api_keyyour-api-key, base_urlhttps://your-endpoint.example.com/v1 ) response client.chat.completions.create( modelyour-model, temperature0, response_format{type: json_object}, messages[ {role: system, content: 你是一个合同信息抽取引擎只输出JSON。}, {role: user, content: text} ] ) raw_output response.choices[0].message.content当接口支持json_object时模型至少会被约束为“只输出JSON”这已经能解决大部分“带解释文字”的问题。如果平台进一步支持json_schema模式也就是把schema直接传给接口那我建议直接升级使用效果完全不同response client.chat.completions.create( modelyour-model, temperature0, response_format{ type: json_schema, json_schema: { name: contract_info, strict: True, schema: { type: object, properties: { contract_code: {type: [string, null]}, sign_date: {type: [string, null]}, party_a: {type: [string, null]}, total_amount: {type: [number, null]} }, required: [contract_code, sign_date, party_a, total_amount], additionalProperties: False } } }, messages[...] )用json_schema的时候有个注意点很多平台对strict有硬性要求schema必须包含required和additionalProperties否则直接报错。这其实是个好设计逼着你把字段边界写明白。拿到这种模式后模型的解码过程就被死死限制在schema框架内不会再出现“多出来的字段”或者“嵌套结构乱掉”的情况。function calling是另一个思路。如果你把这次请求包装成一个工具调用比如定义一个extract_contract_info函数让模型决定调用它并传参解析时读arguments字段就能拿到字符串后再解析。这个方法在Agent场景里非常常用但单次抽取任务里我比较少用因为它要求你把schema描述成函数参数语义上稍微绕一些。但如果你所在平台不支持response_format只支持工具调用那完全可以作为替代方案。3. 响应这头别轻视清洗、解析、校验、重试一个都不能少请求发出去只是上半场真正容易翻车的其实是响应回来之后。很多人的处理方式就是一个json.loads看着简单但实际返回的文本里什么情况都有直接解析大概率挂在路中间。整个响应侧的处理水平决定了你这条链路能撑多久。3.1 拿到模型原文后第一步先做归一化清洗即使加了response_format我也建议保留一个清洗函数。原因很简单网络层、SDK、网关、代理都可能在中间环节做手脚而且模型服务升级也可能导致行为变化。如果默认输入就是干净的清洗函数跑一遍只损失几毫秒但一旦出现脏输入清洗函数能救命。一个典型的脏输入长这样好的下面是提取结果 json { contract_code: HT2024-001, sign_date: 2024-06-18, ... }或者长这样 text { contract_code: HT2024-001, sign_date: 2024-06-18 } 这里如果有问题请随时告诉我。我的清洗逻辑分四步。第一步去掉可能存在的BOM和首尾空白字符。第二步检查有没有Markdown的代码块标记如果有就把外围的json和剥掉。第三步如果文本里混杂着非JSON文字尝试找到第一个左花括号位置从那里切片再找到最后一个右花括号只保留中间片段。第四步把可能存在的全角标点替换成半角以免某些代理层做了编码转换。这里有一个细节值得反复提醒简单用str.find({)找第一个花括号不安全因为可能正文里提到“根据《第3条第2款》”这种花括号出现在解释文字里。如果开头文字里既有花括号又有JSON切片起点就选错了。所以我一般先用代码块包裹标记来定边界再用“第一个左花括号且通过后续json.loads验证”的逻辑尝试多次而不是一次定死。3.2 解析成功不算成功字段级校验才是防火墙json.loads成功只说明你拿到一个合法JSON对象不代表字段内容是对的。模型可能把金额字段输出成字符串可能日期格式不符合约定可能某个必填字段压根没出现。这些情况json.loads完全不报错但下游代码一用就炸。所以我习惯在解析之后加一个字段级校验环节。这个环节可以用Pydantic做最方便也可以手写校验函数看项目复杂度。Pydantic的好处是你只需要定义一个模型类类型转换和校验自动完成from pydantic import BaseModel, Field, ValidationError from typing import Optional class ContractInfo(BaseModel): contract_code: Optional[str] sign_date: Optional[str] party_a: Optional[str] party_b: Optional[str] total_amount: Optional[float] Field(None, ge0) items: list[str] [] data json.loads(cleaned_text) try: contract ContractInfo(**data) except ValidationError as e: print(校验失败:, e.json()) else: print(校验通过:, contract.model_dump())比如字段total_amount在JSON里是字符串12345.67Pydantic会自动帮你转成浮点数这就是省心的地方。如果模型晚输出fields缺失pydantic会根据Optional的设置自动填None测试方便。到这一步之后你的下游代码就可以直接操作contract对象了。校验过程还要关注一些业务规则比如日期格式是否满足YYYY-MM-DD金额是否大于0数组里是否存在空字符串。这些规则你可以写在Pydantic的field_validator里也可以单独写一个业务校验函数。不要把业务规则混在pydantic类里否则维护起来非常头疼。3.3 失败后的重试不是无脑重发而是有方向的修复清洗和校验都做完之后就可能进入重试逻辑。重试最忌讳的就是直接把同一段请求原样重发一遍模型大概率犯一模一样的错误。重试时应该把上一次失败的信息带进去让模型知道错在哪里针对性修复。我常用的修复方式是这样的当原始输出解析失败时找出失败原因多数是“JSON截断”“存在多余文本”“字段类型错误”这几种。然后把新消息追加进messages告诉模型你上一次的输出无法被程序解析原因是JSON不完整或存在额外文本。 请直接输出完整的JSON确保能被json.loads正确解析。 字段名和数据来源保持一致不要添加任何解释。然后再请求一次。如果第二次还是失败但至少这次的结构接近合法比如只是缺了一个字段或某字段类型不对第三种做法是把Pydantic校验报错信息回传给模型让它按错误逐条修改输出。这本质上是一种“让模型自己debug自己”的做法在字段数量较多时成功率相当可观。但记住要给重试设置次数上限。我自己的默认值是总共尝试两次到三次超过就不要再烧token了直接进入人工队列或者标记异常。因为单次请求的时延本来就有几百毫秒到几秒连续失败五次就是十几秒对用户而言已经不可接受。做工程不是跑学术实验链路必须对时延和成本敏感。4. 端到端落地示例从一段合同文本到可写库的干净数据前面讲了很多零散细节这节把它们串成一个完整的可运行脚本。场景选一个最常见的输入合同原文输出合同编号、签约日期、甲乙双方、合同金额、服务项列表。整个过程严格走“请求前schema定义、请求中参数约束、响应后清洗解析校验重试”的链路。4.1 先定义业务Schema所有的约束都从它展开我把Schema定义为整个链路的“唯一事实源”提示词、请求参数、校验代码都围绕这份schema展开。这样做的好处是改字段只需要改一个地方。contract_schema { type: object, properties: { contract_code: {type: [string, null], description: 合同编号}, sign_date: {type: [string, null], description: 签订日期格式YYYY-MM-DD}, party_a: {type: [string, null], description: 甲方全称}, party_b: {type: [string, null], description: 乙方全称}, total_amount: {type: [number, null], description: 合同总金额单位元}, items: { type: array, items: {type: string}, description: 交付物或服务内容列表 } }, required: [contract_code, sign_date, party_a, party_b, total_amount, items], additionalProperties: False }contract_schema里的description字段一定不能省。它虽然不参与结构和类型强制但会被底层模型当成参考模型看到清晰描述后填错坑的概率会降低。比如sign_date如果把格式写明白输出“2024年6月18日”的概率就会低很多。4.2 完整实现一次请求的各个环节下面这段代码是完整示例按函数拆开方便你直接复制改成自己的业务import json import re from typing import Optional from openai import OpenAI from pydantic import BaseModel, Field, ValidationError # ---- 业务结构 ---- class ContractInfo(BaseModel): contract_code: Optional[str] None sign_date: Optional[str] None party_a: Optional[str] None party_b: Optional[str] None total_amount: Optional[float] Field(None, ge0) items: list[str] [] # ---- 客户端初始化 ---- client OpenAI( api_keyyour-api-key, base_urlhttps://your-endpoint.example.com/v1 ) # ---- 响应清洗 ---- def clean_model_output(raw: str) - str: if not raw: raise ValueError(empty response) text raw.strip() # 去掉BOM if text.startswith(\ufeff): text text[1:] # 去掉markdown代码块围栏 text re.sub(r^(?:json)?\s*|\s*$, , text.strip()) # 如果还有解释性文字尝试从第一个{开始 if not text.startswith({): start text.find({) if start ! -1: text text[start:] # 去掉包裹JSON的尾部文字只保留到最后一个} end text.rfind(}) if end ! -1: text text[: end 1] return text.strip() # ---- 请求主逻辑 ---- def extract_contract(text: str, max_retries: int 2) - ContractInfo: messages [ {role: system, content: ( 你是合同信息抽取引擎。只输出JSON。 不要输出解释性文字不要使用Markdown代码块。 )}, {role: user, content: text} ] for attempt in range(max_retries 1): try: response client.chat.completions.create( modelyour-model, temperature0, response_format{ type: json_schema, json_schema: { name: contract_info, strict: True, schema: contract_schema } }, messagesmessages ) raw response.choices[0].message.content # 如果有截断迹象直接放弃本轮转修复重试 finish_reason response.choices[0].finish_reason if finish_reason length: raise ValueError(output truncated by max_tokens) cleaned clean_model_output(raw) data json.loads(cleaned) contract ContractInfo(**data) return contract except (ValidationError, ValueError, json.JSONDecodeError) as e: # 触底之前把当前错误作为修复指令追加进上下文 if attempt max_retries: raise RuntimeError(fextract failed after {max_retries 1} attempts: {e}) from e messages.append({ role: assistant, content: raw if raw in dir() else 上一次输出无法解析 }) messages.append({ role: user, content: ( 你上一次的输出无法被程序正确解析错误是 str(e) 。请重新只输出一份能通过json.loads解析的完整JSON 不要增加任何字段不要输出解释文字。 ) })这个脚本把前文的四层逻辑都包进去了清洗函数clean_model_output负责任何脏文本的归一化Pydantic类负责字段校验异常处理负责触发重试重试消息会携带上一次的报错信息让模型了解问题并修正。一个需要注意的细节是我在重试消息里通过raw in dir()判断是否存在上一轮的raw变量这个过程在真实代码里比较绕。更好的做法是把raw变量在循环外先初始化为None每次请求后赋值重试时直接使用代码会更清晰。4.3 跑一次实际效果正常情况下链路各环节的输出我给你模拟一段合同文本然后按脚本跑一遍。输入文本内容简化为“2024年6月18日甲方北京某某科技有限公司与乙方上海某某贸易有限公司签订合同合同编号HT2024-0618合同总金额为人民币1234567.89元交付内容包括数据分析平台开发、运维培训。”模型返回的raw content如果被约束在json_schema下通常是干净的一行JSON。经过clean_model_output后它原样保留。然后json.loads解析再走Pydantic校验最终contract对象大致长这样ContractInfo( contract_codeHT2024-0618, sign_date2024-06-18, party_a北京某某科技有限公司, party_b上海某某贸易有限公司, total_amount1234567.89, items[数据分析平台开发, 运维培训] )到这一步这个对象就可以直接走ORM写入数据库或者继续进入下游业务逻辑。比较一下面向过程写法的区别如果你只拿到模型原文然后自己在业务里到处split字符串任何一次合同格式变化都会让你的代码崩溃。而现在只要schema定义得好模型输出格式被约束住了链路就是稳定的。5. 高频问题排查与现场避坑清单最后这部分我把实战中遇到的高频问题整理成速查表顺便把一些只有跑过生产环境才懂的细节写清楚。这些问题看起来都很小但每一个都可能导致整条链路返工。5.1 单次请求结构化输出的常见问题速查现象原因解决方向接口报400提示response_format不支持当前模型或网关不支持json_schema/json_object检查模型版本与接口文档换用function calling或纯提示词方案返回内容被截断解析失败max_tokens设置过小增加max_tokens同时检查finish_reason是否为length模型输出带json代码块平台没约束或提示词要求不够强加强清洗逻辑同时在提示词里明确禁止代码块JSON解析成功但必填字段缺失模型没理解schema或schema没设置required补全required列表重试消息里附带校验错误金额字段变成字符串1234567.89请求侧schema类型设了string或模型在自由输出模式修正schema类型为number用Pydantic自动转float提示词里写了JSON约束但完全没生效服务端忽略参数或messages配置不正确查看实际返回的content格式调整response_format必要时换更强约束方式每次重试都返回一样的问题模型本身不支持约束且重试没提供错误上下文直接放弃重试走降级人工队列这些问题的共同点是它们都不会在HTTP层报错只在逻辑层悄悄发生所以最容易被忽略。5.2 跑过几十万次请求之后我觉得最值得留意的六个细节第一输出约束参数能开就开开了能省掉大量解析层的脏活。json_schema比json_object约束力度更强如果平台支持别因为多写几行schema就放弃。为JSON Schema花的时间会在后续数据使用阶段几倍地赚回来。第二Pydantic校验阶段一定要写尤其字段类型和取值范围。哪怕是简单的Optional[str]也能在模型“自由发挥”时兜住一道。你想一下模型返回的total_amount如果是1234567.89元这种带单位字符串没有校验直接入库后面做统计的时候再发现代价就大了。第三重试时的错误信息必须足够具体。不要只发一句“你回答格式错误”。把json报错信息、缺失的字段名、期望的类型都告诉它。模型看到明确的报错修复成功率才会高。模糊的批评只会让模型再猜一次大概率还会犯同样的错。第四接口调用务必设置超时。模型服务偶发慢响应很正常单次请求如果允许无限等待会拖垮整个外层服务的线程池。超时时间根据业务容忍度设置我一般设30秒到60秒超时后直接按失败处理走重试或降级。第五日志要记录原始输出。不要只记录解析成功后的结构化数据。一旦线上出现脏数据排查时最需要的就是原始返回。如果日志里只有最终结果出了问题根本没法定位是模型的问题还是解析逻辑的问题。我会在每次调用后面加一条info日志包含request_id、模型名、原始content和parse结果排查效率会高很多。第六如果你们的平台用的是兼容OpenAI的SDK但底层走的是某个网关建议先拿一条请求直接curl看原始返回体。因为有些网关会改写response_format参数或者把json_schema描述丢了。用curl验证一次实际行为比看文档推断靠谱得多。链路检查单把这次完整链路沉淀成一张检查单以后接任何模型服务都能直接参照请求前定义好Schema系统提示词写清楚“只输出JSON、字段名不增不减、找不到填null”。请求中temperature设0或接近0max_tokens留足余量response_format能开就开。响应后先做清洗再去掉Markdown和多余前缀再做JSON解析再做Pydantic字段校验。失败后把具体报错信息带回给模型做一次修复重试最多两次超限走降级。全程记录原始输出日志方便事后排查。这套链路我一直在用刚开始会觉得步骤多、代码复杂但跑一段时间之后就会觉得模型的不可控性才是真正的变量而我们能做的就是把变量之外的所有环节都变成确定性流程。你不需要每次调用都走完整套复杂代码但至少要明确你的链路里缺了哪一环以及它会在什么场景下让你翻车。