人工智能(十七)- 大模型多轮对话、成本控制

你一定见过这种场景:

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

看起来 AI "记住了"你------但你知道吗?大模型本身是没有记忆的 。它记住你名字的唯一方法,就是你每次请求时,把之前所有对话又重新传了一遍给它

这就是多轮对话的本质。看似简单,但在生产环境里藏着三个头号杀手:

  1. Token 疯狂累积 ------ 用户聊到第 50 轮,请求体越来越大
  2. 上下文溢出 ------ 超过模型最大长度直接报错 context_length_exceeded
  3. 信息丢失 ------ 粗暴截断导致"忘记"用户是谁,前后矛盾翻车

这篇文章我们就把多轮对话的全部工程实践讲透------从 messages 协议的底层原理,到上下文管理三大算法,到生产级 Memory 系统设计,再到 Java 代码直接落地。


目录


一、先搞清楚:大模型真的"有记忆"吗?

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 消息只有一条,放在最前面,贯穿整个会话
  • userassistant 严格交替(不允许连续两条同角色)
  • 最后一条必须是 user(你等着 AI 答)

2.3 一个常被忽略的细节:assistant 消息不是照抄

很多小白会犯的错:把 AI 上一轮的完整回答(可能带 Markdown、代码块、流式分片)原封不动塞回去。这会带来两个问题:

  1. Token 浪费:如果 AI 上轮给了 2000 字长文,多轮累积很快爆炸
  2. 格式混淆: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 调用(成本 + 延迟)
控制上下文长度稳定 摘要可能丢细节(如具体金额、订单号)
对话体验显著提升 需要合理触发阈值,调参

实战技巧

  1. 摘要用便宜模型qwen-turboqwen-plus 便宜 10 倍)
  2. 触发阈值设为模型上下文的 50~70%(留足响应空间)
  3. 关键数值 (订单号、金额、地址)可以另存到结构化字段,不依赖摘要

六、上下文管理策略三:向量召回(长期记忆的终极方案)

滚动摘要本质还是"线性衰减"------再早的信息终会被压缩成几个字,甚至丢失。如果用户三个月后回来问"我上次那个订单号是啥",摘要可能早就没了。

向量召回(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 核心心法

  1. 大模型没有记忆,记忆是你给的 ------ 多轮对话本质是"历史重传"
  2. 上下文是敌人也是朋友 ------ 必须主动管理,而不是任由膨胀
  3. 截断 < 摘要 < 召回 ------ 但最优解往往是三者组合
  4. 缓存是白嫖的降本利器 ------ 不开是真的亏
  5. Memory 要持久化 ------ 进程重启后用户不该"失忆"
  6. 每一轮对话都值得 Trace ------ Token、成本、延迟必须埋点

11.3 写在最后

多轮对话看起来只是 messages 数组越塞越长的简单游戏,但在生产环境,它是把 AI 从 Demo 变成产品的第一道分水岭:

  • 一个玩具 Demo,可以不管上下文膨胀、不管成本、不管并发
  • 一个真正的 AI 产品,必须解决好这些问题

掌握了本文讲的三大上下文管理策略 + 缓存 + 工程化 + 持久化,你就有了从"会调 API"到"做 AI 产品"的完整工具箱。

祝你写出不失忆、不爆炸、不烧钱的 AI 应用 🚀

相关推荐
wish3666 小时前
K8S 免镜像部署:通过 API 上传文件动态创建服务(以 Ollama 为例)
人工智能·云原生·容器·kubernetes·local llm
AI导出鸭PC端6 小时前
文心怎样生成word文档?一键智能排版,AI导出鸭解决格式错乱痛点
人工智能·ai·word·豆包·deepseek·ai导出鸭
深小乐6 小时前
DeepSeek Harness 发布一周,登顶 Top10 的插件,暴露了哪些真实需求?
人工智能
奈斯先生Vector6 小时前
OpenScience 安装失败怎么办?Windows 环境、API 配置与本地服务排错指南
人工智能·windows·架构·开源·aigc·midjourney
武子康6 小时前
Email Thread 不是 Agent Session:生产级异步通信网关的状态、幂等与审批合同
人工智能·llm·agent
AIDANHANG6 小时前
放开长期挂着开关前先核到期日默认安全值与残留分支
人工智能
一次旅行6 小时前
2026‑08‑22 AI产业深度解读|Anthropic自研芯片布局、SGLang权重缓存守护进程、Agent任务作弊审计、AI原生SDLC
人工智能·缓存·sglang
Dawson Zhu6 小时前
【AI架构前沿】MEMO:解耦推理与记忆,破解大模型“知识更新“与“灾难性遗忘“的两难困境
人工智能·架构·aigc·agi
江湖有缘6 小时前
跨平台AI终端Wave:智能SSH与文件管理
运维·人工智能·ssh
IT_陈寒6 小时前
Python装饰器把我坑惨了,原来这样用才不掉链子
前端·人工智能·后端