Skip to content

image.png

项目初始化

  1. 使用bun创建一个空项目
bash
bun init
  1. 安装依赖
bash
 pnpm  add zod langchain @langchain/openai @langchain/core
  1. 创建环境变量 .env
bash
OPENAI_API_KEY=
OPENAI_MODEL=
OPENAI_API_BASE_URL=
  1. 校验环境变量
ts
// types/env.ts
import { z } from "zod";

const envSchema = z.object({
  OPENAI_API_BASE_URL: z.url().min(1),
  OPENAI_API_KEY: z.string().min(1),
  OPENAI_MODEL: z.string().min(1),
});

// 解析环境变量
const parsedEnv = envSchema.safeParse(process.env);

if (!parsedEnv.success) {
  console.error(
    "❌ Invalid environment variables:",
    z.treeifyError(parsedEnv.error),
  );
  process.exit(1);
}

export const settings = {
  openai_api_base_url: parsedEnv.data.OPENAI_API_BASE_URL,
  openai_api_key: parsedEnv.data.OPENAI_API_KEY,
  openai_model: parsedEnv.data.OPENAI_MODEL,
};

在程序入口文件引入

ts
// index.ts
import "./types/env.ts";

模型抽象层ChatOpenAI

ChatOpenAI的简单使用

详细参数与方法

ts
// src/chat.ts
export const chatOpenAI = new ChatOpenAI({
  model: settings.openai_model,
  apiKey: settings.openai_api_key,
  // 随机性和创造性 越接近0越保守
  temperature: 0.7,
  // 超时时间
  timeout: 60000,
  // 最大重试次数
  maxRetries: 3,
  // 最大令牌数
  maxTokens: 4096,
  // 配置
  configuration: {
    baseURL: settings.openai_api_base_url,
  },
});
export default async function main() {
  const result = await chatOpenAI.invoke("你好");
  console.log(result);
}

返回接口是一个AIMessage 详细参数

json
AIMessage {
  "id": "chatcmpl-e6c0d8d7-6600-94c1-a857-a1a1bb56208d",
  "content": "你好!很高兴见到你。有什么我可以帮你的吗?",
  "additional_kwargs": {
    "reasoning_content": ""
  },
  "response_metadata": {
    "tokenUsage": {
      "promptTokens": 10,
      "completionTokens": 13,
      "totalTokens": 23
    },
    "finish_reason": "stop",
    "model_provider": "openai",
    "model_name": "kimi-k2.6"
  },
  "tool_calls": [],
  "invalid_tool_calls": [],
  "usage_metadata": {
    "output_tokens": 13,
    "input_tokens": 10,
    "total_tokens": 23,
    "input_token_details": {
      "cache_read": 0
    },
    "output_token_details": {}
  }
}

image.png

Message

image.png

SystemMessage vs HumanMessage vs AIMessage

它们都继承自 BaseMessage,核心区别在于 角色定位对模型行为的控制力

SystemMessageHumanMessage
角色系统指令/设定用户输入/提问
作用定义 AI 的行为规则、人格、约束提出具体问题或需求
谁说的开发者(不是最终用户)最终用户
模型对待方式视为不可违背的指令,优先级最高视为需要回应的内容
典型位置消息列表的开头,通常只有一条紧随 SystemMessage 之后,可多条

结合代码来理解:

ts
const messages = [
  // 1️⃣ SystemMessage:设定 AI 的"人设"和行为规则
  new SystemMessage(
    "你是一个代码助手,每次回答后,都需要提示用户,'该回答由AI产生,请仔细检查!'",
  ),
  // 2️⃣ HumanMessage:用户的实际提问
  new HumanMessage("列举一下ts中不经常用到的方法或者API"),
];
  • SystemMessage 告诉模型:"你是代码助手" + "每次回答后必须加免责提示"——这是强约束,模型会在整个对话中遵守。
  • HumanMessage 是用户的具体问题,模型会据此生成回答内容。

简单类比——把模型想象成一个客服:

  • SystemMessage = 公司给客服的培训手册:"你是技术客服,回答完必须说'仅供参考'"
  • HumanMessage = 客户打来的电话:"TypeScript 有哪些冷门 API?"

客服会按照手册的规则来回答客户的问题,但手册本身不是客户看到的回答内容。

AIMessage 代表模型的历史回复。在多轮对话中,消息列表通常是这样的结构:

ts
const messages = [
  new SystemMessage("你是一个代码助手..."), // 系统设定
  new HumanMessage("第一个问题"), // 第1轮 - 用户
  new AIMessage("第一个回答"), // 第1轮 - AI
  new HumanMessage("第二个问题"), // 第2轮 - 用户
];

这样模型就能理解完整的对话上下文,实现多轮对话。

除了以上三种,LangChain 还提供了其他类型的消息,如 FunctionMessageToolMessage,用于处理函数调用和工具使用场景。

也支持字典模式{ role: "system", content: "你是一个代码助手..." }

聊天模式

  1. 多回合对话

    聊天模型其实并不会记住之前的消息,需要在每次对话中传递完整的消息列表。

    image.png

    示例代码

  2. 保持上下文

  3. 流式输出 示例代码

  4. 调整行为

流式输出深入理解

process.stdout.write 类型报错与语法说明

chunk.content 的类型定义是 string | (ContentBlock | Text)[],TypeScript 不允许直接把可能是数组的值传给 process.stdout.write()

修复方式:先做类型收窄,只写入字符串部分。

ts
for await (const chunk of result) {
  const content = typeof chunk.content === "string" ? chunk.content : "";
  process.stdout.write(content);
}

process.stdout.write(data)Node.js / Bun 内置的进程 I/O API,作用是把内容直接写入终端(标准输出),不带换行符

process.stdout.write()console.log()
换行不换行自动加 \n
性能更底层,更快内部调用 .write() + 格式化
适用场景流式逐字输出、进度条普通日志打印

在流式输出中,AI 的回复是一块一块(chunk)回来的。用 process.stdout.write 能实现打字机效果,所有 chunk 在同一行拼接显示;如果用 console.log,每一块都会换行。

for await...of 与异步迭代器

for await...of 是遍历异步可迭代对象(AsyncIterable)的语法。

ts
const result = await model.stream(messages);
for await (const chunk of result) {
  process.stdout.write(content);
}

JavaScript 引擎会自动把它展开成类似这样的代码:

ts
const iterator = result[Symbol.asyncIterator]();

while (true) {
  const { value, done } = await iterator.next();
  if (done) break;
  process.stdout.write(value.content);
}

next() 立即返回一个 Promise,不会阻塞主线程。但 Promise 内部的网络请求可能还没完成。真正的"暂停"是 await 造成的

  1. await iterator.next() 遇到 pending Promise,把当前函数的执行权交还给事件循环
  2. 当模型服务端推送新的 chunk 过来,Promise resolve,await 恢复执行
  3. 循环体执行完,再次 await iterator.next(),重复直到 done: true
