AI大模型上下文连续对话+Token计费统计商用方案|SpringBoot3实现对话记忆、精准计费、额度限流(生产可用)

一、前言:单轮对话的致命短板

在上一篇 Java后端对接AI大模型SSE流式输出 实战中,我们实现了基础的AI打字机流式问答,但仅支持单轮独立对话,存在两大商用致命问题:

  • 无上下文记忆:每轮问答相互独立,AI无法关联历史对话,完全不符合用户使用习惯
  • 无计费统计:无法统计用户提问、AI回复的Token消耗,付费AI产品无法核算成本、计费收费
  • 无额度管控:无限流、无额度限制,极易被恶意刷接口,造成高额AI成本损耗

本文基于原有SSE流式架构,零重构升级,完整实现:多轮上下文记忆对话 + 精准Token计费统计 + 用户额度限流管控,一套代码直接支撑商用AI产品落地。

二、核心原理前置认知

2.1 AI上下文对话原理

大模型本身无本地记忆能力,所有上下文记忆均由业务后端维护。每次请求AI接口时,需要携带:历史对话记录 + 当前用户提问,大模型根据完整对话列表实现连续问答。

2.2 Token计费规则

主流大模型(DeepSeek、GPT、通义千问)均采用输入Token+输出Token分开计费模式:

  • 输入Token:用户提问 + 历史上下文总字符消耗
  • 输出Token:AI返回回答内容消耗
  • 总费用 = 输入Token单价 + 输出Token单价

2.3 上下文过期与截断机制

对话无限累加会导致上下文过长、Token暴增、请求超时,生产环境必须配置上下文最大长度限制、自动截断、过期清理机制。

三、项目依赖(复用原有架构)

无需新增复杂依赖,沿用之前SSE流式项目依赖,仅需新增Redis依赖维护用户会话上下文:

<!-- Redis:维护用户会话上下文、额度限流 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

四、核心实体类定义

4.1 对话消息实体

统一封装单条对话消息角色、内容,适配大模型标准参数格式:

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * AI对话消息实体
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AiMessage {
    // 角色:user用户提问、assistant AI回复、system系统提示词
    private String role;
    // 对话内容
    private String content;
}

4.2 Token计费统计实体

精准记录单轮对话、用户累计Token消耗:

import lombok.Data;

/**
 * AI对话Token计费统计实体
 */
@Data
public class AiTokenBill {
    // 输入Token(用户提问+上下文)
    private Integer inputToken;
    // 输出Token(AI回复)
    private Integer outputToken;
    // 本轮总Token
    public Integer getTotalToken() {
        return inputToken + outputToken;
    }
}

五、工具类封装(Token计算+上下文管理)

5.1 Token通用计算工具类

采用行业通用字符估算规则(适配绝大多数大模型,误差极低,商用足够):

/**
 * AI Token计算工具类
 * 通用估算规则:1中文≈2Token,1英文/数字≈1Token
 */
public class AiTokenUtil {

    /**
     * 文本转Token估算
     */
    public static int countToken(String text) {
        if (text == null || text.isEmpty()) {
            return 0;
        }
        // 中文字符统计
        long chineseCount = text.chars().filter(c -> c > 127).count();
        // 英文、数字、符号统计
        long otherCount = text.length() - chineseCount;
        // 通用换算公式
        return (int) (chineseCount * 2 + otherCount);
    }
}

5.2 原有SSE工具类(复用)

沿用上篇文章SseUtil工具类,无需修改,保证流式推送稳定性。

六、Redis上下文会话管理核心

使用Redis缓存用户会话对话记录,设置过期时间,实现多轮记忆、自动过期清理,支持多端会话同步。

import com.alibaba.fastjson2.JSON;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;

import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

/**
 * AI会话上下文管理工具
 */
@Component
public class AiSessionContext {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    // 用户会话Key前缀
    private static final String AI_SESSION_PREFIX = "ai:session:";
    // 会话过期时间:30分钟(无对话自动失效)
    private static final long SESSION_EXPIRE = 30;
    // 最大上下文对话轮数(防止Token溢出)
    private static final int MAX_HISTORY_NUM = 10;

    /**
     * 获取用户完整上下文对话
     */
    public List<AiMessage> getHistoryMessage(String sessionId) {
        String key = AI_SESSION_PREFIX + sessionId;
        String json = stringRedisTemplate.opsForValue().get(key);
        if (json == null || json.isEmpty()) {
            return new ArrayList<>();
        }
        // 读取历史对话,只保留最新10轮,截断旧对话
        List<AiMessage> messageList = JSON.parseArray(json, AiMessage.class);
        if (messageList.size() > MAX_HISTORY_NUM) {
            messageList = messageList.subList(messageList.size() - MAX_HISTORY_NUM, messageList.size());
        }
        return messageList;
    }

