3.4 多轮对话:上下文与会话管理
场景:聊到第三轮就失忆的助手
Function Calling 版助手上线的第一天,我就被投诉了。同事连续问了三轮:先查库存,再问上周销售,最后说“那这个商品再帮我查一次”。前两轮好好的,第三轮直接报错——它不知道“这个商品”指的是哪个 SKU。翻日志一看,问题发过去时上下文里只剩最新一句,前面的对话记录没保存,模型当然接不上话。
单轮问答 API 是无状态的,每次请求都像第一次见面。要做成能连续聊天的助手,会话上下文必须由我们自己维护。听起来不就是存个消息列表吗?真做起来才体会到,上下文窗口、token 成本、多会话并发,每一个都能折腾好几天。
方案:会话字典加窗口管理
核心设计是在内存里维护按会话 ID 索引的消息列表,每次请求把最近若干条历史拼进 messages。落盘用 SQLite,会话数多到内存放不下时能快速恢复。会话 ID 用 uuid4 生成,前端塞进 Cookie,重启服务后只要从库里加载就能续上对话。
import sqlite3, uuid
def new_session():
sid = str(uuid.uuid4())
with sqlite3.connect("sessions.db") as db:
db.execute("INSERT INTO sessions(sid, updated) VALUES(?, 0)", (sid,))
return sid
def load_history(sid, limit=6):
with sqlite3.connect("sessions.db") as db:
rows = db.execute(
"SELECT role, content FROM msgs WHERE sid=? ORDER BY id DESC LIMIT ?",
(sid, limit)).fetchall()
return [{"role": r[0], "content": r[1]} for r in reversed(rows)]
def save_msg(sid, role, content):
with sqlite3.connect("sessions.db") as db:
db.execute(
"INSERT INTO msgs(sid, role, content) VALUES(?,?,?)",
(sid, role, content))
limit=6 就是简单的窗口截断:只保留最近三轮问答。粗暴但有效,绝大多数追问场景的指代都在最近三轮内。超出窗口的更早内容,RAG 检索的思路同样适用——把早期对话切块向量化,需要时再召回,这比无脑塞全量省太多 token。
会话加载的坑主要在并发。早期版本把 history 存在全局字典里,图快没加锁,灰度时两个用户同时追问,消息列表互相穿插,出现 A 的会话里混进 B 的提问。修法是把读写都收口到 SQLite 单一入口,每次请求独立查、独立拼,全局字典仅作短期缓存并加过期时间。这块和Function Calling里强调的执行边界是同一个原则:状态必须按会话严格隔离。
上下文压缩
窗口截断治标,窗口够不着时就轮到摘要压缩。当历史消息超过阈值,我把最早的几轮交给模型压缩成一段“之前聊过什么”的摘要,放在消息列表开头。有点像人类开会前先念纪要。
def compress_history(history, ask_llm):
keep = history[-4:]
old = history[:-4]
summary = ask_llm(
"把以下对话压缩成不超过 200 字的纪要,保留事实、数字与用户意图:\n"
+ "\n".join(f"{m['role']}: {m['content'][:120]}" for m in old))
return [{"role": "system", "content": f"此前对话纪要:{summary}"}] + keep
实测压缩后 token 占用降了六成,多轮场景下的回答连贯性几乎无损,因为纪要里保全了关键事实。有个反例值得记一笔:我曾把纪要压到 50 字以内,结果用户提到某个客户名时模型死活想不起来,只好放宽到 200 字并强约束“专有名词与编号逐字保留”。压缩不是越短越好,信息保真是第一指标,这套机制后来直接用在 Agent 编排的多步骤任务上,在后续 Agent 编排篇里会展开。
会话策略对照
几种方案我各跑过一段时间的对比:
| 策略 | 实现成本 | token 消耗 | 长期记忆 | 适用场景 |
|---|---|---|---|---|
| 全量拼接 | 极低 | 高,易爆窗 | 完整 | 调试期、短对话 |
| 窗口截断 | 低 | 低 | 丢早期 | 日常问答、客服 |
| 纪要压缩 | 中 | 中 | 摘要级 | 助理类、长会话 |
| 向量召回 | 高 | 中低 | 可检索 | 知识库型、客服日志 |
线上最后选的是截断加压缩的组合拳:新会话截断省事,活跃超过半小时的会话触发压缩,超过一天未活跃归档进向量库,需要时再召回。
并发与会话数也得监控。我在会话表上加了一层按小时聚合的写入计数,挂到工具箱的监控面板上,哪天新增会话突然飙升多半是有人拿它批量灌测试数据,直接按 IP 限流。监控体系的搭建在后续监控与日志那篇里有完整方案。
踩坑与排查
坑零:system 消息被历史覆盖。 最早调会话时发现模型过了一阵就不按设定角色说话,排查发现每次请求我自己拼的 messages 里 system 位只在第一轮存在,后续轮次的历史加载把设定挤没了。规则很简单但容易忘:每轮请求都显式把 system 消息拼回列表开头,它不在历史里,是每轮都生效的指令。
坑一:历史无限拼接爆窗。 第一版图省事把全部历史发给模型,某天一个会话攒了八万字,API 直接返回上下文超限错误。定位靠的是在请求前先估算 token 数,超过七成就触发压缩。估算工具我在接入大模型 API那篇里介绍过,上线前务必加这道护栏。
坑二:多并发会话串线。 灰度时两个用户反馈总能看到对方的对话。排查发现是早期为了省事用了全局变量当会话存储,请求间互相污染。改成每次请求从 SQLite 按 sid 独立加载后立刻解决。并发安全的教训:会话状态宁可用连接池去查库,也别在模块级变量上省事。
坑三:压缩摘要丢失关键事实。 用户说“我之前说的那个订单号是 T-1023”,压缩后纪要里没保留订单号,模型答得云里雾里。修法是给压缩提示词加保留清单:编号、金额、日期类数字必须原样保留;同时纪要落库前抽检了二十段做人工核对,确认敏感字段不丢。
坑四:会话永不清理。 跑了一个月后发现 sessions.db 膨胀到 2GB,大部分是只问了一句就走的僵尸会话。加了定时清理:30 天未活跃的会话归档后删除主表记录,清理任务直接复用定时任务那篇里的调度器,和批量脚本共用一套框架。
坑五:流式输出中途断开。 多轮场景下流式断流比单轮更隐蔽,生成的半截回答被存进历史,下一轮模型被半句话带偏。处理方式是流式聚合完成且校验通过后才写库,中途断流则丢弃并提示用户重试,绝不把残缺内容当既定事实。
小结
多轮对话的本质是在 token 预算内管理记忆。三件看家本领:会话 ID 做状态隔离、窗口加压缩控制成本、落盘存储保证重启可恢复。做完这一节,助手终于能一口气接住连续追问,第三篇智能应用的四大件——API 调用、RAG、Function Calling、会话管理——也算全部齐活。接下来要操心的就是把这些能力做成稳定运行的服务跑在云上,那是第四篇部署与集成的主线,服务器选型可以先翻轻量服务器建站指南。
