上下文工程:把有限的注意力花在刀刃上 · Lesson 6 of 6

第 6 课:实战:给 harness 装上上下文管理

学习目标:

  • 能给一个 stop_reason 驱动的 harness 循环接上 token 用量追踪:用 response.usage 累加,判断上下文是否逼近本轮窗口的上限
  • 能把第 4 课的 compact() 接成阈值触发的机制:定好触发比例,想清楚触发后 messages 和用量计数器该怎么重置
  • 能把结构化笔记接入这条压缩流程,让 NOTES.md 在每次重启新窗口时被读回来兜底,并跑通一个超出单窗口容量的长任务 前置要求:完成第 4 课的压缩与笔记、第 5 课的子代理隔离,手边能跑起本系列第 7 门课《Agent Harness 基础:循环与控制》写的 harness 循环 | 上一课 第 5 课 <<

先看它跑起来的样子

前五课都在讲道理:为什么上下文是有限资源、压缩怎么做、笔记怎么记、子代理怎么隔离。这一课把前两件——压缩、笔记——真正焊进你在本系列第 7 门课写的那个 harness 循环。先看它跑起来是什么样,再拆代码。

下面是一次真实执行的记录(用一个桩 client 模拟模型的多轮回复,好让长任务在几行日志里就能复现;桩 client 的写法本课最后会给出)。任务是第 4 课那个熟悉的场景:修一个订单服务的并发竞态 bug。为了让压缩在几轮内就能触发,这次演示把上下文窗口故意设得很小:

text
[call 1] stop_reason=tool_use 累计tokensUsed=400[call 2] stop_reason=tool_use 累计tokensUsed=1050[call 3] stop_reason=tool_use 累计tokensUsed=1850[call 4] >>> 触发压缩 (第 1 次), tokensUsed 重置为 0[call 5] stop_reason=tool_use 累计tokensUsed=280[call 6] stop_reason=end_turn 累计tokensUsed=490
最终回复: 已给 orders 表加 version 字段并接入乐观锁, 竞态 bug 修复完成。模型调用总次数: 6 (含 1 次压缩调用)压缩触发次数: 1
NOTES.md 最终内容:## 已定决策- 修复方案: 数据库乐观锁 (orders 表加 version 字段)## 未解决- (无 -- updateStatus 竞态已随乐观锁合入解决)

逐行读一遍:前三次调用一路把 tokensUsed 从 400 累到 1050 再到 1850;第三轮工具结果追加进历史之后,累计值越过了设定的阈值,harness 没有等窗口真的爆掉,而是主动打了一次压缩调用(call 4,stop_reason 已经不重要了,因为这次响应根本不进主循环,它的产出直接被用来重启 messages);tokensUsed 归零;后面两轮在新窗口里重新计数,直到模型收尾。整个过程只有一件事对用户可见——最终那句回复;压缩和笔记发生在幕后。这一课剩下的篇幅,就是把这段日志背后的代码一行行搭出来。

一、给循环装上 token 用量追踪

第一步最朴素:知道自己已经用了多少 token,是判断要不要压缩的前提。你在本系列第 7 门课写预算阀(阀2)时已经用过这个字段——response.usage 里带着这次调用的 input_tokensoutput_tokens,每拿到一次响应就往一个累加器里加:

本系列第 7 门课的 TOKEN_BUDGET 阀用这个累加值做的是一件事:到顶就整个停手。本课要做的是另一件事:到某个更早的比例就主动压缩、继续干活,而不是停。两者用的是同一个累加器,触发之后的动作完全不同——一个是刹车,一个是换气。

窗口本身多大,交给一个常量,工程上自己定:

COMPACT_RATIO 定多少没有标准答案——这是一条工程判断,不是规范条款。定得太高,等你判断出「该压缩了」的时候窗口可能已经很挤,下一次请求都未必发得出去;定得太低,压缩会比该发生的更早、更频繁地打断任务,白白多花模型调用。经验上,留出三成左右的余量(也就是 0.7 这个阈值)通常够用,具体数字应该按你实际用的模型窗口大小和单轮工具输出的体积再调。

