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

你一定见过这种场景:

  • 你问 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 应用 🚀

相关推荐
数智工坊1 小时前
【连续动作空间算法2】TD3:DDPG的终极进化,根治连续控制中的函数近似误差
论文阅读·人工智能·算法·计算机视觉
夜影风1 小时前
RAG入门:用LangChain搭建你的第一个知识库问答系统
人工智能·langchain·rag
mingo_敏1 小时前
深度学习论文: ICPR 2026 Competition on Low-Resolution License Plate Recognition
人工智能·深度学习
乐迪信息1 小时前
乐迪信息:智慧港口安全,船舶AI视觉检测实现主动预警
大数据·人工智能·安全·计算机视觉·目标跟踪·视觉检测
小橙讲编程1 小时前
字节跳动开源多模态AI Agent终极形态:Agent TARS 深度技术解读
人工智能·开源·ai编程
五行星辰1 小时前
《AI引擎:三招掌控人工智能》目录
人工智能
视***间1 小时前
视程空间SC系列采集卡:以硬核技术,重构影像采集新生态
人工智能·音视频·采集卡·视程空间·视频采集卡
扬帆破浪1 小时前
免费开源AI软件.桌面单机版,可移动的AI知识库,察元 AI桌面版
人工智能·开源·知识图谱
captain_AIouo1 小时前
全方位降本增效,Captain AI重构OZON运营成本结构
大数据·人工智能·经验分享·重构·aigc