LangChain 系列 6:输出解析器与结构化输出


前文已经介绍了消息和提示词模板。前面的示例向模型发送问题后,得到的主要还是一段自然语言。

自然语言适合人阅读,但程序更希望得到稳定的数据。例如,程序需要的天气信息可能是:

{
  "city": "北京",
  "condition": "晴天",
  "temperature_c": 26
}

有了固定字段以后,代码可以直接读取 city、condition 和 temperature_c,不需要从一整段文字中猜测哪一部分是城市、天气或温度。

本文从最简单的 StrOutputParser 开始,依次介绍 JSON、Pydantic、列表、XML 和 with_structured_output(),最后再看 LangChain 如何解析工具调用格式中的结构化参数。

输出解析器和结构化输出都能得到程序可读取的数据,但它们介入模型调用的位置并不相同:

输出解析器与结构化输出的执行位置

上半部分先让模型正常生成内容,再由解析器转换结果;下半部分在调用模型之前绑定 Schema,让模型服务按照指定协议返回结构化数据。后面的示例都围绕这两条路径展开。

1. 为什么需要输出解析

1.1 聊天模型默认返回 AIMessage

使用 ChatOpenAI.invoke() 调用聊天模型时,返回的不是普通字符串,而是一个 AIMessage:

message = llm.invoke("天空为什么是蓝色的?")

print(type(message).__name__)
print(message.content)

AIMessage 不只保存回答文本,还能保存令牌用量、结束原因、工具调用等信息,因此比普通字符串更完整。

但是,如果当前程序只需要显示或保存回答文本,就还要读取:

text = message.content

输出解析器就是用来完成这类转换的组件。

1.2 程序需要明确的数据类型

同一份模型回答,可以根据后续用途转换成不同类型:

  • 普通回答可以转换成 str。
  • 多个项目可以转换成 list。
  • 固定字段可以转换成 dict。
  • 需要类型校验时可以转换成 Pydantic 对象。
  • XML 文本可以转换成嵌套字典。

这就是输出解析器最主要的作用:把模型返回的内容转换成程序更容易继续处理的数据。

2. 输出解析器和结构化输出不是一回事

这两个概念很容易混淆。

输出解析器的过程是:

提示词 -> 模型生成文本 -> 输出解析器转换文本

例如:

chain = prompt | llm | JsonOutputParser()

with_structured_output() 的过程则是:

先把 Schema 绑定到模型 -> 模型按照指定结构生成结果 -> LangChain 返回结构化对象

例如:

structured_llm = llm.with_structured_output(Weather)

两种方式都能得到结构化结果,但区别很重要:

  • 输出解析器主要依靠提示词约束模型输出,然后在模型返回后进行解析。
  • with_structured_output() 会使用模型接口提供的 JSON 或工具调用能力。
  • 模型服务不支持某种结构化协议时,with_structured_output() 可能失败。
  • 输出解析器更加通用,但提示词不清楚时也可能解析失败。

3. 运行环境

本文代码位于:

/path/to/llm-learning/langchain/p06_structured_output/

九个示例文件如下:

p06_structured_output/
├── 01_str_output_parser.py
├── 02_json_output_parser.py
├── 03_pydantic_output_parser.py
├── 04_list_and_xml_parsers.py
├── 05_with_structured_output_pydantic.py
├── 06_structured_output_schema_types.py
├── 07_include_raw_and_validation.py
├── 08_bind_tools_and_tool_parsers.py
└── 09_structured_output_methods.py

进入项目并激活 Python 3.12 虚拟环境:

cd /path/to/llm-learning
source .venv_langchain/bin/activate
python -m pip install -r requirements.txt

除了列表和 XML 示例,其余脚本都要连接本地 Qwen3。使用前面文章验证过的命令启动服务:

"$VIRTUAL_ENV/bin/python" -m mlx_lm server \
  --model Qwen3-14B-AWQ-4bit-MLX \
  --host 127.0.0.1 \
  --port 18080 \
  --chat-template-args '{"enable_thinking": false}'

另开一个终端检查服务是否已经就绪:

curl http://127.0.0.1:18080/v1/models

运行脚本时还可能看到:

PyTorch was not found. Models won't be available and only tokenizers, configuration and file/data utilities can be used.

本文通过 HTTP 连接独立运行的 MLX-LM Server,不使用 PyTorch 加载模型,因此这条提示不影响运行结果。

4. 解析器 1:StrOutputParser

StrOutputParser 是最简单的输出解析器。它会提取 AIMessage.content,返回普通 str。

代码文件为 01_str_output_parser.py:

# 这个文件演示如何把本地模型返回的 AIMessage 转换成普通字符串。
# 运行前需要先在项目根目录启动 mlx_lm.server。

from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI


# 连接已经启动的本地 Qwen3 OpenAI 兼容接口。
llm = ChatOpenAI(
    base_url="http://127.0.0.1:18080/v1",
    api_key="not-needed",
    model="Qwen3-14B-AWQ-4bit-MLX",
    temperature=0,
    max_tokens=64,
)

# 创建聊天提示词模板,问题会在运行时填入 question 变量。
prompt = ChatPromptTemplate.from_messages(
    [
        ("system", "请用一句简短的话回答用户问题。"),
        ("human", "{question}"),
    ]
)

question = {"question": "天空为什么是蓝色的?"}

print("方式一:不使用输出解析器  chain = prompt | llm")
chain_without_parser = prompt | llm
message = chain_without_parser.invoke(question)
print("返回类型:", type(message).__name__)  # AIMessage
print("返回对象:", message.content)


