← 返回资讯

上下文压缩与裁剪:消灭多轮对话中最大的隐形成本

2026-07-10

多轮对话中,历史对话是输入 token 最大的隐形来源。每多一轮,就要带入一轮完整的问答内容。如果不加干预,一个对话进行 20 轮后,输入 token 可能是第 1 轮的 10–20 倍。上下文压缩是解决这个问题最直接的工程手段。

上下文膨胀有多严重

以一个典型客服 Bot 为例,假设每轮输入 50 token、输出 200 token:

对话轮数本轮输入 token(含完整历史)相比第 1 轮增幅
第 1 轮500(system)+ 50 = 550
第 5 轮500 + 4×250 + 50 = 1,550+182%
第 10 轮500 + 9×250 + 50 = 2,800+409%
第 20 轮500 + 19×250 + 50 = 5,300+864%

第 20 轮的调用成本已经是第 1 轮的近 10 倍——同一个用户,同一个问题,成本却爆炸式增长。

为什么必须每次都带上全部历史

这里有个容易被忽略的底层事实:主流模型 API 大多是无状态的。你发一个 HTTP 请求过去,模型处理完就完事了,它不会替你”记住”上一轮说了什么。所谓”多轮对话”的连贯性,全靠你在下一次请求里,把之前所有轮次的 user/assistant 消息原样塞进 messages 数组一起发过去。模型每次看到的都是一份”从头到尾重新读一遍”的完整对话记录。

这也是为什么有些平台推出了”服务端托管会话历史”的能力——你只需要传一个 session id,历史由平台帮你拼好。这确实能省掉你自己维护历史数组的麻烦,但账单逻辑不变:平台内部依然要把这些历史 token 塞进模型的输入里重新处理一遍,该收的钱一分不少。会话托管解决的是”工程便利性”,不是”成本”,这两件事不要混为一谈——如果你看到某个方案宣传”托管历史=省钱”,多半是没说清楚。

搞清楚这一点,你才会明白压缩历史为什么是成本优化里权重最高的一项:它不是锦上添花的小优化,而是直接砍掉那条随对话轮数线性甚至更快增长的曲线。

三种压缩方案

方案一:滑动窗口(最简单)

只保留最近 N 轮对话,超出部分直接丢弃:

MAX_HISTORY_TURNS = 5

def build_messages(system_prompt: str, history: list, user_input: str) -> list:
    # 只取最近 MAX_HISTORY_TURNS 轮
    recent_history = history[-(MAX_HISTORY_TURNS * 2):]  # 每轮 2 条:user + assistant
    return [
        {"role": "system", "content": system_prompt},
        *recent_history,
        {"role": "user", "content": user_input}
    ]

优点: 实现极简,零额外 API 调用,成本可预测。
缺点: 窗口外的信息完全丢失,长对话中模型会”忘记”早期内容。

一个真实会踩的坑: 上面这段代码按”消息条数”切,隐含了一个假设——每轮对话就是干净的一问一答(user、assistant 各一条)。一旦你的对话里出现了工具调用(模型先返回一条 assistant 消息带 tool_calls,再由你补一条 role: "tool" 的执行结果),一轮就不再是 2 条消息,可能是 3 条、4 条。这时候 history[-(MAX_HISTORY_TURNS * 2):] 很容易从中间把某一轮”腰斩”——只留下 tool_calls 那条,把对应的 tool 结果切没了。多数模型 API 对这种情况会直接拒绝请求,报错内容类似”消息里出现了 role 为 tool 的消息,但它前面没有匹配的、带 tool_calls 的 assistant 消息”。排查思路很简单:先打印切割后的 messages 数组,检查每个 tool_calls 后面是否紧跟着对应的 tool 消息;如果对话里可能有工具调用,就不能按消息数切,得按”完整的一轮”(无论这一轮实际占几条消息)分组之后再滑窗,或者干脆把工具调用相关的消息单独打包成一个不可拆分的单元来处理。

方案二:摘要压缩(平衡方案)

当历史超过阈值时,用轻量模型把早期历史压缩为摘要:

def compress_history(old_history: list, llm_client) -> str:
    """把旧历史压缩为摘要,用最便宜的轻量模型"""
    history_text = "\n".join(
        f"{'用户' if m['role']=='user' else '助手'}: {m['content']}"
        for m in old_history
    )
    prompt = f"请用 100 字以内概括以下对话的关键信息:\n{history_text}"
    return llm_client.chat("gpt-4o-mini", prompt, max_tokens=150)

