做智能客服、智能助手这类应用的朋友,应该都有过这种体验:单轮问答调得很顺,一问一答没问题,可一旦用户连着追问几句,AI就开始“失忆”,不是答非所问,就是把上一轮刚说过的话推翻。这个坎儿背后的核心问题,就是AI的多轮对话功能怎么实现。今天这篇,我把自己在几个真实项目里从零搭多轮对话能力的完整思路、代码方案和踩过的坑都捋一遍。不管你是刚接触大模型应用开发,还是已经在做RAG问答、智能客服、AI助手,只要需要让AI“记得住前文”,这篇文章都能直接给你一套能落地的方案。
1. 先把问题看清楚:多轮对话究竟难在哪里
很多人上来就搜“多轮对话怎么实现”,然后去翻那些概念很全、代码也很漂亮的教程,结果落地时还是各种翻车。原因很简单:没搞清楚多轮对话的难点本质,就直接进入“抄代码”环节,后面遇到问题根本不知道往哪个方向排查。
1.1 模型天生没有记忆,多轮的本质是“历史的重现”
几乎所有主流大模型接口都是无状态的。你每调一次API,它看到的只有你这次传进去的输入,根本不知道上一轮你和它说了什么。所谓“多轮对话”,本质上就是把之前的对话历史,跟着当前这轮用户输入一起重新发给模型,让它“看起来像”记得刚才说过的话。
打个比方:单轮对话就像你每次找一个新客服,都得把问题从头说一遍;多轮对话则是同一个客服,你把完整聊天记录摊在他面前,他看完之后继续跟你聊。模型相当于那个擅长“阅读理解”的客服,你给的历史越完整、越清晰,它表现得就越像“记得”。
这个认知特别重要。因为一旦你明白了“多轮=历史重现”,就会自然推导出两个关键问题:历史怎么存,以及每次该把多少历史送进去。这两个问题,就是多轮对话工程落地的全部核心。
1.2 三个躲不开的坎:记忆、指代、状态
存历史和送历史,说起来简单,做起来有三道坎。
第一是记忆问题。对话历史需要存,存多久、存多少、存在哪里。简单场景存内存列表就行,生产环境要存到Redis或数据库里,还得考虑用户会话过期后历史怎么清理。
第二是指代问题。用户会说“它多少钱”“那个方案呢”“刚才说的那个”。这些代词没有独立语义,必须回到前文才能理解。模型虽然能做一定程度的指代消解,但如果上下文太长、信息被淹没,它就可能“揣着明白装糊涂”,凭空猜一个答案。
第三是状态问题。用户在对话中途可能改变意图。比如一开始问手机,聊着聊着又问耳机,最后又绕回手机。AI要能跟着用户的目标来回切换,还要记住每个话题分别聊到哪了。很多客服机器人做着做着就跑偏,不是模型笨,而是你根本没给模型一种“追踪话题状态”的机制。
这三道坎分别对应三个技术组件:记忆管理模块、输入改写模块、状态追踪模块。下面我按整体架构到具体实现,一个个拆开讲。
2. 整体架构选型:主链路、记忆层、改写层怎么分工
2.1 先看四条主流路线怎么选
现在实现多轮对话,业界主流有四条路线。我直接给你一张对比表,看完你就知道为什么我最后选了混合方案:
| 实现路线 | 核心思路 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 纯消息数组拼接 | 把历史消息全部拼进上下文 | 实现简单、零延迟损耗 | 对话一长就超token限制 | 短对话、Demo演示 |
| 滑动窗口截断 | 只保留最近N轮消息 | 控制成本、稳定 | 早期信息丢失,用户画像留存不住 | 一般客服场景 |
| 摘要压缩 | 把早期历史压成摘要 | 长对话不超限、信息保留度高 | 摘要本身有信息损耗,会增加一次模型调用 | 长会话、深度咨询 |
| 结构化记忆 | 把关键信息抽成字段存储 | 精准沉淀用户画像和业务状态 | 需要设计抽取逻辑,复杂度高 | 销售、荐品、个性化助手 |
选型的核心矛盾是:保留足够多的历史信息,又不让上下文超限。单靠一种方案很难两头兼顾。
2.2 我采用的混合架构:消息数组为主,记忆分层兜底
我用的架构分三层:主链路、压缩层、结构化层。主链路就是一个消息列表,存最近的对话;压缩层负责在消息快要超token预算时,把更早的对话压成一段摘要塞进消息列表;结构化层用来抽用户偏好、业务关键状态等必须长期保留的信息,每次请求时拼进system消息里。
这三层各有分工:主链路保证“最近聊的”完整可见,压缩层保证“较早的”不丢关键信息,结构化层保证“跨会话的”用户特征稳定记住。
自下而上实现,优先级是:先把消息数组跑通,再补摘要压缩,最后加结构化记忆。直接上手就搞复杂的记忆框架,多半会在调试时被三层叠加的日志搞崩溃。
3. 主链路实现:消息数组与上下文构建
主链路是整个多轮对话的地基。地基不稳,上面再花哨的抽象层都是白搭。
3.1 消息结构:system / user / assistant 三条角色的分工
现在各家大模型服务商普遍支持一种消息列表格式,消息分三个角色:
- system:系统设定,告诉模型“你是谁、怎么说话、有哪些约束”,一般放在列表第一位
- user:用户输入,也就是每次用户说的话
- assistant:模型之前的回复
多轮对话里,消息列表的结构长这样:
messages = [ {"role": "system", "content": "你是一名资深的手机产品顾问,回答要简洁专业。"}, {"role": "user", "content": "我想换一台拍照好的手机,预算5000左右。"}, {"role": "assistant", "content": "结合你的预算,建议优先看三款机型:..."}, {"role": "user", "content": "那款支持夜景模式吗?"}, {"role": "assistant", "content": "支持。它搭载了..."}, {"role": "user", "content": "它的电池容量呢?"} ]看到规律没有?user和assistant是交替出现的。模型之所以能理解“它的电池容量”里的“它”指代哪台手机,就是因为前面几轮消息里提到过具体机型。一旦你为了省token把前面几条消息全删了,模型就只能对着“它”猜。
这也是为什么我一直强调:消息数组的顺序不能乱,角色不能混。有些开发者图省事,把多轮历史拼成一段长文本塞进一条user消息里,模型虽然也能读,但角色切换信息就丢了,模型回复的语气和立场很容易漂移。
3.2 Token预算怎么算,窗口怎么控制
大模型每次请求能接收的内容长度是有限的,一般叫上下文窗口。多轮对话设计的第一步,是给“历史消息”设定一个明确的token预算。
我一般用这个公式来算:
历史消息预算 = 上下文窗口上限 - 系统提示词token数 - 预留输出token数 - 安全余量举个例子。假设模型上下文窗口是8000 token,你的系统提示词写得很详细,占了1200 token;你希望模型回答最长不超过800 token;再留200 token的安全余量。那么:
历史消息预算 = 8000 - 1200 - 800 - 200 = 5800 token5800 token能放多少轮对话?取决于每轮平均长度。如果是客服场景,用户一句话平均30 token,你回复平均80 token,一轮就是110 token左右,5800 token大概能放50多轮。如果用户喜欢长篇大论,一轮就几百token,那可能只能放十几轮。
控制窗口的方式主要有两种:一种是记录历史消息的累计token数,每次新消息进来先追加、再裁剪;另一种是直接限制“最多保留N轮”,按轮数裁剪。按token裁剪更精确,按轮数裁剪更简单。我的建议是:初版先按轮数裁剪,等上线后看了真实数据,再改成按token裁剪。因为按token裁剪涉及到每条消息单独估算token数,实现上多一层复杂度,而且估不准反而容易裁错。
3.3 核心代码:一个能跑的多轮对话类
这是最简可运行版本的核心逻辑:
class MultiTurnChat: def __init__(self, model_name, system_prompt, max_history_tokens=6000): self.model_name = model_name self.messages = [{"role": "system", "content": system_prompt}] self.max_history_tokens = max_history_tokens def chat(self, user_input): # 1. 追加本轮用户输入 self.messages.append({"role": "user", "content": user_input}) # 2. 控制历史窗口,只保留最近的消息 self._trim_history() # 3. 调用模型 response = call_llm(self.model_name, self.messages) # 4. 把模型回复存入历史 self.messages.append({"role": "assistant", "content": response}) return response def _trim_history(self): history = self.messages[1:] kept = [] budget = self.max_history_tokens # 从最新消息往前保留,优先保证最近几轮完整 for msg in reversed(history): cost = estimate_tokens(msg["content"]) if budget < cost: break kept.append(msg) budget -= cost kept.reverse() # 如果连当前一轮都放不下,至少保留本轮 self.messages = [self.messages[0]] + kept注意看_trim_history函数的设计思路:从最新消息开始往前累积,直到预算不够就停。这样可以保证“最近几轮一定完整”,丢掉的是较早的消息。还有一种做法是从最旧开始删,但那样容易把刚聊的上下文删掉,导致模型对当前话题突然“失忆”,得不偿失。
call_llm和estimate_tokens是两个需要你自己接入的函数。call_llm就是调你的模型服务商API,返回字符串回复;estimate_tokens可以调模型服务商提供的计数接口,也可以本地用分词器估算,初版直接用“len(text) / 2”这种粗略估算也能跑,只是不太准。
4. 记忆层实现:三种策略对付不同长度的对话
主链路跑通之后,你会很快遇到一个尴尬:用户才聊了20分钟,消息数组就超预算了。这时候就需要记忆层出场。三种策略按复杂度递增,我一个个讲。
4.1 滑动窗口:简单够用,但别让它“一刀切”
严格说,第3.3节代码里的_trim_history就是滑动窗口的雏形。它只保留最近N轮消息,更早的一律丢掉。好处是实现简单、处理速度快、token开销稳定。
但直接“一刀切”有一个明显的坑:用户在第3轮说过“我预算5000左右”,在第40轮问“帮我推荐一款”,模型已经没有预算信息了。在很多业务场景里,用户关键信息往往在对话早期就透露了,后面才会触发具体需求。
所以我在实际项目中,滑动窗口不单做,而是和结构化记忆搭配使用:窗口负责保留近期对话细节,结构化层负责把用户早期透露的关键信息抽出来,单独放好。
4.2 摘要压缩:长会话不断线
当对话太长、滑动窗口容不下时,摘要压缩就派上用场了。思路是:在消息即将超预算时,把最早的一批消息丢给模型,让它生成一段简洁摘要,然后把摘要作为一条特殊消息放回消息列表,替代原本那些冗长历史。
代码逻辑长这样:
def compress_history(self, keep_recent_rounds=6): # 保留最近几轮的完整消息 recent = self.messages[-(keep_recent_rounds * 2):] # 需要压缩的是更早的部分 old = self.messages[1:-(keep_recent_rounds * 2)] if len(old) < 4: return # 消息太少,不值得压缩 history_text = "\n".join( f"{m['role']}: {m['content']}" for m in old ) prompt = ( "把下面的对话压缩成一份简洁摘要,要求:\n" "1. 保留所有事实性信息(价格、时间、数量、偏好);\n" "2. 保留用户明确表达的需求和意图;\n" "3. 省略客套话和无关内容。\n\n" f"对话内容:\n{history_text}" ) summary = call_llm(self.model_name, [{"role": "user", "content": prompt}]) # 用摘要消息替换旧消息 self.messages = [ self.messages[0], {"role": "system", "content": f"以下是更早的对话摘要:{summary}"} ] + recent有几个细节值得注意。
第一,压缩操作本身会多消耗一次模型调用,所以不能每轮都触发。我一般设定一个阈值:历史消息超过预算的70%时才开始压缩,压缩后历史降到预算的40%以下,留出缓冲空间。
第二,摘要放在哪?我习惯把它放到system角色消息里,因为摘要本质上是“背景设定”的一部分,对模型来说是先验信息,不是需要它回应的对话内容。放成user消息会让模型误以为这是一句用户说的话,可能导致奇怪的回复。
第三,摘要的信息损耗是客观存在的。压缩“5000预算”这种关键信息时模型一般都能抓住,但一旦对话里细节过多,它可能丢掉一些你认为不重要的边角料。所以摘要机制只适合同一个会话内的长历史压缩,跨会话的关键用户信息,该用结构化记忆还得用。
4.3 结构化记忆:沉淀用户画像与业务状态
结构化记忆是整个记忆层里最“工程化”的一环。它的做法是:定义一组业务字段,每次对话结束后让模型从对话里抽取字段值,存下来,在下一次请求时重新注入system提示词。
举个例子,一个卖手机的客服机器人:
memory_schema = { "user_name": "用户称呼,没提到就为空", "budget": "用户预算范围", "preferred_brand": "用户偏好的品牌", "use_scene": "用户的使用场景,如拍照、游戏、商务", "current_stage": "当前处于咨询、比较、下单、售后哪个阶段" }每次用户发消息后,除了正常回复,还要并行跑一次抽取:
def extract_memory(user_input, assistant_response): prompt = f""" 从对话中抽取用户信息,按JSON格式返回,只输出JSON。 字段说明:{memory_schema} 用户输入:{user_input} 助手回复:{assistant_response} """ result = call_llm(self.model_name, [{"role": "user", "content": prompt}]) return json.loads(result)下一次请求时,把抽出来的字段拼进system提示词:
system_prompt = f""" 你是手机产品顾问。 用户画像:{json.dumps(memory, ensure_ascii=False)} 当前阶段:{memory.get('current_stage', '咨询')} 请基于以上信息继续对话。 """这套机制的价值在于:即使用户隔了一个月回来,系统依然记得他预算5000、偏好拍照、姓王。这是纯滑动窗口和摘要压缩都做不到的。
代价也很明显:你要设计字段、写抽取提示词、处理抽取失败的兜底,还要防止用户中途改口导致旧画像覆盖新信息。我的经验是:结构化记忆只存“跨轮次、跨会话都重要”的信息,日常寒暄和临时信息别往里塞,否则抽取逻辑会越来越臃肿。
5. 上下文补全层:指代消解与意图改写
记忆层解决的是“模型能看见什么”,上下文补全层解决的是“模型能不能看懂”。指代消解就是这里最关键的环节。
5.1 为什么必须做输入改写
你直接把用户这句“那款呢?”发给模型,它确实有可能通过历史消息推断出“那款”指的是什么。但问题在于:如果历史消息很长,相关信息被埋在很靠前的位置,模型就很有可能忽略它。这和人类一样,聊到第40轮时,你突然说“那款呢?”,陪你聊的人也得翻聊天记录才能想起来。
输入改写(Query Rewrite)的思路是:在把用户输入送进主对话前,先让模型基于完整历史把这句话改写成一句不依赖前文也能理解的独立问句。
def rewrite_query(user_input, history_text): prompt = f""" 你负责把用户的最新提问改写为独立问题。 要求: 1. 如果用户输入中有代词(它/这个/那个/刚才),结合历史补全具体指代对象; 2. 如果用户输入中有省略(如“多少钱”),结合历史补全主题; 3. 保持原意不变,不要新增信息; 4. 如果用户输入本身语义完整,直接原样输出。 对话历史: {history_text} 用户最新输入:{user_input} 改写结果: """ rewritten = call_llm(self.model_name, [{"role": "user", "content": prompt}]) return rewritten.strip()改写后的效果差异很明显:
| 场景 | 改写前 | 改写后 |
|---|---|---|
| 代词指代 | 那款支持夜景吗? | 华为P60 Pro支持夜景模式吗? |
| 省略主语 | 电池多大? | 华为P60 Pro的电池容量是多大? |
| 直接问 | 有红色的吗? | 华为P60 Pro有红色的配色吗? |
改写后的问句替换掉原始用户输入,送进消息数组。对应历史消息还保留着,模型看到的是:用户问了一句语义完整、指代明确的话,回答自然就准。
5.2 改写触发策略与代价控制
输入改写有价值,但不是每句都要改。因为每次改写都多一次模型调用,如果用户输入本身已经完整清晰,这钱就白花了。可以叠几个判断条件来降低触发率:
第一,如果用户输入里含有“它、那个、这个、刚才、之前、那款”这类代词或指代词,基本可以判定需要改写。第二,如果用户输入字符数少于15个字,很可能是省略句,值得改写。第三,如果用户输入里包含数字、明确名词、完整的疑问句结构,大概率不需要改写,直接放行。
我的做法是先用正则做关键词匹配,命中就改写,不命中就直接用原始输入。这样能砍掉至少一半不必要的改写调用。个别用户喜欢发超长语音转文字,内容完整,也直接跳过改写。
5.3 主动澄清:信息不足时把对话拉回正轨
指代消解还有一种情况很头疼:不是省略,而是信息本身就缺。用户说“我想买那个”,但历史里从来就没提过“那个”是什么。这时候改写模型也只能瞎猜,猜错了,后续对话全歪。
遇到这种情况,正确的做法是让模型主动澄清,而不是强行作答。可以在系统提示词里加一条约束:
当用户输入存在指代不明、且历史信息不足以确定指代对象时,先向用户确认:“你说的“那个”是指哪款产品?”,不要自行猜测。加这条约束之后,模型的容错率会明显提升。代价是:如果用户是那种没耐心的人,可能觉得AI“明知故问”。所以触发条件要卡得比较严,只在确实无法推断时才问,但凡历史里有一点线索,优先用改写而非澄清。
6. 常见问题与调试技巧实录
多轮对话的坑,绝大多数不在“能不能跑”,而在“跑着跑着就歪了”。我把自己反复踩过的几个典型问题列出来,每个都附排查思路。
6.1 上下文污染导致角色漂移
现象:前几轮AI还是专业客服,聊到后面突然语气随便、甚至开始用“我觉得”“说实话”这种主观表达。
排查步骤:第一步,把每次进模型的完整messages打印出来,看system提示词是不是一直在最前面。有些代码会在消息数组中间插入系统提示,模型读到一半角色设定就“变味”了。第二步,检查是否有别的模块往messages里塞了多余内容。最常见的是把摘要消息、检索到的知识片段直接当成user消息追加,模型分不清哪句是用户说的,哪句是资料。
我的习惯是给所有“非真实用户输入”的消息都打上明确标记,比如摘要消息用带“对话摘要”四字的system角色,检索知识用“参考资料”标识的单独字段。这样模型能清楚区分“这是背景”和“这是用户在说话”。
6.2 “一轮过后就失忆”的排查思路
现象:用户上一秒刚说了“我预算5000”,下一轮问“有什么推荐”,模型完全无视5000的预算,推荐了一堆上万的手机。
排查步骤:第一,看消息数组里上一轮的用户消息是否真的被保留了。很多人写滑动窗口时,把“裁剪”写成了“清空”,导致上一轮消息被误删。第二,看消息数组的追加顺序。如果你在append用户输入之前就先取了模型回复,那历史里user和assistant的顺序就会乱,模型会被搞糊涂。
一个很笨但有效的调试方法:在每次请求前,把messages数组完整打印到日志里,人工读一遍。读上三轮,你就能看出顺序问题、缺失问题、角色错乱问题到底出在哪一环。
6.3 Token超限和费用控制
现象:长对话跑到一半,API直接报错,提示超出上下文长度。或者月底一算账,对话功能的token消耗远超预期。
长对话token超限是最容易预料的事。解决方案就是第4节那套组合拳:滑动窗口保证“总量可控”,摘要压缩保证“早期信息不丢”,结构化记忆保证“关键信息持久”。但很多人只上了滑动窗口,没上摘要,于是一遇到长对话,要么报错,要么前期信息全丢。
费用控制上有个技巧:改写、摘要、抽取这三次额外模型调用,全部用“小模型”或“更便宜的模型”来做。这些任务对推理能力要求不高,对指令遵循能力要求中等,不需要让旗舰模型来做。主对话用能力强的大模型,辅助任务用轻量模型,费用能省一大截。
6.4 多轮Prompt设计的几个实用习惯
最后分享几个写提示词的习惯,都是小细节,但直接影响多轮效果。
第一,system提示词里最好写明“你会接收到一段对话历史,请基于历史回答用户最新一题”。这样模型就不会把历史里的提问误当成当前提问,避免它“答非所问、重复回答”。
第二,每轮用户输入前面可以加一条当前时间戳或“第N轮提问”标记。比如直接传“(第12轮)用户说:那款呢?”。一些对“问题边界”感知弱的模型,这么做之后误判明显减少。
第三,如果同一个项目里有多个场景(导购、售后、查单),不要把所有场景规则都塞进一个system提示词。按对话阶段动态切换system提示词,消息数组保持干净,模型角色感会稳定得多。我见过把导购规则、售后规则、甚至闲聊人设全写在一段提示词里的,最后模型的表现四个字:人格分裂。
我个人在实际项目里的体验是:多轮对话功能的难点不在模型能力,而在工程对“记忆”和“上下文”的组织是否清晰。消息数组、摘要压缩、结构化记忆、输入改写,这四样东西的优先级依次是“先把消息数组做扎实,再谈其他”。很多团队一上来就搞复杂的Agent框架、记忆中间件,结果基础的消息顺序都是乱的,再怎么包装也白搭。你只要先把这几层按顺序搭好,再根据线上日志一点点调,多轮对话这个功能,其实没那么玄乎。