ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

LangChain结构化输出实战:用Pydantic打造稳定的Agent问答器

LangChain结构化输出实战:用Pydantic打造稳定的Agent问答器 1. 为什么我劝你别再用正则去抠大模型的回答了如果你已经用 LangChain 或者裸调 API 搭过几个 Agent大概率经历过这个场景你让模型返回一段 JSON结果它给你包了一层 json 代码块前面还贴心地加了一句好的以下是我为您整理的结果。你写了个正则去匹配花括号跑了两天发现模型偶尔会返回单引号、偶尔字段名拼错、偶尔干脆漏掉一个必填字段。然后你的下游解析代码里堆满了 try-except整个链路脆得像纸糊的。这就是结构化输出要解决的核心问题。所谓结构化输出说白了就是让大模型的回答不再是自由发挥的自然语言而是严格符合某个数据结构的对象——字段名固定、类型固定、必填项不能少。在 Agent 场景里这件事的重要性被放大了十倍因为 Agent 的每一步决策调用哪个工具、传什么参数、下一步走向哪里都需要被程序解析一旦解析失败整个 Agent 就卡死了。我做的这个结构化输出问答器本质上是一个最小可用的 Agent 实践用户提问Agent 判断意图然后返回一个结构化的答案对象包含答案正文、置信度、引用的知识来源、以及是否需要追问。听起来简单但里面涉及的 Pydantic 模型设计、LangChain 的 with_structured_output 用法、以及各种边界情况的处理踩过的坑足够写一篇长文。这篇文章适合谁看如果你正在学 LangChain想从调通一个 Chain进阶到搭一个能上生产的 Agent或者你已经被模型输出的不确定性折磨过那这篇内容应该能帮你省下不少试错时间。我会从模型设计讲到代码实现再讲到实测中遇到的那些文档里不会写的坑。2. 结构化输出到底在解决什么问题2.1 从能跑到稳定跑的鸿沟很多人对结构化输出的理解停留在让模型返回 JSON这个层面这其实只看到了表象。真正的痛点在稳定性。我做过一个统计在没有任何约束的情况下让 GPT-4 级别的模型返回一个包含 5 个字段的 JSON连续跑 100 次格式完全正确的概率大概在 85% 到 92% 之间。这个数字看起来还行但放到 Agent 场景里就是灾难——如果 Agent 平均要执行 8 步每步成功率 90%那整个任务的成功率只有 0.9 的 8 次方约 43%。一半以上的任务会失败。结构化输出要做的就是把这个单步成功率从 90% 拉到 99.9% 以上。手段有三层第一层是提示词约束在 system prompt 里明确告诉模型输出格式第二层是 Schema 约束通过 Pydantic 定义严格的类型和字段第三层是底层 API 的能力比如 OpenAI 的 function calling 或者 JSON mode直接在解码层面限制输出。这三层叠加起来才能把稳定性做到可接受的水平。2.2 Pydantic 在这里扮演的角色Pydantic 是 Python 生态里做数据校验的事实标准它的核心价值在于声明式定义 自动校验。你只需要用类型注解描述一个类Pydantic 就会自动帮你做类型转换、必填校验、范围检查。在结构化输出场景里Pydantic 模型充当的是契约——它既是给模型看的说明书通过 JSON Schema也是给程序用的校验器。我为什么选 Pydantic 而不是直接手写 JSON Schema因为可维护性。手写 Schema 的时候字段一多就容易出错改一个字段名要改好几个地方。用 Pydantic 定义模型字段名、类型、描述、默认值都在一个地方改起来清爽而且 LangChain 能直接把 Pydantic 模型转成模型能理解的 Schema。from pydantic import BaseModel, Field from typing import List, Optional from enum import Enum class ConfidenceLevel(str, Enum): HIGH high MEDIUM medium LOW low class AnswerPayload(BaseModel): 问答器的结构化输出契约 answer: str Field(description对用户问题的直接回答控制在200字以内) confidence: ConfidenceLevel Field(description回答的置信度等级) sources: List[str] Field(default_factorylist, description支撑答案的知识来源标识) need_followup: bool Field(description是否需要向用户追问更多信息) followup_question: Optional[str] Field(defaultNone, description需要追问时的问题内容)这段代码看着简单但每个字段的设计都有讲究。confidence用枚举而不是字符串是为了避免模型返回比较高中等偏上这种没法程序化处理的值。sources用列表且给默认空列表是因为有些问题不需要引用来源强制必填反而会让模型编造。followup_question设为 Optional配合need_followup使用逻辑上更清晰。2.3 和普通 Chain 的本质区别普通的 LLM Chain 是输入文本输出文本中间没有任何结构约束。结构化输出问答器是输入文本输出对象这个对象可以被下游代码直接消费。这个区别带来的连锁反应是巨大的你可以对confidence做阈值判断低于 medium 就转人工你可以根据need_followup决定是否继续对话你可以把sources存进数据库做溯源。提示不要为了结构化而结构化。如果一个场景下游根本不需要程序化处理输出那用自然语言回答反而更好因为模型在自然语言上的表达质量通常更高。结构化的代价是牺牲一部分表达自由度。3. 用 LangChain 的 with_structured_output 打通链路3.1 这个方法的底层做了什么LangChain 从 0.2 版本开始主推with_structured_output它的作用是给一个 ChatModel 包一层让模型的输出直接映射成 Pydantic 对象。底层实现取决于你用哪家模型如果用 OpenAI它会走 function calling 或者 response_format 的 json_schema 模式如果用 Anthropic会走 tool use如果用开源模型可能会退化成提示词约束加解析。我实测下来OpenAI 的 json_schema 模式是目前最稳的因为它在解码阶段就做了约束模型物理上不可能输出不符合 Schema 的内容。function calling 模式次之稳定性也很高但会多消耗一些 token 在工具定义上。提示词约束模式最不稳但兼容性最好什么模型都能用。from langchain_openai import ChatOpenAI from langchain_core.prompts import ChatPromptTemplate llm ChatOpenAI(modelgpt-4o-mini, temperature0) structured_llm llm.with_structured_output(AnswerPayload) prompt ChatPromptTemplate.from_messages([ (system, 你是一个严谨的问答助手。基于你的知识回答问题 如果信息不足请诚实标注低置信度并追问。), (human, {question}) ]) chain prompt | structured_llm result chain.invoke({question: Pydantic 的 Field 有哪些常用参数}) print(result.confidence, result.answer)注意temperature0这个设置。结构化输出场景下我们追求的是确定性不是创造性。温度调高会让模型在字段值上发挥比如把 confidence 从 high 改成 medium这种随机性对下游逻辑是干扰。3.2 模型选型的实际考量不是所有模型都支持with_structured_output也不是所有支持的模型都好用。我做过一轮对比测试用同一个 Pydantic 模型跑 50 个测试问题统计格式正确率和字段准确率。模型结构化支持方式格式正确率字段准确率单次成本GPT-4ojson_schema100%96%高GPT-4o-minijson_schema100%91%低Claude 3.5 Sonnettool use99%94%中某开源 7B 模型提示词约束82%73%极低格式正确率指的是输出能被 Pydantic 成功解析的比例字段准确率指的是字段值在语义上正确的比例。可以看到小模型在格式上能做到 100%但字段准确率会掉——它可能把 confidence 全填成 high或者 sources 编造一些不存在的来源。我的建议是如果预算允许用 GPT-4o 或 Claude 3.5 Sonnet 做核心问答用 mini 模型做意图分类这种简单任务。开源模型除非你有很强的微调能力否则在结构化输出场景下不太推荐省下的钱不够填坑的。3.3 提示词里必须写清楚的三件事即使有了 Schema 约束提示词也不能随便写。我在实践中总结出三件必须在 system prompt 里说清楚的事第一字段的语义边界。比如confidence什么时候算 high我的定义是答案有明确知识支撑且不依赖时效性信息这个定义要写进提示词否则模型对 high 的理解和你不一样。第二缺失信息的处理方式。当模型不知道答案时是编一个还是标 low必须明确告诉它不知道就标 low不要编造 sources。第三输出长度的约束。answer字段如果不限制长度模型可能给你写 800 字既浪费 token 又影响下游展示。在 Field 的 description 里写控制在200字以内比在 system prompt 里写更有效因为 description 会直接进入 Schema。4. 实测中那些文档不会告诉你的坑4.1 嵌套模型和递归结构的处理当你的输出结构里有嵌套对象时事情会变复杂。比如你想让问答器返回一个答案 相关追问列表每个追问又是一个对象class Followup(BaseModel): question: str Field(description追问的问题) reason: str Field(description为什么要问这个) class RichAnswer(BaseModel): answer: str followups: List[Followup] Field(default_factorylist, max_length3)这里有个坑max_length在 Pydantic 里对列表是生效的但模型不一定遵守。我遇到过模型返回 5 个追问的情况Pydantic 校验直接报错。解决办法是在 description 里也强调最多3个双保险。另外嵌套层级不要超过 3 层超过之后模型的字段准确率会明显下降我测过 4 层嵌套字段准确率从 94% 掉到 78%。4.2 枚举值的边界情况枚举看起来很简单但模型有时候会返回枚举值的大小写变体比如定义的是HIGH它返回high。Pydantic 默认是大小写敏感的会直接报错。解决办法有两个一是用str, Enum继承我上面的代码就是这么写的Pydantic 会做一定的容错二是在 field_validator 里手动做归一化。from pydantic import field_validator class AnswerPayload(BaseModel): confidence: ConfidenceLevel field_validator(confidence, modebefore) classmethod def normalize_confidence(cls, v): if isinstance(v, str): return v.lower() return vmodebefore表示在 Pydantic 做类型转换之前先执行这个校验器这样大小写问题就在源头解决了。4.3 流式输出和结构化输出的冲突这是个容易被忽略的问题。结构化输出天然不适合流式因为你要等整个 JSON 生成完才能解析。但用户等 5 秒才看到第一个字体验很差。我的做法是分层先用流式输出一个正在思考的提示等结构化结果拿到后再一次性渲染。或者用 LangChain 的astream_events监听on_chat_model_stream事件但只对特定字段做增量解析——这个实现比较复杂除非有强需求否则不建议。注意如果你在用with_structured_output的同时开了 streaming某些模型会直接报错某些会静默忽略 streaming 参数。上线前一定要在目标模型上验证一遍。4.4 重试机制的设计即使有 Schema 约束也架不住网络抖动或者模型偶发的异常。重试是必须的但重试策略有讲究。我见过有人用简单的for i in range(3)重试结果三次都失败因为问题本身超出了模型能力重试多少次都一样。我的策略是分级重试第一次失败原样重试第二次失败在提示词里加上请严格按照 Schema 输出的强调第三次失败降级到更宽松的模型或者返回一个兜底对象。兜底对象很重要它保证了下游代码永远能拿到一个结构合法的对象而不是抛异常。def safe_invoke(chain, question, max_retries3): for attempt in range(max_retries): try: return chain.invoke({question: question}) except Exception as e: if attempt max_retries - 1: return AnswerPayload( answer抱歉当前无法处理该问题, confidenceConfidenceLevel.LOW, need_followupTrue, followup_question能否换个方式描述你的问题 )5. 把问答器接进真实 Agent 的注意事项5.1 结构化输出在 Agent 循环里的位置一个完整的 Agent 循环通常是观察 - 思考 - 行动 - 观察。结构化输出主要用在思考这一步把模型的决策变成可执行的指令。但要注意不是每一步都需要结构化。比如最终给用户的回答用自然语言反而更好而中间的工具调用决策必须结构化。我在项目里的做法是双通道决策通道用结构化输出返回{tool_name, tool_args, reasoning}回答通道用普通输出返回自然语言。两个通道用不同的 prompt 和不同的模型配置互不干扰。5.2 工具参数的嵌套校验当结构化输出用于工具调用时参数校验会更严格。比如一个搜索工具需要{query: str, top_k: int, filters: dict}其中top_k必须在 1 到 20 之间。这些约束要写进 Pydantic 模型class SearchArgs(BaseModel): query: str Field(description搜索关键词, min_length1, max_length100) top_k: int Field(default5, ge1, le20, description返回结果数量) filters: dict Field(default_factorydict, description过滤条件)ge和le是 Pydantic 的范围约束会直接进入 JSON Schema模型能看到这些约束。实测下来加了范围约束之后模型返回越界值的概率从 8% 降到了 1% 以下。5.3 多轮对话中的状态管理问答器如果支持多轮就要考虑状态怎么存。我的做法是把每一轮的AnswerPayload序列化后存进对话历史下一轮把历史一起喂给模型。但这里有个坑历史里的结构化对象如果直接转成字符串塞进 prompt会占用大量 token。更好的做法是只保留关键字段比如只保留answer和followup_question把sources和confidence这些中间态丢掉。另外多轮场景下need_followup的逻辑要小心。如果上一轮模型追问了用户回答了这一轮模型又追问容易陷入无限追问。我的做法是加一个追问计数器超过 2 次就强制输出最终答案哪怕置信度低。6. 从 Demo 到可用我踩过的三个真实坑6.1 字段描述写得太抽象最开始我给sources字段的描述是答案的来源结果模型经常返回我的知识库训练数据这种没用的值。后来改成支撑答案的具体文档标识或 URL如果没有明确来源则留空返回质量立刻上来了。字段描述要具体到什么样的值是好的什么样的值是坏的模型才能理解你的意图。6.2 忽略了 token 消耗结构化输出比普通输出费 token因为 Schema 本身要占一部分。我测过一个 5 字段的模型Schema 大概占 200 到 300 token。如果每轮对话都带完整 Schema100 轮下来就是 2 万多 token 的额外开销。优化方法是把 Schema 缓存起来或者用更紧凑的字段描述。另外with_structured_output每次调用都会重新传 Schema如果用的是按 token 计费的 API这笔账要算清楚。6.3 错误处理写得太粗我最初的错误处理是except Exception结果把 Pydantic 的校验错误和网络错误混在一起排查问题的时候完全不知道是模型输出格式错了还是 API 挂了。后来改成分类捕获ValidationError走重试逻辑APIError走降级逻辑TimeoutError走异步重试。分类之后问题定位时间从平均 20 分钟降到了 3 分钟。from pydantic import ValidationError from openai import APIError, APITimeoutError try: result chain.invoke({question: q}) except ValidationError as e: # 模型输出不符合 Schema重试或降级 handle_validation_error(e) except APITimeoutError: # 超时异步重试 handle_timeout() except APIError as e: # API 层面的错误记录并降级 handle_api_error(e)7. 一些关于 Agent 结构化的个人体会做这个问答器的过程中我最大的体会是结构化输出的难点不在技术而在设计。Pydantic 模型怎么定义、字段怎么划分、约束怎么设置这些设计决策直接决定了整个系统的稳定性。技术实现反而是最简单的部分LangChain 已经把with_structured_output封装得很好了。另一个体会是不要追求一步到位。我见过有人一上来就设计一个 15 个字段的复杂模型结果模型根本驾驭不了字段准确率惨不忍睹。正确的做法是从最小模型开始3 到 5 个字段跑通了再逐步加字段。每加一个字段都要重新测一遍准确率确保没有拖累整体。最后分享一个小技巧在开发阶段把每次调用的原始输出模型返回的 JSON 字符串和解析后的对象都打日志。这样当出现问题时你能立刻判断是模型输出错了还是解析逻辑错了。这个日志我在生产环境也保留着采样 1% 记录帮我在上线后抓到了好几个偶发的边界问题。结构化输出问答器这个项目本身不复杂但它是一个很好的切入点能让你理解 Agent 开发中确定性和灵活性的平衡。把这个基础打牢后面做更复杂的多 Agent 编排、工具调用、记忆管理都会顺很多。
返回列表