我们做成了第一个流程完整的chatbot,现在补上另一部分的内容——Memory
。一个LLM要如何在chat中记住沟通的上下文。
如果不差钱,当然可以把所有的沟通上下文都交给LLM,但是受限于LLM的上下文窗口,很容易触及到上限,token也是增加的越来越多。并且,用户后续发送的信息都是有可能和前面的聊天话题完全无关的,无关的聊天记录塞给LLM上下文里,会影响关注点的判断。
所以Memory的概念也就不仅仅是记录完整的聊天记录了
ChatMessageHistory
我们可以认为Chat History是一组Message子类对象组成的列表,Message的子类可能是HumanMessage、AIMessage,而,Memory就是构建在Chat History上的概念。
这个听起来好像蛮抽象的,详细一点的说法就是,用户和LLM的所有聊天记录都会完整的存储在Chat History里,它会负责把这些原始数据存储在内存中或者对接的其他数据库里。
在大多数情况下,我们不会把完整的Chat History嵌入到LLM上下文里;提取聊天记录的摘要或者返回最近几条聊天记录这些处理逻辑就是在Memory里完成的
简单来说就是:
- Chat History负责记录聊天记录
- 在 Chat History 的基础上,通过智能的处理逻辑,提取出关键内容,为构建上下文提供服务
那么,先来理解Chat History吧:
import { ChatMessageHistory } from "langchain/stores/message/in_memory";
import { HumanMessage, AIMessage } from "@langchain/core/messages"
const history = new ChatMessageHistory();
await history.addMessage(new HumanMessage("Hello, how are you?"));
await history.addMessage(new AIMessage("I'm fine, thank you."));
const messages = await history.getMessages();
messages;
得到的结果:
这就是说Chat History的所有内容了,所有的Chat History都继承自BaseChatMessageHistory
export declare abstract class BaseChatMessageHistory extends Serializable {
abstract getMessages(): Promise<BaseMessage[]>;
abstract addMessage(message: BaseMessage): Promise<void>;
abstract addUserMessage(message: string): Promise<void>;
abstract addAIChatMessage(message: string): Promise<void>;
abstract clear(): Promise<void>;
}
类型定义也是很简单的 ↑
任何实现了 BaseChatMessageHistory
抽象类的对象都可以作为 Memory
的底层 Chat History。
例如,ChatMessageHistory
是一个基于内存的实现,用于快速存储和读取聊天记录。
接下来,我们将创建一个自定义的基于文件存储的 Chat History,实现数据的持久化存储。同时,这种自定义实现可以无缝集成到 Memory 中,为后续对话提供更灵活的上下文支持。
手动维护Chat History
LLM的本质是一个根据上下文产出答案的模型。
聊天记录是一种特殊的上下文,让LLM理解之前的沟通内容,就可以方便理解用户的意图
比如一个很简单的例子:我先告诉LLm我叫做HacchiRoku,那后面我再问LLM“我是?”,他就会从聊天记录这个特殊的上下文里回答出来。
LLM是无状态的,不会存储我们的聊天历史,每次都是根据上下文生成的回答。
聊天记录就是我们自己存起来,然后作为传递给LLM的上下文的一部分
来构建一个简单的chain:
import { load } from "dotenv";
const env = await load();
const process = {
env
}
import { ChatPromptTemplate, MessagesPlaceholder } from "@langchain/core/prompts";
import { ChatOpenAI } from "@langchain/openai";
const chatModel = new ChatOpenAI({
configuration: {
baseURL: process.env.OPENAI_API_URL,
},
modelName: process.env.OPENAI_MODEL,
});
const prompt = ChatPromptTemplate.fromMessages([
["system", `You are a helpful assistant. Answer all questions to the best of your ability.
You are talkative and provides lots of specific details from its context.
If the you does not know the answer, you can say "I don't know" or "I'm not sure".`],
new MessagesPlaceholder("history_message"),
])
const chain = prompt.pipe(chatModel);
这里面的MessagesPlaceholder
就是创建一个名为history_message
的插槽,chain对应的参数就会替换这里。
然后我们再创建一个Chat History,向里面添加一条历史记录,要求LLM回答:
import { ChatMessageHistory } from "langchain/stores/message/in_memory";
import { HumanMessage, AIMessage } from "@langchain/core/messages";
const history = new ChatMessageHistory();
await history.addMessage(new HumanMessage("Hello, how are you? My name is HacchiRoku."));
const res = await chain.invoke({
history_message: await history.getMessages(),
});
返回的值就是AIMessage类型的实例:
AIMessage {
lc_serializable: true,
lc_kwargs: {
content: "Hello, HacchiRoku! I'm doing well, thank you for asking. It's great to meet you! How can I assist you today? Whether it's a question you have, a topic you're curious about, or anything else, I'm here to help!",
additional_kwargs: { function_call: undefined, tool_calls: undefined },
response_metadata: {}
},
lc_namespace: [ "langchain_core", "messages" ],
content: "Hello, HacchiRoku! I'm doing well, thank you for asking. It's great to meet you! How can I assist you today? Whether it's a question you have, a topic you're curious about, or anything else, I'm here to help!",
name: undefined,
additional_kwargs: { function_call: undefined, tool_calls: undefined },
response_metadata: {
tokenUsage: { completionTokens: 52, promptTokens: 81, totalTokens: 133 },
finish_reason: "stop"
}
}
这是LLM返回的信息,其也应该添加到Chat History里面,我们加进来,再提一条新问题:
await history.addMessage(res);
await history.addMessage(new HumanMessage("What is my name?"));
请求回复:
const resWithHumanHistory = await chain.invoke({
history_message: await history.getMessages(),
})
resWithHumanHistory
返回的值是:
AIMessage {
lc_serializable: true,
lc_kwargs: {
content: "Your name is HacchiRoku! It's a unique and intriguing name. Is there a story behind it, or does it have a special meaning for you?",
additional_kwargs: { function_call: undefined, tool_calls: undefined },
response_metadata: {}
},
lc_namespace: [ "langchain_core", "messages" ],
content: "Your name is HacchiRoku! It's a unique and intriguing name. Is there a story behind it, or does it have a special meaning for you?",
name: undefined,
additional_kwargs: { function_call: undefined, tool_calls: undefined },
response_metadata: {
tokenUsage: { completionTokens: 33, promptTokens: 146, totalTokens: 179 },
finish_reason: "stop"
}
}
这就是手动维护History的一套流程了,但是真实项目里肯定不会这么搞,这里只是作为解析过程演示的。
本质上就是Chat History充当一个管理Message对象数据的角色,提供了一些方法工具交给外界来使用。
自动维护Chat History
自动维护也蛮简单的,用的是RunnableWithMessageHistory
给任意的chain包裹一层,就可以添加聊天记录管理的能力了:
import { ChatOpenAI } from "@langchain/openai";
import { load } from "dotenv";
import { ChatPromptTemplate, MessagesPlaceholder } from "@langchain/core/prompts";
import { ChatMessageHistory } from "langchain/stores/message/in_memory";
import { RunnableWithMessageHistory } from "@langchain/core/runnables";
const env = await load();
const process = {
env
}
const chatModel = new ChatOpenAI({
configuration: {
baseURL: process.env.OPENAI_API_URL,
},
modelName: process.env.OPENAI_MODEL,
});
const prompt = ChatPromptTemplate.fromMessages([
["system", "You are a helpful assistant. Answer all questions to the best of you ability."],
new MessagesPlaceholder("history_message"),
["human", "{input}"]
])
const history = new ChatMessageHistory();
const chain = prompt.pipe(chatModel)
const chainWithHistory = new RunnableWithMessageHistory({
runnable: chain,
getMessageHistory: (_sessionId) => history,
inputMessagesKey: "input",
historyMessagesKey: "history_message"
})
其中,需要来说道一下RunnableWithMessageHistory
- runnable:需要被包裹的chain,这里可以是任意的chain
- getMessageHistory:接受的是一个函数,函数需要传入
_sessionId
来获取对应的ChatMessageHistory对象,我们并没有用session来管理,就返回默认的对象就可以了。 - inputMessagesKey:用户传入的信息的key,因为这个
RunnableWithMessageHistory
会自动记录用户和LLM发送的信息,所以需要在这里声明用户用什么key传入什么信息 - historyMessagesKey:很简单,聊天记录在Prompt的key,要自动的把聊天记录注入到Prompt里面。
- 如果你的处理逻辑有多个输出,并且需要指定其中哪个作为 LLM 的回复,你可以使用 outputMessagesKey 来明确标识 LLM 的回复字段。这个字段会告诉 RunnableWithMessageHistory,哪个输出应该被存储为消息历史的一部分。如果处理逻辑只有一个输出(默认是 LLM 的回复),你可以省略这个参数,直接使用默认行为。
如何这般我们直接调用这个Chain,历史记录会被自动保存,除了正常invoke传入的参数之外,需要指定当前对话的sessionId,不过我们只有这一个对话,所以只需要传none就行了。如果有多个用户对话或者上下文标识的才用。
const result1 = await chainWithHistory.invoke({
input: "我的名字是八六"
}, {
configurable: { sessionId: "none" }
})
result1的content:
content: "你好,八六!很高兴认识你。有什么我可以帮助你的吗?",
再次提问:
const result2 = await chainWithHistory.invoke({
input: "我的名字是?"
}, {
configurable: { sessionId: "none" }
})
result2
result2的content是:
content: "你的名字是八六。请问你想讨论什么内容呢?",
这样,我们的chain自动有了记忆功能,可以自己打印一下history里记录下来的Message数据:
await history.getMessages()
其中,就出现了四条Message信息,两条AI,两条Human信息,一问一答,所以RunnableWithMessageHistory
就是帮我们自动把用户和LLM的消息存储到history里面,省下了手动操作的麻烦。
自动生成Chat history摘要
前面RunnableWithMessageHistory
把历史记录完整的传递到了LLM里面,我们可以对LLM的历史记录进行更多的操作,比如说我只传最近的n条记录
我们来实现一个自动对当前聊天历史记录进行总结,然后让LLM根据总结的信息回复用户的Chain
- summary:上一次总结的信息
- new_lines: 用户和LLM新的回复
返回值是一个纯文本的信息,根据历史的summary信息和用户新的对话生成的新的summary,实现代码如下:
import { load } from "dotenv"
const env = await load()
const process = {
env
}
import { ChatOpenAI } from "@langchain/openai";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { RunnableSequence } from "@langchain/core/runnables";
import { StringOutputParser } from "@langchain/core/output_parsers";
const summaryModel = new ChatOpenAI({
configuration: {
baseURL: process.env.OPENAI_API_URL,
},
modelName: process.env.OPENAI_MODEL
})
const summaryPrompt = ChatPromptTemplate.fromTemplate(`
Progressively summarize the lines of conversation provided, adding onto the previous summary returning a new summary
Current summary:
{summary}
New lines of conversation:
{new_lines}
New summary:
`)
const summaryChain = RunnableSequence.from([
summaryPrompt,
summaryModel,
new StringOutputParser()
])
传入两个变量构建Prompt,把Prompt传给Model,最后传给StringOutputParser
生成一个纯文本的返回值。
来尝试调用一下:
const newSummary = await summaryChain.invoke({
summary: "",
new_lines: "I am a software developer"
})
newSummary
"Current summary: A conversation involves a statement from a software developer.\n" +
"\n" +
"New summary: The conversation includes a software developer introducing themselves."
随后把新的newSummary又传给这个Chain:
await summaryChain.invoke({
summary: newSummary,
new_lines: "I am man"
})
"The conversation features a software developer introducing themselves as a man."
发现了吧,我们就成功的实现了渐进式总结历史聊天记录的Chat Bot,然后我们以此为基础构建了一个Chat Bot,可以自动把聊天记录进行summary,传递给llm作为上下文。
我们再来创建一个基础的Prompt模板和用于存储聊天记录的ChatMessageHistory
,这里只是用于临时存储聊天记录信息,不会完整的传给LLM。再创建一个summary,存储用户之前聊天记录的总结信息
const chatPrompt = ChatPromptTemplate.fromMessages([
["system", `You are a helpful assistant. Answer all questions to the best of your ability.
Here is the chat history summary:
{history_summary}
`],
["human","{input}"]
]);
let summary = ""
const history = new ChatMessageHistory()
再来就是实现完整的Chain了:
const chatChain = RunnableSequence.from([
{
input: new RunnablePassthrough({
func: (input) => history.addUserMessage(input)
})
},
RunnablePassthrough.assign({
history_summary: () => summary
}),
chatPrompt,
// 这里可以换别的LLM,summaryModel只是总结用的,请注意。
// 这里LLM回答的内容作为输出传递到下一个节点
summaryModel,
new StringOutputParser,
new RunnablePassthrough({
func: async (answer) => {
history.addAIMessage(answer)
const messages = await history.getMessages()
const new_lines = getBufferString(messages)
console.log(new_lines);
const newSummary = await summaryChain.invoke({
summary,
new_lines
})
history.clear()
summary = newSummary
console.log(summary);
}
})
])
这个例子的跨度有点大,我们来慢慢解释一下其中有些方法的作用,因为是头一次见。
我的代码实际上有一些涉及到了RunnableMap
的概念,这个概念其实指的就是一种特殊的 Runnable,用来把输入的字段映射成多个子Runnable,并且把结果合并到一个结果中。
比如我们可以:
import { RunnableMap } from "@langchain/core/runnables"
const mapChain = RunnableMap.from({
a: () => "a",
b: () => "b"
})
const res = await mapChain.invoke("")
res
那它的返回值就会是:
{ a: "a", b: "b" }
其中,这个a和b的函数也是Runnable对象,两个函数还是并行执行的。如果这两个函数换成任意的Runnable对象,比如两个Chain,也一样会进行并行执行,然后返回相应的结果。
那么我们说回来,在RunnableSequence的数组里,如果有Object类型的值,就会自动转换成RunnableMap,也就是说我们Chain第一个Object,本质上就会新建一个RunnableMap。
另外,RunnablePassthrough
也是头一次见,可以理解为Runnable Chain的一个特殊节点
在这里我们用到的写法是:
new RunnablePassthrough({
func: (input) => history.addUserMessage(input)
})
有两个目的:
- 如果只写
new RunnablePassthrough()
,那就是把用户输入的input再传递给下一个Runnable节点,不做任何操作。因为RunnableMap的返回值是对其中每一个Chain执行的,然后把返回值作为结果传递给下一个Runnable节点,如果我不对input使用RunnablePassthrough
,那么下一个节点就拿不到input的值。 - 增加副作用,func函数就是传递input的过程里,执行的一个函数,这个函数的返回值是void,也就是说无论内容是什么,都不会对input产生影响。
在第一个节点里,我们希望把用户的输入存储到history里,并且把input原封不动的传递给下一个节点。
RunnablePassthrough.assign
就是说在不影响上一个节点信息的基础之上,再添加一些信息,这里就是保留了上个节点传递下来的input值,并且添加了history_summary的值。
然后就是很简单的两个过程,生成Prompt,传递LLM,提取返回信息里的文本内容的节点
最后一个节点,我们再次使用了RunnablePassthrough
的func参数,执行了下面几个操作:
- 把LLM的信息添加到history里面
- 获取history的所有信息,存储到messages变量里
- 使用getBufferString,转为字符串
- 使用summaryChain获取新的总结
- 把新的总结存储到summary里
- 清空history
尝试调用一下:
await chatChain.invoke("我的名字叫八六")
返回的结果是:
Human: 我的名字叫八六
AI: 你好,八六!很高兴认识你!有什么我可以帮你的吗?
The human, identifying themselves as 八六, expresses their name, and the AI welcomes them, asking if there's anything it can assist with. The previous summary about the human's desire for instant noodles is retained, with the added context of their introduction and the AI's friendly response.
"你好,八六!很高兴认识你!有什么我可以帮你的吗?"
我们继续调用:
await chatChain.invoke("我是一名前端开发工程师,请记住我的所有信息")
Human: 我是一名前端开发工程师,请记住我的所有信息
AI: 好的,我会记住您是一名前端开发工程师。如果您有其他想分享的信息或者需要帮助的地方,请随时告诉我!
The human, identifying themselves as 八六, introduces themselves as a front-end developer and requests that the AI remember all their information. The AI acknowledges this and confirms that it will remember the human's profession, while also encouraging them to share any other details or ask for assistance as needed. The previous summaries about the human's name and their desire for instant noodles are retained, now enriched with the human's profession and the AI's supportive response.
"好的,我会记住您是一名前端开发工程师。如果您有其他想分享的信息或者需要帮助的地方,请随时告诉我!"
最后检验一下他的记忆能力:
await chatChain.invoke("每次回答记得提起我的名字,并且基于我的职业推荐我的技术栈应该学习哪些内容?")
字有点多,我贴个图:
每次对话结束之后都会清除掉历史聊天记录,这里的getBufferString就只有最近的两次对话。
每次summary也会更新,随着对话的继续,summary会更新跟用户对话中最重要的部分,作为后续的上下文。这样对比直接提供原始内容会更直接节省tokens,也可以在SummaryChain的Prompt嵌入一些你认为很重要的指令。比如说,如果这是一个算命ChatBot,你可以通过指令让chat在总结信息的时候,只关注算命的关键信息,闲聊可以直接无视,提高Summary的密度。
LLM的上下文不是越多越好的,过量的信息会让LLM分神。不明白重点。
这个摘要过程有点不好理解,我做了一张思维导图:
总结
这次的内容有点抽象,需要反复尝试,来验证记忆功能的作用。自动维护和摘要维护都各有优劣,我们来总结一下:
摘要记忆实现
- 核心逻辑: 通过精炼的摘要替代完整历史,传递核心上下文。
优点:
- 存储开销小,适合长对话。
- 避免模型输入长度限制。
- 灵活调整摘要逻辑,提取关键内容。
缺点:
- 可能丢失细节,影响对话连贯性。
适用场景:
- 长时间对话、资源有限的系统。
- 对核心内容要求高但细节要求低。
完整历史记忆实现
- 核心逻辑: 保存并传递完整的对话历史,确保上下文完整。
优点:
- 保留细节,模型对上下文理解更全面。
- 实现简单,依赖自动管理历史。
缺点:
- 存储和计算开销随历史增长增加。
- 容易受模型输入长度限制。
适用场景:
- 短期对话或对上下文细节要求高的场景。
对比总结
特性 | 摘要记忆 | 完整历史记忆 |
---|---|---|
存储开销 | 小,固定增长 | 大,随对话轮次增长 |
上下文传递 | 精炼核心内容 | 完整传递对话历史 |
输入限制 | 不易超出模型限制 | 容易受限于模型输入长度 |
复杂性 | 需要额外摘要逻辑 | 自动管理,逻辑简单 |
适用场景 | 长时间对话 | 短对话或需要高细节场景 |
选择建议
优先使用摘要记忆:
- 对话内容长且系统资源有限。
- 更关注历史的核心信息而非细节。
优先使用完整历史记忆:
- 短对话或要求高上下文细节的场景。
- 模型输入限制可控,资源充足。
混合方式:
- 在对话长度短时保留完整历史,超过阈值后切换为摘要记忆。