2026-06-11 · harness

第 3 章:上下文工程

把上下文窗口当作一份预算,刻意决定什么放进去、什么留在外面。

小满 · 记忆迷宫

小满读得越来越多,脑子快塞满了。今天它得学会,什么该记、什么该丢。

草稿章节。跑通格式用的第一版,正式索引前会再打磨。

本章目标

为第 2 章的 PR 审查助手定一套上下文策略。你写的那个回路会不停往里加消息:系统提示、任务、每一次工具调用、每一份工具结果。不管它,消息会无限制变多,而且变多本身就会让模型变笨。这章你会写一个 assemble_context 函数,把窗口当成固定预算来分配;写一个 compact 步骤,把老旧的轮次总结掉;再加一份小小的记忆文件,让审查助手重置之后还能读回来。最后做出来的东西是:一个能审完 40 个文件的 pull request、中途不跑题的审查助手。

核心观念直接来自 Anthropic 的文档:上下文是有限资源,而且加得越多,回报越少。模型有一份”注意力预算”,token 越多,这份预算就被分得越散。多加 token 不是免费的,常常反而有害。

前置准备

  • 完成第 2 章:带 read_file 工具、维护着一条消息列表的可用最小回路。
  • 大致了解你所用模型的上下文窗口大小和每个输入 token 的成本,详见所用模型的官方文档。
  • 知道 token 不是越多越好,相关的 token 才更好。这章会讲清为什么。

原理

Transformer 是两两注意的:每个 token 都能看到其它每个 token,所以注意力的计算量随序列长度的平方增长。提示词越长,这份固定的注意力预算就要分给越多的关系,模型从里面挑出那一行关键内容的能力就越差。这个现象现在有个名字叫 “context rot”(上下文腐烂),指的是输入越长,检索和推理准确率实测下降。说白了就是:一个 5,000 token、刚好带着正确 diff 的提示词,比一个 90,000 token、把同一份 diff 埋在四十个无关文件里的提示词要好。你的任务不是把窗口填满,而是找出能让模型把下一步做对的、最小的那批高信号 token。

动手做

1. 给预算命名,再切块

选一个远低于硬上限的工作上限,再把它切成几块、每块起个名字。给模型的回复留出空间(输出 token 也占额度),再留一道安全余量,免得一个大工具结果就把请求撑爆。把预算写下来,含糊的”别用太多”就变成了一个你能在测试里断言的具体数字。

PR 审查助手的上下文预算(示意,200k 窗口)
+-----------------------------+----------+-----------------------------------+
| 切片                        | 预算     | 说明                              |
+-----------------------------+----------+-----------------------------------+
| 系统提示 + 工具规格         |  2,000   | 稳定,写一次                      |
| 任务 + PR 元数据            |  1,000   | 钉住,压缩中存活                  |
| 记忆 / 当前发现             |  2,000   | 滚动笔记,重置后读回              |
| 待审 diff                   | 20,000   | 按片检索,不整粘贴                |
| 检索到的文件上下文          | 15,000   | 只取 diff 触及的文件              |
| 近期工具调用记录            | 40,000   | 最近 N 轮原样保留                 |
| -- 压缩阈值 --              | 80,000   | 记录越过此线就压缩                |
| 预留给模型输出              | 16,000   | 永远不花这部分                    |
+-----------------------------+----------+-----------------------------------+

2. 内容分层,从上往下填

把所有可能进提示词的东西分成三层。必备:任务说明和待审的 diff 块。有用:diff 碰到的定义和调用点。可选:完整文件历史、无关模块、整个仓库。从最高层往下填,预算花完就停。可选层很少值得它占的那些 token。

def assemble_context(task, diff, memory, transcript, budget):
    parts = []
    parts.append(SYSTEM_PROMPT)            # 第 0 层:永远在
    parts.append(render_task(task))        # 第 1 层:钉住、必备
    parts.append(render_memory(memory))    # 第 1 层:持久发现
    parts.append(render_diff(diff))        # 第 1 层:待审之物
    # 第 2 层:只拉 diff 引用到的文件,直到预算用尽
    for f in files_referenced_by(diff):
        chunk = read_relevant_slice(f, diff)
        if tokens(parts) + tokens(chunk) > budget.context_ceiling:
            break
        parts.append(chunk)
    # 第 3 层(历史、邻居)除非被要求,否则有意省略
    parts.extend(recent_turns(transcript, budget.transcript))
    return join(parts)

