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、会话管理——也算全部齐活。接下来要操心的就是把这些能力做成稳定运行的服务跑在云上,那是第四篇部署与集成的主线,服务器选型可以先翻轻量服务器建站指南。

延伸阅读