同步 Symbol.iterator异步 Symbol.asyncIterator
next() 返回{ value, done }Promise<{ value, done }>
数据等待立即拿到,CPU 计算需要 I/O(网络/文件)
语法for...offor await...of
是否会"暂停"不会会(await Promise)

流式输出时收集完整内容

可以边流边拼接到一个变量中,循环结束后就得到了完整回答:

ts
let fullContent = "";

for await (const chunk of result) {
  const content = typeof chunk.content === "string" ? chunk.content : "";
  process.stdout.write(content); // 实时输出到终端
  fullContent += content; // 同步拼接到完整字符串
}

console.log("\n--- 完整内容 ---");
console.log(fullContent);

两者不冲突:流式体验保留,完整内容也能拿到。常见用途包括做二次处理、计算 token 数、保存到数据库等。

token使用追踪

usage收集的两种方式

  1. 非流式
ts
import { createModel } from "../utils/index";

export default async function trackTokenUsage() {
  const model = createModel({ modelName: "qwen3.7-max-2026-05-17" });
  const result = await model.invoke("TUI框架是什么?");
  const usage = result.usage_metadata;
  console.log(`\n 输入token: ${usage?.input_tokens}`);
  console.log(`\n 输出token: ${usage?.output_tokens}`);
  console.log(`\n 总token: ${usage?.total_tokens}`);
}
  1. 流式 一般在最后一个chunk返回usage
ts
import { createModel } from "../utils/index";
import { AIMessageChunk } from "@langchain/core/messages";

export default async function trackTokenUsage() {
  const model = createModel({ modelName: "qwen3.7-max-2026-05-17" });
  let finalChunk: AIMessageChunk | undefined;
  const result = await model.stream("TUI框架是什么?");
  for await (const chunk of result) {
    process.stdout.write(chunk.content as string);
    finalChunk = chunk;
  }
  const usage = finalChunk?.usage_metadata;
  console.log(`\n 输入token: ${usage?.input_tokens}`);
  console.log(`\n 输出token: ${usage?.output_tokens}`);
  console.log(`\n 总token: ${usage?.total_tokens}`);
}

降低成本的简单方法

  1. 使用maxTokens限制响应长度
  2. 压缩对话记录
ts
const recentMessages = messages.slice(-10);
const response = await model.invoke(recentMessages);

Templates

Messages vs Templates

ts
import { createModel } from "../utils/index";
import { HumanMessage, SystemMessage } from "langchain";
import { ChatPromptTemplate } from "@langchain/core/prompts";

