模块 01 · LLM 原理与使用 · 第 1.6 课

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,能让模型最可能做对”。

这一课分四块:

  1. 窗口:什么算进窗口、超了会怎样、怎么在发请求前就知道;
  2. 退化:为什么 100 万 token 的窗口不等于能用好 100 万 token;
  3. 组装与管理:单次请求怎么挑内容、怎么排顺序;长任务里怎么清理、压缩、记笔记;
  4. 提示词的系统方法:结构怎么写、规则怎么写,以及最重要的,怎么像管代码一样管提示词。

先看几个你会遇到的问题:

本课结束时你能:

  1. 说清什么算进上下文窗口,判断一次请求是放得下、可能中途截断还是直接 400;
  2. 解释长度退化和位置效应,并据此决定“塞满”还是“检索”、关键信息放在哪里;
  3. 在预算内组装上下文:必需项、优先级、去重、稳定内容在前、工具输出保留头尾;
  4. 为长任务选择清理、压缩、笔记的组合,并考虑它们和缓存的相互影响;
  5. 写结构清晰的系统提示词,用回归评测管理提示词的每一次修改。

关于时效:本课按 2026 年 10 月 Anthropic 和 OpenAI 的官方文档核对。压缩和上下文编辑都还在 beta,参数名和 beta 头变化很快,写代码前一定以文档为准。第二部分的退化模型是玩具模型,参数是编的,只用来讲清形状,不代表任何模型的实测数字。


第一部分:上下文窗口

1. 什么算进窗口

上下文窗口是模型生成回答时能“看到”的全部内容,包括它正在生成的回答。Anthropic 文档列得很清楚,下面这些全部算数:

有三个容易算错的地方:

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 了才知道超了。两种办法:

一个 Agent 的窗口预算大致这样分:

部分 典型占比 说明
工具定义 + 系统提示 5–15% 稳定,放最前面,缓存
记忆、检索结果 10–30% 每步按需挑选
对话和工具历史 剩下的大部分 会增长,要管理
输出预留(含思考) max_tokens 必须预留,否则会被截断

预算的意义不只是“别超”。下一部分会讲,放得下不等于用得好。


第二部分:长上下文退化

4. 两种现象

Anthropic 的上下文窗口文档里有一句很重要的话:更多的上下文并不自动更好,随着 token 数增长,准确率和召回会下降,这个现象叫 context rot。所以“上下文里放什么”和“窗口有多大”同样重要。

长上下文退化主要有两种形状:

不同模型、不同任务退化的程度差别很大。简单的“大海捞针”(在长文里找一句原话)很多模型已经接近满分,但需要推理、需要综合多处信息、或者干扰项和答案很像的任务,退化明显得多。你自己的任务退化多少,只能靠自己的评测测出来。

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

再强调一次,这些数字是玩具模型算出来的。要记住的是结论的方向:

  1. 资料很多时,先检索再放进去,比全塞更好。检索把长度从 80 万降到 3 万,长度退化的损失远大于检索漏掉的那 10%;
  2. 资料不多时,全塞可能比一个烂检索器更好。检索不是免费的,它有自己的召回率。5 万 token 的资料全塞进去几乎不退化,而一个只有 0.6 召回率的检索器直接丢掉了四成;
  3. 关键信息放两端。指令和问题放在最后、离生成最近;最重要的资料放在开头或紧挨着问题,不要埋在中间;
  4. 长度本身就是成本。即使放得下,也要问:这些 token 每一个都有用吗?

模块 05 讲 RAG 时会细讲检索器的召回怎么测。这里先记住:“塞满”还是“检索”,取决于资料总量和检索器质量,要用评测决定,不要凭感觉。


第三部分:单次请求的上下文组装

6. 上下文由哪些部分组成

一次 Agent 请求里通常有这些部分:

部分 稳定还是动态 是否必需
工具定义 稳定 必需
系统提示(角色、规则、示例、输出格式) 稳定 必需
长期记忆(用户偏好、项目约定) 较稳定 按需
检索结果 动态 按需
对话和工具历史 只追加 大部分需要
当前时间、环境信息 动态 必需
本次问题 动态 必需

排列顺序有两个原则,而且它们恰好一致:

