多轮对话的 context 溢出,本质是消息列表超出模型窗口上限。滑动窗口裁剪的目标,不是把历史全部塞进窗口,而是保留对当前回答最有用的部分。设计时先要确定裁剪粒度(按消息条数还是 token 数),再处理固定保留项(系统提示、当前问题),最后通过日志确认裁剪结果。
判断方向:滑动窗口裁剪适合大多数没有内置长记忆机制的开源模型,属于低成本、可落地的止血方案。建议先按消息条数裁剪,再按 token 总数校验;系统提示、用户当前问题必须保留,工具结果和中间轮次可丢。裁剪后需要做质量抽查,不能只看不报错。
先定裁剪粒度:按条数还是按 token 数
按消息条数裁剪最直观:保留最近 N 轮,比如 6 轮。但一轮消息可能很短,也可能是一大段代码或工具返回,单条消息就占去一半窗口。按 token 数裁剪更可控,需要借助模型的分词器统计长度。没有分词器时,可以用字符数估算,但估算结果不一定等于模型实际计分,所以只适合开发调试。
建议的取值方法是:先按最近 N 轮保留,再用 token 数做二次校验。N 可以从 5~8 轮开始试;max_tokens 不要设为模型的完整 context 上限,要留出生成回答的余量。例如模型支持 8192 token,设置 max_response_tokens=1024,那裁剪目标就是 7168 token。
裁剪函数实现:保留 system 和最后一条用户消息
下面这个函数处理 OpenAI 兼容的 messages 列表,适合大多数开源模型的聊天接口。它的规则很保守:system 消息全部保留,最后一条非 system 消息必须保留,然后从最近的往远处逐条加入,直到预估 token 总数接近上限。
def trim_messages_for_window(messages, max_tokens, token_estimator=None):
if token_estimator is None:
token_estimator = lambda text: max(1, len(text) // 2)
system_msgs = [m for m in messages if m['role'] == 'system']
others = [m for m in messages if m['role'] != 'system']
if not others:
return messages
kept = [others[-1]]
total = sum(token_estimator(m['content']) for m in system_msgs) + token_estimator(kept[0]['content'])
for msg in reversed(others[:-1]):
t = token_estimator(msg['content'])
if total + t > max_tokens:
break
kept.insert(0, msg)
total += t
return system_msgs + kept
注意 token_estimator 需要替换为模型自己的分词函数。如果你用的是 Transformers 库,可以这样传入:token_estimator=lambda text: len(tokenizer.encode(text))。
调用位置与验证:在构造请求前裁剪,并记录 token 变化
裁剪要放在构建请求体之前,也就是从数据库或缓存取回历史消息、追加当前用户问题后,再执行裁剪,最后把裁剪结果传给推理服务。下面是一段接入伪代码,同时打印裁剪前后的 token 数,方便确认是否还有溢出风险。
# 假设 tokenizer 已加载,例如:
# tokenizer = AutoTokenizer.from_pretrained('your-model-path')
def tokenize(text):
return len(tokenizer.encode(text))
original = load_messages() # 你的原始历史消息
limit = 7168 # context_limit - max_response_tokens
trimmed = trim_messages_for_window(original, limit, token_estimator=tokenize)
used_before = sum(tokenize(m['content']) for m in original)
used_after = sum(tokenize(m['content']) for m in trimmed)
print(f'before={used_before}', f'after={used_after}', f'trimmed_to={len(trimmed)} msgs')
# 直接调用本地推理服务
resp = requests.post(
'http://127.0.0.1:8000/v1/chat/completions',
json={
'model': 'your-model-id',
'messages': trimmed,
'max_tokens': 1024,
}
)
print(resp.json())
这段伪代码在 service 层完成裁剪和请求发送。验证点有两个:一是裁剪后的 token 数不超过 limit;二是请求能正常返回。如果裁剪后仍然超限,说明最后一条用户消息或 system 提示本身太长,需要单独处理,比如对单条消息做截断或摘要。
边界提醒:别把裁剪当成检索或摘要
滑动窗口裁剪是止血方案,不是长记忆方案。对于需要跨多轮引用的事实,比如用户在前 10 轮提过的偏好,裁剪后可能丢失。如果这类信息重要,应该在裁剪前单独保留,或者把关键信息转为 summary 放入 system 提示。此外,工具调用结果往往很长,如果业务场景不依赖工具结果追溯,裁剪时可以直接丢弃。这些取舍需要在实现前跟产品侧对齐。
质量验证方面,建议准备 5~10 组多轮对话,分别用完整历史和裁剪后历史跑一遍,人工对比回答是否丢失关键信息。这类抽查比看 token 计数更能反映滑动窗口是否可用。