print("方式二:使用 StrOutputParser  chain = prompt | llm | StrOutputParser()")
chain_with_parser = prompt | llm | StrOutputParser()
text = chain_with_parser.invoke(question)
print("返回类型:", type(text).__name__)  # str
print("返回对象:", repr(text))


# 方式二也可以拆成两步理解:先拿 AIMessage,再 parser.invoke(message)
parser = StrOutputParser()
text_from_message = parser.invoke(message)
print("分步解析结果与管道结果一致:", text_from_message == text)

运行:

python langchain/p06_structured_output/01_str_output_parser.py

真实输出:

方式一:不使用输出解析器  chain = prompt | llm
返回类型: AIMessage
返回对象: 因为太阳光在大气中散射,蓝光散射最多,所以天空呈现蓝色。

方式二:使用 StrOutputParser  chain = prompt | llm | StrOutputParser()
返回类型: str
返回对象: '因为太阳光在大气中散射,蓝光散射最多,所以天空呈现蓝色。'

分步解析结果与管道结果一致: True

定义解析器时要写括号:

parser = StrOutputParser()

下面这种写法得到的是类本身,不是解析器实例:

parser = StrOutputParser

把类本身放入管道时,LangChain 无法按照预期执行解析。

5. 解析器 2:JsonOutputParser

当程序需要固定字段时,JSON 是非常常见的中间格式。

使用 JsonOutputParser 时,提示词至少要说明三件事:

  1. 只返回合法 JSON。
  2. 不要返回解释文字。
  3. JSON 中需要哪些字段。

代码文件为 02_json_output_parser.py:

# 这个文件演示如何让本地模型输出 JSON,并解析成 Python 字典。
# 运行前需要先在项目根目录启动 mlx_lm.server。

from langchain_core.output_parsers import JsonOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(
    base_url="http://127.0.0.1:18080/v1",
    api_key="not-needed",
    model="Qwen3-14B-AWQ-4bit-MLX",
    temperature=0,
    max_tokens=128,
)

# 提示词必须明确要求模型只返回合法 JSON,并写清字段名称。
prompt = ChatPromptTemplate.from_messages(
    [
        (
            "system",
            "只返回合法 JSON,不要输出解释或 Markdown 代码块。"
            "JSON 必须包含 city、condition、temperature_c,next 四个字段。 其中 next 表示继续向用户询问, 如是否想继续知道某个城市的天气?",
        ),
        ("human", "请整理下面的天气信息:{weather_text}"),
    ]
)

# JsonOutputParser 会把模型生成的 JSON 文本转换成 Python 字典。
chain = prompt | llm | JsonOutputParser()
result = chain.invoke({"weather_text": "北京今天晴天,气温 26 摄氏度。"})

print("返回类型:", type(result).__name__)
print("完整结果:", result)
print("--------------------------------")
print("城市:", result["city"])
print("天气:", result["condition"])
print("温度:", result["temperature_c"])
print("是否继续:", result["next"])

运行:

python langchain/p06_structured_output/02_json_output_parser.py

真实输出:

返回类型: dict
完整结果: {'city': '北京', 'condition': '晴天', 'temperature_c': 26, 'next': '是否想继续知道广州的天气?'}
--------------------------------
城市: 北京
天气: 晴天
温度: 26
是否继续: 是否想继续知道广州的天气?

模型生成的是 JSON 文本,JsonOutputParser 解析后得到 Python dict。因此可以使用:

result["city"]

直接读取字段。

如果模型在 JSON 前后增加解释、使用错误引号或生成不完整 JSON,解析就可能失败。即使解析器能够处理部分常见的 Markdown JSON 形式,也不应该依赖模型自由发挥,提示词中仍应明确要求只返回 JSON。

6. 解析器 3:PydanticOutputParser

字典可以保存固定字段,但下面的字典仍然存在问题:

{
    "city": "上海",
    "temperature_c": "很热"
}

程序希望 temperature_c 是整数,但字典本身不会主动检查。Pydantic 可以定义字段类型并在运行时校验数据。

代码文件为 03_pydantic_output_parser.py:

# 这个文件演示如何使用 PydanticOutputParser 返回经过校验的对象。
# 运行前需要先在项目根目录启动 mlx_lm.server。

from langchain_core.output_parsers import PydanticOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from pydantic import BaseModel, Field

llm = ChatOpenAI(
    base_url="http://127.0.0.1:18080/v1",
    api_key="not-needed",
    model="Qwen3-14B-AWQ-4bit-MLX",
    temperature=0,
    max_tokens=128,
)

# Pydantic 模型定义程序希望得到的字段和字段类型。
class Weather(BaseModel):
    """一条天气信息。"""

    city: str = Field(description="城市名称")
    condition: str = Field(description="天气情况")
    temperature_c: int = Field(description="摄氏温度,使用整数")
    next: str = Field(description="是否继续向用户询问, 如是否想继续知道另一个城市的天气")


# 解析器会根据 Weather 生成格式说明,并校验最终结果。
parser = PydanticOutputParser[Weather](pydantic_object=Weather)

prompt = ChatPromptTemplate.from_messages(
    [
        ("system", "根据格式要求整理信息。\n{format_instructions}"),
        ("human", "{weather_text}"),
    ]
).partial(format_instructions=parser.get_format_instructions())

