深入浅出 LangChain —— 第三章:模型抽象层与消息的输入输出

216 阅读3分钟

📖 本章学习目标

  • ✅ 理解模型抽象层的设计理念和核心价值
  • ✅ 掌握两种模型配置方式及其适用场景
  • ✅ 接入主流 LLM Provider(OpenAI/Anthropic/Google/Qwen/Ollama)
  • ✅ 理解消息格式和多轮对话的工作原理
  • ✅ 实现流式输出提升用户体验
  • ✅ 使用结构化输出获取类型安全的数据
  • ✅ 根据业务场景选择合适的模型

开始正文之前,我们先对现有的demo项目做一些调整,方便后续测试。目前项目是运行pnpm dev时启动src/index.ts程序,需要改造成运行pnpm dev xxx就运行src/xxx.ts这样的形式。

(1)在项目下创建scripts/dev.ts文件,定义运行的脚本,内容如下:

import { execSync } from 'child_process';

// 获取命令行参数(跳过 node 和脚本路径)
const args = process.argv.slice(2);

// 第一个参数作为文件名,默认为 'index'
const fileName = args[0] || 'index';

// 构建要执行的文件路径
const filePath = `src/${fileName}.ts`;

console.log(`Running: tsx ${filePath}`);

try {
  // 执行 tsx 命令,继承 stdio 以显示输出
  execSync(`tsx ${filePath}`, {
    stdio: 'inherit',
  });
} catch (error) {
  // 如果执行失败,退出码传递给父进程
  process.exit(1);
}

(2)调整packpage.jsontsconfig.json的内容

// package.json

{
   "scripts": {
      "dev": "tsx scripts/dev.ts", // 调整这行
    }
}

// tsconfig.json
{
  "compilerOptions": {
    // ...
    "rootDir": "./",           // 源代码目录
    "types": ["node"]             // 包含 Node.js 类型定义
  },
  "include": ["src/**/*", "scripts/**/*"]  // 增加包含的文件
}

以上调整后就可以运行pnpm dev xxx这样的命令了,不传xxx时默认为index


一、为什么需要模型抽象层

市场有各种各样的模型和模型提供商,这意味着面对不同的生产需求,我们对模型的选择也是各式各样的。放到实际开发中,你可能会遇到因为要接入不同的模型或者切换模型带来的困境,比如你的团队正在用 OpenAI 的 GPT-4o 开发一个 AI 助手。突然有一天:

  • OpenAI 涨价了 50%
  • 或者某个功能在 Claude 上表现更好
  • 某个模型因为各种原因不给使用了
  • 或者公司要求数据不能出境,必须用本地模型

在这种场景下,如果没有抽象层,你可能需要重写大量代码来切换模型。这就是 LangChain.js 模型抽象层要解决的问题。

1、传统方式的痛点

如果直接使用各家的原生 SDK,代码会是这样:

// ❌ 直接绑定 OpenAI SDK
import OpenAI from "openai";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const response = await openai.chat.completions.create({
  model: "gpt-4o",
  messages: [{ role: "user", content: "你好" }]
});

想切换到 Anthropic?需要对代码进行重写:

// ❌ 切换到 Anthropic,API 完全不同
import Anthropic from "@anthropic-ai/sdk";
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
const response = await anthropic.messages.create({
  model: "claude-3-opus",
  messages: [{ role: "user", content: "你好" }]
});

之所以需要对代码进行重写,主要是因为不同的模型在API方面的设计是不一致的,包括但不限于:

  • API 格式不同(参数名、返回结构都不一样)
  • 认证方式不同
  • 错误处理逻辑不同
  • 流式输出的实现方式不同

2、LangChain.js 的解决方案

LangChain.js 提供统一的接口层,无论底层用什么模型,调用方式完全一致:

flowchart TB
    App["你的应用代码<br/>createAgent({ model: '...' })"]

    subgraph LangChain["LangChain.js 抽象层"]
        direction LR
        Interface["统一接口<br/>ChatModel"]
    end

    subgraph Providers["各 Provider SDK"]
        direction LR
        OAI["@langchain/openai<br/>GPT-4o, o1..."]
        ANT["@langchain/anthropic<br/>Claude Opus/Sonnet..."]
        GGL["@langchain/google-genai<br/>Gemini 2.0..."]
        OLL["@langchain/ollama<br/>本地模型"]
    end

    App --> Interface
    Interface --> OAI
    Interface --> ANT
    Interface --> GGL
    Interface --> OLL

    style App fill:#e8f4fd,stroke:#1890ff
    style Interface fill:#f6ffed,stroke:#52c41a,stroke-width:3px

