解锁AI Agent潜能:基于Langchain组件库的落地指南(2)

20 阅读12分钟

在前序章节中(上一章地址),我们已经完成了基础的 RAG 功能实现,即基于组件库文档检索并回答用户问题。但目前的 Agent 助手仅具备核心的问答能力,如同只拥有 “大脑”,仍有大量功能尚未完善。本章将继续为其补充交互与执行能力,为 Agent 装上 “手脚”,让它拥有更完整、更实用的智能交互能力。

1. 记忆(Memory)

我们希望 Agent 助手在对话过程中具备记忆能力,能够记录用户历史提问与自身回复内容,并基于上下文进行思考、总结与规划,从而输出更精准可靠的结果。若无记忆机制,Agent 将无法理解对话上下文,难以推进复杂任务,无法形成连贯的智能交互行为,仅能完成孤立、单次的问答交互。

你问:“帮我查北京天气”,再问:“那明天呢?”它会反问:“明天什么?”

1.1. 自定义ChatHistory

我们使用自定义ChatHistory把聊天记录储存到本地中。接下来用代码实现一下:

在根目录下新建utils/index.js文件:

import { BaseListChatMessageHistory } from "@langchain/core/chat_history";
/**
 * 基于 JSON 文件的聊天历史记录类
 * 继承自 LangChain 的 BaseListChatMessageHistory,用于持久化存储对话记录
 */
export class JSONChatHistory extends BaseListChatMessageHistory {
  // LangChain 命名空间,用于序列化识别
  lc_namespace = ["langchain", "store", "message"];
  // 会话唯一标识
  sessionId = '';
  // 存储目录路径
  dir = '';

  /**
   * 构造函数
   * @param {Object} fields - 配置对象
   * @param {string} fields.sessionId - 会话 ID
   * @param {string} fields.dir - 存储目录路径
   */
  constructor(fields) {
    super(fields);
    this.sessionId = fields.sessionId;
    this.dir = fields.dir;
  }

  /**
   * 获取所有历史消息
   * @returns {Promise<BaseMessage[]>} 消息列表
   */
  async getMessages() {}

  /**
   * 添加单条消息
   * @param {BaseMessage} message - 消息对象
   * @returns {Promise<void>}
   */
  async addMessage(message) {}

  /**
   * 添加多条消息
   * @param {BaseMessage[]} messages - 消息数组
   * @returns {Promise<void>}
   */
  async addMessages(messages) {}

  /**
   * 将消息保存到文件
   * @param {BaseMessage[]} messages - 消息数组
   * @returns {Promise<void>}
   */
  async saveMessagesToFile(messages) {}

  /**
   * 清空历史记录
   * @returns {Promise<void>}
   */
  async clear() {}

  /**
   * 添加 AI 消息(兼容旧版本 BufferMemory 的废弃方法)
   * @param {string} message - 消息内容
   * @returns {Promise<void>}
   * @deprecated 建议使用 addMessage 方法
   */
  async addAIChatMessage(message) {
    return this.addMessage(new AIMessage(message));
  }
}

这里声明了JSONChatHistory类,并继承自BaseListChatMessageHistory

BaseListChatMessageHistory 是 LangChain 框架中用于管理聊天历史记录的抽象基类。主要作用:

  1. 标准化接口:为不同的聊天历史存储方式(内存、文件、数据库等)提供统一的接口。
  2. 核心方法
    • getMessages() - 获取所有历史消息
    • addMessage(message) - 添加单条消息
    • addMessages(messages) - 添加多条消息
    • clear() - 清空历史记录
  3. 使用场景
    • 与 BufferMemory 等内存组件配合,让 AI 模型能够"记住"对话上下文
    • 实现自定义的持久化存储(如你的代码中用 JSON 文件存储)

接着完成类的具体实现,首先在constructor构造函数中,声明了lc_namespace=["langchain", "store", "message"]

  1. 作用:当 LangChain 需要序列化(保存/导出)这个聊天历史对象时,lc_namespace 帮助识别这个类的类型和来源。
  2. 格式["langchain", "store", "messsage"] 表示这个类属于 langchain 库的 store 模块下的 message命名空间。
  3. 使用场景
    • 当你将对话历史保存到文件或数据库时
    • 当你需要反序列化(加载)对象时,LangChain 可以通过这个命名空间找到对应的类定义

第10-12行,11行声明了 sessionId 用于标识会话 ID,作为本地存储文件的唯一标识;12行dir 则指定本地存储文件夹的路径。然后实现了所有抽象方法,包括获取消息、添加消息、保存到文件和清空历史记录等功能。

