2899 字
约 9 分钟
12
S21-A26 · 上下文预算:四笔开销怎么分,超了先砍谁

客服 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. 自测

  1. 四笔开销里,哪一笔最容易被低估?为什么它的增长不是线性的?
  2. "保留最近 N 轮"这个默认策略在 Agent 里为什么会砍错?
  3. 分配比例应该按什么维度定?为什么"重要程度"不适合当标准?
  4. 砍序里"检索"为什么排在"工具返回"前面?两者的关键差别是什么?
  5. 工具 schema 常驻占用的判据是什么?为什么"工具数 > 8 就分组"?
  6. 换 128k 窗口为什么没有解决退化问题?它改变了什么、没改变什么?

9. 一句话总结

上下文预算的本质是"四笔开销共用一份额度,而它们的增长速度不同":工具返回按调用次数线性涨、历史按轮次涨、schema 恒定常驻,混在一起看就永远算不清为什么又满了。判据是恢复成本——幂等的检索先砍、有副作用的工具结果次之、用户约束最后且只能压缩不能丢。窗口从 32k 换到 128k 只挪动失效点;不重新分配比例,就是让同一个病晚几轮发作。

S21-A26 · 上下文预算:四笔开销怎么分,超了先砍谁
https://www.clxhxhhr.top/posts/6723/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。