为什么大模型“记不住”你?从一次 API 调用讲透 LLM 的无状态、上下文与对话历史

本文通过一个 Node.js + DeepSeek 的最小示例,解释大模型 API 为什么默认"没有记忆",多轮对话是如何实现的,以及对话历史变长后应该怎样治理。

很多人第一次调用大模型 API 时,都会遇到一个看似反直觉的问题:

第一次请求:

请记住,我的名字叫落月。

模型回答:

好的,我记住了。

紧接着发起第二次请求:

我的名字是什么?

模型却可能回答:

你还没有告诉我你的名字。

模型不是刚刚说"记住了"吗?

答案是:模型在一次请求中理解了这句话,但下一次请求并不会自动获得上一次请求的内容。

这就是理解 LLM 应用的第一个关键概念:无状态。

一、先把实验跑起来

我们先不讨论概念,直接准备一个最小实验。

示例使用 Node.js 调用 DeepSeek 的 Chat Completions 接口。DeepSeek 提供了与 OpenAI API 兼容的接口格式,因此可以使用 openai JavaScript SDK,并配置 DeepSeek 的 apiKeybaseURL

这里的"兼容"是指主要请求格式和 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 ...

如果使用工具调用,还需要同时保存工具调用和工具结果,保证上下文链条完整。

五、把前面的逻辑组装成完整程序

到这里,我们已经得到实现多轮对话所需的三个步骤:

  1. 收到用户输入后,将它写入 messages
  2. 把完整的 messages 发送给模型;
  3. 收到模型回复后,也将回复写回 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 版本号前面的 ^ 允许安装兼容范围内的新版本。生产项目应提交锁文件,并在升级后运行测试,避免依赖行为变化直接进入线上。

总结

大模型多轮对话最重要的几个结论是:

  1. Chat Completions 的独立请求默认不会自动继承上一轮内容;
  2. 模型"记住"某件事,通常是因为应用把相关历史再次传入;
  3. 用户消息、助手回复和工具结果共同构成完整上下文;
  4. 历史无限增长会增加成本、延迟和上下文溢出风险;
  5. 对话治理不能只靠简单 LRU,应结合 Token 预算、语义重要性、摘要和检索;
  6. Prompt Engineering 关注"怎么说",Context Engineering 关注"给模型看什么",执行循环设计关注"系统怎样持续做事直到完成";
  7. 无状态有利于服务扩展,但应用状态仍然需要由业务系统显式管理。

当你理解了这些,再看聊天机器人、RAG 和 Agent,就会发现它们并不神秘:本质上都是在持续完成三件事------保存状态、选择上下文、驱动下一次模型调用。

相关推荐
血小溅1 小时前
Skill 脚本语言选型:Python、Node.js、Shell 到底怎么选?
人工智能·后端
ZhengEnCi1 小时前
09d-斯坦福 CS336 作业三:缩放定律(Scaling Laws)
人工智能
JieE2121 小时前
从"无状态"到"懂你":深入理解 LLM 对话的本质,以及 Prompt/Context/Loop 三层工程进化之路
人工智能·llm·ai编程
稚雪九月1 小时前
永久记忆,丰富情感,Atrium AI框架:给AI一颗真正的心
人工智能
小鼻子的猫1 小时前
万字长文讲透 AI Agent 架构设计:从 ReAct 到多 Agent 协作,附完整 Python 代码
人工智能
Hector_zh1 小时前
实战·第八篇:当模型陷入死循环——FACA破解JSON生成的架构陷阱
人工智能·agent·vibecoding
魏祖潇2 小时前
AI 能记住了,但能自己干活吗?——看懂执行系统,你就知道它怎么完成复杂任务
人工智能·ai编程
Lkstar2 小时前
Function Calling 原理深度拆解:让 LLM 调用外部工具的机制与工具设计原则
人工智能·llm
IT_陈寒2 小时前
Vue的响应式真把我坑惨了,原来问题出在这
前端·人工智能·后端