MingJunDuan的博客
热爱可抵一切,探索未知之境
全站访问量

大模型无状态: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 为什么不能无限大

窗口不是厂商不想给你,而是受物理和成本双重约束:

  1. 注意力是 O(n²) 的:Transformer 的自注意力机制里,每个 token 都要和所有 token 算一遍相关性。序列长度 n 翻倍,计算量近似翻四倍。
  2. KV Cache 随长度线性增长:为了推理加速,模型会缓存每层的 Key/Value,长度越长,这块显存越大。超长窗口 = 显存爆炸。
  3. 工程与训练成本:模型要”学会”在超长上下文里不乱翻车,需要额外的长上下文训练(如位置编码外推),成本不菲。

所以”窗口大小”本质是一次能提供多少”工作记忆”,而不是”能记住多久”。

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 超限会发生什么

一旦拼装出的输入逼近或超过窗口上限,表现会分层恶化:

  1. 硬截断:客户端或服务端把最老的历史直接砍掉。模型”突然失忆”,你问它三句话前说过的某个变量名,它接不上。
  2. 报错拒绝:请求直接失败(如 context_length_exceeded),客户端提示你”上下文过长,请新开会话”。
  3. 迷失在中间(Lost in the Middle):即便没超限,模型对长上下文的中间部分注意力会自然衰减——开头和结尾记得牢,中间一大段”看是看见了,但没真看进去”。这解释了为什么一份贴了 500 行代码、你问第 250 行的问题,它偶尔会答非所问。
  4. 注意力退化:超长窗口下,模型对细节的抓取能力下降,开始”抓大放小”,精确引用、逐行定位的能力变弱。

所以一条重要心法:窗口长 ≠ 记忆好。 对于”精确定位、逐行修改”这类编码任务,宁可窗口里装的是”精准选中的少量相关代码”,也不要塞满一堆无关文件。

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 包一层。"
    }
  ]
}

注意几个能印证前文的细节:

  1. 无状态:这个 JSON 就是请求的全部。服务端不看”这是第几轮”,一切信息都摊平在这里。
  2. 历史全量重发:你只说了一句”接着帮我重构”,但请求里带着 system、最初的用户输入、工具调用、工具返回、模型上一轮回复……一样不少。
  3. 上下文注入:最初那条 user 里直接拼进了文件内容(@file 的产物)。
  4. 工具闭环assistant(tool_calls) → tool → assistant 的节奏清晰可见。

这就是”多轮对话”在字节层面的真相:不是模型在延续,而是这份越来越长的数组在被反复重发。


七、总结与实践

7.1 一条贯穿全局的心智模型

大模型        = 无状态计算器   (每次独立,记不住任何事)
Cursor 客户端 = 唯一有记忆的"人"(维护 messages 数组,反复重发历史)
上下文窗口    = 每次计算能塞下的"工作记忆"上限(输入+输出共享)
压缩          = 客户端把早期历史换成摘要,用精确性换续航
清空          = 客户端丢掉整份数组,模型侧本就无需"擦除"

记住这一句,就能解释你在 Cursor 里遇到的绝大多数”它怎么又忘了”:

模型无状态,客户端才是记忆。

7.2 几条能直接落地的实践

  1. 一次会话聚焦一个任务:历史越杂,窗口里噪声越多,模型越容易”迷失在中间”。做完就新开。
  2. 换任务就新开,别硬续:干净起点通常比拖着几十轮历史更高效。
  3. 把”长期记忆”写进规则文件:项目规范、代码风格、你的偏好,交给 .cursorrules/规则文件,由 system prompt 每轮带上,才真正跨会话。
  4. 小步编辑,少整文件重写:输出也占窗口,基于 diff 的小步改动既省 token,又把窗口留给最相关的上下文。
  5. 精准引用,少”@整个仓库”:窗口长不等于记得准,塞进来的应是”相关代码”,而不是一堆无关文件。

7.3 收尾

把这套原理再往上拔一层,你会发现它和上一篇《从大模型到 Agent》里的 Compaction、Memory 是完全同构的:记忆从来不是模型自带的属性,而是外面那层”壳”(客户端/Agent 运行时)用工程手段制造出来的。 Cursor 是一个把这件事做得相当顺滑的”壳”,但剥开看,它的底层依旧遵守那条最朴素也最反直觉的规则——大模型,真的不记得你。

下次当你惊讶于”它居然还记得我们十分钟前讨论的那个变量名”时,不妨在心里补一句:那不是它记得,是 Cursor 又把整段历史,原封不动地发了一遍。

本文阅读量