5.2. 滑动窗口与循环缓存是最直接的防线
你需要什么
- Python 3.10+ 运行环境
- tiktoken(OpenAI 开源的分词器)与 openai 客户端库
- 一个可用的 LLM API key(OpenAI 兼容接口即可;本教程以 OpenAI 为例)
- 基本命令行操作技能
- 预计时间:60–75 分钟
注意:即使你使用的是 Anthropic、Gemini 或本地模型,tiktoken 的精确 token 计数逻辑仍然适用;你只需要切换到对应的 tokenizer 即可。本章所有代码均可在纯 Python 环境中运行,无需依赖任何 agent 框架。
最终成果
你将实现一个可配置的滑动窗口记忆类,它:
- 使用 tiktoken 进行与模型完全一致的 token 计数(无偏差);
- 在插入新消息时自动执行基于 token 数的精确截断,且永远不会把一条完整消息从中切断;
- 以 O(1) 时间复杂度完成写入与裁剪,适合高并发的流式对话场景;
- 能够快速切换窗口大小,并搭配一个轻量评估脚本,直观对比不同窗口对任务完成质量的量化影响。
为什么要做这件事?生产环境中的上下文溢出不像超时或 500 那样显眼,它让模型在信息充足的前提下给出错误答案——你会误以为问题出在 prompt、模型或数据上。滑动窗口是第一条可被精确配置和验证的防线,它不依赖任何“聪明”的记忆策略,却在大多数场景下将噪音关在门外。
步骤说明
一、理解滑动窗口的基础模型
一条典型的对话历史是时序递增的消息列表:
[system, user, assistant, user, assistant, ..., user, assistant]
当消息总 token 数超出模型上下文窗口时,纯粹的前端截断(扔掉最旧的消息)虽然简单,却可能把一条 user 消息的后半句截断,或者把 system prompt 切掉。因此滑动窗口的正确行为是:始终保留完整的 system prompt,然后从最早的一条完整对话开始丢弃,直到总 token 数 ≤ 窗口容量。
循环缓冲区(ring buffer)可以把这个过程做到 O(1):用一个双端队列(collections.deque)存储消息,移除最左端元素时直接 popleft(),无需移动整个数组。
二、安装依赖并获取一个精确的 tokenizer
根据模型选择正确的 encoding:
- GPT‑4、GPT‑4 Turbo、GPT‑3.5‑turbo 共用
cl100k_base - GPT‑4o、GPT‑4o‑mini 使用
o200k_base - 其他模型请参考 OpenAI 官方文档
pip install tiktoken openai
在 Python 中验证计数准确性:
import tiktoken
# 对于 GPT‑4 等模型
enc = tiktoken.get_encoding("cl100k_base")
tokens = enc.encode("Hello, world!")
print(len(tokens)) # 4
踩坑经验
tiktoken 的encode()返回的是 BPE token id 的列表,长度即为 token 数,该数量与 API 实际计费 token 数完全一致,无偏差。千万不要用len(string) // 4这种粗糙估算,它在中文、代码、特殊字符上误差可达 40%。
另外,不要混用 encoding:如果你用为 GPT‑4 计数的 encoding 去评估 GPT‑4o 的消费,会在新模型上产生约 5‑10% 的偏差,因为o200k_base的分词表更大、切分更细。
三、实现基于 token 的条件截断器
创建一个类 TokenWindowMemory,它维护:
system_message:始终保留,不计入窗口时可单独处理(本实现将其纳入计数,但保证不被截断)messages:双端队列,存储{"role": ..., "content": ...}字典max_tokens:窗口容量encoding:tiktoken Encoding 对象
核心方法 add_message 的逻辑:
- 将新消息追加到
messages右侧。 - 计算当前所有消息的总 token 数(system + 队列中所有消息)。
- 若总 token 数 >
max_tokens,从messages最左侧开始,逐条计算该条消息的 token 数,并移除该条消息,直到总 token 数回落到max_tokens以内。这一步确保永远不会从一条消息中间切断。
from collections import deque
import tiktoken
from typing import List, Dict, Optional
class TokenWindowMemory:
def __init__(self, max_tokens: int, encoding_name: str, system_message: Optional[Dict] = None):
self.max_tokens = max_tokens
self.encoding = tiktoken.get_encoding(encoding_name)
self.system_message = system_message # {"role": "system", "content": "..."}
self.messages: deque[Dict] = deque()
def _count_tokens(self, message: Dict) -> int:
"""计算单条消息的 token 数(包含 role 标记的少量开销,近似精确)。"""
# 实际 API 会有额外的每消息标记,这里用近似方式:role 的 token 数 + content 的 token 数
# 更精确的方式是使用 openai 提供的 token 计数工具,但此近似误差 < 2 tokens/消息,可接受。
return len(self.encoding.encode(message["role"])) + \
len(self.encoding.encode(message["content"]))
def total_tokens(self) -> int:
"""当前占用 token 总数。"""
total = self._count_tokens(self.system_message) if self.system_message else 0
for msg in self.messages:
total += self._count_tokens(msg)
return total
def add_message(self, role: str, content: str):
"""添加一条用户或助手消息,并自动执行基于 token 的截断。"""
new_msg = {"role": role, "content": content}
self.messages.append(new_msg)
# 剪裁至窗口大小
while self.total_tokens() > self.max_tokens and len(self.messages) > 0:
removed = self.messages.popleft() # O(1) 移除最旧的一条完整消息
# 可选:记录或 debug removed 消息
def get_messages_for_api(self) -> List[Dict]:
"""获取用于 API 调用的完整消息列表。"""
result = []
if self.system_message:
result.append(self.system_message)
result.extend(list(self.messages))
return result
def clear(self):
self.messages.clear()
预期结果:创建一个容量为 500 token 的窗口实例,添加几条超长消息后,total_tokens() 始终 ≤ 500,且 get_messages_for_api() 返回的每条消息都完整无缺。
四、用循环缓冲区支撑高吞吐流式场景
为什么上面使用 deque 而不是 Python 列表?
list.pop(0)的时间复杂度为 O(n),当窗口内历史消息数量很大时(例如几百条),每次移除最旧消息都会导致整个列表的移动。deque.popleft()是 O(1),无论窗口大小,每次写入(添加 + 可能截断)的时间都是常数级。
在流式对话或者 WebSocket 连接中,这个 O(1) 保证能让你的上下文管理成为“无感”组件。实际压测中,使用 list 的实现在历史长度超过 100 条时,单次 add_message 的耗时从微秒级飙升至毫秒级,直接拖慢整体推理管道。
五、评估不同窗口大小对任务质量的影响
我们现在搭建一个小型实验框架,让你直观感受到窗口大小如何决定答案正确与否。
场景设计:一个客服代理需要回答关于“订单历史”的问题。对话历史中依次给出了 5 个订单的详细信息(每个订单约占 80 token),然后用户在第 6 轮提问:“我第二笔订单的物流单号是多少?”
如果窗口太小,最早的那些订单信息会被裁剪掉,模型将不得不编造或返回“未知”。
import openai
import time
# 初始化 openai client(需设置 OPENAI_API_KEY 环境变量)
client = openai.OpenAI()
def simulate_dialogue_and_test(memory: TokenWindowMemory, question: str, ground_truth: str):
"""
使用 memory 中当前的对话历史,让 LLM 回答一个问题,并与标准答案比较。
返回 1 表示完全匹配(简化判断),否则 0。
"""
messages = memory.get_messages_for_api()
# 追加当前问题
messages.append({"role": "user", "content": question})
response = client.chat.completions.create(
model="gpt-4o-mini", # 使用轻量模型加速实验
messages=messages,
temperature=0.0
)
answer = response.choices[0].message.content.strip().lower()
return 1 if ground_truth.lower() in answer else 0
# 准备一份模拟的客户服务对话历史
dialogue = [
("user", "嗨,我想查一下我的订单。"),
("assistant", "当然,请问您的账户 ID 是多少?"),
("user", "账户 ID 是 8821。"),
("assistant", "已找到您的账户。您共有5笔订单。第一笔:订单号 A1001,物流单号 SF12345,状态已签收。"),
("assistant", "第二笔:订单号 A1002,物流单号 SF67890,状态运输中。"),
("assistant", "第三笔:订单号 A1003,物流单号 SF24680,状态已发货。"),
("assistant", "第四笔:订单号 A1004,物流单号 SF13579,状态已签收。"),
("assistant", "第五笔:订单号 A1005,物流单号 SF54321,状态备货中。"),
]
# 测试三种窗口大小
for window_size in [200, 500, 1000]: # 200 token 明显不够
mem = TokenWindowMemory(max_tokens=window_size, encoding_name="cl100k_base",
system_message={"role": "system", "content": "你是客服助手,仅根据对话历史回答。"})
for role, content in dialogue:
mem.add_message(role, content)
score = simulate_dialogue_and_test(mem, "我第二笔订单的物流单号是什么?", "SF67890")
print(f"窗口大小 {window_size:4d} token — 回答正确:{bool(score)}")
time.sleep(0.5) # 避免限流
运行结果(典型输出):
窗口大小 200 token — 回答正确:False
窗口大小 500 token — 回答正确:True
窗口大小 1000 token — 回答正确:True
当窗口只有 200 token 时,历史消息被裁剪得只剩最后两三轮对话,第二笔订单的信息早已消失。模型可能胡诌一个单号或回答“我不知道”。一旦将窗口放宽到 500 token,所有关键信息得以保留,答案立刻准确。
踩坑经验
实验时,如果你发现 500 token 下仍偶尔失败,可能是 system prompt 过长占据了窗口。务必打印total_tokens()与剩余消息条数进行调试。此外,不同模型的 tokenizer 对同一文本的计算结果不同,务必使用与模型一致的 encoding。
六、滑动窗口的失效场景——并引出下一条防线
滑动窗口并非银弹。它在以下场景会彻底失效:
- 任务需要完整历史:例如法律合同审查、长文档问答,你必须在上下文中保留整个合同全文。滑动窗口会把关键条款裁掉。
- 多步推理依赖早期信息:一份问题诊断可能在第 1 轮收集了症状,第 10 轮才综合判断,窗口策略使中间证据丢失。
- 用户的期待是“无限记忆”:在个性化陪伴场景中,用户会问“上次推荐的餐厅是哪家?”,如果已超出窗口,代理将失忆。
此时,摘要与压缩便登场了。下一章我们将实现“摘要即过滤”,用 LLM 将已溢出窗口的历史压缩为高密度表示,作为滑动窗口的补充记忆层。
回顾
在这一章中,你:
- 用
tiktoken拿到了与模型计费完全一致的 token 计数; - 实现了
TokenWindowMemory,一个基于deque的循环缓冲区,能在 O(1) 时间内完成带 token 约束的消息写入,并且绝对不截断一条完整消息; - 搭建了可复现的评估脚本,量化对比了不同窗口大小对任务准确率的直接影响;
- 识别出滑动窗口的边界,为下一层的摘要记忆铺好路。
整个过程大约需要 60 分钟,但获得的是一个可以立刻接入任何对话代理生产管线的核心组件。
行动清单
- [ ] 确认你的模型对应的 tiktoken encoding 名称(
cl100k_base或o200k_base) - [ ] 实现
TokenWindowMemory并针对你的业务消息格式调整_count_tokens - [ ] 在 3 种不同窗口大小下运行评估脚本,找到你的任务的最优窗口
- [ ] 为窗口裁剪添加日志,监控实际截断频率
- [ ] 阅读下一章《摘要即过滤:用 LLM 压缩上下文》,准备耦合滑动窗口与摘要记忆
滑动窗口为你守住了最关键的那道门,但当任务需要看到更远的过去,你就需要一种更“浓缩”的表示方式。下一章,我们着手把那些被挡在窗口外的历史,提炼成可以塞回窗口内的精华。
上下文治理:AI Agent 系统设计
关于 LearnKu