chain = prompt | llm | parser
result = chain.invoke({"weather_text": "上海今天多云,气温 24 摄氏度。"})

print("返回类型:", type(result).__name__)
print("完整结果:", result)
print("城市:", result.city)
print("天气:", result.condition)
print("温度:", result.temperature_c)
print("是否继续:", result.next)

运行:

python langchain/p06_structured_output/03_pydantic_output_parser.py

真实输出:

返回类型: Weather
完整结果: city='上海' condition='多云' temperature_c=24 next='是否想继续知道北京的天气?'
城市: 上海
天气: 多云
温度: 24
是否继续: 是否想继续知道北京的天气?

6.1 Field 的作用

Field(description=…) 不只是给开发者看的注释。get_format_instructions() 会根据 Pydantic 模型生成 JSON Schema 格式说明,字段描述会帮助模型理解每个字段应该填写什么。

format_instructions = parser.get_format_instructions()

再通过 partial() 把格式说明预先填入提示词:

prompt = prompt.partial(format_instructions=format_instructions)

这样每次调用时只需要传入真正变化的天气文字。

6.2 PydanticOutputParser 与 JsonOutputParser 的区别

JsonOutputParser 返回字典:

{"city": "上海", "temperature_c": 24}

PydanticOutputParser 返回对象:

Weather(city="上海", temperature_c=24)

对象字段可以通过点号访问:

result.city
result.temperature_c

更重要的是,Pydantic 会检查必填字段和字段类型。

7. 解析器 4:列表解析器与 XML 解析器

不是所有结构化结果都需要 JSON 或 Pydantic。对于简单列表,可以使用专门的列表解析器。

代码文件为 04_list_and_xml_parsers.py:

# 这个文件演示列表解析器和 XML 解析器,不需要启动本地模型服务。

from langchain_core.output_parsers import (
    CommaSeparatedListOutputParser, # 逗号解析器
    MarkdownListOutputParser,       # Markdown 解析器
    NumberedListOutputParser,       # 编号解析器
    XMLOutputParser,                # XML 解析器
)

# 逗号分隔列表会被转换成 Python list。
comma_parser = CommaSeparatedListOutputParser()
comma_result = comma_parser.invoke("苹果, 香蕉, 橙子")
print("逗号列表:", comma_result) # 逗号列表: ['苹果', '香蕉', '橙子']

# Markdown 无序列表会去掉每一行开头的短横线。
markdown_parser = MarkdownListOutputParser()
markdown_result = markdown_parser.invoke("- 苹果\n- 香蕉\n- 橙子")
print("Markdown 列表:", markdown_result) # Markdown 列表: ['苹果', '香蕉', '橙子']

# 编号列表会去掉数字编号,只保留每一项的内容。
numbered_parser = NumberedListOutputParser()
numbered_result = numbered_parser.invoke("1. 苹果\n2. 香蕉\n3. 橙子")
print("编号列表:", numbered_result) #编号列表: ['苹果', '香蕉', '橙子']

# XMLOutputParser 需要 defusedxml,用于安全地解析 XML 文本。
xml_parser = XMLOutputParser()
xml_text = "<weather><city>北京</city><condition>晴天</condition></weather>"
xml_result = xml_parser.invoke(xml_text)
print("XML 结果:", xml_result) # XML 结果: {'weather': [{'city': '北京'}, {'condition': '晴天'}]}

运行:

python langchain/p06_structured_output/04_list_and_xml_parsers.py

真实输出:

逗号列表: ['苹果', '香蕉', '橙子']
Markdown 列表: ['苹果', '香蕉', '橙子']
编号列表: ['苹果', '香蕉', '橙子']
XML 结果: {'weather': [{'city': '北京'}, {'condition': '晴天'}]}

三个列表解析器都返回 list[str],区别只在于输入文本的格式。

XMLOutputParser 需要额外安装 defusedxml:

python -m pip install defusedxml==0.7.1

如果没有安装,会看到类似错误:

ImportError: defusedxml is not installed.

8. 把 Schema 绑定到模型with_structured_output

前面的解析器都位于模型之后:

chain = prompt | llm | parser

with_structured_output() 则直接给模型绑定一个 Schema:

structured_llm = llm.with_structured_output(WeatherReport)

调用 structured_llm.invoke() 时,LangChain 会根据模型接口支持的方式传递 Schema,再把返回值转换成指定结构。

代码文件为 05_with_structured_output_pydantic.py:

# 这个文件演示使用 with_structured_output 和 Pydantic 获取结构化结果。
# 运行前需要先在项目根目录启动 mlx_lm.server。

from langchain_openai import ChatOpenAI
from pydantic import BaseModel, Field

llm = ChatOpenAI(
    base_url="http://127.0.0.1:18080/v1",
    api_key="not-needed",
    model="Qwen3-14B-AWQ-4bit-MLX",
    temperature=0,
    max_tokens=256,
)

# 嵌套模型用于描述温度数值和单位。
class Temperature(BaseModel):
    """温度信息。"""

    value: int = Field(description="温度数值")
    unit: str = Field(description="温度单位")


# 主模型中包含普通字段、嵌套对象、列表和可选字段。
class WeatherReport(BaseModel):
    """从一段文字中提取天气信息。"""

    city: str = Field(description="城市名称")
    condition: str = Field(description="天气情况")
    temperature: Temperature = Field(description="温度信息")
    suggestions: list[str] = Field(description="出行建议列表")
    warning: str | None = Field(description="预警信息,没有预警时填写 null")
    next: str = Field(description="继续向用户询问的提示, 如是否想继续知道上海的天气? 但城市需要是一个指定城市但不能是上海")

