☰
把Claude上下文窗口撑到100K token:分层记忆与检索增强实战
2026/10/17 7:24:37 网站建设 项目流程

把 Claude 的上下文窗口从默认限制一路撑到 100K token 以上,这句话在不少 Agent 项目里已经不只是优化指标,而是决定能不能跑通的生死线。我搭过长文档问答、任务型对话,也帮同事调过代码仓库分析脚本,最后发现一个很朴素的结论:与其想尽办法让模型一次看完全部历史,不如把记忆拆成工作区、摘要区和检索区,让每次请求只带最该带的东西。这篇文章会讲我实测过的扩展方案、一段能直接改的 Python 上下文管理器,以及几个踩完记得很牢的坑,适合正在为长上下文窗口发愁的开发者。

1. 为什么上下文窗口成了项目的硬瓶颈

1.1 100K token 到底能装多少内容

先解决一个最基础的问题:100K token 是个什么体量。很多人第一反应是“这还不够大吗”,但真把业务文本往里塞的时候,很快就发现不够用。

不同分词器对中英文的消耗差别很大。我按实测经验给一个粗略换算:

内容类型1 token 大约对应100K token 大约能装
中文文本1 个汉字 ≈ 1.2~1.5 token7~8 万个汉字
英文文本1 个单词 ≈ 1.2~1.6 token6~7 万个单词
混合代码1 行常见代码 ≈ 8~15 token七八千行代码
多轮对话1 轮问答 ≈ 200~600 token200~400 轮常规问答

8 万汉字看着不少,但放到真实业务里就是两本中篇小说、一份完整产品说明书、一个中型仓库的两三个核心模块,或者一个 Agent 在跑复杂任务时 30~50 轮的工具调用记录。也就是说,只要你做的是“长内容”而不是“短问答”,100K 窗口被击穿只是时间问题。

还有一个容易被忽略的点:上下文窗口不是“输入上限”,是“输入 + 输出”的上限。你还要给模型的回复留出空间。通常至少留 4K~8K token 给输出。所以实际可用的输入空间,大概只有窗口上限的 85%~90%。如果我们按 100K 算,真正可用的输入大概只有 85K~90K,再掰去系统提示、对话格式占用的 token,实际“干货区”会更小。

1.2 三类最容易撞上窗口墙的场景

结合我自己的项目经历,下面三类场景是撞墙重灾区。

第一类是长文档问答。典型如合同审查、产品文档问答、论文阅读助手。一份合同模板动不动就两三万字,转成 token 已经吃掉大半窗口。如果用户再连续追问多个条款,每一轮都要把整份合同重新塞进去,token 消耗会非常难看。而且很多合同的关键约束分散在不同条款里,模型必须在全文范围内做关联,单纯截断文本基本等于失效。

第二类是多轮任务型 Agent。Agent 跑起来之后每轮都会新增用户指令、工具调用结果、中间推理过程。这些内容叠加起来很快。我在一个客服分单项目里遇到过,Agent 跑到第 25 轮以后,历史记录里光工具返回的订单状态和用户日志就有 5 万多 token。这时候如果一股脑全部塞回上下文,不仅费用暴增,模型还经常抓到旧状态当最新状态用。

第三类是代码仓库分析。把整个仓库塞进窗口听起来很爽,但实际并不可行。除了 token 爆掉,更麻烦的是代码之间的依赖关系横跨大量文件,模型在超长上下文里能记住的信息并没有想象中那么多。我见过把 200K 上下文直接铺满的测试,效果反而不如只注入关键文件的方案。

所以,上下文扩展这件事不是“把窗口调大”这么简单,而是要解决一个核心矛盾:如何在有限窗口里,保留更多有效信息。

2. 拆解突破 100K 的三条技术路线

2.1 原生扩展:直接调大窗口上限

最直接的办法是在平台侧申请更大的上下文窗口。如果业务逻辑要求“一次性看到全部文本”,比如分析一段完整的代码架构、对比长文档前后矛盾的条款,那原生扩展就是最省事的选择。

