
项目初始化
- 使用
bun创建一个空项目
bun init- 安装依赖
pnpm add zod langchain @langchain/openai @langchain/core- 创建环境变量
.env
OPENAI_API_KEY=
OPENAI_MODEL=
OPENAI_API_BASE_URL=- 校验环境变量
// 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,
};在程序入口文件引入
// index.ts
import "./types/env.ts";模型抽象层ChatOpenAI
ChatOpenAI的简单使用
// 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 详细参数
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": {}
}
}
Message

SystemMessage vs HumanMessage vs AIMessage
它们都继承自 BaseMessage,核心区别在于 角色定位 和 对模型行为的控制力:
SystemMessage | HumanMessage | |
|---|---|---|
| 角色 | 系统指令/设定 | 用户输入/提问 |
| 作用 | 定义 AI 的行为规则、人格、约束 | 提出具体问题或需求 |
| 谁说的 | 开发者(不是最终用户) | 最终用户 |
| 模型对待方式 | 视为不可违背的指令,优先级最高 | 视为需要回应的内容 |
| 典型位置 | 消息列表的开头,通常只有一条 | 紧随 SystemMessage 之后,可多条 |
结合代码来理解:
const messages = [
// 1️⃣ SystemMessage:设定 AI 的"人设"和行为规则
new SystemMessage(
"你是一个代码助手,每次回答后,都需要提示用户,'该回答由AI产生,请仔细检查!'",
),
// 2️⃣ HumanMessage:用户的实际提问
new HumanMessage("列举一下ts中不经常用到的方法或者API"),
];- SystemMessage 告诉模型:"你是代码助手" + "每次回答后必须加免责提示"——这是强约束,模型会在整个对话中遵守。
- HumanMessage 是用户的具体问题,模型会据此生成回答内容。
简单类比——把模型想象成一个客服:
SystemMessage= 公司给客服的培训手册:"你是技术客服,回答完必须说'仅供参考'"HumanMessage= 客户打来的电话:"TypeScript 有哪些冷门 API?"
客服会按照手册的规则来回答客户的问题,但手册本身不是客户看到的回答内容。
AIMessage 代表模型的历史回复。在多轮对话中,消息列表通常是这样的结构:
const messages = [
new SystemMessage("你是一个代码助手..."), // 系统设定
new HumanMessage("第一个问题"), // 第1轮 - 用户
new AIMessage("第一个回答"), // 第1轮 - AI
new HumanMessage("第二个问题"), // 第2轮 - 用户
];这样模型就能理解完整的对话上下文,实现多轮对话。
除了以上三种,LangChain 还提供了其他类型的消息,如 FunctionMessage 和 ToolMessage,用于处理函数调用和工具使用场景。
也支持字典模式{ role: "system", content: "你是一个代码助手..." }
聊天模式
流式输出深入理解
process.stdout.write 类型报错与语法说明
chunk.content 的类型定义是 string | (ContentBlock | Text)[],TypeScript 不允许直接把可能是数组的值传给 process.stdout.write()。
修复方式:先做类型收窄,只写入字符串部分。
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)的语法。
const result = await model.stream(messages);
for await (const chunk of result) {
process.stdout.write(content);
}JavaScript 引擎会自动把它展开成类似这样的代码:
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 造成的:
await iterator.next()遇到 pending Promise,把当前函数的执行权交还给事件循环- 当模型服务端推送新的 chunk 过来,Promise resolve,
await恢复执行 - 循环体执行完,再次
await iterator.next(),重复直到done: true
同步 Symbol.iterator | 异步 Symbol.asyncIterator | |
|---|---|---|
next() 返回 | { value, done } | Promise<{ value, done }> |
| 数据等待 | 立即拿到,CPU 计算 | 需要 I/O(网络/文件) |
| 语法 | for...of | for await...of |
| 是否会"暂停" | 不会 | 会(await Promise) |
流式输出时收集完整内容
可以边流边拼接到一个变量中,循环结束后就得到了完整回答:
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收集的两种方式
- 非流式
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}`);
}- 流式 一般在最后一个chunk返回usage
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}`);
}降低成本的简单方法
- 使用
maxTokens限制响应长度 - 压缩对话记录
const recentMessages = messages.slice(-10);
const response = await model.invoke(recentMessages);Templates
Messages vs Templates
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}`);
}Messages | Templates | |
|---|---|---|
| 本质 | 手动拼接,硬编码 | 可复用的"模板函数" |
| 变量替换 | 不支持(字符串拼接 | 支持 {变量名} 插值 |
| 复用性 | 差,每次写一套 | 好,一次定义多处调用 |
| 适合场景 | Agent、动态工作流、多步推理、工具集成 | RAG、复用提示、变量替换、一致性 |
| 链式调用 | 不行 | 支持 `.pipe(model) |
pip
.pipe()就像管道符|`,把前一个组件的输出作为后一个组件的输入。链一旦建好,你就把它当一个整体来调用,不需要手动处理中间转换。
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():
const result = await templateChain.invoke({
text: "今天天气怎么样",
language: "英文",
});为什么叫"可调用的链"?
.pipe() 返回的是 Runnable 对象,它有一套统一的调用接口:
| 方法 | 作用 |
|---|---|
.invoke(input) | 单次调用 |
.stream(input) | 流式输出 |
.batch(inputs) | 批量调用 |
.pipe(next) | 继续拼接下一个组件 |
链可以继续拼接
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
两者的核心区别在于输出格式和目标模型类型:
ChatPromptTemplate | PromptTemplate | |
|---|---|---|
| 输出类型 | BaseMessage[](消息数组) | string(纯字符串) |
| 目标模型 | 聊天模型(Chat Models)如 gpt-4, ChatOpenAI | 文本补全模型(LLMs)如 text-davinci-003 |
| 能否设置 System 角色 | ✅ 可以,明确区分 system/human/ai | ❌ 不行,只有一个字符串模板 |
| 现代推荐度 | ⭐ 首选,所有聊天场景都用它 | 仅用于特定旧模型或纯字符串场景 |
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() | |
|---|---|---|
| 做什么 | 仅渲染模板 | 渲染模板 + 调用模型 |
| 返回值 | string 或 BaseMessage[] | AIMessage(模型回复) |
| 消耗 Token | ❌ 不消耗 | ✅ 消耗 |
| 灵活性 | 高,可以拿到 prompt 做其他事 | 低,直接出结果 |
| 代码量 | 需要再写一行 model.invoke(prompt) | 一步完成 |
什么时候用 .format()?
想查看、调试或二次处理生成的 prompt 时:
const prompt = await template.format({...});
console.log("实际发给模型的内容:", prompt); // 调试
await saveToDB(prompt); // 存日志
const result = await model.invoke(prompt); // 再手动调用结构化输出
基本结构化输出
用 z.object() 来定义模式,用 [model].withStructuredOutput() 来获取类型化、验证过的数据。
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 详解
| jsonSchema | functionCalling | jsonMode | |
|---|---|---|---|
| API 参数 | response_format + type: json_schema | tools + tool_choice | response_format + type: json_object |
| 返回方式 | content 直接是 JSON | tool_calls 返回 | content 是 JSON |
| Schema 强制 | ✅ 严格 | ✅ 严格 | ❌ 不保证 |
| strict 模式 | ✅ 支持 | ✅ 支持 | ❌ 不支持 |
| 模型兼容性 | 仅 GPT-4o+ | 大多数兼容 API ✅ | 较广泛 |

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

简单的工具调用
多工具调用
哪些因素会影响LLM选择工具?
工具名称、工具描述、参数模式、用户的问题
Agent
createAgent() 底层到底做什么?
createAgent 就是 new ReactAgent(),而 ReactAgent 在构造函数中用 LangGraph StateGraph 构建了一个 ReAct 循环的状态机:
// langchain/dist/agents/index.js
function createAgent(params) {
return new ReactAgent(params);
}整个流程可以用这张图概括:
构造函数核心步骤
1. 校验 & 合并工具
// 校验必须提供 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(状态图)
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_agent | BeforeAgentNode | 中间件:在 Agent 运行前执行 |
middleware名.before_model | BeforeModelNode | 中间件:在 LLM 调用前执行 |
middleware名.after_model | AfterModelNode | 中间件:在 LLM 调用后执行 |
middleware名.after_agent | AfterAgentNode | 中间件:在 Agent 运行后执行 |
在没有 middleware 的场景下,只有 agent 和 tools 两个核心节点。
4. 注册边(Edges)—— 循环的关键
// START → agent(入口)
graph.addEdge(START, "agent");
// agent → 路由决定
graph.addConditionalEdges("agent", createModelRouter(), ["tools", END]);
// tools → agent(回到 LLM,形成循环)
graph.addEdge("tools", "agent");核心路由逻辑在 #createModelRouter 中:
(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. 编译图
this.#graph = graph.compile({
checkpointer: options.checkpointer,
store: options.store,
name: options.name,
transformers: [createToolCallTransformer([]), ...],
});compile() 把 StateGraph 变成一个可执行的 CompiledGraph,支持 invoke() 和 stream()。
invoke() 执行流程
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 中的手动写法:
// 手动: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 是否超限:
// @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:
// @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:
// @langchain/langgraph/dist/pregel/utils/config.js
const DEFAULT_RECURSION_LIMIT = 25;可以通过 withConfig 修改:
const agent = createAgent({ model, tools: commonTools });
const result = await agent.invoke(
{ messages: [new HumanMessage(query)] },
{ recursionLimit: 50 }, // 将递归限制调为 50
);第 2 层:AgentNode 内部的 #areMoreStepsNeeded(优雅降级)
在到达硬上限之前,如果状态中存在 remainingSteps 字段,AgentNode 会提前拦截并返回一条礼貌消息而非报错:
// 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() 返回替代消息:
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(可选的细粒度限制)
这是可以主动加入的中间件,提供更精细的控制:
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:
// #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 硬上限 | recursionLimit | 25 步 | 超限抛 GraphRecursionError |
| AgentNode 优雅降级 | remainingSteps 检查 | undefined(不启用) | 步数不足时返回 "Sorry" 消息 |
| toolCallLimitMiddleware | 工具调用计数 | 需手动添加 | 可选 continue / error / end |
Agent 如何决定使用哪个工具?
这不是 Agent 代码决定的,而是 LLM(大语言模型) 决定的。整条链路如下:
第 1 步:工具定义 → JSON Schema
当传入 tools: commonTools,ReactAgent 在构造函数中收集所有工具:
const toolClasses = [...options.tools, ...middlewareTools];然后在 AgentNode.#invokeModel 中,每次调用 LLM 前通过 #bindTools 将工具绑定到模型:
const modelWithTools = await this.#bindTools(
request.model,
request,
structuredResponseFormat,
);#bindTools 内部调用 ChatOpenAI.bindTools(),最终通过 convertToOpenAIFunction 将每个工具转为 OpenAI API 格式:
// @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:
{
"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 步:路由 → 执行 → 回传
影响工具选择的四个因素
| 因素 | 代码中对应 | 作用 |
|---|---|---|
工具名称 name | name: "calculator" | LLM 通过名称做语义匹配 |
工具描述 description | "Useful for performing mathematical calculations..." | 最关键的因素,LLM 以此判断何时使用该工具 |
参数模式 schema | z.object({ expression: z.string().describe(...) }) | 帮助 LLM 理解需要传什么参数 |
| 用户输入 | HumanMessage 的 content | LLM 综合理解用户意图后匹配最合适的工具 |
为什么
description最关键? LLM 无法看到工具的实现代码,它唯一的依据就是name+description+schema。如果发现 LLM 经常选错工具,优先改进description。
可以优先选择某些工具吗?
可以,有 4 种机制,从简单到高级:
方法 1:工具数组顺序(隐式优先级)
OpenAI API 文档指出:排在 tools 数组前面的工具,LLM 会略微偏向选择:
const agent = createAgent({
model,
// calculator 排在前面,LLM 会略微偏向它
tools: [calculatorTool, readFileTool, writeFileTool, listDirTool],
});⚠️ 效果微弱,只是"倾向"而非"保证"。当工具描述明显指向不同场景时(如计算器 vs 文件操作),顺序影响很小。
方法 2:优化 description(最实用)
这是影响 LLM 选工具的核心因素。 LLM 根本看不到工具的实现代码,只能看到 name、description、schema 三个字段。精准的 description 就是指令:
// ❌ 模糊描述
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 必须调用某个工具:
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 个工具,然后再把精选的工具列表传给主模型。
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
wrapModelCall:拦截对模型的调用
主要用途:
- 动态选择模型
- 请求日志与监控
- 上下文注入(用户权限、回话数据)
wrapToolCall:拦截工具执行
主要用途:
- 错误处理与重试
- 工具结果转换
- 工具执行前的权限检查
执行流程
内置中间件
- 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 依赖:
pnpm add pdf-parseimport { 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 | 说明 |
|---|---|---|
.txt | TextLoader | 纯文本文件 |
.csv | CSVLoader | CSV 表格数据 |
.json | JSONLoader | JSON 文件,可指定 jq schema 提取特定字段 |
.jsonl | JSONLoader | 逐行 JSON |
文档类
| 格式 | Loader | 额外依赖 |
|---|---|---|
.pdf | PDFLoader | pdf-parse |
.docx | DocxLoader | mammoth |
.epub | EPubLoader | epub2 |
网页 / 远程
| 来源 | Loader | 说明 |
|---|---|---|
| URL | CheerioWebBaseLoader | 用 Cheerio 解析网页 |
| URL | PuppeteerWebBaseLoader | 用 Puppeteer 渲染动态页面 |
| URL | PlaywrightWebBaseLoader | 用 Playwright |
| Sitemap | SitemapLoader | 批量抓取站点地图中的页面 |
| GitHub | GithubRepoLoader | 加载 GitHub 仓库文件 |
第三方集成
| 来源 | Loader | 说明 |
|---|---|---|
| Notion | NotionDBLoader / NotionAPILoader | Notion 数据库/页面 |
| Figma | FigmaFileLoader | Figma 设计文件 |
| Hugging Face | HFDocumentLoader | HF 数据集 |
| YouTube | YoutubeLoader | 提取字幕/转录文本 |
拆分
文档拆分是 RAG 管道中的关键环节。加载文档后,需要将其拆分为合适大小的片段,以便后续向量化和语义搜索。
可用分割器总览
| 分割器 | 说明 | 适用场景 |
|---|---|---|
CharacterTextSplitter | 按单个分隔符分割(默认 \n\n) | 简单文本,结构均匀 |
RecursiveCharacterTextSplitter | 按分隔符数组递归分割 | 首选,通用场景 |
TokenTextSplitter | 按 token 数分割(基于 tiktoken) | 需精确控制 token 时 |
MarkdownTextSplitter | Markdown 语法感知分割 | Markdown 文档 |
LatexTextSplitter | LaTeX 语法感知分割 | LaTeX 文档 |
RecursiveCharacterTextSplitter
这是最常用的分割器,递归尝试分隔符数组,直到每个片段不超过目标大小。
核心参数
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 300, // 每个块的目标大小(字符数)
chunkOverlap: 50, // 块之间的重叠(字符数)
separators: ["\n\n", "\n", " ", ""], // 分隔符优先级数组
keepSeparator: true, // 是否在输出中保留分隔符
lengthFunction: (text) => text.length, // 自定义长度计算函数
});| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
chunkSize | number | 1000 | 每个块的目标大小 |
chunkOverlap | number | 200 | 相邻块的重叠大小,用于保留上下文连续性 |
separators | string[] | ["\n\n", "\n", " ", ""] | 分隔符数组,按优先级排列 |
keepSeparator | boolean | true | 是否在分割结果中保留分隔符 |
lengthFunction | (text) => number | text.length | 自定义长度计算,可用于按 token 计数 |
chunkOverlap必须小于chunkSize,否则抛出Error: Cannot have chunkOverlap >= chunkSize。
递归分割算法原理
- 从
separators[0](\n\n)开始,检查文本是否包含该分隔符 - 如果包含,按该分隔符拆分;如果不包含,尝试下一个
- 拆分后的每个片段如果仍大于
chunkSize,则用剩余分隔符递归处理 - 如果所有分隔符都用完仍超大,该片段原样保留(并输出警告)
- 小于
chunkSize的片段通过mergeSplits按chunkOverlap重叠合并
// 简化示意
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-small | 8191 | ≤ 3000 |
text-embedding-3-large | 8191 | ≤ 3000 |
text-embedding-ada-002 | 8191 | ≤ 3000 |
1 个中文字符 ≈ 2 tokens,1 个英文单词 ≈ 1 token。粗略换算:3000 字符英文 ≈ 3000 tokens,3000 字符中文 ≈ 6000 tokens。
chunkOverlap 推荐比例:
| 比例 | 效果 |
|---|---|
| 5-10% | 轻微重叠,适合结构化内容(FAQ、代码) |
| 10-20% | 适中重叠,最常用,保留上下文 |
| 20-30% | 较大重叠,适合需要强连续性的长文 |
实际调优方法:
// 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 方法):
// 伪代码简化
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 循环 | 能保留什么? | 最终结果 |
|---|---|---|---|
| 0 | total=78 > 0 → 移除48 → total=30 → 30>0 → 移除30 → total=0 | 无 | [s0] [s1+s2] [s3+s4] 共3块 |
| 20 | total=78 > 20 → 移除48 → total=30 → 30>20 → 移除30 → total=0 | 无(最小单元30 > 20) | [s0] [s1+s2] [s3+s4] ← 和0一模一样 |
| 40 | total=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 按优先级排列,分割器会先尝试第一个,如果该分隔符不存在则尝试下一个,如果分割后仍然太大则递归进入下一级。
// 优先按标题分割,然后是段落,最后是行
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
MarkdownTextSplitter 是 RecursiveCharacterTextSplitter 的子类,内置了 Markdown 感知的分隔符:
import { MarkdownTextSplitter } from "@langchain/textsplitters";
const splitter = new MarkdownTextSplitter({
chunkSize: 500,
chunkOverlap: 50,
});
const docs = await splitter.createDocuments([markdownText]);内置的 Markdown 分隔符(按优先级):
[
"\n## ", // 二级标题
"\n### ", // 三级标题
"\n#### ", // 四级标题
"\n##### ", // 五级标题
"\n###### ", // 六级标题
"```\n\n", // 代码块
"\n\n***\n\n", // 水平线(星号)
"\n\n---\n\n", // 水平线(横线)
"\n\n___\n\n", // 水平线(下划线)
"\n\n", // 段落
"\n", // 行
" ", // 词
"" // 字符
]方式三:LatexTextSplitter
import { LatexTextSplitter } from "@langchain/textsplitters";
const splitter = new LatexTextSplitter({
chunkSize: 500,
chunkOverlap: 50,
});
const docs = await splitter.createDocuments([latexText]);内置的 LaTeX 分隔符(按优先级):
[
"\n\\chapter{", // 章
"\n\\section{", // 节
"\n\\subsection{", // 小节
"\n\\subsubsection{", // 子小节
"\n\\begin{enumerate}", // 枚举列表
"\n\\begin{itemize}", // 无序列表
"\n\\begin{align}", // 公式对齐
"$$", // 行间公式
"$", // 行内公式
"\n\n", "\n", " ", "" // 通用分隔符
]方式四:fromLanguage() 工厂方法
RecursiveCharacterTextSplitter 提供 fromLanguage() 静态方法,为 16 种编程语言和标记语言内置了语法感知的分隔符:
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
// Python 代码:按 class / def / 缩进分割
const splitter = RecursiveCharacterTextSplitter.fromLanguage("python", {
chunkSize: 500,
chunkOverlap: 50,
});
const docs = await splitter.createDocuments([pythonCode]);支持的语言:
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 — 控制分隔符是否保留
// keepSeparator: true(默认) → 分隔符保留在结果中
// 例如按 "\n\n" 分割 "Hello\n\nWorld"
// → ["Hello\n\n", "World"] (分隔符保留)
// keepSeparator: false → 分隔符被移除
// → ["Hello", "World"] (分隔符丢弃)默认为 true,对于 RecursiveCharacterTextSplitter 通常推荐保留,因为分隔符本身包含语义信息(如段落间距、标题层级)。
lengthFunction — 自定义长度计算
默认按字符数计算长度,但有时需要按 token 计算:
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 的场景:
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]);| 参数 | 默认值 | 说明 |
|---|---|---|
chunkSize | 1000 | 每块 token 数 |
chunkOverlap | 200 | 重叠 token 数 |
encodingName | "gpt2" | tokenizer 编码名称,可选 "gpt2", "r50k_base", "p50k_base", "cl100k_base" 等 |
TokenTextSplitter没有分隔符概念,纯粹按 token 索引切割,可能导致语句被截断。一般推荐使用RecursiveCharacterTextSplitter+lengthFunction的组合来实现按 token 分割。
代码示例
// 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() 自动将元数据继承到所有分块:
// 分割前: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/community | SQL WHERE 语法 | metadata->>'category' = 'tutorial' |
| Qdrant | @langchain/qdrant | must / must_not 结构 | { must: [{ key: "category", match: { value: "tutorial" } }] } |
| Weaviate | @langchain/weaviate | GraphQL where 语法 | { operator: "Equal", path: ["category"], valueText: "tutorial" } |
通用模式:所有向量存储都支持在 similaritySearch() 中传入 filter 参数:
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> 对象,随时可读写:
方式一:加载后统一添加
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"]; // 自定义标签
});方式二:分割后按分块差异化添加
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; // 是否最后一个分块
});方式三:加载器自带元数据 + 追加自定义字段
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"; // 处理管道标识
});注意:元数据在后续存储为向量时会影响索引大小。只存储真正需要过滤的字段,避免冗余。
代码示例
// 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);
});
