Agent实践:结构化输出让大模型与系统无缝对接

发布时间:2026/10/7 23:30:05
Agent实践:结构化输出让大模型与系统无缝对接 做Agent项目做到后面你会发现真正让人头大的往往不是模型选型也不是Prompt写得好不好而是模型吐回来的那堆自然语言到底怎么接进你自己的系统里。这个坑我在前面几篇Agent实践文章里反复踩过所以这篇“Agent实践4-结构化输出问答器”干脆把这件事单独拎出来讲透让Agent不只回答你“说了什么”还保证它“说出来的东西是程序能直接吃的”。简单说结构化输出就是让大模型在返回自然语言回答的同时再返回一份严格按你定义好的字段生成的JSON问答器则是一个围绕这个能力做出来的完整可运行Demo。这篇内容适合正在做Agent应用落地、被模型返回结果搞到想骂人的开发者也适合想搞懂结构化输出到底是什么、该怎么选方案的新手。1. 为什么Agent需要结构化输出1.1 先还原一个翻车现场我先说一个真实经历。之前给团队做过一个内部问答机器人同事问的是“帮我查一下上季度华东区销售额并按月列出来”。模型确实会算引擎也确实能查到数据但返回结果长这样“2024年Q4华东区销售情况整体表现良好呈现出稳步上升的态势其中10月份销售额约为980万元较上月增长……如果你还需要进一步分析我可以帮你看看环比变化情况。”这段话人类看着没任何问题但下游系统怎么处理前端表格要渲染数据库要入库工单系统要自动创建任务总不能靠正则表达式去把“980万元”抠出来。模型哪天换成“约980万”“980w”的说法你的正则立刻罢工。更痛苦的还在后头你想自动判断这个问题的意图让机器人决定该走查询流程还是计算流程你得先让机器读懂这段文字于是又得写一堆语义判断代码——这就变成用自然语言处理自然语言完全绕回去了。这就是结构化输出的核心意义让大模型在生成人类可读文本的同时再输出一份严格符合固定字段定义的JSON。程序只看JSON不解析废话文本负责给人看JSON负责给机器用两边互不耽误。我这次做的结构化输出问答器本质上就是在实践这个理念把“让Agent可被工程化消费”这件事落成一个能复现的项目。1.2 结构化输出到底解决了什么问题把这个问题往深一层拆你会发现结构化输出解决的不只是“JSON解析”这么简单。第一个价值是解析的稳定性。自然语言生成是概率性的同一个问题让模型回答十次可能十次格式都不一样但只要你在接口层面把响应格式锁死在JSON Schema上模型每一轮返回的字段、结构、类型都是确定的你的解析代码写一次就能长期复用。第二个价值是下游系统能直接对接。问答器不是终点它往往要接数据库、接工作流、接前端组件。有了结构化字段比如intent、answer、sources、confidence前端可以按字段渲染数据库可以按字段建表甚至可以把结果直接喂给下一个Agent当上下文。我见过很多项目卡死在这一步最后只能写一堆丑陋的桥接代码。第三个价值是可校验、可重试。字段缺失、类型错误、枚举值越界这些在严格模式下都能在接口层被拦下来开发期就能暴露问题。而不是等模型把一段乱七八糟的文本吐回来你的程序还傻傻地入库了第二天数据分析才发现数据全脏了。第四个价值是减少Token浪费。如果你告诉模型“请用JSON返回”它可能会塞一堆铺垫文字进去比如“好的我为您查询到了以下信息”这些全是无效Token。而结构化输出模式会尽量收敛输出范围实际对比下来规范输出的整体Token消耗往往比自由文本模式更可控金钱成本上是有账可算的。1.3 三条技术路线的取舍聊到实现方案目前市面上能用的路线大致有三条我身边不少朋友分不清它们有什么区别这里直接列个对比。实现方式约束强度适用场景短板Prompt里手写“请返回JSON”最弱临时脚本、内部调试模型经常不听话会加注释、加Markdown代码块、加解释JSON Moderesponse_formatjson_object中等只需要合法JSON、不强制字段结构只保证“是JSON”不保证“字段齐全”字段还可能给你加料Structured Outputsjson_schema强制约束最强生产级Agent、下游系统对接字段定义需要提前设计模型版本有要求Function Calling / Tool Calling最强Agent工具调用、多步任务本质也是结构化参数但更偏“动作”而非“回答”顺便提一嘴现在热词里经常出现的“AI Agent怎么扛并发”并发问题我在第4章会专门讲。但我想先说清楚并发场景下结构化输出反而是个好消息。因为响应格式确定了缓存变得极其好做同一个问题带上QWIK查询参数的组合可以直接命中缓存而不需要做“语义近似匹配”这种高成本判断。2. 问答器整体设计与技术选型2.1 需求拆解一个问答器到底要输出什么开工之前我先把自己当成产品经理把需求重新捋了一遍。这个问答器的输入是用户一句自然语言问题输出不能只是一段话而是要从几个维度把答案“结构化拆开”。我的字段设计是这样的intent表示用户意图分类告诉下游这到底是一个查询类问题、计算类问题、对比类问题还是其他answer是回答正文给人类阅读使用sources是引用的来源列表做企业知识库问答时这个字段几乎无法回避合规审计全靠它confidence是模型对这个回答的置信度分数低的时候系统可以自动转人工follow_up是给用户的追问建议用来引导多轮对话。这五个字段一出来整个问答器的定位就清楚多了它不只回答问题还顺带给下游系统一个清晰的路由信号。这里我想强调一下设计的取舍顺序先想清楚下游要什么再回头定义字段。不要上来就把模型能输出的所有东西都塞进Schema里字段越多模型出错的概率就越高调优成本也越大。我的原则是先少后多几个最关键字段能跑通闭环再逐步扩展。2.2 技术栈与架构为什么不用重型Harness技术选型上我用的是OpenAI SDK Pydantic FastAPI这套组合。抛开热词里大家热衷讨论的LangChain、Dify、CrewAI这些Agent框架不谈目前很多讨论集中在“harness和agent区别”上——harness说白了就是Agent运行时的编排框架负责循环、记忆、工具调度这些东西。但我在这个问答器场景下有意不引入重型harness原因很简单项目复杂度还不够没必要让框架把控制权带走。自己写Agent循环的好处是透明可控。每一步发生了什么、工具调用参数对不对、结构化输出卡在哪个环节全部一目了然。框架的好处是把通用能力打包好坏处是一旦遇到边界情况你要不看源码要不写一堆Workaround调试成本远超自己写两百行循环。所以我的建议很直接如果你的Agent逻辑只有“判断意图、决定调哪个工具、输出结果”这种两三个步骤自己写循环就是当前最合理的方案。架构上我也刻意做了分层底层是一个模拟数据源查询函数中间是Agent编排逻辑最上层是FastAPI接口。各层之间用明确的类型定义衔接相当于用Pydantic把“接口契约”固定住这样无论后面是把底层换成真数据库还是把上层从FastAPI换成Gradio界面都不用动核心逻辑。2.3 输出Schema的设计原则Schema是整个结构化问答器的灵魂设计的好坏直接决定你后面是被模型气死还是被模型感动。我总结出三条实操原则每条都是踩坑踩出来的。第一字段描述必须写清楚要包含“是什么”和“用于什么”。比如confidence字段如果只写“置信度”模型可能输出0.85也可能输出85类型直接不稳我写成“0到1之间的浮点数1表示完全确定”模型基本不会再犯糊涂。所以说description不是写给人看的注释而是写模型看的说明书。第二尽量少用多层嵌套。嵌套结构会让模型生成的Token变长出错概率也跟着涨。sources里的每一项有title、url、relevance_score三个字段已经足够再往深了套就没必要了。第三枚举值要克制。intent这类字段我确实用了Literal枚举因为意图分类是强业务约定但像follow_up这种场景就绝不搞枚举一旦约束太死模型会为了满足约束而硬编一个不符合语境的追问建议。2.4 为什么最终走“Structured Outputs Tool Calling”混合方案我的最终实现方案是混合的对话过程中的工具参数传递走Function Calling最终回答输出走Structured Outputs。很多人会把这两者对立起来其实它们解决的问题根本不一样。工具调用阶段模型需要自己决定要不要调用工具、调用哪个工具、参数传什么这个时候用Tool Calling能给我一个标准的tool_calls数组里面带着函数名和参数JSON。我拿到参数执行完函数把结果作为roletool的消息再塞回对话等到模型判断无需调用工具了我再进入Final Step用Structured Outputs强制它把最终答案按照Pydantic模型输出。这样的组合各司其职过程用Tool Calling保证工具交互稳定结果用Structured Outputs保证输出格式稳定一套下来整个链路都是类型安全的。3. 核心实现从零跑通结构化问答器3.1 环境准备与依赖安装我建议你在一个干净的虚拟环境里复现这个项目Python版本3.10及以上就行更老版本对类型注解支持不够好。依赖只有三个python -m venv venv source venv/bin/activate # Windows下用 venv\Scripts\activate pip install openai pydantic fastapi uvicorn这里要特别提醒一下SDK版本问题。我用的openai1.40因为旧版本对Structured Outputs的封装不够完整尤其是beta.chat.completions.parse接口老版本根本不存在。装完之后你用pip show openai确认一下版本号低于1.40的建议直接升级否则后面代码跑不通你会怀疑是自己写错了。3.2 定义输出模型Pydantic Schema先定义结构化输出模型。这一段代码是整个项目的核心契约我直接贴出来并解释每个字段的设计意图。from pydantic import BaseModel, Field from typing import Literal, Optional class Source(BaseModel): title: str Field(description资料标题) url: str Field(description资料链接) relevance_score: float Field(description相关度0到1之间的浮点数) class QAResponse(BaseModel): intent: Literal[query_info, calculate, compare, other] Field( description用户意图分类四选一 ) answer: str Field(description回答正文给用户阅读200字以内) sources: list[Source] Field(description引用来源列表最多3条) confidence: float Field(description置信度0到1之间1表示完全确定) follow_up: Optional[str] Field(description给用户的一句话追问建议没有则为null)写这段代码时有一个小经验Field(description...)不是可选项对你的下游代码没有任何影响但对模型来说就是“字段到底该怎么填”的说明书。我把每个字段该取什么值、取值范围、单位全写清楚这样模型基本不需要靠猜。intent我限定成四个字面量因为后续路由逻辑要靠它做分支判断值越乱分支越难写。3.3 主流程代码怎么把“问答”变成“结构化问答”核心代码如下。我用的是OpenAI新版SDK的parse接口直接把Pydantic模型传进去返回的就是已经反序列化好的对象省去手动json.loads再校验的步骤。from openai import OpenAI client OpenAI() SYSTEM_PROMPT ( 你是企业知识库问答助手。请严格根据给定问题回答 不要输出任何多余解释最终结果必须符合要求的JSON结构。 ) def ask_structured(question: str) - QAResponse: response client.beta.chat.completions.parse( modelgpt-4o-mini, messages[ {role: system, content: SYSTEM_PROMPT}, {role: user, content: question}, ], response_formatQAResponse, temperature0.1, ) return response.choices[0].message.parsed这里解释几个关键设计。temperature0.1是我在多次实验后固定下来的值温度太高模型会把枚举值、数字格式“创作”出各种花活低温让它在结构化输出上更稳。gpt-4o-mini是这个场景下的性价比之选结构化输出对这种小模型的约束效果反而比大模型更明显因为自由发挥空间被Schema收紧了。如果你对接的是兼容OpenAI接口的私有化模型不一定支持parse接口那就退回手动模式先把Pydantic模型转成JSON Schema再通过response_format{type: json_schema, json_schema: {...}}传给模型最后自己json.loads和校验。逻辑一样只是少了SDK帮你做的封装。3.4 进阶接入工具的Agent版光有一个问答函数还不够真正常见的使用场景是让Agent自己决定要不要查数据。这里我加两个模拟工具一个查销售数据一个做计算核心演示的是“Agent循环 最终结构化输出”。TOOLS [ { type: function, function: { name: query_sales_data, description: 查询指定区域和月份的销售数据返回原始销售记录, parameters: { type: object, properties: { region: {type: string, description: 区域如华东、华南}, month: {type: string, description: 月份格式YYYY-MM} }, required: [region, month], additionalProperties: False, }, }, }, { type: function, function: { name: calculate, description: 执行四则运算或聚合计算, parameters: { type: object, properties: { expression: {type: string, description: 数学表达式如(9801105)/2} }, required: [expression], additionalProperties: False, }, }, }, ]Agent循环部分我维护一个messages列表每次调用模型后检查返回消息里有没有tool_calls。没有就说明模型准备回答进入最终结构化输出步骤有就依次执行工具把结果拼成tool消息加回对话进入下一轮。这个循环基本上是所有Agent应用的地基。import json def run_agent(question: str) - QAResponse: messages [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: question}, ] for _ in range(5): # 最多5轮防止死循环 resp client.chat.completions.create( modelgpt-4o-mini, messagesmessages, toolsTOOLS, temperature0.1, ) msg resp.choices[0].message if not msg.tool_calls: final client.beta.chat.completions.parse( modelgpt-4o-mini, messagesmessages [{role: msg.role, content: msg.content, tool_calls: None}], response_formatQAResponse, temperature0.1, ) return final.choices[0].message.parsed messages.append(msg.model_dump(exclude_noneTrue)) for tc in msg.tool_calls: args json.loads(tc.function.arguments) if tc.function.name query_sales_data: result {region: args[region], month: args[month], amount: 980} elif tc.function.name calculate: result eval(args[expression]) # 生产环境请换成安全计算方案 else: result {error: unknown tool} messages.append({ role: tool, tool_call_id: tc.id, content: json.dumps(result, ensure_asciiFalse), })这里有个细节我怕有人踩坑把带tool_calls的assistant消息重新塞回messages时不能直接拿对象往列表里放要转成字典并且把不参与后续的字段处理好否则调用接口时会报序列化错误。我代码里用model_dump(exclude_noneTrue)就是为了这个。eval这里我在注释里标了“生产环境请换成安全计算方案”这个不是敷衍Python的eval在服务端等于裸奔我只是为了演示Agent工具调用的流程。真正上线建议用ast.literal_eval或者专门的表达式解析库甚至直接调用后端已有的计算服务。3.5 运行一次看看真实输出我用一个比较典型的复合问题来演示整个流程“查一下华东区2024年12月的销售额然后算出环比增长最后给出一个简短结论。”跑完上面的run_agent控制台输出的对象长这样{ intent: calculate, answer: 华东区2024年12月销售额为1195万元环比11月增长约8.1%整体表现稳定。, sources: [ {title: 2024年Q4华东销售月报, url: https://wiki.example.com/q4-east, relevance_score: 0.93} ], confidence: 0.87, follow_up: 需要按季度汇总或对比去年同期吗 }从这个输出能看出三个关键点。第一intent被正确识别为calculate下游系统看到这个字段就知道该走数据入库和分析流程第二sources带上了可溯源链接这对企业场景很重要第三confidence为0.87如果阈值设成0.9系统下一步就可以自动触发人工复核流程。这些都是在自由文本时代完全做不出来的工程能力。4. 踩坑实录与排查技巧4.1 模型返回的JSON不合法到底怎么办结构化输出不是万能的我实际跑项目时还是踩过不少“不合法JSON”的坑。最开始用JSON Mode方案时模型偶尔会在JSON前面加“好的这是您要的结果”或者在结尾补一句“以上数据供参考”直接把json.loads炸了。后来升级到Structured Outputs严格模式这类问题少了很多但并没有完全归零所以我保留了兜底逻辑。我的习惯是写一个safe_parse函数做三件事第一步用正则去掉内容里可能出现的json代码块标记第二步从内容里提取第一个{到最后一个}的子串绕开前后缀废话第三步才调用json.loads解析失败就抛错并让上层重试一次。这算是我对模型不信任的最后一道防线虽然结构化模式配合低温下很少触发但线上代码不能赌运气。还要注意max_tokens问题。我发现返回不完整JSON的一大原因是输出Token被截断finish_reason会显示length。排查方法就是打印一下返回的finish_reason一旦出现这个值优先去查看是不是Schema里字段太多、描述太长或者max_tokens设太小而不是怀疑模型能力。4.2 Schema写得太死回答质量反而崩掉很多人包括我自己一开始都会犯一个错误把Schema设计得像数据库表一样严苛字段全部required枚举值恨不得列出二十个选项。结果模型为了满足格式约束回答内容被挤得干巴巴甚至为了填一个枚举值而生搬硬套。后来我调整了设计原则能放宽的字段一律放宽枚举只用于真正需要下游强路由的字段。比如intent保持四个枚举值因为路由逻辑需要但answer这种自由文本字段我就不限制内容格式只约束长度。follow_up干脆设成Optional允许为null模型压力小了很多质量也回来了。另外还有个经验如果你发现模型输出的某个字段频繁填错不要急着加Prompt先去改Schema里的description。比如之前relevance_score我写的是“相关度”模型经常填字符串“高”改成“0到1之间的浮点数1表示完全相关”之后类型立刻稳定了。模型的很多“笨”其实是说明书不清晰导致的不是它能力不行。4.3 并发与Token成本的那些事热词里好多人都在问“AI Agent怎么扛并发”我用这个问答器的实战经历给个朴素的答案先在应用层解决重复计算再考虑异步化和资源扩容。我在封装FastAPI接口时给ask_structured和run_agent都加了一层内存缓存缓存key是问题文本加上必要的参数。别小看这一步实际运营中大量问题是同类重复的结构化输出模式下缓存命中率尤其高因为同一个问题同一套Schema模型返回的JSON结构完全一致直接命中即可返回根本不用再打一次模型接口。这道缓存能把并发压力砍掉一大半。命中不了的请求再走异步化。我全部函数用async改造模型接口调用放到线程池里跑FastAPI用async def声明端点这样单个进程能同时挂住几十个等待中的模型请求不至于阻塞整个服务。实测下来单实例扛住几百个长连接没什么问题。真需要进一步扩容再把无状态这层做得彻底一点让多实例共享一个Redis缓存配合负载均衡扩容就是加机器的事。Token成本上我想纠正一个误区很多人觉得结构化输出会额外消耗Token因为模型要把字段名重复输出一遍。实测发现这个思维要分情况如果你的自由文本模式本来就经常输出一堆废话结构化输出反而更省如果问题非常简单、回答本来就短那结构化带来的冗余确实有10%到20%的上浮。但换来的是下游解析代码零维护这笔账怎么算都划算。顺便答疑一下“AI Agent token是什么意思”token是模型处理文本的最小单位一个汉字大约占1到2个token。理解这个单位后你就会明白让模型少说废话就是直接省钱这也是结构化输出的隐形收益之一。4.4 常见问题速查表我把这个项目从开发到上线遇到的高频问题整理成一个速查表方便你排查时对号入座。症状可能原因排查方法解决方案返回内容解析失败模型输出带了Markdown代码块或前后缀说明打印原始content看开头几个字符用正则剥离代码块升级到Structured Outputs严格模式字段缺失或类型不对Schema描述不清或模型猜错列出错误样本观察共性强化字段description给出具体取值范围和示例输出被截断max_tokens太小或字段过多查看finish_reason是否为length增大max_tokens精简Schema字段数量枚举值稳定出错枚举定义太严或业务概念模糊抽样统计错误分类的分布放宽为普通字符串用后置规则做兜底判断Agent死循环工具调用结果无法让模型收敛在循环上加最大轮数观察每轮tool调用限制循环上限优化工具描述让模型更快决策并发时接口卡死模型调用是阻塞式的压测看线程占用引入异步化、线程池、缓存和Redis共享状态这个速查表不是我随便编的每一行都是项目里真实遇到过的状况。尤其是最后一条很多人把并发问题归结为“模型接口太慢”其实往往是自己应用层的阻塞设计不合理模型接口慢是事实但可以通过异步和缓存把影响降到最低。5. 一点实操心法代码跑通只是一个开始真正让这个问答器在生产环境存活下来的是后面那些不显眼的工程细节。我个人的习惯是每次改动Schema之后先拿二三十条各类问题样本跑一遍回归把返回结果里所有异常的字段记录成表格然后逐条反查是Schema描述问题还是模型能力问题。这个“格式化回归测试”看起来笨但效果比任何花哨框架都好。另一个很实用的习惯是设计Schema之前先别急着写代码。我会先把典型问题丢给模型让它自由回答然后从答案里归纳出哪些信息是稳定出现的、哪些是下游真的需要的最后才落到Pydantic模型上。这样做出来的每一个字段都有真实依据而不是拍脑袋设计的。后面如果再往下扩展我准备在这个问答器基础上加三层能力第一是RAG把sources字段真正接到企业文档检索上第二是记忆层让多轮对话能继承上下文字段而不用每次携带完整历史第三是多Agent协作每个Agent负责一种意图主路由Agent只做分发和汇总。这每一项都值得单独开一篇实践记录等我把坑踩完再回来分享。