对话才几轮,上下文窗口怎么就满了?

0 阅读10分钟

1 才几轮,怎么就快满了?

你在 Claude Code 里敲下一句:"帮我看看 src/main.tsx。"回车一按,屏幕上字开始往外冒——它真的去翻开了那个文件,还一行行给你讲里面写得怎么样。

活儿干得顺,你接着往下问:"那个函数能改吗?""跑下测试看看。"——可才这么几轮,上下文窗口就快满了。一共没敲几句话,怎么就快满了?

你大概会猜:它读到的就是我这几句呗。没错,这几句确实都发过去了——可发给模型的,不止这些

那么,上下文窗口里到底装了些什么?

2 先说清"上下文窗口"

上下文窗口 - 说直白点就是调用大模型时发过去的那份 input——模型每一轮读到的,就是这份 input 的全部。input 不是想发多长都行——上下文窗口有上限;多大、装满了怎么办,后面有一节专门讲。这里先记一句:input 里装什么,不归模型管——每一轮,都是 harness 把一整篇组装好、发过去的。

回到上一节末尾的问题:上下文窗口里,到底装了些什么?

一句话答:装的不止你敲的几句话。打字之前,harness 已经替你备好了四样东西;打字之后,你的对话作为第五样,一层层往上叠。 窗口,就是被这两头一起装满的。

可这"四样"具体是哪几样?每一样干嘛的、又是谁塞进去的?这就得一个个来看了。

3 打字前,四样东西就备好了

底下四层,是 harness 在你打字之前就备好的,这节一层层拆开看;最上面那层是你的对话,打字之后才叠上去,下一节单独讲。先看底下四层。

**第 1 层:系统提示词(一本操作手册)。**它从哪来?大头是写死在 Claude Code 程序里的固定文本——Anthropic 写好、随每个版本发布;你装的哪个版本,操作手册就是哪一版。src/constants/prompts.ts 里的 getSystemPrompt 负责拼装,开头第一句就是定身份:

You are an interactive agent that helps users
with software engineering tasks.

你是一个交互式 agent,帮用户干软件工程的活。往下还有长长短短几十条规矩,随手摘两条:

In general, do not propose changes to code you haven't read.
If a user asks about or wants to modify a file, read it first.

When referencing specific functions or pieces of code include
the pattern file_path:line_number to allow the user to easily
navigate to the source code location.

一条管做任务:没读过的代码,别急着提改法,要改先读。一条管怎么说话:提到代码,要带上 文件路径:行号——你在 Claude Code 里见惯的 src/xxx.ts:123,就是这条的来历。

几十条这样的规矩——你是谁、做任务要怎样、用工具要怎样、语气如何——拼成一大篇,再补一小截现场信息(今天的日期、工作目录之类),就是四层里最大的一块,而且每轮原样带着,不打折扣。谁塞的?harness,不是模型自己——模型不会给自己写操作手册。

**第 2 层:CLAUDE.md(一份项目档案)。**你给这个项目立的家规——一个放在项目根目录的普通 markdown 文件,你想写什么写什么,比如:

- 本项目用 Bun 跑脚本,不要用 npm
- 改完代码必须跑 bun run typecheck
- 回答一律用中文

src/context.tsgetUserContext 负责把它读出来——一样是 harness 替你读的,不是模型自己。它和系统提示词的分别在于:系统提示词对所有项目都一样,CLAUDE.md 只管眼下这一个。至于它怎么被一层层找出来(全局的、项目的、还有更本地的),那是入门③的事,这里按住不表。

**第 3 层:git 此刻的状态(当班状态板)。**还是 src/context.ts,这次换 getSystemContext 出手:在哪个分支、主分支叫什么、最近五条提交、改了哪些文件、git 用户是谁。真发过去的样子(虚构一份示例):

This is the git status at the start of the conversation. Note
that this status is a snapshot in time, and will not update
during the conversation.

Current branch: feat/login
Main branch (you will usually use this for PRs): main

Git user: baigongfeng

Status:
 M src/pages/Login.tsx

Recent commits:
f3a92c1 feat: add remember-me option to login page
9b41d07 fix: clear form error on input change

开头那句值得多看一眼:源码特意注明这是会话开始时的快照,会话里不会更新——模型记得的 git 状态可能已经过时,要紧的事它得自己再跑一次 git 查。有了这一层,模型才知道自己在哪个分支、最近改过什么。一样是 harness 替它查好的,不是模型自己瞥见的。

**第 4 层:工具定义(工具架)。**每只工具交出自己的三件套——namedescriptioninputSchema,也就是名字、描述、参数格式。拿 Part 2 拆过的老朋友 Glob 举例,它交上去的(简化版):

{
  "name": "Glob",
  "description": "Fast file pattern matching tool that works
                  with any codebase size …",
  "inputSchema": { "pattern": "src/**/*.ts" }
}

harness 把每只工具都这样序列化好放进去,源码在 src/Tool.ts。回扣 Part 2:模型得先知道有哪些手能伸,才谈得上动手。几十只工具的描述合在一起,也不是个小数。

四层备完,粗算两万 token 上下——20 万的窗口,还没打一个字,一成没了。可这里有个设计细节,不点破容易看走眼。

getUserContextgetSystemContext 这两个函数,源码里都裹着一层 memoize——同一个会话里,它们只算一次:会话刚开时读一次 git、翻一次 CLAUDE.md、把文本拼好,往后每轮都复用这份缓存,不重跑 git、不重翻文件。省下的是本地的读写和拼接活儿。

