
做Agent问答器最让人抓狂的时刻是什么不是模型答错而是它明明答对了却把答案包装成一团乱麻——开头来一句“您好很高兴为您服务”中间夹上几个表情符号结尾补一段“以上信息仅供参考具体以官方页面为准”。你的下游程序想从这段文本里抽答案、抽置信度、抽来源只能靠正则表达式硬扛扛两天就崩。这就是我在“Agent实践”系列里反复碰到的问题。第4个实践项目我做的“结构化输出问答器”目标就一句话让Agent回答问题时只吐结构化的JSON不吐废话。系统接收用户问题检索知识库生成答案并把答案、置信度、引用来源、是否需要追问等字段以严格JSON Schema的形式交还给上层应用。这篇文章就是把这个项目的完整实现、踩坑过程和扩展思路拆开给你看。无论你是刚接触Agent开发想给自己的机器人加一层稳定输出的协议还是已经在用LangChain、Dify这类框架但被不可控的文本输出搞到头大这篇笔记都适合你。我会从机制原理讲起给出可直接复制的代码实现再重点讲几个我在实测中很难从文档里查到的隐蔽问题。结构化输出不是一个“加了就好”的小功能它是Agent从“聊天玩具”变成“可集成服务”的关键一步。1. 只有先解决“输出人话”才能谈Agent落地先说背景。我在这套问答器之前已经做了三个Agent实践项目分别解决了工具调用、指令跟随和多轮记忆的问题。做到第4个的时候我最大的体会是不管前面的链路设计得多花哨只要最后一公里的输出是“自由文本”整个系统的工程质量就永远停留在Demo阶段。你没法用单元测试去断言一段自由文本的正确性也没法让UI层直接渲染它更没法把它安全地存进数据库供后续流程使用。所以这个项目我把重心全部压在“输出协议”上。问答器本质上是一个典型的Agent应用用户输入问题Agent判断意图检索资料组织答案。而结构化输出的作用就是给这个链路的每一个环节都加上明确的契约边界。1.1 问答器没有结构化解析成本会吃掉你的开发时间先说说我最早踩过的坑。第一版问答器我用的是最朴素的调用方式把用户问题拼进Prompt让模型“正常回答”然后我在后端起一个解析函数靠规则去提取答案文本。听起来简单实际上灾难开始模型会在答案前加“好的呢”这类语气词我的解析逻辑被迫写一份“前缀清洗表”模型偶尔把答案变成了Markdown列表、偶尔变成带标题的短文解析器得同时兼容五六种格式模型偶尔会自己编一个“置信度95%”写在末尾但它的编码口径和我预期的完全不一致有的写0.95有的写99%有的写“很高”最离谱的是当我要求模型给出引用来源时它会把一些看起来像但实际不存在的“文档编号”也给编出来。这些问题的本质不是“模型智力不够”而是没有给输出建立契约。人跟人沟通可以容忍自由发挥程序跟模型沟通绝对不能。只要下游还有代码在消费模型的输出就需要一个严格、稳定、可校验的协议层。1.2 结构化输出解决的恰恰是四个最折磨人的问题我把结构化输出带来的改善总结成四个维度这也是我做这个项目时反复提醒自己的价值锚点维度非结构化输出结构化输出可解析性靠正则和规则硬拆一崩一片直接json.loads字段名固定可靠性字段可能缺失、格式可能漂移Schema校验缺字段立刻发现可观测性模型“理直气壮”但无法审计confidence/sources字段让结果可追溯可扩展性下游系统难以复用同一份JSON可以驱动UI、存储、报表其中“可观测性”这一点容易被忽略但实际价值非常大。当系统出现幻觉答案或者低质量检索时confidence字段和sources字段能让我第一时间定位问题出在哪个环节——是检索没召回正确文档还是模型本身生成走了样。这在非结构化输出的系统里几乎不可能做到。2. 结构化输出的底层机制三层防线怎么选很多教程会把结构化输出简化为“在Prompt里加一句‘请输出JSON’”这话不能说错但远远不够。我做这个项目时把市面上主流的做法拉通测了一遍最后总结成三层防线每一层的稳定性和代价都不同。2.1 第一道防线是提示词第二道是Schema第三道是约束解码第一层防线是在系统提示词里明确要求“只输出JSON不要任何其他文字”。这一层的主要作用是给模型“指明方向”代价为零任何API都支持。但它的稳定性有限尤其当模型上下文很长、回答很复杂时模型偶尔会在JSON前后夹带说明文字。我实测的数据是单纯靠Prompt约束100次调用里有2到5次会出现格式漂移——不是频繁但足够让你的解析管道在半夜报警。第二层防线是使用API原生提供的response_format参数把JSON Schema直接传给模型。以OpenAI API为例你可以在Chat Completions的参数里指定response_format{type: json_schema, json_schema: {...}}也可以简化为{type: json_object}。用了json_schema之后模型会被强制在生成时遵守给定的字段结构、类型和枚举值。这层防线已经比较可靠但要注意它只是“尽量约束”因为有些API实际执行的是“后处理校验重复尝试”策略而不是真正在采样阶段锁死token所以仍需要第三层兜底。第三层防线是约束解码Constrained Decoding。框架层面的工具如Outlines、Llama.cpp的grammar sampling本质上是在词表采样阶段就屏蔽掉不符合规定的token。这一层的稳定性最高接近于100%格式正确代价是你得付出额外的推理开销并且往往要自己部署模型不能直接用托管API。对这个问答器项目来说我选择了“第二层为主、第一层为辅、第三层不做因为用托管API”但保留第三层的概念未来如果迁移到本地模型优先上这套方案。2.2 为什么要用Pydantic作为数据契约在很多实现里人们会手写一大段JSON Schema塞进Prompt这也能跑但维护起来很痛苦——字段一多Schema和实际解析代码容易悄悄分家。我的做法是把数据契约定义成Pydantic模型让Schema从模型自动生成同时让解析也从同一个模型完成。这个设计的关键在于“单一事实来源”Single Source of Truth我只需要维护一个Python类然后# 用同一个Pydantic模型生成Schema传给API schema QAAnswer.model_json_schema() # 也用同一个模型校验API返回的内容 parsed QAAnswer.model_validate_json(response_content)如果前后端字段对不上Pydantic会在校验阶段给出明确错误而不是让程序在深层的字典索引时报一个莫名其妙到看不出原因的KeyError。这就像给模型提供了一张“只有格子没有内容的表格”模型只需要按格子填写而你是那个检查表格的人哪个格子没填、哪个格子填错类型一眼就能看出来。从工程项目而言这种“契约优先”的做法还有一个额外好处测试用例可以直接对照Schema生成CI里跑回归测试时API的响应只要Schema通过率低于99%就直接判失败不用人工一条条看。2.3 不得不提的Function Calling结构化输出的孪生兄弟说到结构化输出一定避不开Function Calling工具调用。我自己在实践里的理解是Function Calling本质上是“结构化输出的一种特殊形式”——它把模型的输出约束成“要调用哪个函数、参数是什么”的固定结构。两者在底层共享了很多机制。在做问答器的过程中我把“生成答案”和“决定是否触发检索”分开成两个阶段第一个阶段模型决定要不要调用retrieve_docs这个工具第二个阶段拿到检索结果后再结构化生成最终答案。这两个阶段的输出都必须是结构化的——第一阶段输出的是函数名和参数第二阶段输出的是答案数据。这套模式做下来比传统的“一步生成”稳定得多因为模型不用在一句话里既做判断又做总结。后面在第5章我会展开这个改造。3. 问答器主链路实现不到300行跑通完整闭环接下来是硬核部分。我先给出整个问答器的架构再分模块拆解每一段代码为什么这么写。3.1 整体链路检索、生成、校验、重试四件套问答器虽然叫“问答器”但真正让它回答出来的内容可靠依赖的是它背后的链路。我的实现分为四个环节接收用户输入做基础清洗去空白、长度限制向量检索召回相关文档片段Top-K召回把用户问题检索片段一起交给LLM要求按Schema生成答案对LLM返回的文本做Pydantic校验不通过则自动重试重试3次仍失败则抛错并返回降级响应。为什么把检索放在LLM之前原因很简单LLM的“知识”是不确定的、静态的检索能给模型提供一份“当前可引用的事实依据”。问答器一旦接到真实用户的业务性问题不是闲聊光靠模型内部知识很容易过时或编造检索增强是把“胡说”概率压下去的最直接手段。我的经验是宁可检索到一堆低分片段让模型筛选也不能让模型空着手答题否则结构化输出的sources字段就会变成幻觉重灾区。3.2 定义答案契约Pydantic模型这么写下面这段代码就是整个系统的数据契约也是唯一需要稳定维护的“接口”。from pydantic import BaseModel, Field from typing import List from enum import Enum class Confidence(str, Enum): high high medium medium low low class Source(BaseModel): doc_id: str Field(description知识库文档ID用于溯源) passage: str Field(description命中的原文片段精简到50字以内) score: float Field(ge0.0, le1.0, description检索相似度得分) class AnswerResponse(BaseModel): answer: str Field(description面向用户的最终回答不要多余寒暄) confidence: Confidence Field(description模型自评置信度) sources: List[Source] Field(default_factorylist, description引用来源) needs_follow_up: bool Field(defaultFalse, description是否需要追问用户澄清) follow_up_question: str Field(default, description如果需要追问此字段存放追问内容)这里有几个细节值得说明confidence字段我特意设计成枚举而不是浮点数。实际测试中发现让模型直接输出0.0到1.0的小数它的“刻度感”极不稳定——同一个答案换个Prompt它就判断自己置信度只有0.2再换个表述又变成0.9。用high/medium/low三档枚举模型对自己的判断反而更稳定也更容易在后端做策略映射。sources字段的类型是一个List[Source]嵌套结构。没有这个字段模型很容易把“有依据”和“编造”混为一谈有了它模型在生成时必须填doc_id相当于强制让模型“交代出处”对压制幻觉有奇效。needs_follow_up和follow_up_question成对出现是为了让问答器在问题不明确时不强行给答案而是结构化地发起一次澄清。3.3 核心实现检索、调用、校验与重试检索部分我用了最简方案——一次性把所有知识库文档预切分成chunks构建TF-IDF向量。生产环境可以替换成嵌入模型加向量库但核心思路完全一致。from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity class SimpleRetriever: def __init__(self, docs: List[str], doc_ids: List[str]): self.doc_ids doc_ids self.vectorizer TfidfVectorizer(ngram_range(1, 2), stop_wordsenglish) self.vectors self.vectorizer.fit_transform(docs) def retrieve(self, question: str, top_k: int 3): q_vec self.vectorizer.transform([question]) scores cosine_similarity(q_vec, self.vectors)[0] top_indices scores.argsort()[-top_k:][::-1] return [ {doc_id: self.doc_ids[i], passage: doc_text[i], score: float(scores[i])} for i in top_indices ]调用LLM的核心函数也不复杂重点是重试逻辑from openai import OpenAI client OpenAI() def ask_structured(question: str, retrieved: List[dict]) - AnswerResponse: context \n\n.join( f[{r[doc_id]}] {r[passage]} for r in retrieved ) sys_prompt SYSTEM_PROMPT.format(contextcontext) messages [ {role: system, content: sys_prompt}, {role: user, content: question}, ] for attempt in range(3): raw client.chat.completions.create( modelgpt-4o-mini, messagesmessages, temperature0.1, response_format{type: json_schema, json_schema: { name: answer_response, schema: AnswerResponse.model_json_schema(), strict: True, }} ).choices[0].message.content try: return AnswerResponse.model_validate_json(raw) except Exception as e: # 把失败信息反馈给模型让它在下一轮修正 messages.append({role: assistant, content: raw}) messages.append({role: user, content: f输出不符合schema请修正。错误{e}}) raise ValueError(连续3次校验失败)这里最容易被忽略的是messages列表中的“反馈修正”策略一旦模型输出不合格就把原输出和错误原因都塞回对话再让它重来一次。这比“默默再调用一次同样的Prompt”有效得多因为模型能看到自己错在了哪里。实测下来单靠这一轮反馈修正几乎能把最终失败率压到1%以下。3.4 Prompt模板的组织方式约束逻辑和业务逻辑分离结构化输出的Prompt不能是一锅粥我会把“输出格式约束”和“业务规则”分开写。格式约束部分保持稳定业务规则部分跟随场景变化。下面是我实际在用的模板骨架你是知识库问答助手。你的任务是根据提供的上下文片段回答用户问题。 ### 输出要求 - 只输出JSON不输出任何其他文字。 - 严格遵循给定JSON Schema。 - answer字段要直接给出结论不要寒暄不超过100字。 - sources字段必须有依据如果没有可靠出处就把sources留空并把confidence设为low。 - 如果问题包含“XX是什么”“XX怎么做”这类表述不清的提问将needs_follow_up设为true并在follow_up_question中写出你的澄清提问。 ### 上下文 {context}把“输出要求”单独作为一个二级标题放在模板里模型遵循的程度会比“埋在长段落中间”高很多。我之前做过一个对比模板里格式约束单独成段时仅靠Prompt约束的一次性格式合规率大约是97.5%把同样的话写到一段长Prompt的末尾合规率掉到91%。这一点看起来玄学但大模型对结构化的提示词确实更敏感。4. 实测中的隐蔽问题三个让我加班到凌晨的坑这一节的价值比前三节加起来都高。代码写出来不是难点真正让你在深夜挠头的是那些“看起来一切正常但结果就是不对”的暗坑。4.1 模型“假装自信”confidence字段的语义漂移第一个坑发生在我加了confidence字段之后。最初版本用的是小数float我在Prompt里写“请评估回答的置信度0到1之间”。结果跑了一周review数据时吓一跳大量回答的confidence长期稳定在0.9以上哪怕答案本身跟检索到的上下文根本对不上。我当时的排查链路是这样的先怀疑是检索器问题做了top-K审查发现召回也确实一般再怀疑是LLM问题把同一个问题换了三种不同的表述去问果然confidence出现了0.2到0.95的巨大浮动。最后定位到的根因有三点一是Prompt里的“置信度”对模型来说太抽象它倾向于给出乐观估计二是温度0.7时随机性让数值不稳定三是float自由输出时模型根本不知道人类对0.8和0.9的边界感受差异。修复方式是一次组合拳把confidence改成三档枚举、把temperature压到0.1、增加一个reasoning日志字段模型内部先描述一句自己判断出这个置信度的理由再输出枚举值。改完之后confidence与真实回答质量的相关性明显变强——低分检索时模型终于会老老实实给出medium甚至low。这个细节让我意识到结构化输出不只是“格式化”更要考虑每个字段的度量语义。4.2 上下文污染某次坏输出会持续毒害后续请求第二个坑更隐蔽。我的问答器服务是多轮对话的用户问答记录会作为历史上下文传入。有一天发现某个会话聊了三轮后模型输出的JSON开始出现莫名其妙的“前缀文本”比如在JSON前面多了一行“好的以下是回答”。初步排查时我以为又是模型随机抽风。后来把那段对话的完整请求体打印出来才发现真相第二轮时模型曾在某个特殊问题上产生过一次非结构化输出当时还没上response_format而我没有任何过滤机制直接把这个坏文本存进了历史。从第三轮开始模型看到历史里有一个“好的以下是回答”的样例就自发模仿了这个风格。LLM是强大的模仿者上下文里出现什么格式它就倾向于延续什么格式。修复方法有两步第一历史字段不再存原始assistant文本而是存AnswerResponse.model_dump()之后的重组文本保证每一条进上下文的消息都是规范的第二在每一轮请求发起前重新注入一次最新的格式约束系统提示词——不让历史消息“稀释”格式指令的优先级。后来我还在服务端做了统一拦截所有assistant输出先过Pydantic不过就直接拒绝进历史。这样即使某个上游调用出了问题也不会污染后续轮次。4.3 不同API的Schema实现差异不能“裸奔”靠后端校验兜底第三个坑是兼容性问题。这个项目我前后适配过OpenAI、Groq、还有国内几个兼容Chat Completions协议的API。每个平台的response_format实现细节都不同。比如有的平台支持json_schema参数有的只支持json_object有的平台会强制要求name字段、有的会把嵌套的description忽略掉更难受的是有的平台虽然接受了Schema但实际遵守得很松散——枚举值它给你输出个“High”而不是“high”直接让Pydantic校验炸掉。我的应对思路是“默认不信任API的强制能力后端校验永远是最后一道闸门”。具体做了三件事所有平台调用统一走同一个ask_structured封装内部按平台能力决定是传完整Schema、只传json_object、还是退回纯Prompt约束在AnswerResponse.model_validate_json之外再写一个normalize_field函数对枚举字段的大小写、浮点数字符串化等常见偏差做归一化然后才做严格校验每次调用落一条审计日志记录平台名、用的约束类型和最终的校验结果。长期统计下来我就能知道哪个平台的格式稳定性最差决定是否给它降权或加更多重试。这三招做完之后跨平台迁移的“惊悚时刻”基本绝迹。现在每当团队要新增一个模型供应商我只需要跑一遍那个平台的审计日志就能判断该不该在响应格式层面加一层额外的兜底策略。5. 从单轮问答到带工具的多轮智能体这条链路还能怎么扩最后聊扩展方向。结构化输出问答器不是终点我在这套核心上做了三种演进每一种都以“保持结构化”为前提否则一扩展就乱。5.1 第一步进化加记忆让问答器不再是“金鱼”第一个方案是给系统加记忆层。我的做法是维护一份“会话状态”结构化对象比如SessionState(history, last_topic, resolved, pending_question)每轮结束时更新这份对象下一轮开始时拼进系统提示词或消息历史。这里有个重点记忆内容本身也要结构化。我见过很多人把记忆直接存成“用户上一句话的原文模型上一句的原文”然后整段塞给模型。这种做法在多轮问答中很容易把格式污染问题放大就是第4.2节那个坑。我的做法是只向模型暴露“经过提炼的状态”——比如“用户当前关注的话题是报销流程”“上一轮澄清问题尚未得到答复”而不是原始对话文本。这样既保留了对多轮语境的感知又大幅减少了上下文噪音。5.2 第二步进化加工具调用把“问答”变成“办事”问答器一旦能调用工具就从“信息复述机”升级成“可行动体”。我在这套系统里加了两个工具一个是search_billing_policy查计费政策另一个是lookup_user_account查账户信息。模型在生成最终答案前先通过Function Calling发出工具调用请求拿到工具返回的结构化数据后再走问答器主链路生成最终答案。这一块的实现逻辑与前面最大不同是模型输出被分成了“工具调用决策”和“最终答案”两个阶段但两个阶段都保持结构化。工具调用的参数由JSON Schema约束比如必须传user_id、query_type工具返回的内容也统一为固定的数据类。这样整条链路从输入到输出都是可验证的中间每一跳都有Schema兜底调试时哪一环出问题日志里一目了然。5.3 第三步进化建一套格式稳定性测评集别让质量悄悄退化最后分享一个很多教程不会提的建议给结构化输出建一套回归测试集。这个做法救了我好几次。我把真实用户问题攒了100条同时让人工标定好“期望的答案类型、是否应触发追问、sources应该有几个”等标签。每次改动Prompt或升级模型版本就离线跑一遍这100条。关注三个指标格式合规率Schema校验通过的比例、字段填充率各字段缺失或为空的占比、置信校准度高置信度回答里人工评分为对的比例。这三个指标不是说“跑一次就行”而是要持续监控。我有一次升级模型版本后格式合规率虽然还是100%但sources字段的填充率从87%掉到了61%——答案依然规范但模型开始偷懒不引用来源。如果不回头看评测集这个问题可能要几个星期后才在线上暴露出来。有了这套回归机制结构化输出系统才算真正达到了生产标准。我自己现在做Agent项目的习惯是任何依赖LLM输出的模块第一步先把“数据契约”和“评测集”钉死再开始写业务代码。结构化的本质不是让输出变漂亮而是让系统变可靠——而可靠性只能靠约束加验证这两件事同时做到位才能拿到。这个问答器做完之后我对“Agent能干什么”的理解也变了一点Agent跟普通脚本的区别不在于它会“聊天”而在于它能在约束下稳定地执行任务链。结构化输出恰好就是那条约束的锚链。后续如果你想在这个项目上继续扩展不妨从“给sources加段落级定位”“把记忆状态升级成图谱结构”或者“接入本地模型约束解码”这几个方向入手每一步都不会让你失望。