1. 为什么我要单独做一个结构化输出问答器
做Agent开发的人迟早会撞上同一堵墙:模型能说会道,但一让它吐结构化数据就开始飘。你让它返回JSON,它给你包一层```json代码块;你让它按schema填字段,它自作主张加个note字段;你让它字段名用snake_case,它偏要写camelCase。更崩溃的是,同样的prompt跑十次,有三次格式是对的,七次各有各的错法。
我在做Agent实践系列的时候,前面几个demo都是"能跑就行",直到要接下游系统——下游要的是干净的、可校验的、字段固定的对象,不是一段自然语言。这时候"结构化输出"从一个可选项变成了硬需求。这个问答器的定位很明确:输入一个自然语言问题,输出一个严格符合Pydantic模型定义的结构化对象,中间不允许有任何格式漂移。
它解决的核心问题有三个。第一是格式确定性,下游拿到的东西必须能直接model_validate通过,不需要写一堆正则去清洗。第二是语义可校验,不只是字段类型对,字段内容也要符合业务约束,比如"置信度必须在0到1之间""分类只能是预设的几个枚举值"。第三是可观测,当模型输出不合规时,我要知道它错在哪、错了几次、重试有没有救回来,而不是两眼一抹黑。
适合谁看?如果你已经跑通过最基础的LangChain调用,知道ChatOpenAI怎么实例化,但一到"让模型稳定输出JSON"就头疼,这篇就是给你写的。如果你还没接触过Agent,建议先把LangChain的入门链路走一遍再回来,不然中间一些概念会有点跳。
关键词里出现了LangChain和Pydantic,这两个是本文的技术底座。LangChain负责编排和模型调用,Pydantic负责schema定义和校验。至于热词里那一堆agent框架对比、agent架构、agent学习路线,我不打算在这里展开——那些是选型话题,本文只聚焦一件事:怎么把结构化输出这件事做扎实。选型可以后面再聊,但结构化输出是无论你用哪个框架都绕不开的基本功。
2. 结构化输出的三条技术路线与我的选型逻辑
在动手写代码之前,得先想清楚用哪条路。市面上做结构化输出大致有三条路线,各有各的脾气,选错了后面会一直难受。
2.1 路线一:纯Prompt约束加事后解析
最朴素的做法,在prompt里写清楚"你必须返回JSON,字段如下……",然后拿到文本自己json.loads。优点是零依赖、任何模型都能用。缺点是完全靠模型自觉,模型心情好就给你对的,心情不好就给你加解释文字。我早期图省事用过这条路,结果在一个批量任务里,1000条数据有将近200条解析失败,全是模型在JSON前后加了"好的,以下是结果:"这种废话。事后解析要写一堆容错逻辑,维护成本极高。
2.2 路线二:模型原生Function Calling / Tool Calling
主流模型都支持function calling,你把schema传进去,模型返回的tool_calls里就是结构化参数。这条路的好处是模型侧做了约束,格式稳定性比纯prompt高一个档次。但它有两个坑:一是不同厂商对schema的支持程度不一样,有些复杂的嵌套结构、联合类型会直接报错或者被静默忽略;二是tool_calls的返回结构本身还要再解析一层,而且模型可能"不调用工具"而是直接回一段文本,你得处理这种fallback。
2.3 路线三:LangChain的with_structured_output
这是LangChain封装好的一层,底层其实还是走function calling或者JSON mode,但对外暴露的接口极其干净:你给它一个Pydantic模型,它直接返回一个该模型的实例。我最终选的就是这条,理由很实在:
- schema即代码。Pydantic模型本身就是校验器,字段类型、约束、默认值、描述全写在一个地方,不用维护两份定义。
- 失败可捕获。校验失败会抛
ValidationError,我能精确知道是哪个字段、什么原因,方便做重试。 - 切换模型成本低。换模型时只要新模型支持结构化输出,业务代码基本不用动。
提示:
with_structured_output在不同LangChain版本里行为有差异,早期版本对嵌套模型支持不完善,建议用较新的稳定版,并且在正式用之前先跑一个最小验证用例。
选型这件事我的经验是:别一上来就追求最花哨的方案,先保证失败时你能定位问题。纯prompt方案失败了你只能猜,function calling失败了你至少能看到原始返回,with_structured_output失败了你还能拿到ValidationError的具体字段。可观测性比一时的成功率更重要,因为成功率可以靠重试补,可观测性补不了。
3. 用Pydantic把"问答结果"这件事定义清楚
结构化输出的灵魂不在代码,在schema设计。schema设计得好,模型填起来顺,校验也顺;schema设计得烂,模型天天给你填错,你还以为是模型不行。这一节我把自己踩过的坑和最终定下来的模型结构讲清楚。
3.1 从业务需求倒推字段
先问自己:这个问答器到底要产出什么?我的场景是"给定一个问题,返回答案、答案的置信度、答案涉及的知识类别、以及支撑答案的要点列表"。于是字段就出来了:
from pydantic import BaseModel, Field from typing import List from enum import Enum class Category(str, Enum): TECH = "tech" LIFE = "life" FINANCE = "finance" OTHER = "other" class QAResult(BaseModel): answer: str = Field(description="对问题的直接回答,控制在200字以内") confidence: float = Field(ge=0.0, le=1.0, description="对答案的置信度,0到1之间") category: Category = Field(description="问题所属类别") key_points: List[str] = Field(description="支撑答案的要点,2到5条")这里有几个设计决策值得说。answer用str而不是更复杂的结构,是因为下游要直接展示,越简单越好。confidence用float加ge/le约束,是因为模型很容易给出1.5或者-0.2这种越界值,用Pydantic的约束直接卡死。category用Enum而不是str,这是关键——枚举是约束模型输出空间最有效的手段,模型只能在几个值里选,不会给你编出"technology"这种同义词。key_points用List[str],是因为要点数量不固定,用列表最自然。
3.2 Field的description是给模型看的prompt
很多人写Pydantic模型只写类型不写description,然后抱怨模型填得不准。description不是文档,是prompt的一部分。with_structured_output会把整个schema(包括description)序列化后传给模型,模型就是靠这些description来理解每个字段该填什么。
我对比过:answer: str和answer: str = Field(description="对问题的直接回答,控制在200字以内"),后者的输出质量明显更稳,尤其是长度控制。模型看到"200字以内"就会自觉收敛,不写description它可能给你写800字。
3.3 嵌套模型要慎用
Pydantic支持嵌套模型,比如key_points: List[KeyPoint],每个KeyPoint又有自己的字段。功能上没问题,但实测下来嵌套层级越深,模型填错的概率越高。我试过三层嵌套,失败率直接翻倍。所以我的原则是:能用扁平结构就别嵌套,能一层解决就别两层。List[str]够用就绝不上List[SomeModel]。
如果业务真的需要嵌套,建议把嵌套部分拆成独立的调用,先拿到主结构,再针对每个子项单独调用一次。虽然多花点token,但稳定性提升明显,而且失败时定位更容易。
3.4 默认值和Optional的取舍
Optional字段和带默认值的字段,模型可以选择不填。这在某些场景下是好事(比如"备注"这种非必填项),但在核心字段上绝对是灾难——模型会偷懒不填,然后你拿到一个None还得处理。
我的做法是:核心字段一律必填,不给默认值。非核心字段才用Optional,并且明确在description里写"如果没有相关信息,填null"。这样模型知道这个字段可以空,但空的时候要显式填null,而不是直接省略。
4. 把问答器跑起来:从模型调用到重试闭环
schema定好了,接下来是把它接上模型跑通。这一节讲完整的调用链路,包括我踩过的几个坑。
4.1 最小可运行版本
先上一个能跑的最小版本,把链路打通:
from langchain_openai import ChatOpenAI from langchain_core.prompts import ChatPromptTemplate llm = ChatOpenAI(model="gpt-4o-mini", temperature=0) structured_llm = llm.with_structured_output(QAResult) prompt = ChatPromptTemplate.from_messages([ ("system", "你是一个严谨的问答助手,请根据用户问题给出结构化回答。"), ("human", "{question}") ]) chain = prompt | structured_llm result = chain.invoke({"question": "Pydantic的Field约束有哪些常用类型?"}) print(result.answer) print(result.confidence) print(result.category)这里temperature=0很重要。结构化输出场景下,温度越低越稳。温度高了模型会"发挥创意",字段值就开始飘。我一般直接设0,如果发现输出太死板再往上调一点点,但绝不超过0.3。
with_structured_output(QAResult)这一步是关键,它把Pydantic模型转成模型能理解的schema,并在返回时自动做校验和反序列化。你拿到的result直接就是QAResult实例,可以.answer这样访问。
4.2 第一次踩坑:模型返回了不合规的枚举值
跑通之后我拿一批真实问题去测,发现category字段偶尔会返回"technology"而不是"tech"。按理说Enum应该卡死,但模型在生成时确实可能吐出枚举外的值,然后Pydantic校验失败抛异常。
这时候有两个选择:一是让异常往上抛,调用方处理;二是加一层重试。我选的是重试,因为大部分格式错误是随机的,重试一次就能救回来。重试的逻辑不是简单重跑,而是把错误信息喂回去:
from pydantic import ValidationError def invoke_with_retry(chain, question, max_retries=3): last_error = None for attempt in range(max_retries): try: return chain.invoke({"question": question}) except ValidationError as e: last_error = e # 把校验错误拼进问题里,让模型知道错在哪 question = ( f"{question}\n\n" f"上一次输出校验失败,错误信息:{e.errors()}。" f"请严格按照schema重新输出,注意枚举字段只能取预设值。" ) raise last_error这个重试逻辑的精髓在于把错误反馈给模型。直接重跑,模型大概率还犯同样的错;把ValidationError的具体内容拼进prompt,模型看到"category字段值'technology'不在允许的枚举里",下一次就会改。实测下来,第一次失败的问题,重试一次的成功率能到90%以上。
4.3 第二次踩坑:重试把上下文撑爆了
上面那个重试逻辑有个隐患:每次重试都把错误信息往question里拼,如果连续失败三次,question会变得很长,token消耗飙升,而且模型可能被一堆错误信息搞晕。
我的改进是只保留最近一次的错误,而不是累积。另外给重试加个上限,超过就放弃并记录,不要无限重试。生产环境里我还见过因为重试逻辑写错导致死循环的,token账单直接爆炸,这个坑一定要避开。
4.4 第三次踩坑:并发下的限流
问答器上线后要支持批量问题,我一开始用chain.batch()并发跑,结果触发模型侧的限流,一堆请求失败。后来加了并发控制:
import asyncio async def batch_invoke(chain, questions, concurrency=5): semaphore = asyncio.Semaphore(concurrency) async def one(q): async with semaphore: return await chain.ainvoke({"question": q}) return await asyncio.gather(*[one(q) for q in questions])concurrency=5是我实测下来比较稳的值,具体多少要看你的账号等级和模型。别一上来就开几十并发,限流一触发,重试反而更慢。
5. 让输出真正"可用":校验、兜底与可观测
跑通只是第一步,能稳定用在生产里才算数。这一节讲三个让结构化输出从"能跑"到"可靠"的关键动作。
5.1 业务级校验不能只靠Pydantic
Pydantic管的是类型和基础约束,但业务规则它管不了。比如"key_points至少2条""answer不能为空字符串""confidence低于0.3时category不能是other"——这些都得自己写。
我的做法是在Pydantic模型上加model_validator:
from pydantic import model_validator class QAResult(BaseModel): # ... 字段定义同上 ... @model_validator(mode="after") def check_business_rules(self): if len(self.key_points) < 2: raise ValueError("key_points至少需要2条") if not self.answer.strip(): raise ValueError("answer不能为空") return self这样业务规则和schema定义在一起,校验失败同样抛ValidationError,能被上面的重试逻辑捕获。把校验前移,别等到下游用的时候才发现数据有问题。
5.2 兜底策略:失败时返回什么
重试三次还是失败怎么办?直接抛异常会让整个批处理挂掉。我的兜底是返回一个"降级结果":
def safe_invoke(chain, question): try: return invoke_with_retry(chain, question) except Exception as e: return QAResult( answer="抱歉,暂时无法给出结构化回答。", confidence=0.0, category=Category.OTHER, key_points=["系统未能生成合规输出", f"错误类型:{type(e).__name__}"] )降级结果本身也是合规的QAResult,下游不用做特殊处理。同时把失败记录下来,方便后续分析是哪些问题容易失败。
5.3 可观测:记录每一次失败
结构化输出最怕的是"静默失败"——失败了但没人知道。我加了一个简单的日志记录,把每次校验失败的问题、错误字段、重试次数都记下来:
| 记录项 | 用途 |
|---|---|
| question | 定位是哪类问题容易失败 |
| error_field | 看是哪个字段最常出错 |
| retry_count | 评估重试策略是否有效 |
| final_status | 成功/降级/彻底失败 |
跑一段时间后我发现,category字段的失败率最高,其次是confidence越界。针对性地在prompt里加强这两个字段的说明后,失败率明显下降。没有观测就没有优化,这一步千万别省。
6. 几个让稳定性再上一个台阶的实战技巧
前面讲的都是主干,这一节补充几个我实际用下来很有效的细节技巧,都是文档里不太会写的东西。
6.1 在system prompt里"预告"schema
虽然with_structured_output会自动传schema,但我在system prompt里还是会用自然语言再描述一遍关键约束。比如"category只能是tech/life/finance/other之一,confidence是0到1的小数"。这看起来冗余,但实测能降低首次失败率。模型同时看到schema和自然语言描述,理解更到位。
6.2 给字段加示例值
Pydantic的Field支持examples参数,把示例值填进去,模型会照着示例的格式来。比如confidence给个examples=[0.85],模型就不太会填85或者"high"这种。示例比描述更直观,尤其是对格式敏感的字段。
6.3 控制输出长度
answer字段如果不加长度约束,模型可能给你写一大段。除了在description里写"200字以内",我还会在prompt里强调"简洁直接"。如果下游对长度有硬要求,可以在校验里加max_length,超了就当失败重试。
6.4 别在schema里放太多字段
字段越多,模型填错的概率越高。我见过有人一个模型塞了二十几个字段,结果失败率高得离谱。只放真正需要的字段,能拆成多次调用的就拆。结构化输出的目标是"稳定拿到需要的几个字段",不是"一次拿全所有信息"。
6.5 版本升级要回归测试
LangChain和底层模型的更新很频繁,with_structured_output的行为可能随版本变化。我每次升级依赖后,都会拿一批固定的测试问题跑一遍,对比成功率和输出质量。别裸升级,结构化输出这种强依赖底层行为的功能,升级翻车的概率不低。
7. 关于这个问答器,我最后想说的
这个问答器本身不复杂,代码量不大,但它把Agent开发里一个很核心的问题讲透了:怎么让模型的输出从"大概对"变成"确定对"。我见过太多项目卡在这一步,模型demo跑得飞起,一接真实系统就各种格式问题,最后不得不写一堆脏兮兮的解析代码。
我的体会是,结构化输出这件事,七分靠schema设计,两分靠重试兜底,一分靠运气。schema设计得好,模型填起来顺,失败率天然就低;schema设计得烂,再怎么重试也是治标不治本。所以如果你要动手做类似的东西,先把Pydantic模型反复打磨几轮,比急着写调用代码重要得多。
另外一点,别追求100%的成功率。模型输出本质上是概率性的,总会有失败的时候。关键是失败时你能兜住、能观测、能定位,而不是假装它不会失败。我现在的策略是接受一个可接受的失败率,把重试和降级做扎实,剩下的交给监控。
这个问答器后面还能往几个方向扩展:接上RAG让answer有依据、把key_points做成可点击的引用、根据confidence做人工复核的分流。但那些都是后话,先把结构化输出这一层做稳,后面的扩展才有地基。