然后我们先实现saveMessagesToFile,新增如下代码:

import { mapChatMessagesToStoredMessages } from "@langchain/core/messages";

export class JSONChatHistory extends BaseListChatMessageHistory {
  //...省略部分代码  

  /**
   * 将消息保存到文件
   * @param {BaseMessage[]} messages - 消息数组
   * @returns {Promise<void>}
   */
  async saveMessagesToFile(messages) {
    const filePath = path.join(this.dir, `${this.sessionId}.json`);
    const serializedMessages = mapChatMessagesToStoredMessages(messages);
    try {
      fs.writeFileSync(filePath, JSON.stringify(serializedMessages, null, 2), {
        encoding: 'utf8',
      });
    } catch(error) {
      console.error(`Failed to save chat history to ${filePath}`, error);
    }
  }

第12行代码将sessionId定为文件名称,然后通过path.join拼接dir目录。

第13行使用了 LangChain 的工具函数 mapChatMessagesToStoredMessages,它将聊天消息对象转换为可存储的 JSON 格式,便于持久化。

打印一下转化后的JSON格式看一下:

{
    "type": "human",
    "data": {
      "content": "请你介绍下input组件的基本用法以及它支持哪些参数,回答尽量详细",
      "additional_kwargs": {},
      "response_metadata": {}
    }
  },
  {
    "type": "ai",
    "data": {
      "content": "根据原文,Input 组件的基本用法如下:\n\n**基本用法**:使用 `v-model` 进行双向数据绑定,并通过 `placeholder` 属性设置占位文本。\n```html\n<el-input v-model=\"input\" placeholder=\"请输入内容\"></el-input>\n```\n\nInput 组件支持以下参数(Attributes):\n*   `placeholder`:输入框占位文本,类型为 `string`。\n*   `disabled`:禁用输入框,类型为 `boolean`,默认值为 `false`。\n*   `value-key`:输入建议对象中用于显示的键名,类型为 `string`。\n*   `value`:必填值,输入绑定值,类型为 `string`。\n*   `debounce`:获取输入建议的去抖延时,类型为 `number`,默认值为 `300`。\n*   `size`:尺寸,类型为 `string`,可选值为 `medium` / `small` / `mini`。\n*   `type`:类型,类型为 `string`,可选值为 `primary` / `success` / `warning` / `danger` / `info` / `text`。\n*   `plain`:是否朴素按钮,类型为 `boolean`,默认值为 `false`。\n*   `round`:是否圆角按钮,类型为 `boolean`,默认值为 `false`。\n*   `circle`:是否圆形按钮,类型为 `boolean`,默认值为 `false`。\n*   `suffix-icon`:输入框尾部图标,类型为 `string`。\n*   `rows`:输入框行数,只对 `type=\"textarea\"` 有效,类型为 `number`,默认值为 `2`。\n*   `autosize`:自适应内容高度,只对 `type=\"textarea\"` 有效,可传入对象,如 `{ minRows: 2, maxRows: 6 }`,类型为 `boolean / object`,默认值为 `false`。\n*   `autocomplete`:原生属性,自动补全,类型为 `string`,可选值为 `on, off`,默认值为 `off`。\n*   `label`:输入框关联的label文字,类型为 `string`。\n*   `prefix-icon`:输入框头部图标,类型为 `string`。\n*   `hide-loading`:是否隐藏远程加载时的加载图标,类型为 `boolean`,默认值为 `false`。\n*   `popper-append-to-body`:是否将下拉列表插入至 body 元素。在下拉列表的定位出现问题时,可将该属性设置为 false,类型为 `boolean`,默认值为 `true`。\n*   `highlight-first-item`:是否默认突出显示远程搜索建议中的第一项,类型为 `boolean`,默认值为 `false`。\n*   `maxlength`:原生属性,最大输入长度,类型为 `number`。",
      "tool_calls": [],
      "invalid_tool_calls": [],
      "additional_kwargs": {},
      "response_metadata": {}
    }
  },

type表示是ai回答还是用户提问,data里是提问或回答的具体内容,这是一个BaseMessage类。

第4行将JSON化后的字符串写入filePath目录。

接着我们完成获取本地目录所有聊天信息,也就是getMessages方法:

import { mapStoredMessagesToChatMessages } from "@langchain/core/messages";

async getMessages() {
    const filePath = path.join(this.dir, `${this.sessionId}.json`);
    try {
      if (!fs.existsSync(filePath)) {
        this.saveMessagesToFile([]);
        return [];
      }

      const data = fs.readFileSync(filePath, { encoding: 'utf8' });
      const storedMessages = JSON.parse(data);
      return mapStoredMessagesToChatMessages(storedMessages);
    } catch(error) {
      console.error(`Failed to read chat history from ${filePath}`, error);
      return [];
    }
  }

第6-9行,判断filePath目录是否存在,不存在则调用saveMessagesToFile创建聊天记录目录。

第11-13行,读取聊天记录目录下的json文件,然后通过JSON.parse将JSON字符串转为js字符串,接着通过mapStoredMessagesToChatMessages将js字符串转为真正的Messages对象。

同样打印一下转化为chat message后的对象:

{
    "lc": 1,
    "type": "constructor",
    "id": [
      "langchain_core",
      "messages",
      "AIMessage"
    ],
    "kwargs": {
      "content": "根据原文,**el-input组件的基本用法**是:\n\n**使用 `v-model` 进行双向数据绑定,并通过 `placeholder` 属性设置占位文本。**\n\n**示例代码:**\n```html\n<el-input v-model=\"input\" placeholder=\"请输入内容\"></el-input>\n\n<script>\nexport default {\n  data() {\n    return {\n      input: ''\n    }\n  }\n}\n</script>\n```\n\n**重要说明:**\n1. **Input 是受控组件**:它**总会显示 Vue 绑定值**,因此必须处理 `input` 事件并更新绑定值(或使用 `v-model`)。\n2. **不支持 `v-model` 修饰符**:如 `.trim`、`.number` 等修饰符不被支持。\n3. **基础用法核心**:通过 `v-model` 实现数据的双向绑定,`placeholder` 提供输入提示。",
      "tool_calls": [],
      "invalid_tool_calls": [],
      "additional_kwargs": {},
      "response_metadata": {}
    }
  },
  {
    "lc": 1,
    "type": "constructor",
    "id": [
      "langchain_core",
      "messages",
      "HumanMessage"
    ],
    "kwargs": {
      "content": "请你介绍下input组件的基本用法以及它支持哪些参数,回答尽量详细。",
      "additional_kwargs": {},
      "response_metadata": {}
    }
  },

这里是BaseMessage对象包含的属性,主要是在id里标识是AIMessage还是HumanMessage,并且在kwargs的content里是具体的回答和提问内容。

接下去继续实现addMessage和addMessages方法:

/**
* 添加单条消息
* @param {BaseMessage} message - 消息对象
* @returns {Promise<void>}
*/
async addMessage(message) {
    const messages = await this.getMessages();
    messages.push(message);
    await this.saveMessagesToFile(messages);
}

/**
* 添加多条消息
* @param {BaseMessage[]} messages - 消息数组
* @returns {Promise<void>}
*/
async addMessages(messages) {
    const existingMessages = await this.getMessages();
    const allMessages = existingMessages.concat(messages);
    await this.saveMessagesToFile(allMessages);
}

这两个方法比较类似,先读取在目录中历史消息,然后通过push或者concat连接新的消息,再存入本地目录中。至此ChatHistory自定义类已经全部完成,我们要接入到Agent中。

1.2. Agent接入Memory

回到agent/index.js目录,我们增加如下代码:

import { readFromMDFiles, JSONChatHistory } from '../utils/index.js';
import { RunnableWithMessageHistory } from "@langchain/core/runnables";

export async function getRagChain() {
    //...省略部分代码
    // 创建一个聊天提示模板,用于生成问题回答的提示
    const prompt = ChatPromptTemplate.fromMessages([
        ['system', SYSTEM_TEMPLATE],
        new MessagesPlaceholder('history'),
        ['human', '现在,你需要基于原文,回答以下问题:\n{standalone_question}`'],
    ]);
    const ragChain = RunnableSequence.from([
        RunnablePassthrough.assign({
          context: contextRetrieverChain,
        }),
        prompt,
        model,
        new StringOutputParser(),
    ]);
    // 定义chat-history目录
    const chatHistoryDir = process.cwd() + '/chat-history';
    // 创建一个 RunnableWithMessageHistory 对象,用于将聊天历史记录与检索链结合起来
    const ragChainWithHistory = new RunnableWithMessageHistory({
        runnable: ragChain,           // 被包装的 RAG 链
        getMessageHistory: (sessionId) =>
          new JSONChatHistory({ sessionId, dir: chatHistoryDir }),  // 获取历史记录的方式
        historyMessagesKey: 'history', // 历史消息在 prompt 中的变量名
        inputMessagesKey: 'question',  // 用户输入的变量名
    });
    return ragChainWithHistory;
}

第7-11行,新增了MessagePlaceHolder('history')这里新增了一个history的占位符,用来放置Memory相关的记忆记录。

第21行定义了chat-history本地目录地址。

第23-30行,这里使用了RunnableWithMessageHistory,这是 LangChain 提供的一个包装类,用于将聊天历史记录功能集成到任意的 Runnable 链中。它让 RAG 链具备了多轮对话记忆能力,能够记住之前的对话上下文,使 AI 能够理解连续的问题。

工作流程

