LangChain Structured Output
代码仓库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 | import os |
真实输出(structured_extract.py):
1 | === 2. ToolStrategy + Pydantic === |
有三个细节值得盯一眼。structured_response 是 Invoice 实例,不是 dict,也不是 JSON 字符串。AIMessage 的 content 是空字符串,这一轮模型只发了一个工具调用,一句自然语言都没写。末尾多出来的 ToolMessage 内容是 Returning structured response: …,这是 LangChain 自己补的,用来让消息历史在下一轮仍然合法。
图 1 是这一轮里数据怎么走的。
flowchart LR
A[Invoice 模型类] --> B[转成 JSON Schema]
B --> C[包成一个工具, 名字取类名]
C --> D[bind_tools 绑定, tool_choice 强制 required]
D --> E[模型返回 tool_call, arguments 是 JSON]
E --> F[按 schema 校验参数]
F --> G[写入 structured_response]
F --> H[补一条 ToolMessage]
有人会问,直接让模型输出 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 | [Pydantic BaseModel] |
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 | from langchain.agents.structured_output import OutputToolBinding, ToolStrategy |
1 | Pydantic 模型的 schema_kind: pydantic |
选择上的建议很直接。默认用 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 是这个分叉。
flowchart TD
A[response_format] --> B{传的是什么}
B -->|None| C[不做结构化输出]
B -->|ToolStrategy| D[按工具调用走]
B -->|ProviderStrategy| E[按原生结构化输出走]
B -->|裸 schema| F[包成 AutoStrategy]
F --> G[先按 ToolStrategy 建好工具]
G --> H{模型 profile 里 structured_output 为真}
H -->|是| I[换成 ProviderStrategy]
H -->|否| J{模型名命中内置名单}
J -->|是| I
J -->|否| D
实测里把 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 把两条路并排放在一起。
flowchart TD
S[同一份 schema] --> T[ToolStrategy]
S --> P[ProviderStrategy]
T --> T1[造一个 StructuredTool, 名字取类名]
T1 --> T2[bind_tools 塞进 tools, tool_choice 强制 required]
T2 --> T3[模型返回 tool_call]
T3 --> T4[校验 tool_call 参数, 写 structured_response]
P --> P1[不造任何工具]
P1 --> P2[bind_tools 带 response_format=json_schema]
P2 --> P3[模型返回 JSON 文本, 放在 content 里]
P3 --> P4[json.loads 后校验, 写 structured_response]
这解释了前面那些差异为什么存在。ToolStrategy 的工具和真实工具在同一个 tools 列表里排队,所以会互相影响;ProviderStrategy 走的是另一条参数通道,只要 provider 认这个参数,模型侧就按 schema 约束输出。失败时的表现也跟着分叉:ToolStrategy 有 ToolMessage 可以回灌,ProviderStrategy 解析不出来就直接抛。
四、provider 支持面:分四档看,Gemini 是唯一正向跑通的
三种验证手段,从轻到重。
1 | === 1. 模型自报能力 === |
第一层看 model.profile。DeepSeek 在 langchain-openai 里是 None,没有能力数据,LangChain 只能靠模型名兜底,deepseek-chat 不在名单里。文档说 langchain>=1.1 会从 profile 动态读结构化输出能力,profile 缺失时可以手动补:
1 | custom_profile = {"structured_output": True} |
我提一句: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 | from langchain_ollama import ChatOllama |
两个 base_url 只差一个 /v1,底下是两套请求拼装、两套响应解析。前面那句「Ollama 云端收下 response_format 却不生效」,只对兼容端点成立。
兼容端点:参数收下,不生效
同一段代码,只换 base_url、api_key、model 三个参数:
1 | om = ChatOpenAI( |
真实输出(structured_provider_strategy.py):
1 | === 2. 硬指定 ProviderStrategy,DeepSeek 的回答 === |
两种错法。DeepSeek 是 400,明说这个参数现在不给用。Ollama 这边请求发得出去,模型也答了,只是答的不是 JSON。想知道它到底回了什么,把 response_format 直接绑在模型上跑一次就够了:
1 | === 4. 同一份请求,直接把 response_format 绑上去,看模型返回什么 === |
带参数和不带参数,输出一个样,照样带小标题、带 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 | === 1. ChatOllama.with_structured_output 的真实签名 === |
直接调 with_structured_output(ContactInfo),温度设成 0。这里得先打个预防针:同一段代码反复重跑,成功率自己在摆,实测出现过 12 次里 5 次、12 次里 12 次、6 次里 2 次。所以下面这个数字是某一次运行的快照,不是模型的能力指标(structured_native_ollama.py):
1 | === 3. 直接 with_structured_output,temperature=0,连续 12 次 === |
不设 temperature 时是 6/12。这个数字每次重跑都不一样,5/6、2/6、12/12 都出现过,所以别把它当成「原生结构化输出可用」。但同一条通路上,create_agent 加 ProviderStrategy 是稳定的 0/4(structured_native_ollama.py):
1 | === 6. 同一原生通路,create_agent + ProviderStrategy === |
另外三种组合(strict=False、默认温度)报错一字不差。同一个 agent,response_format 换成裸 schema(自动策略),又能拿回对象:
1 | === 7. 同一原生通路,create_agent + 裸 schema(自动策略)=== |
为什么直接调用通、agent 不通
这里有个反直觉的地方值得挖到底。with_structured_output(method='json_schema') 的内部实现是 bind(format=<JSON Schema>) | PydanticOutputParser。Ollama 原生端点收到 format 之后,没有把它当硬约束。模型回的常常是这种内容:
1 | === 5. 机制:原生端点没有把 schema 当硬约束 === |
这两行是整节里唯一稳的结论:换个时间、换台机器各跑一次,宽容解析仍然 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 | === 0. 模型能力 === |
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 | === 5. DeepSeek 的 response_format 支持面 === |
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 | attempts = {"n": 0} |
1 | === B. 校验器第一次故意报错,观察重试 === |
模型重发了同样的参数,校验器第二次放行。回灌的那条 ToolMessage 是固定的模板 Error: {错误}\n Please fix your mistakes.,Pydantic 的报错原文被原封不动带了进去。模型能看懂 pydantic 那几行英文错误,这一点不用怀疑。
handle_errors 一共六种取值:
| 取值 | 行为 |
|---|---|
| True(默认) | 任何异常都重试,用默认错误模板 |
| “自定义文本” | 任何异常都重试,用你给的文本 |
| ValueError | 只有这个异常类型重试,其余抛出 |
| (ValueError, TypeError) | 列表里的类型重试,其余抛出 |
| 函数 | 用函数返回值当错误信息,函数里可以按异常类型分支 |
| False | 不重试,直接抛 StructuredOutputValidationError |
图 4 是这个重试环。
flowchart TD
A[模型返回 tool_call 参数] --> B{按 schema 校验}
B -->|通过| C[写入 structured_response, 循环结束]
B -->|失败| D{handle_errors 允许重试}
D -->|是| E[补一条 ToolMessage 带错误信息]
E --> A
D -->|否| F[抛 StructuredOutputValidationError]
False 的行为我也跑了:
1 | === C. handle_errors=False,校验必然失败 === |
重试不是免费的。每次重试都是一次完整的模型调用,token 照算。handle_errors 默认开着,正常情况下一两次就修好了;某个字段反复修不好,多半是 description 写得含糊,或者这个值原文里根本没有,模型只能编。这时候该改 schema,或者干脆允许这个字段为 None。
同一份脚本里还有个反直觉的实测结果。我本来想用文档里那个例子复现重试:schema 限制 rating 在 1 到 5,输入 “Amazing product, 10/10!”,指望模型先填 10 被拒,再改成 5。实际输出是:
1 | === A. 评分 10/10,schema 限制 1-5(handle_errors 默认 True)=== |
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 | class Address(BaseModel): |
1 | === 嵌套属性访问 === |
四样写法的规律:
嵌套模型直接当类型用,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 |
|
1 | === structured_response === |
模型分两轮走完。第一轮调真实工具拿汇率,第二轮调结构化输出工具交结论。两条 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 的排查清单
按这个顺序查,能省不少时间。
- 先 print(sorted(result.keys()))。没传 response_format 时,key 只有 [‘messages’],structured_response 根本不存在,result.get() 拿到 None。实测过,这跟模型无关。
- 传了 response_format 但循环不结束,通常是模型每轮都在调真实工具,或者反复重试校验。把 recursion_limit 调小跑一次,看它撞不撞上限。
- 硬指定 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 属于这种,不需要排查。
- 报 ValueError: ToolStrategy specifies tool ‘X’ which wasn’t declared in the original response format…,说明中间件改了 response_format,加了一个创建 agent 时没声明的 schema。结构化输出工具必须在 create_agent 那一次全部声明。
- 用 JSON Schema 字典时行为古怪,先确认顶层有 title 和 description,再确认它根本不做校验这件事你心里有数。
- 多轮对话里读到了上一轮的结果。配置了 response_format 但本轮没产出结构化响应时,LangChain 会把 structured_response 显式清成 None,防止旧值残留。如果你自己缓存了上一轮的 sr,那就得自己负责清。
- 最后再怀疑模型。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,再看循环有没有停,最后才怀疑模型。