# 本地 Qwen3 可以通过 function_calling 返回符合 Pydantic 的参数。
structured_llm = llm.with_structured_output(
    WeatherReport,
    method="function_calling",
)

result = structured_llm.invoke(
    "广州今天小雨,气温 23 摄氏度。出门建议带伞并穿防滑鞋,没有天气预警。"
)

print("返回类型:", type(result).__name__)
print("完整结果:", result)
print("城市:", result.city)
print("温度:", result.temperature.value, result.temperature.unit)
print("出行建议:", result.suggestions)
print("预警信息:", result.warning)
print("是否继续:", result.next)

运行:

python langchain/p06_structured_output/05_with_structured_output_pydantic.py

真实输出:

返回类型: WeatherReport
完整结果: city='广州' condition='小雨' temperature=Temperature(value=23, unit='摄氏度') suggestions=['带伞', '穿防滑鞋'] warning=None next='是否想继续知道深圳的天气?'
城市: 广州
温度: 23 摄氏度
出行建议: ['带伞', '穿防滑鞋']
预警信息: None
是否继续: 是否想继续知道深圳的天气?

这个例子同时包含了四种常见字段:

  • city , condition,next 是普通字符串。
  • temperature 是嵌套的 Temperature 对象。
  • suggestions 是字符串列表。
  • warning 可以是字符串,也可以是 None。

Schema 类和字段都应该写清楚说明。对于本地模型,类说明可以帮助模型理解这次结构化输出的目的,字段说明可以减少字段值填错的情况。

9. 四种 Schema 定义方式

LangChain 模型接口中常见的 Schema 定义方式有四种:

  1. Pydantic。
  2. TypedDict。
  3. JSON Schema。
  4. dataclass。

它们都可以传给 with_structured_output(),但返回类型和校验能力不同。

代码文件为 06_structured_output_schema_types.py:

# 这个文件比较 Pydantic、TypedDict、JSON Schema 和 dataclass 四种 Schema。
# 运行前需要先在项目根目录启动 mlx_lm.server。

from dataclasses import dataclass
from typing import Annotated

from langchain_openai import ChatOpenAI
from pydantic import BaseModel, Field
from typing_extensions import TypedDict

llm = ChatOpenAI(
    base_url="http://127.0.0.1:18080/v1",
    api_key="not-needed",
    model="Qwen3-14B-AWQ-4bit-MLX",
    temperature=0,
    max_tokens=128,
)

# json_mode 要求提示词明确说明输出 JSON 以及需要的字段。
question = (
    "请只输出 JSON,从‘深圳今天晴天’中提取 city 和 condition 两个字段,"
    "不要输出解释或 Markdown 代码块。"
)

print("=" * 60)
print("方式一:Pydantic BaseModel")
print("=" * 60)
pydantic_llm = llm.with_structured_output(WeatherModel, method="json_mode")
pydantic_result = pydantic_llm.invoke(question)
print("返回类型:", type(pydantic_result).__name__)
print("返回结果:", pydantic_result)
print("点号访问:", pydantic_result.city, pydantic_result.condition)
print()

输出:

============================================================
方式一:Pydantic BaseModel
============================================================
返回类型: WeatherModel
返回结果: city='深圳' condition='晴天'
点号访问: 深圳 晴天
# ---------------------------------------------------------------------------
# 方式三:JSON Schema 字典
# - 不依赖 Pydantic/TypedDict,手写标准 JSON Schema,跨语言、跨工具最通用
# - 字段写在 properties / required 里,适合动态生成或从 OpenAPI 复制
# - with_structured_output 返回 dict,用 result["city"] 访问
# ---------------------------------------------------------------------------
weather_json_schema = {
    "title": "WeatherJson",
    "description": "天气信息",
    "type": "object",
    "properties": {
        "city": {"type": "string", "description": "城市名称"},
        "condition": {"type": "string", "description": "天气情况"},
    },
    "required": ["city", "condition"],
}

输出:

============================================================
方式二:TypedDict
============================================================
返回类型: dict
返回结果: {'city': '深圳', 'condition': '晴天'}
下标访问: 深圳 晴天
print("=" * 60)
print("方式三:JSON Schema")
print("=" * 60)
json_schema_llm = llm.with_structured_output(weather_json_schema, method="json_mode")
json_schema_result = json_schema_llm.invoke(question)
print("返回类型:", type(json_schema_result).__name__)
print("返回结果:", json_schema_result)
print("下标访问:", json_schema_result["city"], json_schema_result["condition"])
print()

输出:

============================================================
方式三:JSON Schema
============================================================
返回类型: dict
返回结果: {'city': '深圳', 'condition': '晴天'}
下标访问: 深圳 晴天
# ---------------------------------------------------------------------------
# 方式四:dataclass
# - 标准库 dataclass,得到带属性的对象;这里用 Field 只是为了补充字段描述
# - 校验能力弱于 Pydantic,但比裸 dict 更易 result.city 点号访问
# - with_structured_output 返回 WeatherData 实例
# ---------------------------------------------------------------------------
@dataclass
class WeatherData:
    """dataclass 格式的天气信息。"""

    city: str = Field(description="城市名称")
    condition: str = Field(description="天气情况")


