你一定见过这种场景:
- 你问 ChatGPT:"我叫小明。"
- 它答:"你好,小明。"
- 再问:"我叫什么名字?"
- 它答:"你是小明。"
看起来 AI "记住了"你------但你知道吗?大模型本身是没有记忆的 。它记住你名字的唯一方法,就是你每次请求时,把之前所有对话又重新传了一遍给它。
这就是多轮对话的本质。看似简单,但在生产环境里藏着三个头号杀手:
- Token 疯狂累积 ------ 用户聊到第 50 轮,请求体越来越大
- 上下文溢出 ------ 超过模型最大长度直接报错
context_length_exceeded- 信息丢失 ------ 粗暴截断导致"忘记"用户是谁,前后矛盾翻车
这篇文章我们就把多轮对话的全部工程实践讲透------从 messages 协议的底层原理,到上下文管理三大算法,到生产级 Memory 系统设计,再到 Java 代码直接落地。
目录
- 一、先搞清楚:大模型真的"有记忆"吗?
- 二、messages 协议:多轮对话的底层通讯格式
- 三、为什么多轮对话会"爆炸"?三个核心痛点
- 四、上下文管理策略一:截断(最简单也最危险)
- 五、上下文管理策略二:滚动摘要(推荐方案)
- 六、上下文管理策略三:向量召回(长期记忆的终极方案)
- 七、三大策略对比与决策树
- 八、成本控制:上下文缓存 + 分级模型
- 九、工程化落地:会话持久化、流式、并发、LangChain4j Memory
- 十、踩坑清单与调优技巧
- 十一、总结
一、先搞清楚:大模型真的"有记忆"吗?
1.1 真相:大模型是无状态的
这是本文最重要的一个认知------
LLM API 本身是 stateless(无状态)的。每一次 HTTP 请求对它来说都是一次"冷启动",它完全不知道你上一次跟它说过什么。
没错,即使是 ChatGPT、通义千问这些看起来"有记忆"的产品,它们的 API 底层依然是无状态的。你感受到的"连续对话",是客户端/服务端把历史对话塞进每次请求的结果。
1.2 一个最小示例:看清单轮 vs 多轮的区别
❌ 单轮(错误做法):
python
# 第一轮请求
llm([{"role": "user", "content": "我叫小明。"}])
# → "你好,小明。很高兴认识你!"
# 第二轮请求(错误:没带历史)
llm([{"role": "user", "content": "我叫什么名字?"}])
# → "抱歉,我不知道你的名字。请问你叫什么?"
✅ 多轮(正确做法):
python
# 第二轮请求(带上第一轮的完整对话)
llm([
{"role": "user", "content": "我叫小明。"},
{"role": "assistant", "content": "你好,小明。很高兴认识你!"},
{"role": "user", "content": "我叫什么名字?"}
])
# → "你叫小明。"
看懂了吗? 多轮对话的"记忆",其实是每次请求都把完整历史塞进 messages 数组 。模型不是真的记住,是你每次都告诉它一遍。
1.3 一张图看清多轮对话的本质
第 1 轮 第 2 轮 第 N 轮
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ [用户问1] │ │ [用户问1] │ │ [用户问1] │
│ │ │ [AI 答1] │ │ [AI 答1] │
│ │ │ [用户问2] │ │ ... │
│ │ │ │ │ [用户问N-1] │
│ │ │ │ │ [AI 答N-1] │
│ │ │ │ │ [用户问N] │
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘
│ │ │
LLM 调用 LLM 调用 LLM 调用
│ │ │
▼ ▼ ▼
[AI 答1] [AI 答2] [AI 答N]
单轮请求体小 中等大小 请求体越来越大 🚨
成本低 成本中 成本越来越高 🔥
这就是多轮对话天然会变贵、变慢、会爆炸的根本原因。所有优化策略,都是在围绕"怎么让这个膨胀的请求体不失控"做文章。
二、messages 协议:多轮对话的底层通讯格式
所有主流大模型 API(OpenAI、通义千问、DeepSeek、Claude)都遵循类 OpenAI 的 messages 协议。理解这个协议是写好多轮对话的基础。
2.1 messages 的三大角色
| role | 作用 | 谁写的 |
|---|---|---|
system |
设定 AI 的人设、行为规则、上下文 | 开发者(通常只在开头一条) |
user |
用户说的话 | 用户输入 |
assistant |
AI 的回答 | 上一轮 LLM 返回的内容 |
tool |
工具调用的结果 | 程序注入(Agent 场景) |
2.2 一段完整的多轮对话请求示例
json
{
"model": "qwen-plus",
"messages": [
{
"role": "system",
"content": "你是一位友善的 AI 客服,回答要简洁、专业。"
},
{
"role": "user",
"content": "我的订单 ORD-12345 什么时候发货?"
},
{
"role": "assistant",
"content": "您的订单 ORD-12345 预计明天下午发货,请保持电话畅通。"
},
{
"role": "user",
"content": "那快递公司是哪家?"
}
],
"temperature": 0.3
}
注意观察:
system消息只有一条,放在最前面,贯穿整个会话user和assistant严格交替(不允许连续两条同角色)- 最后一条必须是
user(你等着 AI 答)
2.3 一个常被忽略的细节:assistant 消息不是照抄
很多小白会犯的错:把 AI 上一轮的完整回答(可能带 Markdown、代码块、流式分片)原封不动塞回去。这会带来两个问题:
- Token 浪费:如果 AI 上轮给了 2000 字长文,多轮累积很快爆炸
- 格式混淆:Markdown 转义、特殊字符可能干扰模型续写
最佳实践 :对于客服、助理类场景,把 assistant 消息**"摘要化"后再入 messages**(只保留核心语义)。
2.4 用 Java 构造一次多轮请求
java
// 基础的消息模型
public record Message(String role, String content) {
public static Message system(String c) { return new Message("system", c); }
public static Message user(String c) { return new Message("user", c); }
public static Message assistant(String c) { return new Message("assistant", c); }
}
// 构造多轮请求
List<Message> messages = new ArrayList<>();
messages.add(Message.system("你是一位友善的 AI 客服。"));
// 历史对话(从数据库 / Redis 加载)
for (ChatTurn turn : chatHistoryRepository.loadBySession(sessionId)) {
messages.add(Message.user(turn.userMsg()));
messages.add(Message.assistant(turn.aiMsg()));
}
// 当前用户新问题
messages.add(Message.user(currentUserInput));
String answer = llmClient.chat("qwen-plus", messages);
核心骨架就这么简单 ------但真正的工程难题,是下一步:这个 messages 数组长到一定程度后,该怎么办?
三、为什么多轮对话会"爆炸"?三个核心痛点
3.1 痛点 1:Token 指数级增长
每轮对话,历史都会被重复计算一次。假设每轮输入/输出平均 500 Token:
| 轮次 | 本轮新增 | 累计发送给模型的 Token | 累计成本(qwen-plus ¥0.004/千 Token) |
|---|---|---|---|
| 1 | 500 | 500 | ¥0.002 |
| 5 | 500 | 4,500 | ¥0.018 |
| 10 | 500 | 9,500 | ¥0.038 |
| 20 | 500 | 19,500 | ¥0.078 |
| 50 | 500 | 49,500 | ¥0.20 |
| 100 | 500 | 99,500 | ¥0.40(单次) |
一次对话 100 轮 = 单轮成本的 ~100 倍 。这不是线性增长,是等差数列求和,真实环境下经常能看到单次用户对话烧掉 ¥5~10 的情况。
3.2 痛点 2:上下文长度硬限制
每个模型都有最大 Token 上限:
| 模型 | 上下文长度 | 超过会怎样 |
|---|---|---|
| qwen-turbo | 8K | API 直接报错 context_length_exceeded |
| qwen-plus | 128K | 同上 |
| qwen-long | 1M | 同上 |
| GPT-4o | 128K | 同上 |
| Claude 3.5 Sonnet | 200K | 同上 |
128K 听起来很多,但实际你只有 100K 左右可用(要留出空间给模型输出和 system prompt)。一场深度技术讨论、一份长文档分析,轻松就把它塞满了。
3.3 痛点 3:长上下文 ≠ 真记得住
即使模型支持 128K,它真的能"记住"这 128K 吗?
- 放在开头 和末尾的信息 → 模型记得很清楚
- 放在中间的信息 → 模型经常"看不见"
这叫 U 形注意力曲线:
召回率 ▲
100% │● ●
│ ● ●
75% │ ● ●
│ ● ●
50% │ ●● ●● ← 中间信息经常被忽略
│ ●● ●●
25% │ ●●●● ●●●●
│ ●●●
0% └──────────────────────────────────▶ 位置
开头 中间 结尾
结论 :盲目把全部历史塞进去不仅贵,还不一定有效。必须做主动的上下文管理。
四、上下文管理策略一:截断(最简单也最危险)
4.1 算法思想
只保留最近的 N 轮对话,老的直接扔掉。
4.2 图解
┌──────────────────────────────────────────┐
│ session: [系统提示] │
│ [轮1][轮2][轮3][轮4][轮5][轮6] │ ← 假设保留最近 3 轮
├──────────────────────────────────────────┤
│ │
│ 截断后发给模型: │
│ [系统提示] [轮4][轮5][轮6] │
│ └────┬────┘ │
│ 保留 │
└──────────────────────────────────────────┘
4.3 Java 实现(两种截断方式)
java
public class WindowedContextManager {
/** 按"轮次"截断:保留最近 N 轮(一轮 = user + assistant 两条) */
public List<Message> truncateByTurns(List<Message> history, int keepTurns) {
List<Message> system = history.stream()
.filter(m -> "system".equals(m.role()))
.toList();
List<Message> conversation = history.stream()
.filter(m -> !"system".equals(m.role()))
.toList();
int keepMessages = keepTurns * 2; // 一轮两条
int start = Math.max(0, conversation.size() - keepMessages);
List<Message> result = new ArrayList<>(system);
result.addAll(conversation.subList(start, conversation.size()));
return result;
}
/** 按"Token 数"截断:从最新往前倒推,累加 Token 直到逼近上限 */
public List<Message> truncateByTokens(List<Message> history, int maxTokens,
TokenCounter counter) {
List<Message> system = history.stream()
.filter(m -> "system".equals(m.role()))
.toList();
int systemTokens = system.stream()
.mapToInt(m -> counter.count(m.content())).sum();
List<Message> result = new ArrayList<>(system);
List<Message> kept = new ArrayList<>();
int budget = maxTokens - systemTokens;
// 从最新往前倒推
for (int i = history.size() - 1; i >= 0; i--) {
Message m = history.get(i);
if ("system".equals(m.role())) continue;
int tokens = counter.count(m.content());
if (budget - tokens < 0) break;
kept.add(0, m);
budget -= tokens;
}
// 修正:确保第一条对话是 user(不能 assistant 开头)
while (!kept.isEmpty() && !"user".equals(kept.get(0).role())) {
kept.remove(0);
}
result.addAll(kept);
return result;
}
}
4.4 Token 计数器
"Token 数"不是按字符数估算的。不同模型有不同的分词器。生产环境推荐:
java
// Java 生态用 jtokkit(OpenAI 官方 tiktoken 的 Java 实现)
// Maven: com.knuddels:jtokkit:1.1.0
public class JTokkitCounter implements TokenCounter {
private final Encoding encoding;
public JTokkitCounter() {
this.encoding = Encodings.newDefaultEncodingRegistry()
.getEncoding(EncodingType.CL100K_BASE); // GPT-4 系列
}
public int count(String text) {
return encoding.countTokens(text);
}
}
通义千问的分词器跟 GPT 不完全一致 ,但 cl100k_base 作为估算上限已经够用(误差 ~10%,宁可算多点别算少)。
4.5 截断的优缺点
| ✅ 优点 | ❌ 缺点 |
|---|---|
| 实现 10 行代码 | "遗忘症":用户早先说的信息会直接丢失 |
| 零额外 LLM 调用 | 回答前后矛盾(上文说过的事,下文又问一遍) |
| 逻辑可预测、可调试 | 长对话体验极差 |
什么时候用截断?
- ✅ 一次性任务、浅层对话(10 轮以内)
- ✅ 对成本极度敏感、对记忆要求不高的场景
- ❌ 客服、助理、陪伴类场景------千万别只用截断
五、上下文管理策略二:滚动摘要(推荐方案)
这是生产环境最常用 的策略------用 AI 来压缩 AI 的历史。
5.1 算法思想
当历史长度超过阈值(如上下文最大值的 70%)时,让大模型给"较早的对话"生成一段摘要,用摘要替换掉原始历史。
核心思路:摘要承载核心事实(用户画像、已达成的共识),最近几轮保持原样(保证连贯性)。
5.2 图解
第 N 轮对话完成后,检查上下文长度...
超过阈值(如 70%)时触发摘要:
┌─────────────────────────────────────────────────────┐
│ [系统] [轮1] [轮2] [轮3] [轮4] [轮5] [轮6] [轮7] │
│ └───────摘要这部分────────┘ │
│ ↓ │
│ 独立 LLM 调用 │
│ "请总结以上对话" │
│ ↓ │
│ 记忆摘要:"用户叫小明,是 Java 程序员, │
│ 想学 Spring Boot,对 MyBatis..." │
└─────────────────────────────────────────────────────┘
下一轮请求时用摘要替换旧对话:
┌─────────────────────────────────────────────────────┐
│ [系统] │
│ [记忆摘要] (塞进 system 或独立 assistant 消息) │
│ [轮5] [轮6] [轮7] [轮8新] │
└─────────────────────────────────────────────────────┘
5.3 摘要用的 Prompt 模板
markdown
你是一个对话历史摘要助手。请将以下对话精炼为简洁的"记忆摘要"。
要求:
1. 保留关键事实:用户身份、核心诉求、重要决定、未完成的任务
2. 丢弃寒暄、无关闲聊、重复信息
3. 使用第三人称陈述,不要用"你""我"
4. 控制在 200 字以内
5. 如果之前已有"旧摘要",请将其与新对话合并更新
{% if previous_summary %}
已有的旧摘要:
{{ previous_summary }}
{% endif %}
新增的对话历史:
{{ conversation_to_summarize }}
请直接输出新的记忆摘要:
5.4 Java 完整实现
java
@Service
@RequiredArgsConstructor
public class RollingSummaryContextManager {
private final LlmClient llmClient;
private final TokenCounter counter;
/** 配置:上下文最大值的 70% 时触发摘要 */
private static final double TRIGGER_RATIO = 0.70;
/** 摘要时保留的最近轮次(不参与摘要,保证连贯性) */
private static final int KEEP_RECENT_TURNS = 3;
public ContextState ensureBudget(ContextState state, int modelMaxTokens) {
int currentTokens = countAll(state.messages());
if (currentTokens < modelMaxTokens * TRIGGER_RATIO) {
return state; // 未超阈值,不处理
}
// 拆分:需要摘要的部分 + 需要保留的部分
List<Message> conversation = state.messages().stream()
.filter(m -> !"system".equals(m.role()))
.toList();
int keepMessages = KEEP_RECENT_TURNS * 2;
int splitIdx = Math.max(0, conversation.size() - keepMessages);
List<Message> toSummarize = conversation.subList(0, splitIdx);
List<Message> toKeep = conversation.subList(splitIdx, conversation.size());
if (toSummarize.isEmpty()) return state;
// 调模型生成摘要(独立 API 调用)
String newSummary = summarize(state.summary(), toSummarize);
// 重新组装 messages
List<Message> rebuilt = new ArrayList<>();
// system 原样
state.messages().stream()
.filter(m -> "system".equals(m.role()))
.forEach(rebuilt::add);
// 摘要作为一条 assistant 消息注入(或拼进 system)
rebuilt.add(Message.assistant("【记忆摘要】" + newSummary));
// 保留最近几轮原文
rebuilt.addAll(toKeep);
return new ContextState(rebuilt, newSummary);
}
private String summarize(String oldSummary, List<Message> toSummarize) {
String transcript = toSummarize.stream()
.map(m -> (m.role().equals("user") ? "用户:" : "助手:") + m.content())
.collect(Collectors.joining("\n"));
String prompt = """
你是对话摘要助手。请将以下对话精炼为简洁的"记忆摘要"。
要求:
1. 保留关键事实:用户身份、核心诉求、重要决定、未完成任务
2. 丢弃寒暄、无关闲聊、重复信息
3. 第三人称陈述,200 字以内
4. 若有旧摘要,合并更新之
%s
新增对话:
%s
请直接输出新的记忆摘要:
""".formatted(
oldSummary != null ? "旧摘要:\n" + oldSummary + "\n\n" : "",
transcript);
// 摘要任务用便宜的小模型即可
return llmClient.chat("qwen-turbo", List.of(Message.user(prompt)));
}
private int countAll(List<Message> messages) {
return messages.stream()
.mapToInt(m -> counter.count(m.content()) + 4) // +4 为角色开销
.sum();
}
public record ContextState(List<Message> messages, String summary) {}
}
5.5 优缺点
| ✅ 优点 | ❌ 缺点 |
|---|---|
| 保留核心事实(用户画像等) | 每次摘要多一次 LLM 调用(成本 + 延迟) |
| 控制上下文长度稳定 | 摘要可能丢细节(如具体金额、订单号) |
| 对话体验显著提升 | 需要合理触发阈值,调参 |
实战技巧:
- 摘要用便宜模型 (
qwen-turbo比qwen-plus便宜 10 倍) - 触发阈值设为模型上下文的 50~70%(留足响应空间)
- 关键数值 (订单号、金额、地址)可以另存到结构化字段,不依赖摘要
六、上下文管理策略三:向量召回(长期记忆的终极方案)
滚动摘要本质还是"线性衰减"------再早的信息终会被压缩成几个字,甚至丢失。如果用户三个月后回来问"我上次那个订单号是啥",摘要可能早就没了。
向量召回(RAG 的一种形态)把对话管理从 "线性传递" 彻底变成了 "按需检索"。
6.1 算法思想
每轮对话结束后,把内容存入向量数据库。用户下次提问时,用语义相似度检索出相关历史,只把相关的拼进
messages。
这就是长期记忆(Long-term Memory) 的核心机制,也是所有"陪伴型 AI"、"智能助理"的底层技术。
6.2 图解
每轮对话结束后:
┌────────────────────────────────────┐
│ 用户:"我上次买的蓝牙耳机是啥型号?" │
│ AI:"您上次买的是 Sony WH-1000XM5。"│
└──────────────┬─────────────────────┘
▼
┌──────────────┐
│ Embedding │ 文本 → 向量(如 1024 维)
│ 模型 │
└──────┬───────┘
▼
┌──────────────┐
│ 向量数据库 │ 存储:{向量, 原文, 时间, session_id, ...}
│ (Milvus/ │
│ Chroma/ │
│ Qdrant/PG) │
└──────────────┘
下次用户提问时:
┌────────────────────────────────────┐
│ 用户:那款耳机续航多少? │
└──────────────┬─────────────────────┘
▼
Embedding 后查询
▼
┌──────────────────────────────┐
│ 检索 top-k 相似的历史对话 │
│ 1. "Sony WH-1000XM5" (0.92) │
│ 2. "上次买的蓝牙耳机" (0.87) │
│ 3. "降噪耳机推荐" (0.75) │
└──────────────┬───────────────┘
▼
构造 messages = [
system,
{"相关历史":top-k 拼接},
最近 3 轮原文,
当前用户输入
]
▼
调用 LLM
6.3 关键工程点
| 要点 | 推荐做法 |
|---|---|
| Embedding 模型 | text-embedding-v3(百炼)/ text-embedding-3-small(OpenAI)/ bge-m3(本地) |
| 向量库选型 | 小规模用 pgvector (免运维,利用现有 PG 资源);中等规模用 Qdrant/Milvus ;Serverless 用 Pinecone |
| 召回粒度 | 以"一轮对话"为单位;不要以"一整个 session"为单位(太粗) |
| 相似度阈值 | 通常 cosine > 0.75 才入选;低于直接丢弃 |
| 时效衰减 | 召回得分 × 时间衰减因子(最近的更重要) |
| Session 隔离 | where 过滤 user_id / session_id,防止串号 |
6.4 优缺点
| ✅ 优点 | ❌ 缺点 |
|---|---|
| 真·长期记忆(几个月/几年都能召回) | 架构复杂,需要引入向量库 |
| 精准召回相关信息,不浪费 Token | 每轮额外 embedding 调用(成本、延迟) |
| 跨 session 也能记住用户信息 | 召回质量依赖 embedding 质量 |
| 为 RAG、知识库打下基础 | 召回可能"误伤"(拉出不相关的旧对话) |
七、三大策略对比与决策树
7.1 一表看尽
| 维度 | 截断 | 滚动摘要 | 向量召回 |
|---|---|---|---|
| 实现复杂度 | 🟢 极简 | 🟡 中等 | 🔴 高 |
| 额外 LLM 调用 | ❌ 无 | ✅ 摘要调用 | ✅ Embedding 调用 |
| 架构依赖 | 无 | 无 | 向量数据库 |
| 信息保留 | 🔴 丢失早期 | 🟡 压缩核心 | 🟢 按需精准召回 |
| 长期记忆 | ❌ 不支持 | 🟡 短期 | ✅ 真·长期 |
| 成本控制 | 🟢 最低 | 🟡 中 | 🟡 中 |
| 适合场景 | 工具类、一次性 | 客服、助理 | 陪伴、长期助理 |
7.2 决策树(按场景选)
┌─────────────────────────┐
│ 多轮对话选型决策 │
└────────────┬────────────┘
│
对话平均轮次 ≤ 10?
├─ 是 ─▶ ① 截断(简单粗暴,够用了)
│
否
│
▼
需要记住用户历史(身份、偏好)?
├─ 否 ─▶ ① 截断
│
是
│
▼
跨 session / 长期记忆?
├─ 否 ─▶ ② 滚动摘要(性价比之王)
│
是
│
▼
③ 向量召回 + 滚动摘要双管齐下
(短期靠摘要,长期靠召回)
7.3 实战推荐组合拳
生产环境的最佳实践不是"选一个",而是"组合使用":
┌─────────────────────────────────────────────┐
│ 真实客服 Agent 的上下文管理 = 三层防御 │
├─────────────────────────────────────────────┤
│ Layer 1 最近 N 轮原文(窗口截断) │
│ ─ 保证回答的自然连贯 │
├─────────────────────────────────────────────┤
│ Layer 2 滚动摘要(短期记忆) │
│ ─ 保留本次对话的核心事实 │
├─────────────────────────────────────────────┤
│ Layer 3 向量召回(长期记忆) │
│ ─ 跨 session 找历史相关对话 │
└─────────────────────────────────────────────┘
八、成本控制:上下文缓存 + 分级模型
上下文管理是"输入减法",但还有两个杀手锏能进一步降本。
8.1 杀手锏一:上下文缓存(Context Caching)
这是 2024 年以来各大厂商推出的新功能,很多小白根本不知道。
多轮对话里,messages 前面的内容在每次请求中都是重复的(system prompt + 前面所有历史)。大模型服务商发现了这个特性,推出了:
Context Caching:把重复的前缀计算结果缓存起来,下次命中缓存部分不再重复计费。
| 厂商 | 产品名 | 命中折扣 | 缓存命中条件 |
|---|---|---|---|
| 阿里云百炼 | 上下文缓存 | 输入 Token 降至 ~10% | qwen-max / qwen-plus 等 |
| OpenAI | Prompt Caching | 输入 Token ~50% | gpt-4o / gpt-4o-mini 等 |
| Anthropic | Prompt Caching | 输入 Token ~10% | Claude 3.5 等 |
| DeepSeek | Context Caching | 输入 Token ~10% | deepseek-chat 等 |
命中缓存的条件(各家大同小异):
- ✅ 前缀完全一致(system + 早期对话)
- ✅ 在 TTL 内(阿里云百炼通常几分钟)
- ✅ 同一个账号/API Key
8.2 怎么让缓存命中?4 个关键技巧
┌───────────────────────────────────────────────┐
│ ✅ 技巧 1:把不变的内容放前面 │
│ system (固定) → 历史对话 → 当前输入 │
│ ------变动的放末尾,不变的放前面,才能命中前缀缓存 │
└───────────────────────────────────────────────┘
┌───────────────────────────────────────────────┐
│ ✅ 技巧 2:避免 system prompt 里塞时间戳 │
│ ❌ "当前时间:2026-04-26 12:58:30" │
│ → 每次时间都变,缓存永远失效 │
│ ✅ 时间信息放到 user 消息的末尾 │
└───────────────────────────────────────────────┘
┌───────────────────────────────────────────────┐
│ ✅ 技巧 3:滚动摘要也要注意缓存失效 │
│ 每次摘要更新都会让前缀变,缓存重建 │
│ → 摘要别太频繁;可以攒几轮一起摘要 │
└───────────────────────────────────────────────┘
┌───────────────────────────────────────────────┐
│ ✅ 技巧 4:开启缓存参数(各家 API 不同) │
│ 阿里云百炼:默认开启,看 response.usage 里 │
│ 的 prompt_tokens_details.cached_tokens │
└───────────────────────────────────────────────┘
8.3 杀手锏二:分级模型路由
不同任务用不同模型,别让大模型干小活。
java
@Service
public class TieredModelRouter {
public String route(ChatRequest req) {
// 1. 简单问候、简短回复 → 最便宜模型
if (isSimpleGreeting(req.message())) {
return "qwen-turbo"; // ¥0.0003/千 Token
}
// 2. 摘要任务 → 中等模型
if (req.isSummaryTask()) {
return "qwen-turbo";
}
// 3. 需要工具调用 / 复杂推理 → 强模型
if (req.needsReasoningOrTools()) {
return "qwen-plus"; // ¥0.004/千 Token
}
// 4. 超长文档 → 长上下文模型
if (req.tokenCount() > 100_000) {
return "qwen-long"; // 支持 1M tokens
}
return "qwen-plus";
}
}
真实场景下,70% 请求能被 turbo 吃掉,能省 70~80% 成本。
8.4 成本控制清单
| 策略 | 降本幅度 | 实施难度 |
|---|---|---|
| ① 上下文管理(截断/摘要/召回) | 30~60% | 🟡 中 |
| ② 启用上下文缓存 | 30~50% | 🟢 低 |
| ③ 分级模型路由 | 40~70% | 🟡 中 |
| ④ 流式首 Token 预算 | 10% | 🟢 低 |
| ⑤ 工具返回结果压缩 | 10~20% | 🟡 中 |
| ⑥ 用户级限流 / 配额 | 防爆仓 | 🟢 低 |
九、工程化落地:会话持久化、流式、并发、LangChain4j Memory
理论讲完,落地实操。
9.1 会话数据模型设计
sql
-- 会话表(元信息)
CREATE TABLE chat_session (
session_id VARCHAR(64) PRIMARY KEY,
user_id VARCHAR(64) NOT NULL,
title VARCHAR(255),
summary TEXT, -- 滚动摘要
summary_updated_turn INT DEFAULT 0, -- 上次摘要到第几轮
total_tokens BIGINT DEFAULT 0, -- 累计 token
total_cost DECIMAL(10,4) DEFAULT 0, -- 累计成本(审计用)
model_name VARCHAR(32) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user (user_id, updated_at DESC)
);
-- 对话明细表(每轮一条)
CREATE TABLE chat_turn (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
session_id VARCHAR(64) NOT NULL,
turn_no INT NOT NULL, -- 第几轮
role VARCHAR(16) NOT NULL, -- user/assistant/tool
content MEDIUMTEXT NOT NULL,
prompt_tokens INT,
completion_tokens INT,
cached_tokens INT, -- 命中缓存的 token(百炼可查)
latency_ms INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_session_turn (session_id, turn_no),
INDEX idx_session_created (session_id, created_at)
);
9.2 Spring Boot + LangChain4j Memory 完整实现
LangChain4j 内置了三种 ChatMemory 实现,对应我们讲的三种策略:
| LangChain4j 类 | 对应策略 | 说明 |
|---|---|---|
MessageWindowChatMemory |
截断(按消息数) | 保留最近 N 条消息 |
TokenWindowChatMemory |
截断(按 Token 数) | 保留最近 N Token,超出删除最早的 |
自定义 ChatMemory 接口 |
滚动摘要 / 向量召回 | 需要自己实现 |
9.2.1 基础版:TokenWindowChatMemory(推荐默认选择)
java
@Configuration
public class ChatConfig {
@Bean
public ChatMemoryProvider chatMemoryProvider(ChatMemoryStore store) {
return sessionId -> TokenWindowChatMemory.builder()
.id(sessionId)
.maxTokens(8_000, new OpenAiTokenizer()) // 给 8K 预算
.chatMemoryStore(store)
.build();
}
@Bean
public ChatMemoryStore chatMemoryStore(JdbcTemplate jdbc) {
return new PersistentChatMemoryStore(jdbc); // 自己实现持久化
}
}
9.2.2 自定义 ChatMemoryStore(落库)
java
@Component
@RequiredArgsConstructor
public class PersistentChatMemoryStore implements ChatMemoryStore {
private final JdbcTemplate jdbc;
private final ObjectMapper mapper = new ObjectMapper();
@Override
public List<ChatMessage> getMessages(Object memoryId) {
String json = jdbc.queryForObject(
"SELECT messages_json FROM chat_memory WHERE session_id = ?",
String.class, memoryId);
if (json == null) return new ArrayList<>();
try {
return ChatMessageDeserializer.messagesFromJson(json);
} catch (Exception e) { return new ArrayList<>(); }
}
@Override
public void updateMessages(Object memoryId, List<ChatMessage> messages) {
String json = ChatMessageSerializer.messagesToJson(messages);
jdbc.update("""
INSERT INTO chat_memory (session_id, messages_json, updated_at)
VALUES (?, ?, NOW())
ON DUPLICATE KEY UPDATE messages_json = VALUES(messages_json),
updated_at = NOW()
""", memoryId, json);
}
@Override
public void deleteMessages(Object memoryId) {
jdbc.update("DELETE FROM chat_memory WHERE session_id = ?", memoryId);
}
}
9.2.3 Service + Controller:完整多轮对话
java
// 对话服务接口(LangChain4j AiServices 自动实现)
public interface ChatAssistant {
@SystemMessage("你是一位专业的 AI 助手,回答要简洁、准确。")
String chat(@MemoryId String sessionId, @UserMessage String message);
}
@Configuration
public class AssistantConfig {
@Bean
public ChatAssistant chatAssistant(ChatLanguageModel model,
ChatMemoryProvider memoryProvider) {
return AiServices.builder(ChatAssistant.class)
.chatLanguageModel(model)
.chatMemoryProvider(memoryProvider)
.build();
}
}
@RestController
@RequestMapping("/api/chat")
@RequiredArgsConstructor
public class ChatController {
private final ChatAssistant assistant;
@PostMapping("/{sessionId}")
public Map<String, String> chat(@PathVariable String sessionId,
@RequestBody ChatReq req) {
String answer = assistant.chat(sessionId, req.message());
return Map.of("sessionId", sessionId, "answer", answer);
}
public record ChatReq(String message) {}
}
关键点:
@MemoryId注解------LangChain4j 会自动按这个参数维度隔离 Memory- 每次请求前自动加载历史、请求后自动追加存储,你一行 CRUD 都不用写
9.3 SSE 流式 + 多轮对话
流式接口下的多轮对话有个关键细节:
每一轮的完整 AI 回答必须在流式结束后完整入库 ,否则下轮请求就少了上轮的
assistant消息。
java
@GetMapping(value = "/stream/{sessionId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(@PathVariable String sessionId,
@RequestParam String message) {
SseEmitter emitter = new SseEmitter(60_000L);
StringBuilder fullAnswer = new StringBuilder();
streamingAssistant.chat(sessionId, message)
.onPartialResponse(token -> {
fullAnswer.append(token);
try { emitter.send(SseEmitter.event().name("token").data(token)); }
catch (IOException e) { emitter.completeWithError(e); }
})
.onCompleteResponse(resp -> {
// ★ 流式完整结束后,LangChain4j 会自动把完整 AI 消息存入 ChatMemory
try { emitter.send(SseEmitter.event().name("done").data("")); }
catch (IOException e) { emitter.completeWithError(e); }
emitter.complete();
})
.onError(emitter::completeWithError)
.start();
return emitter;
}
9.4 并发安全:同一 session 的并行请求
坑 :用户手快连发两条消息,可能同一 session 出现并发修改 Memory 的情况,导致消息顺序错乱。
解决方案(按优先级):
java
// 方案 1:按 sessionId 分布式加锁(推荐)
@PostMapping("/{sessionId}")
public Map<String, String> chat(@PathVariable String sessionId, @RequestBody ChatReq req) {
RLock lock = redissonClient.getLock("chat:session:" + sessionId);
try {
if (!lock.tryLock(0, 30, TimeUnit.SECONDS)) {
return Map.of("error", "上一条消息还在处理中,请稍候");
}
String answer = assistant.chat(sessionId, req.message());
return Map.of("sessionId", sessionId, "answer", answer);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
} finally {
if (lock.isHeldByCurrentThread()) lock.unlock();
}
}
java
// 方案 2:前端层面------AI 回答中禁用输入框(常用)
9.5 失败重试与幂等
LLM 调用偶尔会超时 / 返回 5xx,要有重试。但多轮对话的重试有个陷阱:
重试前要判断"用户消息是否已经入库"。如果已入库再重试,第二次请求的 messages 里会出现两次相同的 user 消息,模型会迷惑。
java
public String chatWithRetry(String sessionId, String userMessage) {
// 1) 先把 user 消息入库,生成一个 turn_id(幂等键)
long userTurnId = turnRepo.insertUserTurn(sessionId, userMessage);
try {
// 2) 带重试调用 LLM
return Failsafe.with(retryPolicy())
.get(() -> {
String answer = assistant.chat(sessionId, userMessage);
turnRepo.insertAssistantTurn(sessionId, userTurnId, answer);
return answer;
});
} catch (Exception e) {
// 3) 失败时标记这个 user_turn 为失败,下次重试用同一 turn_id
turnRepo.markFailed(userTurnId);
throw e;
}
}
private static RetryPolicy<String> retryPolicy() {
return RetryPolicy.<String>builder()
.handle(IOException.class, TimeoutException.class)
.withBackoff(500, 5000, ChronoUnit.MILLIS)
.withMaxRetries(3)
.build();
}
十、踩坑清单与调优技巧
| 症状 | 原因 | 解决 |
|---|---|---|
context_length_exceeded |
历史累积超模型上限 | 上 TokenWindow / 滚动摘要 |
| 多轮后 AI"失忆" | 截断过度,早期信息丢失 | 加摘要或向量召回 |
| AI 前后矛盾 | 历史压缩不当、摘要漏关键信息 | 关键字段结构化存储,不依赖摘要 |
| 成本持续飙升 | 未开上下文缓存 + 未分级模型 | 先开缓存,再做模型路由 |
| 缓存命中率低 | system prompt 里有时间戳 / session 间 prompt 不一致 | 动态内容放末尾;system 做成通用模板 |
| SSE 流完但 memory 没更新 | 没在 onCompleteResponse 收尾 |
确保 framework 调用 add 完整消息 |
| 同一 session 消息错乱 | 并发请求 | 分布式锁 + 前端防重 |
| 向量召回拉来不相关历史 | 相似度阈值太低 | 提阈值 + 加时间衰减 |
| 响应越来越慢 | messages 太长 → 模型读取慢 | 摘要 + 控制 max_tokens 输出长度 |
| Token 计数和账单对不上 | 不同模型分词器不同 | 以服务商返回的 usage 字段为准 |
十一、总结
11.1 一张图记住本文
┌──────────────────────────────────────────────────────────────┐
│ 多轮对话工程 · 五层能力模型 │
├──────────────────────────────────────────────────────────────┤
│ │
│ L1 协议理解 messages 数组 + 三角色 + 交替结构 │
│ │
│ L2 上下文管理 ①截断 ②滚动摘要 ③向量召回 │
│ │
│ L3 成本控制 上下文缓存 + 分级模型 + 流式 │
│ │
│ L4 工程化 持久化 + 并发锁 + 重试 + Memory 抽象 │
│ │
│ L5 可观测 Token 埋点 + 缓存命中率 + 成本审计 │
│ │
└──────────────────────────────────────────────────────────────┘
11.2 核心心法
- 大模型没有记忆,记忆是你给的 ------ 多轮对话本质是"历史重传"
- 上下文是敌人也是朋友 ------ 必须主动管理,而不是任由膨胀
- 截断 < 摘要 < 召回 ------ 但最优解往往是三者组合
- 缓存是白嫖的降本利器 ------ 不开是真的亏
- Memory 要持久化 ------ 进程重启后用户不该"失忆"
- 每一轮对话都值得 Trace ------ Token、成本、延迟必须埋点
11.3 写在最后
多轮对话看起来只是 messages 数组越塞越长的简单游戏,但在生产环境,它是把 AI 从 Demo 变成产品的第一道分水岭:
- 一个玩具 Demo,可以不管上下文膨胀、不管成本、不管并发
- 一个真正的 AI 产品,必须解决好这些问题
掌握了本文讲的三大上下文管理策略 + 缓存 + 工程化 + 持久化,你就有了从"会调 API"到"做 AI 产品"的完整工具箱。
祝你写出不失忆、不爆炸、不烧钱的 AI 应用 🚀