引言

“大模型很聪明,但它太自由了。”
它会给你一段散文式的回答,而不是程序能直接处理的 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,还能结合 zod Schema 进行运行时校验。
  • 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 内部会:
    1. 尝试从模型输出中提取 JSON(即使前后有无关文本);
    2. FrontendConceptSchema.parse() 校验;
    3. 若失败,抛出 ZodError(可被捕获并重试);
    4. 若成功,返回 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() 的两个参数:
  1. 执行函数(Function)

    • 接收一个对象 { city }(由模型生成并传入);
    • 必须是 async(即使内部是同步操作),以统一接口;
    • 返回字符串(工具调用结果,将作为模型下一步的输入)。
  2. 元数据(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
  }
)
  • 此处强调 ab 必须是 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]) 是关键!
    • 它将工具的 namedescriptionparameters(由 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 “听话”又“能干” 的全部秘密。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