print("=" * 60)
print("方式四:dataclass")
print("=" * 60)
dataclass_llm = llm.with_structured_output(WeatherData, method="json_mode")
dataclass_result = dataclass_llm.invoke(question)
print("返回类型:", type(dataclass_result).__name__)
print("返回结果:", dataclass_result)
print("点号访问:", dataclass_result.city, dataclass_result.condition)

真实输出:

============================================================
方式四:dataclass
============================================================
返回类型: dict
返回结果: {'city': '深圳', 'condition': '晴天'}
下标访问: 深圳 晴天
手动转成 dataclass 后点号访问: 深圳 晴天

运行

python langchain/p06_structured_output/06_structured_output_schema_types.py

从实际结果可以看到:

  • Pydantic Schema 返回 WeatherModel 对象,并执行 Pydantic 运行时校验。
  • TypedDict 返回 dict。
  • JSON Schema 返回 dict。
  • dataclass 在当前调用方式下也返回 dict,不是 WeatherData 实例。

如果需要明确的 Python 对象、字段访问和运行时校验,Pydantic 更适合。如果只需要轻量字典,TypedDict 或 JSON Schema 更直接。

10. 三种结构化输出方法

ChatOpenAI.with_structured_output() 支持三种 method:

method="json_schema"
method="json_mode"
method="function_calling"

三种方法的目标相同,但 Schema 进入模型请求的方式不同,服务端需要支持的协议也不同:

三种结构化输出方法的协议路径与本地兼容结果

可以先用一张表理解它们的核心区别:

method Schema 如何进入请求 模型原始结果通常位于 主要约束
json_schema 作为原生 Structured Outputs 的 JSON Schema 传给服务端 raw.content 服务端按照 Schema 生成并校验结果
json_mode 只要求服务端返回 JSON 对象 raw.content 服务端保证 JSON 格式,字段要求主要依靠提示词
function_calling Schema 被转换成工具定义 raw.tool_calls 模型按照工具参数格式填写字段

下面使用同一个 Pydantic 模型比较三种方法。公共代码如下:

from langchain_openai import ChatOpenAI
from pydantic import BaseModel, Field


class Weather(BaseModel):
    """从一段文字中提取天气信息。"""

    city: str = Field(description="城市名称")
    condition: str = Field(description="天气情况")
    temperature_c: int = Field(description="摄氏温度,使用整数")


llm = ChatOpenAI(
    base_url="http://127.0.0.1:18080/v1",
    api_key="not-needed",
    model="Qwen3-14B-AWQ-4bit-MLX",
    temperature=0,
    max_tokens=128,
)

weather_text = "苏州今天多云,气温 25 摄氏度。"

10.1 json_schema

json_schema 使用模型提供商的原生 Structured Outputs API。LangChain 会把 Weather 转换成 JSON Schema,并通过请求的 response_format 发送给服务端。服务端不仅要返回合法 JSON,还要按照 Schema 生成字段、类型和必填项。

json_schema_llm = llm.with_structured_output(
    Weather,
    method="json_schema",
    strict=True,
)

result = json_schema_llm.invoke(
    f"从下面文字中提取天气信息:{weather_text}"
)

print(type(result).__name__)
print(result)
print(result.city)

这里的几个参数分别表示:

  • Weather 决定最终需要的字段和类型。成功时返回经过校验的 Weather 对象。
  • method=”json_schema” 明确使用服务端的原生结构化输出协议。
  • strict=True 要求服务端严格遵循 Schema。使用严格模式时,应先确认模型提供商支持该参数以及所使用的 Schema 特性。

json_schema 的优点是字段约束由服务端执行,不必在用户提示词中再次罗列 city、condition 和 temperature_c。它适合明确支持原生 Structured Outputs 的模型服务。

不过,接口提供 /v1/chat/completions 路径,并不代表它实现了原生 Structured Outputs 的全部行为。当前 MLX-LM OpenAI 兼容服务不能正确完成该模式:实测时服务端返回了普通天气说明,客户端尝试把它解析成 Weather 时产生校验错误:

ValidationError: Invalid JSON: expected value at line 1 column 1

为了让三种方法的对照脚本在这里失败后还能继续运行,可以捕获 Pydantic 的 ValidationError:

from pydantic import ValidationError

try:
    result = json_schema_llm.invoke(
        f"从下面文字中提取天气信息:{weather_text}"
    )
    print(result)
except ValidationError as error:
    print("调用结果:失败")
    print("错误类型:", type(error).__name__)
    print("错误原因:", error.errors()[0]["msg"])

这次失败发生在客户端处理服务端响应的阶段。即使设置 include_raw=True,也可能在 LangChain 组装包含 raw、parsed 和 parsing_error 的返回值之前抛出异常,所以不能把 include_raw 当成所有服务端兼容问题的异常捕获器。

10.2 json_mode

json_mode 会在请求中开启 JSON 对象模式,要求模型返回可解析的 JSON。与 json_schema 不同,它不要求服务端按照完整 Schema 约束每一个字段,因此提示词必须同时说明:

  • 只能返回 JSON。
  • 必须包含哪些字段。
  • 不要添加解释或 Markdown 代码块。
json_mode_llm = llm.with_structured_output(
    Weather,
    method="json_mode",
    include_raw=True,
)

result = json_mode_llm.invoke(
    "只返回合法 JSON,不要输出解释或 Markdown 代码块。"
    "JSON 必须包含 city、condition、temperature_c 三个字段,"
    f"从下面文字中提取天气信息:{weather_text}"
)