export default async function messagesVsTemplates() {
  const model = createModel();
  //messages
  const messages = [
    new SystemMessage("You are a helpful assistant."),
    new HumanMessage("把今天天气怎么样翻译成英文?"),
  ];
  const result = await model.invoke(messages);
  console.log(`messages result: ${result.content}`);
  // templates
  const template = ChatPromptTemplate.fromMessages([
    ["system", "You are a helpful translator."],
    ["human", "Translate '{text}' to {language}"],
  ]);
  const templateChain = template.pipe(model);
  const templateResult = await templateChain.invoke({
    text: "今天天气怎么样",
    language: "日语",
  });
  console.log(`template result: ${templateResult.content}`);
}
MessagesTemplates
本质手动拼接,硬编码可复用的"模板函数"
变量替换不支持(字符串拼接支持 {变量名} 插值
复用性差,每次写一套好,一次定义多处调用
适合场景Agent、动态工作流、多步推理、工具集成RAG、复用提示、变量替换、一致性
链式调用不行支持 `.pipe(model)

pip

.pipe()就像管道符|`,把前一个组件的输出作为后一个组件的输入。链一旦建好,你就把它当一个整体来调用,不需要手动处理中间转换。

ts
const template = ChatPromptTemplate.fromMessages([
  ["system", "You are a helpful translator."],
  ["human", "Translate '{text}' to {language}"],
]);

// 用 .pipe() 把 template 和 model 串联成链
const templateChain = template.pipe(model);

现在 templateChain 是一个统一的可调用对象,可以直接 .invoke()

ts
const result = await templateChain.invoke({
  text: "今天天气怎么样",
  language: "英文",
});

为什么叫"可调用的链"?

.pipe() 返回的是 Runnable 对象,它有一套统一的调用接口:

方法作用
.invoke(input)单次调用
.stream(input)流式输出
.batch(inputs)批量调用
.pipe(next)继续拼接下一个组件

链可以继续拼接

ts
import { StringOutputParser } from "@langchain/core/output_parsers";

// template → model → outputParser
const templateChain = template.pipe(model).pipe(new StringOutputParser()); // 把 AIMessage 转成纯字符串

const text = await templateChain.invoke({
  text: "你好",
  language: "英文",
});
console.log(text); // 纯字符串,不是 AIMessage 对象

ChatPromptTemplate 与 PromptTemplate

两者的核心区别在于输出格式目标模型类型

ChatPromptTemplatePromptTemplate
输出类型BaseMessage[](消息数组)string(纯字符串)
目标模型聊天模型(Chat Models)如 gpt-4, ChatOpenAI文本补全模型(LLMs)如 text-davinci-003
能否设置 System 角色✅ 可以,明确区分 system/human/ai❌ 不行,只有一个字符串模板
现代推荐度首选,所有聊天场景都用它仅用于特定旧模型或纯字符串场景
ts
import { createModel } from "../utils";
import { PromptTemplate, ChatPromptTemplate } from "@langchain/core/prompts";
export default async function TemplateFormat() {
  // ChatPromptTemplate
  const chatPrompt = ChatPromptTemplate.fromMessages([
    {
      role: "system",
      content: "你是一个以{style}风格并用{language}回答问题的{role}",
    },
    {
      role: "human",
      content: "{question}",
    },
  ]);
  const model = createModel();
  const result = await chatPrompt.pipe(model).invoke({
    role: "pirate",
    style: "dramatic",
    language: "中文",
    question: "什么是 TypeScript?",
  });
  console.log(result.content);

  console.log("\n2️⃣  PromptTemplate:\n");
  // PromptTemplate
  const stringTemplate = PromptTemplate.fromTemplate(
    "用{style}风格写一段{topic}的开头,用{language}回答",
  );
  const prompt = await stringTemplate.format({
    style: "幽默",
    language: "中文",
    topic: "今天周三",
  });

  console.log(prompt + "\n");

  const result2 = await model.invoke(prompt);
  console.log(result2.content);
}
.format().pipe(model).invoke()
做什么仅渲染模板渲染模板 + 调用模型
返回值stringBaseMessage[]AIMessage(模型回复)
消耗 Token❌ 不消耗✅ 消耗
灵活性高,可以拿到 prompt 做其他事低,直接出结果
代码量需要再写一行 model.invoke(prompt)一步完成

什么时候用 .format()

查看、调试或二次处理生成的 prompt 时:

ts
const prompt = await template.format({...});
console.log("实际发给模型的内容:", prompt); // 调试
await saveToDB(prompt);                      // 存日志
const result = await model.invoke(prompt);   // 再手动调用

结构化输出

基本结构化输出

用 z.object() 来定义模式,用 [model].withStructuredOutput() 来获取类型化、验证过的数据。

ts
import { createModel } from "../utils/index";
import { z } from "zod";
export default async function main() {
  const model = createModel();
  const personSchema = z.object({
    //使用 .describe() 来告诉 AI 每个字段代表什么
    name: z.string().describe("姓名"),
    age: z.number().describe("年龄"),
    email: z.string().email().describe("邮箱地址"),
  });

  const structuredModel = model.withStructuredOutput(personSchema, {
    strict: true,
    method: "functionCalling",
  });
  const structuredOutput = await structuredModel.invoke(
    "我的名字是张三,年龄28岁,邮箱是1258963@qq.com",
  );

  console.log(structuredOutput);
}

复杂结构化输出

示例代码

输出结果

✅ Extracted Company Data:

{
  name: 'Microsoft',
  founded: 1975,
  headquarters: { city: 'Redmond', country: 'Washington' },
  products: [ 'Windows', 'Office', 'Azure', 'Xbox' ],
  employeeCount: 220000,
  isPublic: true
}

withStructuredOutput` 的三个 method 详解

jsonSchemafunctionCallingjsonMode
API 参数response_format + type: json_schematools + tool_choiceresponse_format + type: json_object
返回方式content 直接是 JSONtool_calls 返回content 是 JSON
Schema 强制✅ 严格✅ 严格❌ 不保证
strict 模式✅ 支持✅ 支持❌ 不支持
模型兼容性仅 GPT-4o+大多数兼容 API ✅较广泛

image.png

Tools

什么是 Function Calling ?

Function Calling 是一种让模型调用函数的机制,通过定义函数的 schema 让模型知道何时以及如何调用函数。将大语言模型从单纯的文本生成器转变为行动协调中枢。模型不再局限于文本输出,而是能够触发真实世界的操作——例如查询天气、检索数据库、调用API等。

67a33cfc435247ada028395922bd132b.jpg

简单的工具调用

示例代码

多工具调用

示例代码

哪些因素会影响LLM选择工具?

工具名称、工具描述、参数模式、用户的问题

Agent

示例代码

createAgent() 底层到底做什么?

createAgent 就是 new ReactAgent(),而 ReactAgent 在构造函数中用 LangGraph StateGraph 构建了一个 ReAct 循环的状态机:

js
// langchain/dist/agents/index.js
function createAgent(params) {
  return new ReactAgent(params);
}

整个流程可以用这张图概括:

构造函数核心步骤

1. 校验 & 合并工具

ts
// 校验必须提供 model
if (!options.model) throw new Error("`model` option is required");
// 检查 model 上是否已经 bindTools 了(不允许重复绑定)
validateLLMHasNoBoundTools(options.model);
// 合并用户传入的 tools + middleware 提供的 tools
const toolClasses = [...options.tools, ...middlewareTools];

2. 创建 StateGraph(状态图)

ts
const { state, input, output } = createAgentState(...);
const graph = new StateGraph(state, { input, output, context });

这是 LangGraph 提供的有向图,每个节点是一个处理步骤,边决定流转方向。状态中最重要的字段是 messages: BaseMessage[]

3. 注册节点(Nodes)

节点作用
"agent"AgentNode调用 LLM,拿到 AI 回复(可能包含 tool_calls)
"tools"ToolNode执行 tool_calls,返回 ToolMessage
middleware名.before_agentBeforeAgentNode中间件:在 Agent 运行前执行
middleware名.before_modelBeforeModelNode中间件:在 LLM 调用前执行
middleware名.after_modelAfterModelNode中间件:在 LLM 调用后执行
middleware名.after_agentAfterAgentNode中间件:在 Agent 运行后执行

在没有 middleware 的场景下,只有 agenttools 两个核心节点。

4. 注册边(Edges)—— 循环的关键

ts
// START → agent(入口)
graph.addEdge(START, "agent");

// agent → 路由决定
graph.addConditionalEdges("agent", createModelRouter(), ["tools", END]);

// tools → agent(回到 LLM,形成循环)
graph.addEdge("tools", "agent");

核心路由逻辑在 #createModelRouter 中:

ts
(state) => {
  const lastMessage = state.messages.at(-1);
  // 如果最后一条消息不是 AIMessage 或者没有 tool_calls → 结束
  if (!AIMessage.isInstance(lastMessage) || !lastMessage.tool_calls?.length)
    return END;
  // 否则 → 发送到 tools 节点并行执行
  return regularToolCalls.map(
    (call) => new Send("tools", { ...state, lg_tool_call: call }),
  );
};

5. 编译图

ts
this.#graph = graph.compile({
  checkpointer: options.checkpointer,
  store: options.store,
  name: options.name,
  transformers: [createToolCallTransformer([]), ...],
});

compile() 把 StateGraph 变成一个可执行的 CompiledGraph,支持 invoke()stream()

invoke() 执行流程

ts
async invoke(state, config) {
  const mergedConfig = mergeConfigs(this.#defaultConfig, config);
  const initializedState = await this.#initializeMiddlewareStates(state, mergedConfig);
  return this.#graph.invoke(initializedState, mergedConfig);
}

本质上就是把状态丢进编译好的 LangGraph 图中执行。图中的循环如下:

对比手动写法

02-multiple-tools.ts 中的手动写法:

ts
// 手动:1. 绑定工具
const boundModel = model.bindTools(tools);
// 手动:2. 调用模型
const result = await boundModel.invoke(messages);
// 手动:3. 处理 tool_calls
for (const toolCall of result.tool_calls) {
  const toolResult = await toolCallHandler(tools, toolCall);
  // 手动:4. 拼装 ToolMessage
  baseMessage.push(
    new ToolMessage({ content: toolResult, tool_call_id: toolCall.id }),
  );
  // 手动:5. 再次调用模型
  const finalResult = await boundModel.invoke(baseMessage);
}

createAgent() 把步骤 2-5 全部自动化到一个 LangGraph 状态机中:LLM 调用 → 检查 tool_calls → 执行工具 → 把结果喂回 LLM → 循环,直到 LLM 不再请求工具调用为止。


示例代码

createAgent() 如何防止无限循环?

createAgent() (即 ReactAgent) 防止无限循环的机制有 三层,由底层到高层分别是:

第 1 层:LangGraph 的 recursionLimit(硬上限)

这是最根本的安全网。ReactAgent 底层是一个 LangGraph StateGraph,而 LangGraph 的执行引擎(PregelLoop)在每次 tick 时检测 step 是否超限:

js
// @langchain/langgraph/dist/pregel/loop.js
const stop = step + (config.recursionLimit ?? DEFAULT_LOOP_LIMIT) + 1;

// tick() 内
if (this.step > this.stop) {
  this.status = "out_of_steps";
  return false;
}

超限后抛出 GraphRecursionError

js
// @langchain/langgraph/dist/pregel/index.js
if (loop.status === "out_of_steps")
  throw new GraphRecursionError(
    [
      `Recursion limit of ${config.recursionLimit} reached`,
      "without hitting a stop condition. You can increase the",
      `limit by setting the "recursionLimit" config key.`,
    ].join(" "),
    { lc_error_code: "GRAPH_RECURSION_LIMIT" },
  );

默认值是 25

js
// @langchain/langgraph/dist/pregel/utils/config.js
const DEFAULT_RECURSION_LIMIT = 25;

可以通过 withConfig 修改:

ts
const agent = createAgent({ model, tools: commonTools });
const result = await agent.invoke(
  { messages: [new HumanMessage(query)] },
  { recursionLimit: 50 }, // 将递归限制调为 50
);

第 2 层:AgentNode 内部的 #areMoreStepsNeeded(优雅降级)

在到达硬上限之前,如果状态中存在 remainingSteps 字段,AgentNode 会提前拦截并返回一条礼貌消息而非报错:

js
// AgentNode.js
#areMoreStepsNeeded(state, response) {
    const allToolsReturnDirect = AIMessage.isInstance(response)
        && response.tool_calls?.every(call => this.#options.shouldReturnDirect.has(call.name));
    const remainingSteps = "remainingSteps" in state ? state.remainingSteps : undefined;
    return Boolean(
        remainingSteps &&
        (remainingSteps < 1 && allToolsReturnDirect ||
         remainingSteps < 2 && hasToolCalls(state.messages.at(-1)))
    );
}

当步数快耗尽时,AgentNode.#run() 返回替代消息:

js
if (this.#areMoreStepsNeeded(state, aiMessage)) {
  commands.push(
    new Command({
      update: {
        messages: [
          new AIMessage({
            content: "Sorry, need more steps to process this request.",
            name: this.name,
            id: aiMessage.id,
          }),
        ],
      },
    }),
  );
}

注意:在基本用法中(没有 checkpointer 管理状态),remainingSteps 通常不会出现在状态中,所以这个检查不会生效。它主要是为使用 LangGraph checkpointer 的场景设计的。

第 3 层:toolCallLimitMiddleware(可选的细粒度限制)

这是可以主动加入的中间件,提供更精细的控制:

ts
import { createAgent, toolCallLimitMiddleware, HumanMessage } from "langchain";

const agent = createAgent({
  model,
  tools: commonTools,
  middleware: [
    toolCallLimitMiddleware({
      runLimit: 10, // 单次运行最多 10 次工具调用
      threadLimit: 50, // 整个线程最多 50 次工具调用
      exitBehavior: "continue", // "continue" | "error" | "end"
    }),
  ],
});

它通过 afterModel 钩子在 LLM 返回 tool_calls 之后、工具执行之前进行拦截,三种退出行为:

exitBehavior行为
"continue"(默认)超限的工具调用被替换为错误 ToolMessage,其他工具继续执行,LLM 看到错误后自行决定是否停止
"error"直接抛出 ToolCallLimitExceededError 异常
"end"立即注入错误 ToolMessage + 最终 AIMessage,然后跳转到 END 终止图

循环如何自然终止?

即使不设限,ReAct 循环也不会无限运行,因为 LLM 认为问题已经解决时会返回纯文本回复(无 tool_calls),路由器返回 END

js
// #createModelRouter
(state) => {
    const lastMessage = state.messages.at(-1);
    if (!AIMessage.isInstance(lastMessage) ||
        !lastMessage.tool_calls || lastMessage.tool_calls.length === 0)
        return exitNode;  // → END
    // 有工具调用 → tools 节点
    return regularToolCalls.map(...);
};

完整循环示意:

总结

层级机制默认值行为
LangGraph 硬上限recursionLimit25 步超限抛 GraphRecursionError
AgentNode 优雅降级remainingSteps 检查undefined(不启用)步数不足时返回 "Sorry" 消息
toolCallLimitMiddleware工具调用计数需手动添加可选 continue / error / end

Agent 如何决定使用哪个工具?

这不是 Agent 代码决定的,而是 LLM(大语言模型) 决定的。整条链路如下:

第 1 步:工具定义 → JSON Schema

当传入 tools: commonToolsReactAgent 在构造函数中收集所有工具:

ts
const toolClasses = [...options.tools, ...middlewareTools];

然后在 AgentNode.#invokeModel 中,每次调用 LLM 前通过 #bindTools 将工具绑定到模型:

ts
const modelWithTools = await this.#bindTools(
  request.model,
  request,
  structuredResponseFormat,
);

#bindTools 内部调用 ChatOpenAI.bindTools(),最终通过 convertToOpenAIFunction 将每个工具转为 OpenAI API 格式:

ts
// @langchain/core/dist/utils/function_calling.js
function convertToOpenAIFunction(tool, fields) {
  return {
    name: tool.name, // 工具名
    description: tool.description, // 工具描述
    parameters: toJsonSchema(tool.schema), // zod → JSON Schema
  };
}

commonTools 为例,最终发给 API 的是类似这样的 JSON:

json
{
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "calculator",
        "description": "Useful for performing mathematical calculations. Use this when you need to compute numbers.",
        "parameters": {
          "type": "object",
          "properties": {
            "expression": {
              "type": "string",
              "description": "The mathematical expression to evaluate, e.g. '25 * 4'"
            }
          },
          "required": ["expression"]
        }
      }
    },
    {
      "type": "function",
      "function": {
        "name": "read_file",
        "description": "Reads the content of a file from the filesystem...",
        "parameters": {
          "type": "object",
          "properties": {
            "filePath": {
              "type": "string",
              "description": "The path to the file to read..."
            }
          },
          "required": ["filePath"]
        }
      }
    }
  ]
}

第 2 步:LLM 做出选择

LLM 收到工具定义 + 用户消息后,自己决定是否调用工具、调用哪个、传什么参数。这是语言模型内置的 Function Calling 能力,不是代码逻辑。

用户输入LLM 推理过程LLM 的 tool_call 响应
"What is 125*8?"包含数学计算,calculator 的 description 匹配{name: "calculator", args: {expression: "125*8"}}
"查看一下有什么文件"需要浏览目录,list_directory 的 description 匹配{name: "list_directory", args: {dirPath: "."}}

第 3 步:路由 → 执行 → 回传

影响工具选择的四个因素

因素代码中对应作用
工具名称 namename: "calculator"LLM 通过名称做语义匹配
工具描述 description"Useful for performing mathematical calculations..."最关键的因素,LLM 以此判断何时使用该工具
参数模式 schemaz.object({ expression: z.string().describe(...) })帮助 LLM 理解需要传什么参数
用户输入HumanMessage 的 contentLLM 综合理解用户意图后匹配最合适的工具

为什么 description 最关键? LLM 无法看到工具的实现代码,它唯一的依据就是 name + description + schema。如果发现 LLM 经常选错工具,优先改进 description


可以优先选择某些工具吗?

可以,有 4 种机制,从简单到高级:

方法 1:工具数组顺序(隐式优先级)

OpenAI API 文档指出:排在 tools 数组前面的工具,LLM 会略微偏向选择

ts
const agent = createAgent({
  model,
  // calculator 排在前面,LLM 会略微偏向它
  tools: [calculatorTool, readFileTool, writeFileTool, listDirTool],
});

⚠️ 效果微弱,只是"倾向"而非"保证"。当工具描述明显指向不同场景时(如计算器 vs 文件操作),顺序影响很小。

方法 2:优化 description(最实用)

这是影响 LLM 选工具的核心因素。 LLM 根本看不到工具的实现代码,只能看到 namedescriptionschema 三个字段。精准的 description 就是指令:

ts
// ❌ 模糊描述
export const calculatorTool = tool(
  async (input) => {
    /* ... */
  },
  {
    name: "calculator",
    description: "Useful for calculations.",
    schema: z.object({
      /* ... */
    }),
  },
);

// ✅ 精确描述
export const calculatorTool = tool(
  async (input) => {
    /* ... */
  },
  {
    name: "calculator",
    description:
      "首选工具。当用户的问题涉及数学计算时,使用此工具而不是其他工具。",
    schema: z.object({
      /* ... */
    }),
  },
);

// ✅ 明确排除
export const readFileTool = tool(
  async (input) => {
    /* ... */
  },
  {
    name: "read_file",
    description: "仅用于读取文件内容。不要用于数学计算。",
    schema: z.object({
      /* ... */
    }),
  },
);

方法 3:tool_choice 强制选择

OpenAI API 的 tool_choice 参数可以强制 LLM 必须调用某个工具:

ts
const model = createModel();

// 强制必须调用 calculator
const boundModel = model.bindTools([calculatorTool], {
  tool_choice: { type: "function", function: { name: "calculator" } },
});

// 强制必须调用某个工具(任意一个)
const boundModel = model.bindTools(commonTools, {
  tool_choice: "required", // 或 "any"
});

// 自动选择(默认行为)
const boundModel = model.bindTools(commonTools, {
  tool_choice: "auto",
});
tool_choice含义
"auto"(默认)LLM 自行决定是否调用工具、调用哪个
"required" / "any"必须调用至少一个工具,LLM 自己选哪个
{ type: "function", function: { name: "xxx" } }强制调用指定工具

方法 4:llmToolSelectorMiddleware(智能筛选)

这是 LangChain Agent 内置的中间件,当工具很多时最有用。原理:先用一个小模型筛选出最相关的 N 个工具,然后再把精选的工具列表传给主模型。

ts
import {
  createAgent,
  HumanMessage,
  llmToolSelectorMiddleware,
} from "langchain";

const agent = createAgent({
  model,
  tools: commonTools, // 20+ 个工具
  middleware: [
    llmToolSelectorMiddleware({
      model: "openai:gpt-4o-mini", // 用便宜的小模型做筛选
      maxTools: 3, // 最多选 3 个工具给主模型
      alwaysInclude: ["calculator"], // calculator 永远包含,不占名额
    }),
  ],
});

参数详解:

参数作用
model用于筛选的小模型,不填则用 agent 的主模型
maxTools最多传给主模型的工具数量
alwaysInclude始终包含的工具名列表,不受 maxTools 限制
systemPrompt自定义筛选指令,默认 "Your goal is to select the most relevant tools..."

执行流程:

总结对比

方法优先级强度适用场景缺点
工具排列顺序⭐ 很弱差别不大时微调基本没有实际效果
优化 description⭐⭐ 中等所有场景需要人工精心编写
tool_choice⭐⭐⭐ 很强已知必须调用某工具灵活性差,只能手动写死
llmToolSelectorMiddleware⭐⭐ 智能筛选工具数量多(10+)时多一次小模型调用,有额外成本

推荐优先级:优先写好 description → 工具多时加 llmToolSelectorMiddleware → 需要强制时用 tool_choice

Middleware

示例代码

  1. wrapModelCall:拦截对模型的调用

    主要用途:

    • 动态选择模型
    • 请求日志与监控
    • 上下文注入(用户权限、回话数据)
  2. wrapToolCall:拦截工具执行

    主要用途:

    • 错误处理与重试
    • 工具结果转换
    • 工具执行前的权限检查

执行流程

内置中间件

示例代码

  1. summarizationMiddleware:总结长对话,使其保持在上下文范围内

ReAct模式

MCP

传输方式通信方法适用场景示例
Streamable HTTP基于网络(客户端 → 服务器,通过网络传输)当 MCP 服务器通过 URL 访问时(本地或远程){ transport: "http", url: "https://api.mycompany.com/mcp" }
stdio基于进程(父进程 ↔ 子进程,通过数据流传输)当 MCP 服务器作为应用程序的子进程运行时{ transport: "stdio", command: "node", args: ["/path/to/server.js"] }

文档、嵌入与语义搜索

加载文档

所有 Document Loader 的输出格式统一:返回 Document[],每个文档包含 pageContent(文本内容)和 metadata(元数据),方便后续统一处理(分割、向量化、存入向量数据库等)。

示例代码

加载 PDF

加载 PDF 需要额外安装 pdf-parse 依赖:

bash
pnpm add pdf-parse
ts
import { PDFLoader } from "@langchain/classic/document_loaders/fs/pdf";

// 加载整个 PDF(默认每页一个文档)
const loader = new PDFLoader("path/to/file.pdf");
const docs = await loader.load();

// 不拆页,合并为一个文档
const loader2 = new PDFLoader("path/to/file.pdf", { splitPages: false });
const docs2 = await loader2.load();

支持的文件类型总览

文本类

格式Loader说明
.txtTextLoader纯文本文件
.csvCSVLoaderCSV 表格数据
.jsonJSONLoaderJSON 文件,可指定 jq schema 提取特定字段
.jsonlJSONLoader逐行 JSON

文档类

格式Loader额外依赖
.pdfPDFLoaderpdf-parse
.docxDocxLoadermammoth
.epubEPubLoaderepub2

网页 / 远程

来源Loader说明
URLCheerioWebBaseLoader用 Cheerio 解析网页
URLPuppeteerWebBaseLoader用 Puppeteer 渲染动态页面
URLPlaywrightWebBaseLoader用 Playwright
SitemapSitemapLoader批量抓取站点地图中的页面
GitHubGithubRepoLoader加载 GitHub 仓库文件

第三方集成

来源Loader说明
NotionNotionDBLoader / NotionAPILoaderNotion 数据库/页面
FigmaFigmaFileLoaderFigma 设计文件
Hugging FaceHFDocumentLoaderHF 数据集
YouTubeYoutubeLoader提取字幕/转录文本

拆分

文档拆分是 RAG 管道中的关键环节。加载文档后,需要将其拆分为合适大小的片段,以便后续向量化和语义搜索。

可用分割器总览

分割器说明适用场景
CharacterTextSplitter按单个分隔符分割(默认 \n\n简单文本,结构均匀
RecursiveCharacterTextSplitter按分隔符数组递归分割首选,通用场景
TokenTextSplitter按 token 数分割(基于 tiktoken)需精确控制 token 时
MarkdownTextSplitterMarkdown 语法感知分割Markdown 文档
LatexTextSplitterLaTeX 语法感知分割LaTeX 文档

RecursiveCharacterTextSplitter

这是最常用的分割器,递归尝试分隔符数组,直到每个片段不超过目标大小。

示例代码

核心参数
ts
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";

const splitter = new RecursiveCharacterTextSplitter({
  chunkSize: 300,       // 每个块的目标大小(字符数)
  chunkOverlap: 50,     // 块之间的重叠(字符数)
  separators: ["\n\n", "\n", " ", ""],  // 分隔符优先级数组
  keepSeparator: true,  // 是否在输出中保留分隔符
  lengthFunction: (text) => text.length,  // 自定义长度计算函数
});
参数类型默认值说明
chunkSizenumber1000每个块的目标大小
chunkOverlapnumber200相邻块的重叠大小,用于保留上下文连续性
separatorsstring[]["\n\n", "\n", " ", ""]分隔符数组,按优先级排列
keepSeparatorbooleantrue是否在分割结果中保留分隔符
lengthFunction(text) => numbertext.length自定义长度计算,可用于按 token 计数

chunkOverlap 必须小于 chunkSize,否则抛出 Error: Cannot have chunkOverlap >= chunkSize

递归分割算法原理
  1. separators[0]\n\n)开始,检查文本是否包含该分隔符
  2. 如果包含,按该分隔符拆分;如果不包含,尝试下一个
  3. 拆分后的每个片段如果仍大于 chunkSize,则用剩余分隔符递归处理
  4. 如果所有分隔符都用完仍超大,该片段原样保留(并输出警告)
  5. 小于 chunkSize 的片段通过 mergeSplitschunkOverlap 重叠合并
ts
// 简化示意
split("\n\n") → ["段落1", "段落2", "段落3"]
  ↓ 某段仍超大
split("\n") → ["行1", "行2", "行3"]
  ↓ 某行仍超大
split(" ") → ["词1", "词2", "词3"]
  ↓ 仍超大
split("") → ["字1", "字2", "字3"]  // 逐字符分割
如何确定最佳 chunkSize?

没有固定规则,需综合考虑以下因素:

因素小 chunk(200-500)大 chunk(1000-2000)
检索精度✅ 更精确,匹配关键词更集中❌ 可能包含无关内容
上下文完整性❌ 可能丢失上下文✅ 保留更多上下文
嵌入质量语义信号更聚焦语义信号可能被稀释
存储成本更多向量更少向量
适用场景FAQ、代码片段、精确问答文章摘要、长文分析、叙事性内容

关键约束:chunkSize < 嵌入模型的 token 限制

嵌入模型最大 token 数建议 chunkSize(字符)
text-embedding-3-small8191≤ 3000
text-embedding-3-large8191≤ 3000
text-embedding-ada-0028191≤ 3000

1 个中文字符 ≈ 2 tokens,1 个英文单词 ≈ 1 token。粗略换算:3000 字符英文 ≈ 3000 tokens,3000 字符中文 ≈ 6000 tokens。

chunkOverlap 推荐比例

比例效果
5-10%轻微重叠,适合结构化内容(FAQ、代码)
10-20%适中重叠,最常用,保留上下文
20-30%较大重叠,适合需要强连续性的长文

实际调优方法

ts
// 1. 先用默认值跑一次
const splitter = new RecursiveCharacterTextSplitter({ chunkSize: 500, chunkOverlap: 100 });
const docs = await splitter.createDocuments([text]);

// 2. 检查每个 chunk 的实际大小和内容
docs.forEach((doc, i) => {
  console.log(`Chunk ${i + 1}: ${doc.pageContent.length} chars`);
  console.log(doc.pageContent.substring(0, 100) + "...\n");
});

// 3. 根据结果调整:
//    - 检索结果不精确 → 减小 chunkSize
//    - 回答不完整/丢失上下文 → 增大 chunkSize 或 chunkOverlap
//    - 经常出现超大 chunk → 检查是否有无分隔符的长段落
重叠机制深入理解

chunkOverlap 不是按字符数精确重叠,而是按分割单元(split)粒度操作的。理解这一点至关重要,否则会出现"改了 overlap 但结果没变化"的困惑。

源码逻辑 (mergeSplits 方法):

ts
// 伪代码简化
for (const split of splits) {
  if (total + split.len > chunkSize) {
    output(currentDoc);                   // 1. 输出当前 chunk
    while (total > chunkOverlap) {        // 2. 从开头逐个移除分割单元
      total -= currentDoc[0].len;         //    直到 total ≤ chunkOverlap
      currentDoc.shift();
    }
  }
  currentDoc.push(split);                 // 3. 加入新分割单元
  total += split.len;
}

关键:循环条件是 total > chunkOverlap,每次移除的是整个分割单元,不是截取部分字符。如果每个分割单元本身就大于 chunkOverlap,则什么都保留不了

实际案例分析 — 以 03-overlap.ts 中的《小石潭记》文本为例:

chunkSize: 100,  分隔符: \n
分割单元长度: [84, 48, 30, 43, 35](5个句子)
chunkOverlap生成第3个chunk时的 while 循环能保留什么?最终结果
0total=78 > 0 → 移除48 → total=30 → 30>0 → 移除30 → total=0[s0] [s1+s2] [s3+s4] 共3块
20total=78 > 20 → 移除48 → total=30 → 30>20 → 移除30 → total=0(最小单元30 > 20)[s0] [s1+s2] [s3+s4] ← 和0一模一样
40total=78 > 40 → 移除48 → total=30 → 30≤40 ✅ 停止保留 s2(30字符)[s0] [s1+s2] [s2+s3] [s4] ✅ 有重叠

结论

情况说明
分割单元 < chunkOverlap重叠生效,末尾单元被保留到下一个 chunk
分割单元 ≥ chunkOverlap重叠失效,行为和 chunkOverlap: 0 相同
分割单元远小于 chunkSize重叠效果最显著,上下文连接平滑

实践建议

  • chunkOverlap 应 ≥ 典型分割单元的大小,否则等于没设
  • 如果文本按短句/短行分割(如代码、对话),小 overlap(10-20)就能生效
  • 如果文本按长段落分割(如文章),需要更大的 overlap(50-100)才能看到效果
  • 如果调整 overlap 后结果没变化 → 打印每个分割单元的长度,确认 chunkOverlap 是否大于最小单元

在特定分隔符上分开

有 4 种方式可以控制拆分行为:

方式一:自定义 separators 数组

separators 按优先级排列,分割器会先尝试第一个,如果该分隔符不存在则尝试下一个,如果分割后仍然太大则递归进入下一级。

ts
// 优先按标题分割,然后是段落,最后是行
const splitter = new RecursiveCharacterTextSplitter({
  chunkSize: 500,
  chunkOverlap: 50,
  separators: ["\n## ", "\n### ", "\n\n", "\n", " ", ""],
});

常见的自定义分隔符模式:

场景推荐 separators说明
通用文本["\n\n", "\n", " ", ""]默认值,按段落 → 行 → 词 → 字符
Markdown["\n## ", "\n### ", "\n\n", "\n", " ", ""]优先按标题拆分
代码["\nfunction ", "\nclass ", "\n\n", "\n", " ", ""]按函数/类拆分
对话记录["\nUser:", "\nAssistant:", "\n\n", "\n"]按对话轮次拆分
HTML["<p>", "<div>", "<br>", "\n", " ", ""]按 HTML 标签拆分
方式二:MarkdownTextSplitter

MarkdownTextSplitterRecursiveCharacterTextSplitter 的子类,内置了 Markdown 感知的分隔符:

ts
import { MarkdownTextSplitter } from "@langchain/textsplitters";

const splitter = new MarkdownTextSplitter({
  chunkSize: 500,
  chunkOverlap: 50,
});
const docs = await splitter.createDocuments([markdownText]);

内置的 Markdown 分隔符(按优先级):

ts
[
  "\n## ",      // 二级标题
  "\n### ",     // 三级标题
  "\n#### ",    // 四级标题
  "\n##### ",   // 五级标题
  "\n###### ",  // 六级标题
  "```\n\n",    // 代码块
  "\n\n***\n\n", // 水平线(星号)
  "\n\n---\n\n", // 水平线(横线)
  "\n\n___\n\n", // 水平线(下划线)
  "\n\n",       // 段落
  "\n",         // 行
  " ",          // 词
  ""            // 字符
]
方式三:LatexTextSplitter
ts
import { LatexTextSplitter } from "@langchain/textsplitters";

