本节摘要:把 3.3 节的"预算分配"四个字展开成可执行的工程。窗口是零和游戏,先扣不可动的三块:常驻层(L1 系统提示 + L3 工具 schema,"常驻税")、输出预留(20%,供料塞满的第一个症状是模型说话越来越短)、pinned 清单(任务目标、当前计划、红线约束——丢了它们,后面全白干)。剩余预算按优先级装配动态层;优先级的工程形态不是一张"谁更重要"的表,而是驱逐顺序(eviction order):超限时从最廉价的层开始丢——陈旧工具结果最先丢,已完成子任务的中间历史次之,探索性对话再次,最近 K 轮最后丢;每层内部还要"丢大块保线索"(截断长输出而非整条删除)。本节给出
budget.py,并交代 token 估算的示意做法与升级路径。
阅读完本节,你应当能够:
以"中型仓库里修 bug 并跑测试"为例(示意数字,总窗口按 128k 计):
| 块 | 内容 | 预算占比(示意) | 性质 |
|---|---|---|---|
| L1 系统提示 | 身份、环境、约束 | 3~5% | 常驻税 |
| L3 工具 schema | 全部工具的契约清单 | 8~12% | 常驻税(4.2 节路由可节税) |
| 输出预留 | 模型本轮生成空间 | 20% | 不可动(3.3 节第一课) |
| pinned 清单 | 任务目标 / 当前计划 / 红线约束 | 2~5% | 不可动 |
| L2 任务与历史 | 对话与决策记录 | 剩余的 1/3 | 动态 |
| L4 工具结果 | 文件内容、命令输出 | 剩余的 2/3 | 动态(无限增长层) |
| L5 环境状态 | 文件树、git 状态 | 按需 5% | 动态 |
两条读表结论:其一,常驻税 + 输出预留合计约 1/3——供料策略真正能调配的只有 2/3 窗口,这是很多人配预算时漏掉的前提;其二,L4 注定是主战场——它是唯一无限增长的层,所有驱逐策略首先冲它去。
说"优先级高"太空洞,工程上直接写谁先丢:
| 顺位 | 被驱逐的层 | 为什么它最先丢 | 丢了之后的补偿 |
|---|---|---|---|
| 1 | 陈旧工具结果(旧日志、已读过的文件原文) | 时效最差:文件还在盘上,可重读 | 引用化:留"路径 + 一行摘要"(3.3 节) |
| 2 | 已完成子任务的中间历史 | 结论已沉淀进计划/摘要,过程无价值 | 结论并入 pinned 的计划 |
| 3 | 探索性对话(早期试错讨论) | 信息密度低 | 关键发现并入任务简报(7.2 节) |
| 4 | 环境状态快照 | 可重新探查 | 下一步按需再取 |
| — | pinned + 最近 K 轮 | 不驱逐:任务目标丢了等于失忆;最近 K 轮丢了模型接不上话 | — |
pinned 清单的最小集合:任务原文、当前计划(含 TodoWrite 类产物)、红线约束("不许做 X"的约定)、关键文件的引用化条目。第四项最容易被忽略——红线条目丢了,模型可能把 7.2 节说的"为什么不做 X"的约束忘掉,转个圈又把 X 做了。
驱逐不等于整条删除。同一条 3000 token 的测试输出,整条删除丢掉的信息远多于截成"首 200 token + [中间截断] + 尾 200 token"——尾部尤其重要:pytest、编译器的结论(成败、错误摘要)几乎总在末尾。这就是 6.2 节 sandbox_exec 做"首尾保留、中间截断"的预算学依据。
budget.py:逐层驱逐的实现# budget.py —— 窗口预算与驱逐顺序(写法示意,以实际工程为准) def est_tokens(msgs: list[dict]) -> int: """粗估 token 数(示意):中英混排按字符数 / 3;生产应换分词器精确计数。""" return sum(len(str(m.get("content") or "")) for m in msgs) // 3 # 驱逐顺序:越靠前越先丢;不在表内的层默认保留 EVICT_ORDER = ["stale_output", "done_history", "old_chat", "env_state"] def fit_budget(msgs: list[dict], budget: int, reserve: float = 0.2) -> list[dict]: """超预算时按层驱逐;pinned 与最近 K 轮(recent)永不丢。""" cap = int(budget * (1 - reserve)) # 输出预留(3.3 节第一课) out = [dict(m) for m in msgs] # 每条消息需带 "layer" 标注 for layer in EVICT_ORDER: if est_tokens(out) <= cap: return out out = [m for m in out if m.get("layer") != layer or m.get("pinned")] for m in reversed(out): # 还超:丢大块保线索(尾部敏感) if m.get("layer") == "stale_output" and not m.get("pinned"): c = str(m.get("content") or "") m["content"] = c[:200] + "\n…[中间截断]…\n" + c[-200:] if est_tokens(out) <= cap: break if est_tokens(out) > cap: raise RuntimeError("pinned 与近期轮次已超预算:该压缩了(7.2 节)") return out
两个实现要点:layer 标注在写入时打——工具结果落进 messages 的那一刻就该标 layer: "stale_output"(随年龄升级,如 10 轮后从 fresh 转 stale),驱逐器只做机械筛选;估算精度——字符数 / 3 是示意,生产换所用模型的分词器,或直接用 API 返回的用量校准(第 9.3 节账本顺便提供校准数据)。
💡 驱逐是"每一步都可能发生的小事",压缩是"水位告警后的大扫除"——驱逐解决日常超支,压缩解决结构性不够用。下一节讲后者。
逐层驱逐能撑过日常超支,但长任务迟早触到水位线——那时候需要的不是再截一段,而是把历史整体换一种形态。下一节:压缩与摘要。