Agent实践:结构化输出问答器从原理到工程实现
1. 先聊聊为什么问答器一定要结构化输出做Agent最怕什么怕模型答非所问更怕它答对了却说不成机器能听懂的话。在Agent实践系列进行到第四篇时我决定好好聊一聊结构化输出——也就是让模型严格按JSON Schema吐结果的能力。为什么要把这个单独拿出来讲因为不管是自己搭Agent还是接LangChain、Dify这类框架最后总绕不开一个环节把用户的自然语言问题翻译成固定结构的应答。问答器这个组件听起来简单但真正让它稳定可用就靠结构化输出这层地基。1.1 一句话说清结构化输出是什么结构化输出本质上不是让模型写得规范一点而是让模型输出的内容可以直接被代码消费。举个例子你在做一个客服问答Agent用户问我们公司笔记本的返修流程是什么如果你只是把模型的回答扔回聊天窗口那模型的文本怎么啰嗦都行但如果这个回答要进入工单系统、要自动归档、要触发后续的库存查询那回答就必须是带固定字段的JSON比如{ category: 售后流程, intent: 售后咨询, answer: 笔记本返修需要先提交工单……, ticket_action: create_ticket, confidence: 0.94 }有了这个结构后面的代码就可以直接读ticket_action判断要不要开单读confidence决定要不要转人工。整个Agent的决策链路才走得通。我在实践里见过太多同学用了市面上各种Agent框架写了一大堆路由逻辑最后卡在模型输出不可解析上。Prompt里明明写了请返回JSON模型还是偶尔给你来一句好的下面我为您列出……再接一段带Markdown的JSON。这就是典型的没有把结构化输出当作独立工程问题来处理。1.2 直接让模型返回JSON到底哪里会翻车很多人第一次做问答器直接在System Prompt里写你必须返回JSON格式。这种做法在小规模试验里可能还行一旦进入真实流量问题就会集中爆发。第一是格式不稳定。同一个问题问十次模型可能返回带json围栏的、不带围栏的、字段顺序不一样的、甚至字段名从answer变成response的。这些东西人眼看不出来但代码解析时就是灾难。第二是内容污染。模型有时候会在JSON旁边附一句这是您要的信息这句多余的话会直接导致json.loads报错。第三是字段缺失。你定义好五个字段模型心情不好只给了三个剩下的字段去哪了完全不可控。第四是上下文截断。输出长度超了max_tokensJSON直接被腰斩连闭合大括号都没有。这些坑单靠Prompt Engineering很难全部堵死因为模型是概率生成你管得住它的语气管不住它在每个token上的选择。真正的解法是让结构化输出成为一个有约束、有校验、有重试的工程闭环。2. 两条技术路线约束生成还是校验重试聊清楚问题之后我们要想明白解决结构化输出到底有几条路。市面上的方案看着五花八门扒开内核其实就两条一条是约束解码一条是校验重试。我用了一个月时间把这两条路都踩了一遍这里把原理和适用场景摊开讲。2.1 约束解码让模型在token层面说不出非法格式约束解码的路子非常硬核既然模型是一次一个token往外蹦的那我就在它每次要选token的时候把不符合JSON Schema的候选token全部屏蔽掉。它想输出}但如果这会导致提前闭合导致非法JSON就强制它只能选}之外的其他token。这样生成的文本在数学上保证是合法的。这听着很理想但代价也很实在。首先它需要你在推理层面就能干预采样过程所以通常要配合专用推理引擎来做比如vLLM的guided decoding、llama.cpp的grammar、Outlines库对HuggingFace模型的支持等。如果你只是调用一个云端API比如OpenAI或通义那就根本改不了采样逻辑只能依赖云端已经暴露出来的JSON Mode或Structured Output功能。云端API的JSON Mode实际上也是一种近似约束——它用特殊的schema引导模型大部分情况下结果合规但在极端情况下仍然可能输出噪声。所以即便我用过不少支持约束解码的服务也不会把鸡蛋全放在一个篮子里。2.2 校验重试把格式问题交给后置逻辑兜底第二条路更接地气先让模型自由发挥返回JSON然后用代码去解析解析成功就收下解析失败就把错误信息反馈给模型让它重新生成。这个生成—解析—报错—再生成的循环看起来有点笨但在很多场景下反而更稳因为云端模型自己带JSON Mode的话第一遍成功率就已经非常高了重试只处理那百分之几的漏网之鱼。校验重试的核心在于把解析器变成Prompt的一部分。你不仅要告诉模型请返回JSON还要把JSON的字段定义、类型、约束条件给它看同时把上次解析失败的错误消息也回传给模型。模型看到你上次输出的answer字段是null这个字段不允许为空之后几乎都能正确地修正自己。说白了这玩的是大模型自己的纠错能力。2.3 主流SDK和框架各自走的哪条路为了让大家少走弯路我做了一张对比表把市面上主流的做法简单归了一下类方案底层路线适用场景备注OpenAI JSON Mode / Structured Output服务端约束后校验云端API支持JSON Schema推荐优先启用Anthropic Claude 工具调用约束生成云端API借助tool definition做结构化通义/文心 等国内云厂商服务端约束云端API各家能力差异大需实测vLLM / llama.cpp guided decoding本地约束解码本地部署可控性最强但工程成本高Outlines本地约束解码本地推理搭配HuggingFace生态Pydantic 手动重试校验重试任何场景通用方案最推荐起步Instructor校验重试 自动重试任何场景封装得很好的库我有段时间特别迷信约束解码觉得这才是正路。后来发现本地部署一套支持guided decoding的推理服务光K8s运维成本就够喝一壶。反而是Pydantic加两行重试逻辑扛住了大部分生产流量。这个结论可能不够浪漫但非常真实。3. 不依赖框架从零搭一个结构化输出问答器上一节聊得再热闹最终还是得落到代码上。这一节我会带你从零写一个不依赖LangChain之类重框架的结构化输出问答器。选择这么干的原因很简单只有亲手写过一遍约束、解析、重试的流程你才会真正理解那些框架替你做了什么事也才能在框架出问题的时候自己定位。3.1 定义输出Schema我习惯用Pydantic定义Schema因为它的类型校验、默认值、嵌套结构都是一等的。以客服问答器为例我希望模型每次回答都输出这样一个结构from typing import Literal, Optional from pydantic import BaseModel, Field class QaResponse(BaseModel): category: Literal[售后, 售前, 产品咨询, 其他] Field(description问题分类) intent: str Field(description用户真实意图保持简短) answer: str Field(description面向用户的最终回答) ticket_action: Literal[create, update, none] Field( defaultnone, description是否需要操作工单 ) confidence: float Field(ge0.0, le1.0, description回答置信度)注意我用了Literal把category和ticket_action的取值范围写死。这样模型能明确知道只能在这几个值里选后续接业务逻辑也更安全。confidence给了0到1的区间如果你忘了加模型可能给你输出一个文本型的高那解析阶段就尴尬了。3.2 构造Prompt并调用模型接口定义好Schema之后最重要的一步是把它注入Prompt。很多人直接把Pydantic的model_json_schema()打印出来塞给模型这没问题但描述字段最好精简。Schema里的description在这一步会变成模型理解语义的关键而不是可有可无的注释。下面是一个构造请求的示例我以OpenAI SDK为例但代码同样适用于绝大多数OpenAI兼容接口import json from openai import OpenAI def build_messages(question: str, schema: dict) - list: system_prompt f 你是结构化问答器。请严格依据用户问题生成回答。 你必须输出严格的JSON对象不要包含任何额外文字、Markdown标记或解释。 输出JSON需匹配以下JSON Schema: {json.dumps(schema, ensure_asciiFalse, indent2)} .strip() return [ {role: system, content: system_prompt}, {role: user, content: question}, ] client OpenAI() schema QaResponse.model_json_schema() messages build_messages(我买的笔记本充电器坏了能换新吗, schema) resp client.chat.completions.create( modelgpt-4o-mini, messagesmessages, response_format{type: json_object}, temperature0.2, )这里有两个细节值得说透。第一个是response_format只要你的模型服务支持JSON Mode就一定要开。它会把模型的输出往合法JSON方向上推一把效果立竿见影。第二个是temperature问答器的结构化输出场景不是写诗建议把温度压到0.2以下采样空间收窄之后输出稳定性明显提升。3.3 用代码做校验与重试拿到模型输出字符串之后真正的工程挑战才刚开始。我会先尝试json.loads再尝试QaResponse.model_validate两步都过了才算成功。一旦失败把错误信息带回Prompt让模型重新生成。from pydantic import ValidationError def strict_parse(model, raw: str): try: data json.loads(raw) except json.JSONDecodeError as e: error_msg fJSON解析失败: {e} return None, error_msg try: instance model.model_validate(data) return instance, None except ValidationError as e: return None, 字段校验失败: str(e.errors()) def run_qa(question: str, max_attempts: int 3): schema QaResponse.model_json_schema() messages build_messages(question, schema) for attempt in range(max_attempts): resp client.chat.completions.create( modelgpt-4o-mini, messagesmessages, response_format{type: json_object}, temperature0.2, ) raw resp.choices[0].message.content parsed, error strict_parse(QaResponse, raw) if parsed is not None: return parsed messages.append({role: assistant, content: raw}) messages.append({ role: user, content: f你的输出不符合要求错误如下{error}。请严格按JSON Schema重新输出。 }) raise RuntimeError(连续多次解析失败停止重试)重试时我用了一种简单但很关键的策略把模型上一次的原始输出raw也追加进对话。这样模型能亲眼看到自己犯了什么错再收到错误说明时就有了双重参照。实测这个组合比单纯报错成功率高出不少因为大模型对自己的输出其实是有认知的你给它看错误现场它修正起来比空泛地要求重新生成靠谱得多。3.4 完整的最小可运行示例把上面几段串起来就是一个能直接跑的最小问答器。这里我顺手加了一个简单的auto_route逻辑用来演示结构化输出的价值——同一个输出既能给用户看也能给代码用if __name__ __main__: result run_qa(笔记本充电器坏了可以换吗) print(回答用户:, result.answer) print(分类:, result.category) print(置信度:, result.confidence) if result.ticket_action create: create_ticket(user_question充电器损坏, answerresult.answer)这段代码放在任何Agent项目的核心模块里都不突兀。你完全可以把它再包一层变成一个HTTP服务、一个消息队列消费者或者塞进你现在正用的Agent框架里当一个基础组件。它就是问答器最朴素的形态。4. 工程化选型LangChain / Instructor / Dify怎么接入从零实现过一遍之后你已经有了内力。这时候再去看框架就不会一头雾水了。本节我会对比三种常见的工程化路线并给出我的选型建议。4.1 先看一张能力对比表很多人在LangChain、Instructor、Dify之间反复横跳其实它们解决的不是同一个层面的问题。我把它拉到结构化输出这个维度来做对比方案封装深度学习曲线适合场景LangChain有with_structured_output方法中等已经在用LangChain构建复杂链路的团队Instructor薄封装绑定Pydantic低需要精细控制校验与重试逻辑的开发者Dify可视化编排Schema节点自动生成低不想写代码、快速搭建Agent应用的团队LlamaIndex有类似structured extractor中等文档问答类Agent我的原则是你自己代码里已经有复杂的流程编排需求再用LangChain如果只是希望模型输出别翻车Instructor足够如果一个非技术同学也想搭问答器Dify是最快路径。4.2 LangChain的with_structured_outputLangChain的最大价值在于把调用模型解析格式这个动作封装成了标准接口。你只要定义好Pydantic类然后挂到LLM上from pydantic import BaseModel, Field from langchain_openai import ChatOpenAI from langchain_core.output_parsers import PydanticOutputParser class QaOutput(BaseModel): answer: str Field(description最终回答) is_actionable: bool Field(description是否触发后续动作) llm ChatOpenAI(modelgpt-4o-mini, temperature0) llm llm.with_structured_output(QaOutput) result llm.invoke(请提取工单描述并告诉我是否可以自动处理) print(result.answer) print(result.is_actionable)这段代码背后做的事情跟我手写的那套基本一致构造schema、注入Prompt、解析结果。区别在于LangChain把这一套固化成了接口并且在链式调用时能和检索、记忆组件拼在一起。我提醒一句用LangChain时一定要确认你用的模型服务支持它期望的输出格式类型。有的with_structured_output默认走tool calling你的云端API如果不支持函数调用就跑不起来。4.3 Instructor以Pydantic为第一公民如果你跟我一样喜欢薄封装、少魔法的风格Instructor会让你用起来很舒服。它的核心思路就一句话让你用Pydantic定义输出结构然后框架负责调用模型、解析、校验、自动重试。import instructor from openai import OpenAI class QaOutput(BaseModel): answer: str is_actionable: bool client instructor.from_openai(OpenAI()) result client.chat.completions.create( modelgpt-4o-mini, response_modelQaOutput, messages[{role: user, content: 工单打印机无法开启配件保修}], max_retries2, )代码的简洁度明显比裸调高了一截。max_retries2的背后就是重试逻辑——解析失败后Instructor会把错误信息重新喂给模型。我建议把它用在我们自研的Agent内部作为一个基础组件既不像LangChain那样重又能覆盖绝大部分结构化需求。4.4 Dify工作流里的结构化节点如果你的团队里有大量非技术角色或者业务方想快速看效果Dify可能是最合适的。Dify在Agent应用的编排里内置了结构化输出节点你只需要在界面上粘贴你的JSON Schema后面的解析、类型校验、错误提示都是自动的。业务人员甚至能在如果解析失败就执行这个分支的可视化条件里拖出一个兜底节点。不过可视化编排也有它的天花板。我在实际项目里发现Dify对复杂嵌套Schema和自定义后处理逻辑的支持不如手写代码灵活。一旦你遇到校验通过但业务上还需要过滤某些字段值这类需求还是得回到代码里去改。所以我的建议是原型验证用Dify正式服务还是以代码为主Dify当管理后台用。5. 生产环境下常见翻车点与止损策略写代码是快乐的上线是痛苦的。这节把我自己在生产环境里踩过的坑集中整理出一份清单。你在自己的项目里大概率会撞上至少两三个。5.1 JSON解析失败的真实案例有一次我和团队排查线上问答器故障用户反馈有时候可以正常回复有时候直接转人工。我们查了日志发现是模型返回的JSON里一个字符串字段的content里包含了换行符和未转义的引号。json.loads直接抛错而我们的重试逻辑又因为犯了另一个错——它只重试了两次且没有把错误详情拼进下一次Prompt——所以第二遍模型依然返回了相似的问题最终整单转人工。后来我们改用strict_parse加错误回传重试成功率直接上了好几个百分点。这个案例想说明的是解析失败不是偶然Bug它是结构化输出的必然组成部分。既然一定有少数几次失败你的止损策略就不是杜绝失败而是失败后快速修正。5.2 中文内容的转义和符号污染中文环境下还有一类很隐蔽的问题模型在JSON字符串里输出中文标点比如把冒号写成全角、把引号写成中文引号“”。这在json.loads阶段很可能不报错但解析出来的字符串内容是脏的存进数据库后再展示时就会出现格式混乱。我习惯在解析之后加一个清洗步骤把全角标点统一转成半角顺便去掉字符串两端的多余空白和换行。这个步骤放在业务使用之前既不影响模型原意也能让下游展示层少挨骂。def clean_value(s: str) - str: if not isinstance(s, str): return s table str.maketrans({: ,, 。: ., : : , “: , ”: }) return s.translate(table).strip()5.3 Schema复杂度的取舍我见过有人把一张业务主表、五张关联表全部塞进一个JSON Schema里要求模型一次性填完。结果就是字段越多模型漏填的越多校验失败率越高重试成本越大。这里有一个残酷的规律模型处理结构化输出的能力会随Schema复杂度急剧下降。我的经验是把单个Schema控制在五六层以内、字段控制在十来个以内。如果确实需要复杂的嵌套结构就拆成多轮结构化问答——第一个问答器先把问题分类第二个再按不同分类拉取不同字段。拆开之后每一层结构都非常简单模型几乎不会在这个维度上翻车。5.4 重试、限流与成本控制重试不是免费的。我在一个高并发Agent里看到过这种情况主模型因为限流返回503调用方连续重试三次每次都触发从库查询、语音合成等下游动作结果成本和延迟全部涨上去。所以重试策略一定要设定上限并且加上指数退避。平均失败率在3%到5%时一次重试就能救回来超过三次还失败的就别再强求了直接降级到人工或其他兜底流程。5.5 结构化结果变成可执行指令时的安全边界最后必须提醒一点结构化输出里的字段如果会被下游直接拿去执行比如ticket_action要触发开单、command参数要传给某个自动化工具那它的安全性就不能只看格式合不合规了。我见过有人把模型的输出直接当Shell命令跑结果一条意外的注解文字都变成执行指令。那些支持function calling的Agent框架看似封得很安全实际上函数名和参数都是模型自己生成的就有注入风险。我的建议是给所有可能落地执行的结构化字段加白名单而不是黑名单。字段值不在预设集合里就一律拒绝并走人工确认对字符串字段做指令类内容的关键词过滤对包含URL、文件路径、数据库语句的字段额外做格式校验。结构化输出让流程自动化成为可能但自动化意味着每一个错误都会被放大安全边界必须前置。跑通了上面这些坑之后你再回头看Agent实践4-结构化输出问答器这个主题会发现它其实不是一个独立的玩具项目而是整个Agent关键路径上必须过关的一个组件。从手写JSON解析到引入SDK从管理重试策略到拆分Schema每一步都有看似简单、实则影响全局的决策点。做完这一层你再去配Agent的记忆、规划、工具调用会省下大量排查格式问题的时间。