大模型无状态:Cursor 多轮对话底层是怎么和大模型对话的
你昨天在 Cursor 里跟 AI 聊了一下午,从”这个空指针怎么回事”聊到”帮我把这个类重构成策略模式”。今天新开一个会话,想问一句”接着昨天的重构继续”,结果它一脸茫然,反问你”哪个类?什么重构?”
这就引出了一个和直觉相悖的事实:那个能跟你连续对话、甚至记得你整个项目的 AI,本身是”无状态”的。 它没有记忆,没有”上一轮”的概念,更不会因为你昨天聊过就认得你。
那”多轮对话”这种连续感是怎么来的?上下文窗口到底限住了什么?Cursor 又是怎么在窗口塞不下时做”压缩”、在你点”新对话”时做”清空”的?
这篇文章延续之前《从大模型到 Agent》的剖析风格,从”无状态”这个根子出发,把 Cursor 里一次多轮对话的底层数据流完整拆开。
一、大模型真的是”无状态”的
1.1 无状态到底是什么意思
上一篇文章里我们把大模型归结为一个纯函数:
f(tokens) -> tokens
“无状态”是这条结论的直接推论:每一次推理都是一次独立的、互不关联的计算。 服务端收到请求 → 做一次前向计算 → 吐出结果 → 本次计算的中间状态(激活值、KV Cache 等)用完即弃。下一次请求来时,服务端既不记得你上一次说了什么,也不记得自己上一次回了什么。
用更工程的话说:大模型的推理接口是一个”无状态服务”,它没有会话(session)这个概念。 你看到的”会话”,全是客户端这一侧制造出来的。
1.2 那”连续对话”的感觉是哪来的
答案是:客户端每轮都把”整个历史”重新发给模型。
打个比方。想象一个记忆力只有几秒钟的人,你想跟他持续讨论一个问题,唯一的办法是——每说一句话之前,先把之前所有的对话内容完整念一遍给他听,然后再说你新要说的这句话。他听完这”一大段”,才能接上话。等你想说下一句时,又得把之前的内容(含刚才那一大段)再念一遍。
这个”每次重新念一遍”的动作,在工程上就叫把历史消息拼进请求里重发。连续感的本质,是客户端不惜成本地”重复投喂历史”堆出来的,而不是模型真的记得你。
1.3 一个最直接的证据
很多人第一次意识到”无状态”是在计费上:你跟同一个 AI 聊得越久,每一轮的 token 消耗越来越大——第 100 轮请求里,有 99 轮的内容是”陈年旧账”。如果模型真的有状态、真的”记得”,它就没必要让你每一轮都为历史买单。
这个”越聊越贵”的现象,正是”无状态 + 全量重发”最直观的副作用,也是后面所有”压缩、清空”手段存在的根本原因。
二、Cursor 里一次多轮对话,底层到底发生了什么
2.1 “会话”不在模型侧,在客户端
在 Cursor 里,真正代表”一段对话”的数据结构,是客户端内存里的一份 messages 列表——一个有序的消息数组。它是”记忆”的唯一真相源(single source of truth)。
Cursor 客户端
│
├─ 会话状态:messages = [
│ { role: "system", content: "……规则、.cursorrules、指令……" },
│ { role: "user", content: "这个空指针怎么回事?" },
│ { role: "assistant", content: "……", tool_calls: [...] },
│ { role: "tool", content: "……read_file 的返回……" },
│ { role: "assistant", content: "……最终回答……" },
│ { role: "user", content: "接着帮我重构……" },
│ ……
│ ]
│
└─ 每次发请求:把 messages 整体(或绝大部分)序列化成 JSON 发出去
模型服务端只做一件事:接收这个数组,逐 token 生成回复。它不知道也不关心这是第几轮、之前发生过什么——因为”之前发生过什么”已经全部摊平在这份数组里了。
2.2 请求里到底装了什么
一次真实的 Cursor 请求,内容远不止”你刚打的那句话”。它通常由这几部分拼装而成:
| 组成 | 说明 | 谁决定的 |
|---|---|---|
| System Prompt | 角色设定、行为规则、工具使用规范,以及你写的 .cursorrules / 项目规则文件 |
Cursor 客户端 + 用户配置 |
| 上下文(Context) | 你 @ 引用的文件、当前打开文件、代码库检索结果(embedding/RAG 命中片段)、Lint/报错信息等 |
Cursor 客户端 |
| 工具 Schema | 每个可用工具(read_file、edit、bash、search…)的名称、描述、参数 JSON Schema | Cursor 客户端 |
| 历史消息 | 本轮之前全部的 system/user/assistant/tool 消息 | 会话状态(客户端内存) |
| 本轮新消息 | 你刚输入的那句话(user) | 你 |
拼装顺序大致是:system + 上下文 + 历史 + 本轮新输入 + 工具 schema(工具 schema 走的是函数调用通道,不占 messages 的 content 位,但也占 token)。
2.3 四种消息角色
一个 messages 数组里的消息,按 role 分四类,各自有明确的语义:
| role | 谁说的 | 内容 | 何时出现 |
|---|---|---|---|
system |
客户端/开发者 | 全局规则、角色设定、工具规范 | 每轮都带,通常在最前面 |
user |
你 | 你的输入、以及”上下文注入”(文件内容常以 user 形式拼入) | 每轮 |
assistant |
模型 | 模型的文本回复;若调用了工具,还带 tool_calls |
每轮 |
tool |
工具执行结果 | 工具(read_file / bash / search…)的真实返回 | 有工具调用的那几轮 |
一个含工具调用的多轮,messages 会呈现”user → assistant(tool_calls) → tool → assistant(最终回答)“的节奏——模型先”要求”查个文件,客户端真去查了,把结果作为 tool 消息塞回数组,模型再据此给出最终答案。
2.4 token 化:历史是怎么被”称重”的
模型不认识”字”,只认 token。发给模型前,整份 messages 会被分词器(tokenizer)切成 token 序列:
"帮我重构一下" → ["帮", "我", "重构", "一下"] (示意,非精确切分)
关于 token 与文字的量级,几个常用的粗经验(仅供参考,不同模型/分词器差异很大):
- 英文:1 token ≈ 0.75 个单词 ≈ 4 个字符;
- 中文:1 个汉字通常 ≈ 1~2 个 token(中文分词”性价比”低于英文,是中文对话更”吃 token”的原因之一);
- 一段 1000 字的代码,往往比 1000 字的中文更”重”,因为符号、缩进、括号都会各占 token。
token 数决定两件事:一次请求能不能塞进窗口,以及这次请求要花多少钱/多少时间。这是后文所有讨论的计量基础。
2.5 一次请求的完整生命周期
把上面串起来,Cursor 里”你发一句话”背后真实发生的事是这样的:
1. 你敲回车,输入 "接着帮我重构这个类"
2. 客户端把本轮输入 append 到 messages(role: user)
3. 客户端拼装完整请求:
system prompt + 上下文(@文件/检索结果) + 历史 messages + 工具 schema
4. 序列化成 JSON,通过 HTTPS 发给模型服务端(无状态请求)
5. 服务端做一次前向计算,逐 token 生成,流式(SSE)返回
6. 客户端把流式文本拼成完整回复,append 到 messages(role: assistant)
7. 若模型返回了 tool_calls:客户端执行工具 → 结果 append 为 role: tool
→ 再发一次请求(此时历史又多了 assistant+tool 两条)→ 回到步骤 5
8. 屏幕上你看到最终的、可读的回复
关键点在第 4~5 步:每一步 5 的”请求”都是全新的、独立的。 服务端不保存第 3 步那份历史,它只在这一刻、对这一份完整输入做一次计算。循环感完全由”客户端反复重发越来越长的历史”这个机制维持。
三、上下文窗口:那个”有限”到底限在哪
3.1 定义
上下文窗口(Context Window)是模型在一次前向计算中能处理的最大 token 数。 它同时覆盖两样东西:
窗口总容量 = 输入 token(含 system/历史/上下文/工具 schema)
+ 输出 token(模型要生成的回复)
注意这个”输出也占窗口”的细节:窗口是输入 + 输出共享的。模型一边生成,一边也在”消耗”同一个窗口;输出写得越长,留给”接下来还能被看到的输入”的空间就越小。所以不是”历史占 128K、回复再额外写 8K”,而是两者在同一个 128K 里抢位置。
3.2 为什么不能无限大
窗口不是厂商不想给你,而是受物理和成本双重约束:
- 注意力是 O(n²) 的:Transformer 的自注意力机制里,每个 token 都要和所有 token 算一遍相关性。序列长度 n 翻倍,计算量近似翻四倍。
- KV Cache 随长度线性增长:为了推理加速,模型会缓存每层的 Key/Value,长度越长,这块显存越大。超长窗口 = 显存爆炸。
- 工程与训练成本:模型要”学会”在超长上下文里不乱翻车,需要额外的长上下文训练(如位置编码外推),成本不菲。
所以”窗口大小”本质是一次能提供多少”工作记忆”,而不是”能记住多久”。
3.3 主流模型的窗口量级(写作时点,仅供参考)
窗口数值更新很快,下表给的是量级而非精确承诺,用于建立直觉:
| 模型 | 上下文窗口量级 | 备注 |
|---|---|---|
| GPT-4o | 128K | 早期主力 |
| GPT-4.1 / o 系列 | 128K ~ 1M | 1M 窗口通常以长文档为卖点 |
| Claude 3.5 Sonnet | 200K | |
| Claude 3.7 / 4 系列 | 200K ~ 1M | 1M 面向超大代码库 |
| Gemini 1.5 / 2.x | 1M ~ 2M | 主打超长窗口 |
| DeepSeek V3 / R1 | 64K ~ 128K | 中文语境常用 |
结论:量级上”十万级”是常态,”百万级”是各家旗舰的差异化卖点。 但要记住——窗口”标称大”不等于”有效利用大”,见 3.4。
3.4 超限会发生什么
一旦拼装出的输入逼近或超过窗口上限,表现会分层恶化:
- 硬截断:客户端或服务端把最老的历史直接砍掉。模型”突然失忆”,你问它三句话前说过的某个变量名,它接不上。
- 报错拒绝:请求直接失败(如
context_length_exceeded),客户端提示你”上下文过长,请新开会话”。 - 迷失在中间(Lost in the Middle):即便没超限,模型对长上下文的中间部分注意力会自然衰减——开头和结尾记得牢,中间一大段”看是看见了,但没真看进去”。这解释了为什么一份贴了 500 行代码、你问第 250 行的问题,它偶尔会答非所问。
- 注意力退化:超长窗口下,模型对细节的抓取能力下降,开始”抓大放小”,精确引用、逐行定位的能力变弱。
所以一条重要心法:窗口长 ≠ 记忆好。 对于”精确定位、逐行修改”这类编码任务,宁可窗口里装的是”精准选中的少量相关代码”,也不要塞满一堆无关文件。
3.5 输入与输出:输出也在”吃掉”窗口
回到 3.1 那个共享公式,它在编码场景里有一个常被忽略的后果:你让模型”一次重写这个 2000 行的文件”,它若真的整文件输出,输出本身可能就吃掉几万 token,加上输入,很容易顶到上限。这也是为什么 Cursor 更推荐基于 diff 的小步编辑(apply patch / edit)而不是整文件重写——小步编辑既省 token,又把窗口留给”对当前改动最有用的上下文”。
四、上下文压缩(Compaction):给记忆”瘦身”
4.1 什么时候触发
窗口有限,而对话可以无限长。当 messages 越来越大、逼近窗口上限时,客户端必须做点什么,否则请求会失败或模型开始”迷失”。这个”做点什么”,最常用的就是压缩(compaction)。
触发时机通常在输入 token 接近某个阈值(如窗口的 80%~90%)时,由客户端主动介入,而不是等撞墙。
4.2 原理:用”摘要”替换”原文”
压缩的核心思想一句话:把早期历史的”原文”删掉,换成一段更短的”摘要”,把省下的 token 留给最近的、最相关的对话。
压缩前:
[system] [msg1] [msg2] [msg3] …… [msg97] [msg98] [msg99] [msg100(你刚发的)]
压缩后:
[system] [摘要(概括 msg1~msg90 的要点)] [msg91] …… [msg100(你刚发的)]
▲
早期原文被一段远短的摘要替换,省出大量 token
摘要的生成方式,通常是再用一次模型:客户端把”要被压缩的那段历史”发给模型,让它输出一个高度概括的总结(谁、在干什么、已有哪些关键结论、还有哪些未决事项),然后把这段总结当作一条消息放回历史头部。
4.3 常见策略对比
| 策略 | 做法 | 优点 | 代价 |
|---|---|---|---|
| 摘要法(Summary) | 用模型把早期历史总结成短文本替换原文 | 保留语义脉络,最”懂”上下文 | 多一次模型调用(有成本);摘要本身会丢细节 |
| 滑动窗口(Sliding Window) | 只保留最近 N 条消息,更早的直接丢弃 | 实现简单、零额外调用 | 早期上下文彻底消失,无摘要兜底 |
| 硬截断(Truncation) | 从最老处按 token 直接砍 | 最简单、成本最低 | 粗暴,可能砍到关键信息 |
| 历史检索(RAG over history) | 把历史向量化/索引,需要时按相关性取回片段 | 保留最多细节,按需取用 | 工程复杂,检索本身有误差 |
Cursor 这类编码工具的实际做法通常是多种混合:既做摘要压缩,又对代码库做检索(RAG)——只把”和当前改动相关”的代码片段放进窗口,而不是把整个仓库塞进去。
4.4 Cursor 里的表现与代价
你会观察到的现象:
- 聊了很久后,模型对你三小时前给的某个精确细节(比如”那段日志里的第 42 行”)开始含糊,因为它已不在窗口里,只剩摘要里的”之前查过一段日志”;
- 模型能”记得大方向”,但精确引用、逐字内容丢失——这是摘要法几乎必然的副作用;
- 压缩发生的瞬间,可能会有一次短暂的停顿/额外耗时(因为多了一次”生成摘要”的模型调用)。
一句话记住代价:压缩是用”精确性”换”续航”。 它保住了”还能继续聊”这件事,代价是早期细节的不可逆丢失。
五、清空 / 新会话:另一种”重置”
5.1 清空 = 客户端丢掉整份 messages
你点”新对话”或”清空上下文”时,模型侧什么都没发生——因为它本就无状态、什么都没存。真正发生的只有一件事:
客户端:messages = [] (或 reset 成一个只含 system prompt 的空会话)
所谓”清空”,是客户端把那份作为记忆真相源的数组整个扔掉。对模型而言,下一轮就是一个全新的、只有 system + 你第一句话的请求。它”忘掉”一切,不是因为它被”擦除”了,而是因为你不再把历史发给它了。
5.2 清空 vs 压缩的区别
这俩都是”应对窗口有限”的手段,但语义完全不同:
| 维度 | 压缩(Compaction) | 清空 / 新会话 |
|---|---|---|
| 动作 | 早期历史 → 摘要 | 整份 messages → 空 |
| 记忆残留 | 有(摘要 + 最近原文) | 无(仅剩 system prompt) |
| 连续性 | 保住”大方向” | 彻底中断 |
| 适用 | 还想在同一任务上继续,但窗口快满 | 换任务 / 任务已结束 / 需要”干净”起点 |
| 成本 | 多一次摘要调用 | 零成本 |
5.3 什么时候该新开,什么时候该让它压缩
- 该新开:任务已经完成、要聊一个毫不相关的新主题、或模型已经开始”糊涂”(历史噪声太多,负收益)。换个干净会话往往比硬续更高效。
- 该压缩/硬续:同一个任务还没做完,早期信息(需求、约束、已做的决定)还有用,丢不起。这时让客户端压缩、保住”大方向”更划算。
- 该用规则文件固化”长期记忆”:有些东西你不希望它随会话消失——项目规范、命名约定、你偏好的代码风格。这些写进
.cursorrules/ 规则文件,由 system prompt 每轮自动带上,才是真正”跨会话”的记忆,而不是靠历史的”肉搏”。
六、一个真实请求示例(模拟)
下面是一段模拟 Cursor 在”你输入一句话、且模型上一轮调用过工具”时,可能拼装出的 messages(精简版,实际还会更长,且 system/工具 schema 被大幅省略):
{
"model": "example-coder",
"messages": [
{
"role": "system",
"content": "你是一名资深后端工程师……遵守 .cursorrules……可用工具见 tools 字段……"
},
{
"role": "user",
"content": "这个空指针是怎么回事?\n\n// @file src/order/OrderService.java\npublic class OrderService { ... }"
},
{
"role": "assistant",
"content": null,
"tool_calls": [
{ "id": "call_1", "type": "function",
"function": { "name": "read_file",
"arguments": "{\"path\":\"src/order/OrderService.java\"}" } }
]
},
{
"role": "tool",
"tool_call_id": "call_1",
"content": " 42: private OrderMapper mapper;\n 43: public Order get(Long id) {\n 44: return mapper.selectById(id).getName();\n 45: }\n"
},
{
"role": "assistant",
"content": "第 44 行 `mapper.selectById(id)` 可能返回 null,直接 `.getName()` 就 NPE 了。建议先判空或返回 Optional。"
},
{
"role": "user",
"content": "接着帮我重构一下,用 Optional 包一层。"
}
]
}
注意几个能印证前文的细节:
- 无状态:这个 JSON 就是请求的全部。服务端不看”这是第几轮”,一切信息都摊平在这里。
- 历史全量重发:你只说了一句”接着帮我重构”,但请求里带着 system、最初的用户输入、工具调用、工具返回、模型上一轮回复……一样不少。
- 上下文注入:最初那条 user 里直接拼进了文件内容(
@file的产物)。 - 工具闭环:
assistant(tool_calls) → tool → assistant的节奏清晰可见。
这就是”多轮对话”在字节层面的真相:不是模型在延续,而是这份越来越长的数组在被反复重发。
七、总结与实践
7.1 一条贯穿全局的心智模型
大模型 = 无状态计算器 (每次独立,记不住任何事)
Cursor 客户端 = 唯一有记忆的"人"(维护 messages 数组,反复重发历史)
上下文窗口 = 每次计算能塞下的"工作记忆"上限(输入+输出共享)
压缩 = 客户端把早期历史换成摘要,用精确性换续航
清空 = 客户端丢掉整份数组,模型侧本就无需"擦除"
记住这一句,就能解释你在 Cursor 里遇到的绝大多数”它怎么又忘了”:
模型无状态,客户端才是记忆。
7.2 几条能直接落地的实践
- 一次会话聚焦一个任务:历史越杂,窗口里噪声越多,模型越容易”迷失在中间”。做完就新开。
- 换任务就新开,别硬续:干净起点通常比拖着几十轮历史更高效。
- 把”长期记忆”写进规则文件:项目规范、代码风格、你的偏好,交给
.cursorrules/规则文件,由 system prompt 每轮带上,才真正跨会话。 - 小步编辑,少整文件重写:输出也占窗口,基于 diff 的小步改动既省 token,又把窗口留给最相关的上下文。
- 精准引用,少”@整个仓库”:窗口长不等于记得准,塞进来的应是”相关代码”,而不是一堆无关文件。
7.3 收尾
把这套原理再往上拔一层,你会发现它和上一篇《从大模型到 Agent》里的 Compaction、Memory 是完全同构的:记忆从来不是模型自带的属性,而是外面那层”壳”(客户端/Agent 运行时)用工程手段制造出来的。 Cursor 是一个把这件事做得相当顺滑的”壳”,但剥开看,它的底层依旧遵守那条最朴素也最反直觉的规则——大模型,真的不记得你。
下次当你惊讶于”它居然还记得我们十分钟前讨论的那个变量名”时,不妨在心里补一句:那不是它记得,是 Cursor 又把整段历史,原封不动地发了一遍。