无状态 Stateless:大模型为什么记不住你是谁

无状态 Stateless:大模型为什么记不住你是谁

上一节课,我从 LLM 与 Agent 的区别出发,理解了 Tool Calling 如何让模型从"回答问题"走向"干活"。紧接着又用 AI Loop 把"人盯着 AI 改"编排成了可观测、可停止的闭环。

但在写那些 Demo 的时候,我一直在做一件重复的事:每次调用大模型,都要手动把之前的对话历史全部带上。 我当时只是照着写,并没有想清楚为什么必须这样做。这一节课,我要解决的问题是:大模型为什么不自己记住我?为什么要我每次都把聊天记录重新发一遍?

答案藏在两个词里:HTTPStateless(无状态)

调用 LLM 接口,本质是什么?

先回到最底层。我平时写的 client.chat.completions.create({...}),看起来像是在调用一个"智能"的函数。但剥开 SDK 的包装,它本质就是一次 HTTP 调用

text 复制代码
我的程序  --HTTP POST-->  LLM 服务器(算力生成结果)

LLM 通过算力生成结果,而要能承受高并发、高可用,后端就必须支持无状态(Stateless)。这不是大模型独有的要求,而是所有要扛住大规模流量的 HTTP 服务共同的设计选择。

无状态是什么?为了减小负担,支持高并发

一个很直接的定义:

无状态是为了减小负担,支持高并发。

具体到 HTTP 协议层面,它是这样的:

text 复制代码
HTTP 无状态协议(无状态 GET/POST... restful 从服务器获取资源)
  + header(cookie? 识别身份 / Authorization)(增量)

所有人都公平

这里的"公平"很关键。HTTP 本身是无状态的,意思是:

  • 每次请求都是独立的,不依赖于之前的请求;
  • 服务器不需要存储客户端的状态
  • 服务器可以水平扩展,因为每个请求都是独立的。

那"有状态"呢?如果服务器要记住"你是谁",就得在服务端维护会话状态,这会导致 LLM 服务器压力太大。想象一下,几百万用户同时在和模型聊天,如果每条连接都要在服务器内存里保存对话状态,一台服务器根本扛不住,而且一旦这台服务器挂了,所有人的对话上下文就全丢了。

所以 LLM 选择无状态,本质上是基于 HTTP 的无状态特性

  • 每个请求独立;
  • 服务器不需要记住任何客户端的会话;
  • 任何一台服务器处理这个请求都没差别。

LLM 运行的底层规则

把上面的理解整理一下,LLM 运行时有几条底层规则:

  • 无状态 Stateless:服务端不保存你的对话状态;
  • 尝试让 LLM"懂"我们:更加清晰地表达我们的需求;
  • 每次手动带上全部对话:因为服务器不记,所以只能由客户端把上下文一起发过去;
  • 服务器端并发:在任何一台服务器上运行都没差别。

这四条规则串起来,就解释了我写 Demo 时那个"每次都要 push chatHistory"的动作------不是 SDK 的设计偏好,而是无状态架构的必然结果。服务器不记你,你只能自己记,然后每次请求都带上。

Stateless 的优势:不是"请求都一样",而是"不依赖某个实例"

笔记里有一段很严谨的表述,我觉得值得原样保留:

Stateless 的含义是:服务端不依赖某个服务器进程保存的会话状态 来处理请求;处理请求所需的信息都由请求本身或外部共享存储提供

为什么能简化高并发

简单点说,由于 Stateless,每一次请求对于 LLM 来说都是一样的。那些携带过来的增量(历史对话、system prompt),本质也不过就是上下文信息罢了,不影响请求的本质。也就是说:

请求的处理不依赖某个服务实例内部持有的会话状态。

那么只要增加处理请求的服务器,就能解决高并发的问题------这就是水平扩展

水平扩展具体有哪些:

  • 请求可以被负载均衡到任意实例;
  • 不需要把同一个用户固定路由到某台服务器;
  • 实例故障时,用户请求可以切换到其他实例
  • 扩容和缩容相对简单;
  • 不需要在每个实例之间同步本地会话状态

但要严谨地说

其实是 Stateless 降低了水平扩展和故障转移的复杂度,但能否支撑高并发,仍取决于模型推理资源、调度策略和其他系统瓶颈。

总结起来:

Stateless 的核心优势不是"所有请求都一样",而是"请求处理不依赖某个服务实例本地保存的状态",因此更容易进行水平扩展、负载均衡和故障转移。对于 LLM,历史对话可以作为请求上下文传入,但上下文长度会影响计算成本,所以 Stateless 只是简化扩展,并不意味着增加服务器就能无限解决高并发问题。

这里提到了一个新的问题:上下文长度会影响计算成本。 每次带上全部对话,不只是网络传输的负担,更是模型推理时算力的负担。对话越长,每次请求的 token 开销就越大。这也直接引出了后面 chatHistory 的问题。