const splitter = new LatexTextSplitter({
  chunkSize: 500,
  chunkOverlap: 50,
});
const docs = await splitter.createDocuments([latexText]);

内置的 LaTeX 分隔符(按优先级):

ts
[
  "\n\\chapter{",       // 章
  "\n\\section{",       // 节
  "\n\\subsection{",    // 小节
  "\n\\subsubsection{", // 子小节
  "\n\\begin{enumerate}", // 枚举列表
  "\n\\begin{itemize}",   // 无序列表
  "\n\\begin{align}",     // 公式对齐
  "$$",                   // 行间公式
  "$",                    // 行内公式
  "\n\n", "\n", " ", ""   // 通用分隔符
]
方式四:fromLanguage() 工厂方法

RecursiveCharacterTextSplitter 提供 fromLanguage() 静态方法,为 16 种编程语言和标记语言内置了语法感知的分隔符:

ts
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";

// Python 代码:按 class / def / 缩进分割
const splitter = RecursiveCharacterTextSplitter.fromLanguage("python", {
  chunkSize: 500,
  chunkOverlap: 50,
});
const docs = await splitter.createDocuments([pythonCode]);

支持的语言:

ts
type SupportedTextSplitterLanguage =
  | "cpp" | "go" | "java" | "js" | "php" | "proto" | "python" | "rst"
  | "ruby" | "rust" | "scala" | "swift" | "markdown" | "latex" | "html" | "sol";