  1. 用户提问 → 自动从 JSONChatHistory 加载该会话的历史消息
  2. 构建 Prompt → 将历史消息 + 当前问题一起传入
  3. 模型回答 → 将 AI 回复保存回 JSONChatHistory
  4. 下次对话 → 重复上述过程,保持上下文连贯

chat history部分基本已经完成,我们在调用的地方增加一些参数,代码如下:

apiRouter.post('/chat', async (req, res) => {
  const { question, sessionId = 'default' } = req.body;

  if (!question) {
    return res.status(400).json({ error: 'Question is required' });
  }

  try {
    // 获取请求体
    const result = await chain.invoke(
      { question },
      { configurable: { sessionId }
    });
    res.json({ content: result });
  } catch (error) {
    console.error('Error during chat:', error);
    res.status(500).json({ error: 'Internal server error' });
  }
});

我们来试试看,问它一下刚刚问了什么问题:

image.png

确实是历史记录中刚刚的提问,说明我们的历史记录已经生效了。这样它就能结合之前的回答来总结出最新的答案。

好了后端部分基本已经完成,接下去完成前端部分。

2. 前端代码

这里要搭建一个聊天界面,整体代码直接给出,我们挑几个重点讲一下如何实现。代码地址

image.png

2.1. Markdown转换

由于大模型返回的是Markdown格式的字符串,因此需要使用Markdown转换器将其转为HTML进行渲染。我使用了MarkdownIt库以及highlightjs来做代码的高亮提示。具体代码如下:

import MarkdownIt from 'markdown-it';
import hljs from 'highlight.js';
import 'highlight.js/styles/vs2015.css';

interface MarkdownRendererProps {
  content: string;
}

// 初始化 markdown-it 实例,配置 highlight.js 进行语法高亮
const md: MarkdownIt = new MarkdownIt({
  html: true,
  linkify: true,
  typographer: true,
  highlight: function (str: string, lang: string): string {
    if (lang && hljs.getLanguage(lang)) {
      try {
        return hljs.highlight(str, { language: lang }).value;
      } catch (e) {
        console.warn('Highlight error', e);
      }
    }
    // 如果语言不支持或出错,使用自动高亮
    try {
      return hljs.highlightAuto(str).value;
    } catch (e) {
      console.warn('Auto highlight error', e);
    }
    // 如果都失败,直接返回文本并转义 HTML
    return md.utils.escapeHtml(str);
  },
});

const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content }) => {
  // 使用 markdown-it 将 Markdown 转换为 HTML
  const htmlContent = md.render(content);

  return (
    <div
      className='markdown-body'
      dangerouslySetInnerHTML={{ __html: htmlContent }}
    />
  );
};

