若依整合AI实战:SSE流式响应与Docker部署压测
2026/9/24 18:29:46 网站建设 项目流程

接手这个“若依整合AI”的实战改造前,我心里很清楚:业务方说“就加个聊天窗口”,实际意味着模型接口对接、流式响应、权限控制、异常兜底、部署压测这五件事一个都不能少。这篇文章是若依整合AI系列的第二篇,上一篇把大模型API选型、账号申请和基础概念讲完了,这篇直接进入代码和部署层面。我目前维护的两套若依项目,一套是 RuoYi-Vue 前后端分离版,一套是 RuoYi-Cloud 微服务版,都完成了AI问答模块的整合。文中所有方案都是这几轮改造里真实跑通过的,包括Java后端、Vue3+TS前端、Docker部署和JMeter压测。如果你正打算在若依框架上接AI能力,或者想给老后台补一个智能助手,这篇里的步骤和坑可以直接抄。

1. 若依接AI之前,先把架构想明白

1.1 别急着写Controller,先确定AI模块放哪个工程

很多初学者拿到若依源码后,第一反应是在ruoyi-admin模块里新建一个AiController,把调用大模型的逻辑全写在里面。这种写法在Demo阶段没问题,但若是要上生产,我强烈建议单独建一个模块。

我的做法是:

  • 若依单体版(RuoYi-Vue),新建ruoyi-ai模块;
  • 若依微服务版(RuoYi-Cloud),新建ruoyi-ai-service微服务,网关单独路由;
  • 前端在ruoyi-ui里新建views/ai目录,不往现有业务页面里塞。

这么拆分的原因有三个。第一,AI模块的依赖太重,大模型SDK、SSE相关库、可能还要接向量数据库,这些依赖如果跟ruoyi-system放一起,maven依赖树会变得很难维护。第二,AI模块的鉴权、限流、超时策略和普通CRUD不一样,独立部署后可以单独调优,不会拖垮主业务。第三,后面如果要接多个模型、做模型路由,独立的模块会更容易扩展。

这里有一个必经的坑:在IDEA里新建Module后,若依经常报error adding module to project: null,或者项目结构里能看到模块但maven完全不识别。我试过的有效处理方式是:先关闭IDEA,手动把根目录.idea/modules.xml.idea/libraries里残留的引用删掉,再重新打开IDEA,让maven重新导入。如果还不行,就在根pom.xml里手动添加<module>ruoyi-ai</module>,确认ruoyi-ai自己的pom.xml<parent>指向若依根工程。这一步做完,模块才算真正纳入了构建体系。

1.2 直连模型API还是自建网关层,我选了后者

团队里有人提出过更简单的方案:前端直接调用大模型厂商的API,后端不管。我直接否决了。如果前端直连,API Key会暴露在浏览器里,等于把公司的钱袋子挂在门口;而且没法统计每个用户消耗了多少token,出了问题也排查不了。

我最终选择在后端自建一层“模型网关”。结构大概是:

层级职责对应实现
接入层提供给若依前端的HTTP接口/ai/chat,Post请求,登录后访问
网关层统一请求模型、切换供应商、统计用量自定义AiChatService+ 适配器
适配层对接各家大模型HTTP接口OpenAiAdapter、通义Adapter、DeepSeekAdapter
存储层会话消息、token用量持久化MySQL + Redis

这样做的收益是:前端永远只面对一个接口,后端今天接的是通义千问,明天想换DeepSeek,甚至想同时接多个模型做负载均衡,都只需要在适配层改动,不用动Controller。

不过我要提醒一点,网关层别设计得过度复杂。我见过有人在网关层引入了一套复杂的规则引擎,反而把链路拖慢了。适配器加一个简单的策略模式就够了,核心是保住“接口稳定、内部可切换”这个底线。

1.3 会话数据表这样设计,后面省很多事

AI对话不是简单的“一问一答”,它要支持多轮会话、历史记录、重新生成。所以至少需要两张表:ai_conversationai_message

ai_conversation用来存会话:

  • id:主键
  • user_id:若依用户id,直接关联sys_user
  • title:会话标题,可由第一轮问题自动生成
  • model_code:使用的模型标识
  • create_time/update_time:时间戳