各语言主要分隔符模式:

语言主要分隔符说明
python\nclass , \ndef , \n\tdef 按类、方法、嵌套方法
js\nfunction , \nconst , \nlet , \nclass 按函数、变量声明、类
java\nclass , \npublic , \nprotected , \nprivate 按类、访问修饰符
rust\nfn , \nconst , \nlet , \nmatch 按函数、绑定、匹配
go\nfunc , \nvar , \nconst , \ntype 按函数、声明、类型
cpp\nclass , \nvoid , \nint , \nif 按类、类型、控制流
html<body>, <div>, <p>, <h1> ~ <h6>按 HTML 标签
markdown\n## ~ \n###### , ```\n\n按标题、代码块
latex\n\\chapter{, \n\\section{, $$, $按章节、公式
ruby\ndef , \nclass , \nif , \nunless 按方法、类、条件
swift\nfunc , \nclass , \nstruct , \nenum 按函数、类型定义
scala\nclass , \nobject , \ndef , \nval 按类、对象、方法
php\nfunction , \nclass , \nforeach 按函数、类、循环
proto\nmessage , \nservice , \nenum 按消息、服务、枚举
rst\n===\n, \n---\n, \n***\n, \n.. 按 RST 标题、指令
sol\ncontract , \nfunction , \nmodifier 按合约、函数、修饰符

进阶参数

keepSeparator — 控制分隔符是否保留
ts
// keepSeparator: true(默认) → 分隔符保留在结果中
// 例如按 "\n\n" 分割 "Hello\n\nWorld"
// → ["Hello\n\n", "World"]  (分隔符保留)

// keepSeparator: false → 分隔符被移除
// → ["Hello", "World"]      (分隔符丢弃)

默认为 true,对于 RecursiveCharacterTextSplitter 通常推荐保留,因为分隔符本身包含语义信息(如段落间距、标题层级)。

lengthFunction — 自定义长度计算

默认按字符数计算长度,但有时需要按 token 计算:

ts
import { encodingForModel } from "js-tiktoken";

const encoding = encodingForModel("gpt-4o");

const splitter = new RecursiveCharacterTextSplitter({
  chunkSize: 500,        // 按 token 而非字符
  chunkOverlap: 50,
  lengthFunction: (text) => encoding.encode(text).length,
});

使用 token 计算更精确,但性能稍差(需要编码每个片段)。对于大多数场景,按字符数即可。

TokenTextSplitter — 按 token 分割

TokenTextSplitter 直接按 token 数分割,不使用分隔符,适合需要精确控制 token 的场景:

ts
import { TokenTextSplitter } from "@langchain/textsplitters";

const splitter = new TokenTextSplitter({
  chunkSize: 200,           // 每块 200 tokens
  chunkOverlap: 20,         // 重叠 20 tokens
  encodingName: "gpt2",     // 使用 GPT-2 的 tokenizer(默认)
});

const docs = await splitter.createDocuments([text]);
参数默认值说明
chunkSize1000每块 token 数
chunkOverlap200重叠 token 数
encodingName"gpt2"tokenizer 编码名称,可选 "gpt2", "r50k_base", "p50k_base", "cl100k_base"

TokenTextSplitter 没有分隔符概念,纯粹按 token 索引切割,可能导致语句被截断。一般推荐使用 RecursiveCharacterTextSplitter + lengthFunction 的组合来实现按 token 分割。

代码示例

ts
// src/section6/02-splitting.ts
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";

const text = `
[Long article about AI and machine learning...]
`;

const splitter = new RecursiveCharacterTextSplitter({
  chunkSize: 300, // Target size in characters
  chunkOverlap: 50, // Overlap between chunks (preserves context)
});

const docs = await splitter.createDocuments([text]);

console.log(`Split into ${docs.length} chunks`);

docs.forEach((doc, i) => {
  console.log(`\nChunk ${i + 1}:`);
  console.log(doc.pageContent);
  console.log(`Length: ${doc.pageContent.length} characters`);
});

元数据

元数据是文档的"标签",在 RAG 管道中有两大核心作用:

作用说明
来源追踪检索后可知每个 chunk 来自哪个文件、哪一页、哪一行
检索过滤在向量搜索时按类别、日期、作者等条件缩小范围

元数据与分割

Document 的结构:{ pageContent: string, metadata: Record<string, any> }

RecursiveCharacterTextSplitter.splitDocuments() 自动将元数据继承到所有分块:

ts
// 分割前:1 个文档,带 metadata
const doc = new Document({
  pageContent: "长文本...",
  metadata: { source: "guide.md", category: "tutorial" },
});

// 分割后:N 个分块,每个都继承了原始的 metadata
const chunks = await splitter.splitDocuments([doc]);
chunks.forEach((c) => console.log(c.metadata));
// { source: "guide.md", category: "tutorial" }
// { source: "guide.md", category: "tutorial" }
// ...

如何通过元数据过滤搜索结果?

过滤功能由向量存储提供,需要在安装对应的向量数据库包后使用。以下是常用向量存储的过滤方式:

向量存储包名过滤语法示例
Chroma@langchain/community对象语法{ category: "tutorial" }
Pinecone@langchain/pinecone对象语法 + 操作符{ date: { $gte: "2024-01-01" } }
PGVector@langchain/communitySQL WHERE 语法metadata->>'category' = 'tutorial'
Qdrant@langchain/qdrantmust / must_not 结构{ must: [{ key: "category", match: { value: "tutorial" } }] }
Weaviate@langchain/weaviateGraphQL where 语法{ operator: "Equal", path: ["category"], valueText: "tutorial" }

通用模式:所有向量存储都支持在 similaritySearch() 中传入 filter 参数:

ts
import { Chroma } from "@langchain/community/vectorstores/chroma";

// 1. 加载 + 分割 + 附带元数据
const docs = [
  new Document({
    pageContent: "LangChain.js is a framework...",
    metadata: { source: "guide.md", category: "tutorial", date: "2024-01-15" },
  }),
  new Document({
    pageContent: "RAG systems combine retrieval with generation...",
    metadata: { source: "rag.md", category: "extended", date: "2024-02-20" },
  }),
];

// 2. 创建嵌入 → 存入向量数据库
const store = await Chroma.fromDocuments(docs, embeddings, {
  collectionName: "my-docs",
});

// 3. 语义搜索 + 元数据过滤
const results = await store.similaritySearch("What is LangChain?", 5, {
  category: "tutorial",              // 只搜索教程类别
});

// 4. 按日期范围过滤(Pinecone 示例)
import { PineconeStore } from "@langchain/pinecone";
const results = await pineconeStore.similaritySearch("What is LangChain?", 5, {
  date: { $gte: "2024-01-01", $lte: "2024-12-31" }, // 日期范围
  category: { $in: ["tutorial", "extended"] },        // 多类别
});

常用过滤操作符(以 Pinecone / Chroma 为例):

操作符示例说明
$eq / 直接值{ category: "tutorial" }等于
$ne{ category: { $ne: "tutorial" } }不等于
$in{ category: { $in: ["a", "b"] } }在集合中
$gte / $lte{ date: { $gte: "2024-01-01" } }大于等于 / 小于等于
$exists{ author: { $exists: true } }字段存在

文档加载后可以添加自定义元数据吗?

可以。Document.metadata 是普通的 Record<string, any> 对象,随时可读写

方式一:加载后统一添加

ts
import { TextLoader } from "@langchain/classic/document_loaders/fs/text";

const loader = new TextLoader("data/sample.txt");
const docs = await loader.load();

// 加载后添加自定义元数据
docs.forEach((doc) => {
  doc.metadata.loadedAt = new Date().toISOString();  // 加载时间戳
  doc.metadata.language = "zh-CN";                     // 语言标注
  doc.metadata.tags = ["tutorial", "beginner"];       // 自定义标签
});

方式二:分割后按分块差异化添加

ts
const chunks = await splitter.splitDocuments(docs);

// 给不同分块添加不同元数据
chunks.forEach((chunk, i) => {
  chunk.metadata.chunkIndex = i;              // 分块序号
  chunk.metadata.isFirst = i === 0;            // 是否第一个分块
  chunk.metadata.isLast = i === chunks.length - 1;  // 是否最后一个分块
});

方式三:加载器自带元数据 + 追加自定义字段

ts
const pdfLoader = new PDFLoader("report.pdf");
const docs = await pdfLoader.load();

// PDFLoader 自动添加了 { source, pdf, loc: { pageNumber } }
// 可以在此基础上追加自定义字段
docs.forEach((doc) => {
  doc.metadata.documentType = "report";          // 文档类型
  doc.metadata.confidentiality = "internal";     // 保密级别
  doc.metadata.ingestedBy = "pipeline-v2";       // 处理管道标识
});

注意:元数据在后续存储为向量时会影响索引大小。只存储真正需要过滤的字段,避免冗余。

代码示例

示例代码

ts
// src/section6/04-metadata.ts
import { Document } from "@langchain/core/documents";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";

// Create documents with metadata
const docs = [
  new Document({
    pageContent: "LangChain.js is a framework for building AI apps...",
    metadata: {
      source: "langchain-guide.md",
      category: "tutorial",
      date: "2024-01-15",
      author: "Tech Team",
    },
  }),
  new Document({
    pageContent: "RAG systems combine retrieval with generation...",
    metadata: {
      source: "rag-explained.md",
      category: "extended",
      date: "2024-02-20",
    },
  }),
];

// Metadata is preserved when splitting
const splitter = new RecursiveCharacterTextSplitter({
  chunkSize: 200,
  chunkOverlap: 20,
});

const splitDocs = await splitter.splitDocuments(docs);

splitDocs.forEach((doc, i) => {
  console.log(`\nChunk ${i + 1}:`);
  console.log("Content:", doc.pageContent.substring(0, 50) + "...");
  console.log("Metadata:", doc.metadata);
});

上次更新:

知识是财富,分享是快乐!