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尽量简单,重试和降级逻辑做扎实,再考虑扩展。结构化输出这件事,稳定性比功能丰富重要得多。