简介:这是一套面向计算机、电子信息工程及数学等专业本科生的毕业设计与课程实践参考项目,基于Spring Boot构建了具备多模型接入能力的人工智能交互机器人系统,有效解决学生在AI应用开发中模型对接复杂、后端集成困难、前后端协同不畅等典型问题。资源包共1157个文件,涵盖584个Java核心业务与控制器类、104个Vue前端组件、108个JavaScript逻辑脚本、73个XML配置及149个PNG界面资源,辅以YML配置、SQL建表、Dockerfile容器化支持等,完整呈现企业级AI应用的分层架构与工程规范,压缩包大小为26.84MB。目前已有1235人学习下载,可直接用于期末大作业或毕设开题,提供从大模型API统一调度、多模态响应(文本+AI绘图)、前后端分离部署到主流国产/国际模型(GPT-3.5/4.0、Kimi、文心一言、Stable Diffusion、Midjourney)的全链路实现参考。
1. 这不是又一个“调 API 的 Spring Boot 项目”:它把大模型交互拆成了可插拔、可审计、可回滚的模块,毕业设计答辩时导师盯着看了三分钟
你肯定见过那种“Spring Boot + OpenAI Key + 一行 RestTemplate 调用”的机器人 demo——跑得通,但一问“如果模型返回乱码怎么定位?”“用户发了敏感词谁来拦截?”“换 Qwen 不改代码行不行?”,当场哑火。这个项目不一样:它把大模型交互抽象成ModelProvider 接口 + Strategy 路由 + MessageChain 消息链三层结构,OpenAI、Azure、Ollama、本地 GGUF 全部走同一套注入逻辑;所有请求/响应自动落库(含 token 统计、耗时、错误堆栈);甚至支持按会话 ID 回溯完整对话树。某高校毕业设计组用它做“智能教务问答系统”,答辩时导师没问技术栈,而是翻着message_log表问:“这条超时请求为什么没触发熔断?你们的 fallback 策略在哪实现的?”——答案就在FallbackModelProvider类里。适合需要真实交付感、可演示细节、能讲清设计权衡的毕业设计或内部 PoC。
2. 项目结构与核心依赖:从 Maven 依赖到包结构,看清它为什么“不裸奔”
2.1 依赖选型:为什么不用 Spring AI?为什么坚持手写 ModelProvider?
项目没引入 Spring AI,原因很实际:Spring AI 0.8.x 对 Ollama 和本地 GGUF 支持弱,且其ChatClient抽象层强制要求所有模型返回Response<ChatResponse>,而 Azure OpenAI 的 streaming 响应格式和本地 llama.cpp 的 JSON-RPC 返回结构差异太大,硬套会导致大量instanceof判断和类型转换异常。本项目选择自己定义ModelProvider接口:
public interface ModelProvider { /** * 同步调用:返回完整响应(适合小模型/低频场景) * @param request 请求体,已包含 model name、messages、temperature 等 * @return 标准化响应,含 content、usage、finishReason */ ModelResponse syncCall(ModelRequest request); /** * 流式调用:通过 Consumer 推送 chunk(必须支持!否则前端打字效果失效) * @param request 同上 * @param chunkConsumer 每收到一个 token 就调用一次 */ void streamCall(ModelRequest request, Consumer<ModelChunk> chunkConsumer); }提示:
ModelRequest和ModelResponse是项目自定义 DTO,字段完全对齐 OpenAI v1 API 规范(messages,model,temperature,max_tokens),但额外增加了sessionId、traceId、source(标识是 web / api / cli 调用)字段,为后续审计埋点。
关键依赖清单(pom.xml 片段):
<!-- 必须:Web 层 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 必须:数据库审计日志 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> <!-- 必须:HTTP 客户端(比 RestTemplate 更可控) --> <dependency> <groupId>io.github.openfeign</groupId> <artifactId>feign-core</artifactId> <version>12.5</version> </dependency> <dependency> <groupId>io.github.openfeign</groupId> <artifactId>feign-jackson</artifactId> <version>12.5</version> </dependency> <!-- 可选但强烈建议:配置中心化 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-configuration-processor</artifactId> <optional>true</optional> </dependency>2.2 包结构:ai.model是心脏,ai.audit是骨架,ai.web是皮肤
src/main/java/com/example/ai/ ├── AiApplication.java // 主启动类,@EnableJpaAuditing ├── ai/ │ ├── audit/ // 审计核心 │ │ ├── entity/MessageLog.java // 主表:id, session_id, trace_id, model_name, ... │ │ ├── repository/MessageLogRepository.java │ │ └── aspect/AuditAspect.java // @Around("execution(* ai.service..*.*(..))") 自动记录 │ │ │ ├── model/ // 大模型能力中枢(重点!) │ │ ├── dto/ // ModelRequest / ModelResponse / ModelChunk │ │ ├── provider/ // 所有实现类在此 │ │ │ ├── openai/OpenAiProvider.java // OpenAI 官方 API │ │ │ ├── azure/AzureOpenAiProvider.java // Azure 部署版(需 endpoint + api-key + deployment-name) │ │ │ ├── ollama/OllamaProvider.java // 本地 Ollama(http://localhost:11434/api/chat) │ │ │ └── fallback/FallbackModelProvider.java // 熔断降级:返回预设话术 or 转人工 │ │ ├── strategy/ // 路由策略 │ │ │ ├── LoadBalanceStrategy.java // 权重轮询(给不同模型配权重) │ │ │ └── RuleBasedStrategy.java // 基于 sessionId 或 userRole 路由 │ │ └── service/ModelService.java // 门面:聚合 provider + strategy + audit │ │ │ ├── web/ // Web 层 │ │ ├── controller/ChatController.java // /api/chat POST(主入口) │ │ ├── dto/ChatRequest.java // { "sessionId": "...", "messages": [...] } │ │ └── config/WebMvcConfig.java // 配置 StreamingResponseBody 支持 SSE │ │ │ └── config/ // 配置类 │ ├── ModelProviderConfig.java // @Configuration + @ConditionalOnProperty 控制启用哪些 provider │ └── AuditConfig.java // JPA Auditing 配置这个结构意味着:你要换模型,只改provider/下一个类;要加审计字段,只改MessageLog.java和AuditAspect;要改路由逻辑,只碰strategy/。没有“改一处,八处报错”的玄学耦合。
3. 核心模块拆解:OpenAiProvider与OllamaProvider的实现差异与统一抽象
3.1OpenAiProvider:如何处理 OpenAI 的 streaming 响应(SSE)并转成标准ModelChunk
OpenAI 的/v1/chat/completionsstreaming 响应是 Server-Sent Events(SSE)格式,每行以data:开头,末尾双换行。直接用RestTemplate解析极容易丢 chunk 或阻塞。本项目用 Feign + 自定义 Decoder:
// openai/OpenAiProvider.java @Override public void streamCall(ModelRequest request, Consumer<ModelChunk> chunkConsumer) { // 1. 构建 Feign Client(复用已配置的 OkHttpClient) OpenAiFeignClient client = Feign.builder() .decoder(new OpenAiSseDecoder(chunkConsumer)) // 关键:自定义解码器 .target(OpenAiFeignClient.class, "https://api.openai.com/v1"); // 2. 构建请求体(注意:OpenAI 要求 messages 是 List<Map<String, String>>) Map<String, Object> body = new HashMap<>(); body.put("model", request.getModel()); body.put("messages", toOpenAiMessages(request.getMessages())); body.put("stream", true); body.put("temperature", request.getTemperature()); // 3. 发起流式调用 client.streamChat(body); } // 自定义 SSE 解码器(核心逻辑) static class OpenAiSseDecoder implements Decoder { private final Consumer<ModelChunk> chunkConsumer; OpenAiSseDecoder(Consumer<ModelChunk> chunkConsumer) { this.chunkConsumer = chunkConsumer; } @Override public Object decode(Response response, Type type) throws IOException { try (BufferedReader reader = new BufferedReader( new InputStreamReader(response.body().asInputStream()))) { String line; while ((line = reader.readLine()) != null) { if (line.startsWith("data: ")) { String json = line.substring(6).trim(); if (!json.equals("[DONE]")) { // 解析 OpenAI 的 chunk JSON JsonNode node = objectMapper.readTree(json); String content = ""; if (node.has("choices") && node.get("choices").size() > 0) { JsonNode delta = node.get("choices").get(0).get("delta"); if (delta.has("content")) { content = delta.get("content").asText(); } } // 转成标准 ModelChunk ModelChunk chunk = new ModelChunk(); chunk.setContent(content); chunk.setFinishReason(node.has("choices") && node.get("choices").get(0).has("finish_reason") ? node.get("choices").get(0).get("finish_reason").asText() : null); chunkConsumer.accept(chunk); // 推送给前端 } } } } return null; } }参数说明:
toOpenAiMessages()方法将通用List<Message>转为 OpenAI 要求的List<Map<String,String>>,其中role必须是"system"/"user"/"assistant",content不能为空字符串(OpenAI 会报错)。这是血泪经验:曾因传入""导致 400 错误,日志里只显示Bad Request,最后抓包才发现是 content 字段问题。
3.2OllamaProvider:如何兼容 Ollama 的 JSON-RPC 风格 API 并保持接口一致
Ollama 的/api/chat接口是同步 JSON-RPC 风格,但本项目要求streamCall必须支持流式——解决方案是:用OkHttpClient的 EventSource(SSE)客户端伪装成流式。Ollama 实际返回的是普通 JSON,但项目在OllamaProvider内部做了“流式包装”:
// ollama/OllamaProvider.java @Override public void streamCall(ModelRequest request, Consumer<ModelChunk> chunkConsumer) { // 1. 构建 Ollama 请求体(注意:Ollama 用 "model" 字段,不是 "model_name") Map<String, Object> body = new HashMap<>(); body.put("model", request.getModel()); // 如 "qwen:7b" body.put("messages", toOllamaMessages(request.getMessages())); // role 映射:user->user, assistant->assistant body.put("stream", true); // 关键:告诉 Ollama 返回流式 // 2. 用 OkHttp 直接发起 POST(不走 Feign,因 Ollama 无标准 REST) Request okRequest = new Request.Builder() .url("http://localhost:11434/api/chat") .post(RequestBody.create( objectMapper.writeValueAsBytes(body), MediaType.parse("application/json"))) .build(); try (Response response = okHttpClient.newCall(okRequest).execute()) { if (!response.isSuccessful()) throw new RuntimeException("Ollama call failed: " + response.code()); // 3. 逐行读取 Ollama 的流式 JSON(每行一个 JSON object) try (BufferedReader reader = new BufferedReader( new InputStreamReader(response.body().byteStream()))) { String line; while ((line = reader.readLine()) != null) { if (line.trim().isEmpty()) continue; JsonNode node = objectMapper.readTree(line); // Ollama chunk 结构:{"message":{"role":"assistant","content":"..."},"done":false} if (node.has("message") && node.get("message").has("content")) { ModelChunk chunk = new ModelChunk(); chunk.setContent(node.get("message").get("content").asText()); chunk.setFinishReason(node.has("done") && node.get("done").asBoolean() ? "stop" : null); chunkConsumer.accept(chunk); } } } } catch (Exception e) { log.error("Ollama stream error", e); throw new RuntimeException(e); } }关键差异点:
- OpenAI 的
messages中role必须小写("user"),Ollama 同样要求小写,但某些旧版 Ollama 文档写成"User",会导致 400;- Ollama 的
model字段值是ollama list输出的名称(如llama3:8b),不是 OpenAI 的gpt-4-turbo;- Ollama 默认不返回
usage字段,所以ModelResponse.usage在 Ollama 路径下为null,前端需兼容。
3.3FallbackModelProvider:熔断不是摆设,它是可配置、可测试的兜底逻辑
当所有模型 provider 都超时或返回 5xx 时,FallbackModelProvider被触发。它不是简单返回“服务器忙”,而是支持三种策略:
| 策略类型 | 配置方式 | 行为说明 |
|---|---|---|
STATIC | fallback.type=static+fallback.message=请稍后再试 | 返回固定文本 |
RULE_BASED | fallback.type=rule+fallback.rules[0].pattern=.*密码.*+fallback.rules[0].response=请勿在对话中输入密码 | 正则匹配敏感词后返回定制回复 |
HUMAN_TRANSFER | fallback.type=human+fallback.humanUrl=https://your-crm.com/ticket?msg={message} | 生成工单链接并返回 |
// fallback/FallbackModelProvider.java @Override public ModelResponse syncCall(ModelRequest request) { String userInput = request.getMessages().get(request.getMessages().size() - 1).getContent(); // 1. 检查是否命中敏感规则 for (FallbackRule rule : fallbackRules) { if (Pattern.compile(rule.getPattern()).matcher(userInput).find()) { return buildResponse(rule.getResponse(), request.getSessionId()); } } // 2. 否则走 human transfer String ticketUrl = fallbackConfig.getHumanUrl().replace("{message}", URLEncoder.encode(userInput, StandardCharsets.UTF_8)); return buildResponse("已为您创建服务工单:" + ticketUrl, request.getSessionId()); } private ModelResponse buildResponse(String content, String sessionId) { ModelResponse resp = new ModelResponse(); resp.setContent(content); resp.setFinishReason("stop"); resp.setUsage(new ModelUsage(0, 0, 0)); // 占位 // 记录到 audit 表 auditService.logFallback(sessionId, content); return resp; }注意:
auditService.logFallback()会插入一条MessageLog,model_name字段为"FALLBACK",error_message字段存原始异常(如果有的话),方便后期统计“降级率”。
4. 运行与调试:从零启动到看到第一个流式响应,避坑指南
4.1 启动前必做的 3 件事
确认 JDK 版本:项目基于 Spring Boot 3.2+,必须使用 JDK 17 或 JDK 21。用 JDK 8 或 JDK 11 会报
java.lang.UnsupportedClassVersionError,且record类型无法编译。配置
application.yml中的模型凭证:ai: providers: openai: enabled: true api-key: sk-xxx # 替换为你自己的 key base-url: https://api.openai.com/v1 ollama: enabled: true base-url: http://localhost:11434 # 确保 Ollama 已运行:ollama serve提示:
base-url末尾不要加/,否则 Feign 会拼出https://api.openai.com/v1//chat/completions导致 404。初始化 H2 数据库:项目默认用 H2 内存库,启动时自动建表。若想看日志表,访问
http://localhost:8080/h2-console,填入:- JDBC URL:
jdbc:h2:mem:testdb - User Name:
sa - Password: (空)
然后执行SELECT * FROM message_log;查看审计记录。
- JDBC URL:
4.2 第一个请求:curl 测试流式接口
别急着打开网页,先用 curl 验证底层通路:
# 发送流式请求(注意 -N 参数禁用缓冲) curl -N -X POST http://localhost:8080/api/chat \ -H "Content-Type: application/json" \ -d '{ "sessionId": "test-123", "messages": [ {"role": "user", "content": "你好,请用中文介绍你自己"} ] }'预期输出(逐行打印):
data: {"content":"我是","finishReason":null} data: {"content":"一个","finishReason":null} data: {"content":"基","finishReason":null} data: {"content":"于","finishReason":null} data: {"content":"Sp","finishReason":null} ... data: {"content":"。","finishReason":"stop"}如果卡住不动:
- 检查
OpenAiProvider中Feign.builder().decoder(...)是否被正确注入; - 检查网络:
curl -v https://api.openai.com/v1/models是否返回 200; - 检查日志:
grep "streamCall" logs/app.log看是否有Entering streamCall日志。
4.3 常见问题排查:5 条真实踩坑记录
现象 1:前端页面显示“连接已关闭”,但后端日志无报错
原因:Spring Boot 默认server.tomcat.connection-timeout=20000(20秒),而大模型响应慢(尤其本地 Ollama 7B 模型),Tomcat 主动断开连接。
解决:在application.yml中增加:
server: tomcat: connection-timeout: 60000 # 60秒现象 2:Ollama 返回{"error":"model not found"},但ollama list显示模型存在
原因:Ollama 模型名区分大小写,ollama run qwen:7b启动后,模型名是qwen:7b,但代码里写了Qwen:7b。
解决:检查application.yml中ai.providers.ollama.model-name值,确保与ollama list输出完全一致(包括冒号和版本号)。
现象 3:审计表message_log中request_body字段为空
原因:AuditAspect使用@Before切面,但ModelRequest是方法参数,未被序列化。原代码用JSON.toJSONString(joinPoint.getArgs())会丢失泛型信息,导致空字符串。
解决:改用 Jackson 序列化:
ObjectMapper mapper = new ObjectMapper(); String requestBody = mapper.writeValueAsString(joinPoint.getArgs()[0]); // 假设第一个参数是 ModelRequest现象 4:切换模型后,FallbackModelProvider仍被调用
原因:ModelService中的getProvider()方法用了@Cacheable,但缓存 key 未包含request.getModel(),导致第一次失败后,后续所有请求都走 fallback。
解决:修改缓存 key:
@Cacheable(value = "modelProviders", key = "#model + '_' + #sessionId") public ModelProvider getProvider(String model, String sessionId) { ... }现象 5:本地运行时,IDEA 控制台中文乱码(显示为?)
原因:Windows 系统默认编码是 GBK,而项目用 UTF-8。
解决:在 IDEA → Settings → Editor → File Encodings → Global Encoding 和 Project Encoding 全部设为UTF-8;并在Run Configuration→VM Options加-Dfile.encoding=UTF-8。
5. 二次开发实战:如何接入千问 Qwen(Qwen2-7B-Instruct)并支持函数调用(Function Calling)
5.1 接入 Qwen2-7B-Instruct:只需新增一个 Provider 类
Qwen2 的 API 兼容 OpenAI 格式(感谢魔搭社区),所以无需重写streamCall,只需继承OpenAiProvider并覆盖基础 URL 和认证:
// qwen/Qwen2Provider.java @Component @ConditionalOnProperty(name = "ai.providers.qwen.enabled", havingValue = "true") public class Qwen2Provider extends OpenAiProvider { // 重写父类的 baseUrl 和 apiKey 获取逻辑 @Override protected String getBaseUrl() { return "https://dashscope.aliyuncs.com/compatible-mode/v1"; // 阿里云 DashScope 兼容地址 } @Override protected String getApiKey() { return qwenConfig.getApiKey(); // 从配置读取 } // Qwen2 要求 model 字段为 "qwen2-7b-instruct",且需加 "qwen/" 前缀 @Override public ModelResponse syncCall(ModelRequest request) { ModelRequest wrapped = new ModelRequest(); wrapped.setMessages(request.getMessages()); wrapped.setModel("qwen/qwen2-7b-instruct"); // 注意前缀 wrapped.setTemperature(request.getTemperature()); wrapped.setMaxTokens(request.getMaxTokens()); return super.syncCall(wrapped); } }配置application.yml:
ai: providers: qwen: enabled: true api-key: sk-xxx-from-dashscope注意:DashScope 的免费额度有限,生产环境需替换为自部署的 Qwen2(用
ollama run qwen2:7b),此时Qwen2Provider应继承OllamaProvider并覆盖getBaseUrl()为http://localhost:11434。
5.2 支持 Function Calling:扩展ModelRequest并改造OpenAiProvider
OpenAI 的 function calling 要求请求体包含functions和function_call字段。本项目将其作为可选能力:
扩展
ModelRequestDTO:public class ModelRequest { private List<Message> messages; private String model; // ... 其他字段 private List<FunctionSpec> functions; // 新增:函数列表 private String functionCall; // 新增:"auto" | "none" | "function_name" }在
OpenAiProvider的buildRequestBody()中加入函数字段:private Map<String, Object> buildRequestBody(ModelRequest request) { Map<String, Object> body = new HashMap<>(); body.put("model", request.getModel()); body.put("messages", toOpenAiMessages(request.getMessages())); if (request.getFunctions() != null && !request.getFunctions().isEmpty()) { body.put("functions", request.getFunctions()); // 直接序列化 body.put("function_call", request.getFunctionCall() != null ? request.getFunctionCall() : "auto"); } return body; }解析 function call 响应:
OpenAI 返回的finish_reason为"function_call"时,message.content为空,message.function_call字段存在。在OpenAiSseDecoder中增强解析逻辑:if (node.has("choices") && node.get("choices").size() > 0) { JsonNode choice = node.get("choices").get(0); if (choice.has("finish_reason") && "function_call".equals(choice.get("finish_reason").asText())) { JsonNode funcCall = choice.get("message").get("function_call"); if (funcCall != null) { chunk.setFunctionName(funcCall.get("name").asText()); chunk.setFunctionArguments(funcCall.get("arguments").asText()); } } }
这样,前端就可以发送带函数定义的请求,并在收到function_call事件后,调用对应后端服务(如查天气、查订单),再把结果塞回messages继续对话。
5.3 验证函数调用:一个完整的 curl 测试链
# 1. 定义函数(查天气) curl -X POST http://localhost:8080/api/chat \ -H "Content-Type: application/json" \ -d '{ "sessionId": "func-test", "messages": [{"role":"user","content":"北京今天天气怎么样?"}], "functions": [{ "name": "get_weather", "description": "获取指定城市的天气", "parameters": { "type": "object", "properties": {"city": {"type": "string", "description": "城市名"}} } }], "functionCall": "auto" }'预期响应流中会出现:
data: {"functionName":"get_weather","functionArguments":"{\"city\":\"北京\"}","finishReason":"function_call"}然后你的业务代码捕获该 chunk,调用WeatherService.get("北京"),得到结果后构造新消息:
{ "role": "function", "name": "get_weather", "content": "{\"temperature\":25,\"condition\":\"晴\"}" }再发一次/api/chat请求,即可获得最终回答:“北京今天 25 度,晴。”
从那以后我每次接入新模型,都强制走一遍这三步:
- 用 curl 测通
syncCall; - 用 curl -N 测通
streamCall; - 用 Postman 发送带
functions的请求,验证function_call解析。
漏掉任何一步,上线后都会在凌晨三点被报警电话叫醒。希望帮到你。
本文还有配套的精品资源,点击获取