代码仓库ChainReaction

让模型输出一段文字,再用正则从里面抠字段,这活干一次就够了。字段多一个、模型换个说法、多写一句解释,正则就得重写。结构化输出解决的就是这件事:给模型一份 schema,等它跑完,你从 state 里取一个 Python 对象,sr.email 直接可用。

LangChain Agents 那篇里提过 ToolStrategy 和 ProviderStrategy,只有几句对比。这一篇换个角度,只看三件事:策略怎么选、失败时长什么样、拿不到结果时从哪儿查。代码在 DeepSeek、Ollama 云端的 OpenAI 兼容端点和原生端点上真实跑过,最后补上 Google Gemini,原生结构化输出总算跑出了正向结果。环境是 langchain 1.4.3、langchain-openai 1.6.7、langchain-ollama 1.1.0、langchain-google-genai 4.4.0、pydantic 2.13.5,脚本在仓库的 StructuredOutput/ 目录下。

一、structured_response 从哪冒出来

最小可用的一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import os
from typing import Literal

from langchain.agents import create_agent
from langchain.agents.structured_output import ToolStrategy
from langchain_openai import ChatOpenAI
from pydantic import BaseModel, Field

model = ChatOpenAI(
api_key=os.getenv('DEEPSEEK_API_KEY'),
base_url="https://api.deepseek.com/v1",
model="deepseek-chat",
temperature=0.1,
max_tokens=1000,
)


class Invoice(BaseModel):
"""一张发票的关键信息。"""

invoice_no: str = Field(description="发票号码")
vendor: str = Field(description="销售方名称")
total: float = Field(description="价税合计金额")
currency: Literal["CNY", "USD", "EUR"] = Field(description="币种")
items: list[str] = Field(description="商品名称列表")
paid: bool | None = Field(default=None, description="是否已付款;原文没提就填 null")


TEXT = (
"发票号 044031900111,销售方是西安链式科技,买了 2 台显示器、1 个键盘,"
"价税合计 4680.00 元,走的人民币。"
)

agent = create_agent(model=model, tools=[], response_format=ToolStrategy(Invoice))
result = agent.invoke(
{"messages": [{"role": "user", "content": f"从这段文字里抽发票信息:{TEXT}"}]}
)
sr = result["structured_response"]
print(type(sr), repr(sr))

真实输出(structured_extract.py):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
=== 2. ToolStrategy + Pydantic ===
type : <class '__main__.Invoice'>
repr : Invoice(invoice_no='044031900111', vendor='西安链式科技', total=4680.0, currency='CNY', items=['显示器', '键盘'], paid=None)
字段遍历:
invoice_no = '044031900111' (str)
vendor = '西安链式科技' (str)
total = 4680.0 (float)
currency = 'CNY' (str)
items = ['显示器', '键盘'] (list)
paid = None (NoneType)

=== 3. 消息序列 ===
HumanMessage: 从这段文字里抽发票信息:发票号 044031900111,...
AIMessage:
ToolMessage: Returning structured response: invoice_no='044031900111' vendor='西安链式科技' total=4680.0 currency='CNY' items=['显示器', '键盘'] paid=None

有三个细节值得盯一眼。structured_response 是 Invoice 实例,不是 dict,也不是 JSON 字符串。AIMessage 的 content 是空字符串,这一轮模型只发了一个工具调用,一句自然语言都没写。末尾多出来的 ToolMessage 内容是 Returning structured response: …,这是 LangChain 自己补的,用来让消息历史在下一轮仍然合法。

图 1 是这一轮里数据怎么走的。

有人会问,直接让模型输出 JSON,再 json.loads,也能用。能,但那是另一件事。自己拼 prompt 的话,模型多写一句”好的,结果如下”你就得先剥壳,字段名写成 camelCase 还要重命名,缺字段只能自己补默认值。response_format 把这些收进框架:schema 由代码生成,校验交给 Pydantic,出错还有一次重来的机会。省下的是每次模型换个说法你都要改一遍的那部分解析代码。

create_agent 的 tools=[] 可以省略,传 None 会被当成空列表。加了 response_format 之后,哪怕一个真实工具都没有,模型那一侧也不再是自由回答模式。

二、schema 四种写法,返回类型不一样

官方文档列了四种 schema:Pydantic 模型、dataclass、TypedDict、JSON Schema 字典。我把同一段文本分别喂给四种写法,只换 schema 类型,其余不动。

写法 传什么 实测返回类型 约束能力
Pydantic BaseModel 类本身 模型实例,属性访问 Field 约束、validator、类型转换
dataclass 类本身 dataclass 实例,属性访问 只有类型注解,靠 TypeAdapter 校验
TypedDict 类本身 dict,下标访问 只有类型注解
JSON Schema dict 字典 dict,下标访问 完全不校验

真实输出(structured_schema_kinds.py):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[Pydantic BaseModel]
type: <class '__main__.ContactPydantic'>
repr: ContactPydantic(name='张三', email='zhangsan@example.com', phone='13800001111')
sr.email -> zhangsan@example.com (属性访问)

[dataclass]
type: <class '__main__.ContactDataclass'>
repr: ContactDataclass(name='张三', email='zhangsan@example.com', phone='13800001111')
sr.email -> zhangsan@example.com (属性访问)

