☰
LangChain+Pydantic结构化输出问答器实战:Schema设计与重试降级
2026/10/9 6:48:35 网站建设 项目流程

1. 为什么我要做结构化输出问答器

做Agent开发的人都有一个共同的痛点:大模型返回的内容太“自由”了。你问它一个问题,它给你洋洋洒洒写一大段,看起来什么都说了,但你想把它塞进下游系统里——比如存数据库、调API、做条件判断——立刻就抓瞎了。我最近在做一个内部知识库问答的项目,需要模型返回的答案能直接被程序解析,而不是靠正则去“猜”它想表达什么。这就是我做这个结构化输出问答器的直接动机。

这个问答器的核心目标很明确:用户输入一个问题,Agent基于给定的上下文或知识库检索结果,输出一个严格符合预定义Schema的JSON对象,包含答案正文、置信度、引用来源、以及是否需要转人工等字段。整个链路用LangChain做编排,用Pydantic做Schema定义和校验。说白了,就是让大模型的输出从“散文”变成“填表”。

适合谁来参考这篇内容?如果你已经跑通过最简单的LangChain Chain,知道PromptTemplate和LLM的基本用法,但还没系统搞过结构化输出,那这篇就是写给你的。如果你正在纠结“到底用JSON mode还是Function Calling还是Output Parser”,我也会把选型逻辑讲清楚。至于完全没接触过LangChain的读者,建议先补一下LCEL表达式的基础,不然有些代码片段看起来会有点跳跃。

我踩过的最大一个坑是:一开始觉得结构化输出不就是写个Pydantic模型然后with_structured_output一下嘛,能有多难?结果实际跑起来发现,模型该不按格式来还是不按格式来,尤其是当问题比较复杂、需要多步推理的时候,Schema约束和推理质量之间会产生冲突。这个矛盾贯穿了整个项目的迭代过程,后面会详细展开。

2. 整体架构设计与技术选型思路

2.1 为什么是LangChain加Pydantic这个组合

LangChain在Agent编排上的优势在于它把“模型调用、工具使用、输出解析”这三件事串成了一条流水线。你不需要自己写状态机去管理多轮交互,LCEL的管道语法天然适合这种“输入→处理→结构化输出”的场景。而Pydantic在这个组合里扮演的是“合同”的角色——它定义了模型必须遵守的输出格式,同时在运行时做校验,格式不对直接报错,不会让脏数据流到下游。

有人可能会问:为什么不直接用OpenAI的Function Calling或者JSON mode?我的实测结论是,Function Calling确实能强制模型输出JSON,但它的Schema表达能力有限,嵌套层级深了之后模型容易漏字段。JSON mode更宽松,只保证是合法JSON,不保证字段对不对。Pydantic加LangChain的with_structured_output本质上是在Prompt层面和解析层面做了双重约束,灵活性更高,而且换模型的时候迁移成本低——你不需要为每个模型单独适配Function Calling的格式。

还有一个现实考量:LangChain的生态里已经有大量现成的Output Parser,比如PydanticOutputParser、JsonOutputFunctionsParser,你不需要从零造轮子。但要注意,不同Parser的行为差异很大,选错了会在边界情况上浪费很多时间。

2.2 问答器的核心数据流

整个问答器的数据流可以拆成四段:输入预处理 → 上下文检索 → 结构化生成 → 后校验与降级。

输入预处理阶段做两件事:一是对用户问题做意图分类,判断是“事实型问题”还是“操作型问题”还是“闲聊”;二是根据意图决定是否需要走检索。事实型问题必须带上下文,闲聊可以直接让模型自由回答但也要套Schema。

上下文检索这块我用的是简单的向量相似度检索,没有上太复杂的RAG架构,因为本文的重点是结构化输出,检索部分够用就行。检索回来的文档片段会作为context注入Prompt。

结构化生成是核心环节。我定义了一个Pydantic模型叫QAResponse,包含以下字段:

from pydantic import BaseModel, Field from typing import List, Optional from enum import Enum class ConfidenceLevel(str, Enum): high = "high" medium = "medium" low = "low" class SourceRef(BaseModel): doc_id: str = Field(description="来源文档的唯一标识") snippet: str = Field(description="引用的原文片段,不超过200字") class QAResponse(BaseModel): answer: str = Field(description="对用户问题的直接回答,控制在500字以内") confidence: ConfidenceLevel = Field(description="回答的置信度等级") sources: List[SourceRef] = Field(default_factory=list, description="引用的来源列表") needs_human: bool = Field(description="是否需要转人工处理") follow_up: Optional[str] = Field(None, description="建议的追问方向")

