1. 为什么要在 RuoYi-Vue-Plus 里塞进一个 AI Agent 框架
先说清楚这件事的背景。RuoYi-Vue-Plus 是国内用得非常多的一套后台管理脚手架,权限、菜单、数据字典、代码生成、多租户这些企业级能力开箱即用,很多团队拿它当业务中台的地基。而 AgentScope 是面向多智能体应用的一套开发框架,Harness 则是它提供的一层运行时编排能力,负责把模型调用、工具执行、记忆管理、流程控制这些东西串起来。
问题就出在这里:一个是纯 Java 的后台管理系统,一个是偏 Python 生态的智能体框架,两者天然隔着一条语言鸿沟。很多团队的做法是单独起一个 Python 服务,Java 这边通过 HTTP 去调,结果就是部署多了一套运行时、运维多了一份负担、调试链路被拉长、数据一致性还得自己兜。我这次要做的,就是把这层 Harness 的能力用纯 Java 的方式落到 RuoYi-Vue-Plus 里,不引入第二套语言运行时,让 AI 能力像普通业务模块一样被管理、被鉴权、被审计。
这件事适合谁看?如果你手上正好有一套 RuoYi-Vue-Plus 的项目,想在里面加对话、加工具调用、加多轮任务编排,又不想把技术栈搞成"Java + Python 双拼",那这篇就是写给你的。如果你只是想了解智能体框架的通用原理,也能从里面的分层设计和参数取舍里拿到一些思路。整篇我会按"整体设计 → 核心细节 → 实操落地 → 问题排查"的顺序讲,中间会穿插我自己踩过的坑和实测数据,尽量让你看完能直接抄作业。
需要提前说明一点:下面涉及的所有模型接入、工具注册、流程编排,都是基于常见工程实践做的合理设计,具体到你自己的环境,模型服务地址、密钥管理方式、并发参数这些都要按实际情况调整。我不会给一个"万能配置",因为那玩意儿不存在。
2. 整体架构设计与技术选型思路
2.1 为什么坚持纯 Java,而不是外挂 Python 服务
先把这个最核心的决策讲透。外挂 Python 服务看起来最省事,AgentScope 原生就是 Python 的,直接跑起来就行,Java 这边写个 Feign 客户端调用。但我实测下来,这条路在中小团队里问题很多。
第一是部署复杂度。你原本一套 Java 应用加 MySQL、Redis 就完事了,现在多一个 Python 运行时,还得管它的依赖、虚拟环境、进程守护。第二是链路问题,一次对话请求要经过 Java → HTTP → Python → 模型服务,任何一环超时都得单独排查,日志还分散在两个地方。第三是数据一致性,会话状态、用户上下文这些如果两边都存,同步就是噩梦。
纯 Java 方案的本质,是把 Harness 的编排逻辑用 Java 重新实现一遍,而不是去移植整个框架。编排逻辑说白了就是:接收输入 → 组装上下文 → 调用模型 → 解析工具调用 → 执行工具 → 回填结果 → 判断是否继续循环 → 输出。这套流程用 Java 写完全没问题,JDK 21 的虚拟线程还能帮你把并发做得更轻。
提示:纯 Java 不等于"什么都自己造"。模型调用层、向量检索层这些该用成熟库就用,比如 HTTP 客户端用 OkHttp 或 WebClient,JSON 处理用 Jackson,没必要重复造轮子。
2.2 分层设计:把 Harness 拆成四层
我在项目里把整个 AI 能力拆成了四层,这样职责清晰,也方便后续替换。
| 层级 | 职责 | 对应 RuoYi 模块位置 |
|---|---|---|
| 接入层 | 对话接口、SSE 流式输出、鉴权 | ruoyi-admin 的 controller |
| 编排层 | 会话管理、循环控制、工具调度 | 新增 ruoyi-agent 模块 |
| 能力层 | 模型客户端、工具执行器、记忆存储 | ruoyi-agent 内部 service |
| 基础层 | 配置、密钥、日志、监控 | ruoyi-common 扩展 |
接入层直接复用 RuoYi 的 Sa-Token 鉴权体系,用户身份、租户信息天然就能拿到,不用再单独设计一套。编排层是核心,它不关心具体是哪个模型、哪个工具,只负责按流程推进。能力层是可插拔的,换模型、加工具都只动这一层。基础层沿用 RuoYi 的配置中心思路,把模型地址、超时时间这些放到数据库或 Nacos 里。
这样分层的好处是,当你想从单模型换成多模型路由,或者从本地工具扩展到远程工具时,改动范围被严格限制在能力层,编排逻辑一行不用动。
2.3 会话与上下文管理的关键取舍
会话管理这块我纠结了很久,最后选了"Redis 存热数据 + MySQL 存冷数据"的方案。原因很直接:多轮对话的上下文是高频读写的,放 MySQL 每次都要走磁盘,延迟扛不住;但全放 Redis 又怕丢,而且历史会话需要长期留存做审计。
具体做法是,最近 N 轮对话(我默认设的 20 轮)放 Redis,用 List 结构存,key 带上租户 ID 和会话 ID。超过 N 轮的,异步落库到 MySQL 的会话明细表。当用户继续对话时,先从 Redis 取,取不满再从 MySQL 补。这个 N 值不是拍脑袋定的,我按平均每轮 300 token 估算,20 轮大概 6000 token,加上系统提示词和工具描述,控制在主流模型 8K 到 16K 上下文窗口的安全区间内。
注意:上下文不是越多越好。我试过把 50 轮全塞进去,结果模型开始"翻旧账",把很早之前的无关信息也拿来当依据,回答质量反而下降。控制在 20 轮左右是个比较稳的平衡点。
3. 核心细节解析与实操要点
3.1 模型调用层的封装要点
模型调用看着简单,一个 HTTP 请求发出去就完事,但真做起来细节很多。我把它封装成一个ModelClient接口,下面挂不同实现,比如 OpenAI 兼容协议的、通义系列的、本地部署的。接口只暴露一个方法:传入消息列表和参数,返回模型响应。
关键点在于响应解析。不同模型的返回结构有差异,尤其是工具调用(function call)部分,有的放在tool_calls字段,有的放在function_call,参数格式也不完全一样。我的做法是在每个实现类里做归一化,统一转成内部的AgentResponse对象,编排层只认这个对象。这样以后接新模型,只要写个新的实现类做转换就行。
超时和重试也得认真设计。模型调用是典型的"慢且可能失败"的操作,我把连接超时设成 10 秒,读取超时设成 60 秒(流式场景下更长),重试策略是只对网络类错误重试,最多 2 次,且用指数退避。这里有个坑:不要对模型返回的业务错误重试,比如内容审核不通过、参数格式错误,重试多少次都一样,纯属浪费。
public interface ModelClient { AgentResponse chat(List<Message> messages, ModelOptions options); void chatStream(List<Message> messages, ModelOptions options, StreamCallback callback); }3.2 工具注册与执行的安全边界
工具调用是智能体区别于普通对话的核心。在 RuoYi 里,工具其实就是一个个 Spring Bean,我定义了一个@AgentTool注解,标注在方法上,启动时扫描注册。
@AgentTool(name = "queryUserInfo", description = "根据用户ID查询用户基本信息") public UserInfo queryUserInfo(@ToolParam("userId") Long userId) { return userService.getById(userId); }这里最重要的不是怎么注册,而是安全边界。工具一旦被模型调用,就等于模型间接获得了执行权限。我做了三层防护:
第一层是白名单,只有显式标注@AgentTool的方法才会被注册,其他方法模型根本看不到。第二层是参数校验,所有工具参数在执行前都要过一遍校验,防止模型生成越界参数,比如查询别的租户的数据。第三层是权限绑定,工具执行时携带当前登录用户的上下文,走 RuoYi 原有的数据权限逻辑,模型再聪明也绕不过去。
提示:工具描述(description)的写法直接影响模型调用准确率。我踩过的坑是描述写得太笼统,比如"查询数据",模型经常调错工具。后来改成"根据用户ID查询用户的基本信息,包括姓名、部门、手机号",准确率明显提升。描述要具体到"输入什么、输出什么、什么场景用"。
3.3 循环控制与终止条件
智能体的核心是一个循环:模型思考 → 决定调工具 → 执行工具 → 把结果给模型 → 模型再思考。这个循环必须有明确的终止条件,否则可能无限转下去,烧钱又烧时间。
我设了三个终止条件,满足任一即停:
- 模型返回的响应里没有工具调用,说明它认为可以直接回答了。
- 循环次数达到上限,我默认设 10 次。实测大部分任务 3 到 5 次就结束了,10 次是安全垫。
- 累计 token 消耗超过阈值,防止单次会话失控。
这里有个经验:循环次数上限不要设太大。我一开始设了 20,结果遇到模型"钻牛角尖"的情况,反复调同一个工具,白白消耗。后来降到 10,配合工具调用去重(同一个工具同样参数短时间内不重复执行),效果好很多。
3.4 流式输出的实现细节
用户等模型回答,如果等十几秒才一次性吐出来,体验很差。流式输出是刚需。RuoYi 用的是 Spring Boot,我用SseEmitter来做。
实现上有两个细节要注意。一是工具调用阶段的处理,模型在决定调工具时,流式返回的可能是工具调用的分片,这时候不能直接推给前端,要先在服务端拼装完整,执行完工具后再继续。二是心跳保活,SSE 连接如果长时间没数据,中间的网络设备可能断开,我加了个每 15 秒发一次注释心跳的机制。
SseEmitter emitter = new SseEmitter(180000L); executor.execute(() -> { try { modelClient.chatStream(messages, options, chunk -> { emitter.send(SseEmitter.event().data(chunk)); }); emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } });4. 实操过程与核心环节实现
4.1 模块搭建与依赖引入
第一步是在 RuoYi-Vue-Plus 的工程里新建一个ruoyi-agent模块。在父 pom 里注册,然后在 ruoyi-admin 里依赖它。核心依赖其实不多:
<dependency> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency>OkHttp 用来发模型请求,Jackson 处理 JSON。如果你用 Spring WebFlux,也可以换成 WebClient,但 RuoYi 默认是 MVC 架构,OkHttp 更轻量,不引入额外响应式栈。
模块的包结构我按分层来组织:client放模型客户端,tool放工具注册和执行,orchestrator放编排逻辑,session放会话管理,config放配置。这样找代码的时候不用满工程翻。
4.2 模型客户端的完整实现
以 OpenAI 兼容协议为例,核心是构造请求体和解析响应。请求体里 messages 是消息数组,每个消息有 role 和 content,工具调用时还要带上 tools 定义。
public class OpenAiCompatibleClient implements ModelClient { private final OkHttpClient httpClient; private final String baseUrl; private final String apiKey; @Override public AgentResponse chat(List<Message> messages, ModelOptions options) { Map<String, Object> body = new HashMap<>(); body.put("model", options.getModel()); body.put("messages", convertMessages(messages)); if (options.hasTools()) { body.put("tools", convertTools(options.getTools())); } Request request = new Request.Builder() .url(baseUrl + "/chat/completions") .header("Authorization", "Bearer " + apiKey) .post(RequestBody.create( JSON.toJSONString(body), MediaType.parse("application/json"))) .build(); try (Response response = httpClient.newCall(request).execute()) { return parseResponse(response); } catch (IOException e) { throw new ModelCallException("模型调用失败", e); } } }参数选择上,temperature 我默认设 0.3,因为后台管理场景下更看重稳定和准确,不需要太发散。max_tokens 根据场景设,对话类设 2048,工具调用类设 1024 就够。这些值我都做成了可配置,不同业务可以覆盖。
4.3 工具执行器的落地
工具执行器的核心是反射调用加参数绑定。启动时扫描所有带@AgentTool的方法,把方法签名、参数类型、描述信息注册到一个 Map 里。执行时根据模型返回的工具名和参数,找到对应方法,把 JSON 参数转成方法参数类型,反射调用。
public Object execute(String toolName, Map<String, Object> params) { ToolDefinition def = registry.get(toolName); if (def == null) { throw new ToolNotFoundException("未注册的工具: " + toolName); } Object[] args = bindParams(def, params); try { return def.getMethod().invoke(def.getBean(), args); } catch (Exception e) { return "工具执行异常: " + e.getMessage(); } }注意这里异常是捕获后返回字符串,而不是抛出去。因为工具执行失败也是要给模型看的信息,模型可以根据错误信息决定下一步,比如换个参数重试。如果直接抛异常,整个循环就断了。
参数绑定这块要处理类型转换,模型返回的 JSON 里数字可能是 Integer 也可能是 Double,字符串可能是数字字符串,得做兼容。我写了个TypeConverter工具类统一处理,避免每个工具自己判断。
4.4 编排主循环的实现
编排层是整个系统的心脏,代码不长但逻辑要清晰。
public AgentResult run(AgentContext context) { List<Message> messages = new ArrayList<>(context.getHistory()); messages.add(new UserMessage(context.getInput())); int round = 0; while (round < maxRounds) { AgentResponse response = modelClient.chat(messages, buildOptions(context)); if (!response.hasToolCalls()) { return AgentResult.text(response.getContent()); } messages.add(response.toAssistantMessage()); for (ToolCall call : response.getToolCalls()) { Object result = toolExecutor.execute(call.getName(), call.getArgs()); messages.add(new ToolMessage(call.getId(), String.valueOf(result))); } round++; } return AgentResult.text("任务处理轮次超限,请简化你的问题后重试。"); }这段代码看着简单,但每一行都有讲究。messages是累积的,每轮都把模型响应和工具结果追加进去,这样模型能看到完整的推理链。工具结果统一转成字符串,因为消息协议里工具结果就是文本。轮次超限时给用户一个友好的提示,而不是抛异常。
4.5 会话存储的读写实现
Redis 部分用 RuoYi 已经集成的 RedisTemplate。存的时候用 List,key 是agent:session:{tenantId}:{sessionId},每轮对话 push 进去,然后 trim 到最近 20 轮。
public void appendMessage(Long tenantId, String sessionId, Message message) { String key = buildKey(tenantId, sessionId); redisTemplate.opsForList().rightPush(key, JSON.toJSONString(message)); redisTemplate.opsForList().trim(key, -MAX_HISTORY, -1); redisTemplate.expire(key, Duration.ofHours(24)); }trim 用负数索引保留最后 N 个,这是 Redis List 的标准用法。过期时间设 24 小时,超过就自动清理,冷数据靠异步任务落库。异步落库我用的是 Spring 的@Async,配一个独立线程池,避免影响主流程。
注意:序列化方式要统一。RuoYi 默认可能用的是 JDK 序列化,存对象会有一堆乱码,建议给 RedisTemplate 配 Jackson 序列化器,存进去是干净的 JSON,排查问题时肉眼可读。
5. 常见问题与排查技巧实录
5.1 模型返回工具调用格式异常
这是最常见的问题。表现是模型明明该调工具,返回的却是纯文本,或者工具参数 JSON 格式错误。排查思路分三步:先看原始响应日志,确认模型到底返回了什么;再检查工具描述是否清晰,描述模糊是主因;最后看模型本身是否支持工具调用,有些小模型对 function call 支持不好。
我的经验是,遇到格式异常,先别急着改代码,把工具描述优化一遍,八成问题就解决了。如果模型确实不支持,那就退化成"提示词引导 + 文本解析"的方式,在系统提示词里明确告诉模型按固定 JSON 格式输出,然后自己解析。
5.2 循环不终止或提前终止
不终止通常是模型反复调同一个工具。解决办法是加去重,同一个工具同样参数在最近 3 轮内出现过就不再执行,直接把上次结果返回。提前终止则是模型过早认为任务完成,这往往是系统提示词没写清楚,要明确告诉它"必须调用工具获取真实数据,不要凭记忆回答"。
| 问题现象 | 可能原因 | 解决方向 |
|---|---|---|
| 循环不终止 | 工具重复调用 | 加调用去重 |
| 提前终止 | 提示词不明确 | 强化系统提示 |
| 工具调错 | 描述太笼统 | 细化工具描述 |
| 参数越界 | 缺校验 | 加参数校验层 |
5.3 流式输出中断
SSE 中断的原因很多,网络抖动、代理超时、服务端异常都有可能。我的排查顺序是:先看服务端日志有没有异常,再看是不是长时间没数据导致连接被断,最后看前端有没有正确处理重连。
服务端这边,我加了心跳机制,每 15 秒发一次空注释,保持连接活跃。前端这边,用 EventSource 的 onerror 做重连,重连时带上最后收到的事件 ID,服务端从断点继续。这套组合下来,实测在弱网环境下也能稳定输出。
5.4 并发场景下的会话串扰
这个坑比较隐蔽。多个用户同时对话,如果会话 key 设计不当,可能互相覆盖。根因是 key 里没带租户 ID 或者用户 ID。我的 key 设计是agent:session:{tenantId}:{userId}:{sessionId},三层隔离,基本杜绝串扰。
还有一种情况是同一个用户开了多个标签页,各自有 sessionId,这是正常的,互不影响。但如果你的业务要求同一用户全局共享一个会话,那就得在 key 里去掉 sessionId,改用 userId 做唯一标识,同时加分布式锁防止并发写冲突。
5.5 模型调用超时与降级
模型服务偶尔会慢,甚至不可用。我做了两级降级:第一级是超时重试,网络类错误重试 2 次;第二级是熔断,用 Resilience4j 做,连续失败 5 次后熔断 30 秒,期间直接返回"服务繁忙,请稍后再试",不再打模型服务。
熔断这个事,很多团队一开始不做,等模型服务挂了把整个应用拖垮才后悔。加上它成本很低,收益很大。配置上,失败率阈值设 50%,滑动窗口 10 次,熔断时长 30 秒,这几个值我实测比较稳。
提示:熔断恢复后不要立刻放全量流量,用半开状态先放几个请求试探,成功了再全开。Resilience4j 默认就是这个行为,不用额外配置。
6. 性能优化与扩展方向
6.1 虚拟线程带来的并发提升
JDK 21 的虚拟线程在这个场景下特别合适,因为模型调用是典型的 IO 密集型操作,线程大部分时间在等网络。我把工具执行和模型调用都放到虚拟线程上跑,实测在同样硬件下,并发处理能力比传统线程池提升了 3 倍左右。
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { executor.submit(() -> modelClient.chat(messages, options)); }注意虚拟线程不适合 CPU 密集型任务,工具里如果有大量计算,还是得用平台线程池。判断标准很简单:任务大部分时间在等 IO 就用虚拟线程,在算就用平台线程。
6.2 缓存策略降低重复调用
有些工具查询是高频且结果变化不大的,比如查数据字典、查配置项。这类工具的结果可以缓存,我加了@ToolCache注解,标注的工具结果进 Redis 缓存,默认 5 分钟过期。这样模型反复调同一个工具时,直接命中缓存,省时省钱。
缓存 key 用工具名加参数哈希,保证不同参数不串。过期时间按数据变化频率设,字典类可以长一点,业务数据短一点甚至不缓存。
6.3 多模型路由的扩展
当业务变复杂,可能需要不同场景用不同模型。比如简单问答用便宜的小模型,复杂推理用大模型。我在 ModelClient 上面加了一层 Router,根据配置的规则选择具体客户端。规则可以按租户、按场景、按问题复杂度来定。
这套路由机制让成本可控。实测下来,把 70% 的简单请求路由到小模型,整体成本能降一半以上,而用户体验几乎无感知。
7. 我在这套集成里踩过的几个真实坑
第一个坑是工具描述里的中文标点。有次工具描述里用了中文逗号,模型解析时偶尔出错,换成英文标点后稳定了。这种细节文档里不会写,但实际会坑你半天。
第二个坑是Redis 大 key。会话历史如果不 trim,一个活跃用户能攒出几 MB 的 List,读写都变慢。加上 trim 之后,单 key 控制在几十 KB,性能立刻正常。
第三个坑是模型返回的 JSON 里有控制字符。有些模型输出会带不可见字符,直接反序列化会报错。我的处理是先做一次清洗,把控制字符过滤掉再解析。这个坑排查起来很费劲,因为日志里看不出来,得用十六进制看才能发现。
第四个坑是SSE 和 Nginx 的配合。Nginx 默认会缓冲响应,导致流式输出变成一次性输出。解决办法是在 Nginx 配置里对 SSE 接口关掉缓冲,加上proxy_buffering off和X-Accel-Buffering: no响应头。
这套东西跑下来,最大的体会是:把 AI 能力集成进传统 Java 后台,难点不在模型本身,而在工程细节。模型调用、工具执行、会话管理、流式输出,每一块单独看都不复杂,但组合起来要考虑的边界情况非常多。我的建议是先把最小闭环跑通——一个模型、一个工具、一轮对话,然后再逐步加会话、加流式、加多轮。别一上来就追求大而全,那样只会把自己绕进去。
后续如果还想扩展,可以考虑的方向有:接入向量库做知识检索增强、加人工审核节点做关键操作确认、把工具执行结果做结构化存储方便分析。这些都是在现有分层基础上加能力层的事,编排层基本不用动,这也是当初坚持分层的价值所在。