在使用通义千问做多轮对话应用时,一个高频出现的问题是对话进行到几轮之后,模型突然忘记了之前聊过的内容,答非所问甚至把用户当成陌生人重新打招呼。这并不是模型本身变笨了,绝大多数情况下是调用方式导致的上下文丢失。本文围绕通义千问上下文记忆丢失这一典型问题,从产生原因、排查思路到具体修复方案逐一展开,配合可直接运行的代码示例,帮你把多轮对话的记忆稳定保持住。

一、为什么会出现上下文记忆丢失
要修复问题,先要弄清楚通义千问是如何记住对话内容的。通义千问本身是无状态的,模型不会在服务端帮你保存聊天记录。每一次请求中模型能记住多少内容,完全取决于这一次请求里你传了多少历史消息进去。也就是说,所谓的记忆,本质上是调用方在每次请求时把之前的对话历史拼接好后一起发送给模型。
理解了这一点,记忆丢失的原因就比较好定位了。最常见的一种情况是每轮请求只发送了当前这条用户消息,历史消息没有拼接,模型自然只能看到最后一句话。另一种情况是拼接了历史,但对话变长之后总Token数超过了模型支持的上下文窗口长度,超出的部分被静默截断,通常是开头的消息先被丢掉,于是模型表现出忘记了早期内容。此外,自己维护会话缓存时逻辑有bug,比如只往列表里追加了用户消息却忘了追加模型的回复,历史记录不完整,也会导致对话衔接混乱。
还有一种容易被忽视的情形是流式输出场景。开启流式返回后,一些开发者拿到了每个分片就立即处理,最后没有把完整的模型回复拼接起来存入历史,下一轮请求时历史里的assistant消息是残缺的,模型读到的上下文自然是不完整的。
二、正确的多轮对话messages组织方式
通义千问的对话接口通过messages参数传递对话历史,这个列表中每个元素带有role和content两个字段,role取值为system、user或assistant。多轮对话的标准做法是:system消息放在最前面,然后按时间顺序交替排列user和assistant消息,最后一条是要模型响应的新的user消息。
下面用Python和DashScope SDK写一个完整的最小示例,重点看历史列表是如何被维护和传递的:
import dashscope
dashscope.api_key = "sk-你的密钥"
# 会话历史,整个会话期间持续维护这一个列表
history = [
{"role": "system", "content": "你是一个耐心的编程助手。"}
]
def chat(user_input):
# 把本轮用户消息追加进历史
history.append({"role": "user", "content": user_input})
resp = dashscope.Generation.call(
model="qwen-plus",
messages=history,
result_format="message"
)
reply = resp.output.choices[0]["message"]["content"]
# 关键:把模型回复也追加进历史,否则下一轮模型看不到自己说过什么
history.append({"role": "assistant", "content": reply})
return reply
print(chat("我叫小李,我正在学习Python。"))
print(chat("我刚才说我在学什么语言?"))
运行后第二条提问能得到正确回答,说明历史被正确传递了。如果删掉两行append中的任意一行,第二条提问立刻就会暴露记忆丢失。这个最小化验证思路也是排查问题的利器:先在一个干净的小脚本里跑通标准流程,再对照检查自己的业务代码哪里出了偏差。
有几个细节值得注意。system消息只放一条且始终在首位,不要在对话中途插入新的system消息,部分模型版本对这种写法支持不佳。user和assistant消息要严格交替,出现连续两条同角色消息时,模型的表现会明显不稳定。如果调用了result_format参数,建议设为message,直接拿到结构化的message对象,省去手工解析文本的麻烦。
三、上下文超长导致的截断与应对策略
即使messages组织正确,对话轮数多了以后,总Token数也会逼近模型的上下文窗口上限。不同型号的通义千问模型窗口大小不同,从几千到几十万Token不等,超限后服务端会截断或直接报错。截断发生时不会提示,表现就是模型突然忘了前面聊的内容。
应对这一问题的思路主要有三种。第一种是滑动窗口,只保留最近N轮对话,把最早的消息移出历史。实现简单,代价是彻底丢掉早期记忆。第二种是摘要压缩,当历史超过阈值时,先让模型把早期对话总结成一段摘要,用摘要替换原始消息,配合最近几轮的原文一起发送。第三种是直接换用更大窗口的模型型号,例如长上下文版本的qwen系列,适合确实需要完整记忆长对话的场景。
下面给出一个带滑动窗口和简单保护逻辑的实现:
MAX_ROUNDS = 10 # 保留最近10轮对话
def trim_history(history):
system_msgs = [m for m in history if m["role"] == "system"]
dialog = [m for m in history if m["role"] != "system"]
# 每轮包含一条user和一条assistant,按消息条数计算
keep = dialog[-(MAX_ROUNDS * 2):]
return system_msgs + keep
def chat_with_trim(user_input, history):
history.append({"role": "user", "content": user_input})
history = trim_history(history)
resp = dashscope.Generation.call(
model="qwen-plus",
messages=history,
result_format="message"
)
reply = resp.output.choices[0]["message"]["content"]
history.append({"role": "assistant", "content": reply})
return reply, history
滑动窗口虽然简单,但裁剪时必须注意保持user和assistant的交替结构,从奇偶位置切断容易把一对消息拆开,导致下一条消息的角色顺序错乱。更稳妥的做法是按轮裁剪而不是按条数裁剪。摘要压缩方案则可以借助一次额外的模型调用,把被移出窗口的旧对话生成一段不超过若干Token的总结,作为一条user消息或附加到system消息末尾,这样模型仍能掌握早期对话的大意。
四、常见踩坑点与排查清单
实际项目中导致记忆丢失的细节远不止上面这些,这里整理一份排查清单,遇到问题时按顺序逐项检查,基本能覆盖九成以上的情况。
- 检查每轮请求是否携带了完整历史,用日志打印出实际发送的messages内容,确认历史确实在列表里。
- 检查assistant回复是否被正确追加进历史,尤其是流式场景,要等所有分片接收完毕拼接完成后再写入。
- 检查是否存在异常分支漏写历史,例如请求失败、超时后重试时,user消息被重复追加了两次。
- 检查多实例部署时的会话粘性,负载均衡把同一会话的请求打到不同机器,各自维护的内存历史不一致。
- 检查是否有中间件或网关对请求体做了裁剪,超大的请求体被截断后messages变成非法JSON。
- 检查并发写入,多个请求同时读写同一个历史列表,可能出现消息乱序或丢失,必要时加锁。
关于会话存储,简单场景用内存字典维护session_id到历史列表的映射即可,但要注意设置过期清理,否则内存会持续增长。生产环境建议把会话历史持久化到Redis或数据库,key用session_id,value存序列化后的messages列表,这样应用重启后对话也不会中断。需要注意历史记录本身要做好隔离和权限校验,避免不同用户的会话串号,那不仅会造成记忆错乱,还是严重的隐私问题。
最后提醒一点,模型没有义务区分多个用户,如果你的应用同时服务多个用户,务必确保每个用户有独立的history列表,绝不能全局共享一份。记忆丢失修好之后,紧接着出现的往往就是记忆串台问题,提前在架构上做好会话隔离可以少走很多弯路。