Administrator
发布于 2025-08-05 / 3304 阅读
76

上下文工程:比 Prompt 工程更重要的事

实习生问我:为什么文档喂得越多,答案越差

七月份团队来了个实习生,负责维护我们的文档问答系统。他有天跑来问我一个问题:「我把 topK 从 5 调到 20,召回率肯定上去了吧?但测试集准确率从 71% 掉到 63%,是不是哪儿写错了?」

我看了下代码,没写错。这不是 bug,这是我们一直没认真对待的一件事——上下文里放什么、放多少、怎么放,比提示词怎么写重要得多

这篇记录我们后来两个月在「上下文工程」上的摸索。

先把现象量化

我们有个 300 条的问答测试集,每条都有人工标注的标准答案和相关文档。用它跑了组对照实验:

topK平均上下文 token答案准确率答案「跑题」率
32,10068.3%4.1%
53,40071.0%5.7%
106,90069.5%9.2%
2013,80063.2%17.4%
5034,20054.8%26.1%

准确率在 topK=5 达到峰值然后单调下降。「跑题」率(答案和任何一份文档都不相关)则一路上升。这说明模型并没有因为信息变多而变聪明,反而更容易被带偏。

更关键的发现来自位置实验。我们把唯一的正确文档分别放在上下文的第 1 份、第 10 份、第 20 份,其余用无关文档填充,topK 固定 20:

正确文档位置准确率
第 1 份84%
第 10 份(中间)61%
第 20 份(末尾)79%

中间位置明显塌陷。这就是常说的 lost in the middle 现象,我们自己测出来的塌陷幅度是 23 个百分点,比论文里报的还狠一些,可能是中文长文档里段落相似度高、更容易互相干扰。

策略一:先筛后进,而不是先进后筛

原来的流程是「向量检索 topK → 全部拼进 prompt」。问题在于向量相似度和「对回答这个问题有用」不是一回事。相似度 0.82 的一段话可能和问题高度同义但不含答案。

我们加了一层轻量重排。不用大模型打分(太贵太慢),用了一个 bge-reranker 的本地服务,对 topK=20 的候选打分后取前 5:

// 两阶段检索:宽召回 + 精排序
List<Doc> retrieve(String query) {
    List<Doc> candidates = vectorStore.similaritySearch(
            SearchRequest.builder().query(query).topK(20).build());

    return reranker.rank(query, candidates).stream()
            .filter(scored -> scored.score() > 0.35)   // 阈值很关键
            .limit(5)
            .map(ScoredDoc::doc)
            .collect(toList());
}

准确率从 71.0% 提到 79.6%。而且因为最终只用 5 份,上下文反而从 3400 token 降到 3100 token,成本还降了一点。

那个 0.35 的阈值是调出来的。我们统计了 rerank 分数的分布,发现正确文档的分数集中在 0.6~0.95,无关文档集中在 0.05~0.3,中间有个明显的谷。阈值设 0.35 时,30% 的问题会只召回 1~2 份文档,但这些问题的准确率反而更高(86%),说明「少而准」确实优于「多而全」。

策略二:给上下文排序,把关键信息放两头

既然中间位置会塌陷,那就别把重要内容放中间。排序规则我们定了三条:

  1. rerank 得分最高的放最前面
  2. 次高的放最后面(模型对末尾的关注度仅次于开头);
  3. 其余按分数从高到低填中间空位。
List<Doc> reorder(List<Doc> ranked) {
    List<Doc> out = new ArrayList<>(Collections.nCopies(ranked.size(), null));
    int head = 0, tail = ranked.size() - 1;
    boolean toHead = true;
    for (Doc d : ranked) {                     // ranked 已按分数降序
        if (toHead) out.set(head++, d);
        else        out.set(tail--, d);
        toHead = !toHead;
    }
    return out;
}

这个改动几乎零成本,准确率又提了 3.2 个百分点。

策略三:文档不是整块塞,先做抽取式压缩

我们的文档平均 1400 字,但真正和问题相关的往往就两三句话。整块塞进去,有用的信息被稀释了。

我们试过用小模型做摘要(抽取式),效果不错但每次多一次调用,P99 增加 400ms 左右。后来改成了更朴素的办法——按句切分后逐句打分,只保留高分句,用的还是那个已经跑起来的 reranker:

String compress(Doc doc, String query) {
    List<String> sentences = splitByPunctuation(doc.content());
    if (sentences.size() <= 6) return doc.content();

    List<ScoredSentence> scored = reranker.rank(query, sentences);
    // 保留分数前 40%,但按原文顺序重排,保持可读性
    return scored.stream()
            .sorted(comparingDouble(ScoredSentence::score).reversed())
            .limit((long)(sentences.size() * 0.4))
            .sorted(comparingInt(s -> s.index()))
            .map(ScoredSentence::text)
            .collect(joining(""));
}

压缩后单份文档平均从 680 token 降到 290 token,五份文档一共省了近 2000 token。准确率 78.9%,比重排方案的 82.8% 低了 4 个点。

所以我们没全量用,只在「文档特别长」的场景开(>2000 字的文档)。这也是上下文工程里的一条经验:压缩一定是有损的,要按场景权衡而不是一刀切

策略四:多轮对话的上下文管理

前面都是单轮。多轮场景更复杂,因为上下文里有三类东西在同时增长:历史问答、工具返回、检索文档。