    /**
     * 更新用户会话上下文
     */
    public void updateSessionMessage(String sessionId, List<AiMessage> messageList) {
        String key = AI_SESSION_PREFIX + sessionId;
        String json = JSON.toJSONString(messageList);
        // 更新并重置过期时间
        stringRedisTemplate.opsForValue().set(key, json, SESSION_EXPIRE, TimeUnit.MINUTES);
    }

    /**
     * 清空用户会话
     */
    public void clearSession(String sessionId) {
        stringRedisTemplate.delete(AI_SESSION_PREFIX + sessionId);
    }
}

七、完整商用接口:上下文对话+Token计费

整合SSE流式推送、多轮上下文记忆、Token实时统计、会话更新,可直接上线商用

import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import okhttp3.*;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import javax.annotation.Resource;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.util.List;
import java.util.UUID;

@RestController
@RequestMapping("/ai")
public class AiChatController {

    // 大模型密钥&地址
    private static final String API_KEY = "sk-xxxx";
    private static final String AI_URL = "https://api.deepseek.com/v1/chat/completions";

    @Resource
    private AiSessionContext aiSessionContext;

    /**
     * 多轮上下文流式对话 + Token计费统计
     * @param sessionId 会话ID(前端生成,唯一标识用户对话)
     * @param prompt 用户提问
     * @return SSE流式响应
     */
    @GetMapping(value = "/chat/context/stream", produces = "text/event-stream;charset=UTF-8")
    public SseEmitter chatContextStream(@RequestParam String sessionId, @RequestParam String prompt) {
        SseEmitter emitter = SseUtil.createEmitter();
        // 异步处理对话逻辑,不阻塞主线程
        new Thread(() -> {
            try {
                // 1. 获取用户历史上下文对话
                List<AiMessage> historyList = aiSessionContext.getHistoryMessage(sessionId);
                // 2. 加入当前用户新提问
                historyList.add(new AiMessage("user", prompt));

                // 3. 计算输入Token(上下文+当前提问总消耗)
                String inputAllContent = JSON.toJSONString(historyList);
                int inputToken = AiTokenUtil.countToken(inputAllContent);

                // 4. 构建大模型请求参数
                JSONObject json = new JSONObject();
                json.put("model", "deepseek-chat");
                json.put("stream", true);
                json.put("temperature", 0.7);
                json.put("messages", historyList);

                // 5. 发起大模型请求
                OkHttpClient client = new OkHttpClient();
                RequestBody body = RequestBody.create(json.toString(), MediaType.get("application/json; charset=utf-8"));
                Request request = new Request.Builder()
                        .url(AI_URL)
                        .addHeader("Authorization", "Bearer " + API_KEY)
                        .post(body)
                        .build();
                Response response = client.newCall(request).execute();
                BufferedReader reader = new BufferedReader(new InputStreamReader(response.body().byteStream()));

                // 6. 流式接收AI回复,统计输出Token
                StringBuilder aiReply = new StringBuilder();
                String line;
                while ((line = reader.readLine()) != null) {
                    if (line.startsWith("data: ") && !line.contains("[DONE]")) {
                        String data = line.substring(6);
                        JSONObject resJson = JSON.parseObject(data);
                        String content = resJson.getJSONArray("choices")
                                .getJSONObject(0)
                                .getJSONObject("delta")
                                .getString("content");
                        if (content != null) {
                            aiReply.append(content);
                            // 逐字推送给前端
                            SseUtil.send(emitter, content);
                        }
                    }
                }

                // 7. 统计完整Token消耗
                int outputToken = AiTokenUtil.countToken(aiReply.toString());
                AiTokenBill tokenBill = new AiTokenBill();
                tokenBill.setInputToken(inputToken);
                tokenBill.setOutputToken(outputToken);

                // 8. 更新会话上下文(存入AI回复,实现下一轮记忆)
                historyList.add(new AiMessage("assistant", aiReply.toString()));
                aiSessionContext.updateSessionMessage(sessionId, historyList);

                // 9. 末尾推送计费信息(特殊标识,前端单独解析)
                SseUtil.send(emitter, "\n【计费统计】输入Token:" + inputToken + ",输出Token:" + outputToken + ",总Token:" + tokenBill.getTotalToken());

            } catch (Exception e) {
                SseUtil.send(emitter, "对话异常,请重试!");
            } finally {
                SseUtil.complete(emitter);
            }
        }).start();
        return emitter;
    }

