重构

思考了一下,将特定的功能交给不同层级的文件夹来管理,每一个ts文件都名为index.ts,这样做就是结合了模块化设计,并使用了文件夹和分层结构来组织代码。这是一种标准的项目结构:

重新整理了demo的结构之后,我们开始接下来的正题:

VectorStoreRetrieverMemory的一些延伸思考

如果只是使用VectorStoreRetrieverMemory会有一些潜在的问题。我这里经过个人的一些思考,有一些优化,当然,因为LCEL的便捷性,我们有很多方法可以尝试优化这个Memory。

  • 向量数据库的局限性

    • 因为依赖的是嵌入模型的语义能力,对于非语义相关性的问题(比如时间线相关的,我上次、我之前、我几个小时前)这一类问题的表现能力会比较弱
  • 短期记忆的不足

    • 没有单独管理的短期记忆,有可能会导致回答的不连贯

那我们要怎么处理这个问题呢?

我们可以配合多个LLM来完成很多优化,我这边方便演示都用同一个LLM了,实际项目里,我们可以把任务交给本地大模型来使用,比如Ollama

一切的前提:修改Prompt:

import { ChatPromptTemplate } from "@langchain/core/prompts";

export const createAnswerTemplate = () => {
  const chatPrompt = `你是一个熟读了新海诚小说《天气之子》的读者,精通根据作品原文详细解释和回答问题,在回答问题的时候你会引用作品原文。
 尽可能详细地回答用户的问题。如果问题与原文中没有相关性,你可以根据情况回答。如果实在是无法判断用户的问题,你可以回答不知道。

  以下是原文中跟用户回答相关的内容:
  {context}

  以下是最近的聊天记录(短期记忆):
  {short_term_memory}

  以下是从历史中检索到的相关记忆(长期记忆):
  {chat_history}

  现在,你要基于原文、最近的聊天记录和历史记忆,回答以下问题:
  {question}`;

  const prompt = ChatPromptTemplate.fromTemplate(chatPrompt);

  return prompt;
};

根据修改了之后的prompt,我们需要处理两段Memory:

  • 短期记忆: short_term_memory
  • 长期记忆:chat_history

让我们开始吧!

第一步:添加短期记忆

短期记忆对于当前对话的连续性和连贯性非常的重要

我们来新建一个short-term-memory/index.ts

import * as fs from "fs";
import * as path from "path";
import { Memory } from "../qdrant-memory/save";

const filePath = path.resolve(__dirname, "./short-term-memory.json");

export const saveShortTermMemory = (
  humanMessage: string,
  aiMessage: string
) => {
  let recentMessages: Memory[] = [];
  if (fs.existsSync(filePath)) {
    const data = fs.readFileSync(filePath, "utf-8");
    recentMessages = JSON.parse(data);
  }

  // 保留最近5条对话
  if (recentMessages.length > 5) {
    recentMessages.shift();
  }
  recentMessages.push({ humanMessage, aiMessage });
  fs.writeFileSync(filePath, JSON.stringify(recentMessages, null, 2), "utf-8");
};

export const getShortTermMemory = async () => {
  if (fs.existsSync(filePath)) {
    const data = fs.readFileSync(filePath, "utf-8");
    return JSON.parse(data).map(
      (item: Memory) => `Huamn: ${item.humanMessage}\nAI: ${item.aiMessage}`
    );
  }
  return "";
};
我们这里使用的是本地JSON写入来存储,这只是为了演示方便做的临时措施,在实际项目里,我的推荐是使用Redis来进行短期数据的管理,会好很多

在每次对话结束的时候,添加存储:

import { saveShortTermMemory } from "./memory/short-term-memory";
...
saveShortTermMemory(question, res);

如此一来,我们在最后的lastRagChain里,就可以直接调用我们定义的Chain

short-term-memory-retriever-chain/index.ts

import { RunnableSequence } from "@langchain/core/runnables";
import { getShortTermMemory } from "../../../src/memory/short-term-memory";
export const getShortTermMemoryRetrieverChain = async () => {
  return RunnableSequence.from([
    (input) => input,
    async () => {
      const shortMemory = await getShortTermMemory();
      return shortMemory;
    },
  ]);
};

last-rag-chain/index.ts

import { getShortTermMemoryRetrieverChain } from "../short-term-memory-retriever-chain";
...

