多轮对话 API 怎么实现?从原理到代码,踩完坑我总结了这套方案

上周接了个活,给客户做一个客服机器人,要求是能「记住上下文」的那种多轮对话。我心想这不简单嘛,调个大模型 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 列表。但要做到生产可用,得处理好这几个问题:

  1. token 管理:用滑动窗口或 token 计数截断,别让消息列表无限膨胀
  2. 摘要压缩:被截掉的历史用小模型压缩成摘要,关键信息不丢
  3. 会话隔离:用 session_id 管理多用户状态,生产环境用 Redis 持久化
  4. 流式输出:用户体验必须有,不然等半天谁受得了

这套方案我在两个客服项目里跑了一个多月了,稳定性还行。如果你的场景对话轮数特别多(上百轮那种),可以考虑结合 RAG 做更精细的上下文管理,但对大多数场景来说,摘要压缩已经够用了。

有问题评论区聊,我看到会回。

相关推荐
GEO_Huang1 小时前
工作流定制选数谷,Agentoffice 让办公快人一步
大数据·人工智能·aigc·rpa·geo
geovindu1 小时前
python: Simple Factory Pattern
开发语言·python·设计模式·简单工厂模式
weixin199701080162 小时前
开山网商品详情页前端性能优化实战
java·前端·python
恋猫de小郭2 小时前
Cursor 自己做了模型 PK ,Cursor 里哪个模型性价比最高?
前端·人工智能·ai编程
前端付豪2 小时前
实现学习报告统计面板
前端·python·llm
AC赳赳老秦2 小时前
国产化AI运维新趋势:DeepSeek赋能国产算力部署的高效故障排查
大数据·人工智能·python·django·去中心化·ai-native·deepseek
1941s2 小时前
01-LLM 基础与提示词工程:从 API 调用到 Prompt 优化技巧
人工智能·python·prompt
python猿2 小时前
打卡Python王者归来--第28天
python
itwangyang5202 小时前
GitHub Push Protection 报错解决指南(检测到 Token / Secret)
人工智能·python·github