核心优势:

优势说明实际价值
快速切换在不同模型间做对比测试,只需改一个字符串节省数小时的重构时间
避免供应商锁定即使某个 Provider 涨价或出故障,迁移成本极低降低商业风险
统一的流式接口不用为每个 Provider 写不同的解析逻辑代码复用率提升 80%+
统一的错误处理所有 Provider 的错误格式标准化简化异常处理逻辑

二、两种模型配置方式

LangChain.js 支持两种方式配置模型,分别适用于不同场景。

1、字符串标识符(推荐用于大多数场景)

在 v1.x 中,最简洁的方式是直接传 "provider:model-name" 格式的字符串,我们可以根据不同的场景创建不同模型的agent,在需要的时候选择合适的agent即可。对于模型的输入、输出和错误处理等都是一致的。

(1)OpenAI 系列

import { createAgent } from "langchain";

// GPT-4o:综合能力强,适合复杂任务
const agent1 = createAgent({ model: "openai:gpt-4o" });

// GPT-4o-mini:性价比高,适合简单任务
const agent2 = createAgent({ model: "openai:gpt-4o-mini" });

// o1-mini:专门优化的推理模型,适合数学和逻辑
const agent3 = createAgent({ model: "openai:o1-mini" });

(2)Anthropic 系列

// Claude Opus:最强推理能力,适合复杂分析
const agent4 = createAgent({ model: "anthropic:claude-opus-4-5" });

// Claude Sonnet:平衡性能和成本
const agent5 = createAgent({ model: "anthropic:claude-sonnet-4-6" });

// Claude Haiku:速度最快,成本最低
const agent6 = createAgent({ model: "anthropic:claude-haiku-3-5" });

(3)Google 系列

// Gemini 2.0 Flash:免费额度高,响应速度快
const agent7 = createAgent({ model: "google:gemini-2.0-flash" });

(4)本地模型(Ollama)

// Llama 3.2:Meta 开源模型,可在本地运行
const agent8 = createAgent({ model: "ollama:llama3.2" });

这种配置方式的优势是非常明显的,代码极其简洁,你只需要写一行配置即可。LangChain会自动处理认证和配置 这在快速原型开发和大多数简单的生产场景下非常适合。

2、显式实例化(需要精细配置时)

当你需要配置模型的温度、超时、最大 Token 等参数时,直接实例化对应的 ChatModel 类:

import { ChatOpenAI } from "@langchain/openai";
import { createAgent } from "langchain";

// 精细配置 OpenAI 模型
const model = new ChatOpenAI({
  model: "gpt-4o",       // 模型名称
  temperature: 0.1,      // 输出随机性(0: 最确定,1: 最随机)
  maxTokens: 2000,       // 限制输出长度
  timeout: 30000,        // 超时时间(毫秒)
  maxRetries: 3,         // 失败自动重试次数
});

const agent = createAgent({ model, tools: [] });

常用配置参数详解:

参数说明建议值影响
temperature控制输出随机性工具调用:0 ~ 0.2
创意写作:0.7 ~ 1.0
值越高,输出越有创造性但也越不可控
maxTokens最大输出 Token 数根据任务需求设置限制输出长度,控制成本
timeout单次调用超时时间30000~60000 ms防止网络问题导致长时间挂起
maxRetries失败自动重试次数2~3 次处理网络抖动和临时故障
topP核采样参数0.8~0.95与 temperature 配合控制多样性

与第一种配置的方式相比,这种配置方式会多出来很多样板代码,但它的最大优势是灵活可控,你拥有对模型参数的控制权,也能规避长期迭代中因框架底层自动转换逻辑变更而带来的潜在风险。

3. 两种配置方式的选择策略

第一种配置方式本质上是一种高度封装的语法糖,目的是简化开发者的配置流程。当你直接传入一个格式为 "提供商:模型名称"(例如 "openai:gpt-4o")的字符串时,LangChain 内部会调用统一入口函数,解析这个字符串中的提供商和模型名,自动加载对应的依赖,处理 API 密钥等认证配置,并最终在后台默默帮你实例化出对应的原生模型类(如 ChatOpenAIChatAnthropic 等)。所以它的背后还是使用对应的ChatModel类来实例化的。

