Context Window,模型的内存条

outlier 15 分钟 5806 字 0 0
系列教程Agent从入门到放弃 第 11 / 19 章
  1. 01Agent 从入门到放弃:教程大纲
  2. 02①大模型、Chatbot、Agent,一次把名字理清楚
  3. 03②Agent 的最小定义,一个 while 循环
  4. 04③Agent 简史,从 ReAct 到 Claude Code
  5. 05④Workflow 还是 Agent?大部分场景你不需要 Agent
  6. 06Agent 能力地图,为什么编码 Agent 最先成熟
  7. 07Token,模型眼里的世界
  8. 08Next-token prediction,一个字一个字地猜
  9. 09采样,temperature、top-p 与"同题不同答"
  10. 10Transformer 与 Attention,不推公式的直觉版
  11. 11Context Window,模型的内存条
  12. 12训练三阶段,能力是怎么被"装"进去的
  13. 13推理模型,让模型先想再答
  14. 14用原理解释怪行为,原理篇结业考
  15. 15从 Prompt 工程到 Context 工程
  16. 16System Prompt 写作,角色、约束与示例
  17. 17Context Rot,为什么长对话越聊越笨
  18. 18项目记忆,CLAUDE.md 与 AGENTS.md
  19. 19检索,RAG 还是 Agentic Search
查看教程目录

开场:一份合同,三个条款

有一次我帮朋友看一份很长的租房合同。偷了个懒,把整份合同贴给模型,问它:"这份合同里,关于提前退租、押金退还、维修责任,分别是怎么约定的?"

它回答得很漂亮:提前退租要提前一个月通知,押金在退租后七天内退还……维修责任?它说"合同中未明确约定维修责任"。

我不太信,自己翻了一遍。维修责任写得清清楚楚,在第十二条,正好是合同的正中间。

再试一次,我把第十二条剪下来,贴到合同的最后面,问同样的问题。这次它答出来了。

同样的内容,放在中间就"看不见",放在最后就看见了。 当时我觉得很神奇,后来才知道,这是一个有名字的现象,叫 lost in the middle,而且有论文专门研究过它。

这一篇讲上下文窗口:模型一次能"看"多少内容,为什么看得越多越贵,怎么用缓存省钱,以及为什么"看得见"不等于"看得清"。读完本篇,你应该能回答这几个问题:

  • 上下文窗口到底是什么?里面装着哪些东西?
  • 为什么上下文越长越贵、越慢?
  • KV cache 和 prompt caching 是什么关系?怎样才能让缓存命中?
  • 什么是 lost in the middle?今天的模型还有这个问题吗?
  • 这些对写 Agent 意味着什么?

(如果你跳过了第 09 篇,只需要知道三个结论:上下文越长越贵;前缀不变的部分可以缓存;上下文里的东西越多,每样东西分到的注意力越少。)

一、上下文窗口:一次能装多少

1.1 内存条,而不是硬盘

上下文窗口(context window)是模型一次请求里能处理的 token 总数。

我喜欢把它比作内存条:

  • 它是工作区,不是存储区:每次请求都要把需要的东西全部装进去,请求结束就清空。第 01 篇说的"模型没有记忆",说的就是这个;
  • 它有上限:装不下就是装不下,超了 API 直接报错,不会替你自动删掉一部分;
  • 装得越多越慢:内存条插满了,机器也会变慢。

以 DeepSeek 为例(截至写作时),它的两个模型上下文窗口都是 100 万 token。这个数字在几年前是不可想象的:2023 年初,主流模型的窗口还只有几千 token。

100 万 token 大概是多少?按第 06 篇的粗略估算,相当于几十万到上百万个汉字,一部长篇小说绰绰有余;换成代码,是一个中等规模项目的大部分源文件。听起来好像什么都装得下了。但第二节会算一笔账:对 Agent 来说,真正的问题从来不是"装不装得下",而是"每一圈都装这么多,付不付得起、看不看得清"。

1.2 里面装着什么

一个常见的误解是"上下文窗口就是我能输入多长的文字"。其实它装的东西多得多:

flowchart TB subgraph W["一次请求的上下文窗口"] direction TB S["system prompt"] T["工具定义"] H["对话历史"] R["检索到的内容"] O["工具输出"] Q["这次的问题"] A["模型这次的输出(也要占位置)"] end style W fill:#e3f2fd,stroke:#1565c0 style O fill:#ffcdd2,stroke:#b71c1c style A fill:#fff9c4