这个Schema的设计有几个讲究。confidence用枚举而不是浮点数,是因为模型对数值的校准很差,给个0.87你也不知道它到底有多确信,但让它选high/medium/low反而更稳定。sources用列表而不是单个字符串,是为了支持多来源引用,同时每个来源带snippet方便前端展示。needs_human这个布尔字段在实际业务里非常关键——当置信度为low或者问题涉及敏感操作时,自动转人工。

后校验与降级是最后一道防线。即使前面做了约束,模型偶尔还是会输出不符合Schema的内容。我的做法是:先尝试用Pydantic解析,失败则触发一次重试,重试时在Prompt里把错误信息带上,让模型自己修正。如果重试两次还是失败,就走降级逻辑——返回一个默认的QAResponse,confidence设为low,needs_human设为true,同时记录日志。

2.3 选型对比:几种结构化输出方案的实测差异

方案格式约束力嵌套支持换模型成本适用场景
Prompt+正则解析弱差低快速原型,字段极少
JSON mode中中低简单扁平结构
Function Calling强中高字段固定的生产环境
Pydantic+Output Parser强好中复杂Schema,需校验
with_structured_output强好低LangChain生态内首选

我最终选的是with_structured_output配合Pydantic模型。它在LangChain里的调用方式最简洁,而且底层会根据模型能力自动选择用Function Calling还是JSON mode,相当于帮你做了适配。实测下来,GPT-4级别的模型基本一次就能输出合规内容,小模型需要重试的概率明显更高。

3. 核心细节解析与实操要点

3.1 Prompt设计里的三个关键决策

结构化输出的成败,一半在Schema,一半在Prompt。我在Prompt设计上做了三个关键决策,每一个都对应着实际踩过的坑。

第一个决策:把Schema的字段说明直接写进System Prompt,而不是只靠Pydantic的description。Pydantic的Field(description=...)在with_structured_output里确实会被传给模型,但实测发现模型对System Prompt里的自然语言说明更敏感。所以我在System Prompt里用一段话把每个字段的要求复述了一遍,尤其是confidence的判定标准——什么情况算high,什么情况算low,给了具体例子。这样做的效果是置信度字段的分布明显更合理了,之前模型倾向于全给high,现在会有一部分medium和low。

第二个决策:要求模型先输出推理过程,再输出结构化结果。这个技巧来自一个很实际的观察:如果直接让模型填Schema,它在复杂问题上容易“偷懒”,答案质量下降。但如果让它在answer字段之前先在一个reasoning字段里写推理步骤,答案的准确率会提升。不过reasoning字段我不建议暴露给最终用户,它只是给模型自己用的“草稿纸”。在Schema里可以加一个reasoning: str字段,后处理时把它去掉。

第三个决策:在Prompt里明确“不知道就说不知道”。结构化输出最怕模型硬编答案。我在Prompt里加了一句:“如果上下文不足以回答问题,confidence必须设为low,answer字段写‘根据现有资料无法确定’,不要编造。”这句话加上之后,幻觉率明显下降。

3.2 Pydantic模型设计的五个避坑要点

Pydantic模型看起来简单,但在和LLM配合的时候有很多细节要注意。

第一,字段顺序有影响。模型是自回归生成的,先输出的字段会影响后输出的字段。我把reasoning放在第一个,answer放在第二个,confidence放在后面。这样模型先想再答,最后再评估置信度,逻辑上更顺。

第二,枚举值要用英文。我试过用中文枚举值,比如高/中/低,结果模型偶尔会输出较高这种不在枚举里的值。换成high/medium/low之后稳定多了。原因是模型在英文token上的生成更确定。

第三,可选字段要慎用。Optional字段在Schema里是可选的,但模型往往会“忘记”填或者填个null。如果某个字段业务上必须有值,就不要用Optional,宁可给它一个默认值。

第四,嵌套层级不要超过三层。我试过一个四层嵌套的Schema,模型输出合规率直接掉到60%以下。后来把结构拍平到两层,合规率回到95%以上。如果业务上确实需要深层嵌套,建议拆成多次调用,每次输出一层。

第五,给列表字段设max_length。sources字段如果不限制长度,模型有时候会列十几条来源,把输出撑得很长。加上max_length=5之后,输出长度可控了,而且模型会优先选最相关的来源。

3.3 重试机制的设计细节

重试不是简单地“再调一次”,那样大概率还是错。我的重试机制包含三个要素:错误信息回传、温度调整、次数上限。