二、阈值触发压缩:把第 4 课的 compact() 接进循环

机制定了调,接下来是把它接进循环体。回忆第 4 课的结论:压缩不是把摘要塞回旧对话接着挤,而是用摘要重启一个新窗口——旧的 messages 整个放弃1。放进循环里,就是在合适的时机把 messages 整个替换掉:

三个位置决定了这段代码对不对:

  • 检查放在哪一步:紧跟在这一轮的工具结果追加进 messages 之后、下一次 client.messages.create 之前。太早(比如追加之前就判断)会漏算刚产生的这批工具输出;太晚(判断完才追加)则会让已经超线的这批内容白白多发一次请求。
  • 压缩后 messages 是整个替换,不是追加compact() 的返回值直接赋给 messages,旧的那个数组连同它装的几十条工具往返一起被丢弃——这是「重启」和「继续在旧对话里堆」的分界线。
  • tokensUsed 必须归零:新窗口从一份摘要开始,用量应该从这份摘要说起重新计数,而不是继续背着旧窗口的累计值。漏了这一步是个常见的坑,本课练习会专门诊断它。

compact() 本身沿用第 4 课的写法,COMPACT_INSTRUCTION 和取舍原则(保留架构决策、未解决的问题、关键实现细节,丢弃冗余工具输出)都不变1。下一节会给它加一处新能力:重启时不止读摘要,还把 NOTES.md 一起带上。

三、结构化笔记兜底:NOTES.md 在压缩里被读回来

压缩是被动的、事后的——它总结的是「触发那一刻窗口里还剩什么」。第 4 课已经讲过,笔记是主动的、随写随存的补丁:让 Agent 在做出决策、发现问题的当下就把它写到窗口之外的 NOTES.md1。两者接在一起的方式很直接:新窗口重启时,除了读摘要,还应该把 NOTES.md 读回来——这样即便这一次总结的取舍出了偏差,笔记里还有一份独立的底。

先给 Agent 一个能写笔记的工具:

update_notesinput 是这次要保存的完整笔记内容,工具实现直接整份覆盖写入——这是最简单的语义:Agent 自己维护一份完整的笔记正文,每次更新都是「这是现在的全貌」,不用处理增量合并。系统提示里配一条要求:「每当做出重要决策、发现新问题、或完成一个阶段,先调用 update_notes 更新笔记再继续」,写法和第 4 课一致。

然后是这一课新加的一步:compact() 在生成摘要之后,顺手把 NOTES.md 也读进重启消息里:

新窗口醒来时手里有两份材料:模型自己总结的摘要,和 Agent 亲手写下的笔记。前者可能因为总结取舍而丢掉细节,后者是无损的——这就是第 4 课说的「笔记记得越勤,压缩丢东西的后果就越轻」在代码里的样子。

四、拼起来:一次超出单窗口容量的长任务

三块零件——用量追踪、阈值触发的压缩、读写 NOTES.md——放进同一个 runAgent,就是本课开头那段日志背后的完整代码:

这就是本课开头日志的完整来源:三次工具调用把 tokensUsed 从 400 推到 1050 再到 1850,越过 2000 * 0.7 = 1400 这道阈值线,compact() 被调用、messages 整个替换、计数器归零;随后在新窗口里再走两轮,模型收尾。全程只发生了一次压缩,但任务本身如果继续做下去、再撞到阈值线,同一套逻辑会再触发第二次、第三次——shouldCompact 不关心这是第几次窗口,它只看当前这个窗口的用量。这正是「跑通一个超出单窗口容量的长任务」的含义:任务的总长度不受限于某一次窗口的容量,受限的只是「一次连续不中断的推理」。

要跑一次完整验证,接上一个模拟模型多轮回复的桩 client(真实调用时把它换成 new Anthropic() 即可,runAgent 的代码不用改一个字):

用这样一份桩 client 验证的意义在于:它把「模型这一轮会不会调工具、用了多少 token」完全钉死成已知量,于是压缩到底在第几轮触发、tokensUsed 到底归不归零、NOTES.md 到底有没有被写进又读出来,全都可以拿断言核对,而不用靠肉眼看真实调用的输出去猜。