我们的处理原则是按「稳定性」分层,稳定的放前面(还能吃到 prompt 缓存),易变的放后面:

// 上下文分层:静态前缀 → 检索文档 → 历史摘要 → 最近原文 → 当前问题
String buildContext(ContextParts p) {
    return String.join("\n\n",
        SYSTEM_AND_TOOLS,        // 约 1800 token,全程不变,可缓存
        p.retrievedDocs(),       // 每轮都变,但和当前问题最相关
        p.historySummary(),      // 早期历史的压缩摘要
        p.recentTurns(2),        // 最近 2 轮保留原文
        p.currentQuestion()      // 当前问题放最后,注意力最强
    );
}

这里有个细节踩过坑:一开始我们把检索文档放在历史后面,结果多轮之后模型经常「回答上一轮的问题」。把文档挪到历史前面,并把当前问题放最末尾,这个问题基本消失了。

历史摘要我们用 qwen-turbo 做,每积累超过 3000 token 就压一次。压缩提示词里明确要求保留「用户已经确认过的事实」和「未解决的疑问」这两类,其他都可以丢:

把以下对话历史压缩为 300 字以内,必须保留:
1. 用户已明确陈述或确认的事实(如订单号、时间、偏好)
2. 尚未解决或待确认的问题
可以丢弃:寒暄、重复的追问、已完成的中间步骤

策略五:上下文预算要硬编码进代码

前面几步做完,我们还加了一道保险:在代码里给上下文设硬预算,超了就按优先级裁剪。这是防止个别异常请求把整套机制击穿——比如用户粘贴了一篇 3 万字的技术文档进来问问题。

public class ContextBudget {

    // 按能力分配预算,不是全局一刀切
    static final Map<String, Integer> BUDGET = Map.of(
        "qa",     24_000,    // 文档问答:5 份文档 + 历史
        "chat",   12_000,    // 闲聊:历史为主,不需要文档
        "agent",  32_000     // Agent:含工具返回
    );

    static final Map<String, Integer> RESERVE = Map.of(
        "qa", 4_000, "chat", 2_000, "agent", 8_000   // 留给输出
    );

    public List<Segment> fit(String capability, List<Segment> parts, int modelLimit) {
        int budget = Math.min(BUDGET.get(capability), modelLimit - RESERVE.get(capability));
        int used = parts.stream().mapToInt(Segment::tokens).sum();
        if (used <= budget) return parts;

        // 按优先级从低到高丢,系统提示永丢
        List<Segment> sorted = parts.stream()
            .filter(s -> s.priority() < 100)
            .sorted(comparingInt(Segment::priority))
            .collect(toCollection(ArrayList::new));

        for (Segment s : sorted) {
            if (used <= budget) break;
            used -= s.tokens();
            parts.remove(s);
        }
        log.warn("context overflow, dropped {} segments, cap={}", dropped, capability);
        return parts;
    }
}

这里的关键是丢之前要告警。我们把这个 warning 接到了监控上,如果某个能力的裁剪触发率超过 5%,说明预算设小了或者上游有问题,要人来看。上线第一个月,告警帮我们发现了两个 bug:一个是某类文档的切分逻辑失效产生了超大分片,一个是某个调用方把整个数据库导出的 CSV 塞进了问题里。

我们最后定下来的流程

阶段做法准确率上下文 token
基线topK=5 直接拼接71.0%3,400
+ 重排筛选topK=20 → rerank → 取 579.6%3,100
+ 位置重排高低分交替两头放82.8%3,100
+ 长文档压缩>2000 字文档抽句81.3%2,400
+ 多轮分层历史摘要 + 当前问题置尾84.1%2,700

最终方案比基线高了 13 个百分点,上下文反而更小,单轮成本从 ¥0.021 降到 ¥0.017。

几条我认为最值得记住的

  • 上下文是有限资源,不是仓库。每往里塞一份文档,都在挤占其他信息的注意力。我们有次排查一个答案错误,发现是因为上下文里有段和问题主题相似但结论相反的历史文档,模型被它带跑了。
  • 位置比数量影响更大。同样 5 份文档,换个顺序能差 3 个点,这个投入产出比高得离谱。
  • 先测量再优化。这套优化我们做了两个月,其中三周花在做测试集和自动化评估上。没有这个测试集,每次改动都是在盲改。
  • 压缩是有损的,要有开关。我们给每种压缩策略都留了配置,可按业务场景单独关闭。客服场景对准确率敏感,压缩开得保守;内部知识检索场景则激进得多。
  • 别忘了检查「实际发出去的东西」。我们有一次排查答案错误,花了半天看模型、看检索,最后打印完整 prompt 才发现上游一个 bug 把两份重复文档塞了进去。现在调试流程第一步就是 dump prompt。

小结

Prompt 工程解决的是「怎么问」,上下文工程解决的是「问的时候让模型看到什么」。前者的天花板在模型自身,后者的天花板在我们的信息组织能力——而后者往往才是瓶颈。

还有一点值得单独说:优化过程中我们一直盯着测试集的细分场景分数,而不是总分。有两次改动总分涨了,但「多轮追问」这一类掉了 5 个点以上,都被拦下来了。上下文策略对不同场景的影响差异很大,看总分很容易被平均掉。

那个实习生后来跟我说,他现在调试 AI 效果的第一反应不是改提示词了,是去打印一遍实际发出去的完整 prompt 看看里面到底有什么。我觉得这个习惯比任何技巧都值钱。

参考