错误信息回传是指,第一次解析失败后,把Pydantic的ValidationError信息格式化后追加到对话里,让模型看到自己哪里错了。比如“字段confidence的值'较高'不在允许的枚举['high','medium','low']中”,模型看到这个提示后第二次基本能改对。

温度调整是指,重试时把temperature从0调到0.3。这个反直觉——通常我们觉得重试应该更确定,但实测发现稍微提高温度能让模型跳出第一次的错误模式。当然这个技巧不是万能的,如果第一次是格式错误,调温度有用;如果是知识错误,调温度没用。

次数上限我设的是2次。超过2次还在错的,说明要么Schema设计有问题,要么模型能力不够,再重试也是浪费token。这时候直接走降级逻辑更划算。

注意:重试的Prompt里不要把原始问题再重复一遍,只追加错误信息即可。重复问题会让模型困惑,以为你在问一个新问题。

4. 完整实操流程与核心代码实现

4.1 环境准备与依赖安装

先把环境搭起来。我用的Python版本是3.10,LangChain的版本要注意,0.1.x和0.2.x的API差异比较大,下面代码基于0.2.x。

pip install langchain langchain-openai langchain-community pydantic chromadb

如果你用的是其他模型提供商,把langchain-openai换成对应的包即可。向量库我用的Chroma,本地跑方便,不需要额外起服务。

环境变量里配好API Key,这个不用多说。建议把模型名称也放到环境变量里,方便切换。

4.2 定义Schema与构建Chain

Schema前面已经给过了,这里补充完整的Chain构建代码:

from langchain_openai import ChatOpenAI from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import PydanticOutputParser from langchain_core.runnables import RunnablePassthrough llm = ChatOpenAI(model="gpt-4o-mini", temperature=0) parser = PydanticOutputParser(pydantic_object=QAResponse) system_template = """你是一个严谨的问答助手。根据提供的上下文回答用户问题。 输出要求: 1. 先写reasoning字段,简要说明你的推理过程 2. answer字段直接回答问题,不超过500字 3. confidence字段:上下文充分且答案明确选high;上下文部分相关选medium;上下文不足选low 4. sources字段列出引用的文档片段,最多5条 5. 如果上下文不足以回答,confidence必须为low,answer写"根据现有资料无法确定" {format_instructions} """ prompt = ChatPromptTemplate.from_messages([ ("system", system_template), ("human", "上下文:\n{context}\n\n问题:{question}") ]).partial(format_instructions=parser.get_format_instructions()) chain = prompt | llm | parser

这里有个细节:parser.get_format_instructions()会生成一段格式说明,我把它放在System Prompt的末尾。实测发现放在末尾比放在开头效果好,因为模型对Prompt末尾的内容注意力更高。

4.3 带重试的调用封装

直接调chain.invoke是没有重试的,我封装了一个函数:

from pydantic import ValidationError def qa_with_retry(question: str, context: str, max_retries: int = 2): last_error = None for attempt in range(max_retries + 1): try: result = chain.invoke({"question": question, "context": context}) return result except ValidationError as e: last_error = e if attempt < max_retries: context = context + f"\n\n[上次输出格式错误:{str(e)[:200]},请修正]" llm.temperature = 0.3 return QAResponse( answer="根据现有资料无法确定", confidence=ConfidenceLevel.low, sources=[], needs_human=True, follow_up=None )

注意llm.temperature = 0.3这行,ChatOpenAI对象是可变,直接改属性就行。但如果你用的是共享的llm实例,记得在重试结束后改回0,不然会影响后续调用。

4.4 检索模块的接入

检索部分我用Chroma做向量存储,embedding用OpenAI的text-embedding-3-small。文档切块用RecursiveCharacterTextSplitter,chunk_size设500,overlap设50。这个参数不是拍脑袋定的——500字大约对应300-400个token,对于问答场景来说,一个chunk能容纳一个完整的知识点,又不会太长导致检索精度下降。

检索的top_k我设的是3。设1的话召回不够,设5的话噪声太多。3是一个实测下来比较平衡的值。检索回来的文档拼成context字符串,每个文档前面加上[doc_id]标记,方便模型在sources字段里引用。

4.5 完整调用示例与输出

question = "LangChain的with_structured_output支持哪些模型?" context = """ [doc_001] with_structured_output方法目前支持OpenAI、Anthropic、Mistral等主流模型提供商。 [doc_002] 对于不支持Function Calling的模型,该方法会回退到JSON mode。 [doc_003] 使用前需要确保模型版本支持结构化输出功能。 """ result = qa_with_retry(question, context) print(result.model_dump_json(indent=2))

