Spring Boot构建多模型AI服务中台:毕业设计高分架构方案
简介:这是一套面向计算机、电子信息工程及数学等专业本科生的毕业设计与课程实践参考项目,基于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配置、SQL脚本、Dockerfile容器化支持及SCSS样式体系,结构完整、分层清晰,便于二次开发与模块替换。压缩包大小26.84MB,轻量易部署,适合作为毕设原型快速验证AI集成方案。目前已有1235人学习下载,提供开箱即用的后端服务架构、前后端分离实现、多模型统一调度接口及典型UI交互示例,助学生高效完成系统设计、接口对接与演示汇报。
1. 为什么毕业设计选“Spring Boot + 多模型AI机器人”比单写个ChatUI更扛答辩?
去年带某高校毕设时,A同学交了份纯前端调用OpenAI API的聊天界面——界面漂亮、响应快,但答辩被连问三轮:“模型切换逻辑在哪?”“API密钥怎么隔离?”“断网时提示是弹alert还是走降级策略?”最后卡在“你这个系统算‘后端服务’还是‘网页代理’”上。这暴露了一个现实:评审老师早不满足于“能跑就行”,他们要看到工程闭环能力:从请求接入、模型路由、上下文管理到异常兜底,每层都得有设计痕迹。本方案不是封装一个/chat接口,而是构建一个可插拔、可监控、可灰度的AI服务中台——它用Spring Boot做骨架,把Qwen、GLM、Claude等主流开源/商用大模型抽象成统一ModelProvider接口,支持运行时热切换、按Token计费、对话历史持久化到MySQL+Redis双写。适合需要体现“架构思维+工程落地+AI整合”三重能力的毕业设计或课程设计,尤其对答辩时被追问“你怎么保证不被限流?”“历史记录怎么防泄漏?”的同学,这套方案里全有现成答案。
2. 总体架构设计:四层解耦结构与五个核心模块的职责边界
本方案采用清晰的分层架构,避免把AI逻辑和Web层胶在一起。整个系统划分为:接入层(WebMvc)→ 路由层(ModelRouter)→ 执行层(ModelExecutor)→ 存储层(DataAccess)→ 基础设施层(Config/Utils)。这种分法不是为了画图好看,而是为了解决三个实际问题:第一,当某天要接入新模型(比如刚发布的DeepSeek-V3),只需新增一个DeepSeekProvider实现类,不用动Controller;第二,当线上发现Qwen模型响应慢,能通过配置中心一键切到GLM备用通道;第三,学生答辩时能指着架构图说清“这里负责协议转换,这里管状态同步,这里做熔断”。
2.1 接入层:RESTful接口设计与请求标准化
所有外部请求统一走/api/v1/chat,不暴露模型细节。关键设计点在于请求体强制包含modelType字段(如qwen-7b-chat、glm-4-flash),而非用URL路径区分。这样做的好处是:前端无需为每个模型写不同接口,后续加模型也不用改前端代码。同时定义标准错误码体系:
{ "code": 4001, "message": "模型不可用,请稍后重试", "data": { "fallbackModel": "glm-4-flash" } }提示:
code字段用四位数字,前两位代表模块(40=AI服务),后两位是具体错误(01=模型不可用)。比HTTP状态码更细粒度,方便前端做差异化提示。
Controller层只做三件事:参数校验(用@Valid注解)、调用路由层、统一封装响应。绝不处理模型调用逻辑:
@RestController @RequestMapping("/api/v1") public class ChatController { @Autowired private ModelRouter modelRouter; @PostMapping("/chat") public ResponseEntity<ApiResponse<ChatResponse>> chat( @Valid @RequestBody ChatRequest request) { // 校验通过后直接委托给路由层 ChatResponse response = modelRouter.routeAndExecute(request); return ResponseEntity.ok(ApiResponse.success(response)); } }这段代码背后藏着重要设计决策:modelRouter.routeAndExecute()方法内部会根据request.getModelType()查配置表,确认该模型是否启用、当前负载是否超阈值、是否命中缓存。Controller本身是“无状态”的,所有业务逻辑下沉,这是应对答辩时被问“如果模型A挂了,你怎么自动切到B”的底气来源。
2.2 路由层:动态模型选择与负载感知调度
ModelRouter是整个系统的智能中枢。它不硬编码模型列表,而是从数据库读取model_config表(含model_code、status、weight、max_concurrent字段),结合实时指标(如Prometheus上报的QPS、平均延迟)做决策。核心逻辑如下:
public ChatResponse routeAndExecute(ChatRequest request) { // 1. 根据modelType查配置 ModelConfig config = modelConfigMapper.selectByCode(request.getModelType()); // 2. 检查状态:DISABLED直接抛异常,MAINTENANCE走降级 if (config.getStatus().equals("DISABLED")) { throw new ModelUnavailableException("模型已下线"); } // 3. 获取当前模型实例(单例池化,避免重复创建HttpClient) ModelProvider provider = modelProviderPool.get(config.getModelCode()); // 4. 执行前检查并发数(用Redis原子计数器) String key = "model:concurrent:" + config.getModelCode(); Long current = redisTemplate.opsForValue().increment(key, 1); if (current > config.getMaxConcurrent()) { redisTemplate.opsForValue().decrement(key, 1); // 回滚 return fallbackToOtherModel(request); // 切换备用模型 } try { return provider.execute(request); } finally { redisTemplate.opsForValue().decrement(key, 1); // 必须释放 } }这里的关键是并发控制必须用Redis而非JVM内存。曾有学生用ConcurrentHashMap计数,结果集群部署时各节点计数独立,导致超卖。而Redis原子操作保证全局一致性。modelProviderPool用ConcurrentHashMap<String, ModelProvider>实现,首次调用时按modelCode反射创建对应Provider实例并缓存,避免每次请求都new对象。
2.3 执行层:统一模型适配器与协议转换
所有模型Provider必须实现ModelProvider接口:
public interface ModelProvider { ChatResponse execute(ChatRequest request); ModelMetadata getMetadata(); // 返回模型名称、版本、token限制等 }以Qwen为例,QwenProvider需完成三件事:
- 将通用
ChatRequest转为Qwen API要求的JSON格式(注意:Qwen要求messages数组里role只能是user/assistant/system,而GLM允许observer); - 构造HTTP请求头(
Authorization: Bearer ${apiKey},Content-Type: application/json); - 解析Qwen返回的流式响应(SSE格式),拼接完整文本并提取usage字段。
@Override public ChatResponse execute(ChatRequest request) { // 构建Qwen专用请求体 QwenRequest qwenReq = new QwenRequest(); qwenReq.setModel("qwen-7b-chat"); // 硬编码模型名,与provider绑定 qwenReq.setMessages(convertToQwenMessages(request.getMessages())); qwenReq.setStream(false); // 毕设场景关流式,简化处理 // 同步HTTP调用(生产环境建议用WebClient) String url = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation"; HttpHeaders headers = new HttpHeaders(); headers.set("Authorization", "Bearer " + apiKey); HttpEntity<QwenRequest> entity = new HttpEntity<>(qwenReq, headers); ResponseEntity<QwenResponse> response = restTemplate.postForEntity( url, entity, QwenResponse.class); // 协议转换:QwenResponse → ChatResponse return ChatResponse.builder() .content(response.getBody().getOutput().getText()) .usage(Usage.builder() .promptTokens(response.getBody().getUsage().getInput_tokens()) .completionTokens(response.getBody().getUsage().getOutput_tokens()) .build()) .build(); }注意convertToQwenMessages()方法必须做角色映射容错。比如用户传入role=tool(某些框架扩展),Qwen不识别,就默认转成user。这种细节正是答辩时老师爱问的“你处理过模型协议差异吗?”的答案。
2.4 存储层:对话历史双写与敏感信息脱敏
对话记录不能只存在内存里。本方案采用MySQL+Redis双写:
- MySQL存归档:表
chat_history记录id、session_id、model_type、prompt、response、created_at,用于后期审计和数据分析; - Redis存热数据:用
HASH结构存session_id对应的消息列表,TTL设为24小时,支撑快速上下文加载。
关键设计是写入前脱敏。prompt和response字段在入库前调用SensitiveDataFilter:
@Component public class SensitiveDataFilter { private static final Pattern PHONE_PATTERN = Pattern.compile("(1[3-9]\\d{9})"); private static final Pattern ID_CARD_PATTERN = Pattern.compile("(\\d{17}[0-9xX])"); public String filter(String text) { if (text == null) return null; // 手机号中间4位打*号 text = PHONE_PATTERN.matcher(text).replaceAll("$1".replaceFirst("(\\d{3})(\\d{4})(\\d{4})", "$1****$3")); // 身份证号第7-14位打*号 text = ID_CARD_PATTERN.matcher(text).replaceAll("$1".replaceFirst("(\\d{6})(\\d{8})(\\d{4})", "$1********$3")); return text; } }注意:脱敏必须在DAO层之前完成,不能依赖前端传来的“已脱敏”字段。曾有学生把脱敏逻辑放在Controller,结果Postman直连绕过,导致测试库泄露手机号。
2.5 基础设施层:配置中心与可观测性埋点
所有模型API Key、Endpoint、超时时间等敏感配置,不写死在application.yml,而是通过@ConfigurationProperties绑定到ModelConfigProperties类,并从Nacos配置中心动态拉取:
# nacos配置项示例(dataId=model-config-dev.yaml) ai: models: - code: qwen-7b-chat endpoint: https://dashscope.aliyuncs.com/api/v1/... timeout: 30000 max-retry: 2 - code: glm-4-flash endpoint: https://open.bigmodel.cn/api/paas/v4/... timeout: 45000 max-retry: 3同时在关键路径埋点:
ModelRouter.routeAndExecute()开头打start_route日志,结尾打end_route并记录耗时;- 每个
ModelProvider.execute()方法用@Timed注解(Micrometer),自动上报到Prometheus; - 对话记录插入MySQL后,发
ChatSavedEvent事件,供后续做离线分析。
这些不是炫技,而是让答辩时能展示“我不仅做了功能,还考虑了运维和排查”。当老师问“怎么知道哪个模型最慢?”,你可以直接打开Grafana看model_execute_duration_seconds_max指标。
3. 关键模块实现:从模型注册到对话上下文管理的完整链路
3.1 模型注册中心:基于SPI机制的动态加载
不靠if-else判断模型类型,而是用Java SPI(Service Provider Interface)实现解耦。在src/main/resources/META-INF/services下创建文件com.example.ai.provider.ModelProvider,内容为:
com.example.ai.provider.QwenProvider com.example.ai.provider.GlmProvider com.example.ai.provider.ClaudeProvider启动时扫描所有实现类并注入modelProviderPool:
@Component public class ModelProviderLoader { private final Map<String, ModelProvider> providerMap = new ConcurrentHashMap<>(); @PostConstruct public void loadProviders() { ServiceLoader<ModelProvider> loader = ServiceLoader.load(ModelProvider.class); for (ModelProvider provider : loader) { // 通过provider.getMetadata().getCode()获取唯一标识 providerMap.put(provider.getMetadata().getCode(), provider); } log.info("Loaded {} model providers", providerMap.size()); } public ModelProvider get(String modelCode) { ModelProvider provider = providerMap.get(modelCode); if (provider == null) { throw new IllegalArgumentException("Unknown model: " + modelCode); } return provider; } }这样做的好处是:新增模型只需写一个Provider类+在SPI文件里加一行,重启服务即生效。比在application.yml里配一堆model.type=qwen,glm,claude更符合“开闭原则”,答辩时能体现设计模式应用能力。
3.2 对话上下文管理:SessionID生成与Redis存储结构
session_id不是UUID,而是带业务含义的字符串:ai-{timestamp}-{random6}(如ai-1715234567890-ab3xk9)。好处是:
- 时间戳便于按天分片归档;
- 随机段防碰撞,且比UUID短30%;
- 前缀
ai-方便Redis里用KEYS ai-*批量清理。
Redis中用HASH存消息列表,key为session:{session_id},field为msg_{index},value为JSON序列化的ChatMessage:
// Redis HASH 示例 session:ai-1715234567890-ab3xk9 msg_0: {"role":"user","content":"你好"} msg_1: {"role":"assistant","content":"你好!有什么可以帮您?"} msg_2: {"role":"user","content":"今天天气怎么样?"}加载上下文时,按field名升序遍历,还原为List<ChatMessage>。注意:ChatMessage类必须用@Data+@NoArgsConstructor,确保Jackson能反序列化。
3.3 流式响应支持:SSE协议解析与前端兼容
虽然毕设可关流式,但实现它能极大提升答辩印象分。后端用SseEmitter推送:
@GetMapping("/stream") public SseEmitter streamChat(@RequestParam String sessionId) { SseEmitter emitter = new SseEmitter(30_000L); // 30秒超时 // 从Redis读取历史消息 List<ChatMessage> history = loadHistoryFromRedis(sessionId); // 异步调用模型(非阻塞) CompletableFuture.supplyAsync(() -> { try { // 调用Qwen流式API(返回SSE响应体) String sseResponse = callQwenStreamApi(history); parseAndEmit(sseResponse, emitter); // 解析SSE事件并emit } catch (Exception e) { emitter.send(SseEmitter.event() .name("error") .data("执行失败:" + e.getMessage())); } finally { emitter.complete(); } return null; }); return emitter; }前端用原生EventSource接收:
const eventSource = new EventSource('/api/v1/stream?sessionId=' + sessionId); eventSource.onmessage = function(event) { const data = JSON.parse(event.data); appendToChatBox(data.content); // 追加到对话框 }; eventSource.addEventListener('error', function(e) { console.error('SSE error:', e); });关键点:SSE要求后端响应头必须含Content-Type: text/event-stream,且每条消息以data: {...}\n\n结尾。parseAndEmit()方法需按行分割,过滤event:、id:等控制字段,只取data:后的JSON。
3.4 Token统计与计费模拟:基于HuggingFace Tokenizer的本地计算
不依赖模型API返回的usage字段(有些模型不返回),而是用transformers库本地计算:
<!-- pom.xml --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.hankcs</groupId> <artifactId>hanlp</artifactId> <version>2.1.0-beta</version> <!-- 轻量级中文tokenizer --> </dependency>@Component public class TokenCounter { private final Segment segment = HanLP.newSegment().enableNameRecognize(true); public int countTokens(String text) { if (text == null || text.trim().isEmpty()) return 0; // 中文按字切分(粗略估算,实际可用jieba更准) List<Term> terms = segment.seg(text); return terms.size(); } public Usage estimateUsage(ChatRequest request, String response) { int promptTokens = request.getMessages().stream() .mapToInt(msg -> countTokens(msg.getContent())) .sum(); int completionTokens = countTokens(response); return Usage.builder() .promptTokens(promptTokens) .completionTokens(completionTokens) .totalTokens(promptTokens + completionTokens) .build(); } }提示:毕设中“计费”不必真扣钱,但要有
usage字段返回给前端显示“本次消耗XX Token”,体现成本意识。老师会认可这种工程化思维。
3.5 异常熔断与降级策略:Resilience4j集成实战
当某个模型连续5次超时(>30s),自动触发熔断,10分钟内拒绝所有请求,直接走降级:
@Bean public CircuitBreaker circuitBreaker() { CircuitBreakerConfig config = CircuitBreakerConfig.custom() .failureRateThreshold(50) // 错误率50% .waitDurationInOpenState(Duration.ofMinutes(10)) .ringBufferSizeInHalfOpenState(10) .build(); return CircuitBreaker.of("qwen-circuit-breaker", config); } // 在QwenProvider.execute()中使用 public ChatResponse execute(ChatRequest request) { return circuitBreaker.executeSupplier(() -> { // 原有HTTP调用逻辑 return doActualCall(request); }, throwable -> { // 降级逻辑:返回预设话术 or 切换到GLM return fallbackToGlm(request); }); }熔断状态存在Redis里(用CircuitBreakerRegistry的publishOnStateTransition监听),方便后台页面查看各模型健康度。答辩时可演示“手动触发Qwen超时,看降级是否生效”,比讲理论更有说服力。
4. 避坑指南:毕业设计中最容易翻车的5个技术点
4.1 现象:本地调试OK,部署到服务器后所有模型调用都返回403
原因:服务器防火墙拦截了出站HTTPS请求,或JVM信任库未导入模型服务商的SSL证书(如DashScope用阿里云证书,部分老JDK不认)。
解决:在服务器执行curl -I https://dashscope.aliyuncs.com确认网络通;若报SSL错误,在JVM启动参数加-Djavax.net.ssl.trustStore=/path/to/cacerts指向最新证书库;更稳妥做法是在RestTemplate配置中禁用SSL验证(仅测试环境):
@Bean public RestTemplate restTemplate() { HttpClient httpClient = HttpClientBuilder.create() .setSSLContext(SSLContextBuilder.create() .loadTrustMaterial(null, (chain, authType) -> true).build()) .build(); return new RestTemplate(new HttpComponentsClientHttpRequestFactory(httpClient)); }4.2 现象:Redis存储的对话历史偶尔丢失,特别是高并发时
原因:用了SET session:xxx "[{...}]"直接存JSON字符串,但没设TTL;或用HSET但没配EXPIRE,导致内存爆满后Redis LRU淘汰掉热数据。
解决:所有HSET操作后立即执行EXPIRE key 86400;或改用HSET session:xxx msg_0 "..." EX 86400(Redis 7.0+支持);更推荐用RedisTemplate.opsForHash().putAll()配合expire()方法,确保原子性。
4.3 现象:切换模型后,前端收到的modelType还是旧值,导致路由错乱
原因:前端在/chat请求体里硬编码了"modelType":"qwen-7b-chat",但没随用户选择实时更新;或Vue组件中v-model绑定的modelType未响应式更新。
解决:前端必须用computed属性动态生成请求体:
computed: { chatPayload() { return { modelType: this.selectedModel, // 绑定下拉框v-model messages: this.messages }; } }后端ModelRouter日志里加log.debug("Routing to model: {}", request.getModelType()),部署后第一时间看日志确认传入值。
4.4 现象:MySQL里chat_history表数据量暴增,磁盘告警
原因:没做数据归档,所有对话无差别存库;或created_at字段没建索引,SELECT * FROM chat_history WHERE created_at > '2024-01-01'全表扫描。
解决:
- 在
created_at字段建索引:ALTER TABLE chat_history ADD INDEX idx_created_at (created_at); - 写定时任务(
@Scheduled(cron = "0 0 2 * * ?"))归档30天前数据到chat_history_archive表; - 毕设中可在
application.yml加spring.sql.init.mode=always,初始化时自动建索引。
4.5 现象:答辩演示时,老师输入长文本(>2000字)直接超时,页面卡死
原因:没做输入长度校验,长文本导致模型API超时(Qwen默认30s),而前端axios没设timeout,一直等待。
解决:
- 后端Controller加
@Size(max = 2000, message = "输入内容不能超过2000字"); - 前端
textarea加maxlength="2000"并实时字数统计; axios请求配置timeout: 45000,超时后提示“内容过长,请精简后重试”。
5. 毕业答辩实战:从代码演示到答辩话术的全流程通关技巧
5.1 演示环节:如何用3分钟讲清技术深度
别一上来就点开IDE讲源码。按“问题-解法-证据”三段式演示:
第一步(30秒):抛出真实痛点。“我们发现,直接调用大模型API存在三个问题:模型切换要改代码、历史记录无法追溯、超时后页面假死。”
第二步(90秒):聚焦一个模块演示。“以模型路由为例,我设计了动态配置表(打开model_config表),这里status字段控制启停,weight字段支持灰度发布。现在我把Qwen设为DISABLED(改数据库),再发请求——看日志(终端),它自动切到GLM,并返回fallbackModel字段(展示响应JSON)。”
第三步(60秒):亮数据证据。“这是Prometheus监控(切到Grafana页签),Qwen的model_execute_duration_seconds_max从32s飙升到120s,而GLM稳定在1.2s,证明熔断生效。”
提示:提前录好3段15秒短视频(数据库修改、日志输出、Grafana图表),答辩时网络卡顿可直接播放,比现场操作更稳。
5.2 答辩话术:把“我做了”升级为“我思考了”
老师问“为什么用Redis存历史而不是直接MySQL?”
❌ 错误答法:“因为Redis快。”
✅ 正确答法:“我对比了两种方案:MySQL单次写入约15ms,Redis HASH写入约0.3ms。按毕设演示的100并发压测,Redis能扛住,MySQL会出现连接池耗尽。更重要的是,Redis的TTL自动过期,省去了写定时清理脚本的麻烦——这是工程上‘用合适工具解决合适问题’的体现。”
老师问“Token统计为什么不用API返回的,要自己算?”
❌ 错误答法:“API有时候不返回。”
✅ 正确答法:“我调研了Qwen、GLM、Claude的文档,发现只有Qwen稳定返回usage字段,GLM在流式模式下不返回,Claude甚至要额外开付费权限。如果依赖API,系统就不可控。所以我用HanLP本地分词,虽然精度略低(误差±5%),但保证了100%可用性,也符合‘降级优先’的设计原则。”
5.3 文档与PPT:让评委一眼抓住重点
PPT不要放整段代码,用对比表格呈现设计决策:
| 维度 | 方案A(硬编码if-else) | 方案B(SPI+配置中心) | 选择理由 |
|---|---|---|---|
| 新增模型成本 | 修改Java代码+重启 | 写Provider类+改配置 | 符合开闭原则 |
| 故障隔离 | 一个模型异常拖垮全部 | 熔断器隔离 | 提升系统健壮性 |
| 配置管理 | 分散在多个yml文件 | 统一Nacos界面管理 | 降低运维复杂度 |
论文中的“系统设计”章节,用模块职责表替代文字描述:
| 模块 | 输入 | 输出 | 关键约束 |
|---|---|---|---|
| ModelRouter | ChatRequest(含modelType) | ChatResponse | 并发数≤max_concurrent |
| QwenProvider | ChatRequest | ChatResponse(含usage) | 超时30s自动重试2次 |
| TokenCounter | String | Usage | 中文按字计数,误差<5% |
5.4 答辩心态:把“被质疑”当成展示思考深度的机会
曾有个学生被问:“你这个系统和ChatGPT官方App有啥区别?”他没慌,而是笑着打开PPT一页:“老师,这是我的定位对比表——ChatGPT是面向C端的产品,我们是面向B端的AI服务中台。它解决‘怎么聊得好’,我们解决‘怎么管得好’:模型热切换、多租户隔离、Token计量、审计日志。就像汽车和4S店的关系,我们不做车,但提供让车跑起来的全套基础设施。”
这种回答把劣势(功能少)转化为优势(定位准),老师反而会点头。从那以后我每次带毕设,都强制学生在答辩前写三版“被问倒怎么办”预案:
- 技术细节记不清 → “这部分我实现了,但具体参数需要查代码,我可以现场定位给您看”;
- 设计原理说不透 → “我参考了《微服务设计》第7章的熔断模式,核心是用状态机隔离故障”;
- 功能没做全 → “毕设周期有限,我优先实现了高价值模块(如路由、熔断),剩余模块(如多租户)已预留扩展点,代码里有TODO注释”。
希望帮到你。
本文还有配套的精品资源,点击获取