第 6 章
上下文:模型每一轮到底看见了什么
Agent 变笨了?先打印它看到了什么。
第 5 章它被权限拦了三次,最后改对了 core.py。
但我得说句实话:那次它只是碰巧改对。它没读 README,也没读第二个测试。它看到第一个测试失败,就把 days_left 改成了“永远返回 0”:
def days_left(task: Task, today: date) -> int:
if task.due is None:
return 0
return 0 # 第一个测试过了,第二个测试挂了第一个测试(到期当天返回 0)过了;第二个测试(未来日期返回 3)挂了。它不知道第二个测试存在,因为它没读。
我一开始以为是提示词不够狠。我写了三遍“先读测试”,没用。后来我打印了它真正看到的东西:
system: 354 chars
tools: 1680 chars
task: 68 chars
history: 0 charsREADME 不在里面,第二个测试也不在里面。
模型不是全知的。它只能基于你放进上下文的东西做决定。 这一章不是重复“上下文很重要”,而是算清楚:每一轮,钱花在哪,规则放在哪,什么时候会被挤掉。
6.1 一次请求的解剖
一次模型调用,模型看到的是四样东西:
| 部分 | 这次的大小 | 里面有什么 |
|---|---|---|
| system | 354 字符 | 角色、工作流、项目规则 |
| tools | 1,680 字符 | 六个工具的 name/description/input_schema |
| task | 68 字符 | “修好 tests/test_core.py” |
| history | 0 字符 | 之前的消息和工具结果 |
总计 2,102 字符。
这是第 6 章 demo 的真实输出(字符数,不是 token)。先别急着看总数,看比例:
tools ████████████████████████████████████ 80%
system ████████ 17%
task █ 3%工具 schema 占了八成。
我们总觉得“系统提示写得好,Agent 就聪明”,但这次请求里,真正占地方的是六个工具的说明书。你写两百字角色设定,不如少给两个工具。
6.2 工具 schema 才是大头
把六个工具的 schema 摊开看,每个都有:
name:工具名;description:什么时候用、返回什么;input_schema:参数类型、必填项。
三个字段加起来,1,680 字符。系统提示只有 354。工具 schema 是系统提示的 4.7 倍。
如果把工具收窄到只读三件套(read_file / list_files / search,对应 SDK 的 Read / Glob / Grep),schema 是 782 字符:
| 工具集 | schema 字符 | 占这次请求 |
|---|---|---|
| 只读三件套 | 782 | 37% |
| 六个工具 | 1,680 | 80% |
少三个写/跑工具,schema 少一半多。
这不是说工具描述不该写清楚。恰恰相反:描述要写清楚“什么时候用”,但不要写小说。一个工具描述多 100 字,六个工具就是 600 字,每一轮都付。
第 11 章会讲工具治理。这里先记住一个判断:
当你觉得“Agent 变笨了”,先看工具 schema 是不是比系统提示还大。如果是,先做减法。
少一个工具,可能比改十遍系统提示更有效:上下文省了,模型的选择也少了。
6.3 项目知识放哪:短规则常驻,长文档按需
项目知识一般有三种:
README.md:项目怎么跑、有什么约定;AGENTS.md/CLAUDE.md:给 Agent 的规则;- 代码本身:目录结构、命名、测试。
最容易犯的错,是把整个 README 或 CLAUDE.md 塞进系统提示。一次两次没事;项目一大,几千字常驻,每轮都付钱,而且模型不一定看。
假设你的 README 有 1,200 字,跑 20 轮:
| 做法 | 每轮常驻 | 20 轮总额 | 优点 | 缺点 |
|---|---|---|---|---|
| 全量塞 system | 1,200 | 24,000 | 模型一定看得到 | 每轮都付;长文淹没规则 |
| 短规则 + 按需读 | 80 | 1,600 + 一次读取 | 便宜;可更新 | 模型可能不读 |
数字是字符估算,不是 token;重点是量级。
我的做法是:
| 内容 | 放哪 | 为什么 |
|---|---|---|
| “改实现,不改测试” | 系统提示的短规则 | 每轮都要,必须常驻 |
| “跑测试再结束” | 系统提示的短规则 | 同上 |
| 项目结构、命令、长约定 | README,按需 read_file | 只在需要时读,省上下文 |
| 当前任务 | user 消息 | 只出现一次 |
| 时间、进度 | volatile 层,放最后 | 每轮变,不能放前缀 |
短规则常驻,长文档按需。这不是“偷懒”,是“把预算花在刀刃上”。
但“按需”有个前提:模型得知道要去读。你可以在系统提示里写一句“改代码前先读 README”,在工具描述里写“改之前先读”,再用 Stop Hook 强制它跑测试。第 13 章会讲 CLAUDE.md 和 setting_sources;第 15 章会用 Hook 把“读”变成流程的一部分。
6.4 稳定前缀与缓存:一个时间戳的代价
大多数模型服务会缓存“前缀”。你的系统提示、工具 schema、历史,如果每轮都一样,只有最后一段变,缓存就能命中。
画出来是这样:
请求 1: [system 354][tools 1680][history 0][task 68]
请求 2: [system 354][tools 1680][history 300][task 68]
↑ 只有这里变了前缀 2,034 字符没变,可以命中缓存;变的只有 history 和 task。
但如果你把时间戳放在系统提示第一行:
请求 1: [Date: 2026-10-21][system ...][tools ...]
请求 2: [Date: 2026-10-22][system ...][tools ...]
↑ 第一个字符就变了前缀从第一个字符开始就不同,后面全部 miss。
这不是玄学,是账单。第 18 章会算这笔账;这里先记住:
稳定的放前面,易变的放后面,历史只往后追加。
Context 里的 volatile 就是干这个的:时间、进度、当前分支,全部放最后,不碰前缀。
6.5 上下文腐化:第 18 轮它忘了规则
就算你一开始组装得很好,跑久了也会坏。
一个长任务的上下文变化,大概是这样:
| 轮次 | history | 模型最需要的东西 | 它实际在哪 |
|---|---|---|---|
| 1 | 0 | 任务、规则 | system + task,很清楚 |
| 10 | 4k | 上一次失败的测试输出 | 最后一条 tool_result |
| 18 | 9k | “不要改测试” | system 第 5 行,已经被大量工具结果包围 |
| 25 | 15k | 当前目标 | 第一条 user,可能已经被截断/摘要 |
三种腐化:
第一种,历史越来越长。 每一轮的工具结果都追加,读一个 500 行的文件,就是 500 行进历史;读十个,就是五千行。模型要在里面找“当前任务”,越来越难。
第二种,无关信息越来越多。 失败的尝试、被拒绝的调用、读错的路径,都留在历史里。模型可能把旧的失败当成当前状态,反复试同一个错。
第三种,关键约定被挤掉。 上下文窗口满了,系统提示还在,但中间的历史被截断;模型忘了“不要改测试”,又开始走捷径。
第 7 章会讲压缩和笔记;这一章先记住:不是模型变笨了,是它看到的东西变脏了。
6.6 修好后的上下文:把规则放到该在的位置
第 6 章的 demo 用 Context 组装系统提示:
@dataclass
class Context:
role: str
rules: str
task: str
volatile: str = ""
def system(self) -> str:
parts = [self.role.strip()]
if self.rules.strip():
parts.append("## Project rules\n" + self.rules.strip())
if self.volatile.strip():
parts.append("## Current state\n" + self.volatile.strip())
return "\n\n".join(parts)四层:
- role:你是谁、怎么工作。稳定,每轮都一样。
- rules:项目规则,短而稳定。比如“改实现,不改测试;改完跑测试”。
- task:用户这一轮的任务。只出现一次,放在
user消息里。 - volatile:时间、进度、当前分支。每轮都可能变,放在最后。
跑一遍:
cd code
python3 -m tiny.agent_context --scripted输出(节选):
[context] {'system': 354, 'tools': 1680, 'task': 68, 'history': 0, 'total': 2102}
[context] system prompt head:
| You are a coding agent working in a small Python repository.
| ...
| ## Project rules
| - Fix taskflow/, not tests/.
[tool] read_file {"path": "README.md"}
[tool] read_file {"path": "tests/test_core.py"}
[tool] read_file {"path": "taskflow/core.py"}
[tool] edit_file {"path": "taskflow/core.py", ...}
-> edited taskflow/core.py
[tool] run_tests {}
-> OK
assistant: 修复完成。days_left 在到期当天返回 0,两个测试都通过。这次它读了四个东西:README、两个测试、实现。然后才动手。
注意,这是脚本让它读的。真实模型不一定这么自觉;你需要在提示里写“先读 README”,在工具描述里写“改之前先读”,甚至用权限/Stop Hook 强制它。
上下文不是“你给了什么”,是“它实际看到了什么”。 你给了工具,但它没调用;你写了 README,但它没读——对模型来说,等于不存在。
6.7 一份上下文审计清单
下次 Agent 变笨,别先改提示词。先回答这六个问题:
- system 里有多少是真正的规则? 角色设定、寒暄、重复三遍的“请注意”,都可以删。
- tools 有几个?schema 多大? 如果 tools 比 system 大,先做减法。
- 项目知识是常驻还是按需? 长文档常驻,就是在每轮为它付钱。
- 稳定前缀里有没有时间戳/随机数? 有的话,缓存全 miss。
- history 多长?有多少是原始工具输出? 大文件、大日志,该截断的截断。
- 这一轮模型最需要的东西,在第几段? 如果它在第 20 段,模型很可能看不到。
这六个问题,比“再写一版提示词”有用。
6.8 下次 Agent 变笨,先打印这四行
print("[context]", ctx.report(tools_chars=len(json.dumps(registry.to_api(), ensure_ascii=False))))
print("[tools]", len(registry.to_api()))
print("[history]", len(messages))
print("[last]", messages[-1])先看数字,再看内容。
如果工具 schema 是系统提示的五倍,你该先减工具,不是先改提示词。
本章代码 分层上下文:https://github.com/kevinkelin/claude-agent-sdk-book/blob/master/code/tiny/context.py 带上下文的 Agent:https://github.com/kevinkelin/claude-agent-sdk-book/blob/master/code/tiny/agent_context.py 离线运行:
cd code && python3 -m tiny.agent_context --scripted跑完恢复:git checkout -- code/lab/taskflow/taskflow/core.py说明:--scripted是离线替身,不是模型真实输出;字符数不是 token,真实用量看 usage。