资讯动态

结构化输出问答器:用Function Calling与Pydantic让大模型输出稳定JSON

发布时间:2026/10/27 15:39:37 来源:尧图企业网站定制
前几篇写了不少Agent落地姿势这篇聊一个更偏工程细节、但早晚绕不开的话题结构化输出问答器。简单说就是让大模型在回答用户问题时不只输出一段好看的话而是返回一份严格符合字段定义的结构化数据比如answer、confidence、sources、category这些固定字段。为什么非要这么折腾因为真实业务里模型自由生成的文本没法直接对接下游系统你要入库、要转工单、要渲染前端卡片、要做会话分析通通需要拿到字段值而不是一段散文。这篇我把结构化输出问答器的设计思路、实现方案、踩过的坑和可运行的代码一起列清楚给同样在搞Agent应用的朋友做个参考。先给这篇文章划一个范围。我默认你已经有基础的Prompt工程经验了解Agent大概是什么形态并且至少跑通过一个大模型API。文章会用Python写示例核心库是openai和pydantic。如果你用的是其他语言或框架也不要紧核心思路完全可以平移先把Schema定死再让模型在约束里作答。1. 为什么问答器需要“结构化输出”很多做Agent的同学第一次尝试都是让模型直接回答然后自己在代码里用正则去抠关键信息。我一开始也这么干过结果就是被五花八门的表达方式折腾到怀疑人生。这个阶段最需要想清楚的就是“模型输出”和“程序可用”之间那几十行解析代码到底应该谁让步。1.1 自由文本回答的三大坑第一个坑是程序没法直接消费。用户问“这款咖啡机支持几个研磨档位”模型回答“这款咖啡机支持5个不同的研磨档位磨豆粗细可以自由调节”。人看这句话毫无障碍但程序如果想把这个“5”存进商品字段就得写正则或者用NLP去猜。你可能会想总共就一个数字正则一下不就行了问题是真实场景还有“五档”“五个档位”“支持5种研磨程度”这类变体每加一种变体解析规则就多一层if-else。第二个坑是格式不稳定。同一个问题模型今天回答是“产品参数如下…”明天可能就是“这款咖啡机的研磨档位为…”。如果你的下游代码依赖某种固定的文本结构比如按冒号切分、按标点定位模型的表达方式一变解析就崩。这类问题在测试集上永远测不出来因为它不是逻辑错而是“人觉得差不多程序觉得完全不一样”。第三个坑是没法跟业务流程无缝衔接。问答器不是只回答就算完通常还要把结果写进工单、推送给人审、在页面上渲染成一个卡片。文本回答想完成这些事情中间要加一层不可靠的“解析格式化”。我做过的项目里这层解析代码往往是Bug率最高的地方。所谓结构化输出本质就是把“解析”这个不可控环节往前挪挪到模型生成那一侧去约束。1.2 结构化输出的本质约束生成空间要理解结构化输出先得理解它不是什么。它不是简简单单在提示词末尾加一句“请用JSON格式返回”。这句话的意思是告诉模型“我比较想要JSON”模型大概率也会照做但模型并不理解你的JSON必须有哪些字段、字段值必须是哪些类型。很多所谓“结构化输出”的失败案例其实都栽在这模型输出了合法JSON但字段要么缺了、要么类型不对、要么枚举值乱写。从原理上看结构化输出是“约束模型的生成空间”。模型的底层是预测下一个token如果不加约束它每个token都在整个词表里挑加了JSON Schema或者工具调用协议就相当于告诉模型你只能在合法的token序列里走。不同方案的约束强度也不同这个下面会展开。理解这一点你就明白为什么光靠提示词不够必须配合API层级的机制再加一层运行时校验才敢把结果拿去做业务。1.3 主流实现方案对比我把实际用过的结构化输出方案拉了个清单按约束力度从弱到强排方案约束力度典型场景坑点提示词里写“请用JSON返回”极弱靠模型自觉内部工具、一次性脚本字段不稳定、偶尔带解释文字JSON Moderesponse_formatjson_object中等保证合法JSON只要格式合法、字段不敏感不保证字段完整、需要手动校验Function Calling / 工具调用较强模型按函数参数填JSONAgent工具链、结构化问答需要处理多轮工具调用解析器 Pydantic校验 重试强运行时强制生产环境、高稳定性要求增加一次重试开销实际项目里我不会只用其中一种。比如Agent场景通常整个会话过程用Function Calling去管理工具调用等最后要产出正式回答时再强制模型调用一个预定义好的respond函数——这个函数的参数Schema就是我的业务Schema。然后Pydantic在代码侧做强校验校验失败就把错误信息喂回给模型要求修正。这套“API约束运行时校验失败重试”的组合是我目前测下来最稳的路径。2. 问答器整体设计先定结构再写Prompt我踩过好几次坑之后总结出一条经验问答器想落地第一步不是调Prompt而是先把输出Schema定下来。因为你连“模型要往哪些字段里填东西”都没想清楚提示词写得再花哨也没用。2.1 场景定义做一个商品信息问答器为了不让文章变成纸上谈兵我整篇用一个具体场景串着说做一个商品信息问答器。假设线上平台卖咖啡机用户会问各种问题比如“这款咖啡机支持几个研磨档位”“水箱容量多大”“有什么赠品”“保修期多久”。问答器需要根据商品资料库回答问题并且把结果结构化输出给前端和业务系统。这个场景选得比较典型理由有三第一问题类型相对集中适合验证分类能力第二必须检索商品资料不是纯靠模型记忆能体现Agent的工具调用第三下游要对结果做渲染、评分、转工单正好能体现结构化输出的价值。2.2 响应结构设计一个最小可用的JSON Schema设计字段的时候我建议你先列一个问题清单“我这个问答器的结果给谁用他们分别需要哪些字段”不要一上来就堆二十个字段能少就少每个字段都要有业务意义。基于商品问答这个场景我定了一版最小但完整的结构字段类型说明answerstring给用户看的正式回答控制在200字内confidencefloat置信度0到1用于判断是否需要人工复核categorystring问题分类取值为price/service/spec/otherneeds_infoboolean是否需要向用户追问信息related_questionsarray[string]用户可能继续追问的相关问题最多3个sourcesarray[object]回答引用的信息源最多2条含title和url每个字段都不是随便加的。answer是主输出前端直接渲染confidence决定系统要不要做人工兜底category方便路由到不同业务线needs_info解决的是用户问题本身含糊的情况比如用户问“这个好用吗”你得先反问“您更看重哪些方面”related_questions用来拉起多轮对话sources用于追溯答案来源、降低幻觉影响。字段设计本质上是业务设计不是你Prompt里想怎么写就怎么写的。2.3 为什么用Pydantic做响应模型我选Pydantic不仅仅是图它写起来短而是它有一个关键优势一份模型定义既可以序列化成JSON Schema传给模型又可以在代码里做运行时校验。换句话说“告诉模型应该输出什么”和“检查模型输出得对不对”这两件事用的是同一份定义完全同步。from pydantic import BaseModel, Field class Source(BaseModel): title: str Field(description信息来源标题) url: str Field(description信息来源链接没有则传空字符串) class QaResponse(BaseModel): answer: str Field(description对用户问题的直接回答200字以内) confidence: float Field(description回答置信度0到1之间) category: str Field(description问题分类只能是price/service/spec/other之一) needs_info: bool Field(description是否需要向用户追问详细信息) related_questions: list[str] Field(description用户可能继续追问的问题最多3个) sources: list[Source] Field(description回答引用的信息源最多2条)这个类定义完你可以用QaResponse.model_json_schema()直接生成一份JSON Schema塞给模型。等模型返回后你又可以用QaResponse.model_validate(json.loads(...))做校验。模型敢把category写成“规格”Pydantic直接抛ValidationError代码侧立刻捕获。这种“定义即标准”的体验用多了就会上瘾。2.4 提示词怎么写才能让模型稳定输出有了Schema提示词的重点就不是“请你用JSON”而是“把字段的口径讲清楚”。模型大部分时候不是故意乱填而是它根本搞不懂字段的语义边界。比如category只有四个枚举值你如果不解释清楚“service指售后相关spec指产品参数”模型就会把“有没有赠品”猜成“service”其实它应该进price或other。我会在系统提示词里把这些口径写明白再用一个few-shot示例。下面是我实际用的一段Prompt骨架你是一个商品信息问答Agent。请根据用户问题和检索到的商品资料从字段层面完成回答。 输出要求 - 严格遵循给定的JSON Schema不要输出Schema之外的字段。 - answer字段要直接、可读不要出现“根据资料”这类口头语。 - confidence低于0.6时needs_info必须为true。 - category只能是price/service/spec/other中的一个价格、优惠、赠品属于price售后、保修属于service参数、功能属于spec其他归other。 - 如果检索信息不足以回答answer写“抱歉暂时没有查到相关信息”confidence填0.1。 回答示例 {answer: 这款咖啡机支持5档研磨调节。, confidence: 0.9, category: spec, needs_info: false, related_questions: [水箱容量是多大, 支持哪些冲泡模式], sources: [{title: 商品详情页, url: https://...}]}注意“confidence低于0.6时needs_info必须为true”这种规则写进Prompt里比写在代码里有用得多因为模型自己能感知到不确定性。另外我已经把temperature调到0.2结构化输出场景温度越高越容易飘尤其枚举字段。3. 完整实操从零搭建结构化输出问答器3.1 环境准备示例代码基于Python 3.11依赖只有两个openai和pydantic。装好之后在环境变量里配好OPENAI_API_KEY即可。如果你用的是国内兼容OpenAI接口的服务也只改base_url就行后面的代码不用动。pip install openai pydantic3.2 定义响应模型响应模型就是上一节那段代码直接复用。我用的是QaResponse.model_json_schema()来生成Schema。有一点需要提醒生成出的Schema里title等字段会自动补全传给模型前可以精简一下把title去掉只留type、properties、required、description、enum模型反而更容易读。3.3 给Agent加一个“查资料”的工具问答器不能只靠模型记忆否则用户问一个不在训练数据里的商品就完了。我写了一个极简的商品检索函数它定义成一个工具Agent在回答前可以先调用它拿资料。这个工具不需要真连数据库演示时用一个字典模拟商品库PRODUCT_DB { cafe_a: { name: A型意式咖啡机, grinder_levels: 5, tank_capacity: 2.0L, warranty: 2年整机保修, gift: 送咖啡豆礼盒, } } def search_product(keyword: str) - str: # 真实场景这里会查数据库demo直接按关键词匹配 if 咖啡机 in keyword: return json.dumps(PRODUCT_DB[cafe_a], ensure_asciiFalse) return json.dumps({error: no data}, ensure_asciiFalse)工具定义就是标准的Function Calling格式description尽量写清楚参数含义。这里有一个经验工具函数返回的字符串模型会拿它做下一步推理所以返回前最好序列化成JSON别返回乱七八糟的文本。3.4 核心运行循环让模型先查资料再输出结构问答器的核心循环不是“调用一次模型拿结果”这么简单。用户问一个问题Agent可能需要先调search_product拿到资料后再生成最终回答。我把这个循环拆成了三步把用户问题和系统提示词发给模型同时声明search_product和respond两个工具。如果模型返回search_product的工具调用就执行它把结果追加到消息列表再调一次模型。模型最终必须调用respond我通过tool_choice强制指定再从respond的参数里拿到结构化答案。第二步和第三步之间我用了一个巧妙的方法第一轮调用tool_choice设为auto让模型自由决定要不要查资料等它查完资料第二轮调用时再把tool_choice强制到respond函数上。这样既保证Agent会先检索又保证最终输出一定走结构化通道。def ask_agent(question: str): messages [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: question}, ] tools [search_product_tool(), respond_tool()] # 第一轮让模型决定是否检索 resp1 client.chat.completions.create( modelMODEL, messagesmessages, toolstools, tool_choiceauto, temperature0.2, ) msg1 resp1.choices[0].message messages.append(msg1) # 执行检索工具 if msg1.tool_calls: for tc in msg1.tool_calls: if tc.function.name search_product: args json.loads(tc.function.arguments) result search_product(args.get(keyword, )) messages.append({ role: tool, tool_call_id: tc.id, content: result, }) # 第二轮强制结构化输出 resp2 client.chat.completions.create( modelMODEL, messagesmessages, toolstools, tool_choice{type: function, function: {name: respond}}, temperature0.2, ) msg2 resp2.choices[0].message tc2 msg2.tool_calls[0] payload json.loads(tc2.function.arguments) return QaResponse.model_validate(payload)这个模式我建议你直接背下来它就是“Agent工具调用 结构化收口”的通用骨架。很多复杂Agent本质都是在循环里反复插入工具调用最后用类似respond的收口函数结束对话。3.5 自动重试校验失败别慌让模型自己改Pydantic校验失败是常态不是异常。模型填错了枚举、把confidence填成字符串、漏了required字段都会抛ValidationError。我的处理方式很暴力但有效捕获错误把错误描述附加到对话里让模型重新生成一次。try: return QaResponse.model_validate(payload) except ValidationError as e: messages.append({ role: user, content: f你上一次输出的JSON不符合Schema要求错误如下\n{e}\n请重新输出务必严格匹配字段类型和枚举值。, }) resp3 client.chat.completions.create( modelMODEL, messagesmessages, toolstools, tool_choice{type: function, function: {name: respond}}, temperature0.2, ) payload3 json.loads(resp3.choices[0].message.tool_calls[0].function.arguments) return QaResponse.model_validate(payload3)注意重试上限设两次别死循环。模型连续两次都格式错大概率是提示词口径有问题别再硬试了应该去调整Prompt或者检查Schema里有没有自相矛盾的要求。3.6 全家桶跑一遍看效果把上面这些拼起来main函数就长这样def main(): q input(请输入问题) result ask_agent(q) print(result.model_dump_json(indent2, ensure_asciiFalse)) if __name__ __main__: main()实测输入“这款咖啡机支持几个研磨档位”输出类似{ answer: 这款咖啡机支持5档研磨调节。, confidence: 0.9, category: spec, needs_info: false, related_questions: [水箱容量是多大, 支持哪些冲泡模式, 保修多久], sources: [{title: 商品详情页, url: https://...}] }到这里一个最简结构化输出问答器已经能跑通了。4. 常见问题与排查技巧实录4.1 JSON解析失败的三个典型场景即使你用了tool_choice强制调用respond也偶尔会遇到解析异常。最常见的一种是模型在JSON前后附加了说明文字比如arguments字段里出现“好的这是你要的JSON{...}”。用json.loads会抛错我一般在解析失败时先把首尾大括号外的内容剔除或者用正则把第一个{到最后一个}之间的内容截出来再做json.loads。另一种是返回空字符串通常是因为模型第一轮没查资料就直接调respond这时可以检查一下工具调用的顺序。还有一种情况更容易被忽略模型调用了search_product但工具参数解析失败。我用json.loads(tc.function.arguments)解析工具参数时也加了try失败就回填一个默认参数。对Agent应用来说任何一步解析异常都不应该让整个对话崩溃要么重试一次要么降级成不带数据库的纯模型回答。4.2 字段内容“看起来对但类型不对”这个坑我印象太深了。模型输出confidence: 0.9看着没问题但有时候它会输出confidence: 0.9字符串类型Pydantic直接报错。还有related_questions输出成字符串而不是数组。这类问题根源不全是模型笨很多时候是Schema里description给得太抽象。你在description里写“置信度0到1”不如写“数字类型例如0.8”。如果想进一步压制这类错误给枚举字段写examples很管用。比如category字段加examples: [price, spec]模型的注意力会被引到合法取值上。字段层面的描述写得越具体Pydantic校验错误就越少。4.3 多工具调用和结构化输出“打架”如果你直接在第一轮就设置tool_choice强制respond模型就不会查资料因为它没有机会“先想一下要不要调工具”。这个问题我碰到后调整了策略第一轮auto放行工具调用等工具结果全部回来再进入第二轮强制respond。相当于把一个Agent决策过程拆成了两个阶段先开放式推理再严格收口。还有一个细节如果第一轮模型返回了多个工具调用比如既查了商品库又查了库存你需要把所有工具结果都追加进messages再发起第二轮。漏掉任意一个tool_call_idAPI都会报错。我在代码里用一个循环处理全部tool_calls而不是只处理第一个。4.4 输出被截断怎么办结构化输出一般不会太长但answer字段如果没限制字数偶尔也会触发max_tokens截断。截断最典型的后果是JSON字符串不完整解析直接失败。我的处理分三层第一Schema里给answer字段描述加上“200字以内”从源头上让模型别啰嗦第二调用参数里把max_tokens设到1500以上给JSON和回答留够余量第三解析失败后触发重试时在消息里加一句“上次回答被截断请精简回答”再让模型来一次。4.5 排查问题速查表现象可能原因解决办法JSON解析失败模型加了额外文字截取首尾大括号内容再解析必填字段缺失Schema的required没生效检查是否用model_json_schema()生成字段类型错误description太抽象写清楚类型和示例值reply不查资料直接答tool_choice阶段设置太早先auto工具调用再强制respond输出被截断max_tokens太小调大上限压缩answer字数校验连续失败Schema要求冲突检查枚举和字段语义是否有歧义5. 从Demo到可上线还需要补哪些功课5.1 评估结构化输出不是“能解析”就行你可能会觉得Pydantic校验过了就万事大吉但校验通过只代表格式对不代表语义对。模型把category填成了price但内容实际是specPydantic没意见业务系统却会送错工单。所以我建议搞一个小规模评测集比如50条用户问题每一条人工标注期望的category、needs_info、sources是否合理。然后跑一轮算字段级别的准确率。这个步骤很费人力但做一次能让后续优化都有据可依。评测的时候我还会关注一个指标模型在不该给高置信度的时候是否乱填高值。理想状态是资料充足给0.9资料不足给0.1。如果模型普遍给0.9说明prompt里对置信度的定义还不够明确需要强调“confidence表达的是资料完备度不是回答流畅度”。5.2 成本和延迟优化结构化输出多一轮强制respond必然会增加一次模型调用重试又可能再加一次。真要上线我一般会做几件优化第一如果系统提示词和工具定义都很大消息列表会越来越长尽量控制历史消息条数比如只保留最近五轮第二工具定义里去掉不必要字段的description能省token第三重试请求的max_tokens可以设得更小给修正输出留的token不用太多。延迟方面实测下来结构化的extra round trip大概多花200到500毫秒取决于模型和上下文长度。能接受就是最优解不能接受的话可以参考一些开源方案对输出层做严格JSON生成约束但那样会牺牲模型的通用性和灵活性有利有弊。5.3 安全与业务兜底结构化输出问答器上线前安全是要单独过一遍的。用户如果问“怎么改装咖啡机才能跳过温控”模型应该拒绝回答而不是真去找资料。我做法是System Prompt里加一层兜底规则涉及人身安全、违法、伤害性内容时直接回答“抱歉这个问题我无法回答”category填otherconfidence填0。同时在代码侧对sources字段做域名白名单校验防止模型引用不存在的来源。needs_info字段本身就是一种兜底机制。当模型对用户意图不确定时与其硬答不如返回一个追问意图。我在前端会基于needs_info渲染一个“需要补充信息”的交互态引导用户把问题说清楚而不是展示一段猜测性回答。5.4 框架选型补充如果你不是从零写代码而是用LangChain、LlamaIndex这类框架它们大多带了结构化输出能力比如LangChain的with_structured_output内部就是“工具调用/JSON Mode Pydantic解析”的封装。我的建议是框架自带的能力够用就直接用但如果你在做深度Agent编排需要自己控制工具调用节奏那我更推荐自己写一遍这个核心循环。这个过程能帮你弄清楚上游框架帮你做了什么出了问题排查起来心里也有底。我自己写这个项目时就是从框架切回原生API才彻底弄明白tool_choice和参数校验之间的关系。如果你用的是Dify、CrewAI这类更上层的Agent工作台同样要面对结构化输出这个问题。工作台一般提供“输出字段定义”之类的可视化配置本质上和我上面说的定义Schema是一回事。理解了原理到哪个平台都能快速上手。最后再分享一点个人经验。结构化输出问答器我前前后后写了不止一版最大的感受是“结构先行”这四个字值很多钱。不要一上来就追求模型回复多生动、多自然先把回答要进的字段定清楚把字段口径写明白把校验和重试机制铺好剩下的事情就顺了。哪怕最后你的机器人口吻很朴实只要数据是结构化的、稳定的下游同事就会感谢你。那些花哨的表达等你把结构稳住了再加也不迟。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