首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >Context压缩与Token优化:让长文档对话不爆上下文

Context压缩与Token优化:让长文档对话不爆上下文

作者头像
陆业聪
发布2026-07-21 13:42:55
发布2026-07-21 13:42:55
1390
举报

📰 科技要闻

• 月之暗面发布Kimi K3,同期智谱AI年化收入(ARR)突破10亿美元,国产大模型商业化节奏明显加快。

• 苹果市值重返全球第一,市场对其AI硬件生态的想象空间再度打开。

• 旷视印奇在WAIC 2026开幕式主论坛演讲,主题"当智能体走进物理世界",具身智能成为会场焦点。

上周三半夜,我们的知识库Agent线上告警响了——不是LangSmith那套忠实度掉分的告警,是一个更原始的错误:context_length_exceeded。一个客户在同一个会话里连续问了四十多轮,每一轮都带着检索结果、工具调用记录和历史对话,到第四十几轮的时候,直接把128K的窗口撑爆了。

我当时就纳闷了——我们评估体系天天在盯Faithfulness、Context Recall,怎么会漏掉这么基础的一个问题?后来复盘才发现,评估用的测试集全是"单轮问答",没人模拟过长会话场景。这暴露了一个我们一直没正视的事实:Token不是无限资源,而是这套系统里最容易被忽视、又最容易在生产环境炸掉的硬约束。这篇就聊聊我们后来怎么把这个坑填上的。

一、先算笔账:128K到底够不够用

很多人一看GPT-4o有128K上下文、Gemini 1.5 Pro能到2M,第一反应是"这么大还怕什么"。但工程上真正能用的窗口,远小于宣传的数字。我们拆开算过一次账:

占用项

典型Token数

备注

System Prompt

1500-3000

规则+工具描述+few-shot

检索片段(TopK=8)

4000-8000

每片段约500-1000 token

对话历史(40轮)

15000-30000

含用户问题+助手回答

工具调用记录

2000-10000

JSON返回体往往很啰嗦

算下来,一个正常的企业知识库长会话,实际占用轻松突破6-7万Token,留给模型"思考"和生成回答的空间被压得很窄。打个比方,128K窗口看起来像一间大仓库,但System Prompt是钉死在地上的固定家具,检索结果是每次问答都要搬进来的一批货物,对话历史是越堆越高的纸箱——真正能站人干活、让模型腾出脑子做推理的空地,其实只剩角落一小块。更麻烦的是,即便没有硬性报错,超长上下文本身会拖累模型的注意力质量——这就是业内说的"Lost in the Middle"现象:塞进去的信息越靠中间,模型越容易漏读。所以Context优化不只是防止报错,是实打实的效果问题。

二、Context压缩三条路:LLMLingua、RECOMP、摘要压缩

压缩检索结果这一块,我们前后试了三种方案,说说各自的坑。

LLMLingua:按Token重要性硬删

LLMLingua的思路挺暴力——用一个小型语言模型给每个Token打"重要性分数",然后按预算删掉分数低的Token,压缩比可以做到2-5倍。用起来也简单:

代码语言:javascript
复制
from llmlingua import PromptCompressorcompressor = PromptCompressor(
model_name="microsoft/
llmlingua-2-bert",
)def compress_context(
contexts: list[str],
question: str,
target_tokens: int = 2000,
):
merged = "\n".join(contexts)
result = compressor.compress_prompt(
merged,
instruction=question,
target_token=target_tokens,
rate=0.4,
)
return result["compressed_prompt"]

效果确实立竿见影,8000 token的检索结果能压到3000以内。但这玩意儿坑了我整整一天——它的删除逻辑有点像拿荧光笔在文档上划重点,然后把没划到的字全部涂黑:划得准是精华摘要,划得太狠就是把半句话拦腰砍断。它是按Token粒度删的,不保证语义连贯,压完之后一段技术文档可能变成"半句话+半句话"的拼贴,遇到需要精确复述代码片段或者API参数的场景,压缩后的上下文经常把关键的括号、参数名删得七零八落。结论:适合"要点提炼型"问答,不适合需要精确引用原文的场景。我们最后只在闲聊/摘要类问题上开启LLMLingua。

RECOMP:先摘要再拼接,粒度更粗但更稳

RECOMP的做法是训练一个专门的摘要模型,把每个检索到的文档块压缩成一句话摘要,再把摘要拼接送进主模型。它比LLMLingua慢一点(多了一次小模型推理),但语义完整性好得多:

代码语言:javascript
复制
def recomp_style_compress(
chunks: list[str],
question: str,
):
# 逐块摘要,保留和问题相关的信息
summaries = []
for chunk in chunks:
s = summarizer.summarize(
text=chunk,
query=question,
max_tokens=80,
)
# 过滤掉与问题弱相关的摘要
if s.relevance_score > 0.3:
summaries.append(s.text)
return "\n".join(summaries)

RECOMP还有个隐藏优点:它天然带了一层"相关性过滤",摘要相关性低的块可以直接丢弃,等于把压缩和重排的部分职责合并了。但代价是需要额外维护一个摘要模型的推理服务,如果团队本来就没有独立的小模型部署能力,这条路的运维成本不低。

摘要压缩:用主模型自己降本,最简单但最贵

最省事的办法是直接用主模型(甚至是级联里的小模型)对检索结果做一次摘要。不需要额外训练模型,缺点是每次都要多一次LLM调用,延迟和成本都会上去。我们只在检索结果特别长(单块超过2000 token的长文档)时才走这条路,作为LLMLingua和RECOMP之外的补充手段,而不是主力方案。

三种方案实测对比,我们内部量化过一版数据(基于自建的200条问答评估集,Faithfulness用RAGAS打分):

方案

压缩比

额外延迟

Faithfulness

不压缩(基线)

1x

0ms

0.87

LLMLingua

3.2x

+120ms

0.79

RECOMP

2.1x

+280ms

0.85

主模型摘要

2.8x

+600ms

0.84

数据很直白:LLMLingua压缩比最高但对忠实度伤害最大,RECOMP是精度和成本的甜点,主模型摘要精度不差但延迟代价最高。我们最终的组合策略是——默认走RECOMP,遇到超长单文档块降级到主模型摘要,只有闲聊/总结类问题才放开LLMLingua

三、动态上下文选择:TopK不该是个常量

我们最早的检索逻辑很粗暴,TopK写死成8,不管问题是"这个函数的参数含义是什么"还是"帮我梳理一下这三个模块之间的调用关系并比较优劣",都塞8个片段进去。后果是:简单问题浪费Token,复杂问题又经常因为召回数量不够而漏关键信息。

后来改成按问题复杂度动态调整召回数量,实现上其实不复杂,先用一个轻量分类器(或者直接用小模型做零样本判断)给问题的复杂度打标签:

代码语言:javascript
复制
from enum import Enumclass Complexity(Enum):
SIMPLE = "simple"
MODERATE = "moderate"
COMPLEX = "complex"TOPK_MAP = {
Complexity.SIMPLE: 3,
Complexity.MODERATE: 8,
Complexity.COMPLEX: 15,
}@traceable(name="classify_complexity")
def classify_complexity(
question: str,
) -> Complexity:
# 用小模型做零样本分类,
# prompt里给三档特征描述
resp = small_model.classify(
question,
labels=[
"simple",
"moderate",
"complex",
],
)
return Complexity(resp.label)def adaptive_retrieve(question: str):
level = classify_complexity(question)
top_k = TOPK_MAP[level]
return retrieve(question, top_k=top_k)

分类特征其实很直观:问题里是否出现"比较"、"梳理"、"所有"、"分别"这类词,问题长度,是否涉及多个实体的关联关系——这些都是复杂问题的信号。我们没有专门训练分类模型,用的是一个已经很便宜的小模型(比主对话模型小一个量级)做零样本判断,这一步的Token和延迟成本几乎可以忽略。

上线之后,简单问题的平均Token消耗降了40%左右,复杂问题的召回完整度提升明显——之前"梳理三个模块调用关系"这类问题,固定TopK=8经常漏掉第三个模块的关键片段,动态调到15之后基本能覆盖全。这一步改动不复杂,但ROI是我们这轮优化里最高的。

四、对话历史管理:滑动窗口不够,得配摘要

开头那次爆窗事故,根源就在对话历史这块——我们最早的策略是"最近N轮全量保留",简单但完全不管长会话。后来改成滑动窗口+摘要的组合:

代码语言:javascript
复制
class ConversationBuffer:
def __init__(
self,
recent_turns: int = 6,
summary_trigger: int = 12,
):
self.messages = []
self.summary = ""
self.recent_turns = recent_turns
self.summary_trigger = summary_triggerdef add(self, role, content):
self.messages.append(
{"role": role, "content": content},
)
if (
len(self.messages)
> self.summary_trigger
):
self._compress_old_turns()def _compress_old_turns(self):
# 保留最近recent_turns轮原文,
# 更早的历史滚动进摘要
cutoff = (
len(self.messages)
- self.recent_turns
)
old = self.messages[:cutoff]
self.summary = (
summarizer.rolling_summarize(
previous_summary=self.summary,
new_turns=old,
)
)
self.messages = self.messages[cutoff:]def build_context(self):
parts = []
if self.summary:
parts.append(
f"[历史对话摘要]\n{self.summary}",
)
parts.extend(self.messages)
return parts