const shortTermMemory = await getShortTermMemoryRetrieverChain();
...

  const ragChain = RunnableSequence.from([
    {
      context: contextRetrieverChain,
      // 这里就是插入短期记忆的地方了
      short_term_memory: shortTermMemory,
      chat_history: memoryRetrieverChain,
      question: (input) => input.question,
    },
    prompt,
    model,
    new StringOutputParser(),
  ]);

我们尝试运行一下,在运行的时候,把Qdrant的存储逻辑暂时注释掉,因为我们要测试一下短期记忆是否成功加入了prompt:

运行了第一次:

我们来提问第二次,看看它知不知道我是谁:

很顺利的,第二条消息也成功的回复了我的名字,并且写入了短期记忆里。

第二步:长期记忆

如何判断是不是应该把这次对话加入Qdrant数据库实现长期记忆?

那我加入LLM不就是了!

其实最后我们的对答bot的性能,都可以通过LLM来实现无限可能。

废话不多说,我们来实装吧!

首先我们需要一个新的Prompt来要求LLM根据我们的规定返回我们需要的结果,我们让AI来判断是不是该记住!

prompt-template/memory-judgment-template/index.ts

import { ChatPromptTemplate } from "@langchain/core/prompts";
export const createMemoryJudgmentTemplate = () => {
  const judgmentPrompt = `你是一个负责分析对话内容的重要性和价值的助手。
    请根据以下对话判断是否需要存储到长期记忆中。存储的标准如下:
    1. 如果对话包含对后续对话有帮助的信息,或包含关键的核心意图,请存入长期记忆。
    2. 如果对话是常见问题、无关的信息,或仅仅是寒暄性质的对话,请不要存入长期记忆。
    3. 请重点根据Human后面的内容进行判断,用户如果提到了个人信息这种需要记住的信息,也请存入长期记忆。
  
    对话内容如下:
    Human: {human_message}
    AI: {ai_message}
    
    请完成以下任务:
    1. 提取用户的核心意图。
    2. 判断这次对话是否需要存储到长期记忆,并说明原因。
  
    请使用以下格式输出:
    核心意图:<这里写核心意图>
    是否需要存入长期记忆:<是/否>
    原因:<这里写存储或不存储的原因>`;

  return ChatPromptTemplate.fromTemplate(judgmentPrompt);
};

紧接着,我们需要新建一个Chain,独立于最后需要集合到last-rag-chain的chains,它的作用只是提取出LLM基于这次对话判断的结果:

chains/memory-judgment-chain/index.ts

import { createMemoryJudgmentTemplate } from "../../prompt-template/memory-judgment-template";
import { RunnableSequence } from "@langchain/core/runnables";
import { createChatModel } from "../../model/openai/chat-model";
import { StringOutputParser } from "@langchain/core/output_parsers";

export const getMemoryJudgmentChain = async () => {
  const model = await createChatModel();
  const prompt = createMemoryJudgmentTemplate();

  const judgmentChain = RunnableSequence.from([
    {
      human_message: (input) => input.humanMessage,
      ai_message: (input) => input.aiMessage,
    },
    prompt,
    model,
    new StringOutputParser(),
  ]);

  return judgmentChain;
};

关键点就是save的部分了,要如何存,要如何决定存不存?

在此之前我们先弄两个helper方法,分别用来:

  1. parseJudgmentResult:处理LLM给出的结果,将其转为一些变量,方便调用
  2. isImportantMessage:直接在调用LLM之前,筛选掉一些很明显不需要存入记忆的内容(这个的标准不好说,可以根据需要自己界定范围)

helper/index.ts

export interface JudgmentResult {
  coreIntent: string;
  important: boolean;
  reason: string;
}

export const parseJudgmentResult = (result: string): JudgmentResult => {
  const intentLine = result.match(/核心意图:(.*)/)?.[1]?.trim();
  const storageLine = result.match(/是否需要存入长期记忆:(.*)/)?.[1]?.trim();
  const reasonLine = result.match(/原因:(.*)/)?.[1]?.trim();

  return {
    coreIntent: intentLine || "",
    important: storageLine === "是", // 判断是否存储
    reason: reasonLine || "",
  };
};

