合集

Claude Agent SDK 实战

已更新 27 章
阅读进度 26%

第 6 章

上下文:模型每一轮到底看见了什么

Agent 变笨了?先打印它看到了什么。

第 5 章它被权限拦了三次,最后改对了 core.py。

但我得说句实话:那次它只是碰巧改对。它没读 README,也没读第二个测试。它看到第一个测试失败,就把 days_left 改成了“永远返回 0”:

python
def days_left(task: Task, today: date) -> int:
    if task.due is None:
        return 0
    return 0          # 第一个测试过了,第二个测试挂了

第一个测试(到期当天返回 0)过了;第二个测试(未来日期返回 3)挂了。它不知道第二个测试存在,因为它没读。

我一开始以为是提示词不够狠。我写了三遍“先读测试”,没用。后来我打印了它真正看到的东西:

text
system: 354 chars
tools:  1680 chars
task:   68 chars
history: 0 chars

README 不在里面,第二个测试也不在里面。

模型不是全知的。它只能基于你放进上下文的东西做决定。 这一章不是重复“上下文很重要”,而是算清楚:每一轮,钱花在哪,规则放在哪,什么时候会被挤掉。


6.1 一次请求的解剖

一次模型调用,模型看到的是四样东西:

部分这次的大小里面有什么
system354 字符角色、工作流、项目规则
tools1,680 字符六个工具的 name/description/input_schema
task68 字符“修好 tests/test_core.py”
history0 字符之前的消息和工具结果

总计 2,102 字符。

这是第 6 章 demo 的真实输出(字符数,不是 token)。先别急着看总数,看比例:

text
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 字符占这次请求
只读三件套78237%
六个工具1,68080%

少三个写/跑工具,schema 少一半多。

这不是说工具描述不该写清楚。恰恰相反:描述要写清楚“什么时候用”,但不要写小说。一个工具描述多 100 字,六个工具就是 600 字,每一轮都付。

第 11 章会讲工具治理。这里先记住一个判断:

当你觉得“Agent 变笨了”,先看工具 schema 是不是比系统提示还大。如果是,先做减法。

少一个工具,可能比改十遍系统提示更有效:上下文省了,模型的选择也少了。


6.3 项目知识放哪:短规则常驻,长文档按需

项目知识一般有三种:

  • README.md:项目怎么跑、有什么约定;
  • AGENTS.md / CLAUDE.md:给 Agent 的规则;
  • 代码本身:目录结构、命名、测试。

最容易犯的错,是把整个 README 或 CLAUDE.md 塞进系统提示。一次两次没事;项目一大,几千字常驻,每轮都付钱,而且模型不一定看。

假设你的 README 有 1,200 字,跑 20 轮:

做法每轮常驻20 轮总额优点缺点
全量塞 system1,20024,000模型一定看得到每轮都付;长文淹没规则
短规则 + 按需读801,600 + 一次读取便宜;可更新模型可能不读

数字是字符估算,不是 token;重点是量级。

我的做法是:

内容放哪为什么
“改实现,不改测试”系统提示的短规则每轮都要,必须常驻
“跑测试再结束”系统提示的短规则同上
项目结构、命令、长约定README,按需 read_file只在需要时读,省上下文
当前任务user 消息只出现一次
时间、进度volatile 层,放最后每轮变,不能放前缀

短规则常驻,长文档按需。这不是“偷懒”,是“把预算花在刀刃上”。

但“按需”有个前提:模型得知道要去读。你可以在系统提示里写一句“改代码前先读 README”,在工具描述里写“改之前先读”,再用 Stop Hook 强制它跑测试。第 13 章会讲 CLAUDE.md 和 setting_sources;第 15 章会用 Hook 把“读”变成流程的一部分。


6.4 稳定前缀与缓存:一个时间戳的代价

大多数模型服务会缓存“前缀”。你的系统提示、工具 schema、历史,如果每轮都一样,只有最后一段变,缓存就能命中。

画出来是这样:

text
请求 1:  [system 354][tools 1680][history 0][task 68]
请求 2:  [system 354][tools 1680][history 300][task 68]
                                            ↑ 只有这里变了

前缀 2,034 字符没变,可以命中缓存;变的只有 history 和 task。

但如果你把时间戳放在系统提示第一行:

text
请求 1:  [Date: 2026-10-21][system ...][tools ...]
请求 2:  [Date: 2026-10-22][system ...][tools ...]
          ↑ 第一个字符就变了

前缀从第一个字符开始就不同,后面全部 miss。

这不是玄学,是账单。第 18 章会算这笔账;这里先记住:

稳定的放前面,易变的放后面,历史只往后追加。

Context 里的 volatile 就是干这个的:时间、进度、当前分支,全部放最后,不碰前缀。


6.5 上下文腐化:第 18 轮它忘了规则

就算你一开始组装得很好,跑久了也会坏。

一个长任务的上下文变化,大概是这样:

轮次history模型最需要的东西它实际在哪
10任务、规则system + task,很清楚
104k上一次失败的测试输出最后一条 tool_result
189k“不要改测试”system 第 5 行,已经被大量工具结果包围
2515k当前目标第一条 user,可能已经被截断/摘要

三种腐化:

第一种,历史越来越长。 每一轮的工具结果都追加,读一个 500 行的文件,就是 500 行进历史;读十个,就是五千行。模型要在里面找“当前任务”,越来越难。

第二种,无关信息越来越多。 失败的尝试、被拒绝的调用、读错的路径,都留在历史里。模型可能把旧的失败当成当前状态,反复试同一个错。

第三种,关键约定被挤掉。 上下文窗口满了,系统提示还在,但中间的历史被截断;模型忘了“不要改测试”,又开始走捷径。

第 7 章会讲压缩和笔记;这一章先记住:不是模型变笨了,是它看到的东西变脏了。


6.6 修好后的上下文:把规则放到该在的位置

第 6 章的 demo 用 Context 组装系统提示:

python
@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:时间、进度、当前分支。每轮都可能变,放在最后。

跑一遍:

bash
cd code
python3 -m tiny.agent_context --scripted

输出(节选):

text
[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 变笨,别先改提示词。先回答这六个问题:

  1. system 里有多少是真正的规则? 角色设定、寒暄、重复三遍的“请注意”,都可以删。
  2. tools 有几个?schema 多大? 如果 tools 比 system 大,先做减法。
  3. 项目知识是常驻还是按需? 长文档常驻,就是在每轮为它付钱。
  4. 稳定前缀里有没有时间戳/随机数? 有的话,缓存全 miss。
  5. history 多长?有多少是原始工具输出? 大文件、大日志,该截断的截断。
  6. 这一轮模型最需要的东西,在第几段? 如果它在第 20 段,模型很可能看不到。

这六个问题,比“再写一版提示词”有用。


6.8 下次 Agent 变笨,先打印这四行

python
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。