[TypedDict]
type: <class 'dict'>
repr: {'name': '张三', 'email': 'zhangsan@example.com', 'phone': '13800001111'}
sr['email'] -> zhangsan@example.com (下标访问)

[JSON Schema dict]
type: <class 'dict'>
repr: {'name': '张三', 'email': 'zhangsan@example.com', 'phone': '13800001111'}
sr['email'] -> zhangsan@example.com (下标访问)

dataclass 这一行值得单独说。文档写的是 “Dataclasses: Python dataclasses with type annotations. Returns dict”,实测返回的是 ContactDataclass 实例。我第一版脚本就是按文档写的 sr[“email”],直接 TypeError: ‘ContactDataclass’ object is not subscriptable。原因在解析那一步:Pydantic 和 dataclass 都走 TypeAdapter(schema).validate_python(args),dataclass 会被实例化,TypedDict 会还原成 dict。写代码前先 print(type(sr)),比背文档靠谱。

JSON Schema 字典更需要注意。ToolStrategy 的实现里有一段警告,说原始 JSON schema 字典的参数会原样返回,不做校验,handle_errors 因此形同虚设。我直接用绑定对象验证了这件事:

1
2
3
4
5
6
7
8
9
from langchain.agents.structured_output import OutputToolBinding, ToolStrategy

BAD_ARGS = {"order_id": "A1", "status": "processing"} # status 只能是 paid / unpaid

def binding_of(schema):
return OutputToolBinding.from_schema_spec(ToolStrategy(schema).schema_specs[0])

binding_of(OrderStatus).parse(BAD_ARGS) # 抛 ValueError
binding_of(JSON_SCHEMA).parse(BAD_ARGS) # 原样返回
1
2
3
4
5
6
7
8
9
10
11
Pydantic 模型的 schema_kind: pydantic
JSON Schema 的 schema_kind : json_schema

--- Pydantic ---
parse 抛出: ValueError
消息: Failed to parse data to OrderStatus: 1 validation error for OrderStatus
status
Input should be 'paid' or 'unpaid' [type=literal_error, input_value='processing', input_type=str]

--- JSON Schema dict ---
parse -> {'order_id': 'A1', 'status': 'processing'} ← 原样返回,没有校验

选择上的建议很直接。默认用 Pydantic 模型,理由很实际,只有它能写 Field(ge=1, le=5) 和 validator,校验失败时才有东西回灌给模型。dataclass 和 TypedDict 适合内部轻量场景,注意返回类型。JSON Schema 字典留给”schema 来自配置文件、需要跨语言共享”的场景,并且自己补一层校验。另外文档要求 JSON Schema 字典顶层必须有 title 和 description,漏了名字会退化成随机串。

三、response_format 的三种传法

response_format 收四种值,行为差别比名字看起来大。

传法 谁决定策略 模型侧看到什么 出错时的表现
ToolStrategy(Schema) 你 多一个同名工具 校验失败可重试
ProviderStrategy(Schema) 你 response_format 里的 json_schema 直接抛异常,不回退
裸 schema 类型 LangChain 取决于模型能力 自动回退到工具调用
None 无 无 结果里没有这个 key

裸 schema 的自动判断是这样的:create_agent 先把裸类型包成内部的 AutoStrategy,并提前按 ToolStrategy 把工具建好;真正绑模型之前再查一次模型能力,支持就换成 ProviderStrategy,不支持就用已经建好的 ToolStrategy。判断依据有两个,模型 profile 里的 structured_output 字段,以及一份内置的模型名正则名单(gpt-4o、gpt-5.x、claude 几款、grok-4 之类)。

图 2 是这个分叉。

实测里把 response_format 换成裸的 Invoice,拿到的还是 Invoice 实例,消息序列也和显式 ToolStrategy 那次一模一样。DeepSeek 命中不了上面任何一条,自动选择的结果就是 ToolStrategy。

ProviderStrategy 还有个 strict 参数,langchain>=1.2 才有。开了以后会在 json_schema 里带上 strict: true,让 provider 把 schema 当硬约束。这个参数只有部分 provider 认,文档点名的是 OpenAI 和 xAI。DeepSeek 上用不到,但值得知道它存在,它是 ProviderStrategy 唯一比 ToolStrategy 更硬的地方。

文档里还有一句:JSON Schema 字典必须显式包在 ProviderStrategy 或 ToolStrategy 里,裸传不会被识别成 schema。这个坑不容易自己发现,因为裸传一个 dict 不会报错,只会安静地不生效。

两种策略在模型那一侧的动作完全不同。ToolStrategy 会造一个真正的工具塞进 tools,模型返回的是 tool_call;ProviderStrategy 不造工具,它在请求里带一个 response_format 参数,模型返回的 JSON 文本落在 AIMessage.content 里,LangChain 再 json.loads 一遍。图 3 把两条路并排放在一起。

这解释了前面那些差异为什么存在。ToolStrategy 的工具和真实工具在同一个 tools 列表里排队,所以会互相影响;ProviderStrategy 走的是另一条参数通道,只要 provider 认这个参数,模型侧就按 schema 约束输出。失败时的表现也跟着分叉:ToolStrategy 有 ToolMessage 可以回灌,ProviderStrategy 解析不出来就直接抛。

四、provider 支持面:分四档看,Gemini 是唯一正向跑通的

三种验证手段,从轻到重。