ai_message用来存每一轮消息:

  • id
  • conversation_id:会话id
  • roleuserassistant
  • content:消息内容
  • prompt_tokens/completion_tokens:token统计,方便后续做成本核算
  • extra_json:扩展字段,存模型返回的引用来源、思考链等

一个我踩过之后才想明白的细节:不要只存纯文本,建议把模型返回的原始JSON完整保留在extra_json里。前端页面可能今天只展示正文,明天就要求展示“引用了哪些文档”,如果当初没存原始JSON,后面做知识库类功能会很被动。

2. 后端模型网关与SSE流式响应:核心代码一次讲透

2.1 定义一个不依赖具体厂商的ChatService接口

ruoyi-ai模块里,我先定义了一个顶层接口:

public interface AiChatService { String getModelCode(); void chat(AiChatRequest request, SseEmitter emitter); }

getModelCode()返回当前实现对应的模型编码,比如deepseek-chatqwen-pluschat()方法接收请求对象和SseEmitter,用流式方式把模型输出推送给前端。

实现类是按模型去写的,例如DeepSeekChatServiceImplQwenChatServiceImpl。每个实现类内部负责跟对应厂商的API打交道。为了能在多个实现类之间切换,我加了一个工厂类:

@Service public class AiChatServiceFactory { private final Map<String, AiChatService> serviceMap; public AiChatServiceFactory(List<AiChatService> services) { serviceMap = services.stream() .collect(Collectors.toMap(AiChatService::getModelCode, Function.identity())); } public AiChatService getService(String modelCode) { return serviceMap.getOrDefault(modelCode, serviceMap.get("default")); } }

这个工厂看着不起眼,实际价值很大。用户在前端选择“要用哪个模型”,后端只需要取出用户对应的配置项,然后从工厂拿实现。

2.2 SseEmitter流式输出:关键细节和三个大坑

大模型接口是流式返回的,如果你用普通HTTP请求去接,用户点击发送后要等十几秒才能看到第一个字,体验非常差。SSE(Server-Sent Events)是当前最合适的方案,它是服务器单向推送,通过一个HTTP长连接按行推送文本。

Controller的核心写法如下:

@PostMapping("/chat") public SseEmitter chat(@RequestBody AiChatRequest request) { SseEmitter emitter = new SseEmitter(5 * 60 * 1000L); AiChatService chatService = aiChatServiceFactory.getService(request.getModelCode()); chatService.chat(request, emitter); return emitter; }

SseEmitter默认超时是30秒,远远不够,我设成了5分钟。这里要注意,SseEmitter要在异步线程里推送数据,不能占用Tomcat的工作线程。我在异步配置里单独定义了一个线程池:

@Bean(name = "aiTaskExecutor") public ThreadPoolTaskExecutor aiTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(10); executor.setMaxPoolSize(50); executor.setQueueCapacity(100); executor.setThreadNamePrefix("ai-task-"); executor.initialize(); return executor; }

模型调用这种耗时操作全部丢给这个线程池,避免把Tomcat线程池打满。

接下来是三个我实际踩过的坑。