7. 在预算内挑选

练习的 Assemble 做了四件事:

  1. 必需项全部保留;必需项本身就超预算,直接报错(说明系统提示或工具定义太大了,要从设计上解决,不能靠截断);
  2. 其他内容按优先级从高到低,放得下就放;
  3. 完全相同的文本只放一份(同一段文档被两个检索器同时召回是很常见的事);
  4. 输出时稳定内容在前、动态内容在后,组内保持原来的顺序。

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 头)提供两种服务端策略:

清理在服务端进行,你本地的历史保持完整,不需要同步。

压缩(compaction):把旧的对话压成一段摘要,替换原文。

两家的压缩结果都是给模型看的: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

还有两个变体:

读这张表:

  1. 什么都不管,一定会撑爆,而且在撑爆之前就已经在退化区间里了;
  2. 只清理不记录,会忘事。模型后面需要的事实恰好在被清掉的工具结果里。这就是开头那个问题“为什么后面又开始忘事”的答案。修复办法是清理之前把关键信息写进笔记(或者把重要工具加入 exclude_tools);
  3. 摘要的质量就是上限。摘要能记住的事实有限,记不住的就丢了。真实的摘要还会出现“记错”,比模拟更糟。所以要为压缩写专门的摘要提示词,并且测它;
  4. 每次改写历史都在打掉缓存。清理和压缩都会从改写点开始让缓存失效,下一轮要重新写入。阈值一过就每轮清一点,等于每轮都付一次 1.25× 的写入费。Anthropic 的 clear_at_least 就是为这个设计的:攒够了再清,一次清一大块。

这四点合起来,就是长任务上下文管理的基本原则:少改写、一次改够、改之前先把要紧的记下来。

11. 跨会话:状态写在上下文之外

有些任务一个上下文窗口装不下,要跨多个会话(比如一个写了几天的编码任务)。这时可靠的状态不能只存在对话里:

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>

几个要点:

13. 规则怎么写:合适的“高度”

Anthropic 的文章把这叫找到合适的 altitude(高度):

具体的写法建议:

  1. 说清为什么。“不要报风格问题,因为团队用 golangci-lint 自动检查,人工审查只看正确性”比“不要报风格问题”效果好。模型知道了原因,就能推广到你没列出来的情况;
  2. 说要做什么,而不只是不要做什么。“不要输出客套话”不如“直接从结论开始写”。只有禁止的规则,模型知道哪里不能走,但不知道该往哪走;
  3. 一条规则说一件事,冲突的规则要写明优先级;
  4. 对现代模型不要过度强调。全大写、“必须”“绝对”“CRITICAL”,在较早的模型上能提高遵循度,在 2026 年的模型上往往导致过度执行(比如让它“一定要调用搜索工具”,它会在不需要的时候也去搜)。OpenAI 和 Anthropic 针对新模型的提示词指南都提到过这一点;
  5. 工具描述也是提示词。工具名、描述、参数说明,决定了模型什么时候调、怎么调。描述要写清这个工具做什么、什么时候该用、什么时候不该用、参数的格式和例子。两个功能重叠、描述含糊的工具,是 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
}

即使两个版本通过率一样,也可能是“修好三个、弄坏三个”。只看总通过率会掩盖回归,必须逐个用例比较。

一个可以直接用的工作流:

  1. 提示词放进仓库,和代码一起版本管理,每次修改有提交记录和理由;
  2. 维护一个评测集:每发现一个线上问题,就把它变成一个用例加进去。评测集只增不减;
  3. 每次修改都跑全量评测,看通过率,更要看 fixed 和 broke 两个列表;
  4. 真实模型有随机性,同一个用例要跑多次,看通过的比例,而不是一次的对错(模块 10 讲 Eval 时会细讲统计方法);
  5. 换模型时也要跑。同一份提示词在新模型上的表现可能不同,1.5 课提到的缓存最小长度、1.4 课的推理参数,换模型时都可能变。