def build_messages_with_summary(system_prompt, history, user_input, llm_client):
    COMPRESS_THRESHOLD = 10  # 超过 10 轮触发压缩
    
    if len(history) > COMPRESS_THRESHOLD * 2:
        old_history = history[:-(5 * 2)]   # 保留最近 5 轮
        recent_history = history[-(5 * 2):]
        summary = compress_history(old_history, llm_client)
        summary_msg = {"role": "system", "content": f"[早期对话摘要]: {summary}"}
        return [{"role": "system", "content": system_prompt}, summary_msg, *recent_history,
                {"role": "user", "content": user_input}]
    else:
        return [{"role": "system", "content": system_prompt}, *history,
                {"role": "user", "content": user_input}]

优点: 保留历史语义,长对话连贯性好。
缺点: 每次压缩多一次 API 调用(成本极低但非零),摘要有信息损失。

为什么摘要要用 gpt-4o-mini 这类轻量模型,而不是你的主力模型? 摘要本质是”信息抽取+压缩表达”,对模型的推理能力要求很低,轻量模型完全能胜任,而且价格通常比旗舰模型低一个数量级(具体倍率以你实际使用的模型定价页为准)。粗算一笔账:假设摘要模型单次调用的成本只有主对话模型的几分之一到十几分之一,那么只要压缩后主对话省下的 token 超过个位数百分比,这次多出来的摘要调用就已经回本了,后面轮次越多赚得越多。真正要盯防的不是”多了一次调用”,而是摘要 prompt 写得太粗糙,导致摘要要反复重试或者摘要本身跑偏(比如把用户明确否定过的方案当成结论)。

同步压缩会拖慢响应速度——上面的代码是在处理当前请求的过程中同步调用 compress_history,这意味着用户要多等一次 API 往返的时间才能拿到回复。更好的做法是把压缩挪到”请求处理完之后”异步执行:这一轮先用旧摘要(或者干脆不压缩,走完整历史)正常应答用户,应答返回后再另起一个后台任务生成新摘要、写回存储,下一轮请求进来时用的就是更新后的摘要。这样用户感知到的延迟里,永远不包含压缩这一步。

摘要不要每次都从头总结一遍。 上面的实现每次触发压缩时,都是把”全部旧历史”重新丢给摘要模型,随着对话越来越长,这个”旧历史”本身也会越来越长,摘要调用的输入 token 也会跟着膨胀——这就是同一个问题在小范围内又重演了一遍。更合理的做法是滚动摘要:只把”上一次的摘要”加上”这次新增的几轮对话”喂给摘要模型,让它输出一份更新后的摘要,而不是每次都从对话开头重新读一遍。

def rolling_summary(prev_summary: str, new_turns: list, llm_client) -> str:
    """增量更新摘要:只处理"旧摘要 + 新增轮次",输入长度不随对话总轮数增长"""
    new_text = "\n".join(
        f"{'用户' if m['role']=='user' else '助手'}: {m['content']}"
        for m in new_turns
    )
    prompt = (
        f"已有摘要:{prev_summary or '(无)'}\n"
        f"新增对话:\n{new_text}\n"
        "请在已有摘要基础上更新,用 100 字以内概括到目前为止的关键信息"
        "(用户身份、明确诉求、已达成的结论),旧摘要中不再重要的细节可以舍弃。"
    )
    return llm_client.chat("gpt-4o-mini", prompt, max_tokens=150)

这样无论对话进行到第 10 轮还是第 100 轮,每次摘要调用的输入长度都大致固定——真正做到了”压缩逻辑本身也不随对话增长而变贵”。

方案三:选择性裁剪(精细控制)

基于相关性打分,只保留与当前问题相关的历史消息:

裁剪维度操作效果
时间维度丢弃超过 N 分钟前的消息适合实时客服场景
相关性用 Embedding 余弦相似度筛选历史保留最相关的历史轮次
重要性标记关键信息(如用户姓名、订单号)显式提取到 system prompt不会被滑动窗口丢失

优点: 在控制 token 的同时最大化保留有用信息。
缺点: 实现复杂,需要额外的 Embedding 调用。

这里最容易踩的坑是只靠语义相似度一条腿走路。举个例子:用户在第 2 轮报了订单号,第 15 轮才问”我这单能退款吗”——这两句话在 Embedding 空间里未必靠得多近(一个是纯数字信息,一个是诉求描述),如果你只按相似度取 Top-K,订单号这条关键信息很可能排不进前几名,直接被漏掉。所以实际工程里,选择性裁剪很少单独使用,通常是三层叠加:

