文章目录
- LangChain 结构化输出:让模型稳定吐出符合 schema 的 JSON
- 一、先看看不用它会怎样
- 三、三种 schema 定义方式
- 3.1 Pydantic(最常用)
- 3.2 TypedDict(我个人更推荐)
- 3.3 JSON Schema(跨语言场景)
- 四、method 参数:三种实现机制
- 五、include_raw:调试救命参数
- 六、接进 LCEL 链
- 6.1 抽取多条:包一层列表
- 6.2 可选字段:让模型能说"不知道"
- 七、实战:从合同文本抽结构化字段
- 八、常见坑
- 九、小结
LangChain 结构化输出:让模型稳定吐出符合 schema 的 JSON
让模型"返回 JSON"听起来简单,做起来全是坑:
有时候包在 ```json 代码块里,有时候键名对不上,
有时候多一段解释文字,有时候干脆少一个字段。这篇讲
with_structured_output怎么用、三种模式怎么选,
以及为什么我推荐 TypedDict 而不是 Pydantic。
一、先看看不用它会怎样
最常见的"土办法":
prompt="请分析下面这句话的情感,返回 JSON:{'sentiment': ..., 'score': ...}\n文本:xxx"resp=llm.invoke(prompt)print(resp.content)你会得到各种意外:
好的,分析结果如下: ```json {"sentiment": "positive", "score": 0.9}希望对你有帮助!
或者:{“情感”: “正面”, “分数”: 0.9} # 键名被翻译了
或者:{“sentiment”: “positive”} # 少了一个字段
然后你得写一堆正则、容错、重试。**这些麻烦 `with_structured_output` 全帮你处理了。** ## 二、上手 ```python from pydantic import BaseModel, Field from langchain_openai import ChatOpenAI class Sentiment(BaseModel): """情感分析结果""" sentiment: str = Field(description="情感倾向:positive / negative / neutral") score: float = Field(description="置信度,0~1 之间", ge=0, le=1) keywords: list[str] = Field(description="体现情感倾向的关键词") llm = ChatOpenAI(model="gpt-4o-mini") structured_llm = llm.with_structured_output(Sentiment) result = structured_llm.invoke("这家餐厅的牛排太好吃了,就是上菜有点慢") print(type(result)) # <class 'Sentiment'> print(result.sentiment) # positive print(result.score) # 0.85 print(result.keywords) # ['牛排好吃', '上菜慢']返回的直接是 Python 对象,不是字符串。类型安全,IDE 能补全,
下游代码可以直接.score用,不用再json.loads。
三、三种 schema 定义方式
3.1 Pydantic(最常用)
frompydanticimportBaseModel,FieldclassProduct(BaseModel):name:str=Field(description="商品名称")price:float=Field(description="单价(元)")tags:list[str]=Field(description="标签列表",default_factory=list)优点:自带校验。定义了ge=0, le=1,模型传 1.5 会抛校验错误,
你能立刻发现而不是等到线上。
3.2 TypedDict(我个人更推荐)
fromtyping_extensionsimportTypedDict,AnnotatedclassProduct(TypedDict):"""商品信息"""name:Annotated[str,...,"商品名称"]price:Annotated[float,...,"单价(元)"]tags:Annotated[list[str],...,"标签列表"]为什么不直接用 Pydantic?
因为 Pydantic 的BaseModel有个"顺序陷阱":
想在描述里引用前面定义的字段,在某些写法下会出问题;
而且 Pydantic 的校验错误有时会把模型输出的小瑕疵放大成硬失败。
TypedDict 是纯类型声明,没有运行时校验,也就不会因为一点小偏差直接报错,
配合Annotated的三段式(类型, 默认值/..., 描述)语义非常清晰。
什么时候必须用 Pydantic?需要运行时校验、需要嵌套复杂约束、
或者下游要复用这个 model 做序列化时。其余场景 TypedDict 更轻。
3.3 JSON Schema(跨语言场景)
schema={"title":"Product","type":"object","properties":{"name":{"type":"string","description":"商品名称"},"price":{"type":"number","description":"单价(元)"},"tags":{"type":"array","items":{"type":"string"},"description":"标签"},},"required":["name","price"],}structured_llm=llm.with_structured_output(schema)适合 schema 存在配置文件里、或由其他系统生成的场景。
四、method 参数:三种实现机制
with_structured_output(schema, method=...)是关键参数。
| method | 机制 | 支持度 | 特点 |
|---|---|---|---|
function_calling | 用 tool calling 模拟 | OpenAI、大部分主流模型 | 最通用 |
json_schema | 模型原生的结构化输出 | OpenAI 新模型 | 最严格,保证 100% 符合 schema |
json_mode | 只强制输出合法 JSON | 部分模型 | 只保证是 JSON,不保证符合 schema |
# 默认会自动选,也可以显式指定structured_llm=llm.with_structured_output(Product,method="function_calling")怎么选?
- 模型支持原生
json_schema(如 gpt-4o 系列)→用它,最稳; - 不支持 →
function_calling,兼容性最好; - 只有在前两者都不可用时才用
json_mode,它只保证"是合法 JSON",
字段对不对它不管,你还得自己校验。
判断方法:直接试试,报错就换。
五、include_raw:调试救命参数
structured_llm=llm.with_structured_output(Product,include_raw=True)result=structured_llm.invoke("iPhone 15 售价 5999 元")print(result)# {# 'raw': AIMessage(content='', tool_calls=[...]),# 'parsed': Product(name='iPhone 15', price=5999.0, tags=[]),# 'parsing_error': None# }解析失败时parsed是 None、parsing_error有内容,
而raw保留模型原始输出。
排查"为什么又解析失败了"的时候,这个参数能省你半小时。
生产环境建议先开着跑一段时间,收集几次parsing_error,
确认稳定后再关掉。
六、接进 LCEL 链
结构化输出返回的还是 Runnable,可以直接链起来:
fromlangchain_core.promptsimportChatPromptTemplate prompt=ChatPromptTemplate.from_messages([("system","你是信息抽取助手。从文本中抽取商品信息,不要编造。"),("human","{text}"),])chain=prompt|llm.with_structured_output(Product)result=chain.invoke({"text":"这款 MacBook Pro 16G+512G 售价 12999 元"})print(result.name,result.price)6.1 抽取多条:包一层列表
frompydanticimportBaseModel,FieldclassProductList(BaseModel):"""商品列表"""products:list[Product]=Field(description="抽取到的所有商品")chain=prompt|llm.with_structured_output(ProductList)不要直接传list[Product]——包一层带描述的容器模型,
模型的抽取完整度会明显更好,也方便你加"总数""备注"等字段。
6.2 可选字段:让模型能说"不知道"
fromtypingimportOptionalclassProduct(BaseModel):name:str=Field(description="商品名称")price:Optional[float]=Field(description="单价,文本中没提到则为 null",default=None)明确告诉模型"没有就填 null",比让它硬编一个数字好得多。
这是我反复强调的原则——给模型留"不知道"的出口,它才不会编。
七、实战:从合同文本抽结构化字段
fromtypingimportOptionalfromtyping_extensionsimportTypedDict,Annotatedfromlangchain_core.promptsimportChatPromptTemplateclassContractInfo(TypedDict):"""合同关键信息"""party_a:Annotated[str,...,"甲方名称,原文照抄"]party_b:Annotated[str,...,"乙方名称,原文照抄"]amount:Annotated[Optional[float],...,"合同金额(元),未提及则为 None"]sign_date:Annotated[Optional[str],...,"签订日期,YYYY-MM-DD 格式,未提及则为 None"]has_penalty:Annotated[bool,...,"是否包含违约金条款"]prompt=ChatPromptTemplate.from_messages([("system","你是合同信息抽取助手。\n""只抽取文本中明确出现的信息,没有提到的一律填 None,绝对不要推测。\n""金额只填数字,不要带货币单位和千分位。"),("human","合同文本:\n{text}"),])chain=prompt|llm.with_structured_output(ContractInfo)info=chain.invoke({"text":contract_text})print(info["party_a"],info["amount"],info["has_penalty"])几个 prompt 技巧,效果差别很大:
- “原文照抄”——避免模型自作主张规范化公司名;
- “没有提到的一律填 None”——给"不知道"的出口;
- 格式举例(
YYYY-MM-DD、只填数字)——减少格式抖动; - “不要带货币单位”——明确边界。
八、常见坑
| 现象 | 原因 | 解法 |
|---|---|---|
| 报"不支持 structured output" | 模型不支持该 method | 换method="function_calling" |
| 字段值被翻译/改名 | schema 描述用的中文 | 描述用中文没问题,检查是不是模型幻觉,加"原文照抄" |
| Pydantic 校验报错 | 模型传了越界值 | 放宽约束,或换 TypedDict |
| 嵌套结构抽不全 | 直接用了list[X] | 包一层容器模型 |
| 偶尔返回 None | 解析失败 | 开include_raw=True看 raw |
| 长文本抽取质量下降 | 上下文太长 | 先切片,逐片抽取再合并 |
九、小结
with_structured_output直接返回Python 对象,告别正则和容错代码;- schema 三种写法:Pydantic(要校验)、TypedDict(推荐,轻量)、JSON Schema(跨语言);
method优先级:json_schema(最严格)>function_calling(最通用)>json_mode(兜底);- 调试开
include_raw=True,能看到 raw / parsed / parsing_error 三部分; - 抽多条时包一层容器模型,别直接传
list[X]; - 可选字段用
Optional+ “没有就填 None”,给模型留说不知道的出口。
下一篇讲 Agent 入门——把工具调用变成自动的多步循环。