大模型应用中的上下文窗口治理:从对话压缩、记忆分层到长上下文成本与效果平衡

在很多 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_p9595 分位响应时延上下文过长导致生成变慢
task_success_rate任务成功率压缩后关键信息丢失
context_hit_rate回答中是否用到了正确上下文检索或记忆注入失效

这里有个工程细节。压缩后 token 下降,不代表质量就稳。

比如客服助手里,用户连续 15 轮讨论退款、发票和物流,如果你把前面的“退款原因”和“订单号”压没了,后面再省 token 也没意义。上线后经常会看到一种假象:成本降了,人工接管率却涨了。

所以治理目标应该是:

  1. 给不同上下文来源设预算上限
  2. 让高价值信息留在窗口里
  3. 在成本、时延、效果之间做动态平衡

三、上下文治理的整体架构

我通常把上下文拆成四层,而不是一股脑拼接:

[系统规则层]
  固定注入,严格控长

[会话短期层]
  保留最近若干轮原始对话

[会话摘要层]
  对较早历史做压缩摘要

[长期记忆层]
  存结构化用户事实、偏好、关键事件

再叠加一个外部知识层,也就是 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总输入 tokenp95 时延任务成功率
不压缩,保留 18 轮原文468073104210ms82.4%
只做通用摘要126039802890ms79.8%
做状态摘要 + 最近 6 轮原文181043203010ms85.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 一个简单预算表

场景systemrecent chatsummarymemorydocstool
通用问答80012006004001800600
文档问答7008005003003200700
任务执行 Agent900140070050012001800

有预算表之后,再根据问题类型做路由。比如用户问“帮我解释这份制度差旅报销部分”,就把 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,也不会先砍最近对话。一个更稳的顺序是:

  1. 先压缩低分检索片段
  2. 再裁剪冗长工具返回
  3. 再把较早对话合并进摘要
  4. 最后才收缩长期记忆注入数量

这背后的原因很现实。系统规则通常是行为边界,最近几轮对话是当前意图,真要删也应该最后动。


七、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 tokenp95 时延关键事实保留率任务成功率
基线:最近 20 轮 + top8 文档全塞68404380ms91.2%81.6%
方案A:只做摘要39202960ms84.7%77.9%
方案B:状态摘要 + 记忆分层 + docs 预算控制44103090ms92.5%85.4%

我自己当时看到这组结果,没想到最省 token 的方案反而不是上线选择。原因很简单,线上系统不是做压缩比赛,最终还是看整体收益。


十、一个可直接参考的落地清单

如果你准备在现有应用里接入上下文治理,我建议按这个顺序推进:

10.1 先补观测

先拿到每次请求的:

  • 各上下文来源 token 占比
  • 超预算比例
  • 截断发生位置
  • 请求时延
  • 任务成功率或人工兜底率

没有这些数据,后面的优化很容易偏。

10.2 再做预算表

按场景区分预算,不同任务不同配额。固定一个总上限,然后给 system、recent chat、summary、memory、docs、tool 分配额度。

10.3 接着做状态摘要

从“总结全文”改成“保留可执行状态”,并加最基础的事实校验。

10.4 最后做记忆分层

把长期有效信息单独沉淀,别把整段历史都塞进 memory。召回时按相关性、置信度、新鲜度排序,只取少量高分项。


十一、结尾

上下文窗口治理,本质上不是单纯压 token,也不是一味追求更长窗口。工程上更有用的做法,是把上下文看成一种受限资源:谁该进窗口,进多少,什么时候压缩,超预算时怎么退让,都要提前设计。

真在线上跑起来后,你会发现一个很实际的规律:保留正确的信息,比保留更多的信息更值钱。

如果你现在的系统已经出现这些信号——成本上涨、长对话质量变差、RAG 文档越塞越多、Agent 工具结果越来越长——那可以先从预算埋点和状态摘要开始做,改动不算大,但通常能很快看到对比结果。

后面如果你愿意,我可以继续写一篇配套文章,把“上下文治理的离线评测集怎么构建、关键事实保留率怎么标”拆开讲,给一套更细的可复现方案。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