然而,字符串配置方式主要支持 LangChain 官方深度集成且生态成熟的主流大模型提供商。比如OpenAI系列、Google系列、Anthropic系列以及本地大模型。对于许多第三方或国产模型(如通义千问 、DeepSeek  等)虽然可以通过 LangChain 调用,但往往不在自动识别的字符串白名单中,或者其 API 密钥、基础地址等认证方式较为特殊,需要手动引入对应的类(如果兼容OpenAI格式可以使用ChatOpenAI)并传入特定参数实例化,或者使用社区封装好的库。

在配置方式的选择上,如果你想快速验证或实现,那么使用字符串的方式无疑是最快捷方便的,但如果你有以下的需求,那么就强烈建议使用显式实例化的方式:

  • 需要精确控制模型参数,比如温度(如结构化输出时必须用低温度)、自定义超时和重试策略、使用 Provider 特有的高级参数
  • 需要在运行时动态调整配置
  • 保证代码的明确性和可拓展性
  • 项目需要长期迭代和维护,需要避免因框架底层自动转换逻辑变更而带来的潜在风险

实际上,企业级项目或复杂业务,基本都需要采用显示实例化配置的方式。

三、主流 Provider 接入详解

1、OpenAI(GPT 系列)

OpenAI 是最成熟的 LLM Provider,工具调用能力最强。

(1)安装

pnpm add @langchain/openai

(2)基础用法

import { ChatOpenAI } from "@langchain/openai";

// 创建模型实例
const model = new ChatOpenAI({
  model: "gpt-4o",
  temperature: 0,  // 确定性输出
});

// 直接调用模型(不经过 Agent)
const response = await model.invoke([
  { role: "system", content: "你是一个有帮助的助手。" },
  { role: "user", content: "用一句话解释什么是递归。" },
]);

console.log(response.content);
// 输出:递归是函数调用自身来解决更小规模同类问题的编程技巧。

(3)OpenAI 特有参数

const model = new ChatOpenAI({
  model: "gpt-4o",
  
  // 强制开启 JSON 输出模式
  // 注意:这只是保证输出是合法 JSON,不保证字段结构
  responseFormat: { type: "json_object" },
  
  // 指定使用的 API Base URL
  // 适用于代理服务器或兼容 OpenAI 接口的服务
  configuration: {
    baseURL: "https://your-proxy.com/v1",
  },
});

⚠️ 注意responseFormat: { type: "json_object" } 只能保证输出是合法的 JSON,但不能保证字段名称和类型符合预期。如果需要严格的结构化输出,应该使用后面介绍的 withStructuredOutput() 方法。

2、Anthropic(Claude 系列)

Claude 系列在长文本处理和复杂推理方面表现优秀。

(1)安装

pnpm add @langchain/anthropic

(2)基础用法

import { ChatAnthropic } from "@langchain/anthropic";

const model = new ChatAnthropic({
  model: "claude-opus-4-5",  // opus模型
  temperature: 0,
  maxTokens: 4096,           // Claude 需要显式指定 maxTokens
});

const response = await model.invoke([
  { role: "user", content: "用一句话解释什么是递归。" },
]);

console.log(response.content);

Claude 的特点:

特点说明适用场景
超长上下文Opus/Sonnet 支持 200K tokens处理整本书、大型代码库
优秀的代码理解对代码结构和逻辑把握准确代码审查、重构建议
复杂的推理能力在多步推理任务中表现出色数据分析、逻辑推导
自然的对话风格回答更加流畅和人性化客服、教育应用

💡 重要提示:Anthropic 的 API 要求必须设置 maxTokens 参数,否则会报错。这与 OpenAI 不同(OpenAI 有默认值)。

3、Google(Gemini 系列)

Gemini 的优势是免费额度高,多模态能力强。

(1)安装

pnpm add @langchain/google-genai

(2)基础用法

import { ChatGoogleGenerativeAI } from "@langchain/google-genai";

const model = new ChatGoogleGenerativeAI({
  model: "gemini-2.0-flash",
  temperature: 0,
});

const response = await model.invoke([
  { role: "user", content: "介绍一下你自己。" },
]);

console.log(response.content);