print("raw.content =", repr(result["raw"].content))
print("parsed =", result["parsed"])
print("parsing_error =", result["parsing_error"])

实际输出为:

raw.content = '{\n  "city": "苏州",\n  "condition": "多云",\n  "temperature_c": 25\n}'
parsed = city='苏州' condition='多云' temperature_c=25
parsing_error = None

这个过程包含两层处理:

  1. 模型服务保证 raw.content 是 JSON 对象。
  2. LangChain 再用 Weather 解析该 JSON,得到 Pydantic 对象。

因此,JSON 语法正确不等于字段一定正确。如果模型遗漏 temperature_c,或者返回的值无法转换成整数,第二层 Pydantic 校验仍然可能失败。

json_mode 的优势是协议简单,许多兼容服务都能支持;缺点是字段约束主要依赖提示词。Schema 越复杂,提示词越容易遗漏约束。本文第 9 章比较四种 Schema 时使用的就是这种方法。

10.3 function_calling

function_calling 会把 Weather 转换成一个工具定义,让模型不再把天气数据写进普通回答,而是把它填写成一次工具调用的参数。LangChain 从 tool_calls 中读取参数,再用 Weather 完成校验和对象转换。

这里并没有执行查询天气之类的真实函数。工具定义只是结构化数据的载体。

function_llm = llm.with_structured_output(
    Weather,
    method="function_calling",
    include_raw=True,
)

result = function_llm.invoke(
    f"从下面文字中提取天气信息:{weather_text}"
)

print("raw.content =", repr(result["raw"].content))
print("raw.tool_calls =", result["raw"].tool_calls)
print("parsed =", result["parsed"])
print("parsing_error =", result["parsing_error"])

实际输出为:

raw.content = ''
raw.tool_calls = [{'name': 'Weather', 'args': {'city': '苏州', 'condition': '多云', 'temperature_c': 25}, 'id': None, 'type': 'tool_call'}]
parsed = city='苏州' condition='多云' temperature_c=25
parsing_error = None

可以看到,raw.content 是空字符串,真正的数据在 raw.tool_calls[0][“args”] 中。平时直接使用 result[“parsed”] 即可;查看 raw.tool_calls 主要用于理解协议或排查问题。

function_calling 适合已经支持工具调用、但不支持原生 json_schema 的模型服务。本文第 8 章的 Pydantic 示例使用的也是这种方法,当前 Qwen3 与 MLX-LM Server 可以成功返回该格式。

10.4 应该选择哪一种方法

三种方法没有一种能够适用于所有模型服务,可以按下面的顺序选择:

  1. 模型提供商明确支持原生 Structured Outputs 时,优先使用 json_schema,它对字段和类型的约束最强。
  2. 服务只支持 JSON 对象模式,而且 Schema 较简单时,使用 json_mode,并在提示词中完整写出字段要求。
  3. 模型的工具调用能力较稳定,但原生 Structured Outputs 不可用时,使用 function_calling。

在本地或第三方 OpenAI 兼容服务中,最好不要省略 method 并依赖默认值,因为不同 LangChain 版本的默认选择和不同服务端的协议实现可能并不一致。可以先使用一个只有两三个字段的 Schema 做最小测试,再把验证成功的方法用于正式业务。

本文三种方法的完整对照代码位于 09_structured_output_methods.py,运行:

python langchain/p06_structured_output/09_structured_output_methods.py

11. 同时查看原始消息和解析结果include_raw

默认情况下,with_structured_output() 只返回解析后的对象。遇到解析失败时,我们可能还需要查看模型原始返回了什么。

设置:

include_raw=True

以后,结果中会包含三个字段:

  • raw:原始 AIMessage。
  • parsed:解析后的对象。
  • parsing_error:解析异常,没有异常时为 None。

代码文件为 07_include_raw_and_validation.py:

# 这个文件演示 include_raw 的返回内容,以及 Pydantic 解析失败时的异常。
# 运行前需要先在项目根目录启动 mlx_lm.server。

from langchain_core.exceptions import OutputParserException
from langchain_core.output_parsers import PydanticOutputParser
from langchain_openai import ChatOpenAI
from pydantic import BaseModel, Field


class Weather(BaseModel):
    """从文字中提取天气信息。"""

    city: str = Field(description="城市名称")
    temperature_c: int = Field(description="摄氏温度,使用整数")


llm = ChatOpenAI(
    base_url="http://127.0.0.1:18080/v1",
    api_key="not-needed",
    model="Qwen3-14B-AWQ-4bit-MLX",
    temperature=0,
    max_tokens=128,
)

# include_raw=True 会同时保留原始 AIMessage、解析结果和解析异常。
structured_llm = llm.with_structured_output(
    Weather,
    method="function_calling",
    include_raw=True,
)
result = structured_llm.invoke("从‘杭州今天 28 摄氏度’中提取天气信息。")

print("最外层返回类型:", type(result).__name__)
print("包含的字段:", list(result.keys()))
print("raw 类型:", type(result["raw"]).__name__)

tool_call = result["raw"].tool_calls[0]
print("raw 工具名称:", tool_call["name"])
print("raw 工具参数:", tool_call["args"])
print("parsed:", result["parsed"])
print("parsing_error:", result["parsing_error"])

# 下面故意把温度写成无法转换为整数的文字,观察 Pydantic 校验错误。
parser = PydanticOutputParser(pydantic_object=Weather)
invalid_json = '{"city": "杭州", "temperature_c": "很热"}'