export const isImportantMessage = (humanMessage: string) => {
  // 如果用户输入是打招呼或常见问题,直接判断为不重要
  const greetingKeywords = ["你好", "嗨", "您好", "早上好", "晚上好"];
  const importanceKeywords = [
    "重要",
    "关键",
    "核心",
    "记住",
    "我叫",
    "我是",
    "我要",
  ];
  if (
    greetingKeywords.some((kw) => humanMessage.includes(kw)) &&
    !importanceKeywords.some((importanceKey) =>
      humanMessage.includes(importanceKey)
    )
  ) {
    return false; // 直接排除寒暄性质的对话,除非用户提到了个人信息或者特别说明了很重要
  }

  // 如果有其他排除规则,也可以在这里添加
  return true; // 否则认为是重要的
};

然后我们重新设计了save的方法:

/memory/qdrant-memory/save/index.ts

import { parseJudgmentResult, isImportantMessage } from "../../../helper";
import { getMemoryJudgmentChain } from "../../../chains/memory-judgment-chain";
import { createMemoryFromQdrant } from "../create";

export interface Memory {
  humanMessage: string;
  aiMessage: string;
}

export const saveMemoryToQdrantWithFilter = async (messages: Memory) => {
  const judgmentChain = await getMemoryJudgmentChain();

  if (!isImportantMessage(messages.humanMessage)) {
    console.log(
      "对话被判断为不需要存入长期记忆。原因:寒暄或常见问题,已被提前筛除。"
    );
    return;
  } else {
    // 调用判断链
    const judgmentResult = await judgmentChain.invoke({
      humanMessage: messages.humanMessage,
      aiMessage: messages.aiMessage,
    });

    const result = parseJudgmentResult(judgmentResult);

    console.log("AI 判断结果:", result);

    // 判断是否需要存入长期记忆
    if (result.important) {
      const memory = await createMemoryFromQdrant();
      await memory.saveContext(
        { input: messages.humanMessage },
        { output: messages.aiMessage }
      );
      console.log("重要对话已存入长期记忆。原因:", result.reason);
    } else {
      console.log("对话被判断为不需要存入长期记忆。原因:", result.reason);
    }
  }
};

看代码就知道了,我们先根据预设的一些直接不需要存入的内容,直接确认不存。节省Token。如果判断之后,根据AI的判断来决定是否存入:

来点例子:

你好,我是八六

你好

这样,我们就实现了一个长期记忆存入的初步判断逻辑。

理论上,只要完成了前两步就已经足够对记忆有一个好的处理了,下面的第三步和第五个可以视情况是否添加,第四步可以着重做一下

第三步:根据问题复杂度调整检索结果数量

这一步可以做也可以不做,如果业务复杂了,就可以做这一步,在例子中我并没有这样做,因为暂时也没有必要,但是还是需要提一下

我们再加一个Prompt Template,来专门让LLM处理需要几条检索结果,来处理对应复杂度的问题:

import { ChatPromptTemplate } from "@langchain/core/prompts";

export const createComplexityAnalysisPrompt = () => {
  const prompt = `你是一个负责分析问题复杂度的助手。
  请根据用户的问题,判断问题的复杂度和所需的上下文检索条目数量。
  
  用户的问题如下:
  {question}
  
  输出格式:
  问题类型:<简单/复杂/模糊>
  检索条目数量:<数量>`;
  
  return ChatPromptTemplate.fromTemplate(prompt);
};

我们就可以把这个Prompt加到

  const memory = new VectorStoreRetrieverMemory({
    vectorStoreRetriever: vectorStore.asRetriever(1),
    memoryKey: "ten_ki_no_ko_history",
  });

替换掉这个1,另外,得到的结果也需要更正一下。不难,这里就不赘述了。

第四步:在存储前生成摘要,减少存储冗余

这一步,我们经常干了,主要目的也是为了节省之后的Memory调用消耗掉的Token。

一样的,添加LLM来处理问题:

先生成Prompt:

prompt-template/conversation-summary-template/index.ts

import { ChatPromptTemplate } from "@langchain/core/prompts";

export const createSummaryPrompt = () => {
  const prompt = `你是一个负责生成对话摘要的助手。
  请根据以下对话生成简短且概括的摘要,保留对话的核心信息。

  对话内容如下:
  Human: {human_message}
  AI: {ai_message}

  输出格式:
  对话摘要:<这里写摘要>`;
  
  return ChatPromptTemplate.fromTemplate(prompt);
};

然后按照我设计的结构,下一步是新建chain,专门处理Summary的内容:

