人工智能(十七)- 大模型多輪對話、成本控制

你一定見過這種場景:

  • 你問 ChatGPT:"我叫小明。"
  • 它答:"你好,小明。"
  • 再問:"我叫什么名字?"
  • 它答:"你是小明。"

看起來 AI "記住了"你——但你知道嗎?大模型本身是沒有記憶的。它記住你名字的唯一方法,就是你每次請求時,把之前所有對話又重新傳了一遍給它

這就是多輪對話的本質(zhì)。看似簡單,但在生產(chǎn)環(huán)境里藏著三個頭號殺手:

  1. Token 瘋狂累積 —— 用戶聊到第 50 輪,請求體越來越大
  2. 上下文溢出 —— 超過模型最大長度直接報錯 context_length_exceeded
  3. 信息丟失 —— 粗暴截斷導致"忘記"用戶是誰,前后矛盾翻車

這篇文章我們就把多輪對話的全部工程實踐講透——從 messages 協(xié)議的底層原理,到上下文管理三大算法,到生產(chǎn)級 Memory 系統(tǒng)設(shè)計,再到 Java 代碼直接落地。


目錄


一、先搞清楚:大模型真的"有記憶"嗎?

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 消息只有一條,放在最前面,貫穿整個會話
  • userassistant 嚴格交替(不允許連續(xù)兩條同角色)
  • 最后一條必須是 user(你等著 AI 答)

2.3 一個常被忽略的細節(jié):assistant 消息不是照抄

很多小白會犯的錯:把 AI 上一輪的完整回答(可能帶 Markdown、代碼塊、流式分片)原封不動塞回去。這會帶來兩個問題:

  1. Token 浪費:如果 AI 上輪給了 2000 字長文,多輪累積很快爆炸
  2. 格式混淆: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)技巧

  1. 摘要用便宜模型qwen-turboqwen-plus 便宜 10 倍)
  2. 觸發(fā)閾值設(shè)為模型上下文的 50~70%(留足響應(yīng)空間)
  3. 關(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 核心心法

  1. 大模型沒有記憶,記憶是你給的 —— 多輪對話本質(zhì)是"歷史重傳"
  2. 上下文是敵人也是朋友 —— 必須主動管理,而不是任由膨脹
  3. 截斷 < 摘要 < 召回 —— 但最優(yōu)解往往是三者組合
  4. 緩存是白嫖的降本利器 —— 不開是真的虧
  5. Memory 要持久化 —— 進程重啟后用戶不該"失憶"
  6. 每一輪對話都值得 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)用 ??

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容