3. 用到再取,别一股脑全塞

让一个文件出现在模型面前有两种办法:提前塞进提示词,或者给模型一个轻量标识(一个路径),让它用工具自己去拉需要的片段。优先用后者。人类审查者就是这么干的:你不会从头读整个仓库,而是打开 diff 提到的文件、跳到改动的那个函数。预加载只适合又小又稳定的引用(一份风格指南、PR 标题);只要东西大或者很少用到,用到再取更好。Claude Code 自己就是这么混着用的:提前加载一份小小的 CLAUDE.md,其余的全部在运行时用 grep/glob 现取。

import anyio
from claude_agent_sdk import query, ClaudeAgentOptions

# 反模式:把每个改动文件整份读出来塞进 prompt,淹没信号、烧光预算
files = ["src/payments/refund.py", "src/payments/gateway.py", "..."]  # 全部 40 个
dump = "\n\n".join(open(p).read() for p in files)

async def main():
    async for message in query(
        prompt=f"审查这些文件的 bug 和风险:\n{dump}",  # 一次性倒进窗口
        options=ClaudeAgentOptions(allowed_tools=[]),    # 不留检索能力
    ):
        handle(message)

anyio.run(main)

# 正模式:只给路径清单,放行 Read/Grep/Glob,让模型按需自取片段
async def main():
    file_list = ", ".join(["src/payments/refund.py", "src/payments/gateway.py", "..."])
    async for message in query(
        prompt=f"审查这个 PR 的 bug 和风险。改动文件:{file_list}。"
               f"逐个用 Read 取你需要看的片段,不要一次读完所有文件。",
        options=ClaudeAgentOptions(
            allowed_tools=["Read", "Grep", "Glob"],  # 即时检索,模型自取
            cwd="./repo",
        ),
    ):
        handle(message)

anyio.run(main)
import { query } from "@anthropic-ai/claude-agent-sdk";

// 反模式:把每个改动文件整份读出来塞进 prompt,淹没信号、烧光预算
const files = ["src/payments/refund.py", "src/payments/gateway.py", "..."]; // 全部 40 个
const dump = files.map((p) => readFileSync(p, "utf8")).join("\n\n");

for await (const message of query({
  prompt: `审查这些文件的 bug 和风险:\n${dump}`, // 一次性倒进窗口
  options: { allowedTools: [] },                  // 不留检索能力
})) {
  handle(message);
}

// 正模式:只给路径清单,放行 Read/Grep/Glob,让模型按需自取片段
const fileList = ["src/payments/refund.py", "src/payments/gateway.py", "..."].join(", ");
for await (const message of query({
  prompt: `审查这个 PR 的 bug 和风险。改动文件:${fileList}。` +
          `逐个用 Read 取你需要看的片段,不要一次读完所有文件。`,
  options: {
    allowedTools: ["Read", "Grep", "Glob"], // 即时检索,模型自取
    cwd: "./repo",
  },
})) {
  handle(message);
}

4. 到阈值就压缩,别等溢出才动手

当运行中的记录越过压缩阈值(表里是 80k,不是 200k 上限)时,把最旧的几轮总结成一段紧凑笔记,原地替换掉。先保召回:留住架构决策、已确认的 bug、未决问题;丢掉那些已经处理过的冗长工具输出。最近几轮原样保留,因为那是模型正在干活的地方。

def maybe_compact(messages, budget):
    if tokens(messages) < budget.compact_threshold:
        return messages
    old, recent = split_keeping_last(messages, n=budget.keep_recent_turns)
    summary = model.summarize(
        old,
        keep=["已做的决策", "已确认的 bug", "已审过的文件", "未决问题"],
        drop=["原始文件转储", "被推翻的推理"],
    )
    return [PINNED_TASK, PINNED_MEMORY, as_note(summary), *recent]

5. 钉住关键事实,并存一份外部记忆