1
2
3
4
5
6
7
8
9
10
=== 1. 模型自报能力 ===
model.profile : None
model.model_name : deepseek-chat

=== 2. LangChain 内部判断(非公开 API,仅用于排查)===
_supports_provider_strategy(model, tools=[]) -> False

=== 3. 硬指定 ProviderStrategy 跑 DeepSeek ===
异常类型: langchain_openai.chat_models.base.OpenAIInvalidRequestError
异常消息: Error code: 400 - {'error': {'message': 'This response_format type is unavailable now (request_id: ...)', 'type': 'invalid_request_error', 'param': None, 'code': 'invalid_request_error'}}

第一层看 model.profile。DeepSeek 在 langchain-openai 里是 None,没有能力数据,LangChain 只能靠模型名兜底,deepseek-chat 不在名单里。文档说 langchain>=1.1 会从 profile 动态读结构化输出能力,profile 缺失时可以手动补:

1
2
custom_profile = {"structured_output": True}
model = init_chat_model("...", profile=custom_profile)

我提一句:profile 是模型的自报能力,你写错了没人拦。给 DeepSeek 填 structured_output: True,LangChain 就会兴冲冲地走原生路线,然后你收到上面那个 400。别这么干。

第二层是 langchain.agents.factory 里的 _supports_provider_strategy,它不是公开 API,版本升级可能改名,只在排查时用。

第三层最实在:硬指定 ProviderStrategy 跑一次。400 里的 This response_format type is unavailable now 就是 DeepSeek 的回答。这里有个和文档不一致的地方,文档说”如果模型不支持结构化输出,agent 会回退到工具调用策略”,这句话只在自动选择那条路上成立。你显式写了 ProviderStrategy,代码里的注释是 “User explicitly specified a strategy - preserve it”,实测直接 400,不回退。

Ollama 云端有两条通路

只有 DeepSeek 一个反例,说明不了问题出在代码还是出在模型。文档点名支持原生结构化输出的是 OpenAI 和 xAI,这两家的 key 我手上没有,但有 Ollama 云端。这里要先分清一件事:Ollama 云端能接两条通路。

一条是前面一直在用的 OpenAI 兼容端点 https://ollama.com/v1,用 langchain-openai 的 ChatOpenAI 接,请求体按 OpenAI 的格式拼。另一条是 Ollama 自己的原生端点 https://ollama.com,得换成 langchain-ollama 的 ChatOllama,key 放在 client_kwargs 的 Authorization 头里:

1
2
3
4
5
6
7
from langchain_ollama import ChatOllama

om = ChatOllama(
model="gpt-oss:120b",
base_url="https://ollama.com",
client_kwargs={"headers": {"Authorization": "Bearer " + os.getenv('OLLAMA_KEY')}},
)

两个 base_url 只差一个 /v1,底下是两套请求拼装、两套响应解析。前面那句「Ollama 云端收下 response_format 却不生效」,只对兼容端点成立。

兼容端点:参数收下,不生效

同一段代码,只换 base_url、api_key、model 三个参数:

1
2
3
4
5
6
7
8
9
om = ChatOpenAI(
api_key=os.getenv('OLLAMA_KEY'),
base_url="https://ollama.com/v1",
model="gpt-oss:120b",
temperature=0.1,
max_tokens=500,
)

agent = create_agent(model=om, tools=[], response_format=ProviderStrategy(ContactInfo))

真实输出(structured_provider_strategy.py):

1
2
3
4
5
6
7
=== 2. 硬指定 ProviderStrategy,DeepSeek 的回答 ===
--- deepseek-chat ---
OpenAIInvalidRequestError: Error code: 400 - {'error': {'message': 'This response_format type is unavailable now (request_id: 5cd76851-...)'}}

=== 3. 硬指定 ProviderStrategy,Ollama 云端 gpt-oss:120b ===
--- gpt-oss:120b ---
StructuredOutputValidationError: Failed to parse structured output for tool 'ContactInfo': Native structured output expected valid JSON for ContactInfo, but parsing failed: Expecting value: line 1 column 1 (char 0)..

两种错法。DeepSeek 是 400,明说这个参数现在不给用。Ollama 这边请求发得出去,模型也答了,只是答的不是 JSON。想知道它到底回了什么,把 response_format 直接绑在模型上跑一次就够了:

1
2
3
4
5
6
7
8
9
=== 4. 同一份请求,直接把 response_format 绑上去,看模型返回什么 ===
--- gpt-oss:120b / response_format=json_schema(strict=True) ---
'**提取的联系人信息**\n\n```json\n{\n "name": "张三",\n "email": "zhangsan@example.com",\n "phone": "13800001111"\n}\n```'

--- gpt-oss:120b / response_format=json_object ---
'**提取的联系人信息** \n\n```json\n{\n "姓名": "张三",\n "邮箱": "zhangsan@example.com",\n "电话": "13800001111"\n}\n```'

--- nemotron-3-super / response_format=json_schema(strict=True) ---
'姓名:张三 \n邮箱:zhangsan@example.com \n电话:13800001111'

带参数和不带参数,输出一个样,照样带小标题、带 markdown 围栏。Ollama 云端这个 OpenAI 兼容层把 response_format 收下了,没有转给模型。ProviderStrategy 在这条路上不算被拒绝,算被无视,错误推迟到解析那一步才炸。