从 Stateless 到工程升级:让 LLM 越来越"懂"我们

既然 LLM 是无状态的,每次都要靠我们带上上下文,那怎么让模型更好地理解我们?现在有一个大概的升级路径:

text 复制代码
Prompt Engineering  →  Context Engineering  →  Loop Engineering  →  Harness

Prompt Engineering:聊天对话

第一层是 Prompt Engineering。做法是:

  • 历史对话;
  • 知识库 claude.mdagent.md 作为上下文。

不过本质还是依赖 LLM 的概率性生成获得结果,有一个很生动的比喻:抽卡

Prompt 质量或设计只能提升抽到金卡的概率,不是特别可控的。

这让我想起之前写 Prompt 的经历------同样一个 Prompt,有时模型回答得很好,有时又跑偏了。本质原因就是这还是一个概率游戏,Prompt Engineering 解决不了 AI 可信度问题。

Context Engineering:上下文工程

第二层是 Context Engineering。当 LLM 不懂的、没有的知识,就通过更优质的信息喂给它:

  • RAG:检索增强生成,把外部知识检索回来塞进上下文;
  • MCP:Model Context Protocol,标准化地连接外部数据源和工具;
  • skill:把特定能力封装成可复用的技能。

Loop Engineering:循环工程

第三层是 Loop Engineering:

  • ReAct:Reasoning → Action → Observation 的循环框架;
  • 检测标准:每一轮都要有可验收的标准。

Harness:AI 工程手段

第四层是 Harness,也就是上一节课讲的"护栏/约束框架"。它负责约束目标、执行、评估和资源边界,让 Loop 从实验变成生产系统。

这四层不是替代关系,而是叠加关系。Prompt 是基础,Context 补充知识,Loop 处理多步骤任务,Harness 保证安全和可控。

chatHistory 有什么问题?

理解了 Stateless 之后,回头看 Demo 里那个 chatHistory 全局变量,就能看到几个真实的问题:

1. 没有维护全部的 history

大模型的回复也是关键。 如果只存了用户的消息,不存模型的回复,那下一轮请求时上下文就是残缺的,模型就无法理解"你刚才说的那个"指的是什么。

2. 成本:messages 越来越大,token 开销越大

因为每次请求都要带上全部历史对话,messages 数组会越来越长。对话轮数越多,每次请求的 token 开销就越大。这正是前面那段严谨表述里说的"上下文长度会影响计算成本"。

3. LRU 与 capacity

这是减小 messages 或者说上下文也就是开销的两种方案:

  • LRU(Least Recently Used):一次对话里一直聊,是因为任务还没完成。但 tokens 开销会变大,最近在聊的留下,久远了的可以适当地删除。
  • capacity(容量):上下文窗口是有上限的,不能无限增长。

这其实就是工程上要做的事情:在有限上下文窗口里,用 LRU 策略保留最近、最相关的对话,淘汰久远的部分,在成本和上下文完整性之间找平衡。

Demo 实战:手动维护 chatHistory

Demo 项目结构很简单:

text 复制代码
demo/
  ├── index.mjs
  ├── package.json
  └── pnpm-lock.yaml

package.json 依赖 dotenvopenai

json 复制代码
{
  "name": "demo",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "type": "commonjs",
  "dependencies": {
    "dotenv": "^17.4.2",
    "openai": "^7.4.0"
  }
}

核心代码在 index.mjs。这里用 DeepSeek 作为模型服务,通过 OpenAI 兼容接口调用:

javascript 复制代码
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,
})
// 异步任务
// promise 实例,最后返回 promise 对象,里面有 resolve, reject 属性

// 对话历史 (全局变量)
const chatHistory = [
  { role: 'system', content: '你是一个严谨的助手' }
];
async function testStateless() {
  console.log('第一次请求,告诉模型一个信息');
  // 为了让llm 懂我们,每次对话都携带上 history (上下文) 
  // 将信息添加到 history 中
  chatHistory.push({
    role: 'user',
    content: '请记住我叫字节戴'
  })
  const response = await client.chat.completions.create({
    model: 'deepseek-v4-flash',
    messages: chatHistory
  });
  // 模型回复
  chatHistory.push({
    role: 'assistant',
    content: response.choices[0].message.content
  })
  console.log('模型回复:', 
    response.choices[0].message.content);
  console.log('第二次请求,直接问我是谁');
  // 增添信息到 history 中 
  chatHistory.push({
    role: 'user',
    content: '请问我的名字是什么?'
  })
  const response2 = await client.chat.completions.create({
    model: 'deepseek-v4-flash',
    messages: chatHistory
  });
  // 对话历史
  chatHistory.push({
    role: 'assistant',
    content: response2.choices[0].message.content
  })
  // 模型回复
  console.log('模型回复:', 
    response2.choices[0].message.content);
  console.log(chatHistory);
}

// console.log(testStateless())
testStateless()
  .catch(err => { // test函数中的 reject 方法 
    console.log(err);
  })

