如何让大模型输出结构化 JSON 以及调用外部工具 —— 基于 LangChain 的完整实战指南
文章深入剖析 main.js 与 index.js 代码,展示如何利用 JsonOutputParser 与 zod Schema 确保 LLM 输出格式正确,并通过 tool() 和 bindTools() 赋予模型调用外部函数(如查天气、加法)的能力,构建可靠 AI 应用。
引言
“大模型很聪明,但它太自由了。”
它会给你一段散文式的回答,而不是程序能直接处理的 JSON;
它知道 3+5=8,但不会主动调用加法函数;
它听说过北京天气热,却不能实时查询数据库。本文将带你深入剖析两套核心机制:
1. 如何强制 LLM 输出严格符合 Schema 的 JSON(来自main.js+readme.md)
2. 如何赋予 LLM 调用外部函数的能力,让它从“聊天者”变成“执行者”(来自index.js+readme1.md)所有代码逐行解读,API 调用细节拉满,绝不省略任何关键步骤!
第一部分:当 LLM 不按格式输出时——我们如何“驯服”它返回 JSON?
背景问题:LLM 的输出为何不可靠?
langchain outputParser有时候,llm 生成的内容不是我们期望的格式(json),我们需要对其进行解析。
这是构建生产级 AI 应用的最大痛点之一。例如,你问模型:“请用 JSON 描述 Promise”,它可能返回:
好的!Promise 是 JavaScript 中用于处理异步操作的对象。它的核心是……(一大段文字)
{
"name": "Promise",
...
}
或者干脆漏掉某个字段、拼错 key、返回非数组的 useCase……这些都会导致 JSON.parse() 报错,程序崩溃。
解决方案:使用 LangChain 的 JsonOutputParser + zod Schema 校验,配合强约束提示词,形成“三位一体”的结构化输出保障体系。
下面我们逐行拆解 main.js。
源代码链接:ai/langchain/output/main.js · Zou/lesson_zp - 码云 - 开源中国
Step 1:导入所有必要模块
// 从 @langchain/deepseek 模块导入 ChatDeepSeek 类,用于调用 DeepSeek 的大语言模型
import { ChatDeepSeek } from '@langchain/deepseek'
// 从 @langchain/core/prompts 模块导入 PromptTemplate 类,用于构建结构化提示词模板
import { PromptTemplate } from '@langchain/core/prompts'
// 从 @langchain/core/output_parsers 模块导入 JsonOutputParser 类,用于将模型输出解析并校验为 JSON 格式
import { JsonOutputParser } from '@langchain/core/output_parsers'
// zod 是一个类型校验库,用于定义和校验本文件中前端概念对象的字段是否符合预设结构
import { z } from 'zod'
// 加载 .env 文件中的环境变量(如 API 密钥等),使其可在 process.env 中使用
import 'dotenv/config'
逐个解析:
ChatDeepSeek:LangChain 对 DeepSeek 模型的封装。支持deepseek-chat(对话)和deepseek-reasoner(推理)两种模式。此处选用后者,因其更擅长逻辑与结构化任务。PromptTemplate:允许你定义带占位符(如{topic})的模板,避免手动拼接字符串,提升可维护性。JsonOutputParser:核心组件!它不仅能从模型输出中提取 JSON,还能结合zodSchema 进行运行时校验。zod:TypeScript 社区广泛采用的 schema validation 库。比Joi更轻量,与 TS 类型推导完美集成。dotenv/config:自动加载项目根目录的.env文件,将DEEPSEEK_API_KEY注入process.env,避免硬编码密钥。
💡 注意:项目需在
package.json中设置"type": "module",否则import语法会报错
Step 2:初始化模型实例
// 创建一个 ChatDeepSeek 模型实例,指定使用 'deepseek-reasoner' 模型,并设置 temperature=0(使输出更确定、更少随机性)
const model = new ChatDeepSeek({ model: 'deepseek-reasoner', temperature: 0,});
model: 'deepseek-reasoner':专为复杂推理设计,比deepseek-chat更适合结构化任务。temperature: 0:关闭随机性,确保相同输入永远得到相同输出——这对自动化流程至关重要。
Step 3:用 Zod 定义期望的 JSON 结构(Schema)
// 使用 zod 定义前端概念的数据结构 Schema,用于后续校验模型输出是否符合规范:
// - name:字符串类型,表示概念名称
// - core:字符串类型,表示该概念的核心要点
// - useCase:字符串数组类型,表示常见使用场景
// - difficulty:枚举类型,只能是 '简单'、'中等' 或 '复杂',表示学习难度
const FrontendConceptSchema = z.object({
name: z.string().describe("概念名称"),
core: z.string().describe("核心要点"),
useCase: z.array(z.string()).describe("常见使用场景"),
difficulty: z.enum(['简单', '中等', '复杂']).describe("学习难度")
})
深度解析每个字段:
z.object({}):定义一个对象 Schema。z.string():要求值必须是字符串。.describe()添加人类可读描述,也会被JsonOutputParser用于生成格式指令。z.array(z.string()):要求是一个字符串数组,如["处理 AJAX", "避免回调地狱"]。z.enum(['简单', '中等', '复杂']):强约束枚举!模型若返回“难”或“easy”,校验将失败。
✅ 这个 Schema 同时具备:
- 运行时校验能力(防止非法数据进入系统)
- 类型推导能力(TS 可自动推断
response的类型)- 文档生成能力(通过
.describe()生成清晰的格式说明)
Step 4:创建 JsonOutputParser 实例
// 创建一个 JsonOutputParser 实例,并传入上面定义的 FrontendConceptSchema,
// 使得解析器在解析模型输出时自动校验是否符合该 Schema
const jsonParser = new JsonOutputParser(FrontendConceptSchema);
JsonOutputParser内部会:- 尝试从模型输出中提取 JSON(即使前后有无关文本);
- 用
FrontendConceptSchema.parse()校验; - 若失败,抛出
ZodError(可被捕获并重试); - 若成功,返回 TypeScript 类型安全的对象。
Step 5:构造“高压”提示词模板
// 使用 PromptTemplate.fromTemplate 创建一个提示词模板:
// - 明确告知模型它是一个“只输出 JSON 的 API”,禁止任何额外解释
// - 强调输出必须严格符合指定 Schema(字段名、数量、类型都不能变)
// - 要求输出能被 JSON.parse 成功解析(即格式合法)
// - 占位符 {format_instructions} 将由 jsonParser 动态填充(说明期望的 JSON 格式)
// - 占位符 {topic} 将由用户传入(例如 'Promise')
const prompt = PromptTemplate.fromTemplate(`
你是一个只会输出 JSON 的 API,不允许输出任何解释性文字。
⚠️ 你必须【只返回】符合以下 Schema 的 JSON:
- 不允许增加字段
- 不允许减少字段
- 字段名必须完全一致,使用name、core、useCase、difficulty
- 返回结果必须可以被 JSON.parse 成功解析
{format_instructions}
前端概念:{topic}
`);
关键机制:{format_instructions}
调用 jsonParser.getFormatInstructions() 会动态生成如下内容(示例):
The output should be a markdown code snippet formatted in the following schema:
```json
{
"name": string // 概念名称
"core": string // 核心要点
"useCase": string[] // 常见使用场景
"difficulty": "简单" | "中等" | "复杂" // 学习难度
}
这相当于给模型一张“答题卡模板”,极大提升合规率。
📌 注意:提示词中明确禁止“任何解释性文字”,这是防止模型“画蛇添足”的关键。
Step 6:组装处理链(Chain)
// 构建 LangChain 的 chain:将提示词模板 → 模型 → JSON 解析器串联起来
// 调用 chain.invoke 时,会先渲染提示词,再送入模型,最后用 jsonParser 验证并解析输出
const chain = prompt.pipe(model).pipe(jsonParser)
.pipe()是 LangChain 的核心抽象,实现数据流式处理:prompt接收{topic, format_instructions}→ 生成完整提示词;model接收提示词 → 返回模型原始响应;jsonParser接收原始响应 → 提取并校验 JSON → 返回干净对象。
✨ 这种链式设计让代码高度可组合、可测试、可复用。
Step 7:执行并验证结果
// 打印 jsonParser 自动生成的格式指令(即模型应遵循的 JSON Schema 描述),用于调试或理解
console.log(jsonParser.getFormatInstructions(), 'zod');
// 异步调用 chain,传入具体主题 'Promise' 和格式指令
// 模型将根据提示生成严格符合 FrontendConceptSchema 的 JSON 对象
const response = await chain.invoke({
topic: 'Promise',
format_instructions: jsonParser.getFormatInstructions(),
});
// 打印最终解析并校验通过的响应结果
console.log(response);
// 输出结果如下:
// zod
// {
// name: 'Promise',
// core: 'Promise 是 JavaScript 中处理异步操作的核心对象,它表示一个异步操作的最终完成或失败,并返回其结果值。',
// useCase: '常见的用例包括处理 AJAX 请求、setTimeout 异步操作、避免回调地狱(Callback Hell),以及使用 async/await 语法糖进行更清晰的异步编程。',
// difficulty: '中等'
// }
✅ 最终 response 是一个纯 JavaScript 对象,字段齐全、类型正确、可直接用于:
- 前端渲染(如卡片展示)
- 数据库存储
- API 响应体
- 下游任务输入
第二部分:让大模型“动手干活”——调用外部工具(Functions)
背景理念:从“聊天”到“行动”
Tools 模块 Tools 让大模型具有调用外部工具的能力(函数)不只是聊天。申明函数 tools,LLM 负责决定要不要调用这个工具,哪个工具。
这意味着:模型成为“决策者”,程序成为“执行者”。模型不再需要知道天气数据在哪,只需说“查北京天气”,程序就去查。
下面我们逐行分析 index.js。
源代码链接:ai/langchain/tools/index.js · Zou/lesson_zp - 码云 - 开源中国
Step 1:导入工具相关模块
import { ChatDeepSeek } from "@langchain/deepseek";// 从 @langchain/deepseek 包中导入 ChatDeepSeek 类,用于与 DeepSeek 的聊天模型进行交互
import 'dotenv/config';// 加载项目根目录下的 .env 文件中的环境变量(如 API 密钥)到 process.env 中,便于安全配置
import { tool } from "@langchain/core/tools";// 从 LangChain 核心工具模块导入 tool 函数,用于将普通函数封装成大模型可调用的工具
import { z } from "zod"; // 用于定义工具的输入参数的类型\
tool:核心函数!将普通 JS 函数包装成 LLM 可识别的“工具”。- 其他同前。
Step 2:准备模拟数据源
// 定义一个模拟的天气数据库对象,键为城市名,值为包含温度、天气状况和风力的对象
const fakeWeatherDB = {
北京: { temp: "30°C", condition: "晴", wind: "微风" },
上海: { temp: "28°C", condition: "多云", wind: "东风 3 级" },
广州: { temp: "32°C", condition: "阵雨", wind: "南风 2 级" },
};
🌦️ 实际项目中,这里可替换为
axios.get('https://api.weather.com/...')。
Step 3:定义第一个工具 —— 天气查询
// 使用 tool 函数创建一个名为 weatherTool 的工具函数,该函数接收一个包含 city 参数的对象作为输入
const weatherTool = tool(
// 异步函数:根据传入的城市名称查询 fakeWeatherDB 中的天气信息
async ({ city }) => {
// 从 fakeWeatherDB 中获取对应城市的天气数据
const weather = fakeWeatherDB[city];
// 如果数据库中没有该城市的天气信息,则返回提示字符串
if (!weather) {
return `暂无${city}的天气信息`
}
// 如果有该城市的天气信息,则格式化并返回完整的天气描述字符串
return `当前${city}的天气是${weather.temp}, ${weather.condition}, 风力${weather.wind}`
},
// 工具的元数据配置对象,用于描述该工具的功能、名称和输入参数结构
{
name: "get_weather", // 工具的唯一标识名称
description: "查询指定城市的今日天气情况", // 工具的功能描述
schema: z.object({ // 使用 Zod 库定义输入参数的校验结构
city: z.string().describe("要查询天气的城市") // city 参数必须是字符串,并附带描述说明
})
}
)
深度解析 tool() 的两个参数:
-
执行函数(Function):
- 接收一个对象
{ city }(由模型生成并传入); - 必须是
async(即使内部是同步操作),以统一接口; - 返回字符串(工具调用结果,将作为模型下一步的输入)。
- 接收一个对象
-
元数据(Metadata):
name: 必须全局唯一,模型通过此名称“点名”调用;description: 用自然语言描述功能,帮助模型理解何时调用;schema: 用zod定义输入参数结构,LangChain 会将其转换为 OpenAI-style function calling spec。
💡 注意:即使工具返回的是字符串,LangChain 也会将其作为“观察结果(observation)”反馈给模型,模型可据此生成最终回答。
Step 4:定义第二个工具 —— 加法计算
// 函数 定义一个加法工具
const addTool = tool(
// 两个参数
// 等下大模型来调用
// 参数 对象 解构a b
async ({a, b}) => String(a + b),
// 工具的执行函数:接收一个包含 a 和 b 属性的对象,异步返回它们的和(转换为字符串格式)
{
name: "add",// 工具的唯一标识名称,供大模型在需要时引用
description: "计算两个数字的和",// 工具的功能描述,帮助大模型理解何时应调用此工具
// 参数schema
// 定义工具的输入参数的验证规则,确保传入的参数符合预期格式
// 要求传入的对象必须包含两个 number 类型的字段 a 和 b 严谨性
schema: z.object({
a: z.number(),
b: z.number()
})// 使用 Zod 定义输入参数的结构:要求传入的对象必须包含两个 number 类型的字段 a 和 b
}
)
- 此处强调
a和b必须是number,防止模型传入"3"和"5"导致字符串拼接。 - 返回
String(a + b)是因为工具调用结果通常作为文本上下文传回模型。
Step 5:将工具绑定到模型
const model = new ChatDeepSeek({
model: "deepseek-chat",// 指定要使用的 DeepSeek 模型名称为 "deepseek-chat"
temperature: 0// 设置生成随机性为 0,使输出完全确定,适用于需要精确、可重复结果的场景
}).bindTools([addTool, weatherTool]);// 将 addTool 工具绑定到模型实例,使模型在推理过程中能够识别并选择调用该工具
.bindTools([tool1, tool2])是关键!- 它将工具的
name、description、parameters(由schema转换而来)注入模型的上下文; - 模型在生成响应时,会判断是否需要调用工具;
- 如果需要,返回一个特殊的
tool_calls消息,而非普通文本。
- 它将工具的
📌 注意:此处使用
deepseek-chat而非reasoner,因为工具调用更偏向对话场景。
Step 6:用户提问 & 获取模型决策
// const res = await model.invoke("3 + 5等于多少?");
// 向模型发送用户提问 "3 + 5等于多少?",并等待其响应;模型可能返回一个包含工具调用请求的消息
const res = await model.invoke("北京今天的天气怎么样?");
// 向模型发送用户提问 "北京今天的天气怎么样?",并等待其响应;模型可能返回一个包含工具调用请求的消息
此时 res 不是最终答案,而是一个包含 tool_calls 的消息对象,例如:
{
content: "",
tool_calls: [
{
name: "get_weather",
args: { city: "北京" },
id: "call_abc123"
}
]
}
✅ 模型成功识别出“需要调用 get_weather 工具,并传入 city='北京'”。
Step 7:执行工具并获取结果
// 可选链运算符(?.) es6 新增 代码的简洁和优雅
// if (res.tool_calls) {if (res.tool_calls.length) {} }
if(res.tool_calls?.length) {
// 使用可选链操作符(?.)安全地检查 res 是否包含 tool_calls 属性,且其长度大于 0
// 若条件成立,说明模型决定调用某个工具来回答问题
// console.log(res.tool_calls[0]);
if (res.tool_calls[0].name === "add") {
// 检查第一个被调用的工具名称是否为 "add"
const result = await addTool.invoke(res.tool_calls[0].args);
// 调用 addTool 工具,并传入模型生成的参数(res.tool_calls[0].args),获取执行结果
console.log("最终结果:", result);
// 打印工具执行后的最终结果(例如 "8")
} else if (res.tool_calls[0].name === "get_weather") {
// 检查第一个被调用的工具名称是否为 "get_weather"
const result = await weatherTool.invoke(res.tool_calls[0].args);
// 调用 weatherTool 工具,并传入模型生成的参数(res.tool_calls[0].args),获取执行结果
console.log("最终结果:", result);
// 打印工具执行后的最终结果(例如 "北京的天气是30°C, 晴, 风力微风")
}
}
关键点:
res.tool_calls?.length:使用 可选链(Optional Chaining) 安全访问,避免Cannot read property 'length' of undefined。tool.invoke(args):执行工具函数,传入模型生成的参数(已通过schema校验,类型安全)。- 最终
result是工具返回的字符串,可直接打印或作为上下文再次送入模型生成自然语言回答。
🔄 完整 Agent 循环应为:用户问 → 模型决定调用工具 → 程序执行工具 → 将结果作为“观察”送回模型 → 模型生成最终回答。当前代码省略了最后一步,但原理相同。
彩蛋:回顾 JavaScript 的“史前时代”
在 a.js 中,我们看到经典原型写法:
function Person(name, age) {
this.name = name
this.age = age
}
Person.prototype.sayName = function () {
console.log(this.name)
}
这正是 ES6 之前的状态:
- 没有
class - 没有
import/export - 模块靠
<script>标签顺序管理 - 命名空间靠
IIFE或全局变量
而今天我们用:
- ESM 模块(
import/export) - Zod 类型安全
- LangChain 高级抽象
- DeepSeek 大模型
技术演进之快,令人感慨!
总结:两大能力,构建可靠 AI 应用的基石
| 能力 | 核心问题 | 解决方案 | 关键 API |
|---|---|---|---|
| 结构化输出 | LLM 输出格式不可控 | 强约束提示词 + Schema 校验 + 自动解析 | JsonOutputParser, zod, PromptTemplate |
| 工具调用 | LLM 无法执行外部操作 | 声明工具 + 绑定模型 + 执行分发 | tool(), .bindTools(), tool.invoke() |
两者共同目标:将 LLM 从“不可靠的文本生成器”转变为“可靠的智能代理(Agent)”。
🚀 未来,你可以:
- 让模型调用数据库查询用户订单;
- 让模型调用支付 API 完成交易;
- 让模型调用爬虫获取最新新闻;
- 所有这些,都建立在今天所学的基础之上。
现在,你已经掌握了让 AI “听话”又“能干” 的全部秘密。
更多推荐



所有评论(0)