你一定見過這種場景:
- 你問 ChatGPT:"我叫小明。"
- 它答:"你好,小明。"
- 再問:"我叫什么名字?"
- 它答:"你是小明。"
看起來 AI "記住了"你——但你知道嗎?大模型本身是沒有記憶的。它記住你名字的唯一方法,就是你每次請求時,把之前所有對話又重新傳了一遍給它。
這就是多輪對話的本質(zhì)。看似簡單,但在生產(chǎn)環(huán)境里藏著三個頭號殺手:
- Token 瘋狂累積 —— 用戶聊到第 50 輪,請求體越來越大
- 上下文溢出 —— 超過模型最大長度直接報錯
context_length_exceeded- 信息丟失 —— 粗暴截斷導致"忘記"用戶是誰,前后矛盾翻車
這篇文章我們就把多輪對話的全部工程實踐講透——從 messages 協(xié)議的底層原理,到上下文管理三大算法,到生產(chǎn)級 Memory 系統(tǒng)設(shè)計,再到 Java 代碼直接落地。
目錄
- 一、先搞清楚:大模型真的"有記憶"嗎?
- 二、messages 協(xié)議:多輪對話的底層通訊格式
- 三、為什么多輪對話會"爆炸"?三個核心痛點
- 四、上下文管理策略一:截斷(最簡單也最危險)
- 五、上下文管理策略二:滾動摘要(推薦方案)
- 六、上下文管理策略三:向量召回(長期記憶的終極方案)
- 七、三大策略對比與決策樹
- 八、成本控制:上下文緩存 + 分級模型
- 九、工程化落地:會話持久化、流式、并發(fā)、LangChain4j Memory
- 十、踩坑清單與調(diào)優(yōu)技巧
- 十一、總結(jié)
一、先搞清楚:大模型真的"有記憶"嗎?
1.1 真相:大模型是無狀態(tài)的
這是本文最重要的一個認知——
LLM API 本身是 stateless(無狀態(tài))的。每一次 HTTP 請求對它來說都是一次"冷啟動",它完全不知道你上一次跟它說過什么。
沒錯,即使是 ChatGPT、通義千問這些看起來"有記憶"的產(chǎn)品,它們的 API 底層依然是無狀態(tài)的。你感受到的"連續(xù)對話",是客戶端/服務(wù)端把歷史對話塞進每次請求的結(jié)果。
1.2 一個最小示例:看清單輪 vs 多輪的區(qū)別
? 單輪(錯誤做法):
# 第一輪請求
llm([{"role": "user", "content": "我叫小明。"}])
# → "你好,小明。很高興認識你!"
# 第二輪請求(錯誤:沒帶歷史)
llm([{"role": "user", "content": "我叫什么名字?"}])
# → "抱歉,我不知道你的名字。請問你叫什么?"
? 多輪(正確做法):
# 第二輪請求(帶上第一輪的完整對話)
llm([
{"role": "user", "content": "我叫小明。"},
{"role": "assistant", "content": "你好,小明。很高興認識你!"},
{"role": "user", "content": "我叫什么名字?"}
])
# → "你叫小明。"
看懂了嗎? 多輪對話的"記憶",其實是每次請求都把完整歷史塞進 messages 數(shù)組。模型不是真的記住,是你每次都告訴它一遍。
1.3 一張圖看清多輪對話的本質(zhì)
第 1 輪 第 2 輪 第 N 輪
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ [用戶問1] │ │ [用戶問1] │ │ [用戶問1] │
│ │ │ [AI 答1] │ │ [AI 答1] │
│ │ │ [用戶問2] │ │ ... │
│ │ │ │ │ [用戶問N-1] │
│ │ │ │ │ [AI 答N-1] │
│ │ │ │ │ [用戶問N] │
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘
│ │ │
LLM 調(diào)用 LLM 調(diào)用 LLM 調(diào)用
│ │ │
▼ ▼ ▼
[AI 答1] [AI 答2] [AI 答N]
單輪請求體小 中等大小 請求體越來越大 ??
成本低 成本中 成本越來越高 ??
這就是多輪對話天然會變貴、變慢、會爆炸的根本原因。所有優(yōu)化策略,都是在圍繞"怎么讓這個膨脹的請求體不失控"做文章。
二、messages 協(xié)議:多輪對話的底層通訊格式
所有主流大模型 API(OpenAI、通義千問、DeepSeek、Claude)都遵循類 OpenAI 的 messages 協(xié)議。理解這個協(xié)議是寫好多輪對話的基礎(chǔ)。
2.1 messages 的三大角色
| role | 作用 | 誰寫的 |
|---|---|---|
system |
設(shè)定 AI 的人設(shè)、行為規(guī)則、上下文 | 開發(fā)者(通常只在開頭一條) |
user |
用戶說的話 | 用戶輸入 |
assistant |
AI 的回答 | 上一輪 LLM 返回的內(nèi)容 |
tool |
工具調(diào)用的結(jié)果 | 程序注入(Agent 場景) |
2.2 一段完整的多輪對話請求示例
{
"model": "qwen-plus",
"messages": [
{
"role": "system",
"content": "你是一位友善的 AI 客服,回答要簡潔、專業(yè)。"
},
{
"role": "user",
"content": "我的訂單 ORD-12345 什么時候發(fā)貨?"
},
{
"role": "assistant",
"content": "您的訂單 ORD-12345 預計明天下午發(fā)貨,請保持電話暢通。"
},
{
"role": "user",
"content": "那快遞公司是哪家?"
}
],
"temperature": 0.3
}
注意觀察:
-
system消息只有一條,放在最前面,貫穿整個會話 -
user和assistant嚴格交替(不允許連續(xù)兩條同角色) - 最后一條必須是
user(你等著 AI 答)
2.3 一個常被忽略的細節(jié):assistant 消息不是照抄
很多小白會犯的錯:把 AI 上一輪的完整回答(可能帶 Markdown、代碼塊、流式分片)原封不動塞回去。這會帶來兩個問題:
- Token 浪費:如果 AI 上輪給了 2000 字長文,多輪累積很快爆炸
- 格式混淆:Markdown 轉(zhuǎn)義、特殊字符可能干擾模型續(xù)寫
最佳實踐:對于客服、助理類場景,把 assistant 消息"摘要化"后再入 messages(只保留核心語義)。
2.4 用 Java 構(gòu)造一次多輪請求
// 基礎(chǔ)的消息模型
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); }
}
// 構(gòu)造多輪請求
List<Message> messages = new ArrayList<>();
messages.add(Message.system("你是一位友善的 AI 客服。"));
// 歷史對話(從數(shù)據(jù)庫 / 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 數(shù)組長到一定程度后,該怎么辦?
三、為什么多輪對話會"爆炸"?三個核心痛點
3.1 痛點 1:Token 指數(shù)級增長
每輪對話,歷史都會被重復計算一次。假設(shè)每輪輸入/輸出平均 500 Token:
| 輪次 | 本輪新增 | 累計發(fā)送給模型的 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 倍。這不是線性增長,是等差數(shù)列求和,真實環(huán)境下經(jīng)常能看到單次用戶對話燒掉 ¥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)。一場深度技術(shù)討論、一份長文檔分析,輕松就把它塞滿了。
3.3 痛點 3:長上下文 ≠ 真記得住
即使模型支持 128K,它真的能"記住"這 128K 嗎?
- 放在開頭和末尾的信息 → 模型記得很清楚
- 放在中間的信息 → 模型經(jīng)常"看不見"
這叫 U 形注意力曲線:
召回率 ▲
100% │● ●
│ ● ●
75% │ ● ●
│ ● ●
50% │ ●● ●● ← 中間信息經(jīng)常被忽略
│ ●● ●●
25% │ ●●●● ●●●●
│ ●●●
0% └──────────────────────────────────? 位置
開頭 中間 結(jié)尾
結(jié)論:盲目把全部歷史塞進去不僅貴,還不一定有效。必須做主動的上下文管理。
四、上下文管理策略一:截斷(最簡單也最危險)
4.1 算法思想
只保留最近的 N 輪對話,老的直接扔掉。
4.2 圖解
┌──────────────────────────────────────────┐
│ session: [系統(tǒng)提示] │
│ [輪1][輪2][輪3][輪4][輪5][輪6] │ ← 假設(shè)保留最近 3 輪
├──────────────────────────────────────────┤
│ │
│ 截斷后發(fā)給模型: │
│ [系統(tǒng)提示] [輪4][輪5][輪6] │
│ └────┬────┘ │
│ 保留 │
└──────────────────────────────────────────┘
4.3 Java 實現(xiàn)(兩種截斷方式)
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 數(shù)"截斷:從最新往前倒推,累加 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 計數(shù)器
"Token 數(shù)"不是按字符數(shù)估算的。不同模型有不同的分詞器。生產(chǎn)環(huán)境推薦:
// Java 生態(tài)用 jtokkit(OpenAI 官方 tiktoken 的 Java 實現(xiàn))
// 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 作為估算上限已經(jīng)夠用(誤差 ~10%,寧可算多點別算少)。
4.5 截斷的優(yōu)缺點
| ? 優(yōu)點 | ? 缺點 |
|---|---|
| 實現(xiàn) 10 行代碼 | "遺忘癥":用戶早先說的信息會直接丟失 |
| 零額外 LLM 調(diào)用 | 回答前后矛盾(上文說過的事,下文又問一遍) |
| 邏輯可預測、可調(diào)試 | 長對話體驗極差 |
什么時候用截斷?
- ? 一次性任務(wù)、淺層對話(10 輪以內(nèi))
- ? 對成本極度敏感、對記憶要求不高的場景
- ? 客服、助理、陪伴類場景——千萬別只用截斷
五、上下文管理策略二:滾動摘要(推薦方案)
這是生產(chǎn)環(huán)境最常用的策略——用 AI 來壓縮 AI 的歷史。
5.1 算法思想
當歷史長度超過閾值(如上下文最大值的 70%)時,讓大模型給"較早的對話"生成一段摘要,用摘要替換掉原始歷史。
核心思路:摘要承載核心事實(用戶畫像、已達成的共識),最近幾輪保持原樣(保證連貫性)。
5.2 圖解
第 N 輪對話完成后,檢查上下文長度...
超過閾值(如 70%)時觸發(fā)摘要:
┌─────────────────────────────────────────────────────┐
│ [系統(tǒng)] [輪1] [輪2] [輪3] [輪4] [輪5] [輪6] [輪7] │
│ └───────摘要這部分────────┘ │
│ ↓ │
│ 獨立 LLM 調(diào)用 │
│ "請總結(jié)以上對話" │
│ ↓ │
│ 記憶摘要:"用戶叫小明,是 Java 程序員, │
│ 想學 Spring Boot,對 MyBatis..." │
└─────────────────────────────────────────────────────┘
下一輪請求時用摘要替換舊對話:
┌─────────────────────────────────────────────────────┐
│ [系統(tǒng)] │
│ [記憶摘要] (塞進 system 或獨立 assistant 消息) │
│ [輪5] [輪6] [輪7] [輪8新] │
└─────────────────────────────────────────────────────┘
5.3 摘要用的 Prompt 模板
你是一個對話歷史摘要助手。請將以下對話精煉為簡潔的"記憶摘要"。
要求:
1. 保留關(guān)鍵事實:用戶身份、核心訴求、重要決定、未完成的任務(wù)
2. 丟棄寒暄、無關(guān)閑聊、重復信息
3. 使用第三人稱陳述,不要用"你""我"
4. 控制在 200 字以內(nèi)
5. 如果之前已有"舊摘要",請將其與新對話合并更新
{% if previous_summary %}
已有的舊摘要:
{{ previous_summary }}
{% endif %}
新增的對話歷史:
{{ conversation_to_summarize }}
請直接輸出新的記憶摘要:
5.4 Java 完整實現(xiàn)
@Service
@RequiredArgsConstructor
public class RollingSummaryContextManager {
private final LlmClient llmClient;
private final TokenCounter counter;
/** 配置:上下文最大值的 70% 時觸發(fā)摘要 */
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;
// 調(diào)模型生成摘要(獨立 API 調(diào)用)
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. 保留關(guān)鍵事實:用戶身份、核心訴求、重要決定、未完成任務(wù)
2. 丟棄寒暄、無關(guān)閑聊、重復信息
3. 第三人稱陳述,200 字以內(nèi)
4. 若有舊摘要,合并更新之
%s
新增對話:
%s
請直接輸出新的記憶摘要:
""".formatted(
oldSummary != null ? "舊摘要:\n" + oldSummary + "\n\n" : "",
transcript);
// 摘要任務(wù)用便宜的小模型即可
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 優(yōu)缺點
| ? 優(yōu)點 | ? 缺點 |
|---|---|
| 保留核心事實(用戶畫像等) | 每次摘要多一次 LLM 調(diào)用(成本 + 延遲) |
| 控制上下文長度穩(wěn)定 | 摘要可能丟細節(jié)(如具體金額、訂單號) |
| 對話體驗顯著提升 | 需要合理觸發(fā)閾值,調(diào)參 |
實戰(zhàn)技巧:
-
摘要用便宜模型(
qwen-turbo比qwen-plus便宜 10 倍) - 觸發(fā)閾值設(shè)為模型上下文的 50~70%(留足響應(yīng)空間)
- 關(guān)鍵數(shù)值(訂單號、金額、地址)可以另存到結(jié)構(gòu)化字段,不依賴摘要
六、上下文管理策略三:向量召回(長期記憶的終極方案)
滾動摘要本質(zhì)還是"線性衰減"——再早的信息終會被壓縮成幾個字,甚至丟失。如果用戶三個月后回來問"我上次那個訂單號是啥",摘要可能早就沒了。
向量召回(RAG 的一種形態(tài))把對話管理從 "線性傳遞" 徹底變成了 "按需檢索"。
6.1 算法思想
每輪對話結(jié)束后,把內(nèi)容存入向量數(shù)據(jù)庫。用戶下次提問時,用語義相似度檢索出相關(guān)歷史,只把相關(guān)的拼進
messages。
這就是長期記憶(Long-term Memory) 的核心機制,也是所有"陪伴型 AI"、"智能助理"的底層技術(shù)。
6.2 圖解
每輪對話結(jié)束后:
┌────────────────────────────────────┐
│ 用戶:"我上次買的藍牙耳機是啥型號?" │
│ AI:"您上次買的是 Sony WH-1000XM5。"│
└──────────────┬─────────────────────┘
▼
┌──────────────┐
│ Embedding │ 文本 → 向量(如 1024 維)
│ 模型 │
└──────┬───────┘
▼
┌──────────────┐
│ 向量數(shù)據(jù)庫 │ 存儲:{向量, 原文, 時間, session_id, ...}
│ (Milvus/ │
│ Chroma/ │
│ Qdrant/PG) │
└──────────────┘
下次用戶提問時:
┌────────────────────────────────────┐
│ 用戶:那款耳機續(xù)航多少? │
└──────────────┬─────────────────────┘
▼
Embedding 后查詢
▼
┌──────────────────────────────┐
│ 檢索 top-k 相似的歷史對話 │
│ 1. "Sony WH-1000XM5" (0.92) │
│ 2. "上次買的藍牙耳機" (0.87) │
│ 3. "降噪耳機推薦" (0.75) │
└──────────────┬───────────────┘
▼
構(gòu)造 messages = [
system,
{"相關(guān)歷史":top-k 拼接},
最近 3 輪原文,
當前用戶輸入
]
▼
調(diào)用 LLM
6.3 關(guān)鍵工程點
| 要點 | 推薦做法 |
|---|---|
| Embedding 模型 |
text-embedding-v3(百煉)/ text-embedding-3-small(OpenAI)/ bge-m3(本地) |
| 向量庫選型 | 小規(guī)模用 pgvector(免運維,利用現(xiàn)有 PG 資源);中等規(guī)模用 Qdrant/Milvus;Serverless 用 Pinecone |
| 召回粒度 | 以"一輪對話"為單位;不要以"一整個 session"為單位(太粗) |
| 相似度閾值 | 通常 cosine > 0.75 才入選;低于直接丟棄 |
| 時效衰減 | 召回得分 × 時間衰減因子(最近的更重要) |
| Session 隔離 |
where 過濾 user_id / session_id,防止串號 |
6.4 優(yōu)缺點
| ? 優(yōu)點 | ? 缺點 |
|---|---|
| 真·長期記憶(幾個月/幾年都能召回) | 架構(gòu)復雜,需要引入向量庫 |
| 精準召回相關(guān)信息,不浪費 Token | 每輪額外 embedding 調(diào)用(成本、延遲) |
| 跨 session 也能記住用戶信息 | 召回質(zhì)量依賴 embedding 質(zhì)量 |
| 為 RAG、知識庫打下基礎(chǔ) | 召回可能"誤傷"(拉出不相關(guān)的舊對話) |
七、三大策略對比與決策樹
7.1 一表看盡
| 維度 | 截斷 | 滾動摘要 | 向量召回 |
|---|---|---|---|
| 實現(xiàn)復雜度 | ?? 極簡 | ?? 中等 | ?? 高 |
| 額外 LLM 調(diào)用 | ? 無 | ? 摘要調(diào)用 | ? Embedding 調(diào)用 |
| 架構(gòu)依賴 | 無 | 無 | 向量數(shù)據(jù)庫 |
| 信息保留 | ?? 丟失早期 | ?? 壓縮核心 | ?? 按需精準召回 |
| 長期記憶 | ? 不支持 | ?? 短期 | ? 真·長期 |
| 成本控制 | ?? 最低 | ?? 中 | ?? 中 |
| 適合場景 | 工具類、一次性 | 客服、助理 | 陪伴、長期助理 |
7.2 決策樹(按場景選)
┌─────────────────────────┐
│ 多輪對話選型決策 │
└────────────┬────────────┘
│
對話平均輪次 ≤ 10?
├─ 是 ─? ① 截斷(簡單粗暴,夠用了)
│
否
│
▼
需要記住用戶歷史(身份、偏好)?
├─ 否 ─? ① 截斷
│
是
│
▼
跨 session / 長期記憶?
├─ 否 ─? ② 滾動摘要(性價比之王)
│
是
│
▼
③ 向量召回 + 滾動摘要雙管齊下
(短期靠摘要,長期靠召回)
7.3 實戰(zhàn)推薦組合拳
生產(chǎn)環(huán)境的最佳實踐不是"選一個",而是"組合使用":
┌─────────────────────────────────────────────┐
│ 真實客服 Agent 的上下文管理 = 三層防御 │
├─────────────────────────────────────────────┤
│ Layer 1 最近 N 輪原文(窗口截斷) │
│ ─ 保證回答的自然連貫 │
├─────────────────────────────────────────────┤
│ Layer 2 滾動摘要(短期記憶) │
│ ─ 保留本次對話的核心事實 │
├─────────────────────────────────────────────┤
│ Layer 3 向量召回(長期記憶) │
│ ─ 跨 session 找歷史相關(guān)對話 │
└─────────────────────────────────────────────┘
八、成本控制:上下文緩存 + 分級模型
上下文管理是"輸入減法",但還有兩個殺手锏能進一步降本。
8.1 殺手锏一:上下文緩存(Context Caching)
這是 2024 年以來各大廠商推出的新功能,很多小白根本不知道。
多輪對話里,messages 前面的內(nèi)容在每次請求中都是重復的(system prompt + 前面所有歷史)。大模型服務(wù)商發(fā)現(xiàn)了這個特性,推出了:
Context Caching:把重復的前綴計算結(jié)果緩存起來,下次命中緩存部分不再重復計費。
| 廠商 | 產(chǎn)品名 | 命中折扣 | 緩存命中條件 |
|---|---|---|---|
| 阿里云百煉 | 上下文緩存 | 輸入 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 內(nèi)(阿里云百煉通常幾分鐘)
- ? 同一個賬號/API Key
8.2 怎么讓緩存命中?4 個關(guān)鍵技巧
┌───────────────────────────────────────────────┐
│ ? 技巧 1:把不變的內(nèi)容放前面 │
│ system (固定) → 歷史對話 → 當前輸入 │
│ ——變動的放末尾,不變的放前面,才能命中前綴緩存 │
└───────────────────────────────────────────────┘
┌───────────────────────────────────────────────┐
│ ? 技巧 2:避免 system prompt 里塞時間戳 │
│ ? "當前時間:2026-04-26 12:58:30" │
│ → 每次時間都變,緩存永遠失效 │
│ ? 時間信息放到 user 消息的末尾 │
└───────────────────────────────────────────────┘
┌───────────────────────────────────────────────┐
│ ? 技巧 3:滾動摘要也要注意緩存失效 │
│ 每次摘要更新都會讓前綴變,緩存重建 │
│ → 摘要別太頻繁;可以攢幾輪一起摘要 │
└───────────────────────────────────────────────┘
┌───────────────────────────────────────────────┐
│ ? 技巧 4:開啟緩存參數(shù)(各家 API 不同) │
│ 阿里云百煉:默認開啟,看 response.usage 里 │
│ 的 prompt_tokens_details.cached_tokens │
└───────────────────────────────────────────────┘
8.3 殺手锏二:分級模型路由
不同任務(wù)用不同模型,別讓大模型干小活。
@Service
public class TieredModelRouter {
public String route(ChatRequest req) {
// 1. 簡單問候、簡短回復 → 最便宜模型
if (isSimpleGreeting(req.message())) {
return "qwen-turbo"; // ¥0.0003/千 Token
}
// 2. 摘要任務(wù) → 中等模型
if (req.isSummaryTask()) {
return "qwen-turbo";
}
// 3. 需要工具調(diào)用 / 復雜推理 → 強模型
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% | ?? 低 |
| ⑤ 工具返回結(jié)果壓縮 | 10~20% | ?? 中 |
| ⑥ 用戶級限流 / 配額 | 防爆倉 | ?? 低 |
九、工程化落地:會話持久化、流式、并發(fā)、LangChain4j Memory
理論講完,落地實操。
9.1 會話數(shù)據(jù)模型設(shè)計
-- 會話表(元信息)
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 完整實現(xiàn)
LangChain4j 內(nèi)置了三種 ChatMemory 實現(xiàn),對應(yīng)我們講的三種策略:
| LangChain4j 類 | 對應(yīng)策略 | 說明 |
|---|---|---|
MessageWindowChatMemory |
截斷(按消息數(shù)) | 保留最近 N 條消息 |
TokenWindowChatMemory |
截斷(按 Token 數(shù)) | 保留最近 N Token,超出刪除最早的 |
自定義 ChatMemory 接口 |
滾動摘要 / 向量召回 | 需要自己實現(xiàn) |
9.2.1 基礎(chǔ)版:TokenWindowChatMemory(推薦默認選擇)
@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); // 自己實現(xiàn)持久化
}
}
9.2.2 自定義 ChatMemoryStore(落庫)
@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:完整多輪對話
// 對話服務(wù)接口(LangChain4j AiServices 自動實現(xiàn))
public interface ChatAssistant {
@SystemMessage("你是一位專業(yè)的 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) {}
}
關(guān)鍵點:
-
@MemoryId注解——LangChain4j 會自動按這個參數(shù)維度隔離 Memory - 每次請求前自動加載歷史、請求后自動追加存儲,你一行 CRUD 都不用寫
9.3 SSE 流式 + 多輪對話
流式接口下的多輪對話有個關(guān)鍵細節(jié):
每一輪的完整 AI 回答必須在流式結(jié)束后完整入庫,否則下輪請求就少了上輪的
assistant消息。
@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 -> {
// ★ 流式完整結(jié)束后,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 并發(fā)安全:同一 session 的并行請求
坑:用戶手快連發(fā)兩條消息,可能同一 session 出現(xiàn)并發(fā)修改 Memory 的情況,導致消息順序錯亂。
解決方案(按優(yōu)先級):
// 方案 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();
}
}
// 方案 2:前端層面——AI 回答中禁用輸入框(常用)
9.5 失敗重試與冪等
LLM 調(diào)用偶爾會超時 / 返回 5xx,要有重試。但多輪對話的重試有個陷阱:
重試前要判斷"用戶消息是否已經(jīng)入庫"。如果已入庫再重試,第二次請求的 messages 里會出現(xiàn)兩次相同的 user 消息,模型會迷惑。
public String chatWithRetry(String sessionId, String userMessage) {
// 1) 先把 user 消息入庫,生成一個 turn_id(冪等鍵)
long userTurnId = turnRepo.insertUserTurn(sessionId, userMessage);
try {
// 2) 帶重試調(diào)用 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();
}
十、踩坑清單與調(diào)優(yōu)技巧
| 癥狀 | 原因 | 解決 |
|---|---|---|
context_length_exceeded |
歷史累積超模型上限 | 上 TokenWindow / 滾動摘要 |
| 多輪后 AI"失憶" | 截斷過度,早期信息丟失 | 加摘要或向量召回 |
| AI 前后矛盾 | 歷史壓縮不當、摘要漏關(guān)鍵信息 | 關(guān)鍵字段結(jié)構(gòu)化存儲,不依賴摘要 |
| 成本持續(xù)飆升 | 未開上下文緩存 + 未分級模型 | 先開緩存,再做模型路由 |
| 緩存命中率低 | system prompt 里有時間戳 / session 間 prompt 不一致 | 動態(tài)內(nèi)容放末尾;system 做成通用模板 |
| SSE 流完但 memory 沒更新 | 沒在 onCompleteResponse 收尾 |
確保 framework 調(diào)用 add 完整消息 |
| 同一 session 消息錯亂 | 并發(fā)請求 | 分布式鎖 + 前端防重 |
| 向量召回拉來不相關(guān)歷史 | 相似度閾值太低 | 提閾值 + 加時間衰減 |
| 響應(yīng)越來越慢 | messages 太長 → 模型讀取慢 | 摘要 + 控制 max_tokens 輸出長度 |
| Token 計數(shù)和賬單對不上 | 不同模型分詞器不同 | 以服務(wù)商返回的 usage 字段為準 |
十一、總結(jié)
11.1 一張圖記住本文
┌──────────────────────────────────────────────────────────────┐
│ 多輪對話工程 · 五層能力模型 │
├──────────────────────────────────────────────────────────────┤
│ │
│ L1 協(xié)議理解 messages 數(shù)組 + 三角色 + 交替結(jié)構(gòu) │
│ │
│ L2 上下文管理 ①截斷 ②滾動摘要 ③向量召回 │
│ │
│ L3 成本控制 上下文緩存 + 分級模型 + 流式 │
│ │
│ L4 工程化 持久化 + 并發(fā)鎖 + 重試 + Memory 抽象 │
│ │
│ L5 可觀測 Token 埋點 + 緩存命中率 + 成本審計 │
│ │
└──────────────────────────────────────────────────────────────┘
11.2 核心心法
- 大模型沒有記憶,記憶是你給的 —— 多輪對話本質(zhì)是"歷史重傳"
- 上下文是敵人也是朋友 —— 必須主動管理,而不是任由膨脹
- 截斷 < 摘要 < 召回 —— 但最優(yōu)解往往是三者組合
- 緩存是白嫖的降本利器 —— 不開是真的虧
- Memory 要持久化 —— 進程重啟后用戶不該"失憶"
- 每一輪對話都值得 Trace —— Token、成本、延遲必須埋點
11.3 寫在最后
多輪對話看起來只是 messages 數(shù)組越塞越長的簡單游戲,但在生產(chǎn)環(huán)境,它是把 AI 從 Demo 變成產(chǎn)品的第一道分水嶺:
- 一個玩具 Demo,可以不管上下文膨脹、不管成本、不管并發(fā)
- 一個真正的 AI 產(chǎn)品,必須解決好這些問題
掌握了本文講的三大上下文管理策略 + 緩存 + 工程化 + 持久化,你就有了從"會調(diào) API"到"做 AI 產(chǎn)品"的完整工具箱。
祝你寫出不失憶、不爆炸、不燒錢的 AI 應(yīng)用 ??