开场:一份合同,三个条款
有一次我帮朋友看一份很长的租房合同。偷了个懒,把整份合同贴给模型,问它:"这份合同里,关于提前退租、押金退还、维修责任,分别是怎么约定的?"
它回答得很漂亮:提前退租要提前一个月通知,押金在退租后七天内退还……维修责任?它说"合同中未明确约定维修责任"。
我不太信,自己翻了一遍。维修责任写得清清楚楚,在第十二条,正好是合同的正中间。
再试一次,我把第十二条剪下来,贴到合同的最后面,问同样的问题。这次它答出来了。
同样的内容,放在中间就"看不见",放在最后就看见了。 当时我觉得很神奇,后来才知道,这是一个有名字的现象,叫 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 里面装着什么
一个常见的误解是"上下文窗口就是我能输入多长的文字"。其实它装的东西多得多:
几个容易忽略的点:
- 输出也要占窗口:输入加输出的总和不能超过窗口大小。输入塞得太满,留给模型回答的空间就少了;推理模型的思考过程也是输出,也要占位置;
- 工具定义也算:给 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,全是在重复发送旧内容。
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 往后的部分就全部不能复用。
里面有当前时间: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 形曲线:放在开头和结尾时准确率高,放在中间时明显下降。
(这是示意图,形状来自论文的结论,数字是我编的,不是论文里的数据。)
为什么会这样?一个直观的理解是:模型在训练中见过的大量文本,开头往往是标题、摘要、问题,结尾往往是结论、总结;中间的内容,"重要"的概率在训练数据里就低一些。另外,第 09 篇说过,注意力是有限的预算,上下文越长,中间的内容被分到的注意力越容易被稀释。
4.2 今天还有这个问题吗
有,但形式变了。
- 简单的"找一句话",新模型往往能做得很好:厂商们专门在这类测试上下了功夫。下面的动手实验你会亲眼看到,单根"针"藏在几万字里,新模型大概率都能找到;
- 难一点的任务,长度依然是敌人:需要把散落在不同位置的几条信息组合起来推理的任务,或者干扰信息很多、和关键信息很像的任务,随着上下文变长,表现依然会明显下降。第 16 篇会讲一份叫"Context Rot"的研究,它系统地测了这件事。
所以结论是:上下文窗口大,不代表你应该把它塞满。 100 万 token 的窗口,是"必要时能装下",不是"装满了效果最好"。
4.3 长材料怎么摆
既然位置会影响效果,那就可以利用它。几条常用的摆放原则:
- 长材料放前面,问题放最后:先贴文档、代码、日志,最后再写"根据上面的内容,回答……"。这样问题紧挨着模型要开始生成的位置,不会被埋在材料里。开场那份合同,如果我把问题写在合同后面而不是前面,效果多半会好一些;
- 给材料加上清楚的边界和标签:比如用
<document name="租房合同">……</document>把每份材料包起来。多份材料时,模型能更清楚地知道哪段内容来自哪里,你也可以在问题里直接引用"合同里的第十二条"; - 让它先摘出相关的部分:对付长材料的一个老办法,是要求模型"先把和问题相关的原文摘录出来,再根据摘录回答"。这其实是第 07 篇"先推理后结论"的变体:先把散落在中间的信息搬到上下文的末尾,再基于它们作答;
- 最关键的约束可以说两遍:在 system prompt 里写一遍,在问题附近再提醒一句。有点啰嗦,但对付"埋在中间"的问题很有效。
先摘录相关原文,再回答"] C --> D["模型从这里开始生成"] style C fill:#e8f5e9,stroke:#2e7d32
第 15 篇讲 system prompt 写作时,还会回到这些技巧。
五、对写 Agent 意味着什么
把这一篇的内容收拢成几条实用的原则:
是内存条")) P --- A["只装需要的
工具输出要截断(第 28 篇)"] P --- B["前缀保持稳定
让缓存命中(第 37 篇)"] P --- C["重要的放两头
指令写在开头或最后"] P --- D["用不着的及时清
压缩、开新会话(第 16、28 篇)"] P --- E["该分身就分身
子 Agent 隔离上下文(第 30 篇)"] style P fill:#fff3e0,stroke:#e65100
- 只装需要的:Agent 读一个几万行的日志文件,全部塞进上下文,既贵又会稀释注意力。工具输出要截断,只保留关键部分(第 28 篇);
- 前缀保持稳定:不变的内容放前面,变化的内容放后面,历史只追加不修改(第 37 篇);
- 重要的放两头:最关键的指令放在 system prompt 的开头,或者紧挨着用户问题的地方,别埋在一大段背景材料中间;
- 用不着的及时清:任务换了就清空对话,长任务做到一半就压缩成摘要(第 16、28 篇);
- 该分身就分身:需要读大量材料、但最后只需要一个结论的事情,交给一个子 Agent,它的上下文用完就扔,只把结论带回来(第 30 篇)。
六、动手:大海捞针
实验:把一句 Wi-Fi 密码藏进几万字
配套代码 examples/ch10-needle/main.go 做的事情是:
- 用一堆读起来像回事、但毫无意义的段落拼出一个"干草堆",比如"第 37 段:监控系统迁移到了新的机房,延迟略有下降。";
- 把一根"针"藏进去,分别放在开头、25%、中间、75%、结尾五个位置:
// 针:一个模型不可能事先知道的"事实"。
const needle = "顺带一提,乔人尹办公室的 Wi-Fi 密码是 coding-the-world-1024。"
const question = "根据上面的文档,乔人尹办公室的 Wi-Fi 密码是什么?只输出密码本身。"- 每个位置问 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。留意几件事:
- 召回率:新模型在这种单针测试上往往全对。如果真是这样,不要觉得实验失败了,这正是 4.2 节说的"简单的找一句话,新模型做得很好";
- 缓存命中:同一轮里,五个位置用的是同一个干草堆,只是针插的位置不同。针前面的那一段内容在几次请求之间是相同的,DeepSeek 的自动缓存有机会命中。看看"命中缓存"那一列,体会一下"前缀相同就能复用";
- 输入 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- 加长干草堆:
-chars 150000大约是十几万 token,注意这一次的费用会明显增加; - 换个更小的模型:如果你有别的模型可以用,可以对比一下不同模型的召回率;
- 改成需要推理的问题:自己改代码,藏两根针,比如"乔人尹的办公室在 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 节那几个地方,养成习惯看一眼。
一句话总结
上下文窗口是模型的内存条:每次请求都要把所有东西装进去,装得越多越贵越慢;前缀保持稳定,缓存就能把重复的部分变得非常便宜;窗口再大,放在中间的信息也容易被忽略,所以只装需要的,重要的放两头。
评论
还没有评论,来说点什么吧。