上周接了个活,给客户做一个客服机器人,要求是能「记住上下文」的那种多轮对话。我心想这不简单嘛,调个大模型 API 不就完了?结果真上手才发现,多轮对话这事儿,坑比我想象的多得多。消息列表膨胀、token 超限、上下文丢失、响应越来越慢......折腾了两天才把链路跑顺。今天把完整方案和踩坑记录整理出来,希望能帮你少走点弯路。
先说结论
| 问题 | 方案 | 效果 |
|---|---|---|
| 多轮对话基本实现 | 维护 messages 列表,每轮追加 user + assistant | 模型能正确理解上下文 |
| token 超限导致报错 | 滑动窗口 + token 计数截断 | 稳定控制在 token 限额内 |
| 长对话上下文丢失 | 摘要压缩历史消息 | 关键信息不丢,token 消耗降 60%+ |
| 多用户并发 | session_id 隔离对话状态 | 互不干扰 |
多轮对话的本质:模型其实没有「记忆」
很多刚接触大模型 API 的同学(包括之前的我)会有个误解:觉得模型自己能记住之前说了什么。
不是的。 大模型 API 是无状态的。每次请求都是一次全新的调用。所谓的「多轮对话」,本质上是你把之前的对话历史全部塞进 messages 参数里,让模型「看到」之前的内容,从而「假装」记住了上下文。
理解这一点非常关键,因为后面所有的优化策略都是围绕这个事实展开的。
基础实现:维护 messages 列表
最基本的多轮对话实现就是维护一个 messages 列表。先上代码:
python
from openai import OpenAI
client = OpenAI(
api_key="your-key",
base_url="https://api.ofox.ai/v1" # 聚合接口,一个 Key 切换不同模型
)
def chat(messages: list, user_input: str) -> str:
"""单次对话,返回助手回复"""
messages.append({"role": "user", "content": user_input})
response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
temperature=0.7,
)
assistant_msg = response.choices[0].message.content
messages.append({"role": "assistant", "content": assistant_msg})
return assistant_msg
# 使用示例
messages = [
{"role": "system", "content": "你是一个专业的技术客服,回答简洁准确。"}
]
# 第一轮
reply1 = chat(messages, "Python 怎么读取 JSON 文件?")
print(f"助手: {reply1}")
# 第二轮 ------ 模型能理解「它」指的是 JSON
reply2 = chat(messages, "如果它嵌套了很多层怎么办?")
print(f"助手: {reply2}")
# 第三轮
reply3 = chat(messages, "有没有性能更好的库?")
print(f"助手: {reply3}")
这段代码的核心就一件事:每轮对话后把 user 和 assistant 的消息都追加到 messages 里,下次请求时带上完整列表。
跑起来没问题,模型确实能理解指代关系,上下文连贯。但如果你觉得这就完事了,那你跟当时的我一样天真。
第一个坑:token 超限,直接报错
多聊几轮就出事了。messages 越来越长,token 数越来越大,聊到二三十轮的时候直接报错:
vbnet
openai.BadRequestError: This model's maximum context length is 128000 tokens.
客服场景用户聊个几十上百轮太正常了,这必须解决。
方案一:滑动窗口截断
最简单粗暴的方案------只保留最近 N 轮对话:
python
def trim_messages_by_turns(messages: list, max_turns: int = 20) -> list:
"""
保留 system prompt + 最近 max_turns 轮对话
一轮 = 一组 user + assistant
"""
system_msgs = [m for m in messages if m["role"] == "system"]
non_system = [m for m in messages if m["role"] != "system"]
# 每轮 2 条消息(user + assistant),保留最近 max_turns 轮
keep_count = max_turns * 2
if len(non_system) > keep_count:
non_system = non_system[-keep_count:]
return system_msgs + non_system
简单有效,但问题也很明显:早期的对话信息直接丢了。如果用户第 3 轮说过「我用的是 Windows 系统」,到第 25 轮你问他环境信息,模型就不知道了。
方案二:按 token 数截断(更精确)
与其按轮数,不如按 token 数来控制。我用 tiktoken 来计数:
python
import tiktoken
def count_tokens(messages: list, model: str = "gpt-4o") -> int:
"""计算 messages 列表的 token 数"""
try:
encoding = tiktoken.encoding_for_model(model)
except KeyError:
encoding = tiktoken.get_encoding("cl100k_base")
num_tokens = 0
for msg in messages:
num_tokens += 4 # 每条消息的固定开销
for key, value in msg.items():
num_tokens += len(encoding.encode(value))
num_tokens += 2 # 回复的起始 token
return num_tokens
def trim_messages_by_tokens(messages: list, max_tokens: int = 100000) -> list:
"""
从最早的非 system 消息开始删,直到总 token 数低于阈值
"""
system_msgs = [m for m in messages if m["role"] == "system"]
non_system = [m for m in messages if m["role"] != "system"]
while count_tokens(system_msgs + non_system) > max_tokens and len(non_system) > 2:
# 每次删掉最早的一轮(2 条)
non_system = non_system[2:]
return system_msgs + non_system
这个方案好一些了,但还是没解决「早期信息丢失」的根本问题。
第二个坑:上下文丢失,用摘要压缩
这是我觉得最优雅的方案。思路是:把被截掉的历史消息先用模型压缩成一段摘要,塞进 system prompt 里,这样关键信息就不会丢。
python
def summarize_history(client: OpenAI, messages_to_summarize: list) -> str:
"""用模型把历史对话压缩成摘要"""
history_text = "\n".join(
f"{m['role']}: {m['content']}" for m in messages_to_summarize
)
response = client.chat.completions.create(
model="gpt-4o-mini", # 摘要用便宜的小模型就够了
messages=[
{
"role": "system",
"content": "请将以下对话历史压缩为简洁的摘要,保留关键信息(用户偏好、已确认的事实、待解决的问题)。用中文,不超过 300 字。"
},
{"role": "user", "content": history_text}
],
temperature=0.3,
)
return response.choices[0].message.content
def smart_trim(client: OpenAI, messages: list, max_tokens: int = 80000) -> list:
"""
智能截断:超限时把早期消息压缩成摘要
"""
if count_tokens(messages) <= max_tokens:
return messages
system_msgs = [m for m in messages if m["role"] == "system"]
non_system = [m for m in messages if m["role"] != "system"]
# 把前一半历史压缩成摘要
half = len(non_system) // 2
old_messages = non_system[:half]
recent_messages = non_system[half:]
summary = summarize_history(client, old_messages)
# 把摘要追加到 system prompt 里
summary_system = {
"role": "system",
"content": system_msgs[0]["content"] + f"\n\n[历史对话摘要]: {summary}"
}
return [summary_system] + recent_messages
实测下来,这个方案 token 消耗能降 60% 以上,而且模型对早期信息的回忆准确率比直接截断高了一大截。唯一的代价是摘要本身要多一次 API 调用,不过我用 gpt-4o-mini 来做摘要,成本几乎可以忽略。
第三个坑:多用户并发,对话串台
本地调试一切正常,上线后发现用户 A 的对话里出现了用户 B 的信息。原因很简单------我把 messages 搞成全局变量了(别笑,赶工的时候真会犯这种低级错误)。
解决方案是用 session_id 隔离每个用户的对话状态:
python
from collections import defaultdict
import uuid
class ConversationManager:
def __init__(self, client: OpenAI, system_prompt: str, model: str = "gpt-4o"):
self.client = client
self.system_prompt = system_prompt
self.model = model
self.sessions: dict[str, list] = defaultdict(self._init_messages)
def _init_messages(self) -> list:
return [{"role": "system", "content": self.system_prompt}]
def create_session(self) -> str:
session_id = str(uuid.uuid4())
self.sessions[session_id] # 触发 defaultdict 初始化
return session_id
def chat(self, session_id: str, user_input: str) -> str:
if session_id not in self.sessions:
raise ValueError(f"Session {session_id} not found")
messages = self.sessions[session_id]
messages.append({"role": "user", "content": user_input})
# 智能截断
messages = smart_trim(self.client, messages)
self.sessions[session_id] = messages
response = self.client.chat.completions.create(
model=self.model,
messages=messages,
temperature=0.7,
)
assistant_msg = response.choices[0].message.content
messages.append({"role": "assistant", "content": assistant_msg})
return assistant_msg
def clear_session(self, session_id: str):
"""清除指定会话"""
if session_id in self.sessions:
del self.sessions[session_id]
# 使用方式
client = OpenAI(
api_key="your-key",
base_url="https://api.ofox.ai/v1"
)
manager = ConversationManager(
client=client,
system_prompt="你是一个专业的技术客服。",
model="gpt-4o"
)
# 用户 A
session_a = manager.create_session()
manager.chat(session_a, "Python 怎么读 CSV?")
# 用户 B ------ 完全隔离
session_b = manager.create_session()
manager.chat(session_b, "Java 怎么连 MySQL?")
生产环境的话,sessions 不能放内存里,得用 Redis 之类的存储。但原理是一样的。
完整可用版本:加上流式输出
实际项目中基本都要用流式输出(SSE),不然用户等个几秒看着空白屏幕体验很差。把上面的代码改成流式的:
python
def chat_stream(self, session_id: str, user_input: str):
"""流式输出版本"""
messages = self.sessions[session_id]
messages.append({"role": "user", "content": user_input})
messages = smart_trim(self.client, messages)
self.sessions[session_id] = messages
stream = self.client.chat.completions.create(
model=self.model,
messages=messages,
temperature=0.7,
stream=True, # 开启流式
)
full_response = ""
for chunk in stream:
delta = chunk.choices[0].delta
if delta.content:
full_response += delta.content
yield delta.content # 逐块返回给前端
# 流结束后,把完整回复存入历史
messages.append({"role": "assistant", "content": full_response})
前端用 EventSource 或 fetch 的 ReadableStream 接就行,这块不展开了。
踩坑记录
记几个我实际遇到的问题,可能对你有用:
1. tiktoken 对非 OpenAI 模型不准
tiktoken 只对 OpenAI 的模型精确。如果你用 Claude 或者国产模型,token 计数会有偏差。我的做法是预留 20% 的 buffer,比如模型上限 128k,我按 100k 来截断。
2. system prompt 别太长
一开始我把各种规则、示例都塞进 system prompt,结果光 system 就占了 3000 多 token。后来精简到 500 token 以内,把详细规则放到第一轮 user 消息里,效果差不多,还省 token。
3. 摘要压缩可能丢失数字信息
模型在做摘要的时候,对具体数字、代码片段这类信息容易出错。我后来在摘要 prompt 里加了一句「特别注意保留具体数字、代码和配置信息」,好了很多。
4. 并发高的时候要做限流
多用户同时聊天,API 调用量会很大。我在 ConversationManager 外面套了一层令牌桶限流,避免触发 API 的 rate limit。
小结
多轮对话 API 实现说简单也简单,核心就是维护 messages 列表。但要做到生产可用,得处理好这几个问题:
- token 管理:用滑动窗口或 token 计数截断,别让消息列表无限膨胀
- 摘要压缩:被截掉的历史用小模型压缩成摘要,关键信息不丢
- 会话隔离:用 session_id 管理多用户状态,生产环境用 Redis 持久化
- 流式输出:用户体验必须有,不然等半天谁受得了
这套方案我在两个客服项目里跑了一个多月了,稳定性还行。如果你的场景对话轮数特别多(上百轮那种),可以考虑结合 RAG 做更精细的上下文管理,但对大多数场景来说,摘要压缩已经够用了。
有问题评论区聊,我看到会回。