try:
    parser.invoke(invalid_json)
except OutputParserException as error:
    print("\n错误类型:", type(error).__name__)
    print("错误原因:temperature_c 不能转换为整数")

运行:

python langchain/p06_structured_output/07_include_raw_and_validation.py

真实输出:

最外层返回类型: dict
包含的字段: ['raw', 'parsed', 'parsing_error']
raw 类型: AIMessage
raw 工具名称: Weather
raw 工具参数: {'city': '杭州', 'temperature_c': 28}
parsed: city='杭州' temperature_c=28
parsing_error: None

错误类型: OutputParserException
错误原因: Failed to parse Weather from completion {"city": "\u676d\u5dde", "temperature_c": "\u5f88\u70ed"}. Got: 1 validation error for Weather
temperature_c
  Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='很热', input_type=str]
    For further information visit https://errors.pydantic.dev/2.11/v/int_parsing
For troubleshooting, visit: https://docs.langchain.com/oss/python/langchain/errors/OUTPUT_PARSING_FAILURE

include_raw=True 很适合调试。解析成功时可以同时看到原始工具参数和 Pydantic 对象;解析失败时,也能通过 raw 检查模型到底返回了什么。

第二部分没有调用模型,而是故意把字符串 “很热” 交给整数类型字段。PydanticOutputParser 捕获 Pydantic 校验错误后,将它包装成 LangChain 的 OutputParserException。

12. 工具调用解析器与bind_tools

结构化输出还可以复用工具调用格式。这里暂时不创建和执行真正的工具,只观察模型如何按照 Weather Schema 生成参数。

模型把结构化参数写入 AIMessage.tool_calls 后,三个解析器会从同一条消息中提取不同粒度的结果:

同一条工具调用消息经过三种解析器后的返回结果

代码文件为 08_bind_tools_and_tool_parsers.py:

# 这个文件演示 bind_tools 返回工具调用参数,再用三种解析器读取参数。
# 本例只解析工具调用格式,不会执行任何工具。

from langchain_core.output_parsers import (
    JsonOutputKeyToolsParser,
    JsonOutputToolsParser,
    PydanticToolsParser,
)
from langchain_openai import ChatOpenAI
from pydantic import BaseModel, Field

llm = ChatOpenAI(
    base_url="http://127.0.0.1:18080/v1",
    api_key="not-needed",
    model="Qwen3-14B-AWQ-4bit-MLX",
    temperature=0,
    max_tokens=128,
)

class Weather(BaseModel):
    """从文字中提取天气信息。"""

    city: str = Field(description="城市名称")
    condition: str = Field(description="天气情况")

# tool_choice 指定本次必须使用 Weather 结构生成参数。
llm_with_tool = llm.bind_tools([Weather], tool_choice="Weather")
message = llm_with_tool.invoke("从‘成都今天阴天’中提取天气信息。")

print("AIMessage.content:", repr(message.content))
tool_call = message.tool_calls[0]
print("工具名称:", tool_call["name"])
print("工具参数:", tool_call["args"])

print("--------------------------------")
# 返回工具名称和 args 参数。
json_tools_parser = JsonOutputToolsParser(first_tool_only=True)
json_tools_result = json_tools_parser.invoke(message)
print("\nJsonOutputToolsParser:", json_tools_result)
print("--------------------------------")
# 只返回名称为 Weather 的 args 参数。
json_key_parser = JsonOutputKeyToolsParser(
    key_name="Weather",
    first_tool_only=True,
)
json_key_result = json_key_parser.invoke(message)
print("JsonOutputKeyToolsParser:", json_key_result)
print("--------------------------------")
# 把 args 参数校验并转换成 Weather 对象。
pydantic_tools_parser = PydanticToolsParser(
    tools=[Weather],
    first_tool_only=True,
)
pydantic_tools_result = pydantic_tools_parser.invoke(message)
print("PydanticToolsParser:", pydantic_tools_result)
print("--------------------------------")

运行:

python langchain/p06_structured_output/08_bind_tools_and_tool_parsers.py

真实输出:


AIMessage.content: ''
工具名称: Weather
工具参数: {'city': '成都', 'condition': '阴天'}
--------------------------------

JsonOutputToolsParser: {'args': {'city': '成都', 'condition': '阴天'}, 'type': 'Weather'}
--------------------------------
JsonOutputKeyToolsParser: {'city': '成都', 'condition': '阴天'}
--------------------------------
PydanticToolsParser: city='成都' condition='阴天'
--------------------------------

这次模型没有把结果放在 AIMessage.content 中,而是放在 AIMessage.tool_calls 中。

三个解析器读取的是同一条消息,但返回结果不同:

  • JsonOutputToolsParser 保留工具名称和 args。
  • JsonOutputKeyToolsParser 只返回指定工具的参数字典。
  • PydanticToolsParser 把参数转换成 Pydantic 对象。

这里完成的只是“生成并解析工具参数”。真正执行工具、把工具结果交回模型以及 Agent 调度属于后面的内容,本文不展开。

13. 常见问题

13.1 本地请求返回 502

/v1/models 可以访问,只能证明 HTTP 服务已经启动并能返回模型列表,不能证明聊天推理一定成功。如果 /v1/chat/completions 返回 502,应该先查看启动 MLX-LM Server 的终端日志,检查模型加载、推理异常、启动虚拟环境和模型路径。

然后使用 curl 直接发送一条最小聊天请求:

curl http://127.0.0.1:18080/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -d '{
    "model": "Qwen3-14B-AWQ-4bit-MLX",
    "messages": [{"role": "user", "content": "你好"}],
    "max_tokens": 16
  }'

如果这条请求也失败,问题在模型服务端;如果它成功而 Python 请求失败,再检查客户端参数和请求内容。

13.2 默认 with_structured_output 解析失败

with_structured_output() 通过 json_schema 执行时,MLX-LM OpenAI 兼容服务没有完整实现该协议。应根据实际用途明确指定:

method="json_mode"

或者:

method="function_calling"

不能因为 /v1/chat/completions 可以调用,就假设所有 OpenAI 扩展能力都可用。

13.3 JSON 解析失败

优先检查模型原始输出,并在提示词中明确:

只返回合法 JSON,不要输出解释或 Markdown 代码块。

同时写清楚字段名称和字段含义。

13.4 Pydantic 校验失败

常见原因包括:

  • 必填字段缺失。
  • 字段名称与 Schema 不一致。
  • 字段类型无法转换。
  • 模型返回了 Schema 之外的复杂结构。

可以先设置 include_raw=True 查看 raw 和 parsing_error。

13.5 XML 解析器提示缺少依赖

安装:

python -m pip install defusedxml==0.7.1

再重新运行 XML 示例。

14. 现代输出解析器

下面的清单只统计 langchain_core.output_parsers 中的现代解析器,不混入 langchain_classic 兼容层。

顶层模块的 all 一共导出 17 个名称。其中 SimpleJsonOutputParser 是 JsonOutputParser 的别名,不是另一套解析逻辑。

分类 解析器 作用
文本 StrOutputParser 从模型消息中提取普通字符串
JSON JsonOutputParser 把 JSON 文本解析成字典
JSON SimpleJsonOutputParser JsonOutputParser 的别名
Pydantic PydanticOutputParser 把结果校验并转换成 Pydantic 对象
XML XMLOutputParser 把 XML 文本解析成嵌套字典
列表 CommaSeparatedListOutputParser 解析逗号分隔列表
列表 MarkdownListOutputParser 解析 Markdown 无序列表
列表 NumberedListOutputParser 解析编号列表
列表抽象类 ListOutputParser 列表解析器的抽象基础类
Tools JsonOutputToolsParser 解析 OpenAI tools 格式并保留工具信息
Tools JsonOutputKeyToolsParser 只返回指定工具的参数
Tools PydanticToolsParser 把工具参数转换成 Pydantic 对象
抽象基类 BaseLLMOutputParser 模型输出解析器的底层抽象类
抽象基类 BaseGenerationOutputParser 以 Generation 为输入的抽象类
抽象基类 BaseOutputParser 常规输出解析器的基础抽象类
流式抽象类 BaseTransformOutputParser 支持转换流式输入的抽象类
流式抽象类 BaseCumulativeTransformOutputParser 支持累计解析流式结果的抽象类

langchain_core.output_parsers.openai_functions 子模块还提供 5 个旧 OpenAI function-call 格式解析器:

解析器 作用
OutputFunctionsParser 读取旧 function_call 格式的函数调用结果
JsonOutputFunctionsParser 把函数参数解析成 JSON 对象
JsonKeyOutputFunctionsParser 从函数参数 JSON 中提取指定字段
PydanticOutputFunctionsParser 把函数参数转换成 Pydantic 对象
PydanticAttrOutputFunctionsParser 从 Pydantic 解析结果中提取指定属性

这些类处理的是较早的 function_call 消息格式。当前示例使用的是 tools / tool_calls 格式,因此对应使用 JsonOutputToolsParser、JsonOutputKeyToolsParser 和 PydanticToolsParser。

抽象基类主要用于自行开发解析器,入门时不需要直接实例化。常规使用时,先掌握 StrOutputParser、JsonOutputParser、PydanticOutputParser 和三个 Tools 解析器即可。

15. 小结

输出解析器解决的是“模型已经返回内容以后,程序怎样读取”的问题;with_structured_output() 解决的是“调用模型时,怎样要求模型按照 Schema 返回”的问题。

在本文的本地 Qwen3 环境中:

  • 普通文本、JSON、Pydantic、列表和 XML 解析器都可以正常使用。
  • with_structured_output(method=”json_mode”) 可以返回 JSON 结构。
  • with_structured_output(method=”function_calling”) 可以返回 Pydantic 对象。
  • 默认 json_schema 与当前 MLX-LM Server 不兼容,需要明确切换方法。
  • bind_tools() 可以得到标准 tool_calls,并由三个 Tools 解析器转换成不同结果。

最终要点汇总如下:

功能 核心写法 返回类型 适合场景
提取文本 StrOutputParser() str 只需要模型回答文字
解析 JSON JsonOutputParser() dict 需要固定字段,但不需要对象校验
解析 Pydantic PydanticOutputParser(…) Pydantic 对象 需要字段类型校验
解析简单列表 三种 List Parser list[str] 模型返回项目列表
解析 XML XMLOutputParser() dict 模型或接口返回 XML
模型结构化输出 with_structured_output(…) 对象或 dict 调用模型时直接绑定 Schema
查看原始结果 include_raw=True 包含 raw、parsed、parsing_error 的字典 调试结构化输出
解析工具参数 三种 Tools Parser 字典或 Pydantic 对象 读取 AIMessage.tool_calls

文章作者: hnbian
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 hnbian !
评论
  目录