Agent 平台上线几个月后,我们的 Prompt 构造器变成了一个"缝缝补补"的拼接怪兽:system prompt 2K、技能定义 3K、KB 检索 5K、用户画像 300 tokens、对话历史 8K——每样都不多,加起来轻松破 20K。模型确实在回答,但它看到的不再是精心编排的上下文,而是一堆不分权重的噪音。
这篇文章介绍我们如何从"只管拼接不管总量"转型为显式上下文预算分配,以及当历史消息超预算时的压缩策略。
1. 问题:每个组件都说"我只要一点点"
Prompt 构建的典型调用链:
PromptBuilder.build()
├── systemPrompt "给我 2000 tokens"
├── Skills (拼全量) "也就 3000 tokens"
├── KB 检索 (每 KB top 5) "5000 tokens 比较稳"
├── 用户画像 (100 条事实) "不多,1000 tokens"
├── 近期会话摘要 "才 800 tokens"
├── 历史消息 "目前的对话上下文最重要"
└── 模型偏好 "100 tokens"
每一层的开发者都有各自的理由扩张:"我这块再多 500 肯定对质量有帮助"。但没有一个人关心所有层加起来有多大。
实际情况是:
- 中文模型 ~2 字符/token,英文 ~4 字符/token——你看到的字数要减半才是 token 量
- 短模型的 context window 只有 32K,即使 deepseek-v4-pro 的 128K,每次用掉 20-40K 的 prompt 也意味着历史消息只能塞 10 轮左右
- 更关键的是:模型的注意力是有限的,塞进去的噪音越多,对关键信息的注意越分散
2. 解法:显式预算分配器
2.1 核心思路
给定各章节的 token 上限,按优先级分配。当某项超预算时,在后端截断而不是让模型自己忽略——因为模型比你更不擅长"选择性忽略"。
contextBudget:
skills: 500
semantic: 1000 # 用户画像
episodic: 800 # 近期会话摘要
kb: 3000 # 知识库检索
history: 4000 # 对话历史
system prompt 和模型偏好不纳入预算(固定开销),其余按节分配。
2.2 Token 估算
在发给模型之前精确计数是不现实的(tokenizer 不一致),但这不妨碍用字符级启发式快速估算:
public static int estimateTokens(String text) {
int ascii = 0, nonAscii = 0;
for (char c : text.toCharArray()) {
if (c <= 0x7F) ascii++; else nonAscii++;
}
return (int) Math.ceil(ascii / 4.0 + nonAscii / 2.0);
}
经验规则:ASCII 字符(英文)~4 字符/token,非 ASCII(中文)~2 字符/token。混合文本按字符类别加权。误差有,但对于"从 10K 压到 5K"这种量级的控制足够精确——截断本身就是保守的,不追求完美计数。
2.3 截断策略
当章节内容超预算时:按比例计算可保留的字符数,末尾追加截断标记,而不是硬从中截断:
> ⚠️ [上下文预算] skills 章节超出上限(3000/500 tokens),已截断。
截断从后往前做,前面的内容保留完整——通常前面的信息更重要(前置依赖)。
2.4 可观测:分配决策写回 trace
每轮推理完成后,各章节的实际 token 用量写入 trace span 的 metadata:
[
{"section":"skills","tokens":420,"truncated":false,"budget":500},
{"section":"semantic","tokens":850,"truncated":false,"budget":1000},
{"section":"kb","tokens":5120,"truncated":true,"budget":3000},
{"section":"history","tokens":3800,"truncated":false,"budget":4000}
]
前端 trace 视图渲染预算条,一眼看到哪节在挤占空间。这为后续调参提供了数据地基——配多大的 budget 不是拍脑袋,是根据实际分布调的。
3. 历史压缩:最难的一节
3.1 为什么不能硬截断
对话历史是 LLM 理应的核心输入——直接砍掉最早的消息,模型就丢失了对话的起点(用户最初问了什么、Agent 之前做过哪些决定)。但你不可能每轮都带 30 轮历史。
3.2 滑窗 + 压缩摘要
策略:保留最近 4 条消息(至少 2 个完整来回)原样,更早的消息调 LLM 压缩为一段摘要。
对话历史(15 轮,~12K tokens)
├── 消息 1-11(压缩为摘要 ~0.5K) ← 一次性压缩,缓存到 SessionMemory
└── 消息 12-15(保留原样 ~3K)
────────────────────────
总消耗:~3.5K tokens(vs 12K)
摘要缓存在 Redis 的 SessionMemory.summary 字段中,下次同一会话重新加载时直接复用,不会重复调 LLM 压缩。
3.3 多轮压缩
当新消息不断追加又超预算时,已有摘要 + 新增未压缩部分 → 整体重新压缩为新摘要。每次压缩都会用最新的上下文重新概括,保证摘要始终覆盖完整的对话脉络。
3.4 为什么不压缩每一轮
一些方案建议"每轮都增量更新摘要"——这会引入 O(n) 次 LLM 调用(每次对话都调模型做压缩),在成本和延迟上不划算。我们的做法是懒压缩:只在历史的 token 量确实超预算时才触发,一次 LLM 调用把需要压缩的部分全压了。
4. 经验总结
每一层的'我只要一点点'加起来就是'太多了'。 上下文管理的核心不是让每个组件满意,而是让总量有约束。预算分配器的价值在于把隐式竞争变成显式约束。
截断位置很关键。 截 KB 还是截历史?答案取决于场景。当前默认配比是基于"KB 是最不可控的膨胀源"的经验——用户自动挂载 5 个 KB,每个检索 5 条结果就是 25 个片段。具体配比应该根据 trace 数据调。
Token 估算是够用就好,不是数学题。 精确计数需要和模型一样的 tokenizer,但启发式估算对"10K→5K"级别的控制完全够用。保守截断(留 90%)覆盖估算误差。
可观测优先于优化。 先做预算条(trace metadata + 前端渲染),看实际分布,再调 budget 值。没有数据就配 budget 等于盲调——你不知道哪节是真正的膨胀源。
历史压缩是最后一道防线。 prompt 模板、技能定义都是可控的;KB 检索结果可以调 top-K;唯有对话历史是天然不可控的——用户可能聊 50 轮。压缩是必选项,不是可选项。