1.6 上下文工程:窗口预算、长上下文退化与提示词的系统方法
第 1 部分 · 第 6 课 | 预计 5–6 小时 | 前置:1.2 主流模型 API(用量口径)、1.4 推理模型(思考块保留)、1.5 Prompt Caching(前缀与断点)
配套练习:
projects/11-context/(纯 Go 标准库,12 个测试)。判断一次请求会放得下、中途截断还是直接 400;用一个玩具模型看清“越长越差”和“中间最差”的形状;在预算内组装上下文(必需项、优先级、去重、稳定内容在前);模拟 60 轮长任务 Agent 的四种上下文管理策略,算出谁会撑爆窗口、谁会丢事实、谁最省钱;最后用一个小的回归评测看清“改一句提示词,修好一个用例,弄坏两个”。
读前说明
这是模块 01 的最后一课。前五课讲的都是“一次调用”:采样、API、结构化输出、推理、缓存。这一课往上走一层,讲每次调用之前,你往模型里放什么。
Anthropic 的文档把这件事叫 context engineering:上下文是一种有限的资源,而且边际收益递减,放进去的每一个 token 都在和别的 token 抢模型的注意力。所以问题不是“窗口有多大”,而是“在这一步,最少放哪些 token,能让模型最可能做对”。
这一课分四块:
- 窗口:什么算进窗口、超了会怎样、怎么在发请求前就知道;
- 退化:为什么 100 万 token 的窗口不等于能用好 100 万 token;
- 组装与管理:单次请求怎么挑内容、怎么排顺序;长任务里怎么清理、压缩、记笔记;
- 提示词的系统方法:结构怎么写、规则怎么写,以及最重要的,怎么像管代码一样管提示词。
先看几个你会遇到的问题:
- 输入只有 95 万 token,窗口是 100 万,
max_tokens设了 128k,请求会被拒吗? - 把 200 篇文档全塞进 100 万的窗口,和先检索出 8 篇再放进去,哪个答得更准?
- 一次
grep返回了 3000 行,只保留前 200 行有什么问题? - 长任务跑到第 26 轮撑爆了窗口。把旧的工具结果清掉,为什么后面又开始“忘事”?
- 开了工具结果清理之后,为什么缓存命中率掉得厉害?
- 在提示词里加了一句“只回答数字”,修好了一个用例,为什么另外两个坏了,而你很久以后才发现?
本课结束时你能:
- 说清什么算进上下文窗口,判断一次请求是放得下、可能中途截断还是直接 400;
- 解释长度退化和位置效应,并据此决定“塞满”还是“检索”、关键信息放在哪里;
- 在预算内组装上下文:必需项、优先级、去重、稳定内容在前、工具输出保留头尾;
- 为长任务选择清理、压缩、笔记的组合,并考虑它们和缓存的相互影响;
- 写结构清晰的系统提示词,用回归评测管理提示词的每一次修改。
关于时效:本课按 2026 年 10 月 Anthropic 和 OpenAI 的官方文档核对。压缩和上下文编辑都还在 beta,参数名和 beta 头变化很快,写代码前一定以文档为准。第二部分的退化模型是玩具模型,参数是编的,只用来讲清形状,不代表任何模型的实测数字。
第一部分:上下文窗口
1. 什么算进窗口
上下文窗口是模型生成回答时能“看到”的全部内容,包括它正在生成的回答。Anthropic 文档列得很清楚,下面这些全部算数:
- 系统提示词;
messages里的每一条消息,包括工具结果、图片、文档;- 工具定义;
- 本轮生成的输出,包括思考 token。
有三个容易算错的地方:
- 缓存过的 token 照样占窗口。1.5 课的三个字段
input_tokens、cache_read_input_tokens、cache_creation_input_tokens都算进窗口。缓存改变的是价钱,不是占用; - 历史思考块占不占,看模型。1.4 课讲过:Opus 4.5 及以后、Sonnet 4.6 及以后、Haiku 5.5 等默认保留之前的思考块,它们会作为输入占窗口;更早的模型会自动去掉;
- 工具定义本身也很大。几十个工具的定义可能就有上万 token。Anthropic 和 OpenAI 都提供了工具搜索(
defer_loading),用到时再加载定义。
2. 窗口有多大,超了会怎样
2026 年 10 月的 Anthropic 文档:Fable 5.1、Mythos 5.1、Opus 5.5、Opus 5、Opus 4.6 到 4.8、Sonnet 5.5、Sonnet 5、Sonnet 4.6、Haiku 5.5 等是 100 万 token 窗口,单次最多输出 128k;其他模型(包括已弃用的 Sonnet 4.5)是 20 万。100 万窗口默认开启,不需要 beta 头,Haiku 5.5 的提示词超过 10 万 token 以后单价更高。单个请求最多 600 张图片或 PDF 页(20 万窗口的模型是 100 张),塞很多图片时可能先碰到请求大小限制。
超了会怎样,分两种情况:
| 情况 | Anthropic 4.5 及以后的行为 |
|---|---|
| 输入本身超过窗口 | 直接 400,prompt is too long |
输入没超,但输入 + max_tokens 超了 |
请求被接受;生成碰到窗口上限时停下,stop_reason 是 model_context_window_exceeded |
练习的 Window.Check 就是这张表:
func (w Window) Check(input, maxTokens int) Outcome {
switch {
case input > w.Size:
return Rejected
case input+maxTokens > w.Size:
return MayTruncate
default:
return Fits
}
}
所以开头那个问题的答案是:95 万输入 + 128k max_tokens 不会被拒,但最多只能生成大约 5 万 token 就会停下。如果这是一个要输出长文件的任务,你会拿到一个写了一半的文件。更早的模型在这种情况下直接返回校验错误。
3. 发请求之前就知道
不要等 400 了才知道超了。两种办法:
- 精确:Anthropic 和 OpenAI 都有 token counting 接口(Anthropic 的
count_tokens,OpenAI 的POST /v1/responses/input_tokens),把整个请求发过去,返回会占多少 token。Anthropic 的计数接口还支持带上context_management,告诉你清理之后剩多少; - 粗估:做预算时可以用经验比例。练习的
EstimateTokens按“ASCII 约 4 个字符 1 个 token,中文约 1 个字 1 个 token”估算。这只能用来做预算和报警,不能用来计费。
一个 Agent 的窗口预算大致这样分:
| 部分 | 典型占比 | 说明 |
|---|---|---|
| 工具定义 + 系统提示 | 5–15% | 稳定,放最前面,缓存 |
| 记忆、检索结果 | 10–30% | 每步按需挑选 |
| 对话和工具历史 | 剩下的大部分 | 会增长,要管理 |
| 输出预留(含思考) | max_tokens |
必须预留,否则会被截断 |
预算的意义不只是“别超”。下一部分会讲,放得下不等于用得好。
第二部分:长上下文退化
4. 两种现象
Anthropic 的上下文窗口文档里有一句很重要的话:更多的上下文并不自动更好,随着 token 数增长,准确率和召回会下降,这个现象叫 context rot。所以“上下文里放什么”和“窗口有多大”同样重要。
长上下文退化主要有两种形状:
- 长度退化:同一条关键信息,放在 2 万 token 的上下文里和放在 80 万 token 的上下文里,后者更容易被忽略或用错;
- 位置效应:2023 年的论文 Lost in the Middle(Liu 等)发现,多文档问答里答案在开头或结尾时最准,在中间时最差,准确率随位置呈 U 形。后来的模型改善了很多,但“中间更容易被忽略”的倾向在很多评测里仍然能看到。
不同模型、不同任务退化的程度差别很大。简单的“大海捞针”(在长文里找一句原话)很多模型已经接近满分,但需要推理、需要综合多处信息、或者干扰项和答案很像的任务,退化明显得多。你自己的任务退化多少,只能靠自己的评测测出来。
5. 用一个玩具模型看清形状
练习的 RecallModel 把两种现象写成一个公式(参数是编的):
func (m RecallModel) Recall(pos, n int) float64 {
base := 1 / (1 + math.Pow(float64(n)/m.HalfAt, m.Steep)) // 越长越低
x := (float64(pos) + 0.5) / float64(n)
return base * (1 - m.Middle*4*x*(1-x)) // 中间最低
}
用默认参数(总长 40 万时整体召回降到一半,中间比两端少 30%),TestRecallShape 和 TestStuffVsRetrieve 的输出:
| 场景 | 召回 |
|---|---|
| 2 万 token,信息在开头 | 0.989 |
| 20 万 token,开头 / 中间 / 结尾 | 0.739 / 0.517 / 0.739 |
| 80 万 token,信息在开头 | 0.261 |
| 200 篇文档全塞(80 万),答案在中间 | 0.183 |
| 200 篇文档全塞(80 万),答案在最后 | 0.260 |
| 先检索 8 篇(3.2 万,检索器召回 0.9),最相关的放最后 | 0.818 |
| 资料只有 5 万 token,全塞,答案在最后 | 0.946 |
| 资料只有 5 万 token,用召回 0.6 的检索器 | 0.556 |
再强调一次,这些数字是玩具模型算出来的。要记住的是结论的方向:
- 资料很多时,先检索再放进去,比全塞更好。检索把长度从 80 万降到 3 万,长度退化的损失远大于检索漏掉的那 10%;
- 资料不多时,全塞可能比一个烂检索器更好。检索不是免费的,它有自己的召回率。5 万 token 的资料全塞进去几乎不退化,而一个只有 0.6 召回率的检索器直接丢掉了四成;
- 关键信息放两端。指令和问题放在最后、离生成最近;最重要的资料放在开头或紧挨着问题,不要埋在中间;
- 长度本身就是成本。即使放得下,也要问:这些 token 每一个都有用吗?
模块 05 讲 RAG 时会细讲检索器的召回怎么测。这里先记住:“塞满”还是“检索”,取决于资料总量和检索器质量,要用评测决定,不要凭感觉。
第三部分:单次请求的上下文组装
6. 上下文由哪些部分组成
一次 Agent 请求里通常有这些部分:
| 部分 | 稳定还是动态 | 是否必需 |
|---|---|---|
| 工具定义 | 稳定 | 必需 |
| 系统提示(角色、规则、示例、输出格式) | 稳定 | 必需 |
| 长期记忆(用户偏好、项目约定) | 较稳定 | 按需 |
| 检索结果 | 动态 | 按需 |
| 对话和工具历史 | 只追加 | 大部分需要 |
| 当前时间、环境信息 | 动态 | 必需 |
| 本次问题 | 动态 | 必需 |
排列顺序有两个原则,而且它们恰好一致:
- 缓存(1.5 课):稳定的放前面,变化的放后面;
- 注意力(第 5 节):最关键的指令和问题放在最后,离生成最近。
7. 在预算内挑选
练习的 Assemble 做了四件事:
- 必需项全部保留;必需项本身就超预算,直接报错(说明系统提示或工具定义太大了,要从设计上解决,不能靠截断);
- 其他内容按优先级从高到低,放得下就放;
- 完全相同的文本只放一份(同一段文档被两个检索器同时召回是很常见的事);
- 输出时稳定内容在前、动态内容在后,组内保持原来的顺序。
TestAssemble 的例子:预算 11000,必需项(规则 3000、工具 2000、时间 20、问题 100)占 5120,剩下的按优先级放入记忆 800、检索 A 2500、检索 B 2500;检索 A 的副本因为重复被丢掉,检索 C 优先级最低放不下。最终用了 10920。
真实系统里“优先级”从哪里来?常见的来源:检索的相关度分数、内容的新旧、是否被用户明确提到过、上一步模型是否引用过。重要的是丢掉了什么要记录下来,出了问题才能查。
8. 工具输出:保留头尾,按需读取
工具结果是 Agent 上下文增长最快的部分。一次 grep、一次 ls -R、一个网页、一份日志,动不动就是几千行。
最常见的错误是只保留开头:
- 编译错误、测试失败的汇总、命令的退出信息,通常在结尾;
- 只截开头,模型看到的是一堆正常输出,看不到真正的错误。
练习的 TrimLines 保留头 head 行和尾 tail 行,中间换成一句说明:
line 1
line 2
line 3
……省略 95 行(完整输出已保存,可按需读取)……
line 99
line 100
说明里那半句“完整输出已保存,可按需读取”很重要。更好的做法是:把完整输出写进文件,上下文里只放摘要和文件路径,模型需要时再用工具读取具体的行。这就是 Anthropic 文章里说的 just-in-time 思路:上下文里放引用(路径、ID、查询),而不是把所有内容都预先塞进去。Claude Code 这类编码 Agent 就是这样工作的:它不会一开始把整个仓库读进来,而是按需 grep、head、读文件。
第四部分:长任务的上下文管理
9. 三种工具:清理、压缩、笔记
一个跑几十上百轮的 Agent,迟早会碰到窗口上限,或者在碰到上限之前就因为退化开始变差。有三种办法:
清理(clearing):把已经用过的旧内容删掉,换成一个占位符。最典型的是旧的工具结果:文件内容、搜索结果,模型处理过以后通常不再需要原文。Anthropic 的 context editing(beta,context-management-2025-06-27 头)提供两种服务端策略:
clear_tool_uses_20250919:超过trigger(默认 10 万输入 token)后,按时间从旧到新清理工具结果,保留最近keep个(默认 3 个),可以用exclude_tools保护某些工具,用clear_at_least要求一次至少清掉多少 token;clear_thinking_20251015:控制保留最近几轮的思考块。
清理在服务端进行,你本地的历史保持完整,不需要同步。
压缩(compaction):把旧的对话压成一段摘要,替换原文。
- Anthropic:服务端压缩(beta)。按需压缩(
compact-2026-09-04头 + 顶层compaction参数)由你决定什么时候压,可以保留最近几轮原文、可以在后台跑;按阈值压缩由 API 在输入达到阈值时自动执行; - OpenAI:在 Responses 请求里设置
context_management: [{type: "compaction", compact_threshold: ...}],超过阈值时服务端自动压缩,返回一个加密的 compaction 项,下一轮原样带上;也可以单独调/responses/compact接口,它返回一个新的、压缩过的上下文窗口,原样作为下一次请求的输入。
两家的压缩结果都是给模型看的:OpenAI 的 compaction 项是加密的,不能人读;Anthropic 的默认摘要提示词要求写清任务目标、当前状态、重要发现(包括试过但失败的方法)、下一步和需要保留的偏好。
笔记(memory / notes):让 Agent 把重要的东西写到上下文之外(文件、数据库、记忆工具),需要时再读回来。Anthropic 的 memory 工具可以和清理配合:上下文快到清理阈值时,模型会收到提醒,先把重要信息写进记忆文件。
10. 四种策略的模拟
练习的 Sim 模拟一个长任务:基础上下文 8000 token,每轮追加用户 200、工具调用 100、工具结果 3000、回答 300,窗口 10 万,清理/压缩阈值 6 万,保留最近 5 轮。每个工具结果里有一条事实,从第 12 轮起每 4 轮要用到 12 轮之前的事实。60 轮的结果(TestCompactionStrategies):
| 策略 | 结果 | 峰值 | 改写历史 | 需要的事实丢失 | 输入当量(按 1.5 课缓存倍率) |
|---|---|---|---|---|---|
| 全部保留 | 第 26 轮撑爆 | 98000 | 0 | 0/4 | 249700(只跑了 25 轮) |
| 清旧工具结果 | 跑完 | 59550 | 15 | 11/12 | 1359425 |
| 清除前先记笔记 | 跑完 | 61750 | 17 | 0/12 | 1509188 |
| 摘要压缩(能记 10 条事实) | 跑完 | 58400 | 6 | 0/12 | 668560 |
还有两个变体:
- 摘要只能记住 2 条事实时(
TestSummaryCapacity),丢失 7/12; - 清理加上“至少清 2 万 token 才动手”(
TestClearAtLeast),改写从 15 次降到 7 次,输入当量从 1359425 降到 894814。
读这张表:
- 什么都不管,一定会撑爆,而且在撑爆之前就已经在退化区间里了;
- 只清理不记录,会忘事。模型后面需要的事实恰好在被清掉的工具结果里。这就是开头那个问题“为什么后面又开始忘事”的答案。修复办法是清理之前把关键信息写进笔记(或者把重要工具加入
exclude_tools); - 摘要的质量就是上限。摘要能记住的事实有限,记不住的就丢了。真实的摘要还会出现“记错”,比模拟更糟。所以要为压缩写专门的摘要提示词,并且测它;
- 每次改写历史都在打掉缓存。清理和压缩都会从改写点开始让缓存失效,下一轮要重新写入。阈值一过就每轮清一点,等于每轮都付一次 1.25× 的写入费。Anthropic 的
clear_at_least就是为这个设计的:攒够了再清,一次清一大块。
这四点合起来,就是长任务上下文管理的基本原则:少改写、一次改够、改之前先把要紧的记下来。
11. 跨会话:状态写在上下文之外
有些任务一个上下文窗口装不下,要跨多个会话(比如一个写了几天的编码任务)。这时可靠的状态不能只存在对话里:
- 进度文件(做完了什么、下一步是什么);
- 测试和 git 提交(代码状态本身就是最可靠的记录);
- 结构化的决策记录(为什么这么做、试过什么不行)。
Anthropic 的文档和工程文章都建议:设计这些状态文件,让新会话能快速恢复上下文。这和人类工程师交接工作是一回事。我们这门课的仓库其实也在这么做:每课的日志、ROADMAP 的勾选、README 的进度表,都是给“下一个会话”看的状态。
第五部分:提示词的系统方法
12. 结构:把不同的东西分开
一份好的系统提示词,通常有这几块,用 XML 标签或 Markdown 标题分开:
<role>你是一个代码审查助手,服务于一个 Go 后端团队。</role>
<task>审查用户给出的 diff,找出会导致线上问题的缺陷。</task>
<rules>
- 只报告会影响正确性、安全或性能的问题;风格问题交给 linter,不要报。
- 每个问题给出文件和行号、为什么是问题、建议怎么改。
- 不确定的问题标注“需要确认”,而不是当成确定的结论。
</rules>
<examples>
……两三个有代表性的输入和期望输出……
</examples>
<output_format>Markdown 列表,按严重程度从高到低。</output_format>
<context>
当前时间:2026-10-14
仓库:……
</context>
几个要点:
- 分开写:角色、任务、规则、示例、输出格式、动态上下文各占一块。标签让模型和你自己都更容易分辨“这是指令”还是“这是资料”。用户给的资料尤其要放在标签里,和指令分开,这也是防提示词注入的第一步(模块 09 会细讲);
- 稳定在前、动态在后:时间、用户信息放在最后的
context里,不要插进规则中间(1.5 课的缓存,和第 6 节的注意力,两个理由); - 示例要少而准:两三个覆盖典型情况和边界情况的例子,比十个相似的例子更有用。示例会被模型强烈模仿,包括你不想让它模仿的细节(长度、语气、格式)。
13. 规则怎么写:合适的“高度”
Anthropic 的文章把这叫找到合适的 altitude(高度):
- 太低:把提示词写成一堆 if-else,“如果用户问 A 就回答 B,如果出现 C 就做 D”。规则写不全,而且越写越脆,任何没覆盖的情况都会出错;
- 太高:只有一句“你是一个有帮助的助手,请认真回答”。模型拿不到判断的依据;
- 合适:给出目标、原则和理由,让模型能在没写到的情况里自己做出合理的判断。
具体的写法建议:
- 说清为什么。“不要报风格问题,因为团队用 golangci-lint 自动检查,人工审查只看正确性”比“不要报风格问题”效果好。模型知道了原因,就能推广到你没列出来的情况;
- 说要做什么,而不只是不要做什么。“不要输出客套话”不如“直接从结论开始写”。只有禁止的规则,模型知道哪里不能走,但不知道该往哪走;
- 一条规则说一件事,冲突的规则要写明优先级;
- 对现代模型不要过度强调。全大写、“必须”“绝对”“CRITICAL”,在较早的模型上能提高遵循度,在 2026 年的模型上往往导致过度执行(比如让它“一定要调用搜索工具”,它会在不需要的时候也去搜)。OpenAI 和 Anthropic 针对新模型的提示词指南都提到过这一点;
- 工具描述也是提示词。工具名、描述、参数说明,决定了模型什么时候调、怎么调。描述要写清这个工具做什么、什么时候该用、什么时候不该用、参数的格式和例子。两个功能重叠、描述含糊的工具,是 Agent 选错工具的头号原因(模块 02 会细讲)。
14. 把提示词当代码管
这是整个“系统方法”里最重要的一条:提示词的每一次修改,都要跑评测。
原因很简单:提示词的影响是全局的。你为了修一个问题加的一句话,会影响所有的输入。练习的 TestEvalDiff 用一个确定性的假模型演示了这一点:
| 版本 | 提示词 | 通过 |
|---|---|---|
| v1 | 你是助手。 | 3/4 |
| v2 | 你是助手。用中文。只回答数字。 | 2/4 |
v2 修好了一个用例(不知道的问题现在会回答“我不知道”),但弄坏了两个(“约 1200 公里”变成了“1200”,“100 摄氏度”变成了“100”,单位丢了)。如果你只看修好的那个用例,或者只抽查几个,很容易以为 v2 更好。
Diff 的价值就在这里:
func Diff(cases []Case, base, next Result) (fixed, broke []string) {
for _, c := range cases {
switch {
case !base.Passed[c.ID] && next.Passed[c.ID]:
fixed = append(fixed, c.ID)
case base.Passed[c.ID] && !next.Passed[c.ID]:
broke = append(broke, c.ID)
}
}
return
}
即使两个版本通过率一样,也可能是“修好三个、弄坏三个”。只看总通过率会掩盖回归,必须逐个用例比较。
一个可以直接用的工作流:
- 提示词放进仓库,和代码一起版本管理,每次修改有提交记录和理由;
- 维护一个评测集:每发现一个线上问题,就把它变成一个用例加进去。评测集只增不减;
- 每次修改都跑全量评测,看通过率,更要看
fixed和broke两个列表; - 真实模型有随机性,同一个用例要跑多次,看通过的比例,而不是一次的对错(模块 10 讲 Eval 时会细讲统计方法);
- 换模型时也要跑。同一份提示词在新模型上的表现可能不同,1.5 课提到的缓存最小长度、1.4 课的推理参数,换模型时都可能变。
本课小结
- 上下文窗口包括输入和本轮输出(含思考);缓存过的 token 照样占窗口;输入超窗口直接 400,输入加
max_tokens超窗口会在生成中途停下。 - 放得下不等于用得好:上下文越长整体越差,中间的信息最容易被忽略。资料多时先检索,资料少时全塞可能更好,关键信息放两端。
- 单次请求按“必需项、优先级、去重、稳定在前”组装;工具输出保留头尾,完整内容放到上下文之外按需读取。
- 长任务用清理、压缩、笔记管理上下文:只清不记会忘事,摘要的质量就是上限,每次改写都打掉缓存,所以要少改写、一次改够、改之前先记下来。
- 提示词结构分块、稳定在前;规则写目标和理由,说该做什么;工具描述也是提示词。
- 提示词当代码管:版本管理、评测集只增不减、每次修改逐个用例看修好和弄坏了什么。
模块 01 到这里结束。下一个模块讲工具调用与协议:Function Calling 的机制、并行调用、MCP 和 A2A。
练习(今天做完)
练习 1:跑通并读懂实验输出
cd projects/11-context
make test
对照第 5 节和第 10 节的表,在测试日志里找到这些数字,并回答:
- 把
DefaultRecall的Middle改成 0,“200 篇全塞、答案在中间”和“答案在最后”还有区别吗?剩下的差距来自哪里? - 把
DefaultSim的Keep从 5 改成 15,“清旧工具结果”策略丢失的事实会变少吗?峰值会怎样?为什么? - 摘要压缩的输入当量为什么比“清除前记笔记”低一半多?
练习 2:结构化渲染系统提示词
写一个类型和函数:
type PromptSpec struct {
Role, Task string
Rules []string
Examples []string
Output string
Dynamic map[string]string // 每次请求都会变的内容
}
func Render(p PromptSpec) (string, []string)
要求:
- 按
role、task、rules、example、output_format、context的顺序渲染成 XML 标签,Dynamic放在最后的context里,键按字母排序(输出必须稳定,否则缓存失效); - 返回三类警告:稳定区出现了模板变量(形如两个花括号包住的变量名);没有写输出格式;规则以“不要”开头,但没有写该做什么(没有“而是”或“改为”)。
测试:一个三类问题都有的 spec 返回 3 条警告;改正之后返回 0 条。
练习 3:什么时候该压缩
写一个函数,根据一次 Anthropic 响应的用量判断还剩多少空间、要不要压缩:
func Remaining(window, read, write, input, maxTokens int) (left int, compact bool)
剩余 = 窗口 - (read + write + input) - maxTokens;剩余少于窗口的 10% 时建议压缩。用“窗口 100 万、读缓存 80 万、写缓存 5 万、普通输入 2 万、max_tokens 64000”验证:剩余 66000,建议压缩。想一想:为什么这里必须把缓存的两个字段也加进去?
自测题
- 上下文窗口里包括哪些东西?缓存命中的 token 算不算?
- 窗口 100 万,输入 95 万,
max_tokens128k,在 Anthropic 4.5 以后的模型上会发生什么?在更早的模型上呢? - “长度退化”和“位置效应”分别是什么?对你排列上下文有什么影响?
- 什么情况下“把资料全塞进去”比“先检索”更好?
- 为什么工具输出不能只保留开头?更好的做法是什么?
- 只清理旧工具结果、不做别的,会带来什么问题?怎么修?
- 为什么上下文清理和压缩会影响缓存?
clear_at_least解决的是什么问题? - 规则“不要输出客套话”有什么问题?怎么改写?
- 为什么对 2026 年的模型,不建议在提示词里大量使用“必须”“CRITICAL”这样的强调?
- 两个提示词版本通过率一样,能说明修改没有影响吗?应该看什么?
参考答案与详解
自测题答案
1. 系统提示、所有消息(包括工具结果、图片、文档)、工具定义,以及本轮生成的输出(包括思考 token)。缓存命中的 token 照样算,缓存只改变价格,不改变占用。保留下来的历史思考块也算。
2. 4.5 以后:请求被接受,因为输入本身没超;生成到大约 5 万 token 时碰到窗口上限,以 model_context_window_exceeded 停止。更早的模型:直接返回校验错误(除非加了对应的 beta 头)。
3. 长度退化:上下文越长,模型整体越容易忽略或用错信息。位置效应:开头和结尾的信息比中间的更容易被用上。影响:控制总长度,只放有用的;关键指令和问题放在最后,最重要的资料放在开头或紧挨着问题。
4. 资料总量不大(远低于退化明显的长度),而检索器召回率不高的时候。全塞几乎不退化,检索器却会直接漏掉一部分答案。具体要用评测决定。
5. 错误信息、测试失败汇总、命令结论通常在结尾,只留开头会丢掉最关键的信息。更好的做法是保留头尾,中间写明省略了多少行;完整输出存到上下文之外,上下文里放路径,模型需要时按需读取。
6. 后面的步骤需要的事实可能恰好在被清掉的工具结果里,模型会“忘事”,要么重新调用工具(浪费),要么编一个答案(危险)。修法:清理之前把关键信息写进笔记或记忆文件;对重要工具用 exclude_tools 保护;或者改用能保留关键事实的摘要压缩。
7. 清理和压缩都是改写历史,改写点之后的前缀全部变化,缓存从那里失效,下一轮要按 1.25× 重新写入。如果每轮只清一点,等于每轮都打掉缓存。clear_at_least 要求一次至少清掉一定数量的 token,攒够了再清,减少改写次数。
8. 只说了不要做什么,没说该做什么,模型知道哪里不能走,但不知道往哪走。改成“直接从结论开始写,第一句就回答问题”。
9. 较早的模型遵循度不够,需要强调;2026 年的模型对指令很敏感,过度强调会导致过度执行,比如在不需要的时候也去调工具,或者把一条规则用到不该用的地方。用正常的语气写清规则和理由就够了。
10. 不能。可能是修好了几个、又弄坏了同样多个。应该逐个用例比较,看修好了哪些、弄坏了哪些;真实模型还要多次运行看比例。
练习参考答案
练习 1
Middle改成 0 后位置效应消失,答案在中间和在最后的召回一样(都是 0.261,也就是 80 万长度下的整体召回)。剩下的差距(和检索 8 篇的 0.818 相比)完全来自长度退化。- 会,丢失从 11/12 降到 0/12:需要的事实来自 12 轮之前,现在最近 15 轮的工具结果都保留着。但代价很大:峰值从 59550 涨到 89450,离 10 万的窗口很近,退化也更明显;而且保留得多,上下文一直贴着 6 万的阈值,几乎每一轮都要清一个工具结果,改写从 15 次涨到 45 次,输入当量从 1359425 涨到 4403188。这就是“保留多少”的权衡:留得多,忘得少,但更长、更贵,还更频繁地打掉缓存(配合
ClearAtLeast能缓解后一个问题)。 - 两个原因:摘要压缩把旧轮次整个换成 2000 token 的摘要,之后每轮的上下文都更短;而且它只改写了 6 次历史,其余轮次都能命中缓存。“清除前记笔记”每次清理只换掉工具结果,上下文仍然较长,而且改写了 17 次,每次都按 1.25× 重新写入整个上下文。
练习 2
var placeholder = regexp.MustCompile(`\{\{\s*\w+\s*\}\}`)
func Render(p PromptSpec) (string, []string) {
var warns []string
stable := strings.Join(append([]string{p.Role, p.Task, p.Output}, append(p.Rules, p.Examples...)...), "\n")
if placeholder.MatchString(stable) {
warns = append(warns, "稳定区里有模板变量:每次渲染结果不同,缓存失效")
}
if strings.TrimSpace(p.Output) == "" {
warns = append(warns, "没有写输出格式")
}
for _, r := range p.Rules {
if strings.HasPrefix(r, "不要") && !strings.Contains(r, "而是") && !strings.Contains(r, "改为") {
warns = append(warns, fmt.Sprintf("规则“%s”只说了不要做什么,没说该做什么", r))
}
}
var b strings.Builder
fmt.Fprintf(&b, "<role>%s</role>\n<task>%s</task>\n<rules>\n", p.Role, p.Task)
for _, r := range p.Rules {
fmt.Fprintf(&b, "- %s\n", r)
}
b.WriteString("</rules>\n")
for i, e := range p.Examples {
fmt.Fprintf(&b, "<example index=\"%d\">%s</example>\n", i+1, e)
}
fmt.Fprintf(&b, "<output_format>%s</output_format>\n", p.Output)
keys := make([]string, 0, len(p.Dynamic))
for k := range p.Dynamic {
keys = append(keys, k)
}
sort.Strings(keys) // map 遍历顺序随机,必须排序
b.WriteString("<context>\n")
for _, k := range keys {
fmt.Fprintf(&b, "%s: %s\n", k, p.Dynamic[k])
}
b.WriteString("</context>")
return b.String(), warns
}
测试:
func TestRender(t *testing.T) {
p := PromptSpec{
Role: "你是代码审查助手",
Task: "审查 {{diff}}",
Rules: []string{"不要输出客套话", "不要直接改代码,而是给出建议"},
Dynamic: map[string]string{"time": "2026-10-14", "user": "duang777"},
}
out, w := Render(p)
if len(w) != 3 {
t.Fatal(w)
}
if !strings.HasSuffix(out, "<context>\ntime: 2026-10-14\nuser: duang777\n</context>") {
t.Fatal(out)
}
p.Task = "审查用户给出的 diff"
p.Output = "Markdown 列表"
p.Rules[0] = "不要输出客套话,改为直接给结论"
if _, w := Render(p); len(w) != 0 {
t.Fatal(w)
}
}
讨论:第三条检查是一个很粗的启发式,只能发现最明显的写法。它的意义不在于准确,而在于把“提示词审查”变成可以在 CI 里自动跑的东西。团队踩过的坑,都可以变成这样一条检查。
练习 3
func Remaining(window, read, write, input, maxTokens int) (left int, compact bool) {
left = window - (read + write + input) - maxTokens
return left, left < window/10
}
验证:1000000 - (800000 + 50000 + 20000) - 64000 = 66000,小于 100000,建议压缩。
必须加上缓存字段,是因为 Anthropic 的 input_tokens 只是最后一个缓存断点之后的部分(1.5 课),缓存读写的 token 同样占窗口。只看 input_tokens,你会以为只用了 2 万,实际上已经用了 87 万。Anthropic 文档提到 SDK 的客户端压缩也是用这四项之和判断阈值的;同时提醒了一个例外:用服务端工具(比如网页搜索)时,cache_read_input_tokens 可能累计了服务端工具多次内部调用的读取,会高估真实长度,这时要用 token counting 接口拿准确的数字。
完成标准
make test全部通过(12 个测试),能在日志里找到第 5 节和第 10 节表格里的数字;- 练习 1 的三个问题能用“长度退化”“保留窗口”“改写次数与缓存”解释清楚;
- 练习 2 的
Render和测试写完并通过; - 练习 3 的
Remaining通过验证,能说清为什么要加上缓存字段; - 自测题 10 道能不看答案讲出来,尤其是第 2、6、7、10 题。
© 2026 duang777 · 课件源文件:notes/01-llm/06-context-engineering.md