本课小结

  1. 上下文窗口包括输入和本轮输出(含思考);缓存过的 token 照样占窗口;输入超窗口直接 400,输入加 max_tokens 超窗口会在生成中途停下。
  2. 放得下不等于用得好:上下文越长整体越差,中间的信息最容易被忽略。资料多时先检索,资料少时全塞可能更好,关键信息放两端。
  3. 单次请求按“必需项、优先级、去重、稳定在前”组装;工具输出保留头尾,完整内容放到上下文之外按需读取。
  4. 长任务用清理、压缩、笔记管理上下文:只清不记会忘事,摘要的质量就是上限,每次改写都打掉缓存,所以要少改写、一次改够、改之前先记下来。
  5. 提示词结构分块、稳定在前;规则写目标和理由,说该做什么;工具描述也是提示词。
  6. 提示词当代码管:版本管理、评测集只增不减、每次修改逐个用例看修好和弄坏了什么。

模块 01 到这里结束。下一个模块讲工具调用与协议:Function Calling 的机制、并行调用、MCP 和 A2A。


练习(今天做完)

练习 1:跑通并读懂实验输出

cd projects/11-context
make test

对照第 5 节和第 10 节的表,在测试日志里找到这些数字,并回答:

  1. 把 DefaultRecall 的 Middle 改成 0,“200 篇全塞、答案在中间”和“答案在最后”还有区别吗?剩下的差距来自哪里?
  2. 把 DefaultSim 的 Keep 从 5 改成 15,“清旧工具结果”策略丢失的事实会变少吗?峰值会怎样?为什么?
  3. 摘要压缩的输入当量为什么比“清除前记笔记”低一半多?

练习 2:结构化渲染系统提示词

写一个类型和函数:

type PromptSpec struct {
    Role, Task string
    Rules      []string
    Examples   []string
    Output     string
    Dynamic    map[string]string // 每次请求都会变的内容
}

func Render(p PromptSpec) (string, []string)

要求:

  1. 按 role、task、rules、example、output_format、context 的顺序渲染成 XML 标签,Dynamic 放在最后的 context 里,键按字母排序(输出必须稳定,否则缓存失效);
  2. 返回三类警告:稳定区出现了模板变量(形如两个花括号包住的变量名);没有写输出格式;规则以“不要”开头,但没有写该做什么(没有“而是”或“改为”)。

测试:一个三类问题都有的 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,建议压缩。想一想:为什么这里必须把缓存的两个字段也加进去?


自测题

  1. 上下文窗口里包括哪些东西?缓存命中的 token 算不算?
  2. 窗口 100 万,输入 95 万,max_tokens 128k,在 Anthropic 4.5 以后的模型上会发生什么?在更早的模型上呢?
  3. “长度退化”和“位置效应”分别是什么?对你排列上下文有什么影响?
  4. 什么情况下“把资料全塞进去”比“先检索”更好?
  5. 为什么工具输出不能只保留开头?更好的做法是什么?
  6. 只清理旧工具结果、不做别的,会带来什么问题?怎么修?
  7. 为什么上下文清理和压缩会影响缓存?clear_at_least 解决的是什么问题?
  8. 规则“不要输出客套话”有什么问题?怎么改写?
  9. 为什么对 2026 年的模型,不建议在提示词里大量使用“必须”“CRITICAL”这样的强调?
  10. 两个提示词版本通过率一样,能说明修改没有影响吗?应该看什么?

参考答案与详解

自测题答案

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

  1. Middle 改成 0 后位置效应消失,答案在中间和在最后的召回一样(都是 0.261,也就是 80 万长度下的整体召回)。剩下的差距(和检索 8 篇的 0.818 相比)完全来自长度退化。
  2. 会,丢失从 11/12 降到 0/12:需要的事实来自 12 轮之前,现在最近 15 轮的工具结果都保留着。但代价很大:峰值从 59550 涨到 89450,离 10 万的窗口很近,退化也更明显;而且保留得多,上下文一直贴着 6 万的阈值,几乎每一轮都要清一个工具结果,改写从 15 次涨到 45 次,输入当量从 1359425 涨到 4403188。这就是“保留多少”的权衡:留得多,忘得少,但更长、更贵,还更频繁地打掉缓存(配合 ClearAtLeast 能缓解后一个问题)。
  3. 两个原因:摘要压缩把旧轮次整个换成 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 接口拿准确的数字。


完成标准

© 2026 duang777 · 课件源文件:notes/01-llm/06-context-engineering.md