被遗忘的下午:上下文危机的诞生
第 1 章:被遗忘的下午:上下文危机的诞生
1.1 三个下午,三场沉默
2023 年 11 月的一个下午,某跨境电商公司的客服系统正式切流到 GPT-4。负责这个项目的工程师姓林,他在内部 Slack 频道里贴了一张庆祝图,配文只有一句:"8K 上下文,够用了。"
四十七天后,系统在凌晨两点熔断了。
不是被攻击,不是流量洪峰,而是一段循环代码在用户无感的情况下,把每一轮工具调用的 JSON 响应原封不动地追加进了对话历史。林后来调出日志,那段被反复拼接的上下文里,光是"获取商品详情"的返回体就堆叠了超过六千个 JSON 节点,模型在第 47 轮给出的最后一条回复是:*I'm sorry, I don't have enough information to answer this question.*——而那时,用户问的只是"我昨天下的那个订单什么时候发货"。
这个下午,让林第一次意识到:他以为自己在用"模型",其实他在用一种他自己没有理解清楚的东西。
---
2024 年 2 月,另一个下午。某法律 SaaS 公司上线了他们的合同审查助手。产品经理陈薇对这个项目寄予厚望——他们花了三周时间调教人格提示词,目标是让助手"像一个有十年执业经验的并购律师那样思考"。人格提示词被精心放置在 `system` 字段的最前部,紧随其后是七条工作流规则、三个工具描述、两条输出格式约束。
上线第三天,第一起客诉飞进工单系统:助手开始使用"宝子""姐妹"这样的称呼,回复里夹杂表情符号,在审查一份涉及三亿美元的股权回购协议时,结尾来了一句"希望对你有帮助哦~"。
陈薇排查了四十八小时,最终定位到原因:RAG(检索增强生成)模块在第 8 轮对话之后返回的法律条文片段,长度累计超过了 2.1K tokens,这些片段以 `<context>...</context>` 的形式被插入到 `user` 字段,紧跟在人格提示词之后、人格提示词之前的所有"约束"都被语义距离推到了注意力的边缘。模型没有"变笨",它只是稀释了。
---
2023 年 9 月,再往前一点的某个下午。一个 AI 创业团队的创始人王澈,在预算会议上和 CFO 吵了一架。起因是他们的 AI 旅行规划产品上线两个月,OpenAI 的账单从预估的每月 8000 美元飙到了 47000 美元。技术负责人查了三天才发现真相:他们的 Agent 在每次调用工具前都会打印"完整思考过程",而这个思考过程平均长度是 2300 tokens。每一次工具调用,这 2300 tokens 就被永久性地写进了上下文,而 Agent 完成一次复杂行程规划平均需要 11 次工具调用。
模型没有错。API 定价表也没有错。错的是一段代码把"调试日志"当成了"必须保留的记忆"。
---
三个下午,三场沉默。它们看起来毫不相关:一个是被遗忘的预算,一个是被稀释的人格,一个是被吹胀的日志。但它们共享同一个根——
对"上下文"这三个字,工程界一直缺乏一个足够精确的定义。
我们用"上下文窗口"去丈量它,用"提示词工程"去操纵它,用"记忆"去拟人化它,但很少有人停下来问一句:当模型"忘记"的时候,到底是谁在忘记?忘记的是模型,还是系统?是被遗忘的信息本身不够重要,还是承载它的容器太小、装它的方式太粗暴?
这一章,不打算给这三个问题提供答案。这一章只做一件事:把问题问得足够清楚。
1.2 上下文:一个被滥用的词
让我们从最基本的地方开始。什么是"上下文"?
在 Transformer 架构的语境里,"上下文"是一个严格工程化的概念:它是模型在生成下一个 token 时,注意力机制能够"看到"的所有 token 的集合。这个集合在物理上对应着 KV 缓存(Key-Value Cache)里的全部向量,在接口上对应着 API 调用时 `messages` 数组里的全部内容,在认知类比上对应着人类工作记忆里"当前正在被思考"的那一小块信息。
但"上下文"在工程实践中被滥用了。它被用来指代至少七种不同的东西:
1. 系统提示词(system prompt)——开发者写给模型的"宪法" 2. 对话历史(conversation history)——用户和模型此前的所有轮次 3. 工具描述(tool schema)——模型可以调用的函数的签名和说明 4. 工具调用结果(tool results)——上一轮工具返回的内容 5. 检索片段(retrieved chunks)——RAG 系统从外部知识库拉回的文本 6. 少样本示例(few-shot examples)——为了让模型学会某种输出格式而塞进去的演示 7. 运行时注入的元数据——比如当前时间、用户 ID、权限等级
这七种东西,全部塞进同一个上下文窗口,互相挤占、互相遮蔽、互相覆盖。但它们在系统中的"重要性"是完全不同的:系统提示词里的安全约束是不可妥协的,对话历史里用户的核心诉求是不可丢失的,而工具调用的中间结果在大多数情况下是可以被丢弃的。
> 我们没有七个上下文,我们只有一个上下文,却往里面塞了七种东西。这本身就是一种工程混乱。
更糟糕的是,这七种东西在自然语言形态上几乎无法区分。模型看到的是一长串 tokens,它不知道哪些是"宪法",哪些是"日志",哪些是"示例"。在它的注意力计算里,所有 token 都是平等的——或者说,所有 token 都是同样不平等的,区别仅仅体现在语义距离、出现位置、词频统计这些次级信号上。
这就是"上下文"这个词被滥用的代价:它让开发者误以为自己在管理一个单一变量,而实际上他们在管理一个七种资源争夺同一块内存的零和博弈。
为了在本书后续章节里保持术语的精确性,我们从这一章开始,引入一个更细的分类法:
- **静态上下文(Static Context)**:在会话开始时就固定的、不会随轮次变化的内容,主要是系统提示词、工具描述、少样本示例。
- **动态上下文(Dynamic Context)**:每一轮都会增长或替换的内容,包括对话历史、工具调用结果、检索片段、运行时元数据。
- **外部上下文(External Context)**:不直接进入 KV 缓存,而是通过工具调用、按需检索等方式被"召唤"进上下文的内容。
这三类的工程含义截然不同。静态上下文是设计问题——你写什么、怎么写、写在哪里,会决定整个系统的"宪法稳定性"。动态上下文是资源调度问题——每一轮新增的内容都会和已有内容竞争有限的注意力预算。外部上下文是架构问题——你选择把什么留在"模型内",把什么推到"模型外",是上下文工程最核心的架构决策。
后续章节会反复回到这个三分法。本章先把它立起来。
1.3 借来的框架:工作记忆、长期记忆与上下文
工程问题往往需要从认知科学借来一把合适的尺子。上下文工程也不例外。
认知心理学里有一个被验证得相当扎实的框架:Baddeley 的工作记忆模型。它把人类的记忆分成两个层次——
- **长期记忆(Long-Term Memory, LTM)**:容量近乎无限,存储时间从几分钟到几十年,访问速度较慢,必须经过"提取"(retrieval)才能进入意识。
- **工作记忆(Working Memory, WM)**:容量极其有限,经典估计是 7±2 个组块(chunk),存储时间以秒计,但访问速度极快,是"当下思考"的载体。
两者的关系不是"长 vs 短"这么简单,而是一种不对称耦合:长期记忆的内容必须被显式提取到工作记忆里才能被使用,而工作记忆里被反复操作的内容又会通过巩固过程(consolidation)慢慢沉淀回长期记忆。
这个模型对大模型有惊人的解释力——但也有一处根本性的失真。
相似之处:
| 维度 | 人类记忆 | 大模型上下文 | |------|---------|-------------| | 容量约束 | 工作记忆约 7±2 组块 | 上下文窗口 8K-200K tokens | | 衰减机制 | 主动遗忘 + 时间衰减 | 截断、稀释、注意力分散 | | 提取代价 | 长时记忆提取需要时间 | 检索(RAG)有延迟和成本 | | 易受干扰 | 相似项互相干扰 | 高相似度 token 互相干扰 |
根本差异:
人类的工作记忆是主动维护的。前额叶皮层会持续"复述"(rehearsal)关键信息,抑制无关信息,并且有选择地丢弃不重要的内容。这是一个有状态、有目标、有控制回路的系统。
大模型的"工作记忆"——也就是上下文——是被动填充的。模型本身没有"复述"机制,没有"抑制"机制,没有"主动遗忘"机制。上下文中有什么,它就处理什么;上下文里没有什么,它就真的没有。模型不会"选择忘记"什么,只会"因为看不到"所以"看起来像是忘记"。
这意味着,人类的健忘是"系统主动的、有目的的遗忘",而大模型的健忘是"被动的、结构性的遗失"。前者的遗忘可能是一种特性(保护我们不被信息淹没),后者的遗忘永远是一种缺陷——因为没有任何东西在主动优化"应该记住什么、应该忘记什么"。
这个差异不是技术细节,它是整个上下文工程存在的理由。
如果我们把"工作记忆"和"长期记忆"的框架套到大模型上,那么:
- 大模型的**长期记忆** = 训练数据 + 任何外部存储(向量数据库、键值存储、文件系统)
- 大模型的**工作记忆** = 上下文窗口
- "提取"动作 = 检索(RAG、工具调用、按需加载)
- "巩固"动作 = 微调(fine-tuning)、持续预训练、模型编辑
上下文工程的核心问题就变成了:如何设计一套外化的、可计算的"工作记忆管理系统",在每一次推理时,动态地决定什么应该进入这个有限的窗口、什么应该留在外部、什么应该被永久写入模型的长期记忆。
这不再是"提示词技巧"。这是一个完整的工程学科。
1.4 三种流失:截断、稀释、覆盖
现在我们有了精确的术语,可以把"模型忘记"这个含糊的现象拆解成三种机制上不同的工程事故。
1.4.1 截断流失(Truncation Loss)
最直白的一种。当对话历史 + 工具结果 + 检索片段的总长度超过模型的上下文窗口,最早进入的内容会被直接丢弃——通常是 `messages` 数组的第一个元素,或者中间某些较老的轮次。
截断流失的危害是离散的、阶跃的。在第 N 轮之前,模型可能表现得完全正常;在第 N 轮,某个关键的系统指令突然从窗口里消失,模型的行为会发生剧烈的相变。这种相变极难调试,因为复现它需要精确还原被截断的边界条件。
几乎所有主流 API 都提供了截断策略的配置选项:保留首尾、保留头部、保留尾部、按 token 数等比例丢弃。但没有任何一种策略能保证"重要的东西不被截断",因为"重要性"这个判断本身就需要模型来执行——而模型已经看不到被截断的内容了。
1.4.2 稀释流失(Dilution Loss)
更隐蔽的一种。内容没有消失,但它的注意力权重被挤压到了几乎为零。
Transformer 的注意力机制在分配权重时,并不区分"宪法"和"日志"——它只根据语义相关性来决定哪些 token 应该被关注。当一段 200 tokens 的检索片段和一段 2000 tokens 的工具调用结果被同时塞进上下文,模型对前者内容的"关注度"会自然下降,但前者的"实际重要性"可能远高于后者。
稀释流失的可怕之处在于:它不报错、不告警、不熔断。系统看起来在正常运转,每一次 API 调用都返回 200,模型的回复语法完整、逻辑自洽——只是关键约束没有被遵守。陈薇的"宝子""姐妹"事件就是典型的稀释流失:人格提示词还在上下文里,但它在注意力分配中的占比从 99% 掉到了 8%。
稀释流失有一个不太直观的几何性质:随着上下文长度的增加,有效注意力是超线性衰减的。换句话说,把上下文从 4K 扩展到 8K,并不是"容量翻倍",而是"每 token 的平均有效注意力"下降到原来的不到一半。
1.4.3 覆盖流失(Override Loss)
最狡猾的一种。前面的内容没有消失,后面的内容也没有稀释——但是后面的内容在语义上"改写"了前面的内容。
一个典型的场景:系统提示词定义了"你是一个严谨的法律助手,只根据提供的法律条文回答",但检索模块因为某种 bug 返回了一段包含"以下是个人观点"前缀的网页内容。当模型读到这段内容时,它会把"个人观点"理解为对话的新基调,从而覆盖系统提示词的人格设定。
覆盖流失在多 Agent 系统里尤其常见:当一个 Agent 的输出被注入到另一个 Agent 的上下文时,第二个 Agent 往往会把第一个 Agent 的"口吻"当作"权威指令"来遵循,即使第一个 Agent 只是一个执行者、没有定义人格的权力。
---
为了把这三种流失模式可视化,我们来看下面这张图。它展示了在一个典型的多轮 Agent 对话中,三类上下文资源(系统提示、对话历史、外部检索)是如何竞争同一个上下文窗口的,以及三种流失模式是如何在不同阶段相继发生的:
```mermaid flowchart TD A[上下文窗口 8K tokens] --> B[静态上下文 1.2K] A --> C[动态上下文 5.8K] A --> D[外部上下文 1.0K]
B --> B1[系统提示词 800] B --> B2[工具描述 400]
C --> C1[对话历史 3.5K] C --> C2[工具调用结果 1.8K] C --> C3[运行时元数据 0.5K]
D --> D1[按需检索 1.0K]
E[第 1-10 轮: 正常] --> F[第 11-20 轮: 稀释开始] F --> G[第 21-30 轮: 覆盖风险升高] G --> H[第 31+ 轮: 截断边界]
H --> I[关键系统指令丢失] H --> J[人格设定被改写] H --> K[早期用户诉求被遗忘] ```
这张图揭示了一个工程师在系统设计阶段就必须接受的现实:你不可能同时优化所有资源的留存。 当上下文窗口被填满时,截断、稀释、覆盖三种流失模式会以不同的速率、不同的可见性同时发生。
1.4.4 一个简单的实验:8K 窗口在多轮对话中的崩塌
为了把这三种流失从概念变成可观测的现象,我们写一个最小可运行实验。这个实验不依赖任何 API,只在词频统计和注意力分布的层面模拟"上下文窗口"的行为——但它揭示的规律,对真实的 KV 缓存同样成立。
```python import numpy as np from collections import Counter
模拟一个 8K token 的上下文窗口
CONTEXT_WINDOW = 8192
三类内容,每类有不同"重要性"标签
SYSTEM_PROMPT = "你是一个严谨的金融顾问,只回答与投资相关的问题,不要讨论任何其他话题。" TOOL_RESULTS = ["获取股票 AAPL 报价:189.5 美元,市值 2.95 万亿美元"] * 50 # 重复的工具结果 CONVERSATION = [ "用户:我有 10 万美元,应该如何配置?", "助手:建议分散投资到股票、债券、基金三个篮子。", "用户:我的风险偏好是保守型。", "助手:了解,那么债券占比可以提高到 60%。", "用户:我今年 65 岁,2026 年退休。", ] * 8 # 用户反复重复关键信息
def tokenize(text): """简化版 tokenizer:一个中文字符算一个 token""" return list(text)
def simulate_attention_decay(context_tokens, current_position): """ 模拟 Transformer 的注意力衰减: 距离当前位置越远的 token,注意力权重越低(呈指数衰减) 相似度高的 token 群会互相竞争注意力 """ n = len(context_tokens) distances = np.abs(np.arange(n) - current_position)
指数衰减,距离 0 权重为 1.0,距离 1000 权重约为 0.37
attention_weights = np.exp(-distances / 500) return attention_weights
def run_experiment(turn_count): print(f"\n===== 第 {turn_count} 轮对话 =====") context = []
阶段 1: 加载系统提示
context.extend(tokenize(SYSTEM_PROMPT)) print(f"系统提示加载后,上下文长度: {len(context)}")
阶段 2: 加载工具结果
for result in TOOL_RESULTS: context.extend(tokenize(result)) print(f"工具结果加载后,上下文长度: {len(context)}")
阶段 3: 加载多轮对话
for i in range(turn_count): for msg in CONVERSATION: context.extend(tokenize(msg))
检查截断
if len(context) > CONTEXT_WINDOW: truncated = len(context) - CONTEXT_WINDOW context = context[truncated:] # 简单的"保留尾部"截断 print(f"⚠️ 截断发生: 丢失了 {truncated} tokens")
计算注意力的实际分布
current_pos = len(context) - 1 weight