第一个坑是SSE的Content-Type。推送数据时,响应头必须是text/event-stream;charset=utf-8。若依的全局响应包装和异常处理器可能会把这层逻辑覆盖,所以我在WebMvcConfigurer里专门给/ai/**路径做了配置,确保不走ResponseBodyAdvice的包装逻辑。

第二个坑是SSE的数据格式。SSE协议要求每条消息以data:开头,以空行结束,例如:

data: {"content": "你"} data: {"content": "好"}

如果用SseEmitter.SseEventBuilder,则不需要手动拼这些前缀:

emitter.send(SseEmitter.event() .name("message") .data(responseMap));

第三个坑是异常关闭。用户可能在流式输出过程中直接关闭了浏览器,此时如果后端还在调用模型API,会白费token。我实现了CompletionCallback回调,检测到 emitter 的onCompletiononError后,会中断对模型API的连接。

2.3 上下文管理:不能无脑堆历史消息

大模型输入有token上限,不能每次请求都把整个会话历史全部拼进去。我做了一个滑动窗口策略:只携带最近10轮消息,如果超过上下文窗口,就截断最旧的;如果单条消息太长,直接返回提示“内容过长,请精简后重试”。

由于若依自带Redis,我把最近会话缓存到了Redis里,key设计为ai:context:{conversationId},value是一个JSON数组,每次请求前从Redis读取,请求结束后把新消息追加进去并重置过期时间。这样不仅减少数据库查询压力,也给模型提供了一套轻量级的短期记忆。

补充一点,上下文拼接时要注意消息角色。第一轮通常是system提示词,后面必须严格按userassistant交替拼接。如果出现两条连续的user消息,部分模型会报错或降低生成质量。

3. 前端vue3+ts对话页:这样写才不会翻车

3.1 用fetch流式读取,不要用EventSource

若依目前主推的是Vue3+TypeScript版本。我在做对话页时,第一反应是直接用EventSource,但很快发现它只支持GET请求,而且没法自定义请求头。若依所有接口都是带Authorization头的,所以EventSource这路直接走不通。

正确选择是用fetch配合ReadableStream

const response = await fetch('/ai/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + getToken() }, body: JSON.stringify({ conversationId: currentConversationId, content: inputContent, modelCode: currentModel }) }) if (!response.ok || !response.body) { throw new Error('请求失败') } const reader = response.body.getReader() const decoder = new TextDecoder() let done = false let answer = '' while (!done) { const { value, done: readDone } = await reader.read() done = readDone if (value) { answer += decoder.decode(value, { stream: true }) // 把answer按SSE格式拆分,更新当前消息的content handleSseChunk(answer) } }

这段代码里decoder.decode(value, { stream: true })非常关键。如果直接decoder.decode(value),一个完整中文字符被拆成两个字节传输时,会出现乱码。stream: true会告诉解码器保留未完成的字节,等下一块数据到达后继续解码。

3.2 vue3+ts下最常见的TS报错和处理方式

热搜词里“若依vue3 ts报错”简直是这半年高频搜索。我在写流式读取时遇到过两个最典型的报错,这里记录下来方便排查。

第一个是Property 'getReader' does not exist on type 'ReadableStream<Uint8Array>'。这通常是因为tsconfig.jsonlib配置里缺少DOM.IterableDOM.AsyncIterable。改成这样:

{ "compilerOptions": { "lib": ["ESNext", "DOM", "DOM.Iterable"] } }

改完如果还报,就把fetch返回值做一次类型断言:

const stream = response.body as ReadableStream<Uint8Array>

第二个是Property 'crypto' does not exist on type 'Window',这是部分浏览器环境的类型声明缺失。可以关闭相关的严格校验,或者在src/types/global.d.ts里补充声明,但最省事的方案是升级typescript到4.5以上并同步升级@vue/tsconfig

3.3 markdown渲染与XSS过滤

大模型返回的内容绝大多数是markdown。直接用v-html渲染会带来XSS风险,我试过一次,模型生成的内容里如果有一张带onerror属性的图片标签,前端就会执行一段恶意脚本。这不是危言耸听。

我在项目里用的组合是:

  • marked做markdown到HTML的转换;
  • dompurify做HTML白名单过滤;
  • highlight.js做代码高亮。

核心逻辑:

import { marked } from 'marked' import DOMPurify from 'dompurify' import hljs from 'highlight.js' marked.setOptions({ highlight(code) { return hljs.highlightAuto(code).value } }) const renderMarkdown = (text: string): string => { const rawHtml = marked.parse(text) as string return DOMPurify.sanitize(rawHtml) }

渲染顺序是先转HTML,再净化,最后才绑定到v-html。这一步不能省,很多“AI对话功能”项目在上线后出现存储型XSS,基本都是省掉了DOMPurify这一步。

3.4 把AI入口挂进若依的菜单和权限体系

若依的权限体系很成熟,不需要为AI单独另搞一套。菜单表sys_menu里新增一个“AI助手”目录,路径配置为/ai/index,权限字符配置为ai:chat:send。前端页面组件上用v-hasPermi控制按钮显隐:

<el-button v-hasPermi="['ai:chat:send']" @click="sendMessage">发送</el-button>

后端Controller上加@PreAuthorize注解:

@PreAuthorize("@ss.hasPermi('ai:chat:send')") @PostMapping("/chat") public SseEmitter chat(@RequestBody AiChatRequest request) { // ... }

这样用户管理、角色分配、按钮权限全部走若依原有体系,不需要额外开发。

4. 鉴权、异常兜底与提示词配置:上线前必须补齐的边角料

4.1 AI接口必须走登录拦截,别自己开白名单

我看过有些人为了调试方便,把/ai/**直接放进SecurityConfigpermitAll列表里,排除了登录鉴权。生产环境千万别这么干,AI接口是要花钱的,裸奔一天可能烧掉几千块。

正确做法是把AI接口保留在Spring Security的拦截范围内。部署时有个高频问题叫“若依验证码不出现”,这虽然不是AI模块直接造成的,但会影响AI模块联调。验证码不出现最常见的原因是Redis没启动或Redis缓存数据异常,若依的校验码存在Redis里,Redis连不上,验证码就刷不出来。遇到这种情况先确认Java后端能正常连上Redis,再看sys_config表里的sys.account.captchaEnabled是否为true

4.2 模型调用失败时的三层兜底

大模型是外部依赖,网络抖动、限流、服务宕机都会发生。我在项目里做了三层兜底:

第一层,接口异常处理。Controller里所有模型调用包在try-catch里,异常统一交给若依的GlobalExceptionHandler,返回错误码给前端。

第二层,业务内重试。遇到瞬时超时或HTTP 429限流,会自动重试一次,但如果第二次还是失败,就返回“模型繁忙,请稍后重试”,绝不无限重试。

第三层,前端流式中断处理。如果用户已经看到一半输出,突然断流,前端要把当前消息标记为“响应中断”,同时提供一个“重新生成”按钮,把上一次的请求参数重新发一遍。

前端的“重新生成”看起来简单,实际有个细节:需要把当前会话里最后一条assistant消息先删除或标记为失败,再重新调用接口,否则消息列表里会出现两条不完整的回答。

4.3 提示词做成后台配置,别写死在代码里

把提示词硬编码在Java类里,是我早期最爱干的事。直到有一天产品经理说“把语气改得正式一点”,我重新编译部署花了半小时,而产品经理在旁边等着验证,那场面太尴尬了。

现在我把提示词全放到了若依的sys_config表里,或者独立的ai_prompt表。结构包括:

  • prompt_code:提示词编码
  • prompt_content:提示词内容
  • model_code:适用模型
  • version:版本号

后端启动时或保存配置后写入Redis缓存,请求模型前从Redis读取,没有再查库。后台管理页面就用若依自带的表单生成能力做一个文本域,字段绑定prompt_content。这样产品经理自己把提示词改了保存,立刻生效,不用发版。

我甚至把“会话标题自动生成”这个能力也做成了提示词配置。第一轮用户消息发过来,后端拼一段“请根据用户问题生成一个10字以内的会话标题”,然后把模型返回结果存到ai_conversation.title

5. Docker部署与压测验证:从开发机到线上环境

5.1 后端和前端分别打包成镜像

若依项目的部署方式有很多种,我自己习惯用Docker Compose管理一套环境。后端的Dockerfile很简单:

FROM maven:3.8-openjdk-17 AS builder WORKDIR /build COPY . . RUN mvn clean package -DskipTests FROM openjdk:17-jdk-slim WORKDIR /app COPY --from=builder /build/ruoyi-admin/target/ruoyi-admin.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar", "--spring.profiles.active=prod"]

前端的Dockerfile要处理两件事:构建静态文件,以及配置Nginx代理。Vue3项目如果用了history路由,Nginx需要加try_files,否则刷新页面会404。

location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /prod-api/ { proxy_pass http://backend-service:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }

特别提醒SSE在Nginx下的配置:必须关闭proxy缓冲,否则SSE数据会被Nginx缓冲起来,用户看到的是一段一段“卡顿”输出。

proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; proxy_send_timeout 3600s;

这几行不是我凭空编的,而是真实遇到过的线上问题:不加proxy_buffering off的时候,前端收到的SSE数据总是一批一批的,首字延迟反而比非流式还高。

5.2 单机、微服务、迁移云环境的注意事项

若依微服务版和单体版部署差异挺大。微服务版需要先启动Nacos注册中心,再通过网关访问各服务。如果你用的是RuoYi-Cloud或者RuoYi-Plus,AI模块拆成一个独立微服务后,网关路由要单独加:

spring: cloud: gateway: routes: - id: ruoyi-ai-service uri: lb://ruoyi-ai-service predicates: - Path=/ai/**

我去年做过一次环境迁移:单节点K8s上的若依微服务整套环境,要整体迁到云ECS,要求不停服、不丢数据。核心经验是数据库不能直接打快照复制,得先做一次一致性备份,然后使用并行复制的方式追平增量,等两边的数据完全同步后再把流量切换过去。Redis方面,要开启AOF持久化,迁移时把RDB和AOF文件同时拷过去,避免只有内存里的数据。文件资源不能放在本地磁盘,若依的配置文件路径要改成对象存储或云盘挂载路径。

如果你只是用单机Docker Compose部署,数据卷一定要挂到宿主机目录,而且docker-compose.yml里MySQL、Redis都要配上健康检查,服务启动顺序确保MySQL和Redis先就绪,再启动后端。

5.3 用JMeter压测SSE接口,重点关注这几个指标

开发完成后不能直接上线,得验证云上环境的承载能力。我配合压测人员(他们用的工具是JMeter)踩过不少坑,这里说一下SSE接口压测的特殊点。

普通的HTTP接口压测,就是设置线程数、循环次数然后看响应时间。但SSE是长连接,如果你用默认的HTTP请求取样器去压/ai/chat,JMeter会一直等待流式响应结束,一个请求占用连接很久,压出来的并发数根本不真实。

我们的做法是:

  1. 先用“登录接口”获取Token,通过正则表达式提取器或JSON提取器保存到变量;
  2. /ai/chat设置“Sample Timeout”,例如15秒,超过就标记失败;
  3. 设置线程组从10、20、50、100逐级加压;
  4. 同时监控后端所在机器的CPU、内存、TCP连接数。

压测要重点看两个指标:

  • 首字延迟(TTFT):从发出请求到收到第一个data块的耗时;
  • 输出稳定性:断开连接的比例。

我把一次典型压测的结果记录如下,供参考:

并发数成功率p95首字延迟输出中断率问题表现
10100%1.8s0%正常
20100%2.3s0%正常
5098%4.6s2%出现部分连接超时
10082%9.8s18%线程池拒绝连接,CPU接近100%

发现100并发时CPU打满后,我去看了线程池监控,定位到问题出在创建了太多大模型HTTP连接。优化方案有两个,一个是把模型网关的HTTP客户端连接池调大,另一个是引入轻量级限流,让超出阈值后的请求快速失败而不是排队等待。上线前我选择了后者,因为先保证存量用户可用,比强行堆并发更重要。

做AI对话模块的压测,不要只盯着QPS。SSE场景最重要的是用户体验的稳定性,一旦出现大量断流,用户会直接认为功能不可用。要像压普通接口一样压出容量下限,再反推需要分配多少资源。

另外补充一条关于JMeter的提醒:如果压测机在本地,后端在云上,网络延迟会直接影响首字延迟指标。压测机最好跟在云环境同一个内网区域,否则测出来的数字不具备参考价值。

最后再分享一个个人经验。若依本身是个中后台脚手架,接入AI以后,最大的改变不是多了一个聊天窗口,而是系统从一个“被动等用户操作”的工具,变成了“能主动帮用户分析、生成、答疑”的助手。这次整合过程中,提示词后台化是投入产出比最高的一件事,强烈建议先做。AI接口的鉴权和限流则是上线前绝对不能省的底线,哪怕功能再简单,也要把这两件事当正式服务来对待。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询