上下文不是不够用,是没管好
我们的客服 Agent 用的是 128K 上下文的模型。上线初期大家的判断是「128K 够用了,不用操心上下文管理」。
三个月后我们把单次对话的平均 token 从 3,200 涨到了 26,000——不是因为业务变复杂,是因为我们没有做任何管理,历史消息无脑全带上。到第 18 轮对话时,光历史就占了 4 万 token,加上系统提示词和 RAG 召回,一次请求 6 万 token,成本 0.18 元,延迟 14 秒。
这篇记录我们做的三层上下文管理体系,以及中间踩的坑。
先量化:上下文都花在哪了
在动手之前,我把 1,000 个真实会话的 token 构成统计了一遍。用的是我们自己埋的点,每个请求会记录各部分的 token 数。
SELECT
round(avg(system_tokens)) AS system,
round(avg(tool_def_tokens)) AS tools,
round(avg(rag_tokens)) AS rag,
round(avg(history_tokens)) AS history,
round(avg(total_tokens)) AS total
FROM ai_request_log
WHERE created_at > now() - interval '7 days';
system | tools | rag | history | total
--------+-------+-------+---------+--------
1,840 | 8,420 | 6,180 | 14,200 | 30,640
历史消息占了 46%,工具定义占了 27%。这两块加起来 73%,都不是「业务内容」。
再看分布,长尾非常严重:
| 对话轮次 | 占比 | 平均 total token | P99 延迟 | 单次成本 |
|---|---|---|---|---|
| 1~3 轮 | 52% | 17,200 | 3.1s | ¥0.052 |
| 4~10 轮 | 31% | 26,800 | 6.8s | ¥0.081 |
| 11~20 轮 | 13% | 48,400 | 14.2s | ¥0.147 |
| 20 轮以上 | 4% | 82,600 | 31.5s | ¥0.251 |
4% 的长对话消耗了多少成本?算一下:4% × 0.251 / (52%×0.052 + 31%×0.081 + 13%×0.147 + 4%×0.251) = 大约 13.4%。这个比例看着不高,但它们的 P99 是 31.5 秒,用户体验极差,而且这 4% 的用户往往是重度用户。
第一层:工具定义瘦身
8,420 token 的工具定义,是最先被开刀的。41 个工具,平均每个 205 token。
第一步是合并同类项。我们原来按「接口」注册工具,改成了按「意图」注册,41 个收敛到 19 个。这一步在另一篇里写过,不重复。工具定义从 8,420 降到 3,900。
第二步是动态工具加载。不是所有场景都需要全部 19 个工具——用户问「我的订单到哪了」时,「修改收货地址」这个工具的定义完全没必要传。
@Component
public class DynamicToolSelector {
/** 基于用户当前输入,只用向量相似度选 top-k 工具 */
public List<ToolCallback> select(String userQuery, int topK) {
float[] qv = embeddingModel.embed(userQuery);
return allTools.stream()
.map(t -> ScoredTool.of(t, cosine(qv, t.descriptionVector())))
.filter(s -> s.score() > 0.42) // 低于阈值的不带
.sorted(comparingDouble(ScoredTool::score).reversed())
.limit(topK)
.map(ScoredTool::tool)
.toList();
}
}
// 用法
ChatClientResponse resp = chatClient.prompt(userQuery)
.toolCallbacks(toolSelector.select(userQuery, 8))
.call()
.chatClientResponse();
工具定义的向量是启动时算好的,缓存起来。实测动态加载后平均只带 6.2 个工具,token 从 3,900 降到 1,270。
但这里有个坑:相似度筛选会漏掉「跨领域」的工具。用户问「帮我取消订单并且把钱退到原来的账户」,这句话和「查询余额」这个工具相似度不高,但实际需要它。我们的补救是加了一个「必带核心工具集」(4 个高频工具,永远带上),以及当 Agent 表示「没有合适的工具」时,第二轮用全部工具重试一次。
漏选率实测是 3.1%,重试一次之后降到 0.4%。
第二层:RAG 结果压缩
6,180 token 的 RAG 内容。我们召回 topK=5,每个片段平均 600 token,加上表格和代码会更多。
这块的优化不是「少召回」,而是「召回后压缩」。具体做了三件事:
一、去掉 HTML/Markdown 的冗余标记
文档里大量的表格用 Markdown 语法,token 效率很低。一个 10 行 4 列的表格,Markdown 形式要 320 token,转成紧凑的管道分隔形式只要 140。
// 改前(Markdown 表格,含对齐行和多余的空格)
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| orderNo | String | 是 | 订单编号 |
| amount | BigDecimal | 否 | 退款金额 |
// 改后
orderNo|String|Y|订单编号
amount|BigDecimal|N|退款金额
模型能读懂紧凑形式,token 省了 56%。我们测过准确率的差异,在 640 条评测集上是 88.7% vs 89.1%,差 0.4 个百分点,可以接受。
二、rerank 后截断,且做「去重合并」
召回的 5 个片段里经常有内容重叠(同一份文档的不同片段)。做了个简单的去重:
List<Document> dedupe(List<Document> docs) {
List<Document> kept = new ArrayList<>();
for (Document d : docs) {
boolean redundant = kept.stream().anyMatch(k ->
jaccard(k.tokenSet(), d.tokenSet()) > 0.72);
if (!redundant) kept.add(d);
}
return kept;
}
平均能从 5 个片段去掉 1.3 个,省 780 token。
三、超长片段做抽取式摘要
超过 1,200 token 的片段,用一个小模型做抽取式摘要(不是生成式,是「挑出和问题相关的句子」):
if (doc.tokenCount() > 1200) {
doc = smallModel.extractRelevant(doc, query, 800); // 抽到 800 token
}
用 qwen-turbo 做,单次成本 0.00006 元,延迟 180ms。只有 12% 的片段会触发这个逻辑,整体影响很小,但长尾场景(比如用户问整个章节的内容)收益明显。
三件事做完,RAG 部分从 6,180 降到 3,240。
第三层:历史消息管理(最难的一层)
14,200 token 的历史。这是最大的一块,也是最难处理的一块,因为你不能简单地砍掉历史——用户会问「那刚才那个订单呢」,砍掉之后 Agent 就答不上来了。
我们试过三种策略,最后是组合使用。
策略一:滑动窗口(放弃了)
最朴素的做法:只保留最近 N 轮。
// 简单粗暴,但有问题
List<Message> window = history.subList(
Math.max(0, history.size() - 10), history.size());
问题在于「关键信息可能在很早的轮次」。用户第 2 轮说「我的订单号是 SO20260115001」,第 15 轮问「这个订单能退吗」——窗口里没有订单号了。
我们统计过,滑动窗口(保留 10 轮)导致的「指代无法解析」错误率是 23%。完全不可用。
策略二:全量摘要(效果一般)
把早期历史压缩成一段摘要:
@Service
public class HistorySummarizer {
private static final int TRIGGER = 8_000; // 历史超过 8000 token 才触发
public List<Message> compress(List<Message> history) {
if (tokenCount(history) < TRIGGER) return history;
List<Message> toSummarize = history.subList(0, history.size() - 4);
List<Message> keepAsIs = history.subList(history.size() - 4, history.size());
String summary = smallModel.summarize(toSummarize, SUMMARY_PROMPT);
List<Message> out = new ArrayList<>();
out.add(new SystemMessage("此前对话摘要:\n" + summary));
out.addAll(keepAsIs);
return out;
}
}
摘要的 prompt 我们迭代了 6 版,最终版的关键要求是「必须保留所有实体标识符」:
## 要求
1. 保留所有具体的实体标识符:订单号、商品名、金额、日期、用户名
2. 保留用户表达过的偏好和约束(比如「只要红色的」「不要顺丰」)
3. 保留已做出的决定和承诺(比如「已同意退款 120 元」)
4. 省略:寒暄、重复表述、模型的中间推理过程
5. 输出控制在 300 token 以内
6. 用第三人称陈述,不要用「用户说」这种冗余前缀
效果:历史 token 从 14,200 降到 4,100(摘要 800 + 最近 4 轮 3,300)。但「指代无法解析」的错误率还是有 8.7%。分析下来是摘要模型会丢失细节——比如用户说过「要退那两个中的便宜的那个」,摘要写成了「用户要退款」,把「两个」「便宜的」这个关键信息丢了。
策略三:摘要 + 结构化事实槽(最终方案)
最后的方案是混合的:摘要给模型看,事实槽给代码用。
思路是这样:把对话里出现的实体抽出来,存成结构化的「事实槽」(fact slot),这部分不进 prompt,而是在需要的时候由代码精确注入。摘要只负责保留「故事的连贯性」。
public record FactSlot(
String key, // order_no / product / amount / date / preference
String value,
int mentionedAtTurn, // 第几轮提到的
Instant updatedAt
) {}
@Service
public class FactSlotManager {
/** 每轮对话后,用小模型抽取实体,增量更新事实槽 */
public void update(String sessionId, int turn,
String userMsg, String assistantMsg) {
List<FactSlot> extracted = extractor.extract(userMsg, assistantMsg);
Map<String, FactSlot> slots = slotStore.load(sessionId);
for (FactSlot f : extracted) {
FactSlot old = slots.get(f.key());
// 同一 key 被再次提到,用新的覆盖,但保留首次提及的轮次
slots.put(f.key(), f.withMentionedAtTurn(
old == null ? turn : old.mentionedAtTurn()));
}
slotStore.save(sessionId, slots, Duration.ofHours(4));
}
/** 构建请求时,把事实槽以紧凑形式注入 */
public String render(String sessionId) {
return slotStore.load(sessionId).values().stream()
.sorted(comparing(FactSlot::mentionedAtTurn))
.map(f -> "%s=%s".formatted(f.key(), f.value()))
.collect(joining("; "));
}
}
注入到系统提示词里是这样的:
## 已确认的事实
order_no=SO20260115001; product=保温杯(蓝色); amount=129.00;
intent=退款; objection=未收到货; mentioned_turn=2,3,5,7
整个事实槽只有 120 token,但包含了所有「硬信息」。摘要负责讲清楚「发生了什么」,事实槽负责保证「具体数值不会错」。
抽取用小模型,成本 0.00004 元/轮,只在有新实体时才更新(我们做了个简单的 diff,无变化不调模型)。
最终效果:
| 策略 | 历史 token | 指代解析错误率 | 单次成本 | 额外延迟 |
|---|---|---|---|---|
| 全量历史 | 14,200 | 0% | ¥0.147 | 0 |
| 滑动窗口(10 轮) | 5,800 | 23.0% | ¥0.061 | 0 |
| 全量摘要 | 4,100 | 8.7% | ¥0.052 | +210ms |
| 摘要 + 事实槽 | 4,220 | 1.4% | ¥0.054 | +240ms |
1.4% 的指代错误率,比全量历史的 0% 还是差一点,但相对 4,220 token 的成本,我们认为可以接受。
关键信息保留:几条硬规则
上面提到了事实槽的设计,这里把我们在实践中总结的规则列一下。这些是踩坑踩出来的:
- 实体标识符永不摘要。订单号、金额、日期、ID 这类,一律进事实槽,绝不依赖摘要保留。摘要模型对长字符串的保真度很差,我们遇到过订单号末尾数字被改掉的情况;
- 用户的否定和约束优先保留。「不要顺丰」「除了红色都要」这类约束一旦丢失,结果是灾难性的。我们在抽取 prompt 里把这类单独列了一类,且权重最高;
- 已执行的写操作必须保留。「已经帮您取消了订单 SO001」这句话如果被摘掉,Agent 可能会再取消一次。我们的做法是:所有产生副作用的工具调用结果,单独存一条
action_log,永远不被压缩; - 最近 4 轮不压缩。多轮对话里,最近的上下文相关性最高,压缩收益低、风险高;
- 摘要也要有长度上限。摘要本身会随对话增长,我们对摘要也做了压缩(摘要的摘要),超过 1,200 token 就重新生成。
第 3 条特别重要。我们的实现是把工具调用的副作用单独记录:
// 副作用日志独立于对话历史,永不被压缩或丢弃
public record ActionLog(
String toolName,
Map<String, Object> params,
String result,
Instant at
) {}
// 注入时
"## 本会话已执行的操作(不可撤销)
- [14:32] cancelOrder(orderNo=SO20260115001) → 成功
- [14:35] applyRefund(orderNo=SO20260115001, amount=129.00) → 成功
"
整体结果
三层全部做完之后,对比数据:
| 项 | 优化前 | 优化后 | 降幅 |
|---|---|---|---|
| 系统提示词 | 1,840 | 1,720 | -7% |
| 工具定义 | 8,420 | 1,270 | -85% |
| RAG 内容 | 6,180 | 3,240 | -48% |
| 历史消息 | 14,200 | 4,220 | -70% |
| 平均 total | 30,640 | 10,450 | -66% |
| 长对话 P99 延迟 | 31.5s | 8.4s | -73% |
| 单次成本 | ¥0.092 | ¥0.031 | -66% |
| 任务完成率 | 84.2% | 86.9% | +2.7pp |
最后一行值得说一下:砍掉 66% 的上下文之后,任务完成率反而提升了 2.7 个百分点。这符合我们之前的观察——更少的噪声意味着模型更容易抓住重点。之前那些塞满无关工具定义和历史细节的长上下文,并没有帮到模型,反而在干扰它。
写在后面
现在回头看,《上下文窗口管理的工程实践》本身不算多难,难的是线上真出问题那十分钟里的判断。经验都是这么来的。