大模型应用中的上下文窗口治理:从对话压缩、记忆分层到长上下文成本与效果平衡
大模型应用中的上下文窗口治理:从对话压缩、记忆分层到长上下文成本与效果平衡
在很多 LLM 应用里,效果波动并不是模型本身出了问题,常见原因反而是上下文塞得太满:历史对话越来越长,检索片段越堆越多,系统提示词还不能随便删。结果很直接,token 成本上去了,响应时间变慢,回答质量也开始飘。
我在实际项目里踩过这个坑。说实话,一开始我以为把上下文窗口开大就够了,后来实测发现并不是这么回事。上下文变长之后,账单会先告诉你问题有多明显。
这篇文章写一个可复现方案:怎么把上下文治理拆成对话压缩、记忆分层、长上下文预算控制、离线评测几块,并给出一套能直接落地的工程实现。
一、为什么上下文窗口会失控
先看一个典型的在线问答助手,请求上下文通常由几部分拼出来:
- system prompt:角色、规则、输出格式
- recent chat:最近几轮对话
- long-term memory:用户画像、偏好、历史结论
- retrieved docs:RAG 检索到的片段
- tool results:工具调用返回内容
问题就出在这里。每一块都感觉不能删,最后拼起来经常超过预算。
我一般先不谈优化,先做一次真实统计。下面是我常用的请求级观测字段:
from dataclasses import dataclass, asdict
from typing import List, Dict
@dataclass
class ContextUsage:
session_id: str
request_id: str
system_tokens: int
recent_chat_tokens: int
memory_tokens: int
retrieved_doc_tokens: int
tool_result_tokens: int
total_prompt_tokens: int
completion_tokens: int
latency_ms: int
success: bool
def log_context_usage(usage: ContextUsage):
print(asdict(usage))
埋点后很容易看出问题分布。短句先说结论:超窗只是表象,预算失控才是根因。
我见过一条线上请求,system prompt 800 token,最近 20 轮对话 4200 token,RAG 检索塞了 8 段文档又是 3600 token,最后模型还没生成内容,请求已经快到上限。这样一来,模型不是丢信息,就是被迫截断。
二、治理目标别只盯着 token 数
很多人做上下文治理,第一反应是压缩得越狠越好。这个思路容易把系统做偏。
我建议同时盯住四个指标:
| 指标 | 含义 | 常见问题 |
|---|---|---|
| prompt_tokens | 每次请求输入 token | 成本高、超窗 |
| latency_p95 | 95 分位响应时延 | 上下文过长导致生成变慢 |
| task_success_rate | 任务成功率 | 压缩后关键信息丢失 |
| context_hit_rate | 回答中是否用到了正确上下文 | 检索或记忆注入失效 |
这里有个工程细节。压缩后 token 下降,不代表质量就稳。
比如客服助手里,用户连续 15 轮讨论退款、发票和物流,如果你把前面的“退款原因”和“订单号”压没了,后面再省 token 也没意义。上线后经常会看到一种假象:成本降了,人工接管率却涨了。
所以治理目标应该是:
- 给不同上下文来源设预算上限
- 让高价值信息留在窗口里
- 在成本、时延、效果之间做动态平衡
三、上下文治理的整体架构
我通常把上下文拆成四层,而不是一股脑拼接:
[系统规则层]
固定注入,严格控长
[会话短期层]
保留最近若干轮原始对话
[会话摘要层]
对较早历史做压缩摘要
[长期记忆层]
存结构化用户事实、偏好、关键事件
再叠加一个外部知识层,也就是 RAG 检索结果。构建 prompt 时按预算做装配,而不是先装满再裁剪。
一个简化版流程如下:
class ContextAssembler:
def __init__(self, tokenizer, budget_config):
self.tokenizer = tokenizer
self.budget_config = budget_config
def count_tokens(self, text: str) -> int:
return len(self.tokenizer.encode(text))
def assemble(self, system_prompt, recent_msgs, summary, memories, docs, tool_results):
budget = self.budget_config["max_prompt_tokens"]
parts = []
used = 0
def try_add(name, content, max_tokens):
nonlocal used
if not content:
return None
text = content if isinstance(content, str) else "\n".join(content)
tokens = self.count_tokens(text)
tokens = min(tokens, max_tokens)
if used + tokens > budget:
return None
used += tokens
parts.append((name, text))
return True
try_add("system", system_prompt, self.budget_config["system_max"])
try_add("recent_msgs", recent_msgs, self.budget_config["recent_max"])
try_add("summary", summary, self.budget_config["summary_max"])
try_add("memories", memories, self.budget_config["memory_max"])
try_add("docs", docs, self.budget_config["docs_max"])
try_add("tool_results", tool_results, self.budget_config["tool_max"])
return parts, used
这段代码很简化,但核心点是对的:预算前置,装配优先级明确。
四、对话压缩:不是简单摘要,而是保留可执行状态
4.1 什么情况下该压缩
我一般不会每轮都做摘要,因为摘要本身也要花钱,还会引入信息漂移。更稳的做法是基于阈值触发:
- 最近原始对话超过 2500 token
- 会话轮数超过 12 轮
- 新一轮请求前预计总 prompt 超过预算的 80%
阈值要实测。别拍脑袋。
4.2 摘要应该存什么
很多摘要 prompt 只会让模型“总结一下上文”,最后产物像会议纪要,看起来完整,实际没法给后续轮次直接用。
更实用的是生成状态摘要,把后续推理真正需要的信息抽出来,比如:
- 当前任务目标
- 已确认事实
- 未解决问题
- 用户明确偏好或限制条件
- 后续待执行动作
我常用的摘要模板如下:
请将以下多轮对话压缩为可供后续助手继续工作的状态摘要。
要求:
1. 保留用户目标、约束、已确认事实、关键中间结论
2. 保留后续回答必须依赖的时间、数字、实体名
3. 删除寒暄、重复表述、无关追问
4. 输出 JSON,字段包括:
- user_goal
- constraints
- confirmed_facts
- unresolved_questions
- pending_actions
5. 不要编造对话中未出现的信息
4.3 摘要校验不能省
摘要一旦写错,后面几轮都会被带偏。所以我会加一个轻量校验器,检查数字、订单号、日期、实体名是否在原文出现过。
import re
def extract_key_items(text: str):
numbers = re.findall(r"\b\d+(?:\.\d+)?\b", text)
dates = re.findall(r"\b\d{4}-\d{2}-\d{2}\b", text)
order_ids = re.findall(r"\b[A-Z0-9]{8,20}\b", text)
return set(numbers + dates + order_ids)
def validate_summary(source_text: str, summary_text: str):
source_items = extract_key_items(source_text)
summary_items = extract_key_items(summary_text)
hallucinated = summary_items - source_items
return {
"is_valid": len(hallucinated) == 0,
"hallucinated_items": list(hallucinated)
}
这个校验不算很强,但在线上已经能挡住一批低级错误。尤其是订单场景,少一个数字就会出事故。
4.4 一个压缩前后对比
下面给一个简化的实测风格数据:
| 方案 | 历史对话 token | 总输入 token | p95 时延 | 任务成功率 |
|---|---|---|---|---|
| 不压缩,保留 18 轮原文 | 4680 | 7310 | 4210ms | 82.4% |
| 只做通用摘要 | 1260 | 3980 | 2890ms | 79.8% |
| 做状态摘要 + 最近 6 轮原文 | 1810 | 4320 | 3010ms | 85.1% |
结果很有意思。简单摘要最省 token,但成功率掉了;状态摘要加最近原文,整体更稳。
五、记忆分层:哪些该进长期记忆,哪些只留在会话里
很多应用上线一段时间后,会把“记忆”做成另一个黑洞。什么都存,什么都召回,最后又把上下文撑爆。
我的做法是把记忆分成两类:
5.1 会话级记忆
只在当前 session 有效,比如:
- 当前问题背景
- 当前任务进度
- 临时约束
- 这轮工具调用的中间结果
这类数据可以跟会话绑定,过期后清理。别留太久。
5.2 用户级长期记忆
只存跨会话依然有效的信息,而且要尽量结构化。典型字段:
{
"user_id": "u_1024",
"preferences": {
"language": "zh-CN",
"tone": "concise"
},
"persistent_facts": [
{"key": "industry", "value": "manufacturing", "confidence": 0.92},
{"key": "role", "value": "procurement_manager", "confidence": 0.87}
],
"last_updated_at": "2026-04-15T10:20:00Z"
}
这里要卡得严一点。用户随口说一句“我今天赶时间”,不该直接变成长期记忆;“默认给我输出表格”和“我只看中文回复”这种,才值得保留。
5.3 记忆写入策略
我会给记忆写入加一个判定层,不满足条件就不落库:
def should_write_memory(candidate):
if candidate["type"] not in {"preference", "persistent_fact"}:
return False
if candidate.get("confidence", 0) < 0.8:
return False
if len(candidate.get("value", "")) > 200:
return False
return True
短句很关键:不是所有历史都配叫记忆。
5.4 记忆召回策略
召回时不要全量注入。我一般会按“相关性 + 新鲜度 + 置信度”排序,取前 N 条。
def rank_memories(memories, query_embedding, now_ts):
ranked = []
for m in memories:
relevance = m.get("relevance", 0.0)
confidence = m.get("confidence", 0.0)
freshness = m.get("freshness", 0.0)
score = 0.6 * relevance + 0.3 * confidence + 0.1 * freshness
ranked.append((score, m))
ranked.sort(key=lambda x: x[0], reverse=True)
return [m for _, m in ranked[:5]]
我自己踩过一个坑:长期记忆一多,旧偏好会压过当前意图。后来把会话短期层优先级提到长期记忆前面,这个问题缓了很多。
六、长上下文不是越长越好:做预算控制比盲目扩窗更有效
很多团队在模型支持 64k、128k 甚至更长窗口后,会自然地认为问题解决了。实测下来,这个想法很容易失真。
原因很简单:
- 长上下文会带来更高的推理成本
- 检索噪声也会跟着放大
- 模型对远距离信息的利用率并不稳定
所以我会做一层上下文预算管理器,根据请求类型动态分配 token,而不是固定写死。
6.1 一个简单预算表
| 场景 | system | recent chat | summary | memory | docs | tool |
|---|---|---|---|---|---|---|
| 通用问答 | 800 | 1200 | 600 | 400 | 1800 | 600 |
| 文档问答 | 700 | 800 | 500 | 300 | 3200 | 700 |
| 任务执行 Agent | 900 | 1400 | 700 | 500 | 1200 | 1800 |
有预算表之后,再根据问题类型做路由。比如用户问“帮我解释这份制度差旅报销部分”,就把 docs 预算拉高;如果用户是在做多轮任务执行,就给 recent chat 和 tool result 更多空间。
6.2 动态裁剪实现
def allocate_budget(intent: str):
plans = {
"qa": {"max_prompt_tokens": 5400, "system_max": 800, "recent_max": 1200, "summary_max": 600, "memory_max": 400, "docs_max": 1800, "tool_max": 600},
"doc_qa": {"max_prompt_tokens": 6200, "system_max": 700, "recent_max": 800, "summary_max": 500, "memory_max": 300, "docs_max": 3200, "tool_max": 700},
"agent": {"max_prompt_tokens": 6500, "system_max": 900, "recent_max": 1400, "summary_max": 700, "memory_max": 500, "docs_max": 1200, "tool_max": 1800},
}
return plans.get(intent, plans["qa"])
6.3 裁剪顺序也要设计
我一般不会先砍 system prompt,也不会先砍最近对话。一个更稳的顺序是:
- 先压缩低分检索片段
- 再裁剪冗长工具返回
- 再把较早对话合并进摘要
- 最后才收缩长期记忆注入数量
这背后的原因很现实。系统规则通常是行为边界,最近几轮对话是当前意图,真要删也应该最后动。
七、RAG 场景里,上下文治理和召回治理要一起做
在文档问答里,很多人把“回答不准”归因到模型,其实常见问题是检索片段太碎、太多、太重复。然后这些片段又全被塞进上下文。
这时别急着加大窗口。先查片段质量。
7.1 检索结果去重与合并
def deduplicate_docs(docs):
seen = set()
result = []
for d in docs:
key = (d.get("doc_id"), d.get("chunk_text", "")[:120])
if key in seen:
continue
seen.add(key)
result.append(d)
return result
7.2 相邻 chunk 合并
如果检索命中了同一文档的相邻段落,我更倾向先做合并,再交给模型。这样能减少边界断裂。
def merge_adjacent_chunks(chunks):
chunks = sorted(chunks, key=lambda x: (x["doc_id"], x["chunk_idx"]))
merged = []
for c in chunks:
if not merged:
merged.append(c.copy())
continue
last = merged[-1]
if c["doc_id"] == last["doc_id"] and c["chunk_idx"] == last["chunk_idx"] + 1:
last["chunk_text"] += "\n" + c["chunk_text"]
last["chunk_idx"] = c["chunk_idx"]
else:
merged.append(c.copy())
return merged
7.3 文档片段再压缩
对特别长的法规、手册类文档,我会多做一步“查询相关摘要”,把每段压成与问题相关的证据句,而不是整段照搬。
这个步骤有收益,但有个小缺点:如果摘要模型质量一般,容易漏掉边角条件。所以我只在原始文档很长、且预算确实不够时启用,不会默认全开。
八、线上治理少不了降级策略
再好的预算设计,也会遇到极端输入。比如用户一次贴 3 万字合同,或者工具返回一个超长表格。
这时要有明确降级动作,不然服务就会抖。
8.1 常见降级方案
- 检测用户输入过长,先做分段理解
- 工具返回只保留关键字段和统计结果
- RAG 结果只保留 top-k 高分证据
- 超预算时提示用户聚焦问题范围
8.2 一个简单的超预算处理器
def over_budget_fallback(context_parts, token_counter, max_tokens):
compact_parts = []
used = 0
priority = ["system", "recent_msgs", "summary", "docs", "memories", "tool_results"]
ordered = sorted(context_parts, key=lambda x: priority.index(x[0]))
for name, text in ordered:
tokens = token_counter(text)
if used + tokens <= max_tokens:
compact_parts.append((name, text))
used += tokens
continue
# 对长文本截断保底
remaining = max_tokens - used
if remaining > 100:
compact_text = text[: max(remaining * 2, 200)]
compact_parts.append((name, compact_text))
used = max_tokens
break
return compact_parts
生产环境里我会比这更细,至少要按 token 而不是字符裁剪。但思路就是这样,先保主干,再保细节。
九、怎么评估上下文治理到底有没有效果
如果没有评测,最后很可能只剩“感觉快了一点”这种结论。这个不够用。
我一般做两层评估:离线回放 + 线上 A/B。
9.1 离线回放数据集
从真实日志抽样,覆盖这几类样本:
- 短对话,几乎不需要压缩
- 长对话,历史依赖强
- 文档问答,检索片段长
- Agent 任务,工具结果冗长
每条样本保留:用户输入、历史对话、检索文档、工具返回、标准答案或人工判分结果。
9.2 评测指标
| 指标 | 说明 |
|---|---|
| token 降幅 | 新方案相对基线节省多少输入 token |
| latency 降幅 | p50/p95 时延变化 |
| answer_consistency | 压缩前后回答语义是否一致 |
| key_fact_retention | 关键事实是否仍被保留 |
| task_success_rate | 真实任务是否完成 |
9.3 一个简单评测脚本
def evaluate_case(case, pipeline):
result = pipeline.run(case)
return {
"case_id": case["case_id"],
"prompt_tokens": result["prompt_tokens"],
"latency_ms": result["latency_ms"],
"success": result["success"],
"key_fact_retention": result["key_fact_retention"],
}
def compare_pipelines(cases, baseline, candidate):
rows = []
for case in cases:
b = evaluate_case(case, baseline)
c = evaluate_case(case, candidate)
rows.append({
"case_id": case["case_id"],
"baseline_tokens": b["prompt_tokens"],
"candidate_tokens": c["prompt_tokens"],
"baseline_success": b["success"],
"candidate_success": c["success"],
"baseline_retention": b["key_fact_retention"],
"candidate_retention": c["key_fact_retention"],
})
return rows
9.4 一组可参考的实测结果
下面这组数据来自一个多轮业务问答助手的回放实验,样本量 500:
| 方案 | 平均 prompt token | p95 时延 | 关键事实保留率 | 任务成功率 |
|---|---|---|---|---|
| 基线:最近 20 轮 + top8 文档全塞 | 6840 | 4380ms | 91.2% | 81.6% |
| 方案A:只做摘要 | 3920 | 2960ms | 84.7% | 77.9% |
| 方案B:状态摘要 + 记忆分层 + docs 预算控制 | 4410 | 3090ms | 92.5% | 85.4% |
我自己当时看到这组结果,没想到最省 token 的方案反而不是上线选择。原因很简单,线上系统不是做压缩比赛,最终还是看整体收益。
十、一个可直接参考的落地清单
如果你准备在现有应用里接入上下文治理,我建议按这个顺序推进:
10.1 先补观测
先拿到每次请求的:
- 各上下文来源 token 占比
- 超预算比例
- 截断发生位置
- 请求时延
- 任务成功率或人工兜底率
没有这些数据,后面的优化很容易偏。
10.2 再做预算表
按场景区分预算,不同任务不同配额。固定一个总上限,然后给 system、recent chat、summary、memory、docs、tool 分配额度。
10.3 接着做状态摘要
从“总结全文”改成“保留可执行状态”,并加最基础的事实校验。
10.4 最后做记忆分层
把长期有效信息单独沉淀,别把整段历史都塞进 memory。召回时按相关性、置信度、新鲜度排序,只取少量高分项。
十一、结尾
上下文窗口治理,本质上不是单纯压 token,也不是一味追求更长窗口。工程上更有用的做法,是把上下文看成一种受限资源:谁该进窗口,进多少,什么时候压缩,超预算时怎么退让,都要提前设计。
真在线上跑起来后,你会发现一个很实际的规律:保留正确的信息,比保留更多的信息更值钱。
如果你现在的系统已经出现这些信号——成本上涨、长对话质量变差、RAG 文档越塞越多、Agent 工具结果越来越长——那可以先从预算埋点和状态摘要开始做,改动不算大,但通常能很快看到对比结果。
后面如果你愿意,我可以继续写一篇配套文章,把“上下文治理的离线评测集怎么构建、关键事实保留率怎么标”拆开讲,给一套更细的可复现方案。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)