顺带把 Ollama 云端上架的模型全摸了一遍(ollama_model_matrix.py)。这个端点上架了 17 个模型,免费额度只覆盖其中 6 个:

模型 免费额度 普通对话 直接 with_structured_output ProviderStrategy
gpt-oss:120b 可用 通过 失败,Invalid JSON 失败,解析不到 JSON
gpt-oss:20b 可用 通过 失败,Invalid JSON 失败,解析不到 JSON
nemotron-3-super 可用 通过 失败,Invalid JSON 失败,解析不到 JSON
nemotron-3-ultra 可用 通过 失败,Invalid JSON 失败,解析不到 JSON
nemotron-3-nano:30b 可用 通过 失败,Invalid JSON 失败,解析不到 JSON
gemma4:31b 可用 通过 失败,Invalid JSON 失败,解析不到 JSON
glm-5.3 / glm-5.3-flash / glm-5.2 402 未测 未测 未测
minimax-m3 / minimax-m2.7 402 未测 未测 未测
kimi-k3 / kimi-k2.6 / kimi-k2.7-code 402 未测 未测 未测
mistral-large-3:675b 402 未测 未测 未测
deepseek-v4-pro:0813 / deepseek-v4.1-flash 402 未测 未测 未测

402 的原话是 this model is not included in your free usage,跟结构化输出无关,这 11 个模型根本跑不起来。剩下 6 个能跑的,profile 全是 None,LangChain 的能力探测认不出它们,只能退回内置的模型名正则名单,而名单里没有这些名字。这 6 个在两个层面上一律失败:兼容端点上连直接 with_structured_output 都拿不回对象,ProviderStrategy 更是全军覆没。

GLM 值得单独提一句,因为看名字它最像能跑通的那个,版本也够新。但 glm-5.3、glm-5.3-flash、glm-5.2 三个版本在 OpenAI 兼容端点和原生端点上都是同一个 402。想验证 GLM 的原生结构化输出,得先给账户加额度,这一步没法在代码里绕过去。

原生端点:直接调用能拿回对象,agent 里不行

换成 ChatOllama 接原生端点,同一份 schema、同一句 prompt,结果分叉了。先看 inspect 出来的签名,with_structured_output 的真实默认是 method='json_schema':

1
2
=== 1. ChatOllama.with_structured_output 的真实签名 ===
(self, schema: 'dict | type', *, method: "Literal['function_calling', 'json_mode', 'json_schema']" = 'json_schema', include_raw: 'bool' = False, **kwargs: 'Any') -> 'Runnable[LanguageModelInput, dict | BaseModel]'

直接调 with_structured_output(ContactInfo),温度设成 0。这里得先打个预防针:同一段代码反复重跑,成功率自己在摆,实测出现过 12 次里 5 次、12 次里 12 次、6 次里 2 次。所以下面这个数字是某一次运行的快照,不是模型的能力指标(structured_native_ollama.py):

1
2
3
4
5
6
=== 3. 直接 with_structured_output,temperature=0,连续 12 次 ===
run01: 失败 OutputParserException: Invalid json output: **Extracted Contact Information** | Field | Value | |-------|-------| | Name | John Doe
run04: OK name='John Doe' email='john@example.com' phone='(555) 123-4567'
...(中间省略 9 行)
run12: OK name='John Doe' email='john@example.com' phone='(555) 123-4567'
成功率: 5/12

不设 temperature 时是 6/12。这个数字每次重跑都不一样,5/6、2/6、12/12 都出现过,所以别把它当成「原生结构化输出可用」。但同一条通路上,create_agent 加 ProviderStrategy 是稳定的 0/4(structured_native_ollama.py):

1
2
=== 6. 同一原生通路,create_agent + ProviderStrategy ===
ProviderStrategy(strict=True), temperature=0: 失败 StructuredOutputValidationError: Failed to parse structured output for tool 'ContactInfo': Native structured output expected valid JSON for ContactInfo, but parsing failed: Expecting value: line 1 column 1 (char 0)..

另外三种组合(strict=False、默认温度)报错一字不差。同一个 agent,response_format 换成裸 schema(自动策略),又能拿回对象:

1
2
3
=== 7. 同一原生通路,create_agent + 裸 schema(自动策略)===
temperature=0: OK -> ContactInfo name='John Doe' email='john@example.com' phone='(555) 123-4567'
默认温度: OK -> ContactInfo name='John Doe' email='john@example.com' phone='(555) 123-4567'

为什么直接调用通、agent 不通

这里有个反直觉的地方值得挖到底。with_structured_output(method='json_schema') 的内部实现是 bind(format=<JSON Schema>) | PydanticOutputParser。Ollama 原生端点收到 format 之后,没有把它当硬约束。模型回的常常是这种内容:

1
2
3
4
5
6
7
=== 5. 机制:原生端点没有把 schema 当硬约束 ===
format 传的就是 with_structured_output 生成的同一份 JSON Schema
parse_json_markdown(with_structured_output 用的解析器)能提取: 12/12
严格 json.loads(ProviderStrategy 用的解析器)能解析: 0/12
一条原始内容样本(截断 280 字):
'**Extracted Contact Information**\n\n| Field | Value |\n|-------|-------|\n| **Name** | John Doe |\n| **Email** | john@example.com |\n| **Phone** | (555) 123-4567 |\n\n**JSON format**\n\n
```json\n{\n "name": "John Doe",\n "email": "john@example.com",\n "phone": "(555) 123-4567"\n}\n```'

