无状态 Stateless:大模型为什么记不住你是谁
上一节课,我从 LLM 与 Agent 的区别出发,理解了 Tool Calling 如何让模型从"回答问题"走向"干活"。紧接着又用 AI Loop 把"人盯着 AI 改"编排成了可观测、可停止的闭环。
但在写那些 Demo 的时候,我一直在做一件重复的事:每次调用大模型,都要手动把之前的对话历史全部带上。 我当时只是照着写,并没有想清楚为什么必须这样做。这一节课,我要解决的问题是:大模型为什么不自己记住我?为什么要我每次都把聊天记录重新发一遍?
答案藏在两个词里:HTTP 和 Stateless(无状态)。
调用 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.md、agent.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 依赖 dotenv 和 openai:
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 这个全局变量在替它记。
执行流程是这样的:
- 初始化
chatHistory,放一条system消息设定助手人设; - 第一次请求 :push 一条
user消息"请记住我叫字节戴",把整个chatHistory发给模型; - 模型回复后,把
assistant的回复也 push 进chatHistory; - 第二次请求 :push 一条
user消息"请问我的名字是什么?",再次把完整的chatHistory发给模型; - 模型因为上下文里有"我叫字节戴"这句话,所以能回答出"字节戴"。
注意第二次请求时,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 数计算?截断的时候从哪里切才不会破坏上下文连贯性?这些应该会在后面遇到。