几个容易忽略的点:

  • 输出也要占窗口:输入加输出的总和不能超过窗口大小。输入塞得太满,留给模型回答的空间就少了;推理模型的思考过程也是输出,也要占位置;
  • 工具定义也算:给 Agent 配了几十个工具,每个工具的名字、描述、参数 schema 都要放进去,每次请求都带着。第 23 篇讲 MCP 时会专门算这笔账;
  • 在 Agent 里,最大头往往是工具输出:读一个文件、跑一次测试,动辄几千上万 token。第 14 篇会拆开一次真实的请求,看看每一部分各占多少。

二、为什么越长越贵

2.1 四笔账

上下文变长,代价体现在四个地方:

代价原因增长方式
钱按输入 token 计费和长度成正比
计算attention 要让每个 token 看前面所有 token(第 09 篇)比长度增长得更快
延迟输入越长,读完它(prefill)花的时间越长,第一个字出来得越晚随长度增长
显存每个 token 的 K、V 都要存起来(KV cache)和长度成正比

后三笔是厂商的成本,最终也会体现在价格和速度上。

2.2 Agent 把这笔账乘了一遍

单次请求的成本和长度成正比,这还好。但别忘了第 02 篇的结论:Agent 每一圈都要把全部历史重新发一遍。

假设一个任务转了 30 圈,每圈新增 2000 token(一次文件读取加一点对话):

  • 第 1 圈的输入约 2000 token;
  • 第 30 圈的输入约 60000 token;
  • 30 圈加起来,总输入约 93 万 token。

而真正"新"的内容只有 30 × 2000 = 6 万 token。剩下的 87 万 token,全是在重复发送旧内容。