这段代码在做什么

整个 Demo 演示了 Stateless 的核心:模型本身不记任何东西,是 chatHistory 这个全局变量在替它记。

执行流程是这样的:

  1. 初始化 chatHistory,放一条 system 消息设定助手人设;
  2. 第一次请求 :push 一条 user 消息"请记住我叫字节戴",把整个 chatHistory 发给模型;
  3. 模型回复后,把 assistant 的回复也 push 进 chatHistory
  4. 第二次请求 :push 一条 user 消息"请问我的名字是什么?",再次把完整的 chatHistory 发给模型;
  5. 模型因为上下文里有"我叫字节戴"这句话,所以能回答出"字节戴"。

注意第二次请求时,chatHistory 已经包含了四条消息:system + 第一次的 user + 第一次的 assistant + 第二次的 user。模型能记住我的名字,不是因为它的服务器存了我的状态,而是因为我把上一轮对话原封不动地又发了一遍

代码里的几个学习信号

注释里藏着几个值得注意的点:

  • // 异步任务 / // promise 实例,最后返回 promise 对象,里面有 resolve, reject 属性client.chat.completions.create 返回的是 Promise,所以用 async/await,最外层用 .catch() 接住 reject。
  • // 为了让llm 懂我们,每次对话都携带上 history (上下文):这句话就是 Stateless 的实操翻译------"让 LLM 懂我们"靠的不是服务端记忆,而是每次手动带上上下文。
  • // console.log(testStateless()) 被注释掉了,改成 testStateless().catch(...):因为 testStateless 返回的是 Promise,直接 console.log 只会打印 Promise { <pending> },必须用 await.then/.catch 才能拿到真实结果。
  • 两次请求之间,assistant 的回复都被 push 进了 chatHistory:这对应了前面说的"大模型的回复也是关键",如果不存模型回复,上下文就残缺了。

我现在怎么理解这节课

在此之前,我对"为什么要带 chatHistory"的理解是模糊的------只知道照着写,不知道为什么。现在我理清了一条完整的因果链:

text 复制代码
LLM 调用本质是 HTTP 调用
  → HTTP 是无状态协议(每个请求独立,服务器不存客户端状态)
    → LLM 服务端不保存你的对话状态
      → 每次请求必须手动带上全部对话上下文
        → messages 越来越长,token 开销越来越大
          → 需要 LRU、capacity 管理来控制成本

Stateless 的核心优势不是"所有请求都一样",而是"请求处理不依赖某个服务实例本地保存的状态"。正因如此,LLM 服务可以水平扩展、负载均衡、故障转移------你发出去的请求落在哪台服务器上,结果都一样。

但 Stateless 也带来了代价:上下文管理的责任从服务端转移到了客户端。我们要自己维护 chatHistory,要在成本和上下文完整性之间权衡,要用 LRU 淘汰久远的对话。这也解释了为什么从 Prompt Engineering 到 Context Engineering、Loop Engineering 再到 Harness,每一层升级本质上都是在回答同一个问题:在无状态的约束下,怎样让模型更可靠地理解我们、完成任务。

回到 Vibe Coding 时代,这个认知让我重新看待平时用 Cursor、Claude Code 这些工具时的体验。它们能"记住"我的项目上下文,不是模型在服务端帮我记着,而是工具在客户端帮我组装上下文、管理 token、做检索和裁剪。Stateless 是地基,上面所有的"智能感"都是工程一层一层搭上去的。

这里我暂时没有踩坑记录,但有一个后续要验证的问题:如果对话很长,LRU 淘汰策略具体该怎么实现?是简单按条数截断,还是按 token 数计算?截断的时候从哪里切才不会破坏上下文连贯性?这些应该会在后面遇到。

相关推荐
Luhui_Dev1 小时前
AI 写论文,你敢用吗?Google 用 CoE 给结论上证据链
人工智能·agent
嘟嘟07171 小时前
学了 useContext 还是不理解?从 prop drilling 到 useMouse 的一次完整复盘
前端·javascript·react.js
起个名字好难啊这也被占用了1 小时前
浏览器里跑能操作DOM的智能体
人工智能
huabuyu1 小时前
模型吐到一半的 JSON 为什么不崩、不卡、不抖?流式 Function Call 的三层修复
前端·javascript
触底反弹1 小时前
JS 运行原理与 Event Loop:从 Web Worker 实战说起
前端·javascript·react.js
0__O1 小时前
从一段 JS 回调变为异步的代码来了解 JavaScript 异步编程
javascript
饼饼学习空间智能1 小时前
遥操作数据能否提升机器人自主化水平?详解模仿学习、仿真放大与Sim2Real闭环
人工智能·算法
echoVic1 小时前
Agent 的会话为什么是一棵树,而不是一串消息
架构·agent
胡萝卜术1 小时前
声明式与命令式的边界:从鉴权路由守卫到 useRef 的引用哲学
前端·javascript·面试