这两行是整节里唯一稳的结论:换个时间、换台机器各跑一次,宽容解析仍然 12/12,严格 json.loads 仍然 0/12。上面那个成功率会摆,这个不会。

一张 markdown 表格,后面附一段围栏 JSON。PydanticOutputParser 会剥围栏、从文本里捞 JSON,所以这种内容能解析成对象。同一份原始内容丢给 ProviderStrategy,它内部只有一句 json.loads(raw_text),内容以 ** 开头,直接抛 Expecting value: line 1 column 1。上面那两行 12/12 和 0/12 就是同一批响应喂给两个解析器的结果。

所以「直接调用成功」和「ProviderStrategy 失败」不矛盾,差别在解析器的宽容度。create_agent 在 model_node 里自己拼请求、自己解析,ProviderStrategy 走的是 agent 工厂内部那条严格路径,跟 with_structured_output 生成的 runnable 不是同一份代码。拿直接调用的成功去推断 ProviderStrategy 也能成,不成立,两者得分别验证。

再补一句边界:原生端点上的「成功」也不代表 provider 守住了 schema。模型多数时候肯在散文后面补一段 JSON,换个 prompt、换个模型,这段 JSON 可能就不出现。默认温度下 12 次里有 6 次没补,直接抛解析错。

Gemini:同一段代码,正向跑通

上面三条通路全是失败,问题到底在 LangChain 还是在 provider。我拿到一个 Google Gemini 的 key,把同一段代码又跑了一遍(gemini_provider_strategy.py),结果是一个对象:

1
2
3
4
5
6
7
8
9
10
=== 0. 模型能力 ===
model = gemini-3.5-flash
profile.structured_output = True

=== 1. Structured Output 篇:create_agent + ProviderStrategy ===
类型: ContactInfo
值 : name='John Doe' email='john@example.com' phone='(555) 123-4567'