输出大概长这样:

{ "answer": "with_structured_output支持OpenAI、Anthropic、Mistral等主流提供商。对于不支持Function Calling的模型,会回退到JSON mode。", "confidence": "high", "sources": [ {"doc_id": "doc_001", "snippet": "with_structured_output方法目前支持OpenAI、Anthropic、Mistral等主流模型提供商。"}, {"doc_id": "doc_002", "snippet": "对于不支持Function Calling的模型,该方法会回退到JSON mode。"} ], "needs_human": false, "follow_up": "是否需要了解具体某个模型的配置方式?" }

这个输出可以直接被下游系统消费,不需要任何正则清洗。

5. 常见问题与排查技巧实录

5.1 模型输出不合规的六种典型情况

在实际跑的过程中,我整理了一张问题速查表:

问题现象根本原因解决方法
枚举值输出中文或近义词模型对枚举约束不敏感枚举值改英文,Prompt里给示例
嵌套字段缺失Schema层级过深拍平结构或拆分调用
列表字段超长未设max_length加max_length约束
输出被截断max_tokens不够提高max_tokens或精简Schema
重试后仍然错错误信息不够具体把ValidationError完整回传
置信度全是high判定标准模糊Prompt里给具体判定例子

这张表是我迭代了大概两周才稳定下来的,每一条都对应着真实的报错日志。

5.2 置信度校准的实操心得

置信度字段是最难搞的。模型天然倾向于给high,因为它在训练时被奖励“自信”。我试过几种方法来校准:

第一种是在Prompt里给判定标准,比如“如果上下文中有直接答案,选high;如果上下文只提供了部分信息,选medium;如果上下文完全不相关,选low”。这个方法有效果,但不够。

第二种是在Schema里加一个evidence_count字段,让模型数一下自己用了几个来源。来源数少于2个的时候,置信度不太可能是high。这个方法把置信度和可验证的证据挂钩了,效果更好。

第三种是后处理规则:如果sources为空,强制把confidence降为low。这是一个硬规则,不依赖模型判断。实测下来,这条规则拦截了不少模型“自信但无依据”的回答。

5.3 性能与成本的平衡

结构化输出比自由文本输出要贵,因为Schema说明本身占token,而且重试会翻倍消耗。我的优化手段有三个:

一是把Schema说明精简到最必要。get_format_instructions()生成的说明有时候很啰嗦,我会手动改写成更紧凑的版本。

二是缓存。相同问题加相同context的调用结果直接缓存,用functools.lru_cache或者Redis都行。问答场景里重复问题比例不低,缓存能省不少钱。

三是分级调用。简单问题用便宜的小模型,复杂问题才用大模型。判断标准可以用问题长度和检索结果的相似度分数。

提示:不要为了省钱把max_tokens设得太低,输出被截断导致的解析失败,重试成本比省下来的token贵得多。

5.4 一个容易被忽略的坑:Pydantic版本兼容性

Pydantic v1和v2的API差异很大,LangChain不同版本对Pydantic的依赖也不同。我遇到过最坑的情况是:本地用v2写的模型,部署到服务器上因为依赖冲突装成了v1,结果Field(default_factory=list)直接报错。建议在requirements.txt里把pydantic版本锁死,比如pydantic>=2.0,<3.0。另外LangChain的PydanticOutputParser在v1和v2下的行为也有细微差异,升级的时候要跑一遍回归测试。

6. 后续可以扩展的方向

这个问答器目前跑在内部知识库场景,稳定运行了一段时间。如果继续迭代,我会从两个方向入手。

第一个方向是多轮对话的结构化输出。现在的实现是单轮的,每轮独立。如果要做多轮,Schema里需要加一个conversation_state字段来记录上下文状态,同时Prompt里要注入历史对话。这会增加Schema复杂度,需要重新平衡合规率和表达能力。

第二个方向是流式结构化输出。现在必须等模型全部生成完才能解析,用户等待时间长。LangChain有流式解析的接口,可以在模型生成过程中逐步解析部分字段,先把answer推给前端,sources和confidence后到。这个对用户体验提升明显,但实现复杂度也高不少,需要处理“部分JSON”的解析问题。

如果你也在做类似的东西,我的建议是先把单轮跑稳,Schema尽量简单,重试和降级逻辑做扎实,再考虑扩展。结构化输出这件事,稳定性比功能丰富重要得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询