ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Agent结构化输出问答器实战:用JSON Schema约束大模型输出

Agent结构化输出问答器实战:用JSON Schema约束大模型输出 做Agent落地的时候最让人头疼的往往不是模型回答不了问题而是它回答得太自由。你问一个天气问题它能回你三百字小作文里面藏着城市、温度、穿衣建议、紫外线指数……人读着没问题但程序要拿去调API、存数据库、触发下一步动作就得写一堆正则和if-else去考古那篇小作文。我这个Agent实践4-结构化输出问答器项目就是把这层问题一次性解决掉——让大模型严格按照预定义schema吐JSON回答什么字段、字段是什么类型、取值范围多大全部可预期。适合正在做Agent应用的开发者尤其是当你的Agent要接后端服务、要持久化对话记录、要供上游系统读取结果时结构化输出几乎是必经之路。做Agent的朋友应该都有过这种经历模型回答能力很强但输出格式不可控。我在做客服问答Agent时光是处理今天天气适合跑步吗这种问题的回答格式就写了几十行解析逻辑最后还是被各种边角料打穿。后来我才意识到根源不在于模型不够聪明而在于我没给它一张答题卡。结构化输出要解决的核心问题就是让LLM在生成阶段就只走合法的JSON路径而不是生成完再靠正则去收拾残局。这个项目不算复杂但踩坑密度很高。下面把设计与实现完整拆开讲包括为什么选JSON Schema方案、Pydantic定义Schema的正确姿势、重试策略怎么写、本地模型和云端模型的差异以及我在测试集和成本控制上的一些实测数据。1. 项目概述与整体设计思路1.1 为什么问答器必须做结构化输出很多人觉得问答器只要能答上来就行输出格式无所谓。真做起来就会发现这个想法会在接下游系统时把你坑到怀疑人生。举个例子你要把问答结果存进数据库字段里有一个置信度一个分类标签一个参考来源列表。自由文本模式下模型可能把置信度写成大概率、分类写成这属于技术类问题吧、来源列成一段叙述文字。你为了从这些内容里提取结构化字段要么写一堆脆弱的规则去匹配要么把字符串抛给另一个模型做二次格式化——成本和延迟都上来了准确性还未必达标。结构化输出解决的是程序消费模型结果这个环节的问题。它不是在模型生成完之后做一次校验而是在生成过程中就约束采样空间。你可以把它理解成考试只允许按模板答题该写数字的格子只能吐数字该选枚举值的地方只能从给定选项里挑一个。这样一来下游代码面对的永远是干净、合法、字段完整的JSON对象不需要再做任何考古工作。1.2 问答器的整体架构设计这个项目的架构不复杂但边界很清晰。我把整个流程拆成四层输入层接收用户问题做基础清洗空值、超长、重复提问携带对话历史上下文。推理层把问题与上下文组装成messages发给大模型同时带上结构化输出约束。校验层拿返回的JSON做Pydantic的model_validate校验失败进入重试。消费层把校验通过后的对象交给下游比如写入数据库、渲染到前端、触发下游工具或统计。这里有个关键设计推理层和校验层必须分离。推理层负责尽可能生成符合要求的JSON校验层负责确保拿到的对象一定是合法的。两个环节任何一环出问题都不会让脏对象污染下游业务。别把校验逻辑塞进提示词里让模型保证也别让校验层去做修复重试是更省心的路径。1.3 技术选型时的几个备选方案做结构化输出市面上常见有三条路我早期两个小项目里都试过感受完全不同。第一条是纯提示词约束。在system里写你必须输出JSON格式为……。零配置、模型随便换但事实是哪怕最强的模型在复杂嵌套、枚举字段多的时候也会犯浑输出markdown代码块、字段名大小写出错、多一个逗号全都有可能。适合原型验证不适合生产。第二条是JSON Mode很多模型服务商提供强制输出合法JSON的模式。OpenAI的response_format选json_object就是这么个东西。它保证语法合法的JSON但不保证字段和你的预期对齐。比如你要一个confidence数字它可能给你一个高。只解决了一半问题。第三条就是本项目用的JSON Schema约束模式。OpenAI叫json_schemaOllama的format参数填schema字典走的则是底层grammar约束。它既保证语法合法又保证字段级别对齐是当前生产级Agent最值得投入的一条路。后面所有实操都围绕这个方案展开。2. 核心细节解析与关键技术原理2.1 response_format参数究竟做了什么以OpenAI为例create接口里传response_format{type: json_schema, ...}。当你开启strict: true时服务端不是生成完之后给你打个分而是在生成阶段就做约束解码。可以这样理解模型本来在每个位置可以选几万个token现在这个位置只允许选schema允许的那几十个。比如schema规定confidence是number那模型在生成这个字段值的时候只能吐数字和点号它想吐个高字都吐不出来。这点实测下来差距很明显。我在同一组测试问题上跑过对比纯提示词约束的合法率大概93%JSON Mode的合法率接近98%但字段对齐率低而json_schemastrict基本做到100%合法只有极少数服务端超时或上下文溢出导致的失败。做Agent的人应该明白这差别的分量——下游每碰到一次脏输出就要多一层防御和重试生产稳定性全靠这个兜底。2.2 用Pydantic定义Schema的正确姿势我强烈建议用Pydantic定义Schema而不是手写JSON Schema字典。原因很朴素Pydantic的类定义本身就是校验层的类型标准同一个模型类既能导出schema给模型服务端又能拿来做model_validate一处定义、两处使用天然不会出现schema和校验逻辑不一致这种Bug。定义时有个细节要特别注意Field()里的description不要只写给人看它会被直接塞进prompt变成模型理解字段语义的关键。比如from typing import List, Literal from pydantic import BaseModel, Field class QAAnswer(BaseModel): question_id: str Field(description用户问题的唯一编号原样返回) answer: str Field(description对问题的直接回答控制在300字以内) topic: Literal[tech, life, career, other] Field(description按问题内容划分类别) confidence: float Field(description回答确定程度介于0和1之间, ge0, le1) sources: List[str] Field(description参考来源列表没有则返回空列表)description写得越具体模型在生成时越不容易跑偏。尤其原样返回空列表这种指令性描述对模型很有效。用英文还是中文其实都行但我实测下来中文描述配合中文问答场景表现更好英文描述在部分模型上会触发更格式化的回答风格。2.3 strict模式的约束边界json_schema的strict模式对schema本身有要求不注意就会收到服务端400。OpenAI要求严格模式下对象类型必须写明所有字段properties且additionalProperties必须为falseschema里不能出现anyOf、oneOf、allOf这类组合类型每个字段都必须明确是否在required里。翻译成大白话别想用或者这种弹性表达所有字段类型都要是确定性的。另外strict模式下的Optional字段有一个坑。你可能会用Optional[str]表示这个字段可以没有但严格模式要求所有properties都被列全同时用required标记哪些是必填。我的做法是能少用Optional就少用实在可空就把类型写成Optional并在description里写没有则返回null客户端校验时再处理None分支。2.4 流式输出要不要做问答器场景里很多人想上流式让用户看到字一个一个蹦出来。我的建议是如果下游要消费结构化结果流式就别硬上。因为流式返回的chunk是零散的字符串碎片你要把它们拼完整再等生成结束才能解析成JSON中间没有任何收益。前端展示最多用一下正在生成的loading没必要为流式付出额外的组装和中断恢复成本。如果你确实想要体验层面的进度展示有个折中方案让模型先输出一段面向用户的文字摘要再输出JSON小节。但这样schema里就得加一个summary字段模型输出会变长token成本上浮。我在一个医疗咨询类问答器里试过双段式输出效果不错但复杂度和成本都比纯结构化高一个档次小项目不建议一上来就上。3. 实操过程从零搭一个结构化输出问答器3.1 环境准备与依赖安装这个项目对硬件没啥要求普通开发机就能跑。我用的是Python 3.11依赖很简洁openai、pydantic、python-dotenv、pytest。安装就一行命令pip install openai pydantic python-dotenv pytest注意openai库版本尽量大于1.30因为response_format的json_schema严格模式在旧版本SDK里支持不完整有些参数会被静默忽略很容易排查半天发现是SDK版本太老。API Key写在.env里用python-dotenv加载别硬编码在代码里。from dotenv import load_dotenv load_dotenv()3.2 定义输出Schema并导出接着定义业务需要的语义模型。我拿通用知识问答来做例子字段包含答案、分类、置信度和来源。为了测试严谨性我故意加了一个枚举字段和一个带范围的数值字段这样能验证模型是否真的遵守约束。from typing import List, Literal from pydantic import BaseModel, Field class QAAnswer(BaseModel): question_id: str Field(description问题编号原样返回) answer: str Field(description直接回答控制篇幅) category: Literal[tech, life, career, other] Field(description问题所属分类只能取枚举值之一) confidence: float Field(description确定度0到1之间, ge0, le1) sources: List[str] Field(description参考来源标题列表无来源则返回[]) schema_dict QAAnswer.model_json_schema()导出出来的schema_dict就是模型服务端要用的schema。有一点要注意如果SDK严格要求schema里的properties不能包含额外字段Pydantic默认导出是满足的但如果你手写了额外keystrict模式下会报错。所以尽量直接吃model_json_schema()的输出不要二次加工。3.3 实现问答器核心逻辑核心的ask方法就是把messages组装好调create接口把返回的content用pydantic校验。这里我把历史消息也接进去了让问答器支持简单的多轮上下文。import json from openai import OpenAI from pydantic import ValidationError client OpenAI() class StructuredQABot: def __init__(self, modelgpt-4o-mini, max_retries2): self.model model self.max_retries max_retries self.history [] def ask(self, question: str, question_id: str q1): user_payload f问题{question}\n问题编号{question_id} messages [ {role: system, content: 你是一个问答引擎严格按照给定Schema输出JSON。问题编号必须原样返回。} ] messages.extend(self.history) messages.append({role: user, content: user_payload}) content None for attempt in range(self.max_retries 1): try: resp client.chat.completions.create( modelself.model, messagesmessages, response_format{ type: json_schema, json_schema: { name: qa_answer, schema: schema_dict, strict: True, }, }, ) content resp.choices[0].message.content data json.loads(content) parsed QAAnswer.model_validate(data) break except (json.JSONDecodeError, ValidationError) as e: if attempt self.max_retries: raise if content is not None: messages.append({role: assistant, content: content}) messages.append({role: user, content: f刚才的JSON解析失败{e}请重新输出JSON。}) self.history.append({role: user, content: user_payload}) self.history.append({role: assistant, content: json.dumps(parsed.model_dump(), ensure_asciiFalse)}) return parsed这段代码里我把校验失败的反馈重新拼回messages让模型在下一次尝试里参考错误信息修正输出。实测这个策略对JSON语法类错误很管用。由于strict模式基本能阻止语义错误这个重试分支平时很少触发但保留着能兜住极端情况。3.4 多轮上下文与历史消息处理多轮场景有个容易踩的坑assistant历史消息是JSON字符串它会污染后续生成的风格。严格模式可以兜底但如果模型能力偏弱比如本地7B模型它可能在历史里学到错误的输出习惯。我的处理办法如果场景对格式敏感就把历史里的assistant消息摘要成自然语言再拼回去比如你上一轮的回答摘要主题是技术置信度为0.9。这样模型继续对话时就不会照着JSON格式回答。我抽查过gpt-4o-mini和qwen2.5:7b两个模型的表现gpt-4o-mini在纯JSON历史下几乎无感qwen2.5:7b在历史里塞过多JSON后回答结构会偶尔飘向代码块包裹JSON。所以本地模型的用户历史摘要处理不是可选优化而是必选项。3.5 用pytest覆盖边界场景我不太信任人工问答几次没问题这种验证方式。结构化输出这种项目边界情况特别多必须用脚本批量跑。我写了三个维度的测试单轮合法、多轮上下文一致性、异常输入兜底。测试的关键就是assert返回对象的字段类型与枚举值。def test_basic_answer_is_structured(): bot StructuredQABot() result bot.ask(什么是Agent, q1) assert result.question_id q1 assert result.category in [tech, life, career, other] assert 0 result.confidence 1 def test_empty_source_list(): bot StructuredQABot() result bot.ask(今天几号, q2) assert isinstance(result.sources, list)这类测试跑起来很快几十个case几分钟就完成。我习惯把历史里出现过的坏case固化成测试集每次调整schema或者换模型之后先跑一回归能省掉大量手工验证时间。4. 常见问题与排查技巧实录4.1 JSON解析失败却查不出原因这类问题在json_schema严格模式下很少见但一旦出现就很烦人。我在项目里遇到过一次诡异情况接口明明返回的content在网页上看起来是合法JSONjson.loads却报错。后来打印了content的repr()才发现字符串开头多了一个不可见的BOM字符。处理方式是先strip()再解析如果还有问题就再检查首尾字符的repr。这个教训也让我养成了习惯所有JSON解析失败的地方一定要把原始字符串的repr日志打出来。看错误信息看不到的东西看repr一眼就能发现。4.2 schema太复杂导致服务端报错strict模式下schema里不能出现anyOf、oneOf、allOf但实际业务里总会遇到字段要么是字符串要么是null这种需求。我的替代方案是字段用Optional[str]定义description里写明没有则null。但Pydantic的model_json_schema()有时候会把Optional字段变成包含string和null的枚举描述在某些模型服务端会拦截。更稳的做法是把所有可空字段都改成字符串类型空值传空字符串再用业务侧逻辑判断。虽然牺牲了一点类型严谨性但换来的是跨模型、跨服务端的兼容。4.3 枚举字段的编码陷阱category字段如果用中文字面量比如技术类/生活类模型偶尔会在相似词之间摇摆把职场输出成职业之类。我在服务端看到过这种字段对齐失败虽然strict模式理论上应该拦截但某些模型服务端的schema执行只是软校验。我的对策是枚举值用固定英文标识对外展现再映射中文。比如代码里用tech渲染层根据业务映射tech→技术。这个方案在多个模型上都稳定强烈推荐。4.4 token消耗与成本控制很多人忽略structured output的prompt本身是有额外token成本的。json_schema的描述、字段约束都会被拼进系统提示。实测gpt-4o-mini下含5个字段、3个描述句的schema大概额外消耗150到300 token。如果你的问答器qps很高这个数字就不能无视。控制手段有三个description精简到一句话能不写maxItems就别写把sources这类易膨胀的字段限制在3个以内并用description明说。本地模型这块也有一个省钱经验qwen2.5:7b这类模型用format参数做结构约束时如果schema描述过长推理速度会明显下降严重时输出速率只有每秒2到3个token。把description精简掉一半之后速度就恢复了。所以schema不是写越详细越好要在语义清晰和token成本之间找平衡。4.5 测试集与回归建议问答器没有评测集改一次就觉得好像变好了纯属玄学。我建议沉淀一层极简评测集20到50条覆盖不同分类和边界的问题跑完脚本统计字段合法率、枚举命中率、置信度分布。合法率低于99%就看是不是schema描述问题枚举命中率低就检查枚举值设计。这样做每次改动都能量化不比搞一堆复杂指标差但比裸测强得多。我在这个项目里额外做了一个输出时间戳审计给每条回答记录生成耗时和是否重试。跑完评测集就能看到模型在哪些问题上触发重试多是不是某个字段描述总让模型困惑。这个信息对迭代schema非常有用比翻对话日志直观得多。下面把我在项目里遇到过的典型问题整理成一个速查表方便排查时直接对照现象可能原因处理办法json.loads失败肉眼却看不出问题字符串头尾有不可见字符BOM、零宽空格先strip()再解析打印repr()定位strict模式报400schema里出现anyOf/oneOf/未列全properties用Pydantic导出不手改schema枚举值偶尔对不上中文字面量或相似词干扰枚举值改用英文标识展示层做映射本地模型输出速率骤降schema描述过长精简description减少冗余字段说明重试后仍失败模型能力不足或prompt反馈不足升级模型或降低schema复杂度后再试4.6 从问答器扩展到通用Agent工具调用这个项目的产物可以直接作为其他Agent模块的模板。比如要让Agent执行查天气并返回可编程结果只需要把QAAnswer换成WeatherResult的schema要让Agent调用工具还是用同一套response_format约束tool参数。我后来做工具调用时几乎就是把这段代码的schema换一下、重试逻辑保持不变整个项目就起来了。结构化输出本质上是解决大模型与代码世界的接口协议问题。问答器只是第一个落地场景但它教会你的东西——schema设计、重试策略、测试集、token控制——会在后面做任何Agent应用时反复用到。我个人做完这个项目的体会是Agent项目的稳定性很多时候不取决于模型多聪明而取决于你给模型的输出合同有多清晰。结构化输出问答器不是什么高大上的算法它就是把这份合同用schema的形式固化下来让每一次模型回答都可解析、可校验、可审计。如果你正在做Agent应用建议从这个小项目开始把结构化输出做成标配而不是等下游被脏数据打崩了再回头补。后面往评测集、多Agent协作、工具调用方向扩展时你会发现今天花在这套schema上的功夫全都会加倍还回来。
返回列表