客户端封装好了,接下来要往里面装业务。多轮对话是最典型的场景,也是最容易做错的一个。很多人做出第一版之后会遇到同一个现象:聊到十几轮,模型开始"忘了前面说过什么"。这时候第一反应是模型不行,但原因通常在自己这边——上下文没管好。这一篇把这个场景从头拆开。
一、先接受一件事:模型不记得你
所有多轮对话的困惑,都源于一个没被讲清楚的前提:大模型接口是无状态的。
不是"记性不好",是根本不记。你发过去的请求,对模型来说就是一次性的:给它一段文本,它接着往下写,写完就忘。下一次请求,除非你把之前的内容再发一遍,否则它完全不知道你们聊过什么。
这个前提听起来很基础,但它解释了一连串现象:
- 为什么每次请求都要把历史全带上?
- 为什么聊得越久,每次请求越慢、越贵?
- 为什么"它明明刚才说过"这种抱怨,本质上是个伪命题?
所谓"多轮对话",其实是客户端每次把完整的历史重新发一遍,让模型看起来像是有记忆。记忆不在模型那边,在你这边。
所以多轮对话的本质工作,不是"让模型记住",而是"决定每次带多少历史过去"。这一篇剩下的内容都是围绕这一句展开的。
二、别提字符串,用 messages 数组
看两种典型写法。
第一种,自己拼字符串:
String prompt = "用户:" + q1 + "\n助手:" + a1 + "\n用户:" + q2;
第二种,用结构化的 messages:
List<Map<String, Object>> messages = List.of(
Map.of("role", "system", "content", "你是一个技术支持助手"),
Map.of("role", "user", "content", q1),
Map.of("role", "assistant", "content", a1),
Map.of("role", "user", "content", q2)
);
第一种能跑,但问题会陆续冒出来:
- 用户在问题里打一句"助手:好的",历史就被伪造了。模型分不清哪句是它自己说的。这是那种平时遇不到、一旦被恶意用户发现就很难看的漏洞。
- 你要做截断、要按消息数限制、要插入工具调用结果,都得在字符串上做正则,很快就没法维护。
- 一些新能力(工具调用、多模态)根本没法用字符串表达。
所以没有理由用字符串拼接。用 messages 数组,这是接口本身规定的形式,不是风格偏好。
三个角色的分工要记牢:
system:设定身份、约束和规则,放最前面,每次请求都带;user:用户说的话;assistant:模型之前的回答,从上一轮响应里拿到的原文。
assistant 的内容要用模型返回的原文,不要自己改写或截断。改写会污染后续上下文,而且改出来的内容会让模型对自己之前说过的话产生误判。
三、上下文窗口:一个共享的额度
每次请求,输入和输出共享一个 token 上限,这就是上下文窗口。
这一点很关键,因为它意味着你带的输入越多,能留给输出的空间就越少。极端情况是:历史塞得太满,模型只剩下几十个 token 的输出空间,回答还没说完就被截断——表现就是"它突然不说话了"或者"话说到一半停了"。
所以带多少历史,不能只看"能塞多少",要给它留出输出空间:
可用输入额度 ≈ 上下文窗口 − max_tokens − 安全余量
安全余量必须留。原因有两个:一是 token 的估算很难精确(中文一个字往往不是一个 token,代码和标点更不规律),二是有些模型实现会在内部加一些额外的结构 token,你的估算里没算进去。留出 10%~15% 的余量,比事后排查"为什么偶尔报超长"要省事得多。
顺带一个现实提醒:不同模型判断超长的行为不一样。 有的直接报错返回,有的悄悄从中间截掉一段(这种情况最坑,你完全不知道历史缺了一块),有的会拒绝。所以别依赖报错来发现问题,要在自己这边主动控制长度。
四、超长了丢哪部分
这是多轮对话里最需要想清楚的一个决策。历史必然有一天会超出额度,那时候丢掉什么、保留什么,直接决定对话质量。
绝对不能丢的
system 消息。 它是整段对话的规则来源,丢了之后模型的行为会突然变得不受约束,出现完全不遵守指令的情况。截断逻辑里必须把 system 排除在外。
最近几轮对话。 用户当前的问题往往依赖前面的语境,"那它呢?"这种问题离开上一轮就没法回答。所以尾部要完整保留。
三种常见策略
策略一:滑窗(保留最近 N 轮)
最简单,把最老的轮次整对删掉,直到长度够。
// 保留 system + 最近 N 轮(一轮 = 一问一答)
List<Map<String, Object>> trimmed = new ArrayList<>();
trimmed.add(systemMessage);
int from = Math.max(1, messages.size() - N * 2);
trimmed.addAll(messages.subList(from, messages.size()));
优点是实现简单、可预测。缺点是丢历史是硬丢失——用户在前面提过的关键信息,一旦滑出窗口就彻底没了,模型会再问一遍"请问您说的是哪个订单"。长对话里这个体验很糟。
策略二:摘要压缩
当历史超过阈值时,把最早的一段交给模型,让它压缩成一段摘要,然后用这段摘要替换原来的多轮内容。
好处是信息密度高,几十轮能压成几百字,还保住了早期提到的关键事实。代价是多了一次调用(要花钱、要花时间),而且摘要会丢细节,压缩过程中的信息损失不可控。
实践上比较稳的用法是混合:保留 system + 摘要 + 最近 N 轮。这样既有远期记忆,又有精确的近期语境。
策略三:按重要性挑
把历史里的关键信息(用户给的订单号、报错信息、时间范围)单独抽出来,作为一条附加说明保留。
这个做法效果好,但需要针对业务定制,通用性差。适合对话内容比较固定的场景,比如客服。
一个必须处理的边界
真正动手写截断时,有个地方特别容易出问题:别把成对的消息拆散。
具体说三件事:
user和assistant要成对。切在中间会出现连续两条user,或者历史以assistant开头。有些模型对这种结构会直接报错,有些则不报错但回答质量明显下降——后者更难排查。- 工具调用和它的返回必须在一起。如果你用了 Function Calling,一条
assistant消息里带着tool_calls,紧接着必须是对应的tool结果消息。从中间截断会让结构不合法,接口直接拒绝。 - 要用 token 数判断,不要按消息条数判断。消息条数相同,内容长度可能差十倍。按条数截断必然出现"有时候没超,有时候超了"的不稳定现象。
判断超长时,token 数尽量用模型厂商提供的计数接口或 SDK 来算,不要自己按字符数乘个系数估——中文、代码、标点、emoji 的差异很大,估出来的值会系统性偏移。
五、会话状态存在哪
上面讲的都是"怎么拼上下文",但还有一个前置问题:这些历史存在哪里?
内存
用 Map<String, List<Map<String,Object>>> 存,会话 ID 做 key。
适合单机演示。上生产会同时踩两个坑:
- 重启就丢。用户发完一条消息,你这边重启了一下,历史全没了,模型立刻"失忆"。
- 多实例不共享。部署了两个实例,用户的下一条请求被负载均衡打到另一台,历史是空的。表现就是"聊着聊着突然忘了",而且是随机发生的,很难复现。
如果你的服务是多实例部署(绝大多数生产环境都是),内存方案直接排除。
Redis
把 messages 序列化后存 Redis,用会话 ID 做 key,设置过期时间(比如 30 分钟无活动就回收)。
这是多数场景的合理选择:多个实例共享、天然支持过期、读写快。要注意的几点:
- 必须设过期时间。否则废弃的会话会一直堆着,内存慢慢被吃掉。
- 加锁防止并发写坏历史。同一个用户快速连发两条消息,两个请求会同时读到同一份历史、各自追加、各自写回,后写的把先写的覆盖掉——用户会发现自己说的一句话"没被听见"。用分布式锁或者按会话串行化来避免。
- 控制存储大小。一个会话的历史可能占几十 KB,量大了要评估内存成本,必要时压缩后存。
数据库
要把对话留档(审计、数据分析、客服质检)就落库。但别把库当上下文存储用——每次都从库里查全量历史再拼,数据量上来之后读写都会成为瓶颈。
更合理的分工是:库负责留档,Redis 负责上下文。每次对话结束后异步落库,上下文只从 Redis 读写。两边职责分开,谁都不为难。
六、"忘了"的问题,先查这三处
聊到十几轮之后出问题,按这个顺序排查基本能定位:
第一处:历史根本没传全。 打印一次实际发出的 messages 数组,看到底带了几轮。很多时候是拼接逻辑有问题——比如每次都只带了最后一条,或者历史被某个地方覆盖了。这个方法最土,但命中率最高。
第二处:被截断截掉了。 看截断阈值和实际历史长度。如果 token 计数用的是自己估的系数,很可能估算偏低,导致实际请求超长,然后被模型那边悄悄截掉中间一段——你这边完全无感。
第三处:会话串了。 多实例部署 + 内存存储 + 会话 ID 复用(比如用了用户 ID 而不是会话 ID),就会出现两个用户的对话混到一起。这种问题看起来像"模型疯了",实际是数据串了。检查和会话 ID 的生成规则,以及是否存在多实例共享问题。
有一类现象要特别区分开:模型回答变得敷衍、反复问同样的问题、忽略 system 里的约束——这三种往往是上下文过长导致的退化,不是模型能力问题。上下文塞得越满,模型对中间部分的注意力越弱,这是已知的普遍现象。看到这种症状,先怀疑上下文太长,而不是先换模型。
七、几个容易忽略的实践细节
- 历史里的消息要按时间顺序,别交错。并发写坏的典型症状就是顺序乱了。
- 空的 assistant 响应不要进历史。如果上一轮因为超时或过滤返回了空内容,把它塞进历史会让模型困惑。宁可跳过这一轮。
- 用户输入要先做长度限制。一个用户粘贴了十万字的文档进来,一次就把额度占满。在入口处就拦掉或分段,别等到底层报错。
- 上下文长度会影响成本,不是只影响效果。第二轮请求带一轮历史,第十轮带九轮历史——每次请求的输入 token 是递增的。长对话里输入成本会明显超过输出成本。做成本估算时别只算输出。
- 把"最近 N 轮"做成可配置项。不同业务差别很大,客服可能要 10 轮,翻译工具 1 轮就够。写死了后面一定要改。
八、小结
多轮对话的核心可以收成一句话:模型不记得你,记得的人是你。
所以这件工作的实质是每次请求前做一次取舍——带多少历史过去、丢掉哪些、怎么保证带的内容结构合法、存在哪里、并发下怎么不被写坏。这些决策都有明确的取舍代价,没有"标准答案",但都有明确的错误做法。
一个有用的自检方式:把每次实际发出的 messages 数组完整打出来看一眼。 很多"模型不行"的抱怨,在看到那串真实数据的那一刻就解释清楚了。
下一篇
阶段一到这里就结束了:能连上、知道该不该流式、参数会调、客户端能复用、上下文管得住。接下来的问题是,模型稳定返回的格式能不能被程序可靠地解析——也就是结构化输出。这件事看着简单,但"偶尔不是合法 JSON"这种情况,会让下游的解析全部失守。下一篇讲怎么让它稳定。
本文是「用 Java 接入大模型」系列第五篇。前四篇分别讲了流式输出、流式与非流式的取舍、参数调优、客户端封装,建议按顺序看。文中做法基于通义千问的 OpenAI 兼容接口,具体行为以你使用的模型文档为准。