但原生扩展有代价。第一是费用随输入 token 线性增长,窗口越大,每轮请求的输入都更大。第二是长上下文并不等于高质量理解,模型在处理超长文本时会出现“中间迷失”现象:位于上下文中间位置的信息容易被忽略,只有开头和结尾的内容被优先关注。我自己的体会是,一旦单次输入超过 30K token,模型对中间段细节的召回率会明显下滑。

所以原生扩展适合“偶尔需要全量审视”的任务,不适合“每轮都必须携带大量历史”的对话场景。换句话说,原生窗口解决的是“长度”问题,解决不了“注意力”问题。

2.2 递归摘要:把旧历史压成记忆条

递归摘要的思路很直观:对话或文档太久远的部分,不再保留原文,而是定期生成摘要,把摘要留在上下文里。就像人读书做笔记,不需要每页都记在脑子里,但需要知道自己读过什么。

实现上通常是这样的流程:

  1. 维护一份完整历史消息列表。
  2. 每当历史 token 超过预设阈值,就把最早的一段历史取出来。
  3. 调用模型生成这段历史的摘要。
  4. 用摘要替换原始历史,原始消息可以丢弃或归档。
  5. 新的对话继续追加到历史末尾,循环往复。

递归摘要的好处是 token 稳定可控。无论对话进行多少轮,输入 token 都被压在一个相对固定的范围内。缺点是摘要会丢掉细节,而且摘要质量直接决定后续回答质量。更麻烦的是,一旦摘要生成后原始文本被丢弃,如果后续问题恰好需要摘要里没有的信息,就彻底找不回来了。

可以给摘要方案加一个补救措施:摘要生成后,原始消息不直接丢弃,而是进入检索索引。这样摘要负责“常驻记忆”,原始文本负责“按需召回”,两条腿走路。

2.3 检索增强:把外部档案变成临时上下文

检索增强的思路是彻底不依赖“全部塞入”,而是把长文本切成小片段并构建索引。每轮请求先根据用户问题检索最相关的几个片段,只把这些片段注入上下文。

这个方案的信息密度最高。同样是 10K token,塞入的如果是与问题高度相关的片段,信息价值可能比 100K token 的无关内容还高。它天然适合超长文档问答、企业知识库、代码片段检索等场景。

代价是需要额外搭建检索链路。最简单的是关键词检索或 BM25,效果也不错;业务规模再大一些,可以上向量检索。用向量方案的话,选一个稳定的嵌入模型,把文本片段离线向量化,查询时算相似度取 TopK 即可。这套链路初看麻烦,跑顺之后收益非常大。

2.4 分层记忆架构:把上面三条路线串起来

真正能稳定撑住长时间任务的是分层记忆架构,我一般把它分成三层:

  • L1 工作区:最近的对话原文,尽量保留,因为这是当前任务最相关的部分,也是模型做推理的主要依据。
  • L2 摘要区:更早历史的压缩摘要,提供全局脉络,让模型知道“之前聊过什么、定过什么约束”。
  • L3 检索区:全部原始文档和历史消息的索引,按需召回,补充摘要里没存住的细节。

每次构造请求时,把三层的产出合并:L1 原文 + L2 摘要 + L3 检索到的相关片段。这样即使模型窗口只有 100K,也能有效地“接入”超过 1M token 的长期记忆。

我自己实践下来,这个架构比单用任何一种方案都稳。它不是骗模型“我有更大的窗口”,而是把信息重新排列,让有限窗口里的每一段内容都更有价值。

3. 实操:手写一个上下文扩展管理器

3.1 组件设计

我建议把上下文管理器设计成三个独立组件:

  • Token 计数器:负责估算文本 token,触发压缩和拼接时都要用到。
  • 摘要器:负责把旧历史变成摘要,实际项目中替换成模型调用即可。
  • 检索器:负责从文档索引里召回相关片段,生产环境可以替换成向量检索或 BM25。

这三个组件合起来,对外只暴露几个简单方法:add_document、add_message、build_request_messages。这样业务代码不用关心内部怎么拆历史,只需要在每次对话时把消息丢进管理器,然后取回构造好的请求消息。

3.2 核心代码实现