过程还是比较简单的,借助MarkdownIt就能轻松转换,最后通过dangerouslySetInnerHTML进行渲染。

2.2. 打字机效果

在上节中实现的RAG Chain是一次性将结果返回的,这样如果返回的内容非常大,等待结果会非常长,这样的体验会非常差。因此我们需要将chain返回改成流式,让它能够一段一段的返回,优先展示已经生成的结果。

Mar-18-2026 18-22-30.gif

首先需要对agent服务器进行改造,改造如下:

apiRouter.get('/chat', async (req, res) => {
  const { question, sessionId = 'default' } = req.query;

  if (!question) {
    return res.status(400).json({ error: 'Question is required' });
  }

  try {
    // 设置 SSE 响应头
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    res.setHeader('Connection', 'keep-alive');

    // 使用 stream 模式调用 chain
    const stream = await chain.stream(
      { question },
      { configurable: { sessionId } }
    );

    // 逐块返回数据
    for await (const chunk of stream) {
      res.write(`data: ${JSON.stringify({ content: chunk })}\n\n`);
    }

    // 发送结束标记
    res.write(`data: ${JSON.stringify({ done: true })}\n\n`);
    res.end();
  } catch (error) {
    console.error('Error during chat:', error);
    res.write(`data: ${JSON.stringify({ error: 'Internal server error' })}\n\n`);
    res.end();
  }
});

