上下文工程系列前三篇讲的都是"往上下文里放什么":窗口的物理极限、token 的计量方式、系统提示的信息架构。这一篇换一个视角——不先问放什么,先问放得起多少。Agent 的每一轮请求都是一张账单:system 提示、工具定义、历史消息、检索注入、输出预留,五个科目各占多少、谁在膨胀、谁在浪费,构成了上下文工程里最"工程"的一篇。
把上下文当财务问题管理,核心动作只有三个:先立预算表,再实测账单,超支时按优先级裁剪。本文按"预算思维 → 成本实测 → 逐项分析 → 动态分配 → 优化顺序"组织,所有代码可直接搬进自己的 Agent。
1、预算思维与账单构成
Anthropic 工程团队在 Effective context engineering for AI agents 里把上下文称为有限资源,预算思维就是这句话的工程落地:单次 Agent 运行的 token 账单由五个科目构成,先给每个科目定一个占比上限,超了就裁。
一张可直接套用的预算分配表模板如下。
| 科目 | 典型占比 | 内容 | 成本属性 |
|---|---|---|---|
| system 提示 | 5% | 角色、规则、输出格式 | 固定,每轮不变 |
| 工具定义 | 10% | 全部工具的 JSON schema | 固定,每轮重付 |
| 对话历史 | 40% | 用户输入、工具结果、回复 | 变动,线性增长 |
| 检索注入 | 30% | RAG 片段、笔记、文件内容 | 变动,弹性最大 |
| 输出预留 | 15% | 本轮要生成的回复 | 变动,不预留会被截断 |
这张表的关键不是具体数字,而是最后一列:system 与工具定义是固定成本,从第一轮到第一百轮一分不少;历史与检索是变动成本,随任务推进持续膨胀。财务上先锁死固定开销,剩下的才是可支配收入——预算表就是 Agent 版的资产负债表。
预算表的价值不在数字本身,而在迫使你回答:每一块钱 token 花在哪里,换回了什么。
分配比例因 Agent 形态而异:单轮工具型 Agent 检索占比可以到 50%;长程多轮 Agent 历史会吃掉大头。模板只是起点,真正的配额要用第 2 节的实测来校准。
2、成本实测与节点拆账
没有实测的预算表是拍脑袋。好消息是账单数据现成:LangChain 生态里每条模型响应都带 usage_metadata 字段,input_tokens 与 output_tokens 自动填充,缺的只是聚合逻辑。
2.1、usage_metadata 聚合
最小可用的记账器实现如下。
from collections import defaultdict
class TokenLedger:
"""按节点聚合 usage_metadata,单位为 token"""
def __init__(self):
self.rows = defaultdict(lambda: {"in": 0, "out": 0, "calls": 0})
def record(self, node: str, msg) -> None:
um = getattr(msg, "usage_metadata", None) or {}
row = self.rows[node]
row["in"] += um.get("input_tokens", 0)
row["out"] += um.get("output_tokens", 0)
row["calls"] += 1
ledger = TokenLedger()
async for event in graph.astream(inputs, stream_mode="updates"):
for node, chunk in event.items():
if isinstance(chunk, dict) and chunk.get("messages"):
ledger.record(node, chunk["messages"][-1])
解读:TokenLedger 只做一件事——在图节点出口把最后一条消息的用量记到该节点名下。stream_mode="updates" 会按节点推送增量,正好天然对应"按调用点记账"。跑完一个典型任务后打印 ledger.rows,输出形如。
node calls input output
retrieve 6 28640 980
planner 4 9120 1300
writer 4 19880 6100
2.2、按节点拆账
上面的输出已经是一张按节点拆开的账单。如果项目接了 LangSmith 观测平台,这笔账不用自己记:trace 视图里每个节点的 input/output token 现成可查,还能按 span 聚合出平均值与分布。
Agent 系列当年用 trace 排查延迟与失败,现在用同一份 trace 审计 token,方法论完全一致。拿到拆账结果后先与第 1 节的预算表对表:哪个科目实际占比偏离配额最多,哪个就是下一轮优化的头号对象——这一步把实测和预算真正闭环。看拆账时还要盯两个信号:某节点 input 远超均值(上例 retrieve 的 28640),说明检索注入在顶爆上下文;某节点 output 逼近上限(上例 writer 的 6100),说明输出预留不足、回复可能被截断。
3、工具定义的隐形税
五个科目里最容易被忽略的是工具定义。每个工具的 JSON schema——名称、描述、参数定义——会原样进入每一轮请求:Agent 当轮只用得上其中一两个工具,其余十几个的 schema 却一分不少地全程跟随。这就是工具定义的隐形税,一种每轮都重付的固定成本。
工具数量与 token 的定量关系可以用官方的 count_tokens 接口直接实测,请求体与 Messages API 完全一致,支持 tools 参数。
import anthropic
client = anthropic.Anthropic()
def tools_cost(tools: list[dict]) -> int:
"""全部工具 schema 在单次请求里的固定开销"""
base = client.messages.count_tokens(
model="claude-sonnet-4-5",
messages=[{"role": "user", "content": "hi"}],
).input_tokens
full = client.messages.count_tokens(
model="claude-sonnet-4-5",
messages=[{"role": "user", "content": "hi"}],
tools=tools, # 与真实请求完全一致的 schema
).input_tokens
return full - base
解读:两次计数相减,剥离测试消息本身,得到的差值就是纯工具开销。我在一组带参数说明的典型工具上实测,结果如下。
10 个工具: 2,860 token
20 个工具: 5,742 token
40 个工具: 11,503 token
平均每个工具约 287 token。20 个工具 5742 token,在 200K 窗口里只占 2.9%,看似无害;但这是每轮重付的固定税——100 轮对话累计就是 57 万 token 的输入开销(开了 prompt caching 会好很多,账要另算,见本系列第 6 篇)。工具描述写得越"尽职",每个参数三行说明,税就越重。省税手法后面展开,这里先记住结论:工具数量和 schema 长度都要进预算表,加一个工具前先量一量它的固定成本。
4、历史膨胀与压缩触发
历史是账单里唯一必然增长的科目。增长模型很简单:每轮新增 = 用户输入 + 工具结果 + 模型回复,其中工具结果通常是两三倍的大头。膨胀曲线可以提前画出来。
def project(rounds: int, per_round: int) -> list[tuple[int, int]]:
"""历史 token 随轮次线性累积的估算"""
return [(r, r * per_round) for r in range(1, rounds + 1)]
# 实测单轮均值:用户 80 + 工具结果 1200 + 回复 300 ≈ 1580
for r, total in project(20, 1580):
if r % 5 == 0:
print(f"第 {r:2d} 轮: 历史 {total:>6} token")
解读:per_round 用第 2 节的账单除以轮数即可得到。输出如下。
第 5 轮: 历史 7900 token
第 10 轮: 历史 15800 token
第 15 轮: 历史 23700 token
第 20 轮: 历史 31600 token
单轮 1580 token 的 Agent,20 轮历史就 3 万 token,吃掉预算表里历史科目的近四成。真正的麻烦不是绝对数,而是历史线性累积、信息却在衰减:第 3 轮工具结果引用的中间状态,到第 15 轮早已过时,却还在每轮请求里全额计费。
所以压缩触发条件要写在预算层,不写在轮数层:两个常用阈值是历史占比超过预算 40%、或窗口剩余不足 20%,前者防膨胀后者防超窗。压缩的具体做法留给本系列第 5 篇;另一条路是 context editing,自动清理历史里过时的工具结果——Anthropic 官方在 100 轮 web search 评测中把 token 消耗降低了 84%。无论选哪条路,触发逻辑都应该集中收口,而不是散落在各处业务代码里。
5、检索注入的配额制
检索注入是弹性最大也最容易失控的科目,成本公式是一个乘积:检索开销 = top-k × 单 chunk token 数。k=5、每片 800 token,一次注入 4000;把 chunk 换成整段文档、k 提到 10,一次就是两万,一轮就击穿预算。chunk 尺寸本身也有权衡:切得太小语义断裂、召回零碎,切得太大稀释相关性、浪费额度,工程上的常见折中落在 300~800 token 一片。
治理手段是配额制:先给检索定死预算(比如 30%),再反推 k 与 chunk 上限的组合。当候选内容超出配额时,按下面的优先级截断。
| 顺序 | 手段 | 说明 |
|---|---|---|
| 1 | 截断单 chunk | 每片只保留与查询最相关的段落 |
| 2 | 降低 top-k | 相关分最低的候选先丢 |
| 3 | 合并同源片段 | 同一文档的相邻 chunk 合并去重 |
| 4 | 摘要化注入 | 长文档降级为摘要,需要时再展开 |
截断顺序背后的依据是 Lost in the Middle 一文揭示的位置效应:模型对开头与结尾内容的召回显著好于中间。结论很直接——与其塞满中间,不如少放两头:宁可 k=3 且每片精悍,也不要 k=10 平铺直叙。
6、动态预算分配器
前四节是静态账目,这一节把它们组装成运行时组件:每轮调用前,按窗口余量动态决定历史保留多少、检索放几条。这是本篇的核心教学代码。
def allocate(window, system, tools, reserve_out, history, chunks, chunk_t):
"""固定开销先扣,历史保最近的,剩余额度全部给检索"""
budget = window - system - tools - reserve_out
headroom = budget // 7 # 约 15% 余量,吸收估算误差
keep, used = [], 0 # 1) 历史从新到旧装箱
for msg in reversed(history):
cost = len(msg.content) // 4 # 英文约 4 字符 1 token
if used + cost > budget - headroom:
break
keep.append(msg)
used += cost
left = budget - headroom - used # 2) 剩余额度换算 top-k
top_k = max(1, min(len(chunks), left // chunk_t))
return {"history": keep[::-1], "top_k": top_k}
解读:分配分三步。先扣固定开销——system、工具、输出预留一次性减掉,剩下的才是可支配额度;再装箱历史——从最新消息往旧回溯,装不下的旧消息就是本轮压缩的候选对象,最近对话的信息密度最高,优先保住;最后剩余额度整除单 chunk 成本,换算成本轮检索的 top-k 上限。
两个工程细节:len // 4 只是粗估,中文内容要改用实测系数,生产环境建议先用 count_tokens 校准再缓存成每条消息的精确值;15% 余量防的就是粗估误差与偶发超长输出。注意返回值是裁剪指令而非最终消息列表——真正执行裁剪的可以是 trim 逻辑、压缩节点或检索器的 top_k 参数,分配器只管拍板。挂载位置放在调用链路最外层:组装最终请求之前、拿到检索候选之后,这样它看到的才是所有科目变动后的真实余量。
7、成本优化优先级
所有科目一起超预算时先砍谁?按"单位 token 换回的价值"排序,优化顺序是:工具 → 检索 → 历史 → system。
- 工具定义:收益最确定。精简描述、合并同类项、按任务分组挂载(规划阶段只见规划工具),纯减法不损能力。
- 检索注入:弹性最大。降 k、缩 chunk、按第 5 节优先级截断,配合 rerank 保精度。
- 对话历史:最后动的变动成本。压缩必有信息损失,先确认前两项已榨干再动手。
- system 提示:原则上不砍。它单位价值最高,省的是小钱,伤的是行为约束。
这个顺序的理由链:工具税是纯浪费(未用到的 schema 零收益);检索有明确的精度回退路径(rerank 补位);历史压缩损失的是低频旧信息;而 system 里的规则每轮都在发挥作用。先砍零收益的,最后砍全收益的,财务上就这么简单。
8、总结
本文把上下文管理拆成了一套财务流程:先用五科目预算表立账,再用 usage_metadata 与节点拆账实测,然后认清各科目特性——工具定义是每轮重付的固定税、历史是线性累积的变动成本、检索是乘积式配额——最后用动态分配器在每轮调用前完成裁剪,优化时按工具、检索、历史、system 的顺序动手。
对于正在维护多轮 Agent 的团队,建议先花半天做一次 token 审计,拿到自己系统的真实五科目占比,再决定预算怎么分;对于刚起步的项目,建议直接把第 6 节的分配器接进调用链路,防患于膨胀之前。
留一道练习:给 Agent 系列第 17 篇的 RAG Agent 做一次完整 token 审计,步骤如下。
- 用
usage_metadata聚合出五科目实际占比,与预算表模板对比 - 用 count_tokens 实测全部工具 schema 的固定成本
- 画出前 20 轮历史增长曲线,标出历史占比越过 40% 的轮次
- 给检索注入定配额,实现按剩余窗口动态调整 top-k
- 按第 7 节顺序做一项优化,重跑 10 轮对比前后账单
做完这道题,你会发现上下文工程里最难的不是技巧,而是持续记账的纪律——预算表挂墙上没人看,账单每周对一遍才有效。