下面这段代码是一个可运行的简化版本,我在本地点过几轮,能跑通基本流程。生产环境建议把 token 估算换成目标模型提供的官方 tokenizer,把摘要和检索替换成真实实现。

import re from typing import List, Dict # ---------- token 估算与切分 ---------- def estimate_tokens(text: str) -> int: """粗略估算 token 数。中文按 1 字约 1.3 token, 英文按 1 词约 1.4 token,生产环境请交给官方 tokenizer。""" cjk_count = len(re.findall(r'[\u4e00-\u9fff]', text)) word_count = len(re.findall(r'[A-Za-z]+', text)) rest = len(text) - len(re.findall(r'[\u4e00-\u9fffA-Za-z]+', text)) return int(cjk_count * 1.3 + word_count * 1.4 + rest * 0.4) def split_document(text: str, chunk_token: int = 800, overlap_token: int = 100) -> List[str]: """按句子切分,并合并到目标 token 大小,块之间保留少量重叠。""" sentences = re.split(r'(?<=[。!?;])\s*', text) chunks: List[str] = [] current = [] current_len = 0 for s in sentences: s_len = estimate_tokens(s) if current_len + s_len > chunk_token and current: chunk_text = "".join(current) chunks.append(chunk_text) overlap_text = chunk_text[-overlap_token:] current = [overlap_text] if overlap_text else [] current_len = estimate_tokens(overlap_text) current.append(s) current_len += s_len if current: chunks.append("".join(current)) return chunks # ---------- 上下文管理器 ---------- class ContextManager: def __init__( self, max_window_tokens: int = 100_000, summary_threshold_ratio: float = 0.7, recent_window_tokens: int = 20_000, ): self.max_window_tokens = max_window_tokens self.summary_threshold = int(max_window_tokens * summary_threshold_ratio) self.recent_window_tokens = recent_window_tokens self.history: List[Dict[str, str]] = [] self.summaries: List[str] = [] self.document_index: List[str] = [] def add_document(self, doc: str) -> None: self.document_index.extend(split_document(doc)) def add_message(self, role: str, content: str) -> None: self.history.append({"role": role, "content": content}) if self._history_tokens() > self.summary_threshold: self._truncate_via_summary() def _history_tokens(self) -> int: return sum(estimate_tokens(m["content"]) for m in self.history) def _truncate_via_summary(self) -> None: batch_size = max(len(self.history) // 2, 2) batch = self.history[:batch_size] if not batch: return summary = self._summarize(batch) self.summaries.append(summary) self.history = self.history[batch_size:] def _summarize(self, messages: List[Dict[str, str]]) -> str: # 实际项目中改成调用模型生成摘要 preview = ";".join(m["content"][:18] for m in messages[:3]) return f"[已压缩{len(messages)}条消息] {preview}" def _retrieve(self, query: str, top_k: int = 3) -> List[str]: # 生产环境建议替换成向量检索或 BM25,这里用字符交集演示 if not self.document_index: return [] q_chars = set(query) scored = [] for chunk in self.document_index: chunk_chars = set(chunk) overlap = len(q_chars & chunk_chars) / max(len(chunk_chars), 1) scored.append(overlap) top = sorted(range(len(scored)), key=lambda i: scored[i], reverse=True)[:top_k] return [self.document_index[i] for i in top] def _recent_messages(self) -> List[Dict[str, str]]: collected = [] used = 0 for msg in reversed(self.history): t = estimate_tokens(msg["content"]) if used + t > self.recent_window_tokens and collected: break collected.append(msg) used += t return list(reversed(collected)) def _last_user_content(self) -> str: for msg in reversed(self.history): if msg["role"] == "user": return msg["content"] return "" def build_request_messages(self) -> List[Dict[str, str]]: system_msg = {"role": "system", "content": "请优先参考摘要中的历史信息与检索片段,结合最近对话回答。"} messages = [system_msg] if self.summaries: messages.append({ "role": "user", "content": "以下是更早对话的摘要,用于提供背景:\n" + "\n---\n".join(self.summaries) }) query = self._last_user_content() retrieved = self._retrieve(query, top_k=3) if retrieved: messages.append({ "role": "user", "content": "以下是检索到的相关资料片段:\n" + "\n---\n".join(retrieved) }) messages.extend(self._recent_messages()) return messages # ---------- 使用示例 ---------- def call_model(messages): # 实际项目里替换成真实模型调用,这里只演示流程 return "收到,我会先查看摘要和检索片段,再结合最近对话回答。" cm = ContextManager(max_window_tokens=100_000) cm.add_document("这里放一份非常长的产品说明文档,包含安装步骤、参数说明、故障码含义等。") # 模拟用户与模型的多轮问答 for user_input in [ "这个产品的故障码 E203 是什么意思?", "安装时需要额外注意哪些接口?", "结合刚才提到的内容,帮我整理一份排查清单。" ]: cm.add_message("user", user_input) reply = call_model(cm.build_request_messages()) cm.add_message("assistant", reply)