但别把"只算一次"听成"只发一次"。模型那边是无状态的——它记不住上一轮看过什么。所以每一轮,这四层都得完整重发给它看,一遍不能少。一句话点破:会话内只算一次,省的是本地;每轮照发不误,是协议的要求。 这两件事别混。读者这时候多半会追一句:每轮都完整重发,不费钱吗?费——token 该怎么计费还怎么计费,一轮不少。真省 token 靠的是另一招:prompt cache,把重复发送的内容缓存起来、按折扣计费——那是后面讲成本的深度篇的题,本篇只点这个区分,不展开。

把这五层画成一张图,就是每轮发给模型的全部内容:

01-five-layers.png

这份伪代码落到真实的请求里,就是三个字段:系统提示词、CLAUDE.md、git 状态三段拼在一起,进 system;工具定义进 tools;你的对话进 messages

底下四层是打字前就备好的——会话内只算一次、每轮照发不误;最顶上那层是你的对话,它跟底下四层不一样,每按一次回车,它就厚一截。这层有什么讲究?下一节专门说它。

4 最上面那层,一个回合多一截

讲究在哪?这一层会涨。别急着解释,直接看——拿开头那句"帮我看看 src/main.tsx",把每一回合实际发出去、收回来的消息,一条一条摆出来。

第一回合,你按下回车。 这一回合发出去的对话层,就一条消息:

你:帮我看看 src/main.tsx

模型回话了,一回两条。 一句是说给你听的,一条是调工具的:

模型:我先读一下这个文件。
模型:[调工具] Read,file_path = "src/main.tsx"

harness 跑完 Read,把回执也当成一条消息塞回去——这一步你没说话,是 harness 在替你们传话:

回执:1  import …
      2  …
      (src/main.tsx 全文,约 200 行,逐行贴在这里)

模型看完回执,输出一通分析,第一回合到此结束。

第二回合,你问"那个函数能改吗",再按回车。这次发出去的对话层,是第一回合的全部消息原样一条不少,再加两条新的:

你:帮我看看 src/main.tsx             ┐
模型:我先读一下这个文件。             │ 第一回合的,
模型:[调工具] Read src/main.tsx       │ 原样重发
回执:src/main.tsx 全文,约 200 行     ┘
模型:这个文件是程序入口……(一通分析) ┐ 新叠上去的
你:那个函数能改吗                    ┘ 就这两条

一整份对话里,你新敲的只有最后一条——但前面的每一条都得再发一遍,模型这轮才看得见。所谓"记得",就是这份清单只增不删:旧的不掉,新的往上叠。

消息的大小也看清了:你前后两句话三十来个字,中间夹的是模型的分析加一份 200 行的文件——最占地方的消息,往往是你一条都没敲过的(回执、分析)。

第三回合你说"改吧",第四回合"跑下测试看看"——改文件得先读更多文件,跑命令还有输出,一个回合多一截,越叠越多。把各回合发出去的总量排成一列,走势就是这样:

刚开场    █░░░░░░░░░   四层静态打底(约一成),对话薄薄一层
半小时后  ███████░░░   对话叠了几十个回合,文件回执占大头
1个小时后 ██████████   满了(█ 已占 ░ 剩余 · 长度示意)

底下四层是死份额——不管这会话聊多久,它们占的地方不变;真正让上下文窗口越来越满的,是顶上这层对话——只增,不减。

短短几轮,上下文窗口里就堆起一座小山。可上下文窗口不是无限大的——这就引出了下一个麻烦。

5 上下文窗口就这么大

上下文窗口,是有上限的,计量单位是 token——一个 token 大致是四分之三个英文词、或半个汉字多一点。那到底多大?说具体的。

Claude Code 默认用 Claude 系列模型,这个数在源码里是写死的:20 万 token,眼下所有 Claude 型号都是这个数

出处:src/utils/context.ts 里一行常量 MODEL_CONTEXT_WINDOW_DEFAULT = 200_000,注释就一句——200k tokens for all models right now。部分型号另支持 100 万 token 的长上下文档位,但得显式带上 [1m] 标记才生效,不带就还是 20 万。

其他几家代表模型,官方文档口径:

模型上下文窗口
Claude(Claude Code 默认)20 万 token(部分型号可开 100 万)
GPT-5.2(OpenAI)40 万 token(含输出预留,实际可输入约 27 万)
Gemini 3 Pro(Google)100 万 token
DeepSeek-V4100 万 token(上一代 V3 是 12.8 万)

注:各家数字随版本迭代会变,以官方文档为准。

这张表两个观察:一,差距不小——最大最小差五倍;二,没有一家的窗口是无限的。这个上限是模型出厂自带的规格,谁都改不了。20 万 token 什么概念?按一个 token 约半个汉字多一点折算,20 万 token 约合 10 万汉字出头——差不多是一本两三百页的书,听着不小。

可回到开头那个问题:才对话了几轮,怎么就快满了?两头一起作用:打字前,那四层静态的——系统提示词、CLAUDE.md、git 状态、工具定义——先占掉固定的一成;打字后,真正填窗口的,是每轮全量重发、只增不删的对话,里头最占地方的又是回执。

智能在模型,可它每轮收到什么、能记住什么,活儿都在 harness 那一边

窗口再大,也架不住这么装:底下四层打底,顶上对话上不断叠加——聊到某一刻,上下文窗口就满了,撞顶。撞了顶,第一反应是扔掉几样腾地方,可哪样都不好扔:系统提示词撤了——模型忘了自己是谁、该怎么干活;对话撤了——它忘了你三分钟前说过什么,你接着往下问,它一脸茫然。不能扔。怎么办?

Claude Code 的答案是——一套四级压缩流水线:不是一砍到底,而是按轻重缓急压缩。怎么压?下篇见。