Gemini 的优势:

  • 每月有大量免费额度(适合个人开发者和小团队)
  • 原生支持图像、音频等多模态输入
  • 响应速度快

4. 其它的模型

以千问模型为例,我们一般使用兼容OpenAI的API方式来创建模型实例:

(1)安装

pnpm add @langchain/openai

(2)基础用法

import { ChatOpenAI } from "@langchain/openai";

// 创建模型实例
const model = new ChatOpenAI("qwen-max",{
  apiKey: process.env.QWEN_API_KEY, 
  configuration: {
    baseURL: process.env.QWEN_API_URL,
  }
});

// 直接调用模型(不经过 Agent)
const response = await model.invoke([
  { role: "system", content: "你是一个有帮助的助手。" },
  { role: "user", content: "用一句话解释什么是递归。" },
]);

console.log(response.content);

5、Ollama(本地模型)

Ollama 允许你在本地运行开源模型,数据不离开你的机器。

(1)安装 Ollama

# macOS/Linux
curl -fsSL https://ollama.ai/install.sh | sh

# Windows
# 从 https://ollama.ai/download 下载安装包

(2)下载并运行模型

# 下载 Llama 3.2 模型(约 2GB)
ollama pull llama3.2

# 测试运行
ollama run llama3.2

# 查看已安装的模型
ollama list

(3)安装 LangChain 集成

pnpm add @langchain/ollama

(4)使用本地模型

import { ChatOllama } from "@langchain/ollama";

const model = new ChatOllama({
  model: "llama3.2",
  baseUrl: "http://localhost:11434",  // Ollama 默认地址
  temperature: 0,
});

const response = await model.invoke([
  { role: "user", content: "你好!" },
]);

console.log(response.content);

使用本地模型通常是基于数据隐私、成本方面的考虑,一般有以下优势:

  • 数据隐私:敏感数据不会发送到外部 API
  • 零成本:本地运行完全免费(指的是Token费用)
  • 离线可用:不需要网络连接即可在本地使用

但是本地模型的劣势也是比较明显的,比如:

  • 硬件要求高:需要较好的 GPU(至少 8GB 显存)
  • 推理速度慢:比 API 慢 5-10 倍甚至更多
  • 质量稍逊:开源模型在复杂推理上通常不如 GPT-4o 或 Claude等商业模型

典型场景:

  • 企业内部文档问答(数据敏感)
  • 开发阶段高频测试(控制成本)
  • 边缘设备部署(无网络环境)

四、消息格式:理解多角色对话

LangChain.js 的消息系统与 OpenAI 的 Chat Completion 格式对齐。理解消息类型对于写好 Prompt 至关重要。

1、四种消息角色

flowchart LR
    S["system<br/>系统消息<br/>设定角色和规则"] --> H
    H["human<br/>用户消息<br/>用户的输入"] --> A
    A["tool<br/>工具返回的信息"]-->H2
    H2["assistant<br/>助手消息<br/>模型的历史回复"]
    H-->H2

    style S fill:#e8f4fd,stroke:#1890ff
    style H fill:#f6ffed,stroke:#52c41a
    style A fill:#fff7e6,stroke:#fa8c16
    style H2 fill:#f6ffed,stroke:#52c41a
消息类型角色用途示例
SystemMessagesystem设定模型的角色、行为规范、背景知识"你是一个专业的 Python 程序员"
HumanMessagehuman/user用户的输入"如何排序一个列表?"
AIMessageassistant模型的历史回复(多轮对话中用到)"你可以使用 sorted() 函数..."
ToolMessagetool工具调用的返回结果(Agent 内部使用)"{result: [1,2,3]}"

2、构建多轮对话

多轮对话能让 AI 在连续的交流中,记住你们之前说过的话,理解上下文的关联,从而做出连贯、自然的回应,而不是每一句话都当成全新的问题来处理。

但大模型本身其实是“无状态”的,它不会自己偷偷记住你上一秒说了什么。所谓的“记忆”,其实是我们在每次向 AI 发起新提问时,把之前的对话历史(包括你的提问和 AI 的回答)打包,连同新问题一起发给它

在代码层面,构建多轮对话的本质就是维护一个按时间顺序排列的消息列表

import agent from './agents'
import { HumanMessage, AIMessage, SystemMessage } from "@langchain/core/messages";

