多轮对话不是单轮问答的简单累加。用户说"再便宜一点的",模型必须知道"再"指的是哪件商品、之前筛过什么条件。上下文管理要做的,就是在有限的窗口长度里,让模型"记得住"且"不混淆"。
1. 核心矛盾
- 窗口有限:哪怕是 128K 的模型,也不可能无限堆积聊天记录。
- 成本与延迟:传得越多,Token 越贵、响应越慢。
- 噪音干扰:太久远的闲聊会淹没当前真正重要的信息。
2. 常用策略
| 策略 | 适用场景 | 做法 |
|---|---|---|
| 滑动窗口 | 一般客服/助手 | 只保留最近 N 轮(如最近 10 轮),超出的丢弃。 |
| Token 硬截断 | 精确控制长度 | 按 Token 数从后往前截,确保总长度不超过阈值;系统提示始终保留。 |
| 摘要压缩 | 长文档/深度访谈 | 每过 M 轮,把早期对话调用模型总结成 1~2 句话,替换原始记录。 |
| 槽位缓存 | 任务型对话(订酒店、查订单) | 提取关键实体(时间、地点、价格)存入结构化缓存,后续直接读变量,不用翻历史。 |
3. 最小可用示例
下面是一个兼顾滑动窗口与 Token 截断的简化实现:
class ContextManager:
def __init__(self, max_token=3000, max_rounds=10):
self.history = [] # [(role, content), ...]
self.max_token = max_token
self.max_rounds = max_rounds
self.slots = {} # 关键信息槽位
def add(self, role, content):
self.history.append({"role": role, "content": content})
# 1. 按轮数滑动
if len(self.history) > self.max_rounds * 2: # *2 因为含问答
self.history = self.history[-self.max_rounds * 2:]
# 2. 按 Token 截断(伪代码,实际需调用 tokenizer)
while estimate_tokens(self.history) > self.max_token:
self.history.pop(0) # 丢弃最早的一轮
def get_messages(self, system_prompt):
# 系统提示必须始终置顶
messages = [{"role": "system", "content": system_prompt}]
# 注入槽位摘要,避免模型遗忘关键事实
if self.slots:
slot_text = "已知信息:" + str(self.slots)
messages.append({"role": "system", "content": slot_text})
messages.extend(self.history)
return messages
4. 工程注意事项
- 系统提示不可丢:截断时永远保留
system消息,否则模型会突然"失忆"自己的角色。 - 主动检测话题切换:当用户输入"换个问题"或意图分类显示新主题时,及时清空或归档旧上下文,避免残留干扰。
- 敏感信息定期清理:用户名、手机号等 PII 数据,要么不进上下文,要么对话结束后立即清除,不要长期放在内存或日志里。
- 摘要别做太勤:生成摘要本身也要调模型,建议每 5~8 轮做一次,而不是每轮都做。
上下文管理没有银弹。短对话直接滑动窗口最省事;长流程任务优先把关键信息抽到槽位里;只有持续很长时间的深度对话,才值得上摘要压缩。先跑起来,再按实际 Token 消耗和错误率微调阈值。