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的支持不完整,这时候可以切换到method="json_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_factory=list, description="支撑答案的引用来源列表,没有引用时为空列表" ) need_follow_up: bool = Field(description="是否需要向用户追问更多信息") follow_up_question: Optional[str] = Field( default=None, 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 绑定结构化输出到ChatModel
from langchain_openai import ChatOpenAI llm = ChatOpenAI(model="gpt-4o", temperature=0) structured_llm = llm.with_structured_output(QAResponse)temperature=0很重要,结构化输出场景不需要创造性,温度越低输出越稳定。with_structured_output返回的是一个Runnable,可以直接用invoke调用,传入消息列表,返回的就是QAResponse实例,不需要再手动解析JSON。
如果你用的模型不支持Function Calling,可以这样切换:
structured_llm = llm.with_structured_output(QAResponse, method="json_mode")但json_mode要求Prompt里必须包含“JSON”字样,否则模型可能不按格式返回。我通常会在System Message里加一句“请以JSON格式返回结果”。
4.4 构建完整的问答Chain
from 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_retries=3): for i in range(max_retries): try: return chain.invoke(inputs) except Exception as e: if i == max_retries - 1: raise print(f"第{i+1}次尝试失败,重试中...") 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少了url,Pydantic会报验证错误。我的处理方式是把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代码块 | 模型习惯性包裹JSON | Prompt里加“直接返回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 Calling,Token消耗比普通输出高大概15%到20%。如果每天调用量很大,这个成本差异会很明显。我的做法是对不同场景分级:核心业务走结构化输出,边缘场景走普通输出加正则兜底。这样既保证了关键路径的稳定性,又控制了整体成本。
这套方案我目前跑了大概两个月,日均调用量在五千次左右,格式错误率从最初的30%降到了0.5%以下。剩下的0.5%主要是网络超时和模型服务波动导致的,跟结构化输出本身没关系。如果你也在做类似的事情,建议先从Pydantic模型定义开始,把Schema设计好,后面的事情会顺很多。