flowchart LR A["新内容
6 万 token"] --> T["总输入
93 万 token"] B["重复发送的旧内容
87 万 token"] --> T style B fill:#ffcdd2,stroke:#b71c1c style A fill:#e8f5e9,stroke:#2e7d32

把它换算成钱(deepseek-flash 高峰价,只算输入):

情况怎么算输入费用
完全不命中缓存93 万 × $0.3 / 百万约 $0.28
旧内容全部命中6 万 × $0.3 + 87 万 × $0.006,单位都是每百万约 $0.023

同一个任务,差了十几倍。一个任务两毛八看起来不多,但一个团队每天跑几百个任务、每个任务转的圈数还不止 30,这个差距就是真金白银了。

这时候,缓存就变得至关重要了。

三、KV cache 与 prompt caching

3.1 一次请求之内:KV cache

第 09 篇讲过:生成每个新 token 时,要拿它的 Query 去和前面所有 token 的 Key、Value 打交道。前面 token 的 K 和 V 不会变,所以不用每次都重算,存起来复用就行。这就是 KV cache。

没有它,生成第 1000 个 token 时,要把前面 999 个 token 全部重新算一遍;有了它,只需要算新 token 自己。这是所有模型服务都会做的基本优化,你感知不到,但它让生成变得可行。

3.2 跨请求:prompt caching

再往前走一步:如果两次请求的开头一模一样,开头那部分的 K、V 也一模一样,那服务端为什么不把上次算好的存起来,这次直接用?

这就是 prompt caching(前缀缓存)。它的收益非常可观。以 DeepSeek 为例(截至写作时,deepseek-flash 高峰价):

输入每百万 token
缓存未命中$0.3
缓存命中$0.006

命中缓存的部分,只要五十分之一的价格。 回到刚才那个 30 圈的例子,如果那 87 万 token 的旧内容都能命中缓存,这部分的成本就几乎可以忽略不计。

3.3 两种缓存方式

不同厂商的缓存,用法不太一样:

  • 显式缓存:你在请求里标出"缓存断点",告诉服务端"这个位置之前的内容请帮我缓存起来"。有的厂商还会对"写入缓存"单独收一笔稍高的费用;
  • 自动缓存:服务端自动检测请求之间的公共前缀,命中了就按缓存价计费,你什么都不用做。

DeepSeek 用的是自动缓存(截至写作时,以官方文档为准)。几个要点:

  • 默认开启,不需要改代码;
  • 命中多少,在响应的 usage 里有两个字段:prompt_cache_hit_tokens 和 prompt_cache_miss_tokens;
  • 尽力而为,不保证一定命中;缓存一段时间不用就会被清掉,通常是几小时到几天;
  • 匹配的是"缓存单元",要完整匹配上一个之前存下来的前缀单元才算命中。

配套仓库的 DeepSeek 客户端把这两个字段翻译成了统一的用量格式,internal/llm/deepseek_format.go 里是这么写的:

// toUsage:DeepSeek 的缓存是全自动的,命中的部分按缓存价计费,没有单独的"写缓存"费用。
func (u dsUsage) toUsage() Usage {
    miss := u.PromptCacheMissTokens
    if miss == 0 && u.PromptCacheHitTokens == 0 {
        miss = u.PromptTokens
    }
    return Usage{InputTokens: miss, CacheReadInputTokens: u.PromptCacheHitTokens, OutputTokens: u.CompletionTokens}
}

注意一个细节:这里的 InputTokens 只是没命中缓存的那部分。要算"这次一共输入了多少 token",得把命中的也加上。配套仓库专门加了一个 TotalInput() 方法来干这件事,我第一版就是因为只看了 InputTokens,以为缓存命中之后"输入变短了"。

3.4 让缓存命中的唯一秘诀:前缀稳定

不管哪种方式,缓存命中的条件都是同一个:请求的开头部分必须和之前一模一样。 只要开头有一个 token 不同,从那个 token 往后的部分就全部不能复用。

flowchart TB subgraph G["前缀稳定:命中"] G1["system prompt"] --> G2["工具定义"] --> G3["旧历史"] --> G4["新内容"] end subgraph B["开头变了:从这里往后全部失效"] B1["system prompt
里面有当前时间:14:03:27"] --> B2["工具定义"] --> B3["旧历史"] --> B4["新内容"] end style G1 fill:#e8f5e9,stroke:#2e7d32 style G2 fill:#e8f5e9,stroke:#2e7d32 style G3 fill:#e8f5e9,stroke:#2e7d32 style B1 fill:#ffcdd2,stroke:#b71c1c style B2 fill:#ffcdd2,stroke:#b71c1c style B3 fill:#ffcdd2,stroke:#b71c1c

几个常见的"缓存杀手":

  • system prompt 开头放了精确到秒的时间戳:每次请求都不一样,缓存永远不命中;
  • 工具定义的顺序不固定:比如从一个 map 里遍历出来,每次顺序都可能不同;
  • 修改了历史中间的某条消息:比如为了省 token,把第 3 轮的工具输出删短了,那从第 3 轮往后的缓存全部失效。

所以配套仓库的 mini-agent 在设计时就注意了这几点。agent/prompt.go 开头有一行注释:

// 越稳定的内容越靠前,带日期的环境信息放在最后,方便第 37 篇的 prompt caching。

日期只精确到天,而且放在 system prompt 的最后。工具定义在注册表里按名字排序(第 25 篇)。压缩历史时整段替换,而不是去修改中间的某一条(第 28 篇)。这些看起来不起眼的小决定,最后都会体现在账单上。

3.5 怎么知道缓存到底命中了没有

缓存是"尽力而为"的,所以光是"设计得对"还不够,要能看到它到底命中了多少。配套仓库里有三个地方可以看:

  • 本篇的实验:每一行都会打印这次请求命中缓存的 token 数;
  • mini-agent 的 /cost 命令:在 REPL 里随时打出当前会话的账本,按模型列出输入、缓存读、输出和"命中率"(第 37 篇);
  • cmd/ledger:同一张账本的离线版,读的是 -trace 写下的文件,适合任务做完之后复盘。

一个经验:Agent 跑到第二圈之后,命中率通常应该很高,因为每一圈的前缀就是上一圈的全部内容。如果命中率一直很低,几乎可以肯定是前缀里有什么东西在每一圈都变。 顺着 3.4 节那几个"缓存杀手"挨个排查,往往很快就能找到。

四、Lost in the middle:看得见,不等于看得清

4.1 一条 U 形曲线

回到开场那份合同。2023 年,有一篇论文叫《Lost in the Middle: How Language Models Use Long Contexts》(Liu 等),专门研究了这个现象:把回答问题需要的关键信息,分别放在一段长上下文的开头、中间、结尾,看模型的准确率。

结果是一条 U 形曲线:放在开头和结尾时准确率高,放在中间时明显下降。

xychart-beta title "示意:关键信息的位置 vs 准确率" x-axis ["开头", "25%", "中间", "75%", "结尾"] y-axis "准确率" 0 --> 100 line [85, 65, 55, 63, 80]

(这是示意图,形状来自论文的结论,数字是我编的,不是论文里的数据。)

为什么会这样?一个直观的理解是:模型在训练中见过的大量文本,开头往往是标题、摘要、问题,结尾往往是结论、总结;中间的内容,"重要"的概率在训练数据里就低一些。另外,第 09 篇说过,注意力是有限的预算,上下文越长,中间的内容被分到的注意力越容易被稀释。

4.2 今天还有这个问题吗

有,但形式变了。

  • 简单的"找一句话",新模型往往能做得很好:厂商们专门在这类测试上下了功夫。下面的动手实验你会亲眼看到,单根"针"藏在几万字里,新模型大概率都能找到;
  • 难一点的任务,长度依然是敌人:需要把散落在不同位置的几条信息组合起来推理的任务,或者干扰信息很多、和关键信息很像的任务,随着上下文变长,表现依然会明显下降。第 16 篇会讲一份叫"Context Rot"的研究,它系统地测了这件事。

所以结论是:上下文窗口大,不代表你应该把它塞满。 100 万 token 的窗口,是"必要时能装下",不是"装满了效果最好"。

4.3 长材料怎么摆

既然位置会影响效果,那就可以利用它。几条常用的摆放原则:

  • 长材料放前面,问题放最后:先贴文档、代码、日志,最后再写"根据上面的内容,回答……"。这样问题紧挨着模型要开始生成的位置,不会被埋在材料里。开场那份合同,如果我把问题写在合同后面而不是前面,效果多半会好一些;
  • 给材料加上清楚的边界和标签:比如用 <document name="租房合同">……</document> 把每份材料包起来。多份材料时,模型能更清楚地知道哪段内容来自哪里,你也可以在问题里直接引用"合同里的第十二条";
  • 让它先摘出相关的部分:对付长材料的一个老办法,是要求模型"先把和问题相关的原文摘录出来,再根据摘录回答"。这其实是第 07 篇"先推理后结论"的变体:先把散落在中间的信息搬到上下文的末尾,再基于它们作答;
  • 最关键的约束可以说两遍:在 system prompt 里写一遍,在问题附近再提醒一句。有点啰嗦,但对付"埋在中间"的问题很有效。
flowchart TB A["<document> 长材料 </document>"] --> B["关键约束(再提醒一次)"] B --> C["问题:根据上面的内容……
先摘录相关原文,再回答"] C --> D["模型从这里开始生成"] style C fill:#e8f5e9,stroke:#2e7d32

第 15 篇讲 system prompt 写作时,还会回到这些技巧。

五、对写 Agent 意味着什么

把这一篇的内容收拢成几条实用的原则:

flowchart TB P(("上下文
是内存条")) P --- A["只装需要的
工具输出要截断(第 28 篇)"] P --- B["前缀保持稳定
让缓存命中(第 37 篇)"] P --- C["重要的放两头
指令写在开头或最后"] P --- D["用不着的及时清
压缩、开新会话(第 16、28 篇)"] P --- E["该分身就分身
子 Agent 隔离上下文(第 30 篇)"] style P fill:#fff3e0,stroke:#e65100
  1. 只装需要的:Agent 读一个几万行的日志文件,全部塞进上下文,既贵又会稀释注意力。工具输出要截断,只保留关键部分(第 28 篇);
  2. 前缀保持稳定:不变的内容放前面,变化的内容放后面,历史只追加不修改(第 37 篇);
  3. 重要的放两头:最关键的指令放在 system prompt 的开头,或者紧挨着用户问题的地方,别埋在一大段背景材料中间;
  4. 用不着的及时清:任务换了就清空对话,长任务做到一半就压缩成摘要(第 16、28 篇);
  5. 该分身就分身:需要读大量材料、但最后只需要一个结论的事情,交给一个子 Agent,它的上下文用完就扔,只把结论带回来(第 30 篇)。

六、动手:大海捞针

实验:把一句 Wi-Fi 密码藏进几万字

配套代码 examples/ch10-needle/main.go 做的事情是:

  1. 用一堆读起来像回事、但毫无意义的段落拼出一个"干草堆",比如"第 37 段:监控系统迁移到了新的机房,延迟略有下降。";
  2. 把一根"针"藏进去,分别放在开头、25%、中间、75%、结尾五个位置:
// 针:一个模型不可能事先知道的"事实"。
const needle = "顺带一提,乔人尹办公室的 Wi-Fi 密码是 coding-the-world-1024。"
const question = "根据上面的文档,乔人尹办公室的 Wi-Fi 密码是什么?只输出密码本身。"
  1. 每个位置问 3 次,统计找回来了几次。
go run ./examples/ch10-needle

不设 Key 的时候,用的是离线演示模型。它会作弊:直接找文档里和问题重合字最多的那一行,所以每次都能找回来,输出全是 3/3(也可以在 Playground 的第 10 篇里跑)。这个结果没有意义,但你能看清实验是怎么设计的。

这个实验要接真实模型才有意义,在线 Playground 演示不了:先 export DEEPSEEK_API_KEY=sk-... 再跑。默认约 3 万字的干草堆,5 个位置 × 3 次 = 15 次调用,每次都要把整个干草堆发一遍,留意费用。
export DEEPSEEK_API_KEY=sk-...
go run ./examples/ch10-needle

输出的每一行会告诉你召回了几次、每次输入大约多少 token、命中缓存多少 token。留意几件事:

  1. 召回率:新模型在这种单针测试上往往全对。如果真是这样,不要觉得实验失败了,这正是 4.2 节说的"简单的找一句话,新模型做得很好";
  2. 缓存命中:同一轮里,五个位置用的是同一个干草堆,只是针插的位置不同。针前面的那一段内容在几次请求之间是相同的,DeepSeek 的自动缓存有机会命中。看看"命中缓存"那一列,体会一下"前缀相同就能复用";
  3. 输入 token:3 万字的干草堆大约是多少 token?对照第 06 篇,看看中文和 token 的比例,再用第 06 篇 5.3 节那种算法,估一下这 15 次调用一共花了多少钱,和实际的账单对一对。

程序里关掉了思考模式:找一句话用不着推理,而且思考的 token 也会挤占 max_tokens,可能把真正的答案截掉。

对照 4.3 节,你还可以看看这个实验的 prompt 是怎么摆的:干草堆在前,问题在最后。试着把问题挪到干草堆的前面再跑一次,看看召回率和缓存命中有没有变化。想一想:问题放在最前面时,五个位置的请求还有公共前缀吗?

让它翻车

想看到 lost in the middle,得把难度调上去:

go run ./examples/ch10-needle -chars 150000 -trials 1
  1. 加长干草堆:-chars 150000 大约是十几万 token,注意这一次的费用会明显增加;
  2. 换个更小的模型:如果你有别的模型可以用,可以对比一下不同模型的召回率;
  3. 改成需要推理的问题:自己改代码,藏两根针,比如"乔人尹的办公室在 3 楼"和"3 楼的 Wi-Fi 密码是 xxx",然后问"乔人尹办公室的 Wi-Fi 密码是什么"。这时候模型需要把两条分散的信息组合起来,难度会高很多。

七、踩坑记录:上下文窗口的七个坑

坑 1:以为窗口大就可以随便塞。
窗口大是能力,不是建议。塞得越多,越贵、越慢、越容易漏掉关键信息。好的 Agent 是"按需取用",而不是"一股脑全给"。第 18 篇讲检索时会看到,今天的编码 Agent 大多选择"让它自己去搜、自己去读",而不是一开始就把整个代码库塞进上下文,原因就在这里。

坑 2:忘了输出也要占窗口。
输入已经占到窗口的 99%,模型就没地方写回答了。推理模型尤其要注意,它的思考过程可能很长。

坑 3:system prompt 开头放时间戳。
精确到秒的时间戳放在最前面,等于每次请求都换了一个前缀,缓存永远不命中。需要时间信息,精确到天,放在最后。

坑 4:为了省 token,修改历史中间的消息。
删短第 3 轮的工具输出,确实省了一点 token,但从第 3 轮往后的缓存全部失效,可能反而更贵。要压缩就整段压缩,第 28 篇细讲。

坑 5:只看 InputTokens,以为输入变少了。
开了缓存之后,大部分输入会记在"缓存命中"那个字段里。只看未命中的部分,会严重低估上下文的真实大小。上下文管理要看总量。

坑 6:把关键指令埋在长材料中间。
"请特别注意以下要求"写在一大段背景材料的中间,模型很可能注意不到。关键指令放在开头,或者紧挨着问题放。

坑 7:没看命中率就以为缓存在工作。
缓存默认开启,不代表它真的命中了。我有一次给 system prompt 加了一个"当前工作目录下的文件列表",每改一个文件列表就变一次,整整一周的命中率都只有个位数,直到看账本才发现。设计得对不对,要用数字验收,3.5 节那几个地方,养成习惯看一眼。

一句话总结

上下文窗口是模型的内存条:每次请求都要把所有东西装进去,装得越多越贵越慢;前缀保持稳定,缓存就能把重复的部分变得非常便宜;窗口再大,放在中间的信息也容易被忽略,所以只装需要的,重要的放两头。

flowchart LR C["上下文窗口"] --> X["越长越贵越慢"] C --> Y["前缀稳定 → 缓存命中 → 便宜"] C --> Z["中间的信息容易被忽略"] style X fill:#ffcdd2,stroke:#b71c1c style Y fill:#e8f5e9,stroke:#2e7d32 style Z fill:#fff9c4

评论

还没有评论,来说点什么吧。

发表评论

Ctrl + Enter 快速提交
Enter 搜索Esc 关闭