本文通过一个 Node.js + DeepSeek 的最小示例,解释大模型 API 为什么默认"没有记忆",多轮对话是如何实现的,以及对话历史变长后应该怎样治理。
很多人第一次调用大模型 API 时,都会遇到一个看似反直觉的问题:
第一次请求:
请记住,我的名字叫落月。
模型回答:
好的,我记住了。
紧接着发起第二次请求:
我的名字是什么?
模型却可能回答:
你还没有告诉我你的名字。
模型不是刚刚说"记住了"吗?
答案是:模型在一次请求中理解了这句话,但下一次请求并不会自动获得上一次请求的内容。
这就是理解 LLM 应用的第一个关键概念:无状态。
一、先把实验跑起来
我们先不讨论概念,直接准备一个最小实验。
示例使用 Node.js 调用 DeepSeek 的 Chat Completions 接口。DeepSeek 提供了与 OpenAI API 兼容的接口格式,因此可以使用 openai JavaScript SDK,并配置 DeepSeek 的 apiKey 和 baseURL。
这里的"兼容"是指主要请求格式和 SDK 调用方式兼容,并不代表两个服务商支持的模型、参数和功能完全相同。
创建项目并安装依赖:
bash
mkdir stateless-demo
cd stateless-demo
pnpm init
pnpm add openai dotenv
新建 .env:
dotenv
DEEPSEEK_API_KEY=你的_API_Key
DEEPSEEK_BASE_URL=https://api.deepseek.com
DEEPSEEK_MODEL=deepseek-v4-flash
再新建 index.mjs:
js
import OpenAI from "openai";
import { config } from "dotenv";
config();
const client = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: process.env.DEEPSEEK_BASE_URL
});
async function main() {
console.log("第一次请求:告诉模型我的名字");
const firstResponse = await client.chat.completions.create({
model: process.env.DEEPSEEK_MODEL,
messages: [
{
role: "user",
content: "请记住,我的名字叫落月。"
}
]
});
console.log(firstResponse.choices[0].message.content);
console.log("第二次请求:询问我的名字");
const secondResponse = await client.chat.completions.create({
model: process.env.DEEPSEEK_MODEL,
messages: [
{
role: "user",
content: "我的名字是什么?"
}
]
});
console.log(secondResponse.choices[0].message.content);
}
main().catch(console.error);
运行:
bash
node index.mjs
这里故意发起了两次互相独立的请求。第一次把名字告诉模型,第二次只问名字,却没有再次发送第一轮内容。
第二次请求中的模型看不到"落月",这正是我们接下来要解释的问题。
二、"无状态"到底是什么
HTTP 本身不要求服务端自动保存两次请求之间的应用会话状态。除非应用通过 Cookie、Token、会话 ID 或请求正文主动关联,请求 A 和请求 B 就是两次独立交互。
放到 Chat Completions API 中,可以把每次调用理解成:
text
客户端准备本轮上下文
↓
发送一次 HTTP 请求
↓
模型根据本次请求中的 messages 生成结果
↓
请求结束
下一次调用时,如果客户端只发送:
js
[
{
role: "user",
content: "我的名字是什么?"
}
]
模型只能看到这一句话。它无法凭空知道上一次独立请求中出现过"落月"这个名字。
需要注意,无状态并不等于:
- 服务端不能识别用户;
- 请求不能携带 API Key、Cookie 或 Token;
- 应用不能保存聊天记录;
- 所有大模型 API 都只能以无状态方式使用。
更准确的描述是:
对于无状态调用,处理当前请求所需的身份、指令和上下文,需要由客户端在请求中明确提供。
身份认证和业务状态是两个不同问题。Authorization 通常用于识别调用凭证所属的账户、项目或应用,但它不会自动告诉模型"当前最终用户之前聊过什么"。
三、多轮对话的本质:把历史重新发给模型
要让第二次请求理解第一次请求,最直接的办法是在客户端维护一个 chatHistory 数组:
js
const chatHistory = [
{
role: "system",
content: "你是一个严谨的助手"
}
];
用户发送消息后,将消息加入历史:
js
chatHistory.push({
role: "user",
content: "请记住我的名字是落月"
});
调用模型:
js
const response = await client.chat.completions.create({
model: process.env.DEEPSEEK_MODEL,
messages: chatHistory
});
得到回复后,还要把模型回复也加入历史:
js
chatHistory.push({
role: "assistant",
content: response.choices[0].message.content
});
第二轮提问时,实际发送给模型的内容类似:
js
[
{
role: "system",
content: "你是一个严谨的助手"
},
{
role: "user",
content: "请记住我的名字是落月"
},
{
role: "assistant",
content: "好的,我记住了。"
},
{
role: "user",
content: "我的名字是什么?"
}
]
模型这次能回答"落月",不是因为第一次请求在模型内部留下了永久记忆,而是因为应用把相关历史再次放进了本轮上下文。
所以,一个更准确的结论是:
多轮对话的连续性,通常由应用层的状态管理实现,而不是由一次无状态 API 调用自动实现。
四、为什么用户和助手消息都要保存
只保存用户消息是不完整的。
模型的回复可能包含:
- 对用户意图的确认;
- 已经给出的方案;
- 工具调用结果;
- 对术语的约定;
- 尚未完成的步骤;
- 用户随后引用的内容。
例如:
text
用户:给我三个方案。
助手:A、B、C。
用户:展开第二个。
如果历史里缺少助手的 A、B、C,模型就不知道"第二个"指什么。
因此,手动管理 Chat Completions 对话历史时,至少要按顺序保存:
text
system → user → assistant → user → assistant ...
如果使用工具调用,还需要同时保存工具调用和工具结果,保证上下文链条完整。
五、把前面的逻辑组装成完整程序
到这里,我们已经得到实现多轮对话所需的三个步骤:
- 收到用户输入后,将它写入
messages; - 把完整的
messages发送给模型; - 收到模型回复后,也将回复写回
messages。
现在把这些步骤封装成一个 sendMessage 函数。这样,每次调用它都代表完成一轮对话,调用方不需要重复维护历史。
下面是一份可以直接替换前面 index.mjs 的完整代码:
js
import OpenAI from "openai";
import { config } from "dotenv";
config();
const {
DEEPSEEK_API_KEY,
DEEPSEEK_BASE_URL,
DEEPSEEK_MODEL
} = process.env;
if (!DEEPSEEK_API_KEY || !DEEPSEEK_BASE_URL || !DEEPSEEK_MODEL) {
throw new Error(
"缺少环境变量:DEEPSEEK_API_KEY、DEEPSEEK_BASE_URL 或 DEEPSEEK_MODEL"
);
}
const client = new OpenAI({
apiKey: DEEPSEEK_API_KEY,
baseURL: DEEPSEEK_BASE_URL
});
const messages = [
{
role: "system",
content: "你是一个严谨的软件工程师。"
}
];
async function sendMessage(content) {
const userMessage = {
role: "user",
content
};
messages.push(userMessage);
try {
const response = await client.chat.completions.create({
model: DEEPSEEK_MODEL,
messages
});
const reply = response.choices?.[0]?.message;
if (!reply?.content) {
throw new Error("模型没有返回可用的文本内容");
}
messages.push({
role: "assistant",
content: reply.content
});
return reply.content;
} catch (error) {
// 请求失败时撤销本轮用户消息,避免历史中留下不完整的一轮
if (messages[messages.length - 1] === userMessage) {
messages.pop();
}
throw error;
}
}
async function main() {
console.log(await sendMessage("请记住,我的名字是落月。"));
console.log(await sendMessage("我的名字是什么?"));
}
main().catch((error) => {
console.error("调用大模型失败:", error);
process.exitCode = 1;
});
这段实现假设 sendMessage 按顺序调用,只适合演示单个进程中的单个会话。不要同时并发调用它,否则共享数组可能出现消息交叉或顺序错乱;生产环境需要按会话隔离状态,并控制同一会话的并发。
这份代码相较于开头的实验版本,做了几件事:
- 使用
DEEPSEEK_MODEL环境变量,不在业务代码中硬编码模型; - 启动时检查必要配置,避免请求发出后才发现参数缺失;
- 将用户消息和模型回复都写回历史;
- 请求失败时撤销本轮用户消息,避免历史中残留不完整状态;
- 将一轮对话封装成
sendMessage,便于后续接入命令行、HTTP 接口或网页; - 使用可选链检查返回结果,避免直接访问不存在的字段。
还应注意:不要把真实 .env 提交到 Git 仓库。可以额外提供一个不含密钥的 .env.example:
dotenv
DEEPSEEK_API_KEY=
DEEPSEEK_BASE_URL=https://api.deepseek.com
DEEPSEEK_MODEL=deepseek-v4-flash
并在 .gitignore 中加入:
gitignore
.env
node_modules/
截至 2026 年 6 月 23 日,DeepSeek 官方文档列出了 deepseek-v4-flash 模型。模型可用性可能调整,实际项目仍应以服务商当前文档和账户权限为准。
这个版本已经能够正确维持当前进程内的多轮对话,但它仍然只是教学示例。最明显的问题是:messages 会不断变长。
六、历史记录不能无限追加
最简单的多轮对话实现,是每轮都把完整历史发送给模型。但随着对话变长,会出现三个问题。
1. Token 成本持续增加
历史消息属于输入内容。即使用户本轮只问一句话,前面的长对话仍然可能被重复计入输入 Token。
2. 上下文窗口有限
模型一次能够处理的上下文不是无限的。当消息总量超过限制时,请求可能失败,或者应用必须先删减内容。
3. 噪声会影响回答质量
并非历史越多,回答越准确。大量过期、重复或与当前任务无关的信息,会分散模型注意力,还可能把旧约束错误地带入新任务。
因此,生产环境不能只写一句:
js
messages.push(newMessage);
然后永远不清理。
七、为什么不能简单套用 LRU
看到"容量有限",很容易想到 LRU:删除最久未使用的数据。
但对话历史不是普通缓存,消息之间存在语义依赖:
text
用户:我想做一个订单系统。
助手:你更重视一致性还是可用性?
用户:一致性。
......十轮对话后......
用户:那就按刚才的优先级设计。
最早的业务目标和中间确认的"一致性优先"可能仍然非常重要。单纯按时间删除最旧消息,可能把任务成立的前提一起删掉。
对话裁剪更适合采用 Token 预算加语义分层:
text
固定保留:系统指令、安全规则、当前任务目标
长期保留:用户偏好、关键事实、重要决策
近期保留:最近若干轮原始对话
压缩保存:较早对话的结构化摘要
按需检索:知识库、文档、历史记录
可以删除:寒暄、重复信息、已失效细节
这比固定"只保留最近 20 条"更可靠。
八、一种实用的上下文治理方案
可以把上下文拆成四层。
第一层:稳定指令
例如:
- 助手角色;
- 输出格式;
- 安全边界;
- 业务规则;
- 工具使用规范。
这部分通常放在 system 或开发者指令中,并尽量保持简洁稳定。
第二层:任务状态
保存当前任务真正需要的状态:
json
{
"goal": "设计订单系统",
"constraints": ["一致性优先", "需要审计日志"],
"decisions": ["使用关系型数据库"],
"openQuestions": ["峰值 QPS 尚未确认"]
}
结构化状态通常比让模型每次从几十轮自然语言中重新提取更稳定。
第三层:近期原始对话
保留最近几轮完整消息,避免摘要损失语气、指代和临时细节。
第四层:外部知识
通过 RAG、数据库查询、文件检索或 MCP 等方式,在需要时加载相关资料,而不是把整个知识库永久塞进每一次请求。
最终发送给模型的上下文可以表示为:
text
稳定指令
+ 当前任务状态
+ 相关外部知识
+ 较早对话摘要
+ 最近原始对话
+ 当前用户消息
九、从 Prompt Engineering 到 Context Engineering
只关注 Prompt,常见思路是:
怎样把这一句话写得更好,让模型这次回答得更准?
当应用进入多轮对话、知识检索和工具调用阶段,问题会升级为:
在当前时刻,模型究竟应该看到哪些信息?
这就是 Context Engineering(上下文工程)关注的核心。它包括:
- 指令的优先级与组织方式;
- 对话历史管理;
- RAG 检索;
- 用户状态与长期记忆;
- 工具定义和工具结果;
- 上下文裁剪、摘要与压缩;
- Token、延迟和成本控制。
Prompt 是上下文的一部分,但不是全部。
十、再进一步:从上下文管理走向执行循环
单次请求只包含"输入---生成---输出"。真正的 Agent 往往是一个循环:
text
理解目标
↓
制定下一步
↓
调用工具
↓
读取结果
↓
更新状态
↓
判断是否完成
└──── 未完成则继续
当系统进入这个阶段,工程重点不再只是"把提示词写好",还包括:
- 何时调用工具;
- 工具失败如何重试;
- 怎样防止无限循环;
- 如何保存中间状态;
- 哪些动作需要人工确认;
- 如何记录日志、指标和调用链;
- 怎样做超时、限流、熔断和降级;
- 如何评估任务是否真正完成。
一些开发者会把这类工作称为 Loop Engineering,把承载模型、工具、状态和控制逻辑的运行框架称为 Agent Harness。它们是便于交流的工程术语,并不是已有统一定义的正式标准。
十一、无状态为什么有利于后端扩展
如果应用服务不把会话状态只保存在某个进程内,而是让请求携带必要信息,或从共享存储读取状态,那么任意一个应用实例都可以接手请求:
text
┌→ 应用实例 A ─┐
客户端 → 网关 ├→ 应用实例 B ├→ 模型 API
└→ 应用实例 C ┘
这有利于:
- 水平扩容;
- 负载均衡;
- 故障转移;
- 弹性伸缩;
- 在满足幂等性要求时进行请求重试。
但这不代表整个 LLM 应用没有状态。聊天记录、用户偏好、任务进度和长期记忆仍然需要存储,只是它们通常由应用层、数据库或专门的状态服务管理,再在请求时组装成模型所需的上下文。
可以把职责分成两部分:
text
应用层:保存状态、选择上下文、控制流程
模型层:根据本次输入进行推理并生成结果
十二、生产环境还需要补什么
这个示例适合验证概念,但要用于真实业务,还需要补齐:
- 为每个会话分配唯一 ID,避免不同用户共享同一个内存数组;
- 将历史或任务状态保存到 Redis、数据库等外部存储;
- 对同一会话的并发请求进行串行化或版本控制,避免消息顺序错乱;
- 按 Token 而不是消息条数控制上下文预算;
- 对旧历史进行摘要,并验证摘要没有丢失关键约束;
- 设置超时、重试、限流和并发控制;
- 区分可重试错误与认证、参数等不可重试错误;
- 记录 Token 用量、延迟、模型和请求 ID;
- 避免在日志中输出 API Key 和敏感对话;
- 使用评测集验证裁剪和摘要是否降低回答质量;
- 对模型名和服务地址使用配置管理,而不是硬编码。
另外,SDK 版本号前面的 ^ 允许安装兼容范围内的新版本。生产项目应提交锁文件,并在升级后运行测试,避免依赖行为变化直接进入线上。
总结
大模型多轮对话最重要的几个结论是:
- Chat Completions 的独立请求默认不会自动继承上一轮内容;
- 模型"记住"某件事,通常是因为应用把相关历史再次传入;
- 用户消息、助手回复和工具结果共同构成完整上下文;
- 历史无限增长会增加成本、延迟和上下文溢出风险;
- 对话治理不能只靠简单 LRU,应结合 Token 预算、语义重要性、摘要和检索;
- Prompt Engineering 关注"怎么说",Context Engineering 关注"给模型看什么",执行循环设计关注"系统怎样持续做事直到完成";
- 无状态有利于服务扩展,但应用状态仍然需要由业务系统显式管理。
当你理解了这些,再看聊天机器人、RAG 和 Agent,就会发现它们并不神秘:本质上都是在持续完成三件事------保存状态、选择上下文、驱动下一次模型调用。