
1. 为什么“能聊天”的Agent不值钱“能交格式”的才值钱我做过不少Agent项目从最早的纯Prompt拼接到后来的LangChain链路再到现在的LangGraph编排踩过的坑基本能写一本小册子。但有一个坑几乎每个刚上手Agent开发的人都会踩而且踩完之后还不太知道自己踩了——Agent能说人话但下游系统读不懂它说的话。举个最典型的场景。你让Agent去分析一段用户反馈它给你回一段“这位用户整体情绪偏负面主要抱怨物流太慢同时对客服响应速度也不太满意建议优先跟进。”人看着挺好但如果你要把这条结果写进数据库、喂给BI看板、或者触发一个自动化工单这段自然语言就是废的。你得再写一堆正则去抠“负面”“物流”“客服”这些词抠着抠着就发现Agent今天说“偏负面”明天说“情绪不太积极”后天说“用户有点不爽”你的正则永远追不上它的表达欲。这就是结构化输出要解决的核心问题。所谓结构化输出说白了就是让Agent别自由发挥按你给定的“表格”来填内容。你给它一个Schema它就必须返回符合这个Schema的JSON字段名、字段类型、嵌套结构全都对得上下游程序拿到就能直接用不需要任何二次解析。这一篇要聊的“结构化输出问答器”就是围绕这个思路做的一个小项目。它的定位很明确输入一个问题或者一段待处理的文本Agent经过推理后输出一个严格符合Pydantic模型定义的结构化结果。适合谁看适合已经跑通过“Hello World”级别LangChain Demo、想让Agent真正接入业务系统的开发者也适合被Agent输出格式折磨过、想找一套稳定方案的人。关键词里出现的LangChain、Pydantic是这个项目的两大支柱。LangChain负责编排和调用模型Pydantic负责定义“什么叫合法输出”。至于热搜词里那一堆agent开发、agent架构、agent记忆、多agent之类我后面会挑几个跟结构化输出强相关的点展开聊不相关的就不硬蹭了。先说结论结构化输出不是“让模型听话”这么简单它是一套从Schema设计、Prompt约束、解析兜底到错误重试的完整工程。下面我按自己实际搭这个问答器的顺序把每一层都拆开讲。2. 结构化输出问答器的整体链路长什么样2.1 从“输入一句话”到“输出一个对象”的完整流转先把这个问答器的骨架画清楚不然后面聊细节容易飘。整个链路其实不复杂但每一环都有讲究输入层接收用户的问题可能是一句自然语言提问也可能是一段需要抽取信息的原始文本。Schema定义层用Pydantic定义一个模型明确告诉系统“我要的答案长什么样”。这一步是整个项目的地基。Prompt构造层把Schema的字段说明、类型约束、示例翻译成模型能理解的指令。模型调用层通过LangChain调用底层大模型把Prompt和Schema一起传进去。解析与校验层拿到模型返回的内容用Pydantic做校验合法就通过不合法就进入重试。重试与兜底层校验失败时把错误信息回传给模型让它自己修正最多重试N次。输出层返回一个Pydantic对象下游代码可以直接.field取值。这个链路里第2步和第6步是最容易被低估的。很多人以为Schema随便写写就行结果字段设计得不合理模型怎么都填不对也有很多人压根没做重试模型偶尔抽风返回个带markdown代码块的JSON程序直接崩。2.2 为什么选Pydantic而不是手写JSON Schema这里要解释一个选型问题。你完全可以直接写一个JSON Schema字符串丢给模型为什么还要绕一层Pydantic我自己的理由是三条。第一Pydantic的模型即文档。你定义一个类字段名、类型、描述、默认值全在里面既是给模型看的约束也是给同事看的接口文档一份东西两用。第二校验能力内置。模型返回的JSON是不是合法、字段类型对不对、必填项有没有缺Pydantic一个model_validate就搞定不用自己写一堆if-else。第三和LangChain生态无缝衔接。LangChain对Pydantic模型有一等公民级别的支持可以直接把模型类传给结构化输出接口省掉大量胶水代码。手写JSON Schema当然也能跑但你会在校验和重试这两块付出额外成本。除非你的项目有特殊约束不能用Pydantic否则我建议直接用Pydantic。2.3 一个最小可用的Schema长什么样先给一个具体例子后面所有讨论都围绕它展开。假设我们要做一个“用户反馈分析问答器”输入一段用户反馈输出结构化的分析结果from pydantic import BaseModel, Field from typing import List, Literal class FeedbackAnalysis(BaseModel): sentiment: Literal[positive, neutral, negative] Field( description用户反馈的整体情绪倾向 ) topics: List[str] Field( description反馈中提到的具体话题如物流、客服、产品质量 ) urgency: int Field( ge1, le5, description紧急程度1为最低5为最高 ) summary: str Field( max_length100, description一句话总结用户的核心诉求不超过100字 ) needs_followup: bool Field( description是否需要人工跟进 )这个Schema里有几个设计细节值得说。sentiment用Literal而不是str是为了把模型的输出锁死在三个枚举值里避免它自由发挥。urgency用ge和le限定范围防止模型返回个“8”或者“-1”。summary加了max_length因为不加的话模型可能给你写一篇小作文。needs_followup用布尔值比让模型返回“是/否/可能需要”这种模糊表达要可靠得多。提示Schema里的description字段不是装饰品它会被拼进Prompt里直接影响模型的理解。写description要像给新人写注释一样具体、无歧义。3. Schema设计结构化输出成败的八成在这里3.1 字段粒度太粗模型乱填太细模型填不动Schema设计最难的其实是粒度。我见过两种极端。一种是字段太粗。比如只定义一个result: str让模型把所有分析塞进一个字符串里。这等于没做结构化下游还是得解析字符串白折腾。另一种是字段太细。有人恨不得把每个可能的子话题都拆成一个独立字段搞出二三十个字段。结果模型在填的时候顾此失彼要么漏填要么把内容填错位置校验通过率极低。我的经验是字段数量控制在5到10个之间每个字段承载一个独立的语义单元。什么叫独立语义单元就是“这个字段的值不需要参考其他字段就能理解”。比如sentiment和urgency是两个独立维度可以分开但“用户对物流的不满”和“用户对客服的不满”如果都拆成字段就太细了不如统一放进topics列表里。还有一个技巧能合并的同类信息就用列表不要用多个平行字段。比如上面例子里topics是一个List[str]而不是topic1、topic2、topic3。列表的好处是长度可变模型有几个话题就填几个不会因为固定字段数不够而丢信息。3.2 类型选择枚举、布尔、数值哪个更抗造类型选择直接决定了模型的“犯错空间”。我按抗造程度从高到低排个序类型抗造程度适用场景注意事项枚举Literal/Enum最高分类、标签、状态枚举值要穷举别留“其他”布尔bool高是否判断、开关避免用“是/否/可能”三态数值int/float中评分、数量、优先级必须加范围约束字符串str低摘要、描述、解释必须加长度限制嵌套对象最低复杂结构层级别超过两层枚举是最抗造的因为模型只能在给定选项里选没有发挥空间。布尔次之二选一也不容易错。数值要注意加ge/le约束否则模型可能给你返回个离谱的值。字符串最危险模型容易写嗨所以max_length几乎是必须的。嵌套对象能不用就不用层级一深模型填错的概率指数级上升。3.3 描述文本写给模型看的“填表说明”Field里的description是很多人忽略的重灾区。我见过有人写description情绪这跟没写一样。模型看到“情绪”两个字它不知道你要的是三分类还是五分类是用户情绪还是客服情绪。好的description应该包含三要素这个字段代表什么、取值范围是什么、边界情况怎么处理。举个例子urgency: int Field( ge1, le5, description紧急程度评分。1可以慢慢处理3本周内需要响应5需要立即处理。如果反馈涉及人身安全或资金损失一律评为5。 )你看这样写模型就知道边界情况怎么处理了。description写得越像“填表说明”模型的输出就越稳定。这是我在多个项目里反复验证过的规律。3.4 必填与可选别让模型在“不知道”时瞎编Pydantic默认所有字段都是必填的。这在结构化输出里其实是个陷阱。因为有些信息原文里压根没提你强制模型填它就只能瞎编。正确做法是对于原文可能不包含的信息给一个默认值或者设为Optional。比如from typing import Optional class FeedbackAnalysis(BaseModel): # ... 其他字段 mentioned_competitor: Optional[str] Field( defaultNone, description如果用户提到了竞品名称填在这里没提到就留空 )这样模型在原文没提竞品时可以老老实实返回null而不是硬编一个品牌名出来。让模型有“说不知道”的权利是提升结构化输出可信度的关键。4. 用LangChain把Schema“翻译”给模型听4.1 with_structured_output一行代码背后的机制LangChain提供了一个非常方便的接口with_structured_output。用法大概是这样from langchain_openai import ChatOpenAI llm ChatOpenAI(modelgpt-4o-mini, temperature0) structured_llm llm.with_structured_output(FeedbackAnalysis) result structured_llm.invoke(用户说下单三天了还没发货客服也联系不上太气人了) print(result.sentiment) # negative print(result.urgency) # 5看起来就一行代码但背后做了不少事。它会把Pydantic模型转换成底层模型能理解的格式不同模型厂商支持的方式不一样有的用function calling有的用JSON mode然后自动解析返回结果并做校验。这里有个关键点with_structured_output的可靠性取决于底层模型对结构化输出的原生支持程度。支持function calling的模型输出稳定性明显高于只靠Prompt约束的模型。所以选模型的时候这一点要纳入考量。4.2 当模型不支持原生结构化输出时怎么办不是所有模型都支持function calling或者JSON mode。如果你用的是本地部署的小模型或者某些API还没跟上就得走“Prompt约束手动解析”这条路。做法是在Prompt里明确写出Schema的JSON结构并要求模型“只返回JSON不要有任何其他文字”。然后在代码里手动解析import json from pydantic import ValidationError def parse_with_retry(raw_output: str, model_class): # 去掉可能的markdown代码块标记 cleaned raw_output.strip() if cleaned.startswith(): cleaned cleaned.split(\n, 1)[1] cleaned cleaned.rsplit(, 1)[0] try: data json.loads(cleaned) return model_class.model_validate(data) except (json.JSONDecodeError, ValidationError) as e: return None, str(e)这条路明显更脆因为模型可能返回带解释文字的JSON、可能用单引号、可能漏掉闭合括号。所以能走原生结构化输出就走原生别跟自己过不去。4.3 Prompt里到底该放什么、不该放什么即使有了with_structured_outputPrompt本身还是要认真写。我的经验是Prompt里放三样东西就够了任务说明一句话说清楚要干什么比如“分析以下用户反馈提取结构化信息”。字段补充说明Schema的description已经覆盖了大部分但有些跨字段的规则要在这里说比如“如果sentiment是negative且urgency大于3needs_followup必须为true”。输入内容待处理的文本。不该放的东西也有三样不要放完整的JSON Schema字符串with_structured_output已经处理了重复放反而干扰、不要放大量示例一两个就够多了占token还容易让模型照抄示例内容、不要放模棱两可的指令比如“尽量准确”这种废话。注意Prompt里的跨字段规则是提升输出质量的高杠杆点。模型单独看每个字段可能都填对但字段之间的逻辑一致性需要你显式约束。5. 校验失败之后重试机制才是稳定性的分水岭5.1 为什么第一次调用失败是常态不是异常新手最容易犯的错是把“模型返回了不合法的结果”当成异常来处理一失败就抛错。但在实际项目里第一次调用校验失败是常态尤其是Schema字段多、约束复杂的时候。我统计过自己项目里的数据在Schema有7个字段、其中3个带约束的情况下首次校验通过率大概在70%到85%之间取决于模型。也就是说有15%到30%的请求需要重试。如果不做重试你的系统可用性直接打七折。所以重试不是“锦上添花”是“雪中送炭”。没有重试机制的结构化输出只能算Demo不能算产品。5.2 把校验错误“翻译”回模型能懂的话重试的关键在于你不能只是简单地把同样的请求再发一遍那样模型大概率还是错。你要把校验失败的具体原因告诉它。Pydantic的ValidationError会给出详细的错误信息比如“urgency字段的值8超出了允许范围1到5”。你要把这段信息拼进下一轮的Prompt里让模型知道错在哪def invoke_with_retry(llm, prompt, model_class, max_retries3): last_error None for attempt in range(max_retries): if last_error: retry_prompt f{prompt}\n\n上次输出有以下错误请修正\n{last_error} else: retry_prompt prompt raw llm.invoke(retry_prompt) try: return model_class.model_validate_json(raw.content) except ValidationError as e: last_error str(e) raise RuntimeError(f重试{max_retries}次后仍失败{last_error})这个模式我用了很多次效果很稳。把错误信息回传相当于给模型一次“看答案改错”的机会比盲目重试有效得多。5.3 重试次数、退避策略与成本权衡重试次数不是越多越好。每重试一次就多一次模型调用成本和延迟都上去了。我的经验值是最多重试2到3次。超过3次还失败的基本不是模型的问题而是Schema设计有问题或者输入本身就不适合这个Schema。另外重试之间可以加一点退避比如等个几百毫秒。虽然对API调用来说意义不大但如果你的模型是本地部署、有并发限制退避能减少撞车。还有一个成本细节重试时可以把temperature调低一点。首次调用用0.1重试时用0让模型更“死板”一点减少再次犯错的概率。5.4 兜底方案当重试也救不回来时重试3次还失败怎么办我的做法是返回一个“降级对象”而不是直接抛错。比如所有字段用默认值填充同时打一个标记class FeedbackAnalysis(BaseModel): # ... 字段定义 parse_failed: bool Field(defaultFalse, description解析是否失败)失败时返回一个parse_failedTrue的对象下游代码看到这个标记就知道这条数据不可信可以走人工审核或者丢弃。这比让整个流程崩掉要优雅得多。6. 实测中那些文档不会告诉你的坑6.1 模型偷偷加markdown代码块这是最高频的坑。你明明要求“只返回JSON”模型还是给你包一层json。with_structured_output在支持function calling的模型上能规避这个问题但走手动解析路线时几乎必踩。我的处理方式是在解析前先做一次清洗把首尾的代码块标记去掉。这个清洗逻辑要写得宽容一点兼容json、、甚至只有开头没有结尾的情况。6.2 中文描述导致的字段名漂移如果你用中文写description有时候模型会把字段名也“翻译”成中文。比如你定义的是sentiment它返回情绪。这在英文模型上偶尔出现在中文模型上更常见。解决办法有两个一是在Prompt里明确强调“字段名必须使用英文保持与Schema一致”二是解析时做一个字段名映射把常见的中文别名映射回英文。我一般两个都用双保险。6.3 长文本输入时的“中间遗忘”当输入文本很长比如超过2000字时模型容易“忘记”Schema里的某些约束尤其是那些写在description末尾的规则。这是注意力机制的特性不是模型笨。应对策略是把关键约束前置。在Prompt的最开头就把最重要的规则说一遍比如“输出必须是合法JSON字段名必须与Schema一致urgency必须在1到5之间”。别把这些规则藏在最后。6.4 并发场景下的重试风暴如果你的问答器要扛并发重试机制会放大请求量。假设首次通过率80%重试2次那实际请求量大约是原始请求量的1.25倍。如果并发很高这个放大效应会压垮你的API配额。我的做法是给重试加一个全局的并发控制比如用信号量限制同时进行的重试请求数。另外对于批量任务可以把失败的重试请求收集起来统一在低峰期处理而不是立即重试。7. 从单轮到多轮结构化输出在Agent记忆里的角色7.1 结构化结果本身就是最好的记忆载体聊到Agent记忆很多人第一反应是向量数据库、是对话历史。但在结构化输出问答器这个场景里每一次的结构化结果本身就是高质量的记忆。为什么因为结构化结果已经把非结构化的对话压缩成了字段化的信息。你要做用户画像直接聚合sentiment字段就行你要做话题统计直接展开topics列表就行。这比把原始对话丢进向量库再检索要精确得多。所以我的建议是结构化输出的结果要单独存一份不要只存在对话历史里。它是你后续做分析、做记忆、做个性化的数据基础。7.2 多轮问答时如何保持Schema一致如果问答器支持多轮对话第二轮、第三轮的输出Schema要不要和第一轮一样我的答案是看场景。如果是同一个任务的追问比如第一轮分析了反馈第二轮问“这条反馈应该分给哪个部门”那Schema可以扩展加一个department字段。但扩展的时候要注意新字段要有默认值否则历史数据反序列化会失败。如果是完全不同的任务那就换一个Schema。不要试图用一个万能Schema覆盖所有场景那样每个字段都会变得模糊模型填得也差。7.3 把结构化输出接进下游系统的三种姿势结构化输出做出来最终是要用的。我总结三种常见的接入姿势直接入库Pydantic对象转dict直接写进关系型数据库或者文档数据库。适合做数据沉淀。触发动作根据字段值触发下游流程比如needs_followupTrue就自动创建工单。适合做自动化。喂给下一个Agent把结构化结果作为下一个Agent的输入形成Agent链。适合做复杂编排。这三种姿势可以组合使用。我自己的项目里通常是先入库再根据字段触发动作偶尔把结果喂给下游Agent做二次处理。8. 写在最后几个我踩过之后才明白的道理Schema设计不是一次成型的。我第一个版本的Schema字段又多又细校验通过率惨不忍睹。后来砍到6个字段通过率直接上去了。Schema要像API一样迭代别指望一版到位。重试机制要早做。我一开始觉得重试是“优化项”等项目上线发现15%的请求失败才回头补。补的时候发现重试逻辑要改的地方比想象中多。重试应该是第一版就有的基础设施不是后期补丁。description值得花时间打磨。我现在的习惯是Schema写完之后自己读一遍description问自己“如果我是模型看到这句话知道该怎么填吗”。如果有一丝犹豫就改。这个习惯让我的首次通过率提升了大概10个百分点。最后分享一个小技巧给Schema加一个reasoning字段。让模型在填其他字段之前先写一段推理过程。这个字段不参与下游逻辑但能显著提升其他字段的准确率。原理很简单模型“想一遍再填”比“直接填”要靠谱。代价是输出变长、成本略增但在准确性要求高的场景里这个代价值得。