这段代码里最关键的是build_request_messages方法的组装顺序:系统提示最先,摘要其次,检索片段再次,最近对话最后。顺序很有讲究。把摘要和检索片段放在头部,能保证它们进入模型注意力较好的位置;最近对话放在靠后的位置,也能让模型优先看到当前正在讨论的内容。

3.3 关键逻辑走查

我拿一个具体场景演示一下这套管理器怎么把 100K 的“物理窗口”变大。

假设模型窗口上限是 100K token,我把摘要触发阈值设为 70K。也就是说只要历史消息累计超过 70K,就会触发一次压缩,把最旧的一半历史变成摘要。假设历史里有 60 轮对话,总 token 一共 75K,触发压缩后:

  • 最旧的 30 轮被压缩成一段摘要,按我的经验大概占 3K~6K token。
  • 历史消息只剩最近 30 轮,假设约 25K token。
  • 从 3 万字的文档索引里检索出 3 个相关片段,每个约 800 token,合计约 2.4K token。
  • 系统提示和问题占约 2K token。

最后拼接出的请求消息大约在 33K token。对比压缩前的 75K,直接省了一半还多。全部塞入原来会很快击穿 100K 上限;而分层组合后,每轮请求都稳定在 30K~40K token 区间,模型还能得到更精准的信息。

这个示例里最容易被忽视的是add_message里自动触发的压缩点。没有这个自动压缩,历史会一直膨胀,直到某一次请求因为 token 超出而被平台拒绝。有了自动压缩,管理器就像一个自动整理内存的进程,把旧数据归档、压缩、丢弃,给新数据腾出空间。

4. 参数选型与量化调优

4.1 五个关键参数的经验取值

我调试过不少参数组合,下表是目前用得比较顺的经验值。

参数作用经验取值范围补充说明
max_window_tokens模型窗口上限按平台实际配置不要用满,留出输出空间
summary_threshold_ratio触发摘要的阈值比例0.6~0.75太低容易频繁压缩,太高容易击穿窗口
recent_window_tokens保留原始消息的预算窗口上限的 20%~30%保留太短会丢失正在讨论的细节
chunk_token检索分块大小500~1000 token块太小检索噪音大,块太大信息不精确
overlap_token分块重叠块大小的 10%~15%避免关键信息正好落在切分处被拆散

以 100K 窗口为例,我常用的一组参数是:summary_threshold_ratio = 0.7,recent_window_tokens = 20K,chunk_token = 800,overlap_token = 100。

这里着重说一下为什么summary_threshold_ratio放到 0.7 而不是 0.9。如果阈值设 0.9,历史要到 90K 才压缩,而压缩过程本身需要一次额外的模型调用,还要留下一段窗口给最终回复。在 90K 时触发压缩,压缩后还要拼接摘要、检索片段,很容易逼近甚至超过 100K。0.7 的触发点能留下较多缓冲,压缩后的请求消息也更轻量,模型回答时不会被大量上下文干扰。

4.2 一次完整的参数计算示例

假设我使用 100K 窗口,设置 summary_threshold = 70K,recent_window_tokens = 20K。某次请求构造完成后的 token 分布可能长这样:

组成部分token 数
系统提示500
L2 摘要区6,000
L3 检索片段(3 段)2,400
L1 最近对话18,000
用户当前问题1,200
合计输入28,100
预留输出6,000
占用窗口34,100

34K 对 100K 的窗口来说相当宽松。剩余空间可以容纳更长的回答,或者在摘要区信息不足时临时多注入几段检索片段。这个余量非常有用,因为在调试阶段你永远不知道下一轮用户问题会需要多少额外检索内容。

4.3 成本估算方法

上下文扩展方案的成本核心是“每轮请求消耗的 token 数量”。把全量历史塞入窗口是最昂贵的做法;分层记忆方案则是把大部分历史从“每轮都带”变成“只带摘要 + 按需检索”。

我习惯用一个相对成本公式来看待这个问题:

单轮成本 = 输入 token 数 × 输入单价 + 输出 token 数 × 输出单价

假设输入单价为 P,输出单价约为 3.5P(常见比例)。全量方案如果每轮输入 80K token,输出 6K token,单轮成本大约是 80K×P + 6K×3.5P = 101K×P。分层方案输入约 30K token,输出不变,成本是 30K×P + 6K×3.5P = 51K×P。整体成本降到原来的一半左右。如果对话轮数更多、历史更长,节省会更明显。

当然,分层方案有额外的摘要调用开销。压缩一次历史的成本取决于被压缩文本的长度,通常远低于后续每轮全量携带的成本。以 60 轮对话压一次摘要为例,压缩成本只相当于一两轮全量请求,完全可以接受。

4.4 不要盲目追求最大窗口

我把一个很贵的教训写在前面:不要因为平台上提供了超大窗口,就习惯性把它塞满。窗口越大,单轮输入越贵,延迟越高,模型对中间内容的把握也会变差。有效上下文不是“一次性塞进去的量”,而是“模型能稳定利用的量”。

如果业务场景需要非常长的全局理解,比如分析整个仓库的依赖关系、对比上下文中前面和后面的矛盾表述,使用大窗口是合理的。但如果是多轮对话、任务型 Agent,我更推荐把窗口预算控制在上限的 60%~70%,用摘要和检索来代替“无脑全塞”。

5. 常见问题与排查实录

5.1 高频问题速查表

我整理过一份自己经常翻看的速查表,几乎每次上下文扩展出问题都能在这里找到方向。

现象根本原因排查方法解决方案
模型说“没有看到之前的内容”摘要触发后原始消息脱离上下文打印 build 后的消息列表,检查旧消息是否被截断将关键约束抽到系统提示的不可变区
回答缺少某个关键细节摘要是压缩信息,细节丢失比对摘要与原始文本把原始文本放入检索索引,按需召回细节
检索到无关片段分块粒度不对或 TopK 过大逐块检查切分结果和相关性分数调整块大小、降低 TopK、换更强检索
token 估算和真实计数偏差大自研估算公式不准确对比官方 tokenizer 统计生产环境直接使用官方 tokenizer
历史压缩后语义断裂压缩时机选择不合理观察压缩点前后的对话主题在主题边界处触发压缩,而不是机械按 token
延迟明显上升每轮检索耗时或输入过大分别测试摘要、检索、拼接耗时缓存检索结果、限制最近窗口大小

5.2 一次“摘要吞掉了关键约束”的排查经历

这个坑我印象特别深。调一个客服自动分单 Agent 的时候,用户在第 3 轮明确说过“只处理华东区订单”,结果跑到第 20 轮,模型开始给全国订单分单了。第一反应是压缩摘要丢信息。我去改代码,把摘要内容打印出来,发现摘要里确实没有“华东”这个约束。

但问题不在摘要模型,而在压缩触发逻辑。当时摘要触发点是每满 20 轮压缩一次最旧的一半消息。用户约束在第 3 轮,等第一次触发压缩时,它正好被包进了那一批被压缩的消息里,而摘要提示词写得太简单,没有强调“要保留所有硬性约束”,于是关键规则就在压缩过程中丢了。

