接手这个“若依整合AI”的实战改造前我心里很清楚业务方说“就加个聊天窗口”实际意味着模型接口对接、流式响应、权限控制、异常兜底、部署压测这五件事一个都不能少。这篇文章是若依整合AI系列的第二篇上一篇把大模型API选型、账号申请和基础概念讲完了这篇直接进入代码和部署层面。我目前维护的两套若依项目一套是 RuoYi-Vue 前后端分离版一套是 RuoYi-Cloud 微服务版都完成了AI问答模块的整合。文中所有方案都是这几轮改造里真实跑通过的包括Java后端、Vue3TS前端、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里手动添加moduleruoyi-ai/module确认ruoyi-ai自己的pom.xml有parent指向若依根工程。这一步做完模块才算真正纳入了构建体系。1.2 直连模型API还是自建网关层我选了后者团队里有人提出过更简单的方案前端直接调用大模型厂商的API后端不管。我直接否决了。如果前端直连API Key会暴露在浏览器里等于把公司的钱袋子挂在门口而且没法统计每个用户消耗了多少token出了问题也排查不了。我最终选择在后端自建一层“模型网关”。结构大概是层级职责对应实现接入层提供给若依前端的HTTP接口/ai/chatPost请求登录后访问网关层统一请求模型、切换供应商、统计用量自定义AiChatService 适配器适配层对接各家大模型HTTP接口OpenAiAdapter、通义Adapter、DeepSeekAdapter存储层会话消息、token用量持久化MySQL Redis这样做的收益是前端永远只面对一个接口后端今天接的是通义千问明天想换DeepSeek甚至想同时接多个模型做负载均衡都只需要在适配层改动不用动Controller。不过我要提醒一点网关层别设计得过度复杂。我见过有人在网关层引入了一套复杂的规则引擎反而把链路拖慢了。适配器加一个简单的策略模式就够了核心是保住“接口稳定、内部可切换”这个底线。1.3 会话数据表这样设计后面省很多事AI对话不是简单的“一问一答”它要支持多轮会话、历史记录、重新生成。所以至少需要两张表ai_conversation和ai_message。ai_conversation用来存会话id主键user_id若依用户id直接关联sys_usertitle会话标题可由第一轮问题自动生成model_code使用的模型标识create_time/update_time时间戳ai_message用来存每一轮消息idconversation_id会话idroleuser或assistantcontent消息内容prompt_tokens/completion_tokenstoken统计方便后续做成本核算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-chat、qwen-pluschat()方法接收请求对象和SseEmitter用流式方式把模型输出推送给前端。实现类是按模型去写的例如DeepSeekChatServiceImpl、QwenChatServiceImpl。每个实现类内部负责跟对应厂商的API打交道。为了能在多个实现类之间切换我加了一个工厂类Service public class AiChatServiceFactory { private final MapString, AiChatService serviceMap; public AiChatServiceFactory(ListAiChatService 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请求去接用户点击发送后要等十几秒才能看到第一个字体验非常差。SSEServer-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;charsetutf-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 的onCompletion或onError后会中断对模型API的连接。2.3 上下文管理不能无脑堆历史消息大模型输入有token上限不能每次请求都把整个会话历史全部拼进去。我做了一个滑动窗口策略只携带最近10轮消息如果超过上下文窗口就截断最旧的如果单条消息太长直接返回提示“内容过长请精简后重试”。由于若依自带Redis我把最近会话缓存到了Redis里key设计为ai:context:{conversationId}value是一个JSON数组每次请求前从Redis读取请求结束后把新消息追加进去并重置过期时间。这样不仅减少数据库查询压力也给模型提供了一套轻量级的短期记忆。补充一点上下文拼接时要注意消息角色。第一轮通常是system提示词后面必须严格按user、assistant交替拼接。如果出现两条连续的user消息部分模型会报错或降低生成质量。3. 前端vue3ts对话页这样写才不会翻车3.1 用fetch流式读取不要用EventSource若依目前主推的是Vue3TypeScript版本。我在做对话页时第一反应是直接用EventSource但很快发现它只支持GET请求而且没法自定义请求头。若依所有接口都是带Authorization头的所以EventSource这路直接走不通。正确选择是用fetch配合ReadableStreamconst 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 vue3ts下最常见的TS报错和处理方式热搜词里“若依vue3 ts报错”简直是这半年高频搜索。我在写流式读取时遇到过两个最典型的报错这里记录下来方便排查。第一个是Property getReader does not exist on type ReadableStreamUint8Array。这通常是因为tsconfig.json的lib配置里缺少DOM.Iterable或DOM.AsyncIterable。改成这样{ compilerOptions: { lib: [ESNext, DOM, DOM.Iterable] } }改完如果还报就把fetch返回值做一次类型断言const stream response.body as ReadableStreamUint8Array第二个是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] clicksendMessage发送/el-button后端Controller上加PreAuthorize注解PreAuthorize(ss.hasPermi(ai:chat:send)) PostMapping(/chat) public SseEmitter chat(RequestBody AiChatRequest request) { // ... }这样用户管理、角色分配、按钮权限全部走若依原有体系不需要额外开发。4. 鉴权、异常兜底与提示词配置上线前必须补齐的边角料4.1 AI接口必须走登录拦截别自己开白名单我看过有些人为了调试方便把/ai/**直接放进SecurityConfig的permitAll列表里排除了登录鉴权。生产环境千万别这么干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 --frombuilder /build/ruoyi-admin/target/ruoyi-admin.jar app.jar EXPOSE 8080 ENTRYPOINT [java, -jar, app.jar, --spring.profiles.activeprod]前端的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-PlusAI模块拆成一个独立微服务后网关路由要单独加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/chatJMeter会一直等待流式响应结束一个请求占用连接很久压出来的并发数根本不真实。我们的做法是先用“登录接口”获取Token通过正则表达式提取器或JSON提取器保存到变量对/ai/chat设置“Sample Timeout”例如15秒超过就标记失败设置线程组从10、20、50、100逐级加压同时监控后端所在机器的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接口的鉴权和限流则是上线前绝对不能省的底线哪怕功能再简单也要把这两件事当正式服务来对待。