关键设计是rolling_summarize——每次压缩不是从头重新摘要整段历史,而是把"上一份摘要"和"新增的这批旧轮次"一起喂给模型,滚动更新摘要。这样摘要本身的Token开销是稳定的,不会随会话轮数无限增长。我们线上跑了一个月,40轮以上的长会话,历史部分的Token占用从原来的2万+稳定控制在4000左右,而且没有出现"摘要漏掉早期关键信息"的用户投诉——前提是必须把摘要的prompt写清楚"哪些信息属于必须保留的硬事实(比如用户身份、明确的偏好设置、已确认的结论)",不然模型摘要会把这些细节当废话丢掉。

五、Long Context还是RAG:这个问题被讨论得有点走偏

这半年"Long Context会不会取代RAG"的讨论一直没停,我的判断很直接:这不是二选一,是两条腿走路,只是各自的成本结构和适用场景完全不同。

知识库总量有多大?

✅ 单文档/几十份文档,能塞进窗口 → 直接Long Context,省去检索链路的工程复杂度

❌ 千万级文档/持续增长的知识库 → 必须RAG,Long Context成本和延迟撑不住

是否需要溯源/引用具体来源?

✅ 需要精确定位来源文档 → RAG天然带元数据,溯源成本低

❌ 只要综合性回答,不追溯出处 → Long Context也能接受

还有一个大家容易忽视的成本维度:Long Context每次调用都要把全部内容重新算一遍Attention,即便有Prefix Cache优化,长期高频调用的成本依然比"检索几个片段+短上下文生成"贵得多。我们做过粗算,同样的问答量,纯Long Context方案(把整个知识库摘要塞进窗口)单次调用成本大概是RAG方案的3-4倍——这还没算上因为上下文过长导致的响应延迟增加。所以我们的结论是:RAG负责"从海量知识里精确捞出相关的一小部分",Long Context负责"在这一小部分范围内做深度理解和推理",两者是同一条流水线的不同阶段,不是替代关系。

六、成本优化:把Token监控和模型级联做成基础设施

前面聊的都是"怎么塞得更少",最后这块聊"怎么花得更值"。我们线上跑的是三级模型级联,思路跟医院分诊台差不多——护士能处理的常见问题不用直接排主任专家号,越简单的问题越用便宜模型处理,只有真正棘手、需要复杂推理的才一步步转诊升级到最贵的模型:

代码语言:javascript
复制
@traceable(name="cascaded_answer")
def cascaded_answer(
question: str, context: str,
):
# 第一级:便宜模型先答,
# 附带一个自评分
draft = cheap_model.generate(
question, context,
with_confidence=True,
)
if draft.confidence > 0.85:
return draft.answer# 第二级:中等模型复核,
# 置信度不够才继续升级
mid = mid_model.generate(
question, context,
with_confidence=True,
)
if mid.confidence > 0.85:
return mid.answer# 第三级:只有真正难的问题
# 才用最贵的模型
return premium_model.generate(
question, context,
).answer

说实话,我最开始也不信"让便宜模型自己打置信度分"这个思路能靠得住,担心它会盲目自信。实测下来确实存在一定的过度自信问题,但只要把置信度阈值卡得足够高(我们最后定在0.85,不是随便拍的,是拿评估集反复扫出来的),加上对高风险问题类型(比如涉及金额、合规条款的问题)强制跳过级联直接走最贵模型,整体误判率能控制在可接受范围。上线之后,Token成本降了将近55%,而端到端的回答质量在评估集上几乎没有明显下降。

监控这一块,我们在上一篇LangSmith的基础上加了一个专门的Token仪表盘,按stage(压缩/检索/生成/摘要)拆分消耗,配合按租户的成本归因,每周会自动生成一份Token消耗周报。这份周报意外地成了跟业务方沟通"为什么这个功能贵"的利器——之前业务方总觉得AI调用是个黑盒,现在能拿着具体数字说"这个功能70%的Token花在了对话历史摘要上,因为你们的用户平均会话轮数是同类功能的3倍",沟通成本直接降了一个量级。

这一篇填的坑,说到底还是那句话——Token不是无限的,能省的地方省,不能省的地方(比如涉及金钱和合规的判断)绝不含糊。下一篇我们要往基础设施层走一步:把整个知识库Agent的开发环境用Docker Compose串起来,前端、后端、向量库一键拉起,这是我们内部新人上手项目从"配一天环境"缩短到"十分钟能跑起来"的关键一步。到时候见。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-07-21,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 陆业聪 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档