结构化输出这件事,看起来只是让模型"按格式返回",但真做过 Agent 项目的人都知道,它往往是决定一个问答系统能不能从"玩具"变成"工具"的分水岭。我这次做的 Agent 实践4,核心目标很明确:把用户五花八门的自然语言提问,稳定地转成程序能直接消费的结构化数据,再交给下游逻辑去处理。关键词里提到的 LangChain、Pydantic,正是这套方案的两根支柱——前者负责编排模型调用与工具链,后者负责把"模型说的话"变成"程序信得过的对象"。这篇内容适合已经跑通过基础对话 Demo、想往生产级 Agent 迈一步的开发者,也适合被"模型输出格式飘忽不定"折磨过的朋友。我会把选型理由、字段设计、校验兜底、踩坑排查完整讲一遍,尽量让你看完就能照着搭一个自己的结构化输出问答器。
1. 为什么结构化输出是问答器从能用到好用的关键
1.1 自由文本输出的三个致命短板
先说说我为什么非要做结构化输出。最早我搭的问答器就是最朴素的形态:用户问一句,模型答一段。Demo 阶段看着挺美,一旦要接下游就全是问题。
第一个短板是解析不可靠。你让模型返回一段 JSON,它可能给你包一层 ```json 代码块,可能前面加一句"好的,以下是结果:",也可能在 JSON 后面补一句"希望对你有帮助"。这些在人类看来无伤大雅,但json.loads()直接抛异常。我统计过早期版本,纯靠提示词约束格式,解析成功率大概只有七成出头,剩下三成全靠正则去捞,越捞越脏。
第二个短板是字段缺失无感知。模型可能漏掉某个字段,或者把本该是数字的字段写成"大约 5 个"。自由文本里这些错误是隐形的,直到下游逻辑崩了才发现。结构化输出配合校验,能在入口就把这类问题拦下来。
第三个短板是无法做类型约束和业务约束。比如"优先级"字段我只允许 high/medium/low 三个值,自由文本里模型可能给你编出"较高""紧急"这种同义词。结构化输出加上枚举校验,非法值直接拒绝,逼着模型重试。
1.2 结构化输出到底解决了什么
一句话概括:它把"模型输出"从不可信的自然语言,变成了可信的、带类型的数据契约。
这个转变的意义在于,问答器不再只是一个"聊天窗口",而是一个可以被程序调用的信息抽取与意图理解组件。用户问"帮我查一下上周那笔订单为什么还没发货",结构化输出器返回的可能是这样一份数据:
{ "intent": "order_status_query", "entities": { "order_time_range": "last_week", "issue_type": "not_shipped" }, "priority": "medium", "need_human": false }下游拿到这份数据,就能直接路由到订单查询工具,而不是再去猜用户到底想干嘛。这就是结构化输出的核心价值——让 Agent 的每一步都有明确的输入输出契约。
1.3 问答器场景下结构化输出的特殊性
通用的结构化输出方案很多,但问答器场景有几个特殊点,直接决定了我的设计取舍。
一是输入是开放的自然语言,用户不会按你的 schema 说话,所以抽取逻辑必须足够鲁棒,字段设计要能容纳"用户没说"的情况,也就是大量字段需要可选。
二是输出要服务于多轮对话。结构化结果不只是给下游用,还要能塞回上下文,让下一轮知道"上一轮已经确认了哪些信息"。所以字段里往往要带一个"已收集信息"和"待补充信息"的结构。
三是容错要求高。问答器是面向终端用户的,不能因为模型偶尔抽风就整个崩掉。必须有重试、降级、兜底三层机制。这三点在后面章节会反复体现。
2. LangChain 与 Pydantic 的分工:谁负责编排,谁负责约束
2.1 为什么不是纯提示词,也不是纯手写解析
在选型阶段我对比过三条路线。
第一条是纯提示词 + 手写正则/JSON 解析。优点是零依赖,缺点是前面说的解析不可靠,而且每加一个字段就要改一次解析逻辑,维护成本随字段数线性上升。
第二条是直接用模型厂商的原生结构化输出能力(比如某些 API 支持的 JSON mode 或 function calling)。这条路其实很香,格式约束由模型侧保证,解析成功率很高。但它的问题是和具体厂商绑定,换模型就要重写,而且复杂 schema 的支持程度各家不一。
第三条就是LangChain + Pydantic。LangChain 提供统一的模型抽象和输出解析器,Pydantic 提供强类型 schema 和校验。好处是换模型基本无感,schema 用 Python 类定义,可读性和可维护性都很好。我最终选它,核心就是看中这个"可移植性 + 可维护性"。
2.2 Pydantic 模型:把"期望的输出"写成代码
Pydantic 在这里的角色,是把"我希望模型返回什么"从一段自然语言描述,变成一个有类型、有约束、可校验的类。这是整个方案的地基。
我定义的核心模型大概长这样:
from pydantic import BaseModel, Field from typing import Optional, List, Literal from enum import Enum class Intent(str, Enum): order_status = "order_status_query" product_info = "product_info_query" complaint = "complaint" other = "other" class QAOutput(BaseModel): intent: Intent = Field(description="用户提问的核心意图") entities: dict = Field(default_factory=dict, description="抽取到的关键实体") priority: Literal["high", "medium", "low"] = Field( default="medium", description="问题紧急程度" ) need_human: bool = Field(default=False, description="是否需要转人工") missing_info: Optional[List[str]] = Field( default=None, description="为完成回答还需要用户补充的信息" ) reply: str = Field(description="给用户的自然语言回复")这里有几个设计细节值得展开。Intent用枚举而不是字符串,是为了把意图收敛到有限集合,避免模型自由发挥。priority用Literal而不是枚举,是因为它取值少且语义直观,Literal 写起来更轻。entities用 dict 而不是固定字段,是因为不同意图需要的实体差异很大,用 dict 更灵活,代价是失去了字段级校验——这是个权衡,后面会讲怎么补。
2.3 LangChain 的 PydanticOutputParser 与 with_structured_output
LangChain 里做结构化输出有两条主流路径,我都试过。
第一条是PydanticOutputParser。它的工作方式是:把 Pydantic 模型转成一段格式说明,塞进提示词里,让模型按说明输出,然后解析器负责把返回文本解析成 Pydantic 对象。这条路兼容性最好,几乎所有模型都能用,因为本质上还是靠提示词约束。
第二条是with_structured_output。它利用模型原生的结构化能力(function calling 或 JSON mode),把 schema 直接传给模型侧。这条路格式更稳,但依赖模型支持。
我的实践结论是:优先用 with_structured_output,用不了再退回 PydanticOutputParser。代码上可以这样写:
from langchain_openai import ChatOpenAI llm = ChatOpenAI(model="gpt-4o-mini", temperature=0) structured_llm = llm.with_structured_output(QAOutput) result = structured_llm.invoke("上周那笔订单为什么还没发货")如果换成不支持原生结构化的模型,就改成:
from langchain_core.output_parsers import PydanticOutputParser parser = PydanticOutputParser(pydantic_object=QAOutput) prompt = prompt_template.partial(format_instructions=parser.get_format_instructions()) chain = prompt | llm | parser两条路的调用方式几乎一样,这就是 LangChain 抽象层的价值——上层业务代码不用关心底层用的是哪种约束机制。
2.4 两者的边界:别让 Pydantic 干 LangChain 的活
有个容易踩的坑:把太多业务逻辑塞进 Pydantic 的 validator 里。Pydantic 的 validator 适合做字段级的格式和范围校验,比如"priority 必须是三个值之一"。但如果你在 validator 里调用外部 API 去查订单是否存在,那就越界了——校验应该是纯函数、快速、无副作用的。
业务逻辑应该放在 LangChain 的 chain 里,或者干脆放在拿到结构化结果之后的独立处理层。我一般把职责划成三层:Pydantic 管格式与类型,LangChain 管调用与重试编排,业务层管语义与副作用。这个边界划清楚,后面排查问题会轻松很多。
3. 从零搭一个结构化输出问答器的完整链路
3.1 环境准备与依赖版本的那些坑
先把环境说清楚,因为 LangChain 生态版本迭代快,版本不匹配是新手最容易卡住的地方。
pip install langchain langchain-core langchain-openai pydantic这里有个血泪教训:langchain 和 langchain-core 的版本要匹配。我有一次只升级了 langchain,结果with_structured_output报了个莫名其妙的 AttributeError,查了半天才发现是 core 版本太旧。建议用pip install -U一起升,或者干脆用 poetry/uv 锁版本。
另外 Pydantic 要v2,v1 和 v2 的 API 差异很大,Field的用法、model_dump()和dict()都不一样。如果你看到教程里用.dict(),那大概率是 v1 的老文章,注意甄别。
提示:装完之后跑一句
import pydantic; print(pydantic.VERSION)确认是 2.x,能省掉后面一堆玄学问题。
3.2 定义 schema:字段设计决定了下游好不好用
Schema 设计是整个项目里最需要动脑子的部分。我的经验是先想清楚下游怎么用,再倒推字段。
比如下游要根据 intent 路由到不同工具,那 intent 就必须是枚举,且枚举值要和工具一一对应。下游要判断是否转人工,那 need_human 就得是布尔。下游要展示"还差什么信息",那 missing_info 就得是列表。
字段设计的三条原则:
- 能枚举就枚举,别用自由字符串,否则下游要写一堆 if-else 去归一化。
- 可选字段给默认值,别让模型因为"用户没说"就报错,用
default或Optional。 - 字段描述要写清楚,
Field(description=...)里的文字会进提示词,描述越准,模型抽得越对。这是很多人忽略的——description 不是注释,是给模型看的指令。
3.3 提示词与 schema 的配合:description 就是给模型的说明书
很多人以为用了结构化输出就不用管提示词了,这是误解。schema 负责"格式",提示词负责"语义",两者缺一不可。
我的提示词模板大概是这样组织的:
from langchain_core.prompts import ChatPromptTemplate prompt = ChatPromptTemplate.from_messages([ ("system", """你是一个问答信息抽取器。 请从用户提问中抽取意图和实体。 intent 只能从预定义枚举中选择。 如果用户没有提供完成回答所需的关键信息,把它们列进 missing_info。 priority 判断依据:涉及资金、投诉、时效问题为 high;一般咨询为 medium;闲聊为 low。"""), ("human", "{question}") ])注意 system 里我补充了枚举之外无法表达的判断规则,比如 priority 的判定标准。这些规则写不进 schema,只能靠提示词。schema 和提示词是互补关系,不是替代关系。
3.4 组装 chain 并跑通第一个样例
把上面几块拼起来:
chain = prompt | structured_llm result = chain.invoke({"question": "我上周买的鞋到现在还没发货,太气人了"}) print(result.intent) # Intent.order_status print(result.priority) # high print(result.need_human) # True第一次跑通那一刻还是挺爽的,因为拿到的是真正的 Python 对象,不是字符串。你可以直接result.intent == Intent.order_status去判断,IDE 还能给你补全字段名。这种"类型安全"的体验,是自由文本输出给不了的。
3.5 把结果接回对话:多轮场景下的状态维护
单轮跑通只是开始,问答器真正难的是多轮。用户第一轮说"订单没发货",第二轮说"就是上周那个",你得把两轮信息合并。
我的做法是在结构化输出里加一个"已收集信息"的累积逻辑:每轮把新的 entities 合并进一个 session 级的字典,再把当前还缺的字段算出来放进 missing_info,下一轮提示词里带上"已知信息"和"待补充信息"。这样模型就知道该追问什么,而不是每轮都从头问。
session = {"entities": {}, "missing": []} # 每轮 result = chain.invoke({"question": q, "known": session["entities"]}) session["entities"].update(result.entities) session["missing"] = result.missing_info or []这个模式跑下来,多轮体验会顺很多,用户不会觉得"我刚说过的你怎么又问一遍"。
4. 校验、重试与兜底:让输出真正稳定下来
4.1 Pydantic 校验失败时到底发生了什么
结构化输出不是万能的,模型偶尔还是会返回不符合 schema 的内容。这时候 Pydantic 会抛ValidationError,里面会告诉你哪个字段、什么原因失败了。
我遇到过的典型失败有三类:
| 失败类型 | 报错示例 | 根因 |
|---|---|---|
| 枚举越界 | Input should be 'high', 'medium' or 'low' | 模型编了个"urgent" |
| 类型错误 | Input should be a valid boolean | 模型返回了字符串"true" |
| 字段缺失 | Field required | 模型漏了必填字段 |
看懂这些报错很重要,因为不同的失败要用不同的兜底策略。枚举越界可以尝试映射,类型错误可以尝试转换,字段缺失只能重试。
4.2 重试策略:不是简单重跑,而是带着错误信息重跑
最朴素的重试是失败就再调一次,但这样成功率提升有限,因为模型不知道上次错哪了。更好的做法是把 ValidationError 的内容塞回提示词,告诉模型"你上次这么返回是错的,请修正"。
LangChain 的with_retry或者自己包一层循环都能实现:
from langchain_core.runnables import RunnableLambda def with_retry(chain, max_retry=3): def _run(inputs): last_err = None for i in range(max_retry): try: return chain.invoke(inputs) except Exception as e: last_err = e inputs["error_hint"] = str(e) raise last_err return RunnableLambda(_run)实测下来,带错误信息重试的成功率明显高于盲重试,尤其是枚举越界这类问题,模型看到报错后基本一次就能改对。
4.3 降级方案:当模型就是不听话时怎么办
重试三次还失败,就得降级了。我的降级策略分两级:
第一级是宽松解析。把严格 schema 换成宽松版,比如把枚举字段降级成字符串,先保证能拿到结果,再在业务层做归一化。这样至少不会整个请求失败。
第二级是兜底回复。如果连宽松解析都失败,就返回一个预设的兜底结构,intent 设为 other,need_human 设为 true,让用户转人工。宁可转人工,也不要给用户一个崩溃的页面。
注意:降级一定要打日志。我早期没打日志,结果线上降级率悄悄涨到 15% 都不知道,后来加了监控才发现是某个意图的 schema 设计有问题。
4.4 用日志和指标定位"模型不听话"的真实原因
排查结构化输出问题,光看报错不够,得看分布。我一般会记录这几个指标:
- 解析成功率(按意图分组)
- 重试次数分布
- 降级触发率
- 各字段的缺失率
有一次我发现某个意图的解析成功率特别低,查下来是那个意图的 entities 字段太复杂,模型总是漏字段。后来我把那个意图拆成两个更简单的意图,成功率立刻上去了。很多"模型不行"的问题,其实是 schema 设计的问题。
5. 实战中踩过的坑与排查链路
5.1 坑一:模型把 JSON 包在代码块里
这是最经典的坑。早期用 PydanticOutputParser 时,模型经常返回:
```json {"intent": "..."}解析器直接懵了。排查链路是这样的:先打印原始返回,确认是格式问题;然后看 LangChain 的解析器有没有内置处理——实际上新版解析器已经能处理代码块包裹了,但我当时用的是旧版;最后升级版本解决。 **经验**:遇到解析失败,第一步永远是**打印原始返回**,别猜。很多问题看一眼原始输出就明白了。 ### 5.2 坑二:可选字段被模型填成 null 字符串 我有个 Optional 字段,模型不填的时候返回了字符串 `"null"` 而不是真正的 null。Pydantic 校验直接失败,因为 `"null"` 不是 None。 这个坑的根因是**提示词里没说清楚"没有就留空"**。后来我在 description 里明确写了"如果没有相关信息,请省略该字段或设为 null,不要填字符串 null",问题就没了。 ### 5.3 坑三:中文枚举值导致的匹配失败 我一开始枚举值用了中文,比如 `"高"、"中"、"低"`。结果模型有时候返回"高",有时候返回"较高",匹配经常失败。后来全部改成英文枚举值,中文只放在 description 里做说明,稳定性大幅提升。 **经验**:**枚举值用英文,语义说明用中文**。英文枚举值更稳定,模型不容易给你编同义词。 ### 5.4 坑四:temperature 没调导致输出飘忽 这个坑最隐蔽。我一开始没设 temperature,默认值下模型输出比较随机,同样的输入两次结果可能不一样,结构化输出偶尔就飘了。后来把 temperature 设成 0,稳定性立竿见影。 **结构化输出场景,temperature 一定要调低甚至设 0**。这不是可选项,是必选项。 ### 5.5 排查链路总结:遇到解析失败该按什么顺序查 把上面的经验整理成一条排查链路,遇到问题照着走: 1. **打印原始返回**,确认是格式问题还是内容问题。 2. **看 ValidationError 详情**,定位是哪个字段、什么原因。 3. **检查 temperature**,是不是没调低。 4. **检查枚举值**,是不是用了中文或容易产生同义词的值。 5. **检查 description**,是不是描述不清导致模型理解偏差。 6. **检查版本**,langchain 和 core 是否匹配。 7. **看分布指标**,是不是某个意图或字段的系统性问题。 这条链路我用了很多次,基本能覆盖九成以上的结构化输出问题。 ## 6. 几个让问答器更稳的进阶技巧 ### 6.1 用 Few-shot 示例锚定复杂字段 对于 entities 这种结构复杂的字段,光靠 description 有时候不够。我会在提示词里塞一两个 few-shot 示例,展示"什么样的输入对应什么样的 entities"。实测下来,**复杂字段加 few-shot 后抽取准确率提升明显**。 示例不用多,一两个就够,多了反而占 token 还容易让模型照抄。 ### 6.2 把大 schema 拆成多个小 schema 分步抽取 如果一个 schema 字段特别多,模型容易顾此失彼。我的做法是**拆成两步**:第一步只抽 intent 和核心实体,第二步根据 intent 再抽该意图专属的字段。这样每步的 schema 都简单,成功率更高。 代价是多一次模型调用,但对于复杂场景,这个代价是值得的。 ### 6.3 用单元测试锁住 schema 的稳定性 结构化输出的 schema 是会演进的,加字段、改枚举都可能引入回归。我一般会给 schema 写一组单元测试,用固定的输入断言输出结构。这样改 schema 时能立刻发现破坏性变更。 ```python def test_qa_output_schema(): out = QAOutput(intent="other", reply="test") assert out.priority == "medium" assert out.need_human is False测试不用复杂,能锁住默认值和必填约束就行。
6.4 监控解析成功率,把它当成核心指标
最后再强调一次监控。结构化输出问答器的健康度,解析成功率是最核心的指标。我建议按意图、按字段分别监控,一旦某个维度掉下来,就能快速定位。这个指标比"用户满意度"更早发现问题,是真正的先行指标。
搭这套东西的过程,本质上是在"模型的自由度"和"程序的确定性"之间找平衡。schema 太松,下游没法用;schema 太紧,模型老失败。我踩了这么多坑之后的体会是:schema 要跟着真实数据演进,别一次设计到位,先跑起来,用监控数据驱动迭代。另外,别指望模型百分百听话,重试和兜底不是可选项,是必需品。把这套机制搭好之后,你会发现 Agent 的很多"不稳定",其实都能被工程手段驯服。