// 构建多轮对话历史
const messages = [
  // 第一步:系统消息(设定角色)
  new SystemMessage("你是一个专业的 TypeScript 开发助手。"),
  
  // 第二步:第一轮对话
  new HumanMessage("什么是泛型?"),
  new AIMessage("泛型是 TypeScript 中允许类型作为参数的机制,可以编写适用于多种类型的通用代码。"),
  
  // 第三步:当前问题(基于上下文的追问)
  new HumanMessage("能给一个实际的例子吗?"),
];

const response = await agent.invoke(messages);
console.log(response.content);
// 输出会基于上下文,给出泛型的具体代码示例

💡 在实际 Agent 开发中,你不需要手动管理这个消息列表——LangChain.js 会自动维护对话历史。但理解消息格式,对于调试 Prompt 和理解 Agent 的工作原理很有帮助。

3、使用对象简写(推荐)

除了使用消息类,你也可以直接用对象表示(更简洁):

const messages = [
  { role: "system", content: "你是一个助手。" },
  { role: "user", content: "你好!" },
  { role: "assistant", content: "你好!有什么可以帮助你的?" },
  { role: "user", content: "谢谢!" },
];

const response = await agent.invoke(messages);

两种方式的区别:

  • 消息类(HumanMessage 等):提供更多元数据和方法,适合复杂场景
  • 对象简写:代码更简洁,适合大多数场景

五、流式输出:提升用户体验

大模型在底层生成内容时是逐 Token 的过程(类似打字机逐个字符输出),它本质上是基于前面的字,一个词一个词(Token)地往外“蹦”的。当生成长文本时通常需要几十秒甚至更长,如果是传统的等待一次性返回,用户面对空白屏幕会以为系统卡死了,这无疑增加了用户的心智负担。 "。

流式输出(Streaming) 完美契合了LLM这种“自回归”的生成过程,模型每算出一个词,就立刻通过接口推送到前端。这样可以让用户实时看到生成的内容,大幅改善体验。

LangChain支持流式输出,在使用invoke时是等待一次性返回,而stream方法则是逐Token输出的方式。

1、基础流式调用

import  { model } from './agents'

// 使用 stream() 方法替代 invoke()
const stream = await model.stream([
  { role: "user", content: "用 100 字介绍一下 TypeScript。" },
]);

// 逐块处理输出
process.stdout.write("回答:");
for await (const chunk of stream) {
  // chunk.content 是当前块的文本内容
  process.stdout.write(chunk.content as string);
}
process.stdout.write("\n");

调用stream() 方法,会返回一个异步迭代器,使用 for await...of 循环遍历流式数据,就可以每收到一块数据就立即输出。

效果:打字机效果

回答:TypeScript是JavaScript的超集,添加了静态类型系统。它能在编译时发现类型错误,提高代码可维护性。TypeScript支持接口、泛型、装饰器等高级特性,被广泛应用于大型前端项目。→

2、在 Agent 中获取流式输出

Agent实例上同样也提供了stream方法来支持流式输出。

LangChain的model和agent实例都有 invoke 和 stream(agent的稍复杂些),这得益于 LangChain 统一的 Runnable 标准。如果你只是想让 AI 逐字吐出最终回复,无论是 Model 还是 Agent,直接调用它们的 stream 方法(Agent 配合 stream_mode="messages")即可。

import  agent from './agents'
import { z } from "zod";

// streamMode: "messages" 可以获取逐 Token 的流式输出
const stream = await agent.stream(
  { messages: [{ role: "user", content: "写一首关于编程的短诗。" }] },
  { streamMode: "messages" }
);

for await (const [message, _metadata] of stream) {
  // 只处理 AI 的文本输出块
  if (message.role === "assistant" && typeof message.content === "string") {
    process.stdout.write(message.content);
  }
}

streamMode 的三种模式:

模式返回格式适用场景
"messages"[message, metadata] 元组对话界面,逐 Token 显示
"values"完整状态对象需要了解 Agent 的中间思考过程
"updates"增量变化部分只关心状态变化的场景

⚠️ 注意:不同 streamMode 的返回格式不同,需要根据你的需求选择合适的模式。

3、Web 应用中的流式输出

在真实的 Web 应用中,用户是在你提供的聊天界面来进行输出和输出等交互,对于服务端输出的数据流,我们可以通过 Server-Sent Events (SSE)WebSocket 将流式数据推送到前端:

// Express.js 示例
app.get("/chat", async (req, res) => {
  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  
  const stream = await agent.stream({
    messages: [{ role: "user", content: req.query.q }]
  }, { streamMode: "messages" });
  
  for await (const [message] of stream) {
    if (message.role === "assistant") {
      res.write(`data: ${JSON.stringify({ content: message.content })}\n\n`);
    }
  }
  
  res.end();
});

这里的关键是将响应头的Content-Type设置为text/event-stream。然后在遍历迭代器的过程中,不断的往响应主体追加数据。

六、结构化输出:让模型返回可靠的数据

LLM 默认返回自由格式的文本,但在实际应用中,我们往往需要模型输出结构化的数据。

一些典型场景:

  • 从文章中提取人名、地点、时间等实体
  • 将用户描述解析为 JSON 格式的配置
  • 对文本进行分类并返回固定类别
  • 生成符合特定 Schema 的代码

1、为什么需要结构化输出?

假设你要从新闻中提取信息,当你调用LLM后,在不适用结构化输出的情况下,LLM会返回一串将所有内容糅杂在一起的文本,这种情况下,如果你需要提取相应的信息可能需要使用正则表达式之类的方式来解析文本。

const response = await model.invoke([
  { role: "user", content: "提取这篇文章的标题、摘要和标签" }
]);

// 返回的是自由文本,难以解析
console.log(response.content);
// "标题:AI技术突破\n摘要:最近AI领域...\n标签:AI, 技术"

// 你需要写复杂的正则表达式来解析
const title = response.content.match(/标题:(.*)/)?.[1];

LangChain提供了结构化输出的解决方案,我们可以结合使用 withStructuredOutput() + Zod Schema来实现稳定可靠的结构化输出。其中,Zod Schema用于指定期望输出的数据结构,withStructuredOutput()则是将模型与结构化输出进行绑定。

import  { model } from './agents'
import { z } from "zod";

// 定义期望的输出结构
const ArticleSchema = z.object({
  title: z.string().describe("文章标题"),
  summary: z.string().describe("文章摘要,不超过 100 字"),
  tags: z.array(z.string()).describe("文章标签列表,3-5 个"),
  difficulty: z.enum(["入门", "中级", "高级"]).describe("文章难度"),
});

// 绑定结构化输出 Schema
const structuredModel = model.withStructuredOutput(ArticleSchema);

const result = await structuredModel.invoke([
  {
    role: "user",
    content: `分析这篇文章并提取信息:
    "本文介绍了如何使用 React Hooks 优化组件性能,包括 useMemo、useCallback 和 memo 的使用场景与最佳实践,适合有 React 基础的开发者。"`,
  },
]);

// result 是完全类型安全的!TypeScript 知道它的结构
console.log(result.title);      // string
console.log(result.summary);    // string
console.log(result.tags);       // string[]
console.log(result.difficulty); // "入门" | "中级" | "高级"

优势对比:

方式返回值类型类型安全可靠性易用性
自由文本string❌ 需要手动解析
JSON 模式any⚠️ 部分⚠️ 字段可能缺失⚠️
结构化输出定义的 Schema✅ 完全✅ 字段保证存在

2、提取多个实体

有时候我们不仅仅是要提取一个对象,我们更希望提取的是一个列表,这种场景在在LangChain中也是支持的。你只需要将Zod Schema设置为一个对象数组即可。

import  { model } from './agents'
import { z } from "zod";

// 定义联系人 Schema
const ContactSchema = z.object({
  contacts: z.array(
    z.object({
      name: z.string().describe("姓名"),
      email: z.string().email().optional().describe("邮箱,如果有的话"),
      phone: z.string().optional().describe("电话号码,如果有的话"),
      company: z.string().optional().describe("公司名称,如果有的话"),
    })
  ).describe("提取出的联系人列表"),
});

const extractor = model.withStructuredOutput(ContactSchema);

const text = `
  本次会议参与者:
  张三(zhangsan@example.com,18800001111,来自 ABC 科技)
  李四(lisi@company.com,来自 XYZ 集团)
  王五(13900002222)
`;

const result = await extractor.invoke([
  { role: "user", content: `从以下文本中提取联系人信息:\n${text}` }
]);

