LangChain 结构化输出:让模型稳定吐出符合 schema 的 JSON

个人主页: > for_ever_love__ <(欢迎各位大佬莅临😊)
其他栏目: > 大模型开发从0到1 <
其他栏目: > iOS项目总结大全 <
其他栏目: > 我想学python了 <
其他栏目: > iOS UI <

文章目录

  • [LangChain 结构化输出:让模型稳定吐出符合 schema 的 JSON](#LangChain 结构化输出:让模型稳定吐出符合 schema 的 JSON)
    • 一、先看看不用它会怎样
    • [三、三种 schema 定义方式](#三、三种 schema 定义方式)
      • [3.1 Pydantic(最常用)](#3.1 Pydantic(最常用))
      • [3.2 TypedDict(我个人更推荐)](#3.2 TypedDict(我个人更推荐))
      • [3.3 JSON Schema(跨语言场景)](#3.3 JSON Schema(跨语言场景))
    • [四、method 参数:三种实现机制](#四、method 参数:三种实现机制)
    • 五、include_raw:调试救命参数
    • [六、接进 LCEL 链](#六、接进 LCEL 链)
      • [6.1 抽取多条:包一层列表](#6.1 抽取多条:包一层列表)
      • [6.2 可选字段:让模型能说"不知道"](#6.2 可选字段:让模型能说"不知道")
    • 七、实战:从合同文本抽结构化字段
    • 八、常见坑
    • 九、小结

LangChain 结构化输出:让模型稳定吐出符合 schema 的 JSON

让模型"返回 JSON"听起来简单,做起来全是坑:

有时候包在 ```json 代码块里,有时候键名对不上,

有时候多一段解释文字,有时候干脆少一个字段。

这篇讲 with_structured_output 怎么用、三种模式怎么选,

以及为什么我推荐 TypedDict 而不是 Pydantic。

一、先看看不用它会怎样

最常见的"土办法":

python 复制代码
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(最常用)

python 复制代码
from pydantic import BaseModel, Field

class Product(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(我个人更推荐)

python 复制代码
from typing_extensions import TypedDict, Annotated

class Product(TypedDict):
    """商品信息"""
    name: Annotated[str, ..., "商品名称"]
    price: Annotated[float, ..., "单价(元)"]
    tags: Annotated[list[str], ..., "标签列表"]

为什么不直接用 Pydantic?

因为 Pydantic 的 BaseModel 有个"顺序陷阱":

想在描述里引用前面定义的字段,在某些写法下会出问题;

而且 Pydantic 的校验错误有时会把模型输出的小瑕疵放大成硬失败。

TypedDict 是纯类型声明,没有运行时校验,也就不会因为一点小偏差直接报错 ,

配合 Annotated 的三段式(类型, 默认值/..., 描述)语义非常清晰。

什么时候必须用 Pydantic? 需要运行时校验、需要嵌套复杂约束、

或者下游要复用这个 model 做序列化时。其余场景 TypedDict 更轻。

3.3 JSON Schema(跨语言场景)

python 复制代码
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
python 复制代码
# 默认会自动选,也可以显式指定
structured_llm = llm.with_structured_output(Product, method="function_calling")

怎么选?

  • 模型支持原生 json_schema(如 gpt-4o 系列)→ 用它,最稳;
  • 不支持 → function_calling,兼容性最好;
  • 只有在前两者都不可用时才用 json_mode,它只保证"是合法 JSON",
    字段对不对它不管,你还得自己校验。

判断方法:直接试试,报错就换。

五、include_raw:调试救命参数

python 复制代码
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,可以直接链起来:

python 复制代码
from langchain_core.prompts import ChatPromptTemplate

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 抽取多条:包一层列表

python 复制代码
from pydantic import BaseModel, Field

class ProductList(BaseModel):
    """商品列表"""
    products: list[Product] = Field(description="抽取到的所有商品")

chain = prompt | llm.with_structured_output(ProductList)

不要直接传 list[Product] ------包一层带描述的容器模型,

模型的抽取完整度会明显更好,也方便你加"总数""备注"等字段。

6.2 可选字段:让模型能说"不知道"

python 复制代码
from typing import Optional

class Product(BaseModel):
    name: str = Field(description="商品名称")
    price: Optional[float] = Field(description="单价,文本中没提到则为 null", default=None)

明确告诉模型"没有就填 null" ,比让它硬编一个数字好得多。

这是我反复强调的原则------给模型留"不知道"的出口,它才不会编。

七、实战:从合同文本抽结构化字段

python 复制代码
from typing import Optional
from typing_extensions import TypedDict, Annotated
from langchain_core.prompts import ChatPromptTemplate

class ContractInfo(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 技巧,效果差别很大:

  1. "原文照抄"------避免模型自作主张规范化公司名;
  2. "没有提到的一律填 None"------给"不知道"的出口;
  3. 格式举例 (YYYY-MM-DD、只填数字)------减少格式抖动;
  4. "不要带货币单位"------明确边界。

八、常见坑

现象 原因 解法
报"不支持 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 入门------把工具调用变成自动的多步循环。

相关推荐
健康活着就好1 小时前
# 手写Promise.all和Promise.race,面试再也不怕了
面试·json
flash俊杰19 小时前
给 7B 小模型当 Agent 框架:真机三轮排障,十条失败链逐个补
langchain
打工仔折腾 AI21 小时前
LangChain 工具调用实战:从 Tool 定义到 Agent 执行链路拆解
java·前端·人工智能·后端·python·langchain·ai agent 实战
Dxy123931021621 小时前
MySQL JSON 生成列索引原理、实战与坑点
mysql·adb·json
for_ever_love__1 天前
LangChain 上线部署:把链和 Agent 变成能对外服务的 API
langchain·部署·fastapi·rest api·服务·上线·langserve
染指11101 天前
145.Agent-LangGraph基础概念和安装和简单代码示例
java·服务器·数据库·langchain·agents
一木 之林1 天前
LangChain 第6章 Agent 智能体核心:create_agent 统一入口与 ReAct 循环
前端·react.js·langchain
l1t1 天前
DeepSeek总结的DuckDB 从 JSON 字符串到类型化列
数据库·sql·json
小王师傅661 天前
LangChain 系统化学习入门
学习·langchain