有些事实绝不能被总结掉:任务、验收标准、不断更新的发现清单。把它们钉在固定位置,并把发现写进一份外部笔记(类似 REVIEW_NOTES.md 这样的文件),让 agent 在每次压缩或重置后重新读回来。这就是结构化笔记的做法:模型把记忆写到磁盘上,需要时再加载回来,这样一次长审查就算上下文被重置也不会丢,就像人去倒杯咖啡回来后看一眼笔记本接着干。

# 每审完一个文件,往记忆里追加一行持久记录
memory.append(f"- {path}: 发现 {finding}; 严重度 {sev}; 建议 {fix}")
write("REVIEW_NOTES.md", memory)
# 下一轮回路,assemble_context() 把 REVIEW_NOTES.md 重新读进第 1 层

6. 测一测有没有跑偏

每隔一阵在提示词里重述一遍目标,看 agent 是不是还在照着它做。跑偏指的是:记录被工具噪声塞满,模型慢慢忘了原来的任务。一个便宜的探针:每隔几轮,让模型复述一下它在审什么、已经发现了什么。要是回答开始飘了,说明你压缩时丢得太多,或者任务没钉牢。

习得「取舍」小满审一个四十文件的 PR 时,不再把整个仓库塞进窗口,而是按预算分层装,只拉 diff 真正碰到的文件,旧轮次到阈值就压成一段笔记。

如何验证

  • 对一个大 diff(20 个以上文件)跑一次审查。在测试里断言:跑了若干次工具调用之后,tokens(assemble_context(...)) 还是低于你设的上限。
  • 在一条长记录上故意触发压缩,确认总结里还留着任务、已确认的 bug 和未决问题。把压缩前后的发现清单做个 diff:持久层里的东西一个都不该消失。
  • 同一个大 PR 跑两遍:一遍只是简单追加,一遍带压缩加记忆。带压缩的那遍应该能更久地守在任务上,而且用更少的 token 得出同样的结论。
  • 探一下跑偏:在长运行快结束时问一句”你现在在审什么?“。回答应该还跟原任务对得上。

习得「确认没漏」你能在长记录上手动触发一次压缩,再 diff 压缩前后的发现清单,确认任务、已确认的 bug、未决问题一条都没被总结掉。

小结

你现在把上下文当成一份要管理的预算,而不是随便往里扔东西的地方。你给预算命了名、切了块,把内容分了层、从上往下填,用到再取而不是一股脑全塞,在没到上限的阈值处就压缩,把关键事实钉进外部记忆,还做了一个跑偏探针。底下的道理很简单:注意力预算有限,加上注意力是平方级的,所以信号密度比体量更重要。长 agent 任务跑偏就是这么来的,上面这六步就是用来对付它的。下一章把这个审查助手打包成一个能被发现、能复用的 Agent Skill。

常见坑

  • 什么都往里塞。 把整个仓库放进提示词会淹没信号,还在每一轮都把成本抬上去。改成按需取片段。
  • 压缩太晚。 等窗口溢出才压缩,请求会直接失败。要在低于上限的阈值处就压,并给一个大工具结果留出余量。
  • 把任务也总结没了。 压得太狠会把目标或一个已确认的 bug 抹掉。把任务和关键决策钉在被总结的范围之外,让总结器优先保召回。
  • 预加载那些很少用到的东西。 “以防万一”把整份风格指南和每个相邻文件都加载进来,会把预算花在模型根本不读的 token 上。可选的上下文就让它保持可选。
  • 没有外部记忆。 没有笔记文件,每次压缩都是有损且不可逆的。把发现写到磁盘上,让 agent 能重新加载。

小满第一次主动忘掉一段它判断为不重要的上下文,结果漏看了关键的一行。它学会了取舍,也第一次尝到取舍的代价。记忆迷宫,亮了。

刚点亮 记忆迷宫 · 地图已点亮 4 / 16

同样的活它每次都从头摸索,你想把这门手艺固化下来。下一站:手艺作坊。

来源

  1. Anthropic:面向 AI agent 的有效上下文工程 · official
  2. Microsoft:AI Agents for Beginners · official
下一章 · 第 4 章 第一个 SKILL.md