结合你提供的两段 LangChain + DeepSeek 代码,我会从核心差异、执行原理、代码逐行拆解、优缺点、底层本质四个维度,把两种写法讲透,同时解答「LLM 无状态」「记忆实现」的核心问题。
一、先明确核心前提(理解两种写法的根基)
✅ LLM 接口的本质:天生无状态所有大模型的 HTTP API 调用(包括 DeepSeek/OpenAI/ 文心一言)都是「无状态请求」—— 模型对每一次请求的处理都是独立的、孤立的,不会主动保存上一次对话的任何信息。
- 你传什么内容,模型就基于这个内容生成结果;
- 不传历史对话,模型就「失忆」,完全不知道之前聊过什么。
✅ 两种写法的核心区别
✦ 写法 1(无上下文):直接调用模型,每次请求只传「当前问题」,模型无记忆;✦ 写法 2(带上下文):基于 LangChain 的对话记忆组件封装,自动维护历史消息,每次请求「拼接历史 + 当前问题」传给模型,实现多轮上下文关联。
二、写法 1:「原生直调」无上下文版(无记忆,最简调用)
✅ 完整代码回顾
import { ChatDeepSeek } from '@langchain/deepseek';
import 'dotenv/config';
// 1. 初始化大模型实例
const model = new ChatDeepSeek({
model: "deepseek-chat",
temperature: 0,
});
// 2. 两次独立调用
const res = await model.invoke('我叫陈昊,喜欢喝白兰地');
console.log(res.content);
const res2 = await model.invoke('我叫什么名字');
console.log(res2.content);
✅ 执行原理 & 运行结果
1. 核心逻辑
这是 LLM 最基础的调用方式:直接用 model.invoke(单条提问) 发起请求,两次调用之间没有任何关联。
- 第一次调用:仅向模型传入
我叫陈昊,喜欢喝白兰地,模型正常回复; - 第二次调用:仅向模型传入
我叫什么名字,模型完全不知道上一轮的信息。
2. 必然的运行结果
// res.content 输出(正常回应)
你好呀陈昊,很高兴认识你~白兰地口感醇厚,确实是很经典的烈酒呢!
// res2.content 输出(核心:完全失忆)
你还没有告诉我你的名字哦,可以和我说一下呀 😊
3. 代码核心特点(逐行拆解)
- 极简结构:只有「模型初始化 + 直接调用」两步,无额外封装;
- 无中间层:
model.invoke()直接发起 HTTP API 请求,参数就是「单条字符串」; - 完全独立:两次
invoke是两个毫无关系的 HTTP 请求,模型不会共享任何数据。
✅ 写法 1 优缺点总结
✅ 优点
- 代码极简,上手快,适合「单轮问答」场景(比如查知识点、单次翻译、单次生成);
- Token 开销最小:每次只传当前问题,不会产生额外的历史消息 Token 消耗;
- 无额外依赖:仅需初始化模型,不依赖 LangChain 的记忆、Prompt 等组件。
❌ 缺点
- 无上下文记忆:致命问题,无法实现多轮对话,模型答不上「上文相关」的问题;
- 扩展性差:无法自定义对话格式、无法统一系统指令、无法复用对话规则。
三、写法 2:「LangChain 封装」带上下文版(有记忆,多轮对话)
✅ 完整代码回顾
import { ChatDeepSeek } from '@langchain/deepseek';
import { ChatPromptTemplate } from '@langchain/core/prompts';
import { RunnableWithMessageHistory } from '@langchain/core/runnables';
import { InMemoryChatMessageHistory } from '@langchain/core/chat_history';
import 'dotenv/config';
// 1. 初始化大模型实例
const model = new ChatDeepSeek({ model: 'deepseek-chat', temperature: 0 });
// 2. 定义 Prompt 模板(核心:预留历史消息占位符)
const prompt = ChatPromptTemplate.fromMessages([
['system', "你是一个有记忆的助手"], // 系统指令,固定角色
['placeholder', "{history}"], // 历史消息占位符(核心!)
['human', "{input}"] // 当前用户输入占位符
])
// 3. 组装执行链路:Prompt模板 → 模型
const runnable = prompt
.pipe((input) => { // 调试节点:打印最终传给模型的完整内容
console.log(">>> 最终传给模型的信息(Prompt 内存)");
console.log(input)
return input;
})
.pipe(model);
// 4. 初始化「内存级对话历史存储器」
const messageHistory = new InMemoryChatMessageHistory();
// 5. 封装「带记忆的可运行链路」(核心组件)
const chain = new RunnableWithMessageHistory({
runnable, // 基础执行链路(Prompt+模型)
getMessageHistory: async () => messageHistory, // 指定历史存储器
inputMessagesKey: 'input', // 绑定「当前输入」的键名
historyMessagesKey: 'history', // 绑定「历史消息」的键名
});
// 6. 带会话ID的多轮调用
const res1 = await chain.invoke({ input: '我叫陈昊,喜欢喝白兰地' }, {
configurable: { sessionId: 'makefriend' }
})
console.log(res1.content);
const res2 = await chain.invoke({ input: '我叫什么名字' }, {
configurable: { sessionId: 'makefriend' }
})
console.log(res2.content);
✅ 执行原理 & 运行结果
1. 核心逻辑(最关键)
这种写法的本质是:借助 LangChain 组件,自动帮你「维护对话历史 + 拼接历史消息」,解决 LLM 「无状态」的痛点,流程如下:✅ 第 1 步:第一次调用 chain.invoke
- 系统将
input: 我叫陈昊,喜欢喝白兰地传入 Prompt 模板; - 此时无历史消息,模板最终拼接为:
[{role:system, content:你是一个有记忆的助手}, {role:human, content:我叫陈昊,喜欢喝白兰地}]; - 传给模型生成回复后,自动把「用户提问 + 模型回复」存入
messageHistory存储器。
✅ 第 2 步:第二次调用 chain.invoke
-
系统读取
messageHistory中的历史消息; -
自动拼接「系统指令 + 历史消息 + 当前提问」,最终传给模型的完整内容为:
[ {role: "system", content: "你是一个有记忆的助手"}, {role: "human", content: "我叫陈昊,喜欢喝白兰地"}, {role: "assistant", content: "上一轮模型的回复内容"}, {role: "human", content: "我叫什么名字"}] -
模型基于「完整的上下文」生成回复,自然就能答出你的名字。
2. 必然的运行结果
plaintext
// res1.content 输出
你好陈昊!白兰地果香浓郁、口感顺滑,确实是一款很有格调的饮品呢~
// res2.content 输出(核心:精准记忆)
你叫陈昊呀 😊
3. 核心组件拆解(逐模块讲透,看懂就会用)
这是 LangChain 实现「记忆」的核心,也是两种写法的最大差异,每个组件各司其职,缺一不可:
✦ 组件 1:ChatPromptTemplate 对话模板(格式约定)
typescript
运行
ChatPromptTemplate.fromMessages([ ['system', "你是一个有记忆的助手"],
['placeholder', "{history}"],
['human', "{input}"]
])
- 作用:统一对话的格式规范,定义「系统指令、历史消息、当前提问」的固定顺序;
- 关键:
{history}是历史消息占位符,LangChain 会自动把存储的历史对话填充到这里; - 优势:后续修改角色、补充规则,只需改模板,无需改调用逻辑。
✦ 组件 2:InMemoryChatMessageHistory 内存存储器(存历史)
- 作用:临时保存对话历史(用户提问 + 模型回复),数据存在「内存」中,程序重启后丢失;
- 替代方案:生产环境可替换为「Redis / 数据库」存储器,实现历史持久化。
✦ 组件 3:RunnableWithMessageHistory 记忆链路封装(核心)
这是实现「上下文关联」的核心封装类,核心能力:
- 自动读取「存储器」中的历史消息;
- 自动将「历史消息 + 当前输入」填充到 Prompt 模板;
- 自动将本次的「用户输入 + 模型回复」追加到存储器;
- 通过
sessionId实现多会话隔离(比如同时开 2 个对话窗口,历史互不干扰)。
✅ 写法 2 优缺点总结
✅ 优点
- 支持多轮上下文记忆:完美解决 LLM 无状态痛点,实现连贯对话;
- 标准化、可扩展:Prompt 模板统一管理格式,可灵活添加系统指令、角色设定;
- 会话隔离:通过
sessionId区分不同用户 / 不同对话,历史互不污染; - 可调试:支持中间节点(如代码中的
pipe(debug)),查看传给模型的完整内容。
❌ 缺点
- 代码复杂度更高,需要理解 LangChain 核心组件;
- Token 开销随对话递增:历史消息会「滚雪球」式拼接,对话越长,单次请求的 Token 越多,成本越高;
- 内存存储器「非持久化」:程序重启后历史丢失,生产需额外适配持久化方案。
三、两种写法「核心维度」全方位对比表
| 对比维度 | 写法 1(无上下文・原生直调) | 写法 2(带上下文・LangChain 封装) |
|---|---|---|
| 核心能力 | 仅支持「单轮问答」,无记忆 | 支持「多轮对话」,有上下文记忆 |
| LLM 调用方式 | 直接调用 model.invoke(单条内容) | 封装后调用 chain.invoke({input}) |
| 历史消息处理 | 完全不处理,无历史拼接 | 自动维护、自动拼接历史消息 |
| Token 开销 | 极小(仅传当前问题) | 递增(历史 + 当前,滚雪球) |
| 代码复杂度 | 极简(2 步:初始化 + 调用) | 稍复杂(需封装模板 / 存储器 / 链路) |
| 扩展性 | 差(无法统一格式、无角色设定) | 强(模板可改、存储器可替换、支持多会话) |
| 适用场景 | 单轮独立问答(查资料、单次生成) | 多轮连贯对话(智能客服、聊天机器人) |
| 本质区别 | 单次无状态 HTTP 请求 | 「存历史 + 拼历史 + 发请求」的自动化流程 |
四、关键补充:解答你文中提到的「核心疑问」
✅ 疑问 1:为什么 LLM API 调用是无状态的?
- 从服务端角度:大模型的推理计算成本极高,服务端不会为每个用户「长期保存会话」(会耗尽服务器资源);
- 从接口设计角度:HTTP 协议本身就是「无状态」的,一次请求对应一次响应,服务端不保留请求上下文。
✅ 疑问 2:让 LLM 有记忆的「底层本质」是什么?
无论用不用 LangChain,实现记忆的核心逻辑完全一致:
✅ 手动 / 自动 维护一个
messages数组 → 每次调用 LLM 时,把messages完整传给模型 → 模型基于数组内的所有内容生成回复 → 把本次「用户输入 + 模型回复」追加到messages。
你的文中代码也印证了这一点:
messages = [
{role: 'user', content: '我叫陈昊,喜欢喝白兰地'},
{role: 'assistant', content: '----------'},
{role: 'user', content: '你知道我是谁吗?'}
]
👉 LangChain 的作用只是帮你自动化完成上述流程,不用自己手动拼接数组、手动追加历史,简化开发。
✅ 疑问 3:「滚雪球」Token 开销大,怎么解决?
这是多轮对话的通病,有 3 种主流解决方案:
- 历史消息截断:只保留最近 N 轮对话,超出部分丢弃(LangChain 内置该能力);
- 历史消息总结:对过长的历史做「摘要压缩」,用一句话概括之前的对话,减少 Token;
- 使用支持「向量记忆」的组件:将历史对话存入向量库,只把「和当前问题相关」的历史拼接,而非全部。
五、最终总结(快速选型指南)
✅ 选写法 1(原生直调),如果你的需求是:
单轮问答、无上下文要求、追求极简代码、控制 Token 成本(比如:翻译一句话、查一个知识点、生成一段文案)。
✅ 选写法 2(LangChain 封装),如果你的需求是:
多轮连贯对话、需要上下文记忆、需要统一角色设定、需要多会话隔离(比如:智能客服机器人、聊天助手、AI 陪伴)。
✅ 核心一句话
两种写法的本质差异,是是否为 LLM 请求「拼接历史消息」;LangChain 没有创造新原理,只是把「维护历史、拼接消息」的繁琐工作做了标准化封装,让开发者更高效地实现多轮对话。