添加依赖

pnpm add @langchain/core @langchain/langgraph uuid
pnpm add dotenv

创建.env文件,并添加 API_KEYBASE_URL(如果没有使用代理,就不用添加) 两个配置。

创建一个简单对话

import { ChatOpenAI } from '@langchain/openai'
import 'dotenv/config'

const llm = new ChatOpenAI({
modelName: 'o3-mini',
openAIApiKey: process.env.API_KEY,
configuration: {
baseURL: process.env.BASE_URL,
},
temperature: 0, //设置为0是为了让大模型的回复不会有太大的随机性
})

const resp = await llm.invoke([
{
role: 'user',
content: 'Hi im bob',
},
])

console.log(resp.content)

返回

问题的产生

如果我们再询问一下问题

await llm.invoke([{ role: "user", content: "What's my name"}])

返回内容如下,可见模型并不知道我们的名字

但如果我们带了上下文内容

await llm.invoke([
{ role: "user", content: "Hi! I'm Bob" },
{ role: "assistant", content: "Hello Bob! How can I assist you today?" },
{ role: "user", content: "What's my name?" },
])

我们得到了很好的回应,可见让大模型与我们正常进行对话的基本要求在于消息的持久化

Message Persistence 消息持久化

LangGraph实现了一个内置的持久层,使其成为支持多个轮次对话的理想选择。它让我们能够自动持久化消息历史记录,从而简化多轮次对话应用的开发。

import { START, END, MessagesAnnotation, StateGraph, MemorySaver, } from '@langchain/langgraph'
import { v4 as uuidv4 } from 'uuid'

//定义一个节点函数,这个函数作用就是将传入的用户输入传给大模型,然后将生成的回答返回
const callModel = async (state) => {
const response = await llm.invoke(state.messages)
return { messages: response }
}

//创建一个工作流对象 以及对应的流程
const workflow = new StateGraph(MessagesAnnotation)
.addNode('model', callModel) //往工作流中声明一个节点,这个节点就执行上面定义的那个函数
.addEdge(START, 'model') //声明状态转移,工作流从开始转移到执行 'model' 节点
.addEdge('model', END) //声明状态转移,工作流从执行 'model' 节点转移到结束

//创建一个内存保存对象,实际使用中应该换成对应的RedisSaver 或者FirestoreSaver 以及自定义的BaseCheckpointerSaver等等。内存保存对象会在应用重启后丢失,仅适用于调试
//存储使用键值对形式,通过唯一标识关联检查点
const memory = new MemorySaver()

//将工作流编译成执行对象,传入检查点持久化使用的保存对象
//checkpointer可以提供状态持久化和恢复的能力,会记录对话历史,变量值,以及节点执行结果
//并且支持暂停和恢复,允许工作流在中断后,从上一次的检查点继续执行
const app = workflow.compile({ checkpointer: memory })

//创建一个设置对象,内部生成一个uuid来给检查点使用的唯一标识
//thread_id用于唯一标识一个会话,用于关联检查点
//每次调用app.invoke的时候,系统会根据thread_id从MemorySaver中加载状态
const config = { configurable: { thread_id: uuidv4() } }

//第一次传入用户输入的内容
const input = [{ role: 'user', content: "Hi! I'm Bob" }]
let output = await app.invoke({ messages: input }, config)
console.log('output', output.messages[output.messages.length - 1].content)

//第二次传入用户输入的内容
const input2 = [{ role: 'user', content: "What's my name?" }]
output = await app.invoke({ messages: input2 }, config)
console.log('output2', output.messages[output.messages.length - 1].content)

返回结果可见模型已经能够获取到了上下文来支持进行多轮对话了

打印 output.messages 可以看到每轮的对话都被放入了messages中了

切换别的thread_id

一旦我们切换别的thread_id时,就会发现进行了新的对话了

const config2 = { configurable: { thread_id: uuidv4() } }
const input2 = [{ role: 'user', content: "What's my name?" }]
output = await app.invoke({ messages: input2 }, config2)
console.log('output2', output.messages[output.messages.length - 1].content)

结果可以看到切换了thread_id就得不到原先的上下文了,也就记不住之前的对话内容了

返回原先的对话

不用担心这个问题,因为我们在内存中持久化了原先的对话,因此我们可以通过原先的thread_id将对话切换回来

output = await app.invoke({ messages: input2 }, config)
console.log('output3', output.messages[output.messages.length - 1].content)

可以看到在第三次对话中,又能获取到之前的聊天内容了

Prompt Templates 提示词模板

添加提示词模板,会让一些输入可以进行复用以及简化一些输入

//使用MessagesPlaceholder来将后面所有的输入消息都塞入这里的占位符中
const promptTemplate = ChatPromptTemplate.fromMessages([
['system','You talk like a pirate. Answer all questions to the best of your ability'],
['placeholder', '{messages}'],
])

