在Agent开发这条路上摸爬滚打了一段时间之后,我发现一个特别有意思的现象:很多人能把Agent跑起来,能调工具、能对话、能查资料,但一到要拿它的输出对接下游系统,就全乱套了。模型返回一段洋洋洒洒的自然语言,你还得再写一堆正则去抠字段,抠不干净就报错,报错了还得重试,重试几次token就烧没了。这个问题的本质,其实是Agent的"最后一公里"没打通——它说了什么你听得懂,但你的代码听不懂。
结构化输出问答器要解决的就是这件事。它让Agent不再吐一大段散文,而是直接给你一个规规矩矩的JSON对象,字段名、类型、嵌套关系全都定死,下游拿到就能用。关键词里提到的LangChain和Pydantic,正是干这活儿的两把好手:LangChain负责编排Agent的推理和工具调用流程,Pydantic负责把"我想要什么形状的数据"这件事用代码写清楚,然后逼着模型按这个形状交作业。这篇内容适合已经跑通过基础Agent、想把它真正接入生产系统的开发者,也适合刚接触LangChain、想搞明白结构化输出到底怎么落地的新手。我会从为什么需要它、核心机制怎么运转、代码怎么写、坑在哪里,一路讲到怎么扛住真实场景的考验。
1. 为什么自然语言输出在工程上是个灾难
1.1 从一次真实的对接事故说起
我之前做过一个内部知识库问答的Agent,需求很简单:用户问一个问题,Agent去检索文档,然后返回答案。第一版我让它直接输出自然语言,前端拿到就渲染,跑demo的时候一切正常,效果还挺惊艳。结果上线第二天,产品经理跑来说要加个功能——把答案里的"相关文档来源"单独抽出来做成可点击的链接列表。
我当时想这有什么难的,让模型在答案末尾按固定格式列出来不就行了。于是我改了prompt,要求它输出"答案:xxx 来源:xxx"。测试了十几条,格式都对。上线之后,问题来了:模型有时候把"来源"写成"参考资料",有时候写成"出处",有时候干脆忘了写,有时候一条来源里塞了三个文档用逗号隔开。我写了七八个正则去兼容,代码越写越丑,还是时不时漏掉。
这就是自然语言输出的根本问题:它对人是友好的,对代码是敌对的。人类能容忍"来源""出处""参考资料"这些同义词,能理解"张三、李四和王五"是三个人,但你的解析代码不能。你每加一条兼容规则,就多一个潜在的bug点。
1.2 解析成本、重试成本和不可预测性
自然语言输出的代价体现在三个层面。第一是解析成本,你得写解析逻辑,而且这个逻辑会随着模型输出的"创造性"不断膨胀。第二是重试成本,一旦解析失败,你要么让模型重来一遍,要么降级处理,前者烧token和时间,后者损失功能。第三是不可预测性,这是最要命的——你没法在编译期知道模型会返回什么,所有字段都得做空值判断,代码里全是防御性逻辑。
我做过一个粗略的统计,在一个中等复杂度的问答场景里,纯自然语言输出的解析失败率大概在5%到15%之间波动,取决于问题的多样性和模型的"心情"。这个数字在demo里无所谓,在生产里就是灾难。而换成结构化输出之后,同样的场景失败率能压到1%以下,而且失败的原因变得非常明确——要么是模型没按schema来,要么是schema本身设计得不合理,排查起来有方向。
1.3 结构化输出到底改变了什么
结构化输出的核心价值,是把"模型说什么"这件事从开放式的文本生成变成了受约束的数据填充。你不再问模型"请回答这个问题",而是问它"请把答案填进这个形状的容器里"。容器是你在代码里定义好的,字段名、类型、是否必填、嵌套结构全都写死,模型的任务变成了往容器里填内容。
这个转变带来的好处是连锁的。下游代码不用再猜字段名,因为字段名是你定的;不用再处理类型转换,因为Pydantic会帮你校验和转换;不用再写一堆if-else兜底,因为schema里标了必填的字段如果缺失,框架会直接报错让你知道。更重要的是,结构化输出让Agent的输出变得可测试。你可以写单元测试断言"返回的对象里source字段是一个长度大于0的列表",这在自然语言输出时代是做不到的。
2. Pydantic模型:把"我要什么"写成代码
2.1 从字典到模型,为什么多此一举反而更省事
很多人第一反应是:我直接用dict定义字段不就行了,为什么要用Pydantic?我一开始也这么想,直到被坑了几次才明白。用dict的话,你只能告诉模型"大概长这样",但没法强制校验。模型返回的age字段是字符串"25"而不是数字25,你的代码在运行时才会炸,而且炸的地方可能离出错的地方很远。
Pydantic的BaseModel做的事情,是把你的数据结构意图变成可执行的约束。你声明age: int,Pydantic就会在数据进来的时候尝试把"25"转成25,转不了就报ValidationError。你声明tags: list[str],它就会确保tags是个列表且每个元素都是字符串。这种"声明即校验"的模式,让错误在数据进入系统的第一道关口就被拦住,而不是等到业务逻辑深处才暴露。
2.2 字段描述不是注释,是给模型的说明书
Pydantic的Field里有个description参数,很多人把它当注释写,随便填几个字。这是个巨大的浪费。在结构化输出的场景里,description是直接喂给模型的提示词,模型会根据它来判断这个字段该填什么。你写得越清楚,模型填得越准。
举个例子,我有个字段叫confidence,一开始description写的是"置信度",结果模型有时候填0.9,有时候填"高",有时候填"90%"。后来我把description改成"对答案的置信程度,取值范围0到1之间的小数,1表示完全确定,0表示完全不确定",模型就稳定输出小数了。同样的字段,同样的模型,就因为description写清楚了,输出质量天差地别。
2.3 嵌套模型与枚举:处理复杂答案的利器
真实场景里的答案往往不是扁平的。比如一个问答器要返回"答案正文+引用来源列表+答案类型",来源列表里每条又有"文档标题+段落编号+相关度"。这种嵌套结构用Pydantic表达起来非常自然,定义两个模型,一个嵌套另一个就行。
枚举(Enum)是另一个被低估的工具。当某个字段的取值是有限集合时,用Enum比用str强太多。比如答案类型只有"事实型""观点型""操作型"三种,你定义成Enum,模型就只能从这三个里选,不会给你造出第四个。这比在description里写"请从以下三种里选"要可靠得多,因为Enum是代码层面的约束,模型在生成时会被引导到这些选项上。
from pydantic import BaseModel, Field from enum import Enum from typing import List class AnswerType(str, Enum): FACTUAL = "factual" OPINION = "opinion" PROCEDURAL = "procedural" class Source(BaseModel): title: str = Field(description="来源文档的标题") snippet: str = Field(description="来源中与问题最相关的原文片段") relevance: float = Field(description="相关度,0到1之间的小数", ge=0, le=1) class QAResponse(BaseModel): answer: str = Field(description="对用户问题的完整回答,控制在200字以内") answer_type: AnswerType = Field(description="答案的类型分类") sources: List[Source] = Field(description="支撑答案的来源列表,没有来源时返回空列表") confidence: float = Field(description="整体置信度,0到1之间的小数", ge=0, le=1)这段代码定义了一个完整的问答响应结构。注意ge=0, le=1这种约束,它会在校验时强制检查范围,模型如果返回1.5会被直接拦下。这种细粒度的约束,是纯prompt做不到的。
3. LangChain怎么把模型"逼"进这个结构里
3.1 with_structured_output的底层逻辑
LangChain提供了with_structured_output这个方法,用起来就一行代码,但背后的机制值得说清楚。它的原理是:把你传入的Pydantic模型转换成模型能理解的schema描述(通常是JSON Schema格式),然后通过两种方式之一来约束输出。
第一种是函数调用(function calling),如果底层模型支持工具调用,LangChain会把schema包装成一个"工具",模型在生成时会被引导去调用这个工具并填充参数。第二种是JSON模式,对于不支持函数调用的模型,LangChain会要求模型直接输出符合schema的JSON。两种方式各有适用场景,前者通常更可靠,后者兼容性更广。
from langchain_openai import ChatOpenAI llm = ChatOpenAI(model="gpt-4o", temperature=0) structured_llm = llm.with_structured_output(QAResponse) result = structured_llm.invoke("什么是结构化输出?") print(result.answer) print(result.answer_type)这里有个细节值得注意:temperature=0。结构化输出场景下,你几乎总是希望温度调低,因为你要的是稳定和准确,不是创造性。温度高了,模型更容易在字段填充上"发挥",反而增加出错概率。
3.2 当模型不支持函数调用时的降级方案
不是所有模型都支持函数调用,尤其是一些开源模型或者老版本API。这时候with_structured_output会退回到prompt-based的方式,也就是在提示词里塞入schema描述,要求模型输出JSON。这种方式可靠性会打折扣,因为模型可能输出带markdown代码块的JSON,或者在JSON前后加解释文字。
应对这种情况,LangChain内置了输出解析器(OutputParser)来处理常见的格式问题,比如自动剥离json标记。但如果模型实在不听话,你可能需要自己写一个后处理函数,用正则提取JSON部分再解析。我的经验是,如果条件允许,优先选支持函数调用的模型,能省掉大量格式兼容的麻烦。
3.3 在Agent循环里保持结构化:难点在哪
前面说的都是单次调用,但Agent是多轮循环的——它可能先调用工具检索,再基于检索结果生成答案。这时候结构化输出怎么保持?难点在于,Agent的中间步骤(比如决定调用哪个工具)和最终输出(结构化答案)需要的格式是不一样的。
常见的做法是分阶段处理:Agent的推理和工具调用阶段用普通的消息格式,到了要产出最终答案的时候,再走一次结构化输出。LangChain的AgentExecutor或者LangGraph的图结构都能支持这种模式。在LangGraph里,你可以定义一个专门的"格式化节点",前面所有节点跑完之后,把状态里的信息喂给这个节点,由它负责产出结构化的最终结果。这样既保证了中间流程的灵活性,又保证了最终输出的规范性。
4. 一个能跑的问答器:从检索到结构化答案
4.1 整体架构与数据流
这个问答器的数据流是这样的:用户提问 → Agent判断是否需要检索 → 调用检索工具拿回相关文档 → 基于文档生成答案 → 把答案填充进Pydantic模型 → 返回结构化对象。整个流程里,检索是可选步骤,有些问题Agent能直接回答,有些需要查资料。
我用LangGraph来编排这个流程,因为它对状态管理和条件分支的支持比传统的AgentExecutor更清晰。图里大概有这么几个节点:入口节点接收问题,路由节点判断是否需要检索,检索节点调用向量库,生成节点产出结构化答案。路由节点和生成节点之间有条件边,根据路由结果决定走不走检索。
4.2 检索工具的设计与返回格式
检索工具本身也应该返回结构化数据,而不是一段拼接好的文本。我见过很多人把检索到的文档拼成一个长字符串塞给模型,这样模型很难区分哪段来自哪个文档。更好的做法是让检索工具返回一个文档列表,每个文档带标题、内容、来源等字段,然后在生成节点里把这些结构化信息组织进prompt。
from langchain_core.tools import tool @tool def search_docs(query: str) -> List[dict]: """根据查询检索相关文档,返回文档列表""" docs = vectorstore.similarity_search(query, k=3) return [ {"title": d.metadata["title"], "content": d.page_content} for d in docs ]注意这里的返回类型标注是List[dict],LangChain会把这个返回值序列化后喂给模型。结构化的检索结果,能让模型更准确地判断哪条来源该被引用。
4.3 生成节点的prompt怎么写才不跑偏
生成节点的prompt是整个问答器的灵魂。我的写法是分三块:角色设定、任务说明、输出要求。角色设定告诉模型它是谁,任务说明告诉它要干什么,输出要求则明确指向Pydantic模型。
关键技巧是不要在prompt里重复描述schema,因为with_structured_output已经通过函数调用或JSON schema把结构信息传给模型了。你在prompt里再写一遍字段说明,反而可能造成冲突。prompt里只需要说清楚"基于以下资料回答问题,如果资料不足以回答就如实说明",剩下的交给schema。
GENERATE_PROMPT = """你是一个严谨的问答助手。 基于以下检索到的资料回答用户问题。 如果资料中没有相关信息,请如实说明,不要编造。 检索资料: {context} 用户问题:{question} """这个prompt刻意保持简洁,因为结构约束由schema负责,内容约束由prompt负责,两者分工明确。
4.4 把整条链路串起来
把上面这些拼起来,一个完整的问答器大概长这样:定义好Pydantic模型,配置好结构化LLM,定义检索工具,用LangGraph把节点和边连起来,编译成可执行图。调用的时候传入问题,拿到的是一个QAResponse对象,直接访问.answer、.sources就能用。
实测下来,这套架构在几百条测试问题上的结构化成功率能到98%以上,剩下的2%主要是模型在复杂嵌套结构上偶尔出错,比如sources列表里某个元素的relevance字段超范围。这种错误Pydantic会直接抛ValidationError,你能立刻定位到是哪个字段的问题,而不是拿到一个半成品数据去猜哪里错了。
5. 那些文档里不会写的坑
5.1 字段太多模型会"偷懒"
我踩过最深的坑是schema设计得太复杂。有一次我定义了一个有十二个字段的模型,结果模型经常漏填其中几个,尤其是那些description写得比较模糊的。后来我做了个实验,把字段数从十二个减到五个,漏填率立刻降下来了。
结论很明确:字段数量要克制。每个字段都应该是下游真正需要的,不要因为"可能有用"就加进去。如果确实需要很多信息,考虑拆成多个模型分步生成,而不是一次性让模型填一个大模型。模型在填充字段时是有"注意力预算"的,字段越多,每个字段分到的注意力越少,出错概率越高。
5.2 可选字段与默认值的陷阱
Pydantic允许字段有默认值,比如tags: List[str] = []。这在Python层面很方便,但在结构化输出场景里要小心。如果字段有默认值,模型可能会倾向于不填它,因为它"知道"不填也不会报错。结果就是你拿到一个空列表,但你以为模型会填。
我的做法是:真正必填的字段不给默认值,让Pydantic在缺失时直接报错,这样你能立刻发现问题。只有那些确实可选的字段才给默认值,并且在description里明确说明"没有时返回空列表"。这个区分很重要,它决定了你是主动发现问题还是被动接受错误数据。
5.3 中文场景下的编码与转义问题
中文场景有个容易被忽略的坑:模型在输出JSON时,中文字符可能被转义成\uXXXX形式。大多数情况下解析器能正确处理,但如果你自己写解析逻辑,或者中间经过了某些不支持Unicode转义的环节,就可能出问题。
我的建议是尽量用框架自带的解析器,不要自己造轮子。如果确实要自己处理,确保用json.loads而不是字符串操作,Python的json库对Unicode转义的处理是可靠的。另外,在prompt里可以加一句"直接输出中文,不要转义",虽然不总是有效,但能减少一部分问题。
5.4 长文本字段被截断怎么办
答案字段如果要求比较长,模型可能会在中途截断,尤其是当max_tokens设置得不够大的时候。这个坑很隐蔽,因为截断后的JSON可能仍然是合法的(如果截断发生在字符串闭合之后),你拿到一个不完整的答案却不知道。
应对方法是:给足max_tokens,并且在prompt里明确答案长度上限,让模型自己控制。比如"答案控制在300字以内",比让它自由发挥再截断要好。另外,可以在Pydantic模型里用max_length约束字段长度,这样超长会直接报错而不是静默截断。
6. 让结构化问答器扛住真实流量
6.1 并发下的状态隔离
Agent是有状态的,尤其是带记忆的Agent。在并发场景下,如果多个请求共享同一个Agent实例,状态就会串。LangGraph的设计天然支持状态隔离,因为每次invoke都会创建一个新的状态对象,但前提是你不要把可变状态挂在全局。
我见过有人把对话历史存在一个全局列表里,结果并发一上来,A用户的对话历史混进了B用户的回答。正确的做法是把状态放在图的state里,每次调用传入独立的state。如果确实需要跨请求的记忆,用外部存储(比如数据库或缓存)按用户ID隔离,而不是用全局变量。
6.2 超时与重试策略
结构化输出虽然可靠,但不是100%成功。网络抖动、模型限流、偶发的格式错误都可能导致失败。生产环境里必须有超时和重试机制。
我的配置是:单次调用超时30秒,失败后重试2次,重试时把temperature再调低一点(如果之前不是0的话)。重试之间加一个短暂的退避,避免瞬间打爆API。如果三次都失败,返回一个降级响应,比如"抱歉,暂时无法回答,请稍后再试",而不是把异常抛给用户。
6.3 监控什么指标才有意义
结构化问答器的监控,不能只看"请求成功率"。我关注这几个指标:结构化成功率(返回对象通过Pydantic校验的比例)、字段填充率(可选字段被填的比例,太低说明schema设计有问题)、平均重试次数(反映稳定性)、P95延迟(反映用户体验)。
其中结构化成功率是最核心的。如果这个指标低于95%,说明要么schema有问题,要么模型选得不对,要么prompt需要调整。我一般会把这个指标做成实时看板,一旦跌破阈值就告警。
6.4 缓存能省下的不只是钱
结构化输出的结果非常适合缓存,因为同样的输入应该得到同样的结构化对象。我在检索层和生成层都加了缓存:检索层缓存query到文档列表的映射,生成层缓存question加context到QAResponse的映射。
缓存带来的不只是成本节省,还有延迟降低。实测下来,命中缓存的请求延迟能从两三秒降到几十毫秒。对于高频重复问题,这个提升非常明显。需要注意的是,缓存key要包含所有影响输出的因素,比如模型版本、prompt版本,否则模型升级后可能返回旧缓存。
7. 从问答器到通用模式:结构化输出的迁移思路
这套结构化输出的模式,其实不局限于问答器。任何需要Agent产出可被程序消费的数据的场景,都能套用。比如让Agent做信息抽取,从一段文本里抽出实体和关系;让Agent做分类,把用户反馈归到预定义的类别;让Agent做决策,输出一个包含动作和参数的结构化指令。
迁移的关键在于把业务需求翻译成Pydantic模型。你先想清楚下游需要什么字段、什么类型、什么约束,然后把这些写成模型,剩下的交给LangChain和模型。这个翻译过程本身就是一种设计活动,它逼着你把模糊的需求想清楚。我甚至觉得,写Pydantic模型的过程,比写prompt更能帮你理清业务逻辑。
有个小技巧:当你不知道怎么设计schema时,先手写几个理想的输出样例,然后从样例反推模型。这比对着空白页面硬想要高效得多。样例里出现的字段就是候选字段,样例里的嵌套关系就是模型结构,样例里的取值范围就是约束条件。
最后分享一个我在实际项目中养成的习惯:每次调整schema或prompt之后,跑一遍回归测试集,对比结构化成功率和字段填充率的变化。没有这个对比,你根本不知道改动是变好了还是变坏了。我吃过这个亏,改了一版prompt感觉"应该更好",结果成功率掉了三个点,因为没有测试集,过了两周才发现。结构化输出的好处之一就是它可测,别浪费这个优势。