修复方式是把“只处理华东区订单”这类指令性内容单独识别出来,放进系统提示的固定区。固定区不参与摘要压缩,每轮请求都原样保留。从那以后我养成了一个习惯:在上下文管理器里单独开一个 hard_rules 列表,所有“必须、禁止、只能”类内容都优先进入这个列表,而不是混在普通对话里。

5.3 检索命中但模型“假装没看到”

另一个让我挠头的场景是:检索片段明明进了上下文,模型还是回答错误,仿佛那段信息根本不存在。后来排查发现,问题出在片段放置的位置。

模型对超长上下文的注意力并不是均匀分布的。放在请求靠前位置的内容容易被优先关注,放在靠后位置的内容也能被关注,但夹在中间的大段内容会被弱化。我之前把检索片段放在摘要之后、最近对话之前,而这个位置恰好属于“中段”。当检索片段不够突出时,模型很容易忽略它。

解决办法有两个。一个是把检索片段放在系统提示之后,作为“第二优先区域”;另一个是在片段前面加一句引导,比如“以下片段是回答当前问题的重要参考,请优先阅读”。这两招配合之后,检索内容的利用率明显提升。

6. 本地压测:用一份长剧本验证扩展效果

6.1 测试场景设计

只看代码和参数还不够,我习惯在本地搭一个可重复的压测环境。下面是我经常用来验证上下文扩展效果的一套测试方案,你也可以照着搭。

测试素材是一份约 5 万字的虚构长篇小说,外加 30 轮围绕小说内容的问答对话。小说里故意埋了 10 个“伏笔”,比如前面出现过的某个物品、某个人物的无名习惯、某段环境描写,都只在第 1~5 章出现过。问答题里会随机考这些细节,用来检验模型能不能从长文本里找回信息。

对照组采用原生方案:把整本小说塞进上下文,然后拼接最近 30 轮对话。处理组采用分层记忆方案:小说切块后进入检索索引,每轮只检索 3 个相关片段,同时携带摘要和最近对话。

6.2 实测数据与结论

我在本地模拟环境里跑了几轮,得到的数据大致如下:

指标原生方案分层记忆方案
单轮输入 token约 61K约 9.5K
10 个细节问题命中率7/109/10
平均单轮延迟较高明显更低
相对成本基准 100%约 30%~40%

分层方案在细节问题上的命中率反而更高,原因很简单:当上下文被压缩到只有摘要、检索片段和最近对话时,模型更容易把注意力放在真正相关的信息上。原生方案虽然把全文都给了模型,但大量无关内容形成了噪音,干扰了模型对关键细节的定位。

这里必须说明一点:数据来自我本地的模拟环境,不是标准 benchmark,结论仅供参考。不同任务、不同文本类型、不同检索实现,结果会有差异。但方向性判断是可靠的——信息密度高的上下文,比信息量大但噪音多的上下文,更利于模型发挥。

6.3 什么情况下应该放弃“扩展”改“切任务”

扩展上下文不是万能药。如果任务本身是高度结构化的,比如“分析这个模块的代码规范、测试覆盖、性能瓶颈”,更好的做法不是把整个模块塞进窗口,而是拆成多个独立子问题,让模型逐个分析后再汇总。

我的判断标准是:如果任务需要同时看到全局关系和跨区域依赖,优先考虑扩展上下文;如果任务可以被拆成多个只依赖局部信息的子任务,优先考虑分步处理。分步处理的好处是每轮输入更少、结果更容易验证、单步失败可以重试,不会因为某段文本超出窗口而整体崩掉。

做个上下文扩展方案之前,先花半小时把任务拆清楚,能省下后面大量的调试时间。

最后分享一个我很坚持的小细节:在构造 request messages 之前,我会给每条消息单独做一次 token 小计,看看哪部分占比最高。一旦总和超过上限的 90%,我优先砍掉检索片段,而不是砍摘要。原因很简单——检索片段丢了随时可以重新查一遍,摘要是一次性压缩产物,丢了就真丢了。上下文扩展做到后面,大部分时间不是在跟模型较劲,而是在跟自己的内存规划较劲,希望这套方案能帮你少走点弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询