上下文窗口与 KV cache:模型的记忆账本
AICon 2026 学习系列 · 阶段 0 · 大模型地基 · 第 2/43 篇 · 📖 师生对话实录(进行中)
上一篇:《下一个 token 的概率世界:模型到底在算什么》(已完结)
下一篇:《训练三段论:pretrain、SFT 与 RL 各产出什么》
学习大纲:《AICon 2026 学习总纲》
🧪 本篇实验代码:code-corey/ai-lab · 0-llm-basics/llm-basics-02(含运行说明与实验索引,下文各实验处注明来源)
状态:课堂实录,随每轮教学按时间顺序追加,不作整理。
对应总纲单元:阶段 0 · 单元 0.2「上下文窗口与 KV cache 直觉」
本篇验收标准(全部通过才进入下一篇):
本篇课程规划(占位时所列,随教学推进逐个击破)
- 上下文窗口的定义:输入 + 输出共享一个窗口,超了就截断
- 注意力直觉(一句话版):每个 token 回看所有前文、按相关性加权
- KV cache:前文记一遍账、生成时反复查——为什么它随长度线性膨胀、为什么吃显存
- 大海捞针(NIAH)测试:长上下文能力的标准考题
- 「记住」≠「用得上」:中间信息被忽视的 lost-in-the-middle 现象
计划实验:给同一问题垫 0 / 1k / 10k token 前缀测延迟与计费;在 10k 前缀中间藏关键事实做大海捞针。
课前准备(操作记录 · 2026-08-24)
| 检查项 | 结果 |
|---|---|
| 实验环境 | 复用第 1 篇的 ~/llm-lab/(uv + 方舟 API),无需新装 |
| 新建文件 | exp9_context_memory.py(本篇实验 1) |
| 实验编号说明 | 篇内「实验 N」从 1 重新计数;文件名沿用工作区流水号(exp9 起) |
第 1 轮:新篇开场 + 概念一「上下文:模型每猜一个字,都要重读一遍全文」
学生:继续,然后对话记录在下一篇博客中
老师(新篇开场白):
同学,欢迎回到课堂。上一篇你拿到了单元 0.1 的毕业证——接龙机器、token 积木、采样温度、幻觉三拼图,四块地基已经夯实。今天开第二篇,先把本篇要破的三个日常之谜摆出来,你一定都体感过:
① 网页版 AI 为什么「记得」你们之前聊过的天?
② 长对话聊到几十轮,它为什么开始「忘了」开头说过的话?
③ 为什么各家发布会都吹「百万上下文」,但真拿长文档去问答,又贵又慢?
剧透:三个谜底是同一件东西。它就是今天的主角——上下文。
老师(概念一:上下文与上下文窗口):
按课堂规矩,新词先定义:
上下文(context):模型猜下一个字时,摊开在桌上看的全部文字——系统设定、你们之前的对话历史、你刚发的问题,一个不落。第 1 篇说过:它每猜一个字都要「再看一遍全部文字」——看的这份全文,就是上下文。
上下文窗口(context window):这张桌子的大小上限,按积木数(token)计。比如「128k 窗口」= 桌上最多摊 12.8 万块积木,装不下的部分进不来。
桌子有了,现在投本课第一颗炸弹:
模型本身没有记忆。
它是「金鱼柜员」:每次请求对它都是一位新客人——上一单聊了什么,它完全不知道。你以为它「记得」,其实是网页程序在替它记:程序把历史存在自己的数据库里,每轮把你俩的全部对话重新打包、塞进这一次请求的上下文里发过去。所谓「记忆」,是每轮重发全文换来的。
空口无凭。实验:同一个问题「我叫什么名字?」发两遍,唯一差别是——历史塞不塞进请求。
操作记录(实验 1 · 文件 exp9_context_memory.py,源码):
# -*- coding: utf-8 -*-
"""实验 1(篇内):模型记不记事?——历史在请求里 vs 不在"""
import json
import os
import urllib.request
BASE = os.environ["OPENAI_BASE_URL"].rstrip("/")
KEY = os.environ["OPENAI_API_KEY"]
MODEL = os.environ["OPENAI_MODEL"]
def chat(messages):
body = {"model": MODEL,
"messages": messages,
"temperature": 0,
"max_tokens": 200,
"thinking": {"type": "disabled"}}
req = urllib.request.Request(
f"{BASE}/chat/completions", data=json.dumps(body).encode("utf-8"),
headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=60) as r:
d = json.load(r)
return (d["choices"][0]["message"]["content"].strip(),
d["usage"]["prompt_tokens"])
QUESTION = "我叫什么名字?"
HISTORY = [
{"role": "user",
"content": "我叫王小明,在一家科技公司上班,养了一只橘猫叫胖橘。请记住这些信息。"},
{"role": "assistant",
"content": "好的,记住了:你叫王小明,在一家科技公司上班,养了一只橘猫叫胖橘。"},
]
print("===== 对话 A:历史随请求一起发 =====")
content, pt = chat(HISTORY + [{"role": "user", "content": QUESTION}])
print(f"答:{content}")
print(f"输入账 prompt_tokens:{pt} 段")
print()
print("===== 对话 B:不带历史,光秃秃直接问 =====")
content, pt = chat([{"role": "user", "content": QUESTION}])
print(f"答:{content}")
print(f"输入账 prompt_tokens:{pt} 段")真实运行输出:
===== 对话 A:历史随请求一起发 =====
答:你叫王小明。
输入账 prompt_tokens:58 段
===== 对话 B:不带历史,光秃秃直接问 =====
答:抱歉,我无法知道您的名字。如果您愿意告诉我,我可以记住并在这次对话中用您的名字称呼您!😊 您叫什么名字呢?
输入账 prompt_tokens:8 段老师(实验讲解):
发现一:它知道的 = 请求里有的。 带历史→「你叫王小明」;不带→「抱歉,我无法知道您的名字」。同一个模型、同一个问题、温度 0,唯一变量是历史在不在请求里——「记忆」的真相就是「把历史塞进这一次请求」。模型的「金鱼性」实锤。
发现二:记忆是花钱的。 看两笔输入账:带历史 58 段,不带 8 段——历史一塞,账单立刻厚了 50 段。往前推一步就是谜题③的一半答案:每多聊一轮,历史就长一截,下一轮的输入账就厚一截——长对话是滚动变贵的(第 1 篇「两本账」在这里开始按轮计息)。
发现三(一个温柔的陷阱): 再看对话 B 的原话——「如果您愿意告诉我,我可以记住」——它承诺了一个自己根本做不到的事:跨请求记忆。它做不到,替它做到的是网页程序。以后你在产品文案里看到「AI 记忆」「长期记忆」,心里要自动翻译成:「程序在替金鱼柜员抄笔记」。
顺手把谜题②的伏笔埋下:「忘了开头」有两条嫌疑路径——①历史太长,程序打包时把最早的裁掉了(窗口装不下,行话叫截断);②就算全塞进去了,开头的影响也可能被中间几万字稀释(这现象有个外号叫「迷失在中间」,lost in the middle,本篇后面亲手测)。
老师(检查题):
你和网页版 AI 连聊 100 轮,突然发现它把第 1 轮说的事忘了。基于今天所学,列出两条可能的嫌疑路径,并各自说清「是怎么导致忘的」。
答完进概念二:窗口边界实测——给同一个问题垫上 0 / 1 千 / 1 万 token 的废话前缀,亲手测延迟和账单怎么变,看「又贵又慢」到底多贵、多慢。有问题随时打断我。📝
学生:(等待回复)
第 2 轮:检查题全对 + 概念二「窗口边界实测:贵立刻兑现,慢另有一本账」
学生:
1、对话超过上下文窗口了,丢失了这些数据,2、信息被压缩,稀释了,也丢失了
老师(批改):
两条嫌疑路径全对。各补一块精修:
- 路径①补「谁动的手」:动刀的通常不是模型,是程序——打包历史时窗口装不下,程序把最早的轮次裁掉(行话叫截断,truncate)。API 场景还有另一种死法:直接报错拒收(报「上下文超长」错误)。一个是悄悄丢,一个是当面拒。
- 路径②精修用词:「压缩」二字不准确——没有任何字被压缩掉,全部原文都还摊在桌上。变淡的是影响力:模型的注意力要分给前文每一个字,前文越长,分到开头那几句的注意力越稀。比喻:一滴墨滴进水缸,墨一点没少,浓度没了。这就是 lost in the middle 的直觉版,本篇后面亲手测。
老师(概念二:窗口边界实测——「又贵又慢」贵多少、慢多少):
谜题③现在动手拆。设计:同一个问题(「请只回复两个字:收到」),垫上不同长度的废话前缀,看账单和延迟各自怎么变。
这是一个 大语言模型 API 性能压测脚本,用于测量输入长度(上下文大小)对响应延迟和计费 Token 数的影响。
| 测试维度 | 说明 |
|---|---|
| 输入长度 | 分别测试 0 / 约1k / 约10k / 约40k token 四种输入规模 |
| 测量指标 | prompt_tokens(实际计费输入 Token 数) + 请求延迟(秒) |
| 抗抖动 | 每档连打 3 发请求,取 最小延迟 作为参考值 |
操作记录(实验 2 · 文件 exp10_prefix_cost_latency.py,源码):
# -*- coding: utf-8 -*-
"""实验 2(篇内):垫 0 / 1k / 10k / 40k token 废话前缀——测输入账与延迟怎么涨
每档连打 3 发取最小延迟(单次测量会被网络抖动骗)"""
import json
import os
import time
import urllib.request
BASE = os.environ["OPENAI_BASE_URL"].rstrip("/")
KEY = os.environ["OPENAI_API_KEY"]
MODEL = os.environ["OPENAI_MODEL"]
def chat(content):
body = {"model": MODEL,
"messages": [{"role": "user", "content": content}],
"temperature": 0, # 确定性输出
"max_tokens": 10, # 只让模型回复极短内容
"thinking": {"type": "disabled"}}
req = urllib.request.Request(
f"{BASE}/chat/completions", data=json.dumps(body).encode("utf-8"),
headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
t0 = time.perf_counter()
with urllib.request.urlopen(req, timeout=300) as r:
d = json.load(r)
latency = time.perf_counter() - t0
return d["usage"]["prompt_tokens"], latency, d["choices"][0]["message"]["content"].strip()
def make_filler(chars):
"""生成指定字数的废话前缀"""
unit = ("今天的市场调研报告显示,华东地区的物流吞吐量环比上升了百分之三,"
"华南地区保持平稳,华北地区受天气影响略有下降。仓库管理团队建议"
"在下周三之前完成第二轮盘点,并把结果同步给运营部门。")
parts, i = [], 0
# 统计parts中的p的数量
while sum(len(p) for p in parts) < chars:
parts.append(f"【第{i}段】{unit}")
i += 1
return "".join(parts)[:chars]
QUESTION = "\n\n以上就是全部材料。请只回复两个字:收到"
print(f"{'前缀目标':>12} | {'输入账(段)':>9} | {'3发延迟(秒)':>20} | 最小 | 回答")
print("-" * 78)
for label, chars in [("0(裸问)", 0), ("约1k token", 1400),
("约10k token", 14000), ("约40k token", 56000)]:
prompt, runs, ans = None, [], ""
for _ in range(3):
pt, latency, ans = chat(make_filler(chars) + QUESTION)
prompt, _ = pt, runs.append(latency)
time.sleep(0.2)
detail = "/".join(f"{x:.2f}" for x in runs)
print(f"{label:>12} | {prompt:>9} | {detail:>20} | {min(runs):.2f} | {ans}")
# 每档(0 / 约1k / 约10k / 约40k token)连打 3 发,记录 prompt_tokens 与延迟真实运行输出:
前缀目标 | 输入账(段) | 3发延迟(秒) | 最小 | 回答
------------------------------------------------------------------------------
0(裸问) | 15 | 1.19/1.23/1.09 | 1.09 | 收到
约1k token | 787 | 1.43/1.02/1.07 | 1.02 | 收到
约10k token | 7659 | 1.14/1.50/1.14 | 1.14 | 收到
约40k token | 30409 | 1.32/1.52/1.46 | 1.32 | 收到老师(实验讲解):
发现一:输入账线性涨,一步不让。 15 → 787 → 7659 → 30409 段:前缀加到约 10 倍,账单也涨约 10 倍(7659 ÷ 787 ≈ 9.7)。这就是「贵」的兑现方式——按段计费,废话也是段。第 1 篇那只水表,今天开始按万计数。
发现二:延迟几乎没动(这是本实验最意外的发现)。 最小延迟 1.09 → 1.02 → 1.14 → 1.32 秒:涨幅约 0.2 秒,前两档的差别还在抖动范围里。修正一句预期:「贵」立刻兑现;「慢」在这个量级还疼不出来。为什么 3 万段只慢 0.2 秒?两个原因:① 读前文的运算是并行的(GPU 一口气算完,不是一块一块排队);② 每个请求还有约 1 秒的网络与排队固定开销垫底。真正的「慢」不在单次延迟里,而在吞吐和显存里——那是下一个概念 KV cache 的账本,也是 vLLM、SGLang 这些推理引擎拼命优化的主战场。谜题③的另一半答案,押给下一轮。
发现三:输入和输出共享同一张桌子。 桌上摊的不只是你发进去的(输入),还有它将要写出的回答(输出)——历史 + 你的问题 + 它的回答,全在抢同一块窗口面积。所以「128k 窗口」不等于「能塞 128k 文档再让它随便写」。
超窗的两种死法小结:API 当面报错拒收 / 程序悄悄截断丢开头。前者看得见,后者更阴险——你未必发现它忘了。
老师(检查题):
① 看表算账:前缀从约 1k 加到约 10k(×10),输入账涨几倍?延迟涨几倍?为什么账单「听话」而延迟「不听话」?
② 设计题:你有 50 份长文档(每份约 1 万 token)要做问答。方案 A:每次把 50 份全塞进上下文;方案 B:只挑和问题最相关的 3 份塞。请基于今天所学 + 第 1 篇所学(提示:幻觉高发区),给方案 A 至少两条反对理由。
②里「挑着塞」这件事有个正式名字,阶段 2 整个 RAG(检索增强生成)都在讲它——种子先埋着。
答完进概念三:KV cache——模型的记忆账本。模型每读一块积木都要记一笔账,生成每个字时都要翻这本账;账本随长度线性膨胀、吃的是显存。它是「长上下文又贵又慢」的真正主犯,也是将来听懂 vLLM / SGLang 议题的钥匙。📝
学生:(等待回复)
第 3 轮:学生喊停——「太机器人味道了」,第 2 轮大白话重讲
学生:
你这个讲的什么东西,完成听不明白你在说什么,说话的时候,要说自然的人话,你这太机器人的味道了
老师(先认账):
你这一嗓子喊得对,我认两条错:
第一,一轮塞了太多东西。上一轮我又讲钱、又讲速度、又讲窗口上限、又讲什么 vLLM,还有两个题目——开头说好的「一次只讲一个概念」,我自己没守住。
第二,说话像念稿子。满屏黑体字、口号式的句子、什么「谜题③」「押给下一轮」,这不是人讲课,是 PPT 成精。你听着累是正常的。
从这轮起改三条:一轮只讲一两件事;比喻只用一个,用到底;说人话。现在把上一轮的东西倒回去,重新讲。其实就三件事。
老师(重讲一:字发得越多,钱花得越多):
这个就是水费的事,你早就会。
上次实验就是往前缀里塞废话,塞了多少废话,就付多少钱。你看那行数字:15 → 787 → 7659 → 30409。垫的废话多了十倍,钱就多了十倍。就这么直白。没有别的门道。
老师(重讲二:但是速度几乎没变慢):
这个可能是你没想到的。你发三万段过去,会不会它要读半天?结果没有,就慢了 0.2 秒,你根本感觉不出来。
为什么?打个比方。你把一本三百页的书递给一个过目不忘的人,他不是一行一行读的,他是哗啦一翻,一整页一整页同时看进去的。机器读你发的内容就是这样:一大片一起算,不是一块积木一块积木排队过。
再加上一件事:你每次发消息,光是在网上传、在它那边排队,就要一秒左右。这一秒是固定的,把那 0.2 秒完全盖住了。所以你感觉不到变慢。
老师(重讲三:桌子有大小,装满了就出事):
窗口就是那张摊字的桌子。你跟它聊过的所有话,加上它接下来要写的回答,全都得摊在这张桌上。桌子有尺寸,比如十二万八千块积木,再多就放不下了。
放不下会怎样?两种下场。网页版聊天,一般是把最早聊的话扔掉,好腾地方——所以聊久了它就「忘了」开头,其实就是被扔了。API 调用,一般是直接报错,告诉你装不下了。
好了,三件事讲完了:钱随字数涨;速度在这个量级没明显变慢;桌满则丢或报错。 上一轮那些花哨的话,全部内容其实就这三句。
老师(检查题,就一个):
假如你表弟问你:「哥,我给 AI 发了好多资料,是不是它要想很久才能回我?」用你自己的话,把「钱」和「速度」这两件事给他讲明白。
老师(预告下一轮,也用人话):
下一个概念叫 KV cache。先用人话打个底:模型读你的话时,会给每个字记一张小卡片;之后它每写一个字,都要回头翻一遍这些卡片。你的内容越长,卡片越多。卡片堆在显卡的内存里——那东西又贵又不大。所以长内容真正贵的地方在这儿,下一轮细讲。今天先到这,消化一下。有问题接着打断我,你打断得越早,我讲得越好。
学生:(等待回复)