def build_context(history: list, user_input: str, embed_client) -> list:
    # 第一层:硬保留最近 3 轮,保证短期连贯性
    recent = history[-6:]
    older = history[:-6]

    # 第二层:对更早的历史做相关性检索,取语义最相关的 Top-3
    if older:
        query_vec = embed_client.embed(user_input)
        scored = [(embed_client.cosine_sim(query_vec, embed_client.embed(m["content"])), m) for m in older]
        scored.sort(key=lambda x: x[0], reverse=True)
        relevant = [m for _, m in scored[:3]]
    else:
        relevant = []

    # 第三层:显式提取的关键信息(订单号、姓名等),无论是否"相关"都强制保留
    key_facts = extract_key_facts(history)  # 你自己实现的规则/正则提取

    return [*key_facts, *relevant, *recent]

第一层管”最近发生的”,第二层管”语义上相关的”,第三层管”业务上重要但语义未必相关的”,三层互补,比单纯一个 Embedding 检索靠谱得多。当然真这么做之前先想清楚:你的对话场景是不是真的需要这个复杂度——多数中小流量场景,滑动窗口 + 摘要压缩的组合就够用了,选择性裁剪留给那些历史确实很长、且早期信息确实高频被引用的场景(比如长期陪伴型助手、多轮排障类客服)。

方案对比与选型建议

方案实现复杂度成本控制信息保留适用场景
滑动窗口强(可预测上限)弱(会遗忘)短对话、任务型 Bot
摘要压缩较强较好长对话、客服、陪伴
选择性裁剪强(精细控制)最好复杂知识库问答、RAG

推荐路径: 先用滑动窗口快速上线,观察用户行为;如果有”模型忘记之前说的事”的投诉,再引入摘要压缩。

常见问题

压缩会导致模型表现变差吗?
滑动窗口会导致模型无法引用窗口外的内容,但对多数任务型场景影响有限。摘要压缩的质量取决于摘要本身的质量,用高质量摘要 prompt 可以把损失控制在可接受范围。

system prompt 也要压缩吗?
system prompt 一般固定不变,重点是配合 Prompt Caching 让其缓存命中,而不是压缩内容。详见 Prompt Caching 省钱原理与实践

每次都重新压缩历史太慢了,怎么优化?
摘要可以持久化存储(数据库/Redis),只在历史超过阈值时重新生成,而不是每次请求都重算。

上下文超限报错了,第一反应该做什么?
先别急着上压缩方案。多数平台的超限报错里会直接给出两个数字:本次请求实际的 token 数,和模型允许的最大值。先把这两个数字记下来,看差距有多大——如果只是超了一点点(比如超 5%),可能只需要把最近一两轮的长回复截断一下就够了;如果超了几倍,说明历史确实堆积到该压缩了,再挑本文里的方案。直接一上来就上最复杂的选择性裁剪,往往是过度工程。

摘要压缩和 Prompt Caching 会不会打架?
会,如果你没注意摆放顺序的话。Prompt Caching 依赖”前缀完全一致”才能命中缓存,如果你把摘要内容硬塞在 system prompt 和历史消息中间、而且这段摘要每轮都在变,就相当于每次都在改动一段本该稳定的前缀,缓存命中率会跟着下降。更稳妥的做法是把固定不变的 system prompt 放在最前面单独占一段(配合 Prompt Caching 让这部分稳定命中),摘要作为独立的一条消息紧跟其后,这样至少 system prompt 那部分的缓存不受摘要更新的影响。具体怎么配合参见 Prompt Caching 省钱原理与实践

怎么验证压缩方案真的有效,而不是自我感觉良好? 给自己定三步验证,别只看代码跑通就算完事:第一步,接入压缩前,先把每一轮实际发给模型的 messages 数组和对应 token 数打印出来,画出膨胀曲线,作为基线;第二步,接入压缩后,同样的对话脚本再跑一遍,对比 token 曲线的降幅,你应该能看到 10 轮以后的增速明显变缓甚至走平;第三步,做一次”对抗测试”——在对话早期埋一个关键信息(比如订单号、用户明确说的偏好),到十几轮之后再问一个依赖这个信息的问题,看压缩后模型是否还能答对。前两步验证的是成本,第三步验证的是质量,两个都过了才算这套方案真正上线可用。


延伸阅读: