
1. 为什么要做结构化输出问答器1.1 聊天式回答的业务困境这个系列前面几篇都在聊Agent框架、工具编排这些东西看起来都挺“聪明”的但真把一个Agent放进业务链路里最先卡住你的往往不是模型聪明不聪明反而是输出太“自由”。我第一版问答器就是典型的踩坑案例用户问“我上周买的充电宝坏了怎么办”模型回了一大段热情洋溢的话术人工看着没问题可后台要自动创建售后工单、自动打上“售后报修”标签、自动提取订单关联信息那一刻整条链路就断在了“模型自由发挥”这四个字上。自然语言输出对终端用户很友好对程序却是灾难。你要想从一段回答里稳定提取用户意图、客户等级、订单号、需要人工介入的标记光靠正则和字符串匹配基本是不归路。当时我试过用第二层模型去抽取字段效果勉强能看但成本翻倍、时延变长而且第二层模型偶尔也会把字段抽错等于在脆弱的链条上又加了一环。最终得出的结论很朴素在不确定性的链路里每少一次自由生成就多一分稳定。所以这一篇的“Agent实践4-结构化输出问答器”核心就干一件事——让Agent像填表一样回答业务问题它说出来的每个字段程序都能直接吃进去。1.2 结构化输出的核心价值结构化输出说白了就是给Agent发一张“表单”让它在表单限定的字段里填内容而不是在空白A4纸上写作文。表单固定了字段名、字段类型、可选值模型的自由度被压缩但系统的可控度瞬间提上来。举个例子同样一个问答器如果它输出的数据格式是{ intent: after_sale, need_human: true, order_no: SO20240517001, answer: 您好查询到订单…… }那么后端服务拿到这个JSON就能直接开始处理先判断意图再查订单最后根据状态决定是发话术还是转人工。整个过程没有解析歧义没有字段缺失也没有人对机器结果的二次转译。我习惯把这个思路类比成“让实习生做会议纪要”你如果只告诉他“今天的会议记一下”他记出来的东西和你想象中的完全不是一回事但如果你给他一个模板固定“结论、待办事项、负责人、截止时间”四列他哪怕文笔一般产出也是可用的。结构化输出就是这个模板Agent的“聪明”体现在如何在模板里填出高质量内容而不是体现在重新发明格式上。1.3 这个问答器要解决的三个核心问题做这个结构化输出问答器我给自己定了三个必须解决的问题后面所有代码和方案都围绕它们展开。第一是格式漂移问题。同一个模型、同一个Prompt今天返回正经JSON明天返回带着markdown标记的JSON后天可能直接在JSON后面追加一段解释文字。没有约束的LLM输出是不稳定的概率采样格式化行为会随着温度、上下文、甚至服务器负载变化而漂移。第二是字段完整性问题。模型经常漏字段比如要求返回五个字段它只填了三个就停了漏输出一个need_human下游系统就可能把紧急售后当成普通咨询处理。第三是下游集成问题。问答器最终是要被API调用的调用方需要一份稳定的数据契约像调用普通接口一样处理结果而不是每天跟模型输出斗智斗勇。这三个问题在这个问答器里会被同一个方案解决用Tool Calling强制模型走工具输出通道用Pydantic Schema定义严格的数据契约再用重试机制把模型偶尔“跑偏”的行为纠正回来。下面我先把方案选型的思路讲透再上代码。2. 结构化输出方案选型与技术原理2.1 先搞清楚模型为什么会“乱说话”不把原理讲清楚后面遇到坑你会无从下手。大语言模型的生成本质上是一个token一个token地自回归采样模型在每一步都会计算下一个token的概率分布然后按概率挑一个出来。如果温度调高模型就更愿意挑那些概率不是最高的token输出就更发散格式也就更容易放飞自我如果温度接近0模型大概率每次都挑概率最高的token行为就相对稳定。所以“模型输出不稳定格式”这件事不完全是模型笨而是采样机制决定了它天生就有随机性。Prompt里的“请务必输出JSON”只是一个软性引导模型看到了也“想”遵守但它并不是在JSON解析器的约束下生成内容的很可能在中途冒出换行、注释、多余逗号。理解这一点很重要我们要做的不是祈祷模型听话而是从机制上限制它“不听话”的空间。这就是结构化输出方案的核心思路——硬约束优先软引导兜底。2.2 四种主流结构化输出方案对比市面上常见的结构化输出方案有四类我按工程实践中的典型形态拆开讲。第一种是纯Prompt约束加后处理兜底。你在System Prompt里写“只输出JSON不要多余内容”拿到原始文本后用json.loads解析解析失败就重试。这个方案实现成本最低但稳定性也最低适合快速原型不适合接生产链路。第二种是Function Calling也就是工具调用。模型在对话过程中会生成一个特殊的tool_calls结构里面是JSON格式的工具参数。API服务端把这段内容单独抽取出来程序可以直接读取。这等于把“输出结构”这件事从模型随缘生成变成了训练时就被强化的能力稳定性明显提升而且主流API都支持。第三种是Schema原生约束。不少模型服务商在API里提供了response_format之类的参数可以让响应强制符合JSON Schema。比如OpenAI的json_schema模式会在服务端加限制基本不会再出现markdown代码块包裹JSON的问题。第四种是约束解码代表有Outlines、Guidance这类库。它们直接在模型采样阶段做手脚用一个有限状态机跟踪当前已生成的内容每一步只允许真实的JSON语法允许出现的token进入候选集。这个方案能让输出100%合法JSON但主要适用于本地模型部署商业API通常没法接入。这四类方案我用一张表总结过方便大家对照选型方案格式稳定性实现成本模型兼容性适用场景Prompt 正则兜底低格式会漂低所有模型通用原型验证、一次性脚本Function Calling较高低主流API基本都支持生产中首选通用性强Schema原生约束高低部分API提供需确认版本对格式要求极严的单模型链路约束解码极高中高本地模型效果好商业API难接本地私有化部署、离线推理2.3 我为什么选“Tool Calling Schema校验 重试”组合我在这个问答器项目里最终没有押注单一方案而是选了组合拳Tool Calling作为主通道Pydantic Schema作为校验层重试机制作为纠错闭环。选Tool Calling当主通道是因为兼容性最好。OpenAI、Anthropic以及国内主流模型服务商都支持工具调用这意味着Agent不管接哪家的大模型这套代码的骨架都不用推倒重来。而且Tool Calling天然适合Agent场景Agent本来就需要工具来查订单、查库存、搜FAQ把“提交结构化结果”也做成一个工具模型在生成输出的时候会把它当成一次普通工具调用来处理这一套逻辑是统一的。为什么还要加Schema校验和重试因为Tool Calling只能提高格式稳定性不能保证字段值一定合法。比如intent枚举里只有after_sale和pre_sale模型可能凭空生成一个consult出来又比如confidence要求0到1之间的小数模型可能给你输出一个字符串。这些错误都需要程序端用Schema去卡卡不住就让模型看一眼错误信息自己改。个人体会校验层不能省省了等于把不确定性的风险全都留给下游系统。加了校验和重试之后结构化输出问答器的格式成功率从90%左右拉到了99%以上。顺便说下社区里最近聊得比较多的harness和agent区别。我的理解是harness是围绕Agent运行的运行时壳负责收集上下文、调度工具、处理异常Agent本身更像决策单元。结构化输出中的Schema约束、校验、重试本质上都发生在harness层而“选哪个意图”“要不要转人工”的决策是模型做的。这个分层想清楚之后你在工程上就不会把一堆跟“模型能力”无关的琐事堆进Agent的业务逻辑里后续加记忆、加工具都会顺手很多。2.4 结构化输出对模型参数和成本的影响定了方案之后模型参数也得跟着改。做自由对话时你可能习惯把temperature调到0.7以上但做结构化输出时必须压低我实测最佳区间是0到0.2。温度越高模型越容易在JSON字符串里加入莫名其妙的措辞或换行遇到枚举值也更容易挑一个“看起来相关但不在列表里”的选项。max_tokens也要注意结构化输出的JSON本身不会特别长但如果你预留的token不够模型会在JSON写到一半被迫掐断产生残缺的JSON。我的经验是给max_tokens留出正常回答长度的1.5到2倍余量。Token成本有时会被忽略。Schema本身是要随请求发到模型那端的它会占用固定的输入token。输出端的JSON字段名、枚举值也会占输出token。如果每个问答请求都要带一个几百token的Schema并发量上来之后这部分成本是肉眼可见的。做项目预算的时候记得把“结构化约束的token开销”算进去别只按纯对话的token估。3. 问答器Agent搭建全过程与关键实现3.1 需求定义与数据契约设计这次做的是售后客服问答器我对它的要求是用户任意提问Agent必须返回一个结构固定的结果包含意图分类、客服话术、关联商品、置信度、是否需要人工介入。第一步就是把这个数据结构定义成Pydantic模型——它是Agent输出规范也是后端接收接口的契约。from pydantic import BaseModel, Field from typing import List, Literal class SupportResult(BaseModel): 售后问答器统一输出结构 intent: Literal[pre_sale, after_sale, need_human, chitchat] Field( description用户意图售前咨询/售后报修/转人工/闲聊 ) answer: str Field( description给用户的客服话术语气自然、礼貌、简洁 ) skus: List[str] Field( default_factorylist, description命中的商品SKU编码列表没命中商品则返回空列表 ) confidence: float Field( ge0.0, le1.0, description模型对本次输出结果的置信度0到1之间 ) need_human: bool Field( description是否需要转人工客服处理 )为什么字段类型要设计得这么“死”因为字段越死模型越没有自由发挥空间。比如intent用Literal枚举模型只能在四个值里选就不会出现“other”这种让下游摸不着头脑的分类。confidence用ge0.0, le1.0限定了范围就不会出现1.5这种明显不合法的值。这些限制在数据结构层面就把很多潜在问题挡掉了比在业务代码里做各种if判断要优雅得多。3.2 Agent模块拆解与工具层设计问答器的Agent内部我从功能上拆成了三层决策层、工具层、输出层。决策层负责理解用户问题并规划下一步动作。用户问物流问题它决定调用订单查询工具用户问退换货政策它决定调用FAQ检索工具用户突然发一句“今天天气不错”它决定走闲聊分支。工具层是Agent可以调用的实际能力集合比如订单查询工具返回订单状态的JSON、商品库存工具返回SKU和库存数量、FAQ检索工具返回知识库中命中的片段。输出层就是上一节定义的SupportResult它把所有决策和工具结果汇总成一个结构化结果。这三层的职责边界很清楚决策层只做判断工具层只做数据获取输出层只做格式整理。最怕的就是把这三层揉在一起比如让模型边检索边输出那它很可能在回答里夹带工具返回的原始JSON用户看着像天书后端也没法处理。我在设计阶段就把工具返回的数据和模型最终的answer话术做了隔离工具数据是给模型决策参考的不是给用户看的。3.3 核心链路Tool Calling与Schema校验这一节是全文的实操重点。我把核心链路拆成四步组织上下文、调用模型、解析工具参数、校验结果。我用OpenAI SDK示例用其他API的读者思路是相通的。第一步组织上下文。从会话维度把用户问题、历史摘要、工具返回结果组成消息列表messages [ { role: system, content: ( 你是售后客服Agent。接到用户问题后你需要先调用工具获取必要信息 最后调用submit_support_result提交结构化客服结果。 不要向用户解释你的内部过程。 ), }, { role: user, content: 我上周买的移动电源充不进电了怎么处理, }, ]这里的关键词是“最后调用”在System Prompt里明确告诉模型所有信息收集完之后必须调用指定工具提交结果。模型对这种指令的理解力相当好但前提是工具名要起得直白。第二步调用模型并把SupportResult的JSON Schema注册成一个工具from openai import OpenAI client OpenAI() tools [ { type: function, function: { name: submit_support_result, description: 提交售后服务问答的结构化结果, parameters: SupportResult.model_json_schema(), }, } ] response client.chat.completions.create( modelgpt-4o-mini, messagesmessages, toolstools, tool_choice{type: function, function: {name: submit_support_result}}, temperature0.1, )注意tool_choice这个参数。如果不指定模型可能觉得某些问题不需要提交结构化结果直接自由回答。而我明确要求它必须调用submit_support_result等于是把“结构化输出”从软性建议变成了硬性任务。实测下来这招比在Prompt里喊一百遍“你必须输出JSON”都有效。第三步解析工具参数。模型返回的message.tool_calls里有一段JSON格式的arguments它就是要填的表单内容import json message response.choices[0].message tool_call message.tool_calls[0] arguments json.loads(tool_call.function.arguments)第四步校验。这一步不能省直接把解析出来的字典交给Pydanticfrom pydantic import ValidationError try: result SupportResult(**arguments) except ValidationError as e: # 进入重试逻辑下一节详细展开 print(e.errors()) else: print(结构化结果, result.model_dump())到了这一步后端拿到的就是一个完全合法的SupportResult对象所有字段类型、取值范围都已经经过校验。程序后续无论怎么消费它都不会因为“这字段怎么是None”这类问题炸掉。3.4 重试机制把错误反馈给模型的正确方式模型偶尔还是会填错表处理它的正确方式不是反复调同一个Prompt而是把校验错误信息作为一条工具消息反馈给它让它自己看到哪里不合法然后修正。这个机制其实就是闭环纠错我在项目里把重试次数控制在2次。伪代码逻辑是这样的for attempt in range(2): response client.chat.completions.create( modelgpt-4o-mini, messagesmessages, toolstools, tool_choice{type: function, function: {name: submit_support_result}}, temperature0.1, ) message response.choices[0].message tool_call message.tool_calls[0] try: arguments json.loads(tool_call.function.arguments) result SupportResult(**arguments) return result except (json.JSONDecodeError, ValidationError) as e: messages.append({ role: tool, tool_call_id: tool_call.id, content: f你提交的结果校验失败请修正。错误详情{e.errors() if hasattr(e, errors) else e}, }) # 重试耗尽返回兜底结构 return SupportResult( intentneed_human, answer系统未能生成有效回复已为您转接人工客服。, skus[], confidence0.0, need_humanTrue, )有个细节容易踩坑追加错误反馈消息时tool_call_id必须用原来那条tool_call.id这样模型才知道这个错误是针对它的哪次调用。如果这个ID丢了或填错一些API会直接报错一些API会把消息顺序搞乱。重试次数我定为2次不多设是因为实测第3次以后成功率提升不明显反而会带来token成本翻倍和延迟拉长。另一个经验是兜底结构也要标准化。就算重试全部失败我们也要返回一个合法的SupportResult只不过把need_human置为True转人工处理。这样下游系统永远能拿到一个可解析的对象不会因为Agent“卡壳”而整条链路崩溃。这个兜底策略的价值是在线上被验证过的宁可把不确定的case交给人工也不能让机器用错误的结果去自动执行。3.5 并发与稳定性一个问答器扛住业务流量Agent服务一旦接进生产并发和稳定性就是绕不开的问题。先说结论要把这个结构化输出问答器做成无状态服务所有会话状态外置到Redis然后按流量模型设计限流和缓存策略。无状态化是什么意思就是Agent服务内部不存任何会话数据每次请求的上下文都从外部存储读取。用户聊到第三轮服务端从Redis取出前两轮的对话摘要和结构化结果拼到消息列表里再交给模型。这样做的好处是服务可以随意横向扩容多实例之间没有状态同步问题。模型调用本身是IO密集型的一个请求要等模型响应1到3秒如果同步调用会很快占满线程池。我的做法是用异步客户端加并发控制把同时进行的模型调用数量限制在一个合理范围避免上游API被自己打爆。缓存也很关键针对FAQ类的高频重复问题可以做语义缓存把用户问题embedding后查找相似度高的历史问题如果命中就直接返回上次的结构化结果不再调用大模型接口。这个策略能把热门问题的QPS扛上去成本也能降一大截。限流和熔断要配合着做一个防自己超量一个防上游故障拖垮整个服务。我遇到过上游模型接口偶发5xx的情况如果不做熔断重试请求会堆积最终把Agent服务自己也拖死。加了一个简单的熔断器连续失败超过阈值就快速失败返回兜底结构上游恢复后再逐步放量。这套组合下来这个问答器在实际流量下没有因为格式问题或者模型接口抖动出现过一次宕机。4. 常见问题与排查技巧实录4.1 我在线上踩过的五个坑第一个坑是模型输出被markdown代码块包裹。有时候模型会在JSON外面加json和标记直接json.loads必炸。但用Tool Calling之后这个问题基本消失因为在工具调用模式下模型走的是独立通道不会把代码块混进来。如果还在用纯Prompt方案可以考虑在解析前先做一次strip并尝试提取首个{到末尾}之间的内容。第二个坑是JSON被截断。典型场景是回答里生成了很长的客服话术把max_tokens耗尽了JSON末尾缺了右括号。这个靠重试能救一部分但治本还是要给足max_tokens我按回答长度的1.5到2倍设置。第三个坑是枚举值非法。模型偶尔会给intent造出一个不在枚举里的值比如“refund”。这类问题靠Pydantic校验能拦下来但拦下来之后不能只报错不引导。我的做法是在重试反馈消息里把合法枚举值列出来明确告诉模型只能从这些值里选。第四个坑是中文被转成\uXXXX。这其实是合法JSON行为不是错误但如果消费方介意可以在解析后用ensure_asciiFalse重新处理。第一次遇到时以为数据坏了排查了半天才发现是正常unicode转义。第五个坑是重试陷入死循环。早期我把重试次数设成了5次结果发现模型偶尔会反复犯同一个错每次都消耗大量token。后来改成2次加兜底整体链路反而更稳。4.2 排错思路从原始响应开始分层定位排查结构化输出问题时我的第一动作永远是拿到模型返回的原始响应。别直接去看报错堆栈先确认模型到底吐了什么。打开响应日志查看message.tool_calls是否存在、arguments原始字符串长什么样。然后按这个顺序分层定位先看参数层temperature、max_tokens是否合理再看Prompt层System Prompt里是否强调了“最后调用submit_support_result”接着看Schema层字段类型、枚举、描述是否写清楚最后看工具返回层工具返回的数据格式是否与Schema期望一致。这四个层次基本覆盖了90%的结构化输出问题。我遇到过最隐蔽的一个问题一个工具返回的日期格式是20240517而Schema期望2024-05-17模型直接把这个字符串原样塞进了JSON里下游解析日期就炸了。这种问题只有把工具返回的细节纳入排查范围才能发现。4.3 常见问题速查表为了后续维护方便我把踩过的坑整理成了一张速查表团队新同学接手时不用从头摸索现象常见原因处理方式输出被json等代码块包裹模型习惯性输出富文本用Tool Calling规避或解析前提取JSON主体JSON解析报Expecting valuemax_tokens截断或内容空增加max_tokens余量开启重试int字段变成字符串模型对Schema理解偏差在Field描述里补充示例枚举值非法Schema描述不够明确重试消息里列出合法枚举值中文变成\uXXXX合法unicode转义按需进行ensure_ascii处理重试多次仍失败错误信息未正确回传或模型重复犯错限制重试2次直接走兜底转人工高并发时错误率上升温度或负载波动API限流降温度加并发控制与熔断5. 后续扩展从单一问答器走向多Agent协作5.1 接RAG、接外部系统的扩展点这个问答器目前的知识来源是FAQ检索和简单的订单工具如果要做成更通用的产品形态下一步自然要接RAG。RAG的接入不影响结构化输出层知识库检索结果作为工具返回值给模型模型参考之后依然提交固定的SupportResult只是answer字段的内容会更有事实依据。这里要注意的是上下文里嵌入的文档片段会显著拉长输入token一定要做切片和摘要否则token成本会涨得非常快。外部系统方面结构化输出天生适合对接工单系统、CRM、ERP。问答器返回的SupportResult可以直接映射成工单字段intent对应工单类型skus对应关联商品need_human对应是否自动流转人工。这也是当初把数据契约做得这么严格的原因——它是可以被其他系统直接消费的不是只给前端展示用的。5.2 多Agent编排的实践观察跑完这个单一的问答器我开始把注意力放到多Agent协作上。这里我自己的观察是多Agent编排的核心反而不是“多个模型一起干活”而是每个Agent的职责边界和输出契约都足够清晰。当前这个问答器本身已经具备两个可以复用的特征它有明确的输入输出契约也有独立的工具调用能力。这意味着它可以作为一个子Agent被更大的编排系统调用。比如上层有个“客服中台Agent”它遇到售后问题时可以把当前会话转给这个问答器处理拿到SupportResult后自己决定下一步动作。多Agent没有那么神秘它更像把一个大而全的Agent拆成多个小而专的Agent每个都用自己的结构化输出与外界交互。在这个架构里结构化输出就是Agent之间的共同语言比模型之间互相传自然语言的稳定性高出一个量级。5.3 一点个人体会把这个问答器接进内部工单系统跑了几周之后我最大的体会是结构化输出不是模型性能问题而是系统协作问题。模型本身并没有因为加了Schema约束而变笨反而因为输出目标明确回答质量变得更可控了。整套实践中花时间最多的不是模型调用代码而是字段设计、校验规则、兜底策略这些看起来“不性感”的工程细节。但它们恰恰是Agent能够从玩具走向生产环境的关键。下一阶段我准备把多Agent之间共享记忆和路由这部分沉淀出来等有了稳定的实践经验再来更新这个系列。