客服 Agent 上线第四周开始"变傻":前 3 轮答得准,第 8 轮起答非所问、重复调用同一个工具。日志里 prompt_tokens 一路涨到 31,900,贴着 32k 上限。有人提议"换 128k 的模型"——换了之后,第 20 轮又开始犯同样的毛病,只是晚了几轮。
问题不在窗口大小,在没有人给这 32k 定过分配比例。四笔开销各按自己的速度长,涨到顶时默认从尾部截——被截掉的恰好是最新一次工具结果,模型于是拿着旧数据回答新问题,还觉得自己很有依据。
上下文不是"能装多少",是**"装不下的那一刻,你希望先失去什么"**。这篇把这件事拆成四笔开销、一条砍序、和一个判据。
1. 四笔开销,四种增长速度
先把账摊开:32k 窗口里四类内容增速完全不同,混着看就说不清"为什么又满了"。
| 开销 | 内容 | 增长方式 | 32k 窗口下的典型占用 |
|---|---|---|---|
| system + 工具 schema | 角色设定、12 个工具的 JSON Schema | 固定,随工具数线性 | 1,800–3,200 token |
| 检索片段 | RAG top-k 的原文 | 每次检索按 k 重算 | 2,000–6,000 token |
| 历史对话 | 用户与助手的往返 | 每轮线性,且工具消息翻倍 | 每轮 400–900 token |
| 工具返回 | 查询结果、网页正文 | 每次调用整段塞回 | 单次 300–4,000 token |
第四笔最容易被低估:一次 search 返回 20 条结果,就是 3,000 token;一个循环里调 5 次,光工具返回就吃掉了 15k。而工具调用会把一轮拆成 6 条消息,历史实际每轮涨 2,000–3,000 token。
// 一次 8 轮会话的实际账(token)
system+schema 2400 ────────────────────── 恒定
retrieval 3000 ██████ 每次检索重算
history 8000 ████████████████ 8 轮 × 1000
tool results 14500 █████████████████████████████ 5 次调用
output reserve 4000 ▓▓▓▓▓▓▓▓ 必须留
────
总计 31900 > 32000 → 触发截断
// 结论:让窗口爆掉的是工具返回,不是"用户话太多"
2. 默认截断为什么总是砍错地方
多数框架的默认策略是"保留首尾、截中间",或者"从最早的消息开始丢"。两种在 Agent 场景都会砍错:Agent 的消息序列不是对话,是一棵调用树压平的线性数组。
| 默认策略 | 它假设的是 | 在 Agent 里的实际后果 |
|---|---|---|
| 保留 system + 最近 N 轮 | 越新越重要 | 最近的恰好是大段工具返回,挤掉用户需求 |
| 从最早开始丢 | 早期内容可弃 | 丢掉用户第一句里的关键约束 |
| 按字符截断尾部 | 尾部可省 | 截断工具结果 JSON,模型看到半个对象 |
| 超限就报错重试 | 调用方可处理 | 重试仍超限,进入死循环 |
第二行最隐蔽:用户第一轮说的"只查 2024 年之后的数据",被丢掉之后模型不会报错,它会按默认年份查,然后拿着错误范围的结果写一份格式完美的报告。这类失效没有任何异常信号。
// 截断前先做一次"可恢复性"分类,而不是按位置砍
def trim(msgs, budget):
for m in msgs: // 先标记,不急着删
m.restore = "cheap" if m.role == "tool" else \
"expensive" if m.role == "user" else "free"
while tokens(msgs) > budget:
victim = pick(msgs, prefer=["free", "cheap"]) // 先丢可重建的
msgs.remove(victim)
// 关键:user 消息永不整体删除,可压缩成摘要
3. 分配比例:按"可压缩性"分,不按重要程度分
"重要"说不清、开会能吵一小时;可压缩性是硬的:能摘要、能重取、能丢弃,三档定配额。
| 内容 | 可压缩性 | 建议配额(32k 窗口) | 越界后的动作 |
|---|---|---|---|
| system + schema | 不可压缩(工具少了就不能干活) | 3,000 硬上限 | 砍工具数,不是砍描述 |
| 用户约束 | 可摘要,不可丢 | 2,000 | 摘要成"约束清单" |
| 工具返回 | 可重取(最可压) | 8,000 滚动窗口 | 只留最近 2 次的摘要 |
| 检索片段 | 可重排(按需再检索) | 6,000 | 降 k 或换更小片段 |
| 历史对话 | 可摘要 | 7,000 | 三轮前的转摘要 |
| 输出预留 | 不可压缩 | 4,000 | 低于 2,000 就必然截半句 |
这张表真正的用法是反向检查:某项长期贴着上限,说明它在替别的项承担溢出——工具返回常年满格,往往不是工具返回太多,而是历史摘要没做。
// 把配额写成代码,才不会每轮靠人拍
context_budget:
window: 32768
reserve_output: 4000 // 先扣,再分配
pools:
system_schema: 3000 // 超了告警,不自动扩
user_constraints: 2000 // 摘要后写入,原文另存
tool_results: 8000 // 滚动窗口,只留最近 2 次
retrieval: 6000 // 按 k 动态,k 上限 8
history: 7000 // 3 轮前转摘要
on_overflow: compress_history_then_shrink_retrieval // 顺序固定
4. 砍的顺序:判据是"恢复成本"
超了先砍谁,答案不是"最不重要的",而是**"砍掉之后恢复它要花多少"**。恢复成本低的先砍,这是唯一不需要争论的排序依据。
| 候选 | 恢复方式 | 恢复成本 | 排序 |
|---|---|---|---|
| 检索片段 | 再检索一次 | 一次调用(200–800 ms) | 1(先砍) |
| 工具返回 | 再调一次工具 | 一次调用 + 可能限流 | 2 |
| 历史对话 | 无法恢复原文,只能摘要 | 信息永久损失一部分 | 3 |
| 用户约束 | 无法恢复 | 必须回问用户 | 4(最后) |
| system + schema | 不可恢复 | 功能直接缺失 | 永不砍 |
检索是幂等的(同一 query 再查一次结果一样),工具调用常常不幂等(下单、扣款重调会重复副作用)。所以先砍检索,别砍"已执行动作"的记录——那是幂等的替代品。
// 溢出处置的固定顺序(写进代码,不靠临场判断)
1 缩 retrieval: k 8 → 4 → 2 // 幂等,最安全
2 压 tool_results: 保留最近 2 次,其余换成一行摘要
3 摘要 history: 3 轮前 → 结构化摘要(不含原文)
4 回问 user: 约束缺失时请求确认 // 唯一允许打断用户的动作
5 拒绝执行 // 仍在溢出 → 任务本身超规格
// 禁止:截断 tool 结果 JSON、丢 system、按字符切尾
5. 工具 schema 是常驻的大头
12 个工具,每个 schema 平均 180 token,就是 2,160 token 每一轮都付。它不像历史会涨,它一直占着,而且很少有人算它。
| 做法 | schema 占用 | 代价 |
|---|---|---|
| 全量 12 工具、完整描述 + 示例 | 2,400 token | 选错工具的概率低,但每轮都贵 |
| 精简描述(去示例、去冗余字段说明) | 1,100 token | 边界场景选错率上升 |
| 按场景分组,每轮只挂 3–5 个 | 500–800 token | 需要一次路由判断 |
判据:工具数超过 8 个就该分组。不是因为贵,是模型在 12 个相似工具里选错的概率显著高于 4 个里选——省 token 是顺带的,选对工具才是主收益。
// 同一个工具的两种写法,差 120 token
{"name":"query_orders","description":"查询订单。支持按用户ID、时间范围、状态过滤,返回订单列表,最多100条,时间格式为YYYY-MM-DD,状态取值为pending/paid/shipped/closed,默认按创建时间倒序,分页从第1页开始……","parameters":{...}}
{"name":"query_orders","description":"按用户ID与时间范围查询订单,最多100条。","parameters":{...}}
// 删掉的细节该进参数 schema 的 enum 与 default,不该堆在描述里
6. 排错:五种"上下文不够"的误判
| 现象 | 真实原因 | 处理方向 |
|---|---|---|
| 后几轮答非所问 | 工具返回挤掉了用户约束 | 改分配比例,锁 user 池 |
| 重复调用同一工具 | 上次结果已被截断,模型以为没执行 | 保留调用记录摘要,标"已执行" |
| 突然开始编造字段 | 工具结果 JSON 被字符截断 | 禁止字符级截断,只做整体替换 |
| 换大窗口后仍然退化 | 分配比例没变,只是放大同一问题 | 重新分配,而不是换模型 |
第一行与第四行是同一个病:窗口变大只是把失效点往后推。比例不变,128k 在第 20 轮照样撞墙,只是日志里看着"还有余量"。
// 定位一次具体会话的四笔开销,别靠猜
$ python3 - <<'EOF'
msgs = load_session("sess-8821")
for k, v in by_role_tokens(msgs).items():
print(f"{k:14s} {v:6d} {v/32768:5.1%}")
// system 2400 7.3% | retrieval 3000 9.2% | history 8000 24.4% | tool 14500 44.3%
EOF
// 哪一列超过配额,就是这一轮该动的地方
7. 边界:什么情况不必精细预算
| 场景 | 为什么不必 | 该做什么 |
|---|---|---|
| 单轮任务(分类、抽取) | 无历史、无工具循环 | 只控输入长度 |
| 会话 ≤ 3 轮 | 撞不到上限 | 留 reserve,别的不动 |
| 工具 ≤ 3 个且返回 < 500 token | 总量天然可控 | 全量保留,省掉摘要开销 |
判断口径一句话:当"被砍掉的内容还能不能拿回来"这个问题有明确答案时,才值得做精细预算;答案全是"能重取",就把配额砍到 3 档,别做 6 档。反过来,只要有一类内容既不可重取、又会被默认策略优先丢弃,就必须显式锁住它。
8. 自测
- 四笔开销里,哪一笔最容易被低估?为什么它的增长不是线性的?
- "保留最近 N 轮"这个默认策略在 Agent 里为什么会砍错?
- 分配比例应该按什么维度定?为什么"重要程度"不适合当标准?
- 砍序里"检索"为什么排在"工具返回"前面?两者的关键差别是什么?
- 工具 schema 常驻占用的判据是什么?为什么"工具数 > 8 就分组"?
- 换 128k 窗口为什么没有解决退化问题?它改变了什么、没改变什么?
9. 一句话总结
上下文预算的本质是"四笔开销共用一份额度,而它们的增长速度不同":工具返回按调用次数线性涨、历史按轮次涨、schema 恒定常驻,混在一起看就永远算不清为什么又满了。判据是恢复成本——幂等的检索先砍、有副作用的工具结果次之、用户约束最后且只能压缩不能丢。窗口从 32k 换到 128k 只挪动失效点;不重新分配比例,就是让同一个病晚几轮发作。