chains/conversation-summary-chain/index.ts

import { createSummaryPrompt } from "../../prompt-template/conversation-summary-template";
import { RunnableSequence } from "@langchain/core/runnables";
import { createChatModel } from "../../model/openai/chat-model";
import { StringOutputParser } from "@langchain/core/output_parsers";

export const getConversationSummaryChain = async () => {
  const model = await createChatModel();
  const prompt = createSummaryPrompt();

  const conversationSummaryChain = RunnableSequence.from([
    {
      human_message: (input) => input.humanMessage,
      ai_message: (input) => input.aiMessage,
    },
    prompt,
    model,
    new StringOutputParser(),
  ]);

  return conversationSummaryChain;
};

修改save的逻辑:

    // 判断是否需要存入长期记忆
    if (result.important) {
      const summaryChain = await getConversationSummaryChain();
      const summaryRes = await summaryChain.invoke({
        humanMessage: messages.humanMessage,
        aiMessage: messages.aiMessage,
      });
      const summary = summaryRes.match(/对话摘要:(.*)/)?.[1]?.trim();
      const memory = await createMemoryFromQdrant();
      await memory.saveContext(
        { summary: summary },
        { aiMessage: messages.aiMessage }
      );
      console.log("重要对话已存入长期记忆。原因:", result.reason);
    } else {
      console.log("对话被判断为不需要存入长期记忆。原因:", result.reason);
    }
  }

我们来看看效果:

这样我们就很好的处理了摘要,提高了精准性和匹配度,在有一些对话内容很长的场合还可以节省Token

第五步:规则引擎:快速响应简单或固定问题

并不是任何问题都需要调用LLM来回答,就像我之前开发QQ Bot一样,我们只对特定的内容做出回复,速度很快。在chat Bot里面也是一样可以的。

将常见问题和固定回答定义为一个规则列表、

随后我们实现规则引擎、

作用就是根据用户的输入,来匹配规则

/pre-defined-res/index.ts

const predefinedRules = [
  {
    pattern: /你好|您好|嗨/i,
    response: "你好!很高兴见到你,有什么我可以帮助的吗?",
  },
  {
    pattern: /《天气之子》是关于什么的/i,
    response: "《天气之子》是一部由新海诚执导的电影,讲述了少年帆高和拥有天气操控能力的少女阳菜之间的故事。",
  },
  {
    pattern: /导演是谁/i,
    response: "《天气之子》的导演是新海诚。",
  },
];

export const ruleBasedResponse = (question) => {
  for (const rule of predefinedRules) {
    if (rule.pattern.test(question)) {
      return rule.response;
    }
  }
  return null; // 如果没有匹配规则,返回 null
};

整合到主流程:

import { getLastRagChain } from "./chains/last-rag-chain";
import { saveMemoryToQdrantWithFilter } from "./memory/qdrant-memory/save";
import { saveQdrant } from "./db/saveQdrant";
import { saveShortTermMemory } from "./memory/short-term-memory";
import { ruleBasedResponse } from "./pre-defined-res";

const run = async () => {
  await saveQdrant();
  const ragChain = await getLastRagChain();

  const question = "《天气之子》是关于什么的?";

  // Step 1: 规则引擎优先响应
  const ruleResponse = ruleBasedResponse(question);
  if (ruleResponse) {
    console.log("规则引擎回答:", ruleResponse);
    return; // 如果匹配规则,直接返回答案
  }

  // Step 2: 调用 RAG 模型
  const res = await ragChain.invoke({
    question,
  });

  console.log(res);

  saveShortTermMemory(question, res);

  await saveMemoryToQdrantWithFilter({
    humanMessage: question,
    aiMessage: res,
  });
};
run();

最后的结果就是:

这样也可以提高反应速度,不需要调用Token来处理一些很简单的问答。

总结

这就是Memory的叠加使用,我思考了很久要怎么做到最好,但是当需求越来越涌现的时候,我意识到,LLM APP的可行性和可玩性实在是太多了,所以我干脆就只是抛砖引玉,在特定的环境下,我们能做的事情也就不一样了,希望这篇文章能够帮助你有更多的灵感来处理独属于你的IDEA而诞生的LLM APP,将它做到更好。

这一节的仓库分支链接

最后修改:2024 年 12 月 22 日
收款不要了,给孩子补充点点赞数吧