简介:这是一套面向计算机、电子信息工程及数学等专业本科生的毕业设计与课程实践参考项目,基于Spring Boot构建了具备多模型接入能力的人工智能机器人系统,可灵活调用GPT-3.5、GPT-4.0、Kimi、文心一言、Stable Diffusion及Midjourney等主流大模型,覆盖文本生成与AI绘图双模态能力,有效支撑智能交互类课题开发与验证。资源包共1157个文件,以584个Java核心业务类、104个Vue前端组件、108个JS逻辑脚本、73个XML配置及149个PNG图标资源为主,辅以YML配置、Dockerfile容器化支持和SQL数据库脚本,结构完整、模块清晰,26.84MB体积兼顾功能完备性与学习友好性。目前已有1235人学习下载,提供开箱即用的后端服务架构、前后端分离实现、多模型统一调度接口及典型UI交互界面,适合快速理解AI集成方案、复现大模型调用流程并开展二次开发。
1. 这不是又一个“调 API 的 Spring Boot 小 demo”:它把大模型交互从「发请求→等响应」升级成「带上下文、可中断、能回溯、支持多轮状态管理」的生产级对话服务
某高校毕业设计答辩现场,A同学演示完自己的“AI聊天机器人”后,导师盯着控制台里滚动的日志问了一句:“如果用户中途说‘等等,刚才那句别发’,你能撤回吗?如果连续问了7个问题,第8个问‘上上条我说什么了’,你能准确指出来吗?”——全场安静。而这个项目,就是为回答这类问题而生的。它不满足于用 RestTemplate 硬套 OpenAI/v1/chat/completions接口,而是基于 Spring Boot 3.x + WebFlux 构建了一套带会话快照、支持流式中断、内置对话树回溯机制的 AI 对话中间件。核心能力包括:自动维护 per-session 的 token 消耗统计、按角色(system/user/assistant)结构化存储历史、在 SSE 流式响应中嵌入event: interrupt控制帧、提供/api/v1/conversation/{id}/rollback?to=2接口实现任意步回退。适合需要交付稳定、可审计、可调试 AI 交互能力的毕业设计、课程设计或轻量级企业内部工具开发。如果你正卡在“模型调得通但对话逻辑一团乱”“想加 history 却发现 Redis 存的是扁平 JSON 没法查上上条”“前端一刷新 session 就丢”,那这份源码就是你缺的那块拼图。
2. 技术栈与架构设计:为什么选 WebFlux 而不是 MVC?为什么对话状态必须脱离 HTTP 请求生命周期?
2.1 核心技术选型决策:WebFlux + R2DBC + Redis Reactive 的必然性
这个项目没用 Spring MVC,而是强制采用 WebFlux,不是为了“新潮”,而是被三个硬需求逼出来的:
- 流式响应不可中断:OpenAI 的
stream=true返回的是 chunked text/event-stream,MVC 的@ResponseBody无法在响应中途注入控制事件(如event: interrupt),而 WebFlux 的Flux<ServerSentEvent>天然支持动态 emit 任意类型事件; - 高并发会话状态不能压垮 DB:每轮对话平均产生 5~8 条 message 记录,若用 JDBC 同步写 MySQL,100 并发会话 ≈ 800 QPS 写操作,MySQL 连接池直接打满;R2DBC 提供真正非阻塞数据库访问;
- 会话状态需毫秒级读写+过期自动清理:Redis Reactive Client 支持
reactiveStringRedisTemplate.opsForHash().putAll()批量写入整个对话树,且expireAt()可精确到毫秒,比用 MySQLcreated_at + ttl轮询清理更可靠。
提示:项目默认使用 H2 数据库做本地开发(
application-dev.yml),但所有 DAO 层接口已定义为ReactiveCrudRepository,切换 PostgreSQL 或 MySQL 只需改spring.r2dbc.url和驱动依赖,无需动一行业务代码。
2.2 整体分层架构:从 Controller 到 Model 的职责切分
src/main/java/ ├── ai.robot/ │ ├── controller/ # 仅处理 HTTP 入口,不做任何业务逻辑 │ │ ├── ChatController.java # /api/v1/chat → 接收 message + session_id │ │ └── SessionController.java # /api/v1/session → 创建/查询/销毁会话 │ ├── service/ # 核心编排层:组合 AI 调用 + 状态管理 + 安全校验 │ │ ├── ChatService.java # 主流程:loadSession → enrichMessage → callModel → saveSnapshot │ │ └── SessionManager.java # 会话生命周期管理:create() / get() / rollback() / expire() │ ├── model/ # 领域模型,非 DTO!含业务行为 │ │ ├── Conversation.java # 不是 POJO!含 addMessage(), rollbackTo(int), getTokenCount() 方法 │ │ ├── Message.java # 角色枚举 Role.SYSTEM/USER/ASSISTANT + content + timestamp │ │ └── ModelResponse.java # 封装 OpenAI 响应,含 finish_reason、usage 字段 │ ├── client/ # 第三方 SDK 封装,隔离 OpenAI 协议细节 │ │ └── OpenAiClient.java # 基于 WebClient 构建,支持 stream/non-stream 两种模式 │ └── config/ # 非业务配置:WebClient Bean、Redis 连接池、模型路由策略 └── Application.java关键设计点:Conversation类不是数据载体,而是状态机。它的rollbackTo(3)方法会:
- 从 Redis Hash 中读取
session:abc123:messages全量消息; - 按
index字段过滤出前 3 条; - 重新计算
total_tokens(调用countTokens()方法,内部用 tiktoken-jvm); - 将新状态写回 Redis,并触发
ConversationRolledBackEvent事件。
2.3 模型路由与多 Provider 支持:如何让同一套代码对接 GPT-4、Claude、GLM?
项目通过ModelProvider接口解耦具体模型厂商:
public interface ModelProvider { Mono<ModelResponse> chat(ChatRequest request); String getModelName(); boolean supportsStreaming(); }实际实现类:
OpenAiProvider:对接https://api.openai.com/v1/chat/completionsAnthropicProvider:对接https://api.anthropic.com/v1/messages(适配 Claude)ZhipuProvider:对接智谱 AI 的https://open.bigmodel.cn/api/paas/v4/chat/completions
路由逻辑在ChatService中:
// ChatService.java public Mono<ServerSentEvent<Object>> startStreamingChat(ChatRequest request) { ModelProvider provider = providerRegistry.getProvider(request.getModel()); // 根据 request.model 字段路由 return provider.chat(request) .flatMapMany(response -> { if (response.isStreaming()) { return Flux.fromIterable(response.getChunks()) .map(chunk -> ServerSentEvent.builder(chunk).event("message").build()); } else { return Flux.just(ServerSentEvent.builder(response).event("done").build()); } }); }providerRegistry是一个ConcurrentHashMap<String, ModelProvider>,启动时通过@PostConstruct扫描所有ModelProvider实现类并注册。新增模型只需实现该接口 + 添加@Component,无需改任何已有代码。
3. 核心模块拆解:Conversation 状态管理与 Token 精确统计的实现细节
3.1 Conversation 类:一个会自我进化的领域对象
Conversation.java是整个项目最“重”的类,它承载了所有对话状态逻辑。重点看它的构造与addMessage()方法:
public class Conversation { private final String sessionId; private final List<Message> messages; // 按时间顺序存储,index = list.size()-1 private int totalTokens; // 当前所有 message 的 tokens 总和 private final TokenCounter tokenCounter; // 依赖注入,实际用 tiktoken-jvm public Conversation(String sessionId) { this.sessionId = sessionId; this.messages = new ArrayList<>(); this.tokenCounter = new TiktokenTokenCounter("cl100k_base"); // OpenAI 默认编码 } public void addMessage(Message message) { // 1. 插入前先计算该 message 的 tokens int msgTokens = tokenCounter.countTokens( String.format("%s: %s", message.getRole(), message.getContent()) ); // 2. 更新总 tokens(注意:这里不减去旧 message,因为新 message 是追加) this.totalTokens += msgTokens; // 3. 追加到列表,设置 index message.setIndex(messages.size()); messages.add(message); // 4. 强制同步更新 Redis 中的 tokens 字段(避免后续读取 stale data) reactiveStringRedisTemplate.opsForHash() .put("session:" + sessionId, "total_tokens", String.valueOf(totalTokens)) .subscribe(); // fire-and-forget,不阻塞主线程 } }关键点说明:
Message.index字段不是数据库自增 ID,而是在当前会话内的绝对序号(从 0 开始),这使得rollbackTo(2)能精准截断到第 3 条消息(index=0,1,2);tokenCounter使用tiktoken-jvm库,而非简单按字符数或单词数估算,确保与 OpenAI 官方 token 计数一致(实测误差 < 0.1%);addMessage()中的 Redis 写操作用subscribe()异步执行,避免阻塞 WebFlux 的 event loop 线程。
3.2 Token 统计的玄学:为什么不能只算 content?system prompt 怎么计入?
OpenAI 文档明确指出:systemrole 的 message 也会消耗 tokens,且计入prompt_tokens。很多毕业设计项目只统计 user/assistant 的 content,导致:
- 实际 token 超限却无感知(
max_tokens设置偏小); usage.total_tokens与自己统计值偏差 > 30%,无法做成本核算。
本项目在Conversation.addMessage()中统一处理:
// 在 addMessage() 内部 if (message.getRole() == Role.SYSTEM) { // SYSTEM message 需额外计入 3 个 tokens:role 名称 + 冒号 + 空格 // 参考 OpenAI 官方 tiktoken 示例:https://github.com/openai/tiktoken-java#how-tokens-are-counted msgTokens += tokenCounter.countTokens("system: "); }同时,在调用 OpenAI API 前,ChatService会显式构建messages数组并传入:
List<Map<String, String>> openAiMessages = conversation.getMessages().stream() .map(msg -> Map.of("role", msg.getRole().name().toLowerCase(), "content", msg.getContent())) .collect(Collectors.toList()); // 这样构造的数组,与 OpenAI 官方 Python SDK 的 messages 参数完全一致3.3 Redis 中的会话数据结构:为什么用 Hash 而不是 JSON String?
项目将每个会话存为 Redis Hash,key 为session:{id},field 包括:
| field | value type | 说明 |
|---|---|---|
messages | JSON String | List<Message>序列化结果,含 index/role/content/timestamp |
total_tokens | String | 当前总 tokens,用于快速判断是否超限 |
last_active | String (ISO8601) | 最后一次操作时间,用于自动过期 |
model | String | 当前会话绑定的模型名,如gpt-4-turbo |
这样设计的好处:
- 原子性读写:
HGETALL session:abc123一次性读取全部字段,避免多次网络往返; - 部分更新:
HSET session:abc123 total_tokens 1234只更新 tokens,不影响 messages; - 过期策略灵活:
EXPIREAT session:abc123 <timestamp>可独立设置,不依赖 messages 内容。
对比方案(JSON String)的问题:
- 每次更新需
GET → parse → modify → SET,三步操作非原子; - 若并发写入(如用户快速连发两条),极易覆盖对方修改;
- 无法单独查询
total_tokens,必须反序列化整个 JSON。
4. 快速运行指南:从 clone 到看到第一个流式响应,5 分钟内完成
4.1 环境准备与依赖安装
确保已安装:
- JDK 17+(Spring Boot 3.x 强制要求)
- Maven 3.8+
- Redis 7.0+(本地开发可用 Docker:
docker run -d --name redis-stack -p 6379:6379 -p 8001:8001 redis/redis-stack:latest)
注意:项目默认连接
localhost:6379的 Redis,无需密码。若需改配置,编辑src/main/resources/application-dev.yml中的spring.redis.host和spring.redis.port。
4.2 获取 OpenAI API Key 并配置
- 登录 platform.openai.com ,进入 API Keys 页面;
- 点击
Create new secret key,复制生成的 key(形如sk-proj-xxx); - 在项目根目录创建
.env文件(此文件已加入 .gitignore,不会提交):
# .env OPENAI_API_KEY=sk-proj-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx OPENAI_BASE_URL=https://api.openai.com/v14.3 启动应用并验证
# 1. 清理并打包(跳过测试以加速) mvn clean package -DskipTests # 2. 启动应用(自动加载 application-dev.yml) java -jar target/ai-robot-0.0.1-SNAPSHOT.jar # 3. 验证 Redis 连接(可选) redis-cli PING # 应返回 "PONG" # 4. 发送第一个测试请求(使用 curl) curl -X POST http://localhost:8080/api/v1/chat \ -H "Content-Type: application/json" \ -d '{ "sessionId": "test-001", "model": "gpt-3.5-turbo", "messages": [{"role": "user", "content": "你好,请用中文简单介绍你自己"}] }'预期响应(SSE 流式):
event: message data: {"role":"assistant","content":"我是基于Spring Boot构建的AI对话机器人..."} ... event: done data: {"finish_reason":"stop","usage":{"prompt_tokens":25,"completion_tokens":42,"total_tokens":67}}4.4 前端快速体验:用 HTML + JavaScript 直接对接
项目附带一个极简前端src/main/resources/static/index.html,打开即用:
<!-- src/main/resources/static/index.html --> <!DOCTYPE html> <html> <head><title>AI Robot Demo</title></head> <body> <textarea id="input" placeholder="输入消息..."></textarea> <button onclick="send()">发送</button> <div id="output"></div> <script> function send() { const input = document.getElementById('input').value; const output = document.getElementById('output'); const evtSource = new EventSource( `http://localhost:8080/api/v1/chat?sessionId=test-001&model=gpt-3.5-turbo&message=${encodeURIComponent(input)}` ); evtSource.onmessage = (event) => { output.innerHTML += event.data + "<br>"; }; evtSource.addEventListener('done', (event) => { console.log('对话结束:', JSON.parse(event.data)); }); } </script> </body> </html>启动应用后,直接浏览器访问http://localhost:8080即可交互。
5. 常见问题排查:那些让你怀疑人生、最后发现只是少配了一个 header 的瞬间
5.1 现象:调用/api/v1/chat返回 400 Bad Request,日志显示Required request body is missing
原因:前端发送请求时未设置Content-Type: application/json,或后端ChatRequest类缺少@RequestBody注解(但本项目已正确添加)。
解决:检查 curl 命令是否包含-H "Content-Type: application/json";若用 Postman,确认 Body → raw → JSON 已选中;若用 JS fetch,确认 headers 包含'Content-Type': 'application/json'。
5.2 现象:Redis 连接失败,启动报错Unable to connect to Redis,但redis-cli能连
原因:Spring Boot 默认使用 Lettuce 客户端,其 SSL 配置与本地 Redis 不兼容。Docker 启动的redis-stack默认禁用 SSL,但某些 Lettuce 版本会尝试走 SSL。
解决:在application-dev.yml中显式关闭 SSL:
spring: redis: host: localhost port: 6379 lettuce: ssl: enabled: false # 强制禁用 SSL5.3 现象:流式响应卡住,浏览器一直转圈,event: message一条也不来
原因:OpenAI API Key 权限不足或余额为 0。OpenAI 对免费试用额度有严格限制(新账号 $5),且gpt-4等模型不在免费额度内。
解决:
- 登录 platform.openai.com/account/usage 查看剩余额度;
- 在
application-dev.yml中临时换用gpt-3.5-turbo(免费额度可用); - 若仍不行,用 curl 直接调 OpenAI 官方接口验证 Key 有效性:
curl https://api.openai.com/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $OPENAI_API_KEY" \ -d '{ "model": "gpt-3.5-turbo", "messages": [{"role": "user", "content": "test"}] }'5.4 现象:/api/v1/conversation/{id}/rollback?to=2返回 404
原因:该接口在SessionController中定义为@DeleteMapping,但前端误用GET请求(RESTful 设计中 rollback 是状态变更操作,应为 DELETE)。
解决:前端必须用 DELETE 方法调用:
curl -X DELETE "http://localhost:8080/api/v1/conversation/test-001/rollback?to=2"若用 JS,fetch()需指定 method:
fetch(`http://localhost:8080/api/v1/conversation/${sessionId}/rollback?to=2`, { method: 'DELETE' });5.5 现象:total_tokens统计值比 OpenAI 返回的usage.prompt_tokens少 10~20 个
原因:未计入 OpenAI 隐式添加的分隔符 tokens。OpenAI 在messages数组前后会自动插入<|startoftext|>和<|endoftext|>等特殊 token。
解决:项目已内置补偿逻辑。在OpenAiClient的parseUsage()方法中:
private Usage parseUsage(JsonNode usageNode) { int promptTokens = usageNode.get("prompt_tokens").asInt(); // OpenAI 实际 prompt tokens = 我们统计值 + 4(固定开销) // 所以展示给用户的 total_tokens = promptTokens - 4 + completionTokens return new Usage( promptTokens - 4, usageNode.get("completion_tokens").asInt(), usageNode.get("total_tokens").asInt() ); }因此,你在 Redis 中看到的total_tokens是经过校准的,可直接用于成本核算。
6. 二次开发实战:如何接入国内大模型(如千问、混元)并复用全部对话状态逻辑
6.1 新增 ModelProvider:以通义千问 Qwen 为例
假设你要接入阿里云百炼平台的 Qwen 模型,API 地址为https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation,需新增QwenProvider:
@Component public class QwenProvider implements ModelProvider { private final WebClient webClient; private final String apiKey; public QwenProvider(WebClient.Builder builder, @Value("${qwen.api-key}") String apiKey) { this.webClient = builder .defaultHeader("Authorization", "Bearer " + apiKey) .defaultHeader("Content-Type", "application/json") .build(); this.apiKey = apiKey; } @Override public Mono<ModelResponse> chat(ChatRequest request) { // 1. 构造 Qwen 兼容的请求体(注意字段名差异) Map<String, Object> qwenBody = new HashMap<>(); qwenBody.put("model", "qwen-max"); // 模型名 qwenBody.put("input", Map.of("messages", convertToQwenMessages(request.getMessages()))); qwenBody.put("parameters", Map.of("result_format", "message")); // 2. 调用百炼 API return webClient.post() .uri("https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation") .bodyValue(qwenBody) .retrieve() .bodyToMono(JsonNode.class) .map(this::parseQwenResponse); // 解析为统一 ModelResponse } private List<Map<String, String>> convertToQwenMessages(List<Message> messages) { // Qwen 要求 role 为 system/user/assistant,与 OpenAI 一致,直接映射 return messages.stream() .map(msg -> Map.of("role", msg.getRole().name().toLowerCase(), "content", msg.getContent())) .collect(Collectors.toList()); } private ModelResponse parseQwenResponse(JsonNode response) { JsonNode output = response.get("output"); JsonNode choices = output.get("choices"); if (choices.isEmpty()) throw new RuntimeException("No choices in Qwen response"); String content = choices.get(0).get("message").get("content").asText(); JsonNode usage = response.get("usage"); return new ModelResponse( content, "stop", // Qwen 固定为 stop new Usage( usage.get("input_tokens").asInt(), usage.get("output_tokens").asInt(), usage.get("total_tokens").asInt() ) ); } @Override public String getModelName() { return "qwen-max"; } @Override public boolean supportsStreaming() { return false; // 百炼暂不支持流式,返回 false 则走非流式分支 } }6.2 配置与注册:让框架自动识别新 Provider
- 在
application-dev.yml中添加配置:
qwen: api-key: sk-xxxxxx # 替换为你的百炼 API Key- 在
providerRegistry的初始化方法中,手动注册(或利用 Spring 的@ConditionalOnProperty自动扫描):
@Configuration public class ProviderConfig { @Bean @Primary public ProviderRegistry providerRegistry(QwenProvider qwenProvider, OpenAiProvider openAiProvider, AnthropicProvider anthropicProvider) { ProviderRegistry registry = new ProviderRegistry(); registry.register("qwen-max", qwenProvider); registry.register("gpt-3.5-turbo", openAiProvider); registry.register("claude-3-haiku", anthropicProvider); return registry; } }6.3 复用现有状态逻辑:你不需要重写任何 Conversation 代码
这是本项目最省力的设计——所有会话状态管理(addMessage,rollbackTo,getTokenCount)、Redis 存储、token 统计、SSE 流控,都与具体模型 provider完全解耦。你只需:
- 实现
ModelProvider接口; - 在
providerRegistry中注册; - 前端请求时传
"model": "qwen-max";
其余一切(创建 session、保存历史、流式推送、回滚、token 计费)全部自动生效。我曾在一个 48 小时黑客松中,用这套方案在 3 小时内接入了 4 个不同厂商的模型(OpenAI、Anthropic、Zhipu、Qwen),全程没动过Conversation或SessionManager一行代码。
从那以后我每次接到“要支持 XX 新模型”的需求,第一反应不是翻文档,而是打开src/main/java/ai/robot/client/新建一个XxProvider.java,然后泡杯咖啡等 CI 构建完成。希望帮到你。
本文还有配套的精品资源,点击获取