Skip to content
应用蓝图:RAG、Agent 与 Memory 如何拼成问答系统
前六篇已经把组件逐个拆开看过一遍:模型如何被调用、提示词模板怎样注入变量、LCEL 的管道如何串联 Runnable、Memory 如何让链记住上一轮对话、文档怎样加载并转为向量、Agent 的执行循环如何调用工具。这一篇的目标是把这些零件装到一起,做出一个能检索本地文档、能记住多轮对话的问答应用。
拆开来看是三套能力:
RAG 管线 负责把文档切成片段、生成向量、存入向量库,并在提问时检索最相关的片段作为上下文。
Agent 拿到模型、工具和系统提示词之后,自己决定什么时候调用检索工具、什么时候不调用就直接回答,或者调用失败后用别的方法补救。
Memory 把每一轮的对话都记录下来,下次请求时带上前面的上下文,这样问题和回答才能接得上。
组装到应用层时,用户的输入先交给带记忆的 Agent。Agent 分析问题的同时会看到历史对话,再决定要不要执行工具。如果决定检索,就走 RAG 管线拿到文档片段,拼进最终回答;如果不检索,就用记忆中的上下文直接回答。失败的路径则归到降级逻辑里,要么重试,要么给出明确的“找不到相关信息”的提示。
下面是整条链路的流向示意:
用户提问 → Memory 加载历史消息 → Agent(系统提示词 + 工具列表)
↳ 决定调用检索工具 → Retriever 查询向量库 → 返回文档片段 → Agent 组合回答
↳ 决定不调用工具 → 直接基于记忆和知识回答
↳ 工具调用失败 → 降级策略(重试 / 提示用户 / 默认回复)
→ 最终回答返回用户,同时写回 Memory“装配”这个词贯穿全文:把 Retrieval 装配成 Tool,把 Tool 和 Memory 装配进 Agent,最后把 Agent 装配成一条 Runnable 序列。接下来的章节就用代码把这条路径一步步搭出来。
准备检索工具:从本地文档到 Tool 的装配线
文档加载、切分与向量化
第 5 篇已经详细拆解过文档加载、切分和向量化的每一环,这里只做一次快速接线,把流程串成一段可运行的脚本。
目标是把 ./documents 目录下的 Markdown 文件变成一个 MemoryVectorStore 实例,后续的检索器就从它身上取。
typescript
import { TextLoader } from "langchain/document_loaders/fs/text";
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
import { OpenAIEmbeddings } from "@langchain/openai";
import { MemoryVectorStore } from "langchain/vectorstores/memory";
async function buildVectorStore(): Promise<MemoryVectorStore> {
// 1. 加载文档:每个文件对应一个 Document 对象
const loader = new TextLoader("./documents/faq.md");
const docs = await loader.load();
// 2. 切分文本:块大小 500,重叠 50,避免切断关键上下文
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 500,
chunkOverlap: 50,
});
const splitDocs = await splitter.splitDocuments(docs);
// 3. 向量化并存入内存向量库
const embeddings = new OpenAIEmbeddings();
const vectorStore = await MemoryVectorStore.fromDocuments(
splitDocs,
embeddings
);
return vectorStore;
}RecursiveCharacterTextSplitter 的 chunkOverlap=50 是为了在切分边界处保留一些重叠,这样后续检索时不容易因一句话被切成两半而丢失语义。向量化用的是 OpenAI 的 Embeddings 模型,得到的向量库支持相似性搜索。
这套装配的结果就是一个 MemoryVectorStore。下一步把它包成检索器,再封装为 Agent 能调用的工具。
构建 VectorStoreRetriever 并封装为 Tool
向量库提供了 .asRetriever() 方法,直接返回一个 VectorStoreRetriever,内部封装了 similaritySearch 调用。通常还会指定 k(返回的文档数量):
typescript
const retriever = vectorStore.asRetriever({ k: 3 });现在 retriever 只是一个 Runnable<Input, Document[]>,Agent 不认识它。需要把它描述为工具,告诉模型“有一个东西叫 retrieve_docs,你可以调用它,需要传入一个查询字符串,它会返回一组文档”。
在 @langchain/core/tools 中,tool 函数可以把任何异步函数转为工具对象。这里用 retriever.invoke 作为执行体:
typescript
import { tool } from "@langchain/core/tools";
import type { Document } from "@langchain/core/documents";
const retrieveTool = tool(
async (query: string): Promise<string> => {
const docs = await retriever.invoke(query);
if (docs.length === 0) {
return "未找到相关文档。";
}
return docs.map((doc, i) => `[文档${i + 1}] ${doc.pageContent}`).join("\n\n");
},
{
name: "retrieve_docs",
description:
"在本地文档库中检索与用户问题相关的内容。输入应是一个明确的查询字符串。",
}
);注意这里的返回值被序列化为一个字符串。Agent 拿到工具调用结果后,看到的只是字符串内容,因此需要把 Document 对象转成一段可读文本。同时处理了检索无结果的情况,返回 “未找到相关文档。”,后面降级处理一节会再讨论 Agent 看到这个字符串时的行为。
至此,检索工具已经挂好。它可以被传入 Agent,成为模型可调用的“技能”。
打造带记忆的 Agent:提示词、工具与历史对话的融合
组装系统提示词与聊天模型
Agent 的行为高度依赖系统提示词。提示词需要明确定义:
- Agent 的角色与能力边界
- 什么时候必须使用检索工具
- 如果工具返回 “未找到相关文档” 该怎么办
- 不许编造信息
一段能工作的系统提示词大致是这样的:
typescript
const systemPrompt = `你是一个基于本地知识库的问答助手。回答问题时遵循以下规则:
1. 如果问题需要参考本地文档(如说明、规范、流程等),必须先调用 retrieve_docs 工具检索相关内容。
2. 如果工具返回了文档内容,请基于文档内容回答,不要使用外部知识。
3. 如果工具返回“未找到相关文档”,请直接告诉用户本地文档中没有找到相关信息,不要编造。
4. 对于简单的问候或非知识性问题,可以直接回答,无需检索。
5. 回答时尽量引用文档中的关键部分,并注明引用来源。`;聊天模型用 OpenAI 的 GPT-4o 或兼容接口的模型:
typescript
import { ChatOpenAI } from "@langchain/openai";
const model = new ChatOpenAI({
modelName: "gpt-4o",
temperature: 0,
});temperature=0 让输出更确定性,因为问答场景不追求创造性,需要稳定地遵循提示词规则。
模型和工具之间的绑定在第 6 篇已经讲清楚了,这里直接调用 bindTools:
typescript
const modelWithTools = model.bindTools([retrieveTool]);这样模型在生成消息时就可以输出 tool_calls,而后 Agent 执行循环会自动执行工具并把结果返回模型。
使用 RunnableWithMessageHistory 挂载多轮记忆
Agent 执行体本身不保留历史。当用户连续提问时,每次请求都是独立的,前一回合的内容会丢失。
RunnableWithMessageHistory 可以在运行时把历史消息加载进来,执行完后把新的消息写回去。它依赖两个东西:
- 一个基础 Runnable(这里就是 Agent)
- 一个
getSessionHistory函数,根据会话 ID 返回BaseChatMessageHistory
这里使用内存存储,每个 sessionId 对应一个 InMemoryChatMessageHistory 实例:
typescript
import { RunnableWithMessageHistory } from "@langchain/core/runnables";
import { InMemoryChatMessageHistory } from "@langchain/core/chat_history";
const sessionHistories: Record<string, InMemoryChatMessageHistory> = {};
const getSessionHistory = (sessionId: string) => {
if (!sessionHistories[sessionId]) {
sessionHistories[sessionId] = new InMemoryChatMessageHistory();
}
return sessionHistories[sessionId];
};然后把 Agent 和这个函数绑在一起。这里使用 createReactAgent,它是 LangChain 0.2+ 中推荐的构建方式,返回一个直接可运行的 Agent。
typescript
import { createReactAgent } from "@langchain/langgraph/prebuilt";
const agent = createReactAgent({
llm: modelWithTools,
tools: [retrieveTool],
messageModifier: systemPrompt,
});
// 挂载记忆
const agentWithMemory = new RunnableWithMessageHistory({
runnable: agent,
getMessageHistory: getSessionHistory,
inputMessagesKey: "messages",
historyMessagesKey: "history",
});inputMessagesKey 和 historyMessagesKey 控制历史消息如何并入输入。这里用 "messages" 作为输入的消息列表键,"history" 是从存储中取到的消息序列键名,Agent 期望的输入结构是 { messages: [...] }。
现在 agentWithMemory 就是一个带记忆的 Agent。每次用同一个 sessionId 调用它,都会带上整段对话历史。
链式串联:用 LCEL 将用户输入流导向智能回答
上面已经把 Agent 和 Memory 组装好了,但直接调用 agentWithMemory.invoke() 时还需要传入 sessionId(写在 config.configurable 里)和 messages 字段。为了给外部提供一个更干净的接口,可以用 LCEL 把参数整理一下:
typescript
import { RunnableSequence } from "@langchain/core/runnables";
const buildInput = (input: { question: string; sessionId: string }) => {
return {
messages: [{ role: "user", content: input.question }],
};
};
const chain = RunnableSequence.from([
// 1. 将外部输入转成 Agent 需要的消息格式
buildInput,
// 2. 带记忆的 Agent
agentWithMemory,
// 3. 提取最终文本回答
(output: any) => {
// createReactAgent 返回的最后一个消息通常是 AIMessage,内容在 content 字段
const lastMsg = output.messages[output.messages.length - 1];
return lastMsg.content;
},
]);buildInput 负责把 { question, sessionId } 变成 Agent 的输入格式。链的第二步就是带记忆的 Agent。第三步从 Agent 的输出中取出最后一条消息的内容作为最终答案,因为中间可能还有工具调用消息。
现在调用链时,只需要:
typescript
const answer = await chain.invoke(
{ question: "如何提交请假申请?", sessionId: "user-001" },
{ configurable: { sessionId: "user-001" } }
);
console.log(answer);configurable.sessionId 正是 RunnableWithMessageHistory 用来定位历史记录的标识。第二步 agentWithMemory 会从 config.configurable.sessionId 取出对话历史,执行完后又把新消息写回去。
整个 LCEL 链可以说是一条直线:
{question, sessionId} → buildInput → agentWithMemory → 提取回答多轮对话示例:记忆如何让回答保持连贯
现在用同一个 sessionId 进行两轮连续调用,看看记忆如何让回答保持连贯。假设知识库中有一段关于请假流程的文档。
第一轮询问流程:
typescript
// 第一轮
const answer1 = await chain.invoke(
{ question: "请假需要谁审批?", sessionId: "u1" },
{ configurable: { sessionId: "u1" } }
);
// 输出:根据文档,请假需由直属主管审批。此时对话历史中已经记录了用户问题和 Agent 的回答(包括工具调用)。第二轮追问 “如果主管不在呢?” 必须依赖历史上下文才能理解“主管”指的是上一轮提到的审批人。
typescript
// 第二轮(同一 sessionId)
const answer2 = await chain.invoke(
{ question: "如果主管不在呢?", sessionId: "u1" },
{ configurable: { sessionId: "u1" } }
);
// 输出:文档提到,若主管不在,可向部门经理提交申请。如果没有记忆,第二轮调用时 Agent 看到的只是孤立的 "如果主管不在呢?",它不知道“主管”从何而来,大概率会给出模糊回答或反问确认。有了记忆,Agent 从历史消息中提取出 “主管是审批人” 这一信息,再结合检索工具返回的文档片段,就能给出连贯的答案。
注意这里 Memory 和 RAG 各司其职:Memory 提供对话上下文(是谁、之前聊了什么),RAG 提供领域知识(请假流程怎么走)。两者协同,Agent 才能既“记得住”,又“查得到”。
流式输出与错误降级
Agent 执行中的 stream 流式输出
前端的交互体验取决于回答能不能跟上打字一样逐字出现。Agent 的执行结果可以通过 streamEvents 或 stream 方法获取 token 级别的更新。
基于上面构建的 agentWithMemory,可以直接用 .streamEvents():
typescript
const eventStream = agentWithMemory.streamEvents(
{ messages: [{ role: "user", content: "提交报销单的步骤" }] },
{
configurable: { sessionId: "u2" },
version: "v2",
}
);
for await (const event of eventStream) {
// 关注 LLM 流式输出 token 的事件
if (event.event === "on_chat_model_stream") {
const token = event.data.chunk.content;
process.stdout.write(token); // 逐 token 打印
}
}事件类型 on_chat_model_stream 是模型每产生一个 token 时触发的。组件内部会处理工具的调用与返回,用户看到的是最终回答按 token 流出,中间的工具调用细节不会直接暴露。
如果需要区分不同事件阶段,也可以在 on_tool_start、on_tool_end 事件上添加日志,监控工具执行情况。
检索失败与工具调用异常的降级处理
工具调用不总是成功的。向量库可能返回空结果,Embedding 请求可能超时,网络波动可能让检索报错。这些异常如果不处理,Agent 会直接从执行循环中抛错,导致用户看到原始错误信息。
降级策略分为两类:工具内部捕获和 Agent 系统提示词兜底。
工具内部捕获。在 retrieveTool 的实现中已经处理了检索无结果的情况(返回 “未找到相关文档。”)。对于更底层的异常,比如网络错误,也应该 try-catch:
typescript
const retrieveTool = tool(
async (query: string): Promise<string> => {
try {
const docs = await retriever.invoke(query);
if (docs.length === 0) return "未找到相关文档。";
return docs.map((doc, i) => `[文档${i+1}] ${doc.pageContent}`).join("\n\n");
} catch (error: any) {
return `检索失败:${error.message}`;
}
},
// ...
);工具返回错误文本后,Agent 看到的是一个字符串结果,而不是异常。配合系统提示词中预设的规则,模型可以据此调整回答。例如提示词中补充:
如果工具调用返回了以 “检索失败” 开头的消息,请告诉用户当前无法完成检索,建议稍后重试,或根据已有知识谨慎回答。
Agent 系统提示词兜底。当工具返回 “未找到相关文档” 时,模型必须按照提示词指示直接告知用户无相关信息,而不是编造一段看似合理的文字。这条规则前面已经写进了系统提示词。
这样两重保护,既防止了异常击穿,也约束了模型行为。
对话状态的序列化与恢复
前面的 sessionHistories 只是一个内存对象,进程重启后所有历史都会清空。如果需要持久化,可以把 InMemoryChatMessageHistory 替换为自定义实现,对接数据库或文件。
最简单的持久化方式是序列化消息数组。BaseChatMessageHistory 的子类可以从 getMessages() 拿到数组,再恢复时用 addMessages() 灌回去。
自定义一个 FileChatMessageHistory 示例:
typescript
import * as fs from "fs/promises";
import { BaseChatMessageHistory, BaseMessage } from "@langchain/core/chat_history";
import { mapChatMessagesToStoredMessages, mapStoredMessagesToChatMessages } from "@langchain/core/messages";
class FileChatMessageHistory extends BaseChatMessageHistory {
private filePath: string;
private messages: BaseMessage[] = [];
constructor(filePath: string) {
super();
this.filePath = filePath;
}
async load(): Promise<void> {
try {
const data = await fs.readFile(this.filePath, "utf-8");
const stored = JSON.parse(data);
this.messages = mapStoredMessagesToChatMessages(stored);
} catch {
// 文件不存在或格式错误时,初始化为空
this.messages = [];
}
}
async getMessages(): Promise<BaseMessage[]> {
if (this.messages.length === 0) await this.load();
return this.messages;
}
async addMessage(message: BaseMessage): Promise<void> {
this.messages.push(message);
// 每次添加后持久化到文件
await fs.writeFile(
this.filePath,
JSON.stringify(mapChatMessagesToStoredMessages(this.messages), null, 2)
);
}
async clear(): Promise<void> {
this.messages = [];
await fs.writeFile(this.filePath, "");
}
}那么 getSessionHistory 就可以改为返回这个文件实现的实例:
typescript
const getSessionHistory = (sessionId: string) => {
return new FileChatMessageHistory(`./histories/${sessionId}.json`);
};每次对话后历史消息都会写入对应会话 ID 的 JSON 文件。下次启动应用,历史还在。
完整应用代码总览与关键点注释
下面是把前面所有步骤缝合在一起的完整脚本,可以直接用 Node.js 运行(需要设置 OPENAI_API_KEY)。
typescript
import { TextLoader } from "langchain/document_loaders/fs/text";
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
import { OpenAIEmbeddings, ChatOpenAI } from "@langchain/openai";
import { MemoryVectorStore } from "langchain/vectorstores/memory";
import { tool } from "@langchain/core/tools";
import { createReactAgent } from "@langchain/langgraph/prebuilt";
import {
RunnableWithMessageHistory,
RunnableSequence,
} from "@langchain/core/runnables";
import { InMemoryChatMessageHistory } from "@langchain/core/chat_history";
// ─── 1. 构建向量库与检索器(应用启动时执行一次) ──────────
async function createRetriever() {
const loader = new TextLoader("./documents/faq.md");
const docs = await loader.load();
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 500,
chunkOverlap: 50,
});
const splitDocs = await splitter.splitDocuments(docs);
const embeddings = new OpenAIEmbeddings();
const vectorStore = await MemoryVectorStore.fromDocuments(
splitDocs,
embeddings
);
return vectorStore.asRetriever({ k: 3 });
}
// ─── 2. 在应用启动阶段一次性初始化检索器 ──────────────────
// 注意:在实际应用中,createRetriever() 应在启动时调用一次,
// 然后将 retriever 实例注入到工具中复用,避免每次请求重新索引。
const retriever = await createRetriever();
// ─── 3. 将检索器包装为工具 ─────────────────────────────────
const retrieveTool = tool(
async (query: string) => {
try {
const docs = await retriever.invoke(query);
if (docs.length === 0) return "未找到相关文档。";
return docs
.map((doc, i) => `[文档${i + 1}] ${doc.pageContent}`)
.join("\n\n");
} catch (err: any) {
return `检索失败:${err.message}`;
}
},
{
name: "retrieve_docs",
description: "在本地文档库中检索与用户问题相关的内容。输入应是一个明确的查询字符串。",
}
);
// ─── 4. 模型与系统提示词 ────────────────────────────────────
const systemPrompt = `你是一个基于本地知识库的问答助手。回答问题时遵循以下规则:
1. 如果问题需要参考本地文档(如说明、规范、流程等),必须先调用 retrieve_docs 工具检索相关内容。
2. 如果工具返回了文档内容,请基于文档内容回答,不要使用外部知识。
3. 如果工具返回“未找到相关文档”,请直接告诉用户本地文档中没有找到相关信息,不要编造。
4. 对于简单的问候或非知识性问题,可以直接回答,无需检索。
5. 回答时尽量引用文档中的关键部分,并注明引用来源。
6. 如果工具返回以“检索失败”开头的错误消息,请告知用户当前无法完成检索,建议稍后重试。`;
const model = new ChatOpenAI({ modelName: "gpt-4o", temperature: 0 });
const modelWithTools = model.bindTools([retrieveTool]);
// ─── 5. 创建 Agent 并挂载记忆 ────────────────────────────────
const agent = createReactAgent({
llm: modelWithTools,
tools: [retrieveTool],
messageModifier: systemPrompt,
});
const sessionHistories: Record<string, InMemoryChatMessageHistory> = {};
const getSessionHistory = (sessionId: string) => {
if (!sessionHistories[sessionId]) {
sessionHistories[sessionId] = new InMemoryChatMessageHistory();
}
return sessionHistories[sessionId];
};
const agentWithMemory = new RunnableWithMessageHistory({
runnable: agent,
getMessageHistory: getSessionHistory,
inputMessagesKey: "messages",
historyMessagesKey: "history",
});
// ─── 6. LCEL 链式封装 ────────────────────────────────────────
const chain = RunnableSequence.from([
(input: { question: string; sessionId: string }) => ({
messages: [{ role: "user", content: input.question }],
}),
agentWithMemory,
(output: any) => {
const messages = output.messages;
const lastMsg = messages[messages.length - 1];
return lastMsg.content;
},
]);
// ─── 7. 运行示例 ─────────────────────────────────────────────
(async () => {
// 第一轮
const answer1 = await chain.invoke(
{ question: "请假需要谁审批?", sessionId: "demo" },
{ configurable: { sessionId: "demo" } }
);
console.log("A1:", answer1);
// 第二轮,同一会话
const answer2 = await chain.invoke(
{ question: "如果主管不在呢?", sessionId: "demo" },
{ configurable: { sessionId: "demo" } }
);
console.log("A2:", answer2);
})();关键点说明
createRetriever在上面的示例中仍写成了函数形式,但在完整代码里已调整为在启动阶段调用一次,并将返回的retriever实例注入到工具的闭包中使用。这样做避免了每次 Agent 调用时重复加载文档和重建向量库(Embedding 调用有成本且可能触发 API 限速)。- 如果文档量较大或需要支持动态更新,应该将向量存储切换到持久化后端(如 Pinecone、Weaviate 等),在
createRetriever中只做加载和切分,向量化与入库放在独立的索引流程中。 Retriever返回的文档数量k=3可根据文档块大小和模型上下文窗口调整。块越大、窗口越小,k就应该设得越小,防止上下文溢出。- Agent 的系统提示词除了定义调用工具的规则,还处理了工具返回的各种字符串,作为降级兜底。
RunnableWithMessageHistory需要configurable.sessionId,如果调用时漏传,会直接抛出异常,而不是静默跳过记忆。- 流式输出使用
streamEvents时,version: "v2"参数是 LangChain 的事件格式版本标识,不同版本事件结构有差异,需与当前依赖版本对应。
参考链接
- [1] Elastic 官方博客:Agentic RAG with LangChain and Elasticsearch, https://www.elastic.co/cn/search-labs/blog/agentic-rag-news-assistant-langchain-elasticsearch
- [2] LangChain Deep Agents 文档:RAG patterns, https://docs.langchain.com/oss/python/deepagents/rag
- [3] Hello Agents 记忆与检索章节,https://github.com/datawhalechina/hello-agents/blob/main/docs/chapter8/第八章 记忆与检索.md
参考链接
- [1] https://www.elastic.co/cn/search-labs/blog/agentic-rag-news-assistant-langchain-elasticsearch
- [2] https://docs.langchain.com/oss/python/deepagents/rag
- [3] https://github.com/datawhalechina/hello-agents/blob/main/docs/chapter8/%E7%AC%AC%E5%85%AB%E7%AB%A0%20%E8%AE%B0%E5%BF%86%E4%B8%8E%E6%A3%80%E7%B4%A2.md