分寸:不是所有任务都要这套机关

装完这一整套,容易生出一种错觉:往后写 Agent 就该默认带上用量追踪、阈值压缩、结构化笔记这三件套。回到第 2 课就定下的分寸:只在额外的复杂度能被证明确实改善结果时,才考虑增加它2。十几轮内能跑完的任务,压缩和笔记都是多余的零件——先从本系列第 7 门课那个裸循环加基本控制阀开始,真撞到窗口上限、真出现「新窗口不知道旧窗口做过什么」的失忆症状,再把本课这一层往上装。

到这里,这门课从第 1 课到第 6 课讲的东西——注意力预算、系统提示的高度、即时检索、压缩与笔记、子代理隔离——都汇到了同一个落点:每一轮该让模型看到什么,永远是一个要不断权衡的工程判断,不是一次性配置完就一劳永逸的开关。

小结

  • 给 harness 装用量追踪只需要一个累加器:每次拿到响应就用 response.usage.input_tokens + response.usage.output_tokens 往上加;它和本系列第 7 门课的 TOKEN_BUDGET 阀共用同一份数据,但触发之后的动作不同——预算阀到顶即停,本课的阈值到点即压缩、继续干活。
  • 阈值触发的压缩把第 4 课的 compact() 接进循环体:检查放在「这一轮工具结果追加完毕、下一次请求发出之前」;触发后 messages 被整体替换成压缩结果——是重启,不是追加1tokensUsed 必须同步归零,否则会陷入不断重复压缩的风暴。
  • 笔记与压缩在这一课接成了一条线:update_notes 工具随写随存——这正是把笔记持久到上下文窗口之外1——compact() 再在生成摘要之外顺手把 NOTES.md 读回重启消息;摘要可能因总结取舍丢内容,笔记则是被原样读回的无损副本。「只在窗口重启这类关键时刻读一次、不塞进每一轮的系统提示」是本课基于注意力预算原理(每个新增 token 都在消耗这份预算1)做出的工程取舍。
  • 一次真实跑通的验证显示:三次工具调用把用量从 400 累到 1850、越过阈值触发一次压缩、计数器归零、再跑两轮收尾——任务的总长度不再受限于单次窗口的容量,受限的只是「一次连续不中断的推理」。
  • 别把这套机关当成默认配置:只有当额外复杂度能被证明确实改善结果时才值得加上2;跑几轮就能收尾的任务,本系列第 7 门课那个裸循环加基本控制阀就够了。

Footnotes

  1. Effective context engineering for AI agents — Anthropic Engineering — https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents 2 3 4 5 6

  2. Building Effective AI Agents — Anthropic Engineering — https://www.anthropic.com/engineering/building-effective-agents 2

Exercises

01

某个 harness 配置为 CONTEXT_WINDOW = 6000、COMPACT_RATIO = 0.75。任务跑起来后,连续四轮的单轮用量(input_tokens + output_tokens 之和)依次是:1200、1500、900、1100。请回答:

Level 1:追踪一次压缩的触发时机
  1. 压缩会在第几轮结束后被触发?触发那一刻的累计 tokensUsed 是多少?
  2. 压缩触发并完成之后,tokensUsed 应该是多少?
  3. 如果压缩之后紧接着的下一次模型调用返回 usage = { input_tokens: 300, output_tokens: 100 },这时 tokensUsed 变成多少?
Done criteria · checked locally
02

有人把阈值触发的压缩接进了循环,但漏了一行:

Level 2:诊断一次「压缩风暴」

接入之后,任务跑到某一轮第一次触发了压缩;但从那之后,每一轮都会再触发一次压缩,哪怕新窗口里其实只进行了一两轮、根本没攒下多少内容。请解释这是为什么,并给出修复。

Done criteria · checked locally

My note

Jot down thoughts, sticking points, things you didn't get. Written to this course's appendix only — the lesson file is never touched.