
在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: intPydantic就会在数据进来的时候尝试把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之间的小数, ge0, le1) class QAResponse(BaseModel): answer: str Field(description对用户问题的完整回答控制在200字以内) answer_type: AnswerType Field(description答案的类型分类) sources: List[Source] Field(description支撑答案的来源列表没有来源时返回空列表) confidence: float Field(description整体置信度0到1之间的小数, ge0, le1)这段代码定义了一个完整的问答响应结构。注意ge0, le1这种约束它会在校验时强制检查范围模型如果返回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(modelgpt-4o, temperature0) structured_llm llm.with_structured_output(QAResponse) result structured_llm.invoke(什么是结构化输出) print(result.answer) print(result.answer_type)这里有个细节值得注意temperature0。结构化输出场景下你几乎总是希望温度调低因为你要的是稳定和准确不是创造性。温度高了模型更容易在字段填充上发挥反而增加出错概率。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, k3) 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感觉应该更好结果成功率掉了三个点因为没有测试集过了两周才发现。结构化输出的好处之一就是它可测别浪费这个优势。