Administrator
发布于 2026-04-21 / 180 阅读
4

上下文窗口管理的工程实践

上下文不是不够用,是没管好

我们的客服 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 tokenP99 延迟单次成本
1~3 轮52%17,2003.1s¥0.052
4~10 轮31%26,8006.8s¥0.081
11~20 轮13%48,40014.2s¥0.147
20 轮以上4%82,60031.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,2000%¥0.1470
滑动窗口(10 轮)5,80023.0%¥0.0610
全量摘要4,1008.7%¥0.052+210ms
摘要 + 事实槽4,2201.4%¥0.054+240ms

1.4% 的指代错误率,比全量历史的 0% 还是差一点,但相对 4,220 token 的成本,我们认为可以接受。

关键信息保留:几条硬规则

上面提到了事实槽的设计,这里把我们在实践中总结的规则列一下。这些是踩坑踩出来的:

  1. 实体标识符永不摘要。订单号、金额、日期、ID 这类,一律进事实槽,绝不依赖摘要保留。摘要模型对长字符串的保真度很差,我们遇到过订单号末尾数字被改掉的情况;
  2. 用户的否定和约束优先保留。「不要顺丰」「除了红色都要」这类约束一旦丢失,结果是灾难性的。我们在抽取 prompt 里把这类单独列了一类,且权重最高;
  3. 已执行的写操作必须保留。「已经帮您取消了订单 SO001」这句话如果被摘掉,Agent 可能会再取消一次。我们的做法是:所有产生副作用的工具调用结果,单独存一条 action_log,永远不被压缩;
  4. 最近 4 轮不压缩。多轮对话里,最近的上下文相关性最高,压缩收益低、风险高;
  5. 摘要也要有长度上限。摘要本身会随对话增长,我们对摘要也做了压缩(摘要的摘要),超过 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,8401,720-7%
工具定义8,4201,270-85%
RAG 内容6,1803,240-48%
历史消息14,2004,220-70%
平均 total30,64010,450-66%
长对话 P99 延迟31.5s8.4s-73%
单次成本¥0.092¥0.031-66%
任务完成率84.2%86.9%+2.7pp

最后一行值得说一下:砍掉 66% 的上下文之后,任务完成率反而提升了 2.7 个百分点。这符合我们之前的观察——更少的噪声意味着模型更容易抓住重点。之前那些塞满无关工具定义和历史细节的长上下文,并没有帮到模型,反而在干扰它。

写在后面

现在回头看,《上下文窗口管理的工程实践》本身不算多难,难的是线上真出问题那十分钟里的判断。经验都是这么来的。

参考