=== 4. 对照:同一段代码在 DeepSeek 上 ===
失败 OpenAIInvalidRequestError: Error code: 400 - {'error': {'message': 'This response_format type is unavailable now (request_id: ...)', '

ContactInfo 的定义、prompt、ProviderStrategy 的写法一个字没改,只换了 model 参数。Gemini 返回 ContactInfo 实例,DeepSeek 返回 400。这组对照把责任划清了:代码没问题,问题在 provider 认不认 response_format 里的 json_schema。

Gemini 也是这几家里第一个把 profile 填满的。gemini-3.5-flash 的 profile 里 structured_output 是 True,max_output_tokens 65536,tool_calling 也是 True。我顺手看了一眼那个非公开的判断函数,_supports_provider_strategy(model, tools=[]) 在 Gemini 上返回 True,在 DeepSeek 上返回 False。三层探测全绿,跟上面 DeepSeek 的全红正好反过来。

这里能推出一个文档没直说的结论。文档点名支持原生结构化输出的只有 OpenAI 和 xAI,Gemini 不在这句话里。真正让自动策略改走原生的是 profile 里的 structured_output 字段,不是那份模型名名单。provider 包只要把能力数据交出来,LangChain 就认;交不出来的,像 DeepSeek 的 profile 是 None,只能去查名单,查不到就退 ToolStrategy。

四档对比

把四条通路放在一张表里(前三行来自 structured_native_ollama.py 第 8 节,Gemini 那行来自 gemini_provider_strategy.py):

provider / 端点 直接 with_structured_output create_agent + ProviderStrategy 原始报错
Gemini(gemini-3.5-flash,langchain-google-genai) 成功 成功,返回 ContactInfo 实例 无
DeepSeek(api.deepseek.com/v1) 失败 失败 400 This response_format type is unavailable now
Ollama 云端 OpenAI 兼容(ollama.com/v1) 失败 失败 ValidationError: Invalid JSON;StructuredOutputValidationError
Ollama 云端原生(ollama.com,ChatOllama) 不稳,temperature=0 下多次重跑在 5/12 到 12/12 之间 失败 StructuredOutputValidationError: … parsing failed

Gemini 属于正向跑通,profile 说支持,请求发出去,回来的就是对象。DeepSeek 属于明确拒绝,参数都不收。Ollama 兼容端点属于收下不生效,错误推迟到解析。Ollama 原生端点属于参数确实传到了模型,但没被当硬约束,靠解析器兜底才偶尔拿到对象。「ProviderStrategy 没有正向路径」这句话到此为止,Gemini 补上了最后一块;「厂商不支持」这个说法同样站不住,同一个厂商、同一份 key,换个端点和调用层,结果就不一样。

DeepSeek 认 json_object,不认 json_schema

ProviderStrategy 走不通,但 DeepSeek 不是完全不认 response_format(structured_provider_strategy.py):

1
2
3
4
5
6
7
8
9
10
11
12
=== 5. DeepSeek 的 response_format 支持面 ===
--- deepseek-chat / json_schema(strict=True) ---
OpenAIInvalidRequestError: Error code: 400 - {'error': {'message': 'This response_format type is unavailable now (request_id: c64ed0bb-...)'}}

--- deepseek-chat / json_object(prompt 里出现 json 这个词) ---
'{"name": "张三", "email": "zhangsan@example.com", "phone": "13800001111"}'

--- deepseek-chat / with_structured_output(Route, method='json_mode') ---
Route(destination='账单', reason='客户反馈发票金额不一致,属于账单/财务核对问题,应由账单部门处理。')

--- deepseek-chat / with_structured_output(Route) 默认 json_schema ---
OpenAIInvalidRequestError: Error code: 400 - {'error': {'message': 'This response_format type is unavailable now (request_id: 47b7320a-...)'}}

json_object 那条还有个前提,prompt 里必须出现 json 这个词,否则它回你 Prompt must contain the word ‘json’ in some form to use ‘response_format’ of type ‘json_object’。这条规矩来自 DeepSeek,跟 LangChain 无关。

所以 with_structured_output 想留在原地,显式写 method=”json_mode” 再在 prompt 里提一句 json,能拿回 Pydantic 对象。代价是没有 schema 约束,字段填错也没人拦,ToolStrategy 那套校验重试这里一样都没有。

结论:DeepSeek 项目里显式写 ToolStrategy。裸传 schema 今天能跑,明天换个支持原生的模型,策略会悄悄换掉,工具抢占方式和错误处理都跟着变。想要原生结构化输出,挑 provider 的时候先看 profile 里的 structured_output,Gemini 就是这么跑通的,不需要换端点或换调用层。DeepSeek 是明确拒绝,Ollama 兼容端点是收下不用,Ollama 原生端点是把 schema 转给了模型但不当硬约束,靠解析器兜底。真要在 with_structured_output 这一层从 DeepSeek 取对象,退到 json_mode 是可行的,别指望 schema 帮你挡错。

五、校验失败:重试与错误处理

ToolStrategy 的 handle_errors 默认是 True:校验失败不抛异常,改成把错误信息塞进一条 ToolMessage 回灌给模型,让它改一版。这是结构化输出里最值钱的机制,也是它最容易被忽略的地方。

我用一个带计数器的 validator 把重试稳定复现出来(structured_error.py):

1
2
3
4
5
6
7
8
9
10
11
12
13
attempts = {"n": 0}

class Report(BaseModel):
title: str = Field(description="标题")
score: int = Field(description="分数")

@field_validator("score")
@classmethod
def reject_first(cls, v):
attempts["n"] += 1
if attempts["n"] == 1:
raise ValueError("score 必须由复核流程确认,请重试")
return v
1
2
3
4
5
6
7
8
9
10
11
12
13
=== B. 校验器第一次故意报错,观察重试 ===
校验器被调用次数: 2
structured_response: Report(title='季度复盘', score=88)
消息序列:
HumanMessage: 把这条记录结构化:标题《季度复盘》,分数 88。
AIMessage: tool_calls=[('Report', {'title': '季度复盘', 'score': 88})]
ToolMessage: Error: Failed to parse structured output for tool 'Report': Failed to parse data to Report: 1 validation error for Report
score
Value error, score 必须由复核流程确认,请重试 [type=value_error, input_value=88, input_type=int]
For further information visit https://errors.pydantic.dev/2.13/v/value_error.
Please fix your mistakes.
AIMessage: tool_calls=[('Report', {'title': '季度复盘', 'score': 88})]
ToolMessage: Returning structured response: title='季度复盘' score=88

模型重发了同样的参数,校验器第二次放行。回灌的那条 ToolMessage 是固定的模板 Error: {错误}\n Please fix your mistakes.,Pydantic 的报错原文被原封不动带了进去。模型能看懂 pydantic 那几行英文错误,这一点不用怀疑。

handle_errors 一共六种取值:

取值 行为
True(默认) 任何异常都重试,用默认错误模板
“自定义文本” 任何异常都重试,用你给的文本
ValueError 只有这个异常类型重试,其余抛出
(ValueError, TypeError) 列表里的类型重试,其余抛出
函数 用函数返回值当错误信息,函数里可以按异常类型分支
False 不重试,直接抛 StructuredOutputValidationError

图 4 是这个重试环。

False 的行为我也跑了:

1
2
3
4
5
6
7
=== C. handle_errors=False,校验必然失败 ===
抛出 StructuredOutputValidationError
tool_name: AlwaysBad
消息: Failed to parse structured output for tool 'AlwaysBad': Failed to parse data to AlwaysBad: 1 validation error for AlwaysBad
name
Value error, 这个 schema 故意不接受任何输入 [type=value_error, input_value='李四', ...
校验器被调用次数: 1 (False 表示不重试,一次就抛)

重试不是免费的。每次重试都是一次完整的模型调用,token 照算。handle_errors 默认开着,正常情况下一两次就修好了;某个字段反复修不好,多半是 description 写得含糊,或者这个值原文里根本没有,模型只能编。这时候该改 schema,或者干脆允许这个字段为 None。

同一份脚本里还有个反直觉的实测结果。我本来想用文档里那个例子复现重试:schema 限制 rating 在 1 到 5,输入 “Amazing product, 10/10!”,指望模型先填 10 被拒,再改成 5。实际输出是:

1
2
3
4
5
=== A. 评分 10/10,schema 限制 1-5(handle_errors 默认 True)===
structured_response: ProductRating(rating=5, comment='Amazing product, 10/10!')
消息序列:
AIMessage: tool_calls=[('ProductRating', {'rating': 5, 'comment': 'Amazing product, 10/10!'})]
ToolMessage: Returning structured response: rating=5 comment='Amazing product, 10/10!'

DeepSeek 自己把 10/10 换算成了 5 分制,一次就通过,重试根本没触发。这件事比它看起来严重:schema 校验管的是”能不能解析成对象”,不管”值对不对”。你的业务字段被模型顺手改写成它认为合理的形式,Pydantic 不会报错,你也不会发现。Field 约束防不住这类问题,能防的只有两件事,在 description 和系统提示里把口径写死(原文写多少就填多少,不要换算),以及在下游比对原文。

还有一类错误单独处理:Union schema 下模型同时调了两个结构化输出工具,抛 MultipleStructuredOutputsError,默认同样会重试,错误文本是 Model incorrectly returned multiple structured responses (A, B) when only one is expected. 想区分这两种错误,就传一个函数给 handle_errors,函数参数是异常对象,里面 isinstance 一下就行。

一个容易踩的边:handle_errors 只对 ToolStrategy 生效。源码里的判断是 if not isinstance(response_format, ToolStrategy): return False, “”,ProviderStrategy 出错直接抛。所以你在 DeepSeek 上调好的重试逻辑,换成原生模型跑不一定还在。

六、嵌套、列表、枚举、可选字段

真实场景的 schema 很少是平铺的几个字段。下面这张订单模型把四样都占全了(structured_nested.py):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Address(BaseModel):
"""收货地址。"""
city: str = Field(description="城市")
district: str | None = Field(default=None, description="区县,没提到就是 null")


class LineItem(BaseModel):
"""订单里的一行商品。"""
sku: str = Field(description="商品编码")
name: str = Field(description="商品名")
qty: int = Field(description="数量", ge=1)
unit_price: float = Field(description="单价")
discount: float | None = Field(default=None, description="该行折扣金额,没有就是 null")


class Customer(BaseModel):
"""下单人。"""
name: str = Field(description="姓名")
vip_level: Literal["none", "silver", "gold"] = Field(description="会员等级")


class Order(BaseModel):
"""一张订单。"""
order_id: str = Field(description="订单号")
customer: Customer = Field(description="下单人")
shipping: Address = Field(description="收货地址")
items: list[LineItem] = Field(description="商品明细")
payment: Literal["wechat", "alipay", "card"] = Field(description="支付方式")
coupon: str | None = Field(default=None, description="使用的优惠券码,没有就是 null")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
=== 嵌套属性访问 ===
order.customer.name = 王小明
order.customer.vip_level = gold
order.shipping.district = 余杭区
order.items[0].sku = SKU-KB01
order.items[1].discount = 10.0
order.coupon = NEW20

=== 模型实际返回的 tool_call args ===
{
"items": [
{"sku": "SKU-KB01", "name": "机械键盘", "qty": 1, "unit_price": 399, "discount": null},
{"sku": "SKU-MS02", "name": "鼠标", "qty": 2, "unit_price": 89, "discount": 10}
],
"payment": "alipay",
"coupon": "NEW20"
}

四样写法的规律:

嵌套模型直接当类型用,Pydantic 会在 $defs 里生成子 schema,模型那边看到的是完整的嵌套结构。列表写 list[LineItem],会变成 array + items,元素类型照样被校验。枚举用 Literal,JSON Schema 里生成 enum,模型选值被限制在枚举内。可选字段写 X | None = None,生成 anyOf [type, null],而且不会进 required 列表,实测原文没提付款状态时 paid 就是 None。

模型返回的 unit_price 是 399 和 89,两个整数,出了校验层变成 399.0 和 89.0。TypeAdapter 顺手做了类型转换,字符串 “399” 大概率也能转成 399.0。别依赖这个转换做数据清洗,该自己校验的数字,在 Pydantic 上加 ge、le,或者干脆加 validator。

嵌套深了 schema 会明显膨胀,模型漏字段的概率跟着涨。字段的 description 比字段名重要得多,中文 description 完全能用,上面这些 schema 的描述都是中文,模型照样填得对。description 里写清”没提到就 null”,比事后判断 None 省事。

七、结构化输出和工具调用同时存在

真实项目里很少只有结构化输出。给 agent 挂一个真工具,再加 ToolStrategy,看它怎么跑(structured_with_tools.py):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@tool
def get_exchange_rate(base: str, quote: str) -> str:
"""查询 base 货币兑 quote 货币的汇率,返回一个数字字符串。"""
rates = {("USD", "CNY"): "7.24", ("EUR", "CNY"): "7.85"}
return rates.get((base.upper(), quote.upper()), "unknown")


class FxAnswer(BaseModel):
"""汇率查询结论。"""
base: str = Field(description="基准货币代码")
quote: str = Field(description="报价货币代码")
rate: float = Field(description="1 单位基准货币兑多少报价货币")
source: Literal["tool", "unknown"] = Field(description="数据来源,调用过工具就是 tool")


agent = create_agent(
model=model,
tools=[get_exchange_rate],
response_format=ToolStrategy(FxAnswer),
)
1
2
3
4
5
6
7
8
9
=== structured_response ===
FxAnswer(base='USD', quote='CNY', rate=7.24, source='tool')

=== 完整消息序列 ===
[0] HumanMessage: 查一下美元兑人民币的汇率,然后用结构化结果告诉我。
[1] AIMessage content='' tool_calls=[('get_exchange_rate', {'base': 'USD', 'quote': 'CNY'})]
[2] ToolMessage: 7.24
[3] AIMessage content='' tool_calls=[('FxAnswer', {'base': 'USD', 'quote': 'CNY', 'rate': 7.24, 'source': 'tool'})]
[4] ToolMessage: Returning structured response: base='USD' quote='CNY' rate=7.24 source='tool'

模型分两轮走完。第一轮调真实工具拿汇率,第二轮调结构化输出工具交结论。两条 AIMessage 的 content 都是空字符串,全程没有自然语言。

机制上有两个点要记住。结构化输出工具会被加进 final_tools,和真实工具混在一起,同时 tool_choice 被强制设成 “any”,langchain-openai 收到后翻译成 “required”。只要用了 ToolStrategy,模型每一轮至少得调一个工具,它没法直接甩一段话给你。LangGraph 那侧的边会把结构化输出工具从待执行列表里摘出去,不送进 ToolNode,真实工具照常执行;structured_response 一旦出现,循环立刻结束。

如果模型就是不肯交结论,一直反复调真实工具,它会一直循环。LangGraph 的 recursion_limit 默认值很大(这个版本是 10007),所以你不会很快看到报错,只会看到 token 一点点烧掉。想早点发现,跑的时候显式传 config={“recursion_limit”: 6} 之类的值,让它撞墙报错,比事后查账单强。

ProviderStrategy 走这条路有额外要求:文档说模型必须支持工具和结构化输出同时使用。源码里还有个特例,带工具时 Gemini 3 之前的模型不给 ProviderStrategy。

写法上的建议:schema 只描述最终结论,取数交给工具。不要把”需要先调工具才能算出来的中间量”塞进 schema,那会让模型在工具和 schema 之间来回纠结。

八、拿不到 structured_response 的排查清单

按这个顺序查,能省不少时间。

  1. 先 print(sorted(result.keys()))。没传 response_format 时,key 只有 [‘messages’],structured_response 根本不存在,result.get() 拿到 None。实测过,这跟模型无关。
  2. 传了 response_format 但循环不结束,通常是模型每轮都在调真实工具,或者反复重试校验。把 recursion_limit 调小跑一次,看它撞不撞上限。
  3. 硬指定 ProviderStrategy 报错,先分清是哪种。400 invalid_request_error 里带 This response_format type is unavailable now,是 provider 明确拒绝,DeepSeek 三个模型都这样。StructuredOutputValidationError 里带 Native structured output expected valid JSON … parsing failed,是内容不是纯 JSON,模型在 JSON 前面加了散文。Ollama 云端的兼容端点和原生端点都是这种。两种都跟你的 schema 无关。反过来,provider 的 profile 里 structured_output 为 True 时请求发出去就回来对象,Gemini 属于这种,不需要排查。
  4. 报 ValueError: ToolStrategy specifies tool ‘X’ which wasn’t declared in the original response format…,说明中间件改了 response_format,加了一个创建 agent 时没声明的 schema。结构化输出工具必须在 create_agent 那一次全部声明。
  5. 用 JSON Schema 字典时行为古怪,先确认顶层有 title 和 description,再确认它根本不做校验这件事你心里有数。
  6. 多轮对话里读到了上一轮的结果。配置了 response_format 但本轮没产出结构化响应时,LangChain 会把 structured_response 显式清成 None,防止旧值残留。如果你自己缓存了上一轮的 sr,那就得自己负责清。
  7. 最后再怀疑模型。print(type(sr)) 和完整的消息序列,比盯着 schema 改描述有效得多。

小结

  • 结构化输出的入口是 response_format,出口是 state 里的 structured_response。前者决定策略,后者是校验过的对象,不是字符串。
  • ProviderStrategy 的正向路径是 Gemini 跑出来的。gemini-3.5-flash 的 profile.structured_output 为 True,同一段代码返回 ContactInfo 实例,换成 DeepSeek 就是 400。选 provider 时先看这个字段,比背厂商名单可靠。
  • DeepSeek 走 ToolStrategy,显式写出来。裸传 schema 能跑,但策略会随模型变化。DeepSeek 上硬指定 ProviderStrategy 会拿到 400;换 Ollama 云端,无论兼容端点还是原生端点,错误都推迟成 StructuredOutputValidationError。原生端点直接调用偶尔能拿回对象,靠的是解析器宽容,不是 provider 守住了 schema。DeepSeek 只认 json_object,显式写 method=”json_mode” 再在 prompt 里带 json,能拿回对象,代价是没有校验。
  • 四种 schema 里默认选 Pydantic。dataclass 实测返回实例,文档写的却是 dict;JSON Schema 字典完全不校验,这两个坑写代码前先 print 一下类型。
  • handle_errors 默认会重试,错误信息以 ToolMessage 回灌。它管解析,不管值对不对,10/10 被模型换算成 5 这类问题得靠 description 和下游校验兜。
  • 用了 ToolStrategy,tool_choice 被强制成 required,模型每轮必须调工具。拿不到 structured_response 时,先看 keys,再看循环有没有停,最后才怀疑模型。