const callModel = async (state) => {
//构建一个提示词
const prompt = await promptTemplate.invoke(state)
const response = await llm.invoke(prompt)
return { messages: response }
}

const input = [{ role: 'user', content: "Hi! I'm Jim" }]
let output = await app.invoke({ messages: input }, config)
console.log(output.messages[output.messages.length - 1].content)

const input2 = [{ role: 'user', content: 'What is my name?' }]
output = await app.invoke({ messages: input2 }, config)
console.log(output.messages[output.messages.length - 1].content)

结果如下,可以发现返回内容都是以海盗的口吻说的

增加点复杂度

//在系统Message中添加自定义的变量
const promptTemplate2 = ChatPromptTemplate.fromMessages([
[
"system",
"You are a helpful assistant. Answer all questions to the best of your ability in {language}.",
],
["placeholder", "{messages}"],
]);

//修改stateGraph接收的字段类型,添加language
const GraphAnnotation = Annotation.Root({
...MessagesAnnotation.spec,
language: Annotation(),
})

//使用新的提示词模板
const callModel = async (state) => {
const prompt = await promptTemplate2.invoke(state)
const response = await llm.invoke(prompt)
return { messages: response }
}

//创建StateGraph对象的时候使用新的字段类型
const workflow = new StateGraph(GraphAnnotation)
.addNode('model', callModel)
.addEdge(START, 'model')
.addEdge('model', END)

const memory = new MemorySaver()
const app = workflow.compile({ checkpointer: memory })
const config = { configurable: { thread_id: uuidv4() } }

const input = [{ role: 'user', content: "Hi! I'm Jim" }]
//invoke的时候,传入对应提示词模板中的变量
let output = await app.invoke({ messages: input, language: 'chinese' }, config)
console.log(output.messages[output.messages.length - 1].content)

const input2 = [{ role: 'user', content: 'What is my name?' }]
//invoke的时候,传入对应提示词模板中的变量
output = await app.invoke({ messages: input2, language: 'chinese' }, config)
console.log(output.messages[output.messages.length - 1].content)

因为整个过程是持久化的,所以我们可以在第二次对话的时候,省略传递language参数,它会继续使用上一次的language

管理对话历史

不受限制的对话历史,可能会产生超出边界导致上下文溢出模型的token限制之类的问题,所以给对话添加消息数量限制是很有必要的

import { ChatOpenAI } from '@langchain/openai'
import 'dotenv/config'
import { SystemMessage, HumanMessage, AIMessage, trimMessages } from '@langchain/core/messages'
import { START, END, MessagesAnnotation, StateGraph, MemorySaver } from '@langchain/langgraph'
import { v4 as uuidv4 } from 'uuid'

const llm = new ChatOpenAI({
modelName: 'deepseek-r1',
openAIApiKey: process.env.API_KEY,
configuration: {
baseURL: process.env.BASE_URL,
},
temperature: 0
})

//创建对Message进行裁剪的对象
const trimmer = trimMessages({
//设置上下文消息最大容量为10Token
maxTokens: 10,
//last会保留新消息,去除老消息,first会保留旧消息去除新消息
strategy: 'last',
//重写token计算方法,将消息总条数作为token数
tokenCounter: (msgs) => msgs.length,
//是否保留所有系统消息,设置为false会剔除掉系统消息
includeSystem: true,
//是否允许截断单条内容
allowPartial: false,
//从第一条user message开始计数
startOn: 'human',
})

const callModel = async (state) => {
//对已有消息进行修剪,将修剪完的消息上下文传给模型
const resultMessages = await trimmer.invoke(state.messages)
const response = await llm.invoke(resultMessages)
return { messages: response }
}

const workflow = new StateGraph(MessagesAnnotation)
.addNode('model', callModel)
.addEdge(START, 'model')
.addEdge('model', END)

const memory = new MemorySaver()
const app = workflow.compile({ checkpointer: memory })
const config = { configurable: { thread_id: uuidv4() } }

const messages = [
new SystemMessage("you're a good assistant"),
new HumanMessage("hi! I'm bob"),
new AIMessage('hi!'),
new HumanMessage('I like vanilla ice cream'),
new AIMessage('nice'),
new HumanMessage('whats 2+2'),
new AIMessage('4'),
new HumanMessage('thanks'),
new AIMessage('no problem!'),
new HumanMessage('having fun?'),
new AIMessage('yes!'),
]

const input = [...messages, { role: 'user', content: "What's my name?" }]
let output = await app.invoke({ messages: input }, config)
console.log('output', output.messages[output.messages.length - 1].content)

结果会发现最上面的"hi! I'm bob"没有在上下文中

如果询问"What's my like?"的话,就能获取到内容

当然在这个对话中多问几次之后,"What's my like?"也会不记得了🤣