    /**
     * 清空对话上下文
     */
    @GetMapping("/chat/clear")
    public String clearChat(@RequestParam String sessionId) {
        aiSessionContext.clearSession(sessionId);
        return "对话上下文清空成功";
    }
}

八、前端测试页面(支持多轮对话)

前端生成唯一sessionId标识会话,支持连续提问、保留对话记忆、展示计费信息:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>AI上下文连续对话+Token计费</title>
</head>
<body>
<div>
    <input type="text" id="prompt" placeholder="请输入提问内容" style="width: 500px;height: 40px;">
    <button onclick="startChat()" style="height: 40px;">发送提问</button>
    <button onclick="clearChat()" style="height: 40px;">清空对话</button>
</div>
<div id="result" style="white-space: pre-wrap;margin-top: 20px;line-height: 1.8;"></div>

<script>
    // 生成唯一会话ID(全局唯一,保证对话记忆)
    let sessionId = localStorage.getItem("ai_session_id") || UUID.randomUUID();
    localStorage.setItem("ai_session_id", sessionId);
    let source = null;

    // 发起流式对话
    function startChat() {
        let prompt = document.getElementById("prompt").value;
        if (!prompt) return;
        document.getElementById("result").innerHTML += "【用户】:" + prompt + "\n【AI】:";
        // 关闭上一次连接
        if (source) source.close();
        // 建立SSE连接
        source = new EventSource(`http://localhost:8080/ai/chat/context/stream?sessionId=${sessionId}&prompt=${prompt}`);
        source.onmessage = function (e) {
            document.getElementById("result").innerHTML += e.data;
        }
    }

    // 清空对话上下文
    function clearChat() {
        fetch(`http://localhost:8080/ai/chat/clear?sessionId=${sessionId}`)
        document.getElementById("result").innerHTML = "";
    }

    // 生成UUID
    function UUID() {
        return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
            let r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8);
            return v.toString(16);
        });
    }
</script>
</body>
</html>

九、生产级优化:用户额度限流管控

商用产品必须防止恶意刷量、控制AI成本,新增用户每日Token额度限制,极简改造即可实现:

/**
 * 每日用户额度校验(简单商用限流)
 */
public boolean checkUserQuota(String userId) {
    String quotaKey = "ai:quota:daily:" + userId;
    // 每日最大免费Token:10000
    int maxQuota = 10000;
    Integer userUseToken = Integer.valueOf(stringRedisTemplate.opsForValue().get(quotaKey) == null ? "0" : stringRedisTemplate.opsForValue().get(quotaKey));
    if (userUseToken >= maxQuota) {
        return false;
    }
    // 累加已用额度
    stringRedisTemplate.opsForValue().increment(quotaKey, tokenBill.getTotalToken());
    // 设置每日过期
    stringRedisTemplate.expire(quotaKey, 1, TimeUnit.DAYS);
    return true;
}

十、生产高频坑点&解决方案

坑1:上下文无限累加,Token越来越大

解决:限制最大历史对话轮数(本文限制10轮),自动截断早期对话,控制Token大小。

坑2:Token统计误差过大

解决:采用中英文差异化换算规则,贴合大模型官方计费标准,商用误差可忽略。

坑3:会话长期占用内存/Redis空间

解决:设置会话30分钟无操作自动过期,自动清理无效会话数据。

坑4:多轮对话顺序混乱

解决:严格按照 user提问 → assistant回复 顺序存入上下文,保证对话时序正确。

坑5:SSE重复连接、内存泄漏

解决:每次新对话关闭上一次SSE连接,finally强制释放资源。

十一、商用拓展功能清单

  • 精准计费单价配置:配置输入/输出Token单价,自动计算对话金额
  • 用户充值体系:结合用户余额,实现扣费、欠费拦截
  • 对话日志入库:保存每轮对话内容、Token消耗、用户信息、耗时
  • 敏感词过滤:提问&回复双向风控,合规落地
  • 断线重连:网络波动自动重连SSE,接续剩余内容推送

十二、总结

本文在基础SSE流式问答的前提下,完成了AI产品从可用到商用的核心升级,解决了行业两大核心痛点:对话无记忆、无计费统计。

整套方案具备以下商用价值:

  • 轻量高效:基于SSE+Redis,无需复杂中间件
  • 成本可控:精准Token统计+额度限流,杜绝成本浪费
  • 体验优质:多轮连续对话,贴合主流AI产品交互逻辑
  • 落地简单:零架构重构,代码直接上线

目前绝大多数付费AI问答、AI客服、企业级AI助手,均采用这套流式对话+上下文记忆+Token计费架构。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