console.log(result.contacts);
// [
//   { name: "张三", email: "zhangsan@example.com", phone: "18800001111", company: "ABC 科技" },
//   { name: "李四", email: "lisi@company.com", company: "XYZ 集团" },
//   { name: "王五", phone: "13900002222" }
// ]

Zod提供了很多便利的API,上面例子就使用了以下几个,更多的API请自行阅读Zod官方文档

  • 使用 .optional() 标记可选字段
  • 使用 .email() 等验证器确保格式正确
  • describe() 中详细说明每个字段的含义

3、strict 模式(v1.2.0 新增)

v1.2.0 中新增了 strict 参数,强制模型严格按照 Schema 输出:

const structuredModel = model.withStructuredOutput(ArticleSchema, {
  strict: true,  // 严格模式
});

strict 模式的作用:

  • 减少字段遗漏的概率
  • 减少格式错误的概率
  • 提高输出的可靠性
  • 可能会略微增加延迟

💡 最佳实践:在生产环境中,始终开启 strict: true,除非你有特殊理由。

4、结构化输出 vs 提示 vs JSON 模式

使用LLM时,其实可以通过设置提示词的方式,让LLM输出结构化的数据。另外,一些 Provider(如 OpenAI)也支持设置 responseFormat: { type: "json_object" } 的 JSON 模式来输出结果。既然如此,为什么还需要专门的结构化输出方法呢?主要有以下原因:

  • 使用提示词,输出不稳定:大模型是基于概率预测下一个字的。即使你千叮咛万嘱咐,模型在生成时仍有可能因为概率波动,顺手加上一句“好的,这是你要的 JSON:”或者把 JSON 包裹在 ```json 代码块里。这对人类来说很好理解,但对程序解析来说就是致命的报错。
  • 使用提示词,缺乏强制力:提示词只是对话历史中的一部分文本,对模型没有真正的物理约束力。当模型遇到复杂逻辑或生成长文本时,很容易“遗忘”前面的格式指令,或者产生幻觉输出不存在的字段。
  • 使用提示词,解析脆弱:依赖提示词时,你的代码通常需要写大量的正则表达式去清洗数据(比如去掉 Markdown 标记、提取大括号内容等)。一旦模型输出稍微变一点花样(比如多加了个逗号,或者用了单引号),整个程序就会崩溃。
  • 使用responseFormat,结果无保障:虽然不会像单纯的使用提示词那么不稳定,但responseFormat只能保证输出是合法的 JSON,让解析不会出错,但不能保证字段名称和类型符合预期。比如丢失字段,字段名修改等。不过你可以通过设置responseFormat: { type: "json_schema" }来强制严格输出。但这种情况下你需要自己对输出和输出做手动的解析,另外有些模型不支持此参数。

withStructuredOutput()本质上是对responseFormat: { type: "json_schema" }的高级封装,对于支持responseFormat的模型来讲,LangChain底层会自动帮你解析并构建参数传递给LLM的API,并对输出结果做自动的解析,大幅提高开发效率;对于不支持responseFormat的模型,LangChain会通过模拟工具调用的方式,让大模型调用一个工具,从而可以提取相应的数据结构。

在大多数场景下,我们需要精确数据结构,因此建议始终优先使用 withStructuredOutput()


七、动态模型选择(高级用法)

在某些场景下,你可能希望根据任务复杂度动态切换模型,比如简单任务用便宜的小模型,复杂任务用性能更强的大模型,以此平衡效果和成本。实现的基本思路是使用LangChain的中间件来拦截请求,动态切换模型。

import { ChatOpenAI } from "@langchain/openai";
import { createAgent, createMiddleware } from "langchain";

// 定义两个不同档次的模型
const basicModel = new ChatOpenAI({ 
  model: "gpt-4o-mini"     // 低成本
});
const advancedModel = new ChatOpenAI({ 
  model: "gpt-4o"          // 高质量
});

// 创建动态模型选择中间件
const dynamicModelMiddleware = createMiddleware({
  name: "DynamicModelSelection",
  
  // 拦截模型调用请求
  wrapModelCall: (request, handler) => {
    const messageCount = request.messages.length;
    
    // 超过 10 条消息时,认为任务较复杂,切换到高级模型
    const selectedModel = messageCount > 10 ? advancedModel : basicModel;
    
    console.log(`选择模型: ${selectedModel.model} (消息数: ${messageCount})`);
    
    // 用选中的模型处理请求
    return handler({ ...request, model: selectedModel });
  },
});

// 创建 Agent 并注册中间件
const agent = createAgent({
  model: basicModel,  // 默认模型
  tools: [],
  middleware: [dynamicModelMiddleware],
});

工作原理:

  1. 中间件拦截每次模型调用请求
  2. 根据请求内容(如消息数量)决定使用哪个模型
  3. 用选中的模型处理请求

动态模型切换是有广泛的应用场景的,比如:

  • 客服系统:简单问题用 mini 模型,复杂投诉用完整版
  • 代码助手:语法查询用 mini,架构设计用完整版
  • 成本控制:90% 的请求用便宜模型,只有 10% 用昂贵模型

中间件系统在复杂系统发挥的作用非常重要,关于中间件系统会在第七章详细讲解,这里只是展示一个直觉性的例子。

八、模型选型建议

在实际项目中,如何选择合适的模型是需要根据我们的具体需求、业务的复杂度、成本等多个维度来考量的。这里提供一个选择的思路供参考。

场景化推荐

场景推荐模型理由成本参考
工具调用密集的 Agentopenai:gpt-4o
智谱GLM
工具调用准确率高,多工具并行能力强$$$
长文档分析(>50K tokens)anthropic:claude-opus系列超长上下文窗口,长文阅读理解优秀$$$$
高频低延迟场景openai:gpt-4o-mini
anthropic:claude-haiku
响应快(<1s),成本低$
复杂推理 / 数学openai:o1-mini
anthropic:claude-opus系列
推理能力专门优化$$$-$$$$
代码生成anthropic:claude-sonnet
openai:gpt-4o
deepseek:deepseek-v3.2
代码质量高,理解上下文能力强$$-$$$
本地部署 / 数据敏感ollama:llama
ollama:qwen
数据不出本地,免费使用免费
开发调试阶段任何 *-mini / *-flash 版本响应快、成本低,适合高频测试$

成本对比(每 1M tokens)

模型输入价格输出价格相对成本
GPT-4o$2.50$10.00基准
GPT-4o-mini$0.15$0.606%
Claude Opus$15.00$75.00600%
Claude Sonnet$3.00$15.00120%
Claude Haiku$0.25$1.2510%
Gemini 2.0 Flash$0.10$0.404%

💡 实践建议:

  1. 开发阶段:优先用便宜的 mini 模型,功能跑通后再切换到高质量模型做效果对比
  2. A/B 测试:同时用两个模型处理相同任务,对比质量和成本
  3. 监控成本:接入 LangSmith,实时监控 Token 消耗和费用
  4. 缓存策略:对重复问题使用缓存,避免重复调用 LLM

九、本章小结

LLM是Agent的大脑,而模型抽象层则是 LangChain.js 的基础设施之一。掌握LangChain模型抽象层的使用,是使用LangChain开发Agent的基础。

📝 核心知识点回顾

这一章我们学习了:

  1. 统一接口的价值:无论哪家 Provider,调用方式一致,避免供应商锁定
  2. 两种配置方式
    • 字符串标识符:简洁,适合大多数场景
    • 显式实例化:灵活,适合需要精细控制的场景
  3. 四大 Provider:OpenAI、Anthropic、Google、Ollama 的特点和接入方式
  4. 消息格式system / human / assistant / tool 四种消息类型的作用
  5. 流式输出stream() 方法和三种 streamMode 的选择
  6. 结构化输出withStructuredOutput() + Zod Schema实现类型安全的结构化返回
  7. 模型选型:根据场景选择合适模型的参考框架

🎯 动手练习

尝试完成以下练习,巩固所学知识:

练习 1:切换模型对比 创建一个简单的问答 Agent,分别用 gpt-4ogpt-4o-mini 回答同一个复杂问题,对比响应时间和质量差异。

练习 2:结构化输出实战 设计一个 Schema,从电影评论中提取:评分(1-5)、情感倾向(正面/负面/中性)、提到的演员名单。

练习 3:流式输出 Web 应用 创建一个简单的 Express.js 应用,实现 SSE 流式输出,在前端页面上实现打字机效果。

练习 4:本地模型测试 安装 Ollama 和 Llama 3.2,对比本地模型和 GPT-4o 在代码生成任务上的表现。

📚 延伸阅读


下一章:《第四章 —— 提示词工程(Prompt Engineering)》