大模型API本身并不保存对话状态,每次请求都需要把此前所有相关消息重新发送给模型。这个机制看起来简单,但真正落到生产环境时,上下文长度失控、token成本飙升、历史会话无法恢复等问题会接连出现。要管好多轮对话,核心就是三个环节:消息数组如何组织、上下文如何裁剪、会话如何持久化。

一、多轮对话的消息组织与状态维护
目前主流大模型API,如OpenAI兼容接口,都采用messages数组来传递上下文。数组中的每个元素是一个对象,至少包含role和content两个字段。role通常有三种取值:system表示系统提示词,user表示用户输入,assistant表示模型回复。多轮对话并不是模型记住了上一轮,而是客户端把每一轮的用户消息和模型回复按顺序追加到数组里,下一次请求时全量带上。
下面这段Python代码演示了一个最简单的多轮对话维护过程。messages列表初始化时放入system消息和第一条用户消息,chat函数接收新的用户输入后,先追加user消息,再调用API,最后把assistant回复追加进列表。这样列表就会随着对话逐步增长。
messages = [
{"role": "system", "content": "你是一个乐于助人的助手"},
{"role": "user", "content": "帮我解释一下闭包"}
]
def chat(user_input):
messages.append({"role": "user", "content": user_input})
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages
)
assistant_text = response.choices[0].message.content
messages.append({"role": "assistant", "content": assistant_text})
return assistant_text
这种方式的优点是实现直观、调试方便,缺点是messages长度会越来越大。如果不加控制,很快就会触及模型的上下文窗口上限,或者让单次请求的token费用成倍增加。因此从消息组织阶段就要建立清晰的追加规则,并预留裁剪和持久化接口。
二、上下文长度限制与截断策略
不同模型的上下文窗口差异很大,有的支持32K token,有的支持128K甚至1M。但窗口越大并不代表可以无限制堆积历史消息,因为token消耗与成本直接相关。精确计算token数可以借助模型官方的tokenizer,例如tiktoken或者API返回的usage字段。简单场景下也可以按字符数进行粗略估算,一般英文1个token约等于4个字符,中文1个token约等于1到2个字符。
当估算的token总量接近上限时,需要自动裁剪最早的历史消息。裁剪时必须保留system消息,然后从最旧的非system消息开始删除,直到总量回到安全阈值以下。下面这个函数给出了一种基于粗略字符估算的裁剪实现。
def trim_messages(messages, limit=3000):
"""按粗略token估算裁剪历史消息,保留system"""
total = sum(len(m["content"]) for m in messages)
while total > limit and len(messages) > 1:
if messages[0]["role"] == "system":
break
removed = messages.pop(0)
total -= len(removed["content"])
return messages
实际生产中还可以结合滑动窗口思想:只保留最近N轮完整对话,前面更早的内容用摘要替代或者直接丢弃。下一节会进一步说明摘要压缩与持久化的配合方式。
三、会话持久化与多轮恢复
如果messages只存在于内存里,服务重启或用户换设备后对话就会丢失。将会话数据写入Redis、MySQL或对象存储,可以让用户在任意时间继续之前的对话。每个会话需要一个唯一ID,通常由服务端生成并返回给前端。保存时把messages列表序列化为JSON字符串,恢复时反序列化即可。
下面的代码展示了一个基于Redis风格接口的保存和加载函数,其中storage可以替换为任何支持get和set方法的存储对象。
import json
def save_session(session_id, messages, storage):
storage.set(session_id, json.dumps(messages, ensure_ascii=False))
def load_session(session_id, storage):
raw = storage.get(session_id)
if raw is None:
return [
{"role": "system", "content": "你是一个乐于助人的助手"}
]
return json.loads(raw)
持久化不仅解决重启丢失问题,也为后续的多轮对话管理提供了数据基础。例如可以在保存前统一执行上下文清理,把超长会话裁剪到合理长度;也可以记录消息的创建时间,方便实现过期清理、敏感信息过滤或审计。
四、进阶优化:滑动窗口与摘要压缩
如果业务场景需要长期记忆,例如客服工单、学习助手或角色扮演,单纯丢弃旧消息会损失重要背景。滑动窗口加摘要压缩是一种常见折中方案:保留最近若干轮完整对话,更早的历史消息先交给模型生成一段简要总结,再把摘要作为system消息的一部分放在窗口前部。
摘要压缩的时机可以设置在消息数量超过阈值时触发,也可以由定时任务异步完成。摘要本身占用token很少,却能保留关键实体、用户意图和已完成的动作。
def summarize_history(messages, client):
history = messages[1:-6] # 保留system和最近6条
if not history:
return messages
summary_prompt = "请把以下对话内容压缩成一段简短的背景摘要:"
summary_text = "\n".join(m["content"] for m in history)
summary_messages = [
{"role": "system", "content": "你是对话摘要助手"},
{"role": "user", "content": summary_prompt + summary_text}
]
summary_resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=summary_messages
)
summary = summary_resp.choices[0].message.content
return [messages[0], {"role": "system", "content": "前文摘要:" + summary}] + messages[-6:]
这个方案的关键在于摘要质量和触发频率之间的平衡。摘要过于频繁会增加额外模型调用成本,过少则旧信息丢失较多。一般建议在窗口剩余空间低于30%时触发一次摘要,将前80%的历史压缩为摘要,再继续追加新消息。这样既控制上下文体积,又避免每次请求都做摘要。