简介:这是一份基于Spring Boot框架开发的人工智能机器人项目资源,目标是帮助计算机、电子信息工程、数学等专业的学生完成课程设计、期末大作业或毕业设计。项目已集成GPT-3.5、GPT-4.0、Kimi、百度文心一言等多种对话大模型,并接入Stable Diffusion与Midjourney绘图能力,能够实现智能问答、内容生成以及AI绘画等典型应用场景。整个压缩包共包含1157个文件,大小约26.84MB,文件类型覆盖584个Java后端源码、104个Vue和108个JS前端页面、73个XML配置,另有149个PNG图片等视觉素材,前后端分层清晰,便于对照阅读代码结构与调用逻辑。截至目前,该资源已有1235人学习浏览,适合作为二次开发的起点;借助完整源码,可以直观理解Spring Boot如何统一封装各类大模型接口,掌握鉴权参数、请求构建、结果解析以及绘图任务调度的实现细节,是一份颇具工程参考价值的练习资料。
1. 项目定位:一个能直接改的Spring Boot多模型AI机器人
如果你拿到手的是一份Spring Boot毕业设计或练手项目,常见结局是跑起来看个界面就关掉了。但这个机器人项目不太一样——它把“对接OpenAI大模型”这件事做成了一个完整的后端闭环:多模型切换、流式输出、上下文记忆、ApiKey多账号管理,全都在Spring Boot里自己实现,不依赖任何前端框架之外的SDK。我第一次打开时有点意外,因为通常大家只接一个GPT接口,而它内置了四五种主流大模型的对接方式,切换一个枚举就能换模型。
这个项目适合两类人:一类是拿它做毕业设计,想要一个既完整又不烂大街的AI应用后端;另一类是已经在写业务代码、想自己动手接大模型API的开发者——你能看到从HttpClient封装到SSE推送的完整链路,而不是零散代码片段。我拆完整个源码后最大的感受是:它把“难的部分”都留在服务端了,前端只需要一个fetch收流,真正的重点是后端怎么拼对话上下文、怎么处理鉴权、怎么在并发下维护会话。接下来我带你把每条链路都走一遍。
2. 技术选型与设计:从启动类到Controller,先看清请求怎么走
2.1 技术栈与依赖清单
这个项目以Spring Boot为主干,选型上没有花哨的东西,都是那种“你换个环境也不会翻车”的组件。核心依赖大概是这样:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.8.22</version> </dependency> <dependency> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> <version>4.12.0</version> </dependency>Web用starter-web提供HTTP接口,WebSocket不是给前端聊天用的,而是为了方便后端主动推送SSE事件;Redis用来缓存会话上下文,避免每次请求都把全部历史丢给模型;HTTP调用选OkHttp是因为它的流式响应处理比JDK自带的HttpClient更顺手,读多少吐多少,正好配合大模型的流式返回。Hutool是个工具库,这里主要用它的JSON解析和ID生成,减少不少样板代码。
我一般做这类项目会纠结一件事:要不要直接用OpenAI官方Java SDK?这个项目选择了自己封装HttpClient,我觉得是更值得学习的做法。因为SDK会隐藏很多细节,比如请求头怎么构建、流式数据怎么按SSE格式解析、错误码怎么处理——而这些恰好是面试或答辩时最容易被追问的点。自己封装虽然代码量大一点,但读项目的你拿到的是“原理”,不是一个黑匣子。
2.2 目录结构速览
项目的包结构很清晰,按职责分层,没有把一堆代码塞进Controller里。大致长这样:
src/main/java ├── com.example.airobot │ ├── AirobotApplication.java // 启动类 │ ├── config │ │ ├── RedisConfig.java // Redis序列化与连接配置 │ │ ├── WebConfig.java // 跨域与拦截器配置 │ │ └── ModelConfig.java // 模型参数配置类 │ ├── controller │ │ ├── ChatController.java // 聊天接口,SSE输出 │ │ └── ModelController.java // 查询/切换模型的接口 │ ├── service │ │ ├── ChatService.java // 对话处理主逻辑 │ │ ├── StreamService.java // 流式转发 │ │ ├── ContextService.java // 会话上下文管理 │ │ └── ApiKeyService.java // ApiKey轮询 │ ├── model │ │ ├── ChatMessage.java // 消息对象 │ │ ├── ModelType.java // 模型枚举 │ │ └── ChatRequest.java // 请求参数封装 │ ├── handler │ │ └── OpenAiStreamHandler.java // OkHttp的Callback处理 │ └── util │ └── SseEmitterUtils.java // SSE工具类启动类没有特别内容,就是标准注解。真正的入口是ChatController的/api/chat接口,它把请求拆成“拿到用户消息 → 拼接上下文 → 调用模型 → 流式回落前端”四步。ModelController负责模型列表查询和切换,前端在聊天窗口下拉选模型时就是调它。ApiKeyService是个容易忽略但很实用的组件,它管理多个Key,轮询取用,避免单个Key触发速率限制。
这样的分层对你做二次开发的引导作用很直接:想加功能,先判断是业务逻辑还是基础设施。比如你想记录每条聊天记录,改ChatService和model层;你想换HTTP客户端,只动handler包,Controller不受影响。
2.3 配置文件的关键项
application.yml里配置了不少东西,但有几个是跑起来之前必须改的:
ai: models: - name: gpt-4o api-key: sk-your-key-1 base-url: https://api.openai.com max-tokens: 2048 temperature: 0.7 - name: gpt-3.5-turbo api-key: sk-your-key-2 base-url: https://api.openai.com max-tokens: 1024 temperature: 0.5 context: max-messages: 10 redis-key-prefix: "ai:chat:"models是一个List,你可以理解成“多个模型账号池”,每个模型独立配置Key和请求参数。max-messages是上下文保留条数,超过10条就把最早的扔掉,这个参数直接决定每次请求拼给模型的token量。redis-key-prefix是会话缓存的前缀,同一用户的所有对话都存在这个Key下面。
关于base-url多说一句:如果你用的是国内合规的大模型中间层或自建网关,只需要把这里换成对应地址,后面的代码完全不用动。这算是这个项目设计上的一个亮点,因为它把“API地址”解耦成了配置,而不是硬编码在代码里。对应到实际开发里,意味着你可以同时接OpenAI官方服务和第三方兼容OpenAI协议的模型服务,只要模型枚举里加一个名字就行。
3. 核心模块拆解:一次对话从进入到返回的完整链路
3.1 配置层:多模型注册与ApiKey轮询
先看ModelType枚举,它是全项目的模型“字典”:
public enum ModelType { GPT_4O("gpt-4o", "OpenAI", 128000), GPT_4_TURBO("gpt-4-turbo", "OpenAI", 128000), GPT_3_5_TURBO("gpt-3.5-turbo", "OpenAI", 16000), QWEN_PLUS("qwen-plus", "阿里云", 128000); // 兼容OpenAI协议 private final String code; private final String provider; private final int maxContextTokens; ModelType(String code, String provider, int maxContextTokens) { this.code = code; this.provider = provider; this.maxContextTokens = maxContextTokens; } // getter... }maxContextTokens这个字段很关键,它不代表模型上限,而是这个项目自己设定的“每轮对话最多塞多少token”。当上下文消息数没超,但token总量预估超了这个值,ContextService会优先裁剪最旧的内容。这就是为什么配置里虽然有max-messages,但仍然需要token级兜底。
ApiKeyService的轮询逻辑我会在避坑章细说,但它的核心实现值得你抄:
public String getAvailableKey() { // 简单轮询:从当前索引开始,遍历所有Key,选一个权重余额充足的 for (int i = 0; i < pools.size(); i++) { String key = pools.get((currentIndex + i) % pools.size()); if (isKeyAvailable(key)) { currentIndex = (currentIndex + i + 1) % pools.size(); return key; } } // 如果全部不可用,回退到第一个Key,让下游走限流错误 throw new KeyExhaustedException("所有ApiKey均已失效或达到速率限制"); }它不真的去查OpenAI余额,而是用一个本地计数器记录每个Key在一分钟内发过多少次请求,超过阈值就跳过。这个设计不算精确,但足够应付毕业设计或小型应用场景。你如果拿到生产环境,建议换成读取HTTP响应头的x-ratelimit-remaining来动态调整,但当前这个版本胜在简单。
3.2 调用层:OkHttp封装与流式响应
这个项目最核心的部分是OpenAiStreamHandler,它把OkHttp的流式回调翻译成Spring的SseEmitter事件。代码很典型,我摘两段关键的。
第一段是发起流式请求:
public void streamChat(ChatRequest request, SseEmitter emitter) { // 请求体:model + messages是基本结构,stream=true 开启流式 RequestBody body = RequestBody.create( JSON.toJsonString(buildPayload(request)), MediaType.parse("application/json") ); Request httpRequest = new Request.Builder() .url(apiBaseUrl + "/v1/chat/completions") .header("Authorization", "Bearer " + apiKey) .post(body) .build(); // OkHttp异步回调 client.newCall(httpRequest).enqueue(new Callback() { @Override public void onResponse(Call call, Response response) throws IOException { // 按行读取流式内容 BufferedReader reader = new BufferedReader(response.body().charStream()); String line; while ((line = reader.readLine()) != null) { // SSE格式:data: {...} if (line.startsWith("data:")) { String json = line.substring(5).trim(); if ("[DONE]".equals(json)) { emitter.complete(); return; } // 解析增量内容 OkJsonParser.parseAndSend(json, emitter); } } } @Override public void onFailure(Call call, IOException e) { emitter.completeWithError(e); } }); }buildPayload这个方法拼的是OpenAI官方接口的请求体,注意stream标记必须为true。OkHttp的enqueue是异步执行,回调线程不是主线程,所以emitter.send天然支持异步发送——这也是为什么Spring MVC的SseEmitter模型在这能成立,因为它就是为这种场景设计的。
第二段是解析流式增量:
private void parseAndSend(String json, SseEmitter emitter) throws IOException { // 流式响应里的choices[0].delta.content是每次的新字块 ChatCompletion chunk = JSON.parseObject(json, ChatCompletion.class); if (chunk.getChoices() != null && !chunk.getChoices().isEmpty()) { String content = chunk.getChoices().get(0).getDelta().getContent(); if (content != null && !content.isEmpty()) { // 直接把增量推给前端,前端做累计展示 emitter.send(SseEmitter.event() .name("message") .data(content)); } } }注意这里有个容易踩坑的点:choices[0].delta可能为null(比如在role字段变化时),所以代码里先判空再吐内容。我初看时觉得这只是一个防御性写法,后来自己接通义千问时才发现,不同兼容OpenAI协议的服务返回格式在空字段上居然有差异,这个判空救了你一次。
3.3 业务层:会话记忆与上下文组装
大模型本身是无状态的,多轮对话全靠在每次请求时把历史消息一并发送。ContextService负责把Redis里存的会话记录拿出来,再拼上当前用户输入,最后裁剪到合适长度。
public List<ChatMessage> buildContext(String sessionId, String userInput) { String key = redisKeyPrefix + sessionId; // 取最近maxMessages条对话 List<ChatMessage> history = redisTemplate.opsForList().range(key, -maxMessages, -1); if (history == null) { history = new ArrayList<>(); } // 先加历史 List<ChatMessage> context = new ArrayList<>(history); // 追加当前用户消息 context.add(new ChatMessage("user", userInput)); // 估算token,超出就砍掉最前面的对话 while (estimateTokenCount(context) > maxContextTokens) { context.remove(0); } return context; }这里用opsForList().range取负索引,是Redis List的一个实用技巧:-10到-1正好取最后10条,不需要先读整个List再截断。保存的时候也走同一数据结构,每次就往右推一条,整个会话就是一个List,天然有序。
裁剪策略简单粗暴:先按条数裁剪,再按token估算裁剪。估算token的真实实现没严格按BPE编码,而是按“中文1字≈1token,英文1词≈0.8token”的近似算法,反正只是裁剪阈值,差几个token不影响体验。这种“近似够用”的思路,我建议你做类似功能时直接采纳——别一上来就引入分词器。
3.4 接口层:SSE把模型输出推给前端
ChatController里接口的签名很直观:
@CrossOrigin @PostMapping(value = "/api/chat", produces = "text/event-stream;charset=UTF-8") public SseEmitter chat(@RequestBody ChatRequest request) { // 1秒超时,断开连接后自动清理 SseEmitter emitter = new SseEmitter(60000L); // 异步执行,避免阻塞Tomcat线程 chatService.streamChat(request, emitter); return emitter; }produces指定了text/event-stream,这是让浏览器能正确识别SSE的关键,如果你漏了,前端拿到的会是一段普通的JSON而非流式内容。new SseEmitter(60000L)是总超时时间,超过60秒模型没返回完就直接断开连接。这个值不是随意设的,因为大模型长回答在慢网络下可能超过这个阈值,你最好在配置里做成可调的。
前端配合的代码也非常简单,浏览器原生的EventSource只支持GET,所以这里要用fetch的post方式读流。项目前端有一个简单的demo页面,但后端这套接口对任何前端都通用。我自己试的时候用的是一个四五行的Node脚本,用fetch打开响应体然后逐块读取,一样能取到流式文本。也就是说,这个后端对客户端几乎没有绑定性。
4. 运行与验证:从克隆源码到第一次收到流式回复
4.1 环境准备
跑这个项目有三个前置条件,缺一个都会在启动时或请求时报错:
- JDK 17 或更高版本。Spring Boot 3.x 强制要求17,老项目用8的可以直接放弃升级。
- Redis 6 以上。项目启动时会连Redis,连不上启动直接报错,而且它的
application.yml里没有配重试,所以必须先启动Redis。 - 一个可用的OpenAI兼容API Key。如果没有,也可以先启动项目,但
/api/chat会返回401。我建议你先用一种免Key的本地假模型打通流程,后面会讲怎么改。
Redis安装方式不唯一,Mac用brew,Windows用WSL或官方zip都行。注意项目配置文件里的redis.host默认是localhost,如果你Redis跑在Docker里,记得把IP改对,否则会出Unable to connect to Redis的错,这个最闹心,因为它常常发生在你改遍代码之后。
4.2 启动步骤
第一步:修改
application.yml里ai.models[0].api-key为你的真实Key。如果你有多个Key,可以继续在列表里加,但第一个是默认模型。第二步:启动Redis,然后在项目根目录执行:
mvn spring-boot:run如果Maven没配好,也可以打包后运行:
mvn clean package -DskipTests java -jar target/airobot-0.0.1-SNAPSHOT.jar启动成功后控制台会出现“Started AirobotApplication”日志,紧接着出现Tomcat端口监听。我建议先用curl确认服务是活的:
curl http://localhost:8080/api/models这一步能拿到JSON数组,说明Web层正常。如果这里返回错误,先别急着调模型,优先看是不是端口被占用或配置没加载。
4.3 联调测试
接口验证直接用curl流式请求是最快的,不需要打开前端页面:
curl -N -X POST http://localhost:8080/api/chat \ -H "Content-Type: application/json" \ -d '{"sessionId":"test-001","message":"你好,请用一句话介绍你自己"}'-N参数是curl不缓冲,立即输出数据。如果一切正常,你会看到一串增量文本逐渐打印出来。这里有一个常见观察点:如果内容一次性返回而不是逐字出现,排查顺序是,先看后端日志里有没有多次emitter.send调用,再确认前端是不是把text/event-stream当成普通JSON解析了。
一个完整的请求生命周期是这样的:前端Post请求来到ChatController,ChatService调ContextService把Redis里的历史取出来组装,然后交给StreamService,StreamService触发OkHttp调用OpenAI,回调里每拿到一段增量就通过SseEmitter写回响应。整个过程不阻塞Tomcat线程,这是异步+流式组合的魅力。
至此这个项目已经能跑通了。但真到了调接口阶段,你会发现网上很多文章里没写的问题——比如中文乱码、同样的配置别人通你就不通、上下文越聊越慢。下一章我把拆代码时候踩过的坑集中罗列一下。
5. 避坑指南:接入多模型时最常翻车的四个位置
5.1 中文内容变成Unicode转义
调用流式接口时,返回的中文经常变成\u4f60\u597d这种形式。这并不是传输错误,而是Java的JSON库在序列化和反序列化时默认启用了escapeNonAscii特性。Hutool的JSONUtil默认转义,导致推给你的是转义后的字符串。
解决方式有两种。一种是在工具类里配置:
JSONObject obj = new JSONObject(true); // 构造参数里允许中文不转义另一种更稳妥,在反序列化解析chunk时正常解析,拿到content字段后,用JSONUtil.parseObj但关闭转义。这个现象最容易在“本地测试正常、部署到服务器上乱码”的诡异场景里出现,原因就是两边依赖版本不同导致默认行为不一致。我建议你统一直接在代码里指定不转义,别依赖环境。
5.2 OkHttp流式响应偶发性断连
现象是前端收到一半内容就报错,后端日志出现SocketTimeoutException或者EOFException。原因通常是大模型生成文本有停顿,而OkHttp的读超时固定了整次请求的时间,一旦模型思考超过这个时间,连接被客户端主动关闭。
解决这个问题的关键是把读超时调到合适值,并且把断开重试做成可配置的。一般做法是:
OkHttpClient client = new OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(0, TimeUnit.MILLISECONDS) // 0表示不限制读超时 .build();readTimeout(0)会让OkHttp在读完响应前一直等待,适合流式场景。但要注意,生产环境别真的设置成无限,通常给个120秒就足够长回答使用。否则当模型服务端挂住了,你的线程池会被占满。
5.3 上下文越积越多导致Token超限
每次对话都把历史全量发给模型,刚开始没问题,但聊了二十轮以后,请求体越来越大,终于有一天报maximum context length exceeded。这是新手最容易犯的错误,也是优化空间最大的一块。
这个项目的处理策略我已经在ContextService说过:消息条数限制和token估算裁剪。但我在跑测试时发现,如果用户一次性输入一篇长文,单条消息就可能超过模型的输出上限,这时不管怎么裁剪历史都不够。所以要加一个前置校验:
if (estimateTokenCount(currentMessage) > modelMaxTokens * 0.8) { throw new BizException("单条消息过长,请分段输入"); }给当前用户输入单独设一条80%的阈值,防止直接打爆模型。这个校验很多开源项目都漏了,它会让你的机器人显得“有脑子”。
5.4 多个ApiKey轮询不均匀导致限流
项目里的ApiKeyService用简单轮询处理多个Key,但你会发现请求多的时候,某个Key还是经常报429。原因在于OpenAI的限流不是全局均分,比如一个组织下某个Key用得多就会被单独限流,而你的轮询并不知道这一点。
我采用的补偿策略是把每个Key的请求计数和最近一次429标记存到Redis里:
if (keyCache.has429(key)) { // 让这个Key冷却5分钟,期间跳过 continue; }这样虽然牺牲了一点负载均衡,但减少了因为429重试造成的体验问题。增加Key数量不是解决限流的唯一手段,让限流Key自动冷却往往更实用。这个设计你拿到生产环境也说得通。
另外还有一个非技术坑。我把application.yml提交到Git仓库后,Key就泄露了。做毕设或者公司项目,务必把api-key用环境变量或者jasypt加密,或者至少加.gitignore忽略配置文件。这不算技术难题,但出事就是大事故。
6. 二次开发思路:把对话机器人扩展成真正的智能助手
6.1 接入一个兼容OpenAI协议的新模型
如果你不想局限于OpenAI,可以很快接入国内合规或本地部署的模型。原项目已经抽象出了base-url配置,所以只需要两步。
第一步,在ModelType枚举里新增一个值:
NEW_MODEL("my-model-code", "某实验室", 32768)第二步,在application.yml里新增一组配置,指到你想要的base-url即可。buildPayload里的model字段就是从ModelType取code,所以如果第三方服务要求的不是model而是别的字段名,你需要在buildPayload里做一个映射表。我一般会维护一个Map<String, String>,把第三方模型的入参名和官方参数对应起来,灵活度更高。这个扩展方式让我一度觉得这个项目的作者本来就打算做成一个“桥接层”。
6.2 给机器人加上函数调用能力
大模型本身不能查询数据库,但GPT的Function Calling机制可以让模型“请求”你这边执行函数。原项目没有实现这个,但我认为它是二开中最值得加的功能。思路概括起来:
- 在构建请求体时,多塞一个
tools参数,里面定义好函数名、描述、参数JSON Schema。 - 模型如果认为需要调用,返回的
delta里会出现tool_calls字段,而不是聊天内容。 - 你拦截到这个字段后,去执行本地方法,再把结果作为一条
tool角色消息发回模型,模型基于结果生成最终回答。
核心改动在parseAndSend方法里,增加对delta.getToolCalls()的判断。这个扩展会让你的机器人从“聊天”变成“能干活”——比如问“昨天订单总金额是多少”,它先查数据库再返回值。写代码的时候注意,tool_calls可能分多次增量传回,需要做一个累积拼接,等它结束再统一执行。
6.3 把ApiKey和会话记录挪进数据库
当前项目把所有Key放在配置文件里,会话记录在Redis里只能被动过期。做毕设答辩时,“数据持久化”往往是老师最爱追的点。一种值得参考的改造:把api_key表放在MySQL里,ApiKeyService改为查库,同时把会话记录定时从Redis同步到MongoDB或MySQL里。
这样改的好处是能做一个“用量统计”页面:每个Key今天发了多少token、多少个请求、平均响应时间。实现时不需要改流式链路,只需要在OpenAiStreamHandler的onResponse里读取response.header("x-ratelimit-usage-tokens")之类的信息,异步写入统计表。这一步做完,这个项目的完整性直接上升一个台阶。
如果你非要把会话记录存MySQL,Redis的List类型就不太好用了,我会换成MySQL表存session_id, role, content, created_at,读取历史直接用Order By created_at。但这样每次请求都打一次数据库,你又需要加一层Redis缓存。这个改造没有标准答案,关键看并发量。
我自己的习惯是,拿到任何这种Spring Boot项目,先把原有的配置和Redis弱依赖跑通,再动手加表。因为很多二次开发失败,不是因为不会写新功能,而是把原有可运行状态弄坏了。每次改完,我会强制走一遍最简链路:启动Redis → 启动Spring Boot → curl一次流式接口 → 确认Redis里会话Key在增长。那以后我遇到再大的重构,心里也始终有底。希望你加新功能的时候,也把这条“最小回归路径”守住,希望帮到你。
本文还有配套的精品资源,点击获取