
1. 为什么我要做结构化输出问答器做Agent开发的人都有一个共同的痛点大模型返回的内容太“自由”了。你问它一个问题它给你洋洋洒洒写一大段看起来什么都说了但你想把它塞进下游系统里——比如写进数据库、传给前端渲染、或者交给另一个Agent继续处理——立刻就抓瞎了。格式不固定、字段时有时无、嵌套层级全靠运气这种“看起来能用但实际没法用”的输出在生产环境里就是灾难。我最近在做一个知识库问答的Agent项目核心需求很明确用户提问Agent检索资料后返回答案但这个答案必须严格按照我定义的Schema输出——包含答案正文、置信度、引用来源列表、以及是否需要追问的标记。一开始我试过在Prompt里反复强调“请用JSON格式返回”结果十次里有三次会多出解释性文字两次会漏字段还有一次直接把JSON包在Markdown代码块里。后来我改用LangChain的StructuredOutputParser配合Pydantic模型才算真正把这个问题按住了。这篇内容就是把我从“Prompt祈祷式输出”到“Schema强制约束”的完整实践过程拆开来讲。涉及的核心技术点包括LangChain的with_structured_output方法、Pydantic的模型定义技巧、以及在实际问答场景中怎么处理模型不配合的情况。适合已经跑通过基础LangChain Chain、想进一步把Agent输出工程化的朋友。如果你还在用正则表达式从模型输出里抠JSON那这篇应该能帮你省下不少头发。2. 结构化输出问答器的整体设计思路2.1 为什么不用Prompt硬约束最开始我走的是最朴素的路子在System Prompt里写清楚字段要求然后让模型自己生成JSON。这个方法在GPT-4上大概有70%的成功率换到更小的模型直接掉到40%以下。问题出在几个地方模型对“JSON格式”的理解和你的预期经常不一致它可能用单引号、可能加注释、可能在JSON前后加“好的以下是答案”这种废话。更麻烦的是当检索到的上下文很长时模型会优先关注内容质量而忽略格式要求导致输出结构崩塌。后来我算了一笔账每次格式错误都要重试重试意味着额外的Token消耗和延迟。按每天一万次调用算30%的失败率就是三千次无效请求这个成本在真实项目里是不可接受的。所以必须从“请求模型配合”转向“强制模型遵守”。2.2 LangChain结构化输出的三种实现路径LangChain目前提供了几种做结构化输出的方式我逐个试过这里说一下各自的适用场景。第一种是StructuredOutputParser配合ResponseSchema。这是比较老派的做法你需要手动定义每个字段的名称和描述然后Parser会生成格式指令注入到Prompt里最后解析模型输出。优点是兼容性好几乎所有模型都能用缺点是格式指令占Prompt长度而且解析失败时错误信息不够直观。第二种是PydanticOutputParser。你把Pydantic模型传进去它会自动生成JSON Schema并注入Prompt。比第一种更简洁字段类型和描述直接从模型定义里来不用重复写。但本质上还是“请求模型按格式输出”模型不听话的时候照样翻车。第三种是with_structured_output方法。这是LangChain较新版本推出的能力底层依赖模型本身支持的Function Calling或JSON Mode。它不是在Prompt里“请求”格式而是通过API层面的约束让模型必须返回符合Schema的内容。我用下来这是最稳的方案但前提是你用的模型支持这个能力。2.3 我的选型决策Pydantic with_structured_output最终我选的是Pydantic定义Schema、通过with_structured_output绑定到ChatModel上。原因有三点第一Pydantic的模型定义本身就是最好的文档字段类型、默认值、描述一目了然团队协作时不用额外写接口文档第二with_structured_output在支持的模型上几乎不会出现格式错误省去了大量异常处理逻辑第三Pydantic的验证能力可以在模型输出后做二次校验比如置信度必须在0到1之间、引用来源不能为空列表这些约束在Schema层面就挡住了。这里有个细节要注意with_structured_output默认使用Function Calling模式但有些模型对Function Calling的支持不完整这时候可以切换到methodjson_mode。我在测试中发现同样的Schema在json_mode下对嵌套结构的支持更好但要求Prompt里必须出现“JSON”这个词否则模型可能不触发JSON模式。3. Pydantic模型定义的核心细节3.1 字段设计的基本原则定义Pydantic模型看起来简单但字段怎么设直接影响模型的理解和输出质量。我踩过的坑包括字段名用缩写导致模型猜错含义、描述写得太模糊模型不知道怎么填、嵌套层级太深模型直接放弃。我的经验是字段名用完整的英文单词不要用缩写。比如用confidence_score而不是conf用reference_sources而不是refs。描述要写成一句完整的指令比如“答案的置信度0到1之间的浮点数1表示完全确定”。对于枚举类型的字段把所有可能的值在描述里列出来模型会照着选。还有一个技巧是给字段设默认值。比如follow_up_question字段如果不需要追问就设为空字符串这样模型在不需要追问时可以直接省略这个字段减少输出负担。3.2 嵌套模型的处理方式问答场景里经常需要返回引用来源列表每个来源包含标题、URL、相关段落。这种嵌套结构用Pydantic的List[BaseModel]来定义。但要注意嵌套层级最好不要超过两层否则模型在生成时容易漏掉内层字段。我试过一个三层嵌套的结构答案包含多个段落每个段落包含多个引用每个引用包含多个元数据字段。结果模型在生成时经常把第三层的字段漏掉。后来我把结构压平把引用信息作为顶层列表每个引用只保留最必要的三个字段问题就解决了。如果确实需要复杂结构可以考虑分两次调用第一次让模型生成主体答案和引用ID列表第二次根据ID去数据库里查完整信息。这样每次调用的Schema都保持简单成功率更高。3.3 验证器的实战用法Pydantic的field_validator可以在模型输出后做二次校验。我加了两个验证器一个是检查confidence_score是否在0到1之间如果超出范围就截断到边界值另一个是检查reference_sources列表是否为空如果为空但答案正文里包含“根据资料”这类词就自动降低置信度。验证器里不要抛异常因为抛异常会导致整个解析失败。更好的做法是修正值或者记录警告。比如置信度超出范围时我直接clamp到0或1而不是让整个响应作废。这样即使模型输出有小瑕疵下游系统仍然能拿到可用的数据。4. 完整实操流程与关键代码4.1 环境准备与依赖安装先确保你的LangChain版本在0.2以上Pydantic在2.0以上。这两个版本的API和早期版本差异很大网上很多教程还是老版本的写法直接抄会报错。pip install langchain langchain-openai pydantic如果你用的是其他模型提供商把langchain-openai换成对应的包就行。我测试时用的是GPT-4o和本地部署的Qwen2.5-7B前者原生支持结构化输出后者需要通过json_mode来约束。4.2 定义问答输出的Pydantic模型from pydantic import BaseModel, Field, field_validator from typing import List, Optional class ReferenceSource(BaseModel): title: str Field(description引用资料的标题) url: str Field(description引用资料的链接地址) snippet: str Field(description引用资料中与问题最相关的原文片段) class QAResponse(BaseModel): answer: str Field(description对用户问题的完整回答要求准确、简洁) confidence_score: float Field(description答案置信度0到1之间的浮点数) reference_sources: List[ReferenceSource] Field( default_factorylist, description支撑答案的引用来源列表没有引用时为空列表 ) need_follow_up: bool Field(description是否需要向用户追问更多信息) follow_up_question: Optional[str] Field( defaultNone, description需要追问时的问题内容不需要时省略 ) field_validator(confidence_score) classmethod def clamp_confidence(cls, v): return max(0.0, min(1.0, v))这个模型定义里Field的description参数非常关键它会被LangChain转换成JSON Schema的描述直接影响模型对字段的理解。我试过把描述写得很简短结果模型经常把confidence_score填成百分比整数比如填85而不是0.85。后来把描述改成“0到1之间的浮点数”就正常了。4.3 绑定结构化输出到ChatModelfrom langchain_openai import ChatOpenAI llm ChatOpenAI(modelgpt-4o, temperature0) structured_llm llm.with_structured_output(QAResponse)temperature0很重要结构化输出场景不需要创造性温度越低输出越稳定。with_structured_output返回的是一个Runnable可以直接用invoke调用传入消息列表返回的就是QAResponse实例不需要再手动解析JSON。如果你用的模型不支持Function Calling可以这样切换structured_llm llm.with_structured_output(QAResponse, methodjson_mode)但json_mode要求Prompt里必须包含“JSON”字样否则模型可能不按格式返回。我通常会在System Message里加一句“请以JSON格式返回结果”。4.4 构建完整的问答Chainfrom langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser system_template 你是一个知识库问答助手。根据提供的参考资料回答用户问题。 如果参考资料不足以回答问题请如实说明并降低置信度。 请以JSON格式返回结果。 prompt ChatPromptTemplate.from_messages([ (system, system_template), (human, 参考资料\n{context}\n\n用户问题{question}) ]) qa_chain prompt | structured_llm调用的时候result qa_chain.invoke({ context: LangChain是一个用于构建LLM应用的框架..., question: LangChain支持结构化输出吗 }) print(result.answer) print(result.confidence_score) print(result.reference_sources)返回的result直接就是QAResponse对象字段访问用点号IDE里还能自动补全比字典取值舒服多了。4.5 处理模型不配合的情况即使有with_structured_output偶尔还是会遇到模型返回不符合Schema的情况尤其是用本地小模型的时候。我的处理策略是加一层重试机制from langchain_core.runnables import RunnableLambda def safe_invoke(chain, inputs, max_retries3): for i in range(max_retries): try: return chain.invoke(inputs) except Exception as e: if i max_retries - 1: raise print(f第{i1}次尝试失败重试中...) return None重试的时候可以稍微调整Prompt比如加一句“请严格按照Schema返回不要添加额外解释”。实测下来大部分格式问题在第二次重试时就能解决。5. 常见问题与排查技巧实录5.1 模型返回的字段类型不对最常见的问题是数字字段被填成字符串。比如confidence_score应该返回0.85模型返回了0.85。Pydantic在验证时会尝试自动转换但有时候会失败。我的做法是在字段定义时用float类型Pydantic会自动做类型强制转换。如果转换失败说明模型返回的内容完全没法用这时候需要检查Prompt里的描述是否足够清晰。另一个高频问题是布尔字段被填成字符串true或false。Pydantic同样会尝试转换但为了保险我建议在描述里明确写“布尔值true或false”。5.2 嵌套列表为空或缺失当reference_sources应该返回列表但模型返回了空列表时先检查Prompt里是否明确要求了“必须列出所有引用来源”。如果Prompt里说了但模型还是返回空可能是检索到的上下文里确实没有可引用的内容。这时候可以在Chain里加一个前置判断如果检索结果为空直接返回一个预设的响应不走模型调用。如果模型返回的列表里元素缺少字段比如ReferenceSource少了urlPydantic会报验证错误。我的处理方式是把url字段设为可选默认空字符串。这样即使模型漏了也不会导致整个响应失败。5.3 结构化输出导致延迟增加with_structured_output底层走的是Function Calling比普通文本生成多了一次API往返延迟大概增加20%到30%。如果对延迟敏感可以考虑用流式输出但结构化输出本身不支持流式因为需要等完整JSON生成完才能解析。我的折中方案是对于简单问答直接用普通输出加后处理对于需要严格结构的场景才走结构化输出。另外可以把max_tokens设小一点避免模型生成过长的内容。5.4 不同模型的表现差异我测试了四个模型GPT-4o、Claude 3.5 Sonnet、Qwen2.5-7B、Llama 3.1-8B。前两个原生支持结构化输出几乎不会出错。Qwen2.5-7B在json_mode下表现不错但偶尔会漏字段。Llama 3.1-8B需要手动在Prompt里加大量格式说明成功率大概80%。如果你的项目必须用本地小模型建议把Schema尽量简化字段数量控制在5个以内嵌套层级不超过一层。另外可以在Prompt里给一个输出示例模型照着抄的成功率会高很多。5.5 常见问题速查表问题现象可能原因解决方法返回内容包含Markdown代码块模型习惯性包裹JSONPrompt里加“直接返回JSON不要用代码块包裹”字段缺失Schema太复杂或描述不清简化Schema补充字段描述类型错误模型对类型理解偏差在描述里明确类型用Pydantic强制转换置信度超出范围模型填了百分比加field_validator做clamp引用列表为空上下文无相关内容前置判断无检索结果时走预设响应延迟明显增加Function Calling额外往返非必要场景改用普通输出加后处理6. 几个让我少走弯路的实操心得第一个心得是关于Prompt里的格式说明。用了with_structured_output之后很多人觉得不需要在Prompt里写格式要求了其实不然。我建议还是在System Message里简单提一句“请以JSON格式返回”尤其是用json_mode的时候这句话是触发JSON模式的必要条件。但不要写太详细的字段说明因为Schema本身已经通过API传给了模型重复写反而浪费Token。第二个心得是关于错误处理。结构化输出虽然稳但不是100%可靠。我在生产环境里加了两层保护第一层是Pydantic的验证器做值修正第二层是重试机制最多重试两次。如果两次都失败就降级到普通文本输出至少保证用户能拿到答案只是格式不保证。第三个心得是关于Schema的版本管理。Pydantic模型定义变了之后下游系统的解析逻辑也要跟着变。我建议把Schema定义单独放在一个模块里加版本号注释每次修改都记录变更内容。这样出问题的时候能快速定位是Schema变了还是模型行为变了。第四个心得是关于测试。结构化输出的测试不能只测正常情况要专门构造边界用例空上下文、超长上下文、问题与上下文无关、上下文包含冲突信息。我写了一个测试脚本跑100次同样的输入统计格式错误率和字段缺失率。这个数据比任何主观判断都可靠。第五个心得是关于成本。结构化输出因为要走Function CallingToken消耗比普通输出高大概15%到20%。如果每天调用量很大这个成本差异会很明显。我的做法是对不同场景分级核心业务走结构化输出边缘场景走普通输出加正则兜底。这样既保证了关键路径的稳定性又控制了整体成本。这套方案我目前跑了大概两个月日均调用量在五千次左右格式错误率从最初的30%降到了0.5%以下。剩下的0.5%主要是网络超时和模型服务波动导致的跟结构化输出本身没关系。如果你也在做类似的事情建议先从Pydantic模型定义开始把Schema设计好后面的事情会顺很多。