这里使用 SSE(Server-Sent Events)实现流式返回,因此需要在响应头中设置 Content-Type: text/event-stream,标识返回格式为流式数据(第 10 行)。

第 15-18 行,将 chain.invoke() 改为 chain.stream(),使 LLM 的返回变为流式格式。

第 21-27 行,遍历流式数据,逐块提取内容并通过 res.write() 实时返回给客户端。

然后前端这边进行改造:

/**
 * 使用 SSE 方式接收流式聊天响应
 * @param {string} prompt - 用户输入的提示词
 * @param {Function} onChunk - 接收到数据块时的回调函数
 * @param {Function} onComplete - 接收完成时的回调函数
 * @param {Function} onError - 发生错误时的回调函数
 */
export const fetchChatStream = (
  prompt: string,
  onChunk: (chunk: string) => void,
  onComplete: () => void,
  onError: (error: Error) => void
) => {
  const baseURL = import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:3000/api';
  const eventSource = new EventSource(`${baseURL}/chat?question=${encodeURIComponent(prompt)}`);

  eventSource.onmessage = (event) => {
    const data = JSON.parse(event?.data || '{}');
    if (data.done === true) {
      eventSource.close();
      onComplete();
    } else {
      onChunk(data.content);
    }
  };

  eventSource.onerror = () => {
    eventSource.close();
    onError(new Error('SSE 连接错误'));
  };

  // 返回关闭连接的方法
  return () => eventSource.close();
};

重点在15-25行,前端这边使用EventSource创建连接,然后在onmessage回调函数中不断接收服务器推送的内容,在onChunk中进行内容拼接,我们看一下onChunk:

(chunk) => {
    // 接收到数据块时更新消息内容
    setMessages(prev => {
      const newMessages = [...prev];
      // 如果返回消息存在,则不断地增加content内容
      if (newMessages[assistantIndex]) {
        newMessages[assistantIndex] = {
          ...newMessages[assistantIndex],
          content: newMessages[assistantIndex].content + chunk
        };
      }
      return newMessages;
    });
},

2.3. 返回历史记录

现在每次刷新都会清空历史记录,因为我们未实现读取历史记录。

image.png

这里来实现一下这个功能,首先在后端服务器新增读取历史记录接口:

import { JSONChatHistory } from '../utils/index.js';
// 新增读取agent历史记录的接口
apiRouter.get('/history', async (req, res) => {
  const { sessionId = 'default' } = req.query;
  const chatHistoryDir = process.cwd() + '/chat-history';

  try {
    const chatHistory = new JSONChatHistory({ sessionId, dir: chatHistoryDir });
    const messages = await chatHistory.getMessages();

    // 将消息转换为前端友好的格式
    const formattedMessages = messages.map(msg => ({
      type: msg._getType(), // 'human' 或 'ai'
      content: msg.content,
      timestamp: msg.additional_kwargs?.timestamp || null
    }));

    res.json({
      sessionId,
      messages: formattedMessages,
    });
  } catch (error) {
    console.error('Error fetching history:', error);
    res.status(500).json({ error: 'Failed to fetch chat history' });
  }
});

这里使用之前实现的JSONChatHistory类来读取历史的聊天记录,然后转换为特定的格式返回给前端。

前端这边只需要在初始化时调用该接口,并渲染里面的内容即可:

// 初始化时加载历史记录
useEffect(() => {
    const loadHistory = async () => {
      try {
        const history = await fetchChatHistory();
        if (history && history.length > 0) {
          // 将历史记录转换为 Message 格式
          const historyMessages: Message[] = history.map((msg) => ({
            role: msg.type === 'human' ? 'user' : 'assistant',
            content: msg.content
          }));
          setMessages(historyMessages);
        }
      } catch (error) {
        console.error('加载历史记录失败:', error);
      }
    };

    loadHistory();
}, []);

前端这边直接调用fetchChatHistory接口,再通过msgType来辨别是human信息还是ai信息,再进行role变量赋值。来试一下结果:

Kapture 2026-03-18 at 20.49.50.gif

3. 总结

我们在两章内容中实现了基于LangChain的组件库RAG问答系统。该系统支持多轮对话,后端基于Express服务器提供流式返回内容;前端则实现了Markdown渲染、SSE流式响应以及历史记录展示。后续有机会再讨论如何接入MCP的流程。