在做一个能对接程序的大模型应用时,一个绕不开的需求是:让模型返回指定结构的数据——比如从一段话里抽出 name、age、city 三个字段,供后续代码使用。过去我们靠提示词「求」模型返回 JSON,再手动 JSON.parse;现在 LangChain 提供了一套更可靠的方案:结构化输出(Structured Output)。这篇文章讲清楚老办法的坑、新方案的思路,以及具体怎么用。
一、老办法为什么不可靠
要让模型返回 JSON,最直觉的做法是在提示词里写清楚:
const res = await model.invoke(
"从这句话提取信息:张三,今年 30 岁,住在上海。请以 JSON 返回,字段为 name, age, city。"
);
const data = JSON.parse(res.content);
但实际跑一下会发现,模型经常这样返回:
```json
{
"name": "张三",
"age": 30,
"city": "上海"
}
```
它用 Markdown 代码块把 JSON 包了起来,JSON.parse 直接报错。于是你不得不写正则去清理围栏:
const cleaned = String(res.content)
.replace(/^```(?:json)?/i, "")
.replace(/```$/, "")
.trim();
const data = JSON.parse(cleaned);
即便这样能跑通,老办法仍然很脆,问题在于它只是**「请求」而非「强制」**:
- 格式不稳定:可能带代码块围栏、前后夹着解释文字,需要各种正则兜底;
- 字段不受控:模型可能把
name写成Name或姓名,把age返回成字符串"30"; - 没有校验:字段缺失、类型不对,代码要到运行时才炸,错误被推迟到很远的地方;
- 没有类型:拿到的是一坨
any,TypeScript 给不了任何提示和补全。
归根结底:提示词是给模型的「建议」,而不是对输出结构的「保证」。
二、新方案的核心思路
新方案换了个思路:不再用提示词描述格式,而是把「你想要的形状」用 schema 表达出来,直接交给模型作为约束。
具体来说:
- 你用一个 schema(通常是 zod)定义输出的字段、类型和含义;
- LangChain 把它转成模型能理解的 JSON Schema;
- 模型据此产出结构化结果(底层要么借工具调用,要么用厂商原生的结构化输出能力);
- LangChain 自动把结果解析成对象,并用 schema 校验——不合法就报错,而不是悄悄给你脏数据。
最终你拿到的是一个类型安全的 JS 对象,可以直接 data.city 这样使用,不再有 JSON.parse 和正则清理。
三、用法一:withStructuredOutput
最简单的方式是给模型套一个 withStructuredOutput(schema),它会返回一个新的 Runnable,调用后直接得到对象:
import { z } from "zod";
import { initChatModel } from "langchain";
const ContactSchema = z.object({
name: z.string().describe("人名"),
age: z.number().describe("年龄(整数)"),
city: z.string().describe("所在城市"),
});
const model = await initChatModel("deepseek:deepseek-chat", { apiKey });
const structured = model.withStructuredOutput(ContactSchema, {
name: "extract_contact", // 底层函数名,可自定义
});
const result = await structured.invoke("从这句话提取信息:张三,今年 30 岁,住在上海。");
console.log(result);
// { name: '张三', age: 30, city: '上海' }
console.log(typeof result.age); // "number" —— 已经是 number,不是字符串
注意 .describe() 会写进 JSON Schema 的 description,帮助模型理解每个字段该怎么填。
includeRaw:同时拿到原始消息
有时你想看看底层到底发生了什么,可以用 includeRaw: true,它会返回 { raw, parsed }:
const structured = model.withStructuredOutput(ContactSchema, {
name: "extract_contact",
includeRaw: true,
});
const { raw, parsed } = await structured.invoke("从这句话提取信息:张三,今年 30 岁,住在上海。");
console.log(parsed); // { name: '张三', age: 30, city: '上海' }
console.log(raw.content); // "" 正文是空的
console.log(raw.tool_calls); // [ { name: 'extract_contact', args: {...}, ... } ]
从 raw.tool_calls 能看出:默认方法其实是借一次工具调用,让模型把结果填进这个「工具」的参数里,再被解析出来。
三种底层方法(method)
withStructuredOutput 支持三种实现,按模型能力选择:
| method | 原理 | 特点 |
|---|---|---|
functionCalling(默认) | 借工具调用产出结构化结果 | 兼容性最好,大多数模型都支持 |
jsonMode | 让模型输出 JSON 文本(response_format) | 依赖模型支持 JSON 模式 |
jsonSchema | 厂商原生的严格结构化输出 | 约束最强,但大多只有 OpenAI 等支持 |
const byFC = model.withStructuredOutput(ContactSchema, { method: "functionCalling" });
const byJson = model.withStructuredOutput(ContactSchema, { method: "jsonMode" });
两个实战注意点:
jsonMode可能要求提示词里出现「json」字样。比如 DeepSeek 的json_object模式,如果提示词里没有json就会报 400;而functionCalling没有这个限制,所以更通用。jsonSchema/strict只有部分厂商支持。DeepSeek 不支持原生严格模式,强行用会报错。不确定时直接用默认的functionCalling最稳妥。
四、用法二:在 Agent 里做结构化输出
进入 v1 的 Agent 世界后,结构化输出成了 createAgent 的一等参数:用 responseFormat 声明输出形状,结果放在 result.structuredResponse。
有两种策略:
toolStrategy(推荐,兼容性好)
借助工具调用来产出结构化结果:
import { createAgent, toolStrategy } from "langchain";
import { z } from "zod";
const ContactSchema = z.object({
name: z.string().describe("人名"),
age: z.number().describe("年龄(整数)"),
city: z.string().describe("所在城市"),
});
const agent = createAgent({
model: "deepseek:deepseek-chat",
tools: [], // 本例只做抽取,不需要工具
responseFormat: toolStrategy(ContactSchema),
});
const res = await agent.invoke({
messages: [{ role: "user", content: "从这句话提取信息:张三,今年 30 岁,住在上海。" }],
});
console.log(res.structuredResponse);
// { name: '张三', age: 30, city: '上海' }
providerStrategy(依赖厂商原生能力)
如果你希望用厂商原生的严格结构化输出,可以用 providerStrategy:
import { providerStrategy } from "langchain";
const agent = createAgent({
model: "openai:gpt-4o",
tools: [],
responseFormat: providerStrategy(ContactSchema),
});
const res = await agent.invoke({ messages: [{ role: "user", content: "..." }] });
console.log(res.structuredResponse);
它会把 schema 直接交给模型厂商的原生结构化输出接口。代价是兼容性:如果厂商不支持(比如 DeepSeek),会直接报错 This response_format type is unavailable now。因此除非你确定目标模型支持,否则优先用 toolStrategy。
五、Python 中的实现
LangChain 在 Python 侧的能力和 JS 完全对齐,只是 schema 的载体换成了 Pydantic(对应 JS 里的 Zod)。上面讲的 with_structured_output 和 Agent 的 response_format 都有对应写法。
with_structured_output
用 Pydantic 的 BaseModel 定义字段,Field(description=...) 相当于 JS 里的 .describe():
from pydantic import BaseModel, Field
from langchain.chat_models import init_chat_model
class Contact(BaseModel):
name: str = Field(description="人名")
age: int = Field(description="年龄(整数)")
city: str = Field(description="所在城市")
model = init_chat_model("deepseek:deepseek-chat", temperature=0)
structured = model.with_structured_output(Contact)
result = structured.invoke("从这句话提取信息:张三,今年 30 岁,住在上海。")
print(result) # name='张三' age=30 city='上海'(Pydantic 实例,不是 dict)
print(result.age) # 30
返回值是一个校验过的 Pydantic 实例,字段缺失或类型不对会直接抛 ValidationError——这就是客户端校验那一层。想要原始消息可以加 include_raw=True:
structured = model.with_structured_output(Contact, include_raw=True)
out = structured.invoke("...")
print(out["parsed"]) # Contact 实例
print(out["raw"]) # 原始 AIMessage
Agent 中的结构化输出
v1 的 create_agent 用 response_format 声明输出形状,结果放在 structured_response:
from langchain.agents import create_agent
agent = create_agent(
model="deepseek:deepseek-chat",
tools=[],
response_format=Contact, # 直接给 Pydantic 模型即可
)
res = agent.invoke({"messages": [{"role": "user", "content": "从这句话提取信息:张三,今年 30 岁,住在上海。"}]})
print(res["structured_response"]) # Contact(name='张三', age=30, city='上海')
四种 schema 载体
Python 侧 with_structured_output / response_format 接受多种 schema,和 JS 里的 Zod / JSON Schema 一一对应:
| Python 载体 | 运行时校验 | 说明 |
|---|---|---|
Pydantic BaseModel | ✅ | 首选,定义 + 校验 + 自动出 JSON Schema |
TypedDict | ❌ | 只有类型注解,返回普通 dict,不做校验 |
@dataclass | ❌(默认) | 结构清晰但不强制类型,可用 Pydantic 包装补齐校验 |
| JSON Schema(dict) | ❌ | 最底层,直接就是发给模型的「线格式」,可动态拼装 |
和 JS 的对应关系:Pydantic ↔ Zod,TypedDict ↔ TS interface(都只是编译/静态期类型),JSON Schema dict 两边一致,@dataclass 在 JS 里没有原生对应、实践中用 Zod 顶替。
六、小结与选型
老办法:提示词「求」模型给 JSON + 手动 JSON.parse,脆弱、无校验、无类型。
新办法:把 schema 交给模型作为约束,自动解析 + 校验,得到类型安全的对象。
按场景选:
- 单独调用模型 →
model.withStructuredOutput(schema); - Agent + 结构化输出 →
createAgent({ ..., responseFormat: toolStrategy(schema) }); - 需要厂商原生严格约束 →
method: "jsonSchema"或providerStrategy(schema)(需确认模型支持)。
一句话总结:从「让模型尽量按格式返回」,变成「用 schema 把格式固定下来,并自动校验」。 这既让代码不再依赖脆弱的正则清理,也让「模型输出」和「程序消费」之间有了可靠的契约。