☰
纯Java实现AgentScope Harness:RuoYi-Vue-Plus集成AI Agent与MCP工具调用实战
2026/10/7 19:34:00 网站建设 项目流程

1. 为什么要在 RuoYi-Vue-Plus 里塞进 AgentScope Harness

RuoYi-Vue-Plus 这套脚手架在国内 Java 圈子里有多普及,不用我多说。权限、代码生成、多数据源、分布式事务、工作流,基本上一个中后台项目该有的东西它都给你备齐了。但这两年做企业项目,甲方张口闭口就是"能不能接大模型""能不能搞个智能助手",纯 CRUD 的后台已经不太够看了。问题在于,RuoYi 本身是个标准的 Spring Boot 单体/微服务架构,而 AI Agent 那一套东西——工具调用、多轮推理、上下文管理、MCP 协议——生态基本都在 Python 那边。硬要在 Java 项目里接 AI,很多人的第一反应是"起个 Python 服务,Java 通过 HTTP 调",但这意味着你要多维护一套运行时、多一套部署、多一套监控,运维成本直接翻倍。

AgentScope 是阿里开源的多智能体框架,原本也是 Python 为主,但它后来推出了AgentScope Harness这个能力层,核心价值在于把 Agent 的编排、工具注册、MCP 协议对接这些脏活累活抽象出来,并且提供了跨语言的接入方式。我这次做的事情,就是把这套 Harness 的能力以纯 Java 的方式嵌进 RuoYi-Vue-Plus,让整个平台在不引入 Python 运行时的前提下,具备 Agent 编排和 MCP 工具调用的能力。说白了,就是让一个传统的 Java 后台,长出一个 AI Agent 的大脑,而且这个大脑是用 Java 写的、跟着 Spring 容器一起启动的。

这篇文章适合谁看?如果你手上正好有一个 RuoYi-Vue-Plus 的项目,或者任何基于 Spring Boot 的 Java 中后台,想接 AI 能力但又不想被 Python 服务绑架,那这篇就是给你写的。我会把整体设计思路、核心模块拆解、MCP 工具怎么注册、Agent 怎么和业务 Service 打通、踩过的坑,全部摊开讲。代码不会贴满屏,但关键配置和核心逻辑会给到位,你照着抄能跑起来。

先说清楚一个前提:我这里讲的"纯 Java",指的是业务侧和 Agent 编排侧全部跑在 JVM 里,不依赖外部的 Python Agent 服务。模型推理本身当然还是走 HTTP 调大模型 API,这个没法绕开,也不该绕开。所谓集成,是把 Agent 的"思考-调用工具-再思考"这个循环,用 Java 实现出来,并且和 RuoYi 现有的权限、数据、缓存体系无缝对接。

2. 整体架构设计与技术选型考量

2.1 为什么不用 Python 微服务方案

先把这个决策讲透,因为这是整个项目的地基。常见的做法是单独起一个 Python 服务,用 FastAPI 或者 AgentScope 原生的 Server 模式,Java 这边通过 Feign 或者 RestTemplate 去调。这个方案不是不能用,我早期也这么干过,但实际跑下来有几个绕不过去的痛点。

第一是上下文传递的损耗。RuoYi 里的用户身份、租户 ID、数据权限这些信息,都封装在SecurityUtils和LoginUser里,你要把这些透传到 Python 服务,得序列化一遍、反序列化一遍,Python 那边还得重新建一套权限模型去对齐。一旦权限逻辑有变更,两边都要改,非常容易漏。

第二是工具调用的往返成本。Agent 要调用一个业务工具,比如"查询当前用户的订单列表",如果工具实现在 Java 侧,那流程是:Python Agent 决定调用工具 → HTTP 回调 Java → Java 执行 → 结果再 HTTP 回 Python → Python 继续推理。一次工具调用两次跨进程往返,延迟叠加,而且链路追踪特别难做。

第三是部署和运维。多一个 Python 运行时,就多一份依赖管理、多一份健康检查、多一份日志采集。对于中小团队来说,这是实打实的负担。

所以我的选择是:Agent 编排循环用 Java 实现,工具直接注册为 Spring Bean,MCP 协议在 JVM 内解析。AgentScope Harness 在这里扮演的角色,是提供 Agent 的抽象模型、消息协议、工具描述规范这些"标准",而不是它的运行时。换句话说,我借鉴它的设计,用 Java 重新落地。

2.2 分层结构怎么切

整个集成我切成了四层,从下往上说。

最底层是模型接入层,负责和各家大模型的 API 打交道。这一层我做了统一抽象,定义一个ChatModel接口,屏蔽掉 OpenAI 格式、通义格式、文心格式之间的差异。为什么要抽象?因为企业项目里模型经常换,今天用这个明天用那个,如果业务代码直接依赖某个 SDK,换模型就是灾难。

往上一层是工具与 MCP 层。所有能被 Agent 调用的能力,都通过@AgentTool注解注册成工具,注解里描述工具的名称、用途、参数 schema。MCP 协议里的 tool 定义,本质上就是这个东西,我把它做成了注解驱动,写起来跟写普通的 Service 方法没区别。

再往上是Agent 编排层,这是核心。它负责维护对话历史、组装 prompt、解析模型的工具调用意图、执行工具、把结果喂回模型、循环直到模型给出最终答案。这一层是整个 Harness 的"发动机"。

最上面是业务接入层,也就是 RuoYi 的 Controller 和 Service。业务代码只需要注入一个AgentExecutor,把用户的问题丢进去,拿回答案就行,完全不用关心底下 Agent 是怎么转的。

2.3 和 RuoYi 现有体系的咬合点

这里有几个关键的咬合点,处理不好就会很别扭。

权限咬合:Agent 调用工具时,必须带上当前登录用户的身份。我的做法是在AgentContext里持有LoginUser,工具执行前通过SecurityUtils校验权限,和普通接口走同一套@PreAuthorize逻辑。这样 Agent 不会成为权限的"后门"。

多租户咬合:RuoYi-Vue-Plus 的租户隔离是靠 MyBatis-Plus 的租户插件做的,工具里执行的 SQL 只要走正常的 Mapper,租户条件会自动加上。这一点很省心,前提是你别在工具里手写 JDBC。

缓存咬合:对话历史我用 Redis 存,key 里带上租户 ID 和用户 ID,天然隔离。RuoYi 已经封装好了RedisUtils,直接用就行。

异步咬合:Agent 推理是耗时的,不能阻塞 Web 线程。我用 RuoYi 自带的线程池配置,把 Agent 执行放到独立线程池里,配合 SSE 做流式返回。

3. 核心模块拆解与关键实现细节

3.1 模型接入层的统一抽象

模型接入这块,核心是定义一个足够简单又足够灵活的接口。我最终定下来的是这样:

public interface ChatModel { ChatResponse chat(ChatRequest request); Flux<ChatResponse> stream(ChatRequest request); }

ChatRequest里装的是消息列表、工具定义、温度、最大 token 这些参数。ChatResponse里装的是模型返回的文本内容、工具调用请求、token 消耗统计。为什么要用 Flux 做流式?因为前端要打字机效果,而且流式能显著降低首字延迟,用户体验差别很大。

具体实现上,我做了三个实现类:OpenAiCompatibleModel、DashScopeModel、OllamaModel。前两个走 HTTP,最后一个走本地。这里有个经验:不要用各家官方 SDK。官方 SDK 版本更新频繁,依赖冲突多,而且很多 SDK 会把 OkHttp、Jackson 的版本锁死,跟 RuoYi 现有的依赖打架。我全部用RestClient(Spring 6.1 之后的新东西)手写 HTTP 调用,依赖干净,可控性强。

参数计算这块要提一句。max_tokens的设置不是随便填的,它直接关系到成本和截断风险。我的经验公式是:max_tokens = 预期输出长度 × 1.5 + 工具调用预留。比如你期望模型输出 500 字,那预留 800 到 1000 比较稳妥,因为中文一个字符大约对应 1.5 到 2 个 token,再加上工具调用的 JSON 结构开销。设太小会导致回答被硬截断,设太大又浪费配额。

3.2 工具注册:注解驱动的设计

工具注册是整个集成里最影响开发体验的部分。我希望业务同学写一个工具,就像写一个普通的 Service 方法一样自然。所以设计了@AgentTool注解:

@Component public class OrderTools { @AgentTool( name = "queryUserOrders", description = "查询当前登录用户的订单列表,支持按状态筛选" ) public List<OrderVO> queryUserOrders( @AgentToolParam(description = "订单状态,可选值:PENDING/PAID/SHIPPED") String status) { Long userId = SecurityUtils.getUserId(); return orderService.listByUserAndStatus(userId, status); } }

启动时,一个AgentToolScanner会扫描所有带@AgentTool的方法,把方法名、描述、参数 schema 反射出来,注册到一个ToolRegistry里。参数 schema 的生成是关键,我要把它转成 JSON Schema 格式,因为大模型的 function calling 需要这个格式来描述工具。

这里有个坑:参数类型的映射。Java 的String、Integer、Boolean、List<String>这些好办,但遇到自定义对象就麻烦了。我的处理是,自定义对象一律要求用@AgentToolParam标注每个字段,或者干脆在工具方法签名里只用基础类型和集合。这样虽然牺牲了一点灵活性,但换来的是 schema 生成的确定性,避免模型因为 schema 模糊而乱调工具。

工具描述(description)的写法极其重要,这是很多人忽略的地方。模型判断该不该调这个工具、怎么填参数,全靠这段描述。我的经验是:描述里要写清楚"什么时候用"和"参数怎么填"。比如上面那个例子,如果只写"查询订单",模型可能在你问"我的订单到哪了"的时候不知道该不该调。加上"支持按状态筛选"和参数的可选值,模型就能准确判断。

3.3 Agent 编排循环的实现

这是整个 Harness 的心脏。核心逻辑是一个循环:

  1. 把系统提示词、历史消息、用户新消息组装成请求
  2. 调用模型
  3. 如果模型返回的是工具调用请求,执行工具,把结果作为一条 tool 消息追加到历史,回到第 2 步
  4. 如果模型返回的是普通文本,结束循环,返回结果

听起来简单,但细节全是坑。

循环次数限制:必须设上限,我默认设 10 轮。为什么?因为模型有时候会陷入"调工具-不满意-再调"的死循环,尤其是工具返回结果不符合预期的时候。超过上限就强制返回当前状态,并提示"任务较复杂,请拆分后重试"。

工具执行异常处理:工具执行失败不能直接抛异常中断整个流程,要把异常信息作为工具结果返回给模型,让模型自己决定是重试、换工具还是告诉用户失败。这一点很关键,我见过太多实现是工具一报错整个 Agent 就崩了,体验极差。

消息历史的裁剪:对话轮次多了之后,历史消息会撑爆上下文窗口。我的策略是保留最近 N 轮完整对话,更早的做摘要压缩。摘要用一个便宜的小模型来做,成本可控。这里要注意,工具调用的消息对不能拆散,一个 assistant 的 tool_call 消息和对应的 tool 结果消息必须成对保留,否则模型会报格式错误。

并发工具调用:现代模型支持一次返回多个工具调用请求,这时候可以并行执行。我用CompletableFuture并发跑,然后按顺序收集结果。但要注意,如果工具之间有依赖关系,并行会出问题,所以我在工具定义里加了一个dependsOn属性,有依赖的串行执行。

3.4 MCP 协议的对接

MCP 是这两年很火的一个协议,全称是 Model Context Protocol,本质上是给模型和外部工具之间定了一套标准通信格式。它的价值在于,你写一次工具,理论上可以被任何支持 MCP 的客户端调用。

在纯 Java 环境里对接 MCP,我做了两件事。一是作为 MCP Server,把注册在ToolRegistry里的工具通过 MCP 协议暴露出去,这样外部的 MCP 客户端也能调用我们 Java 侧的工具。二是作为 MCP Client,能连接外部的 MCP Server,把它们的工具拉进来注册到本地ToolRegistry,让我们的 Agent 也能用。

MCP 的传输层我用的是 stdio 和 SSE 两种。stdio 适合本地进程,SSE 适合远程。这里有个细节:MCP 的消息格式是 JSON-RPC 2.0,解析的时候要注意id的匹配,请求和响应靠 id 关联,异步场景下这个 id 管理不能乱。

提示:MCP 工具的 schema 和本地@AgentTool的 schema 要做一次转换对齐,因为 MCP 用的是标准 JSON Schema,而本地注解生成的 schema 可能有些字段命名不一致。我写了一个SchemaAdapter专门做这件事,别偷懒跳过,否则模型调用外部 MCP 工具时会因为参数对不上而失败。

4. 完整实操流程与关键环节落地

4.1 环境准备与依赖引入

先说环境。JDK 17 是底线,RuoYi-Vue-Plus 5.x 本身就要求 17。Spring Boot 3.2 以上,因为我要用RestClient。Redis 必须有,对话历史靠它。

依赖方面,核心就几个:

<dependency> <groupId>com.github.ben-manes.caffeine</groupId> <artifactId>caffeine</artifactId> </dependency> <dependency> <groupId>io.projectreactor</groupId> <artifactId>reactor-core</artifactId> </dependency>

Caffeine 用来做工具注册表的本地缓存,Reactor 用来做流式返回。注意,不要引入任何 AgentScope 的 Java SDK,因为目前没有官方成熟的 Java 版本,网上那些所谓的 Java SDK 大多是个人封装,质量参差。我是完全自己实现的,只借鉴了 AgentScope 的设计理念。

配置文件里加一段:

agent: model: provider: dashscope api-key: ${AGENT_API_KEY} model-name: qwen-plus temperature: 0.7 max-tokens: 2000 executor: max-iterations: 10 timeout-seconds: 60 memory: history-rounds: 10 ttl-hours: 24

api-key一定要走环境变量,别硬编码在 yaml 里,这是安全底线。

4.2 工具注册表的初始化流程

启动流程是这样的:Spring 容器启动 →AgentToolScanner实现BeanPostProcessor→ 扫描所有 Bean 的方法 → 找到@AgentTool注解 → 生成ToolDefinition→ 注册到ToolRegistry。

ToolDefinition的结构:

public class ToolDefinition { private String name; private String description; private Map<String, Object> parametersSchema; private Object bean; private Method method; private List<String> dependsOn; }

参数 schema 的生成我用了一个递归方法,处理基础类型、集合、枚举、自定义对象。枚举要特别处理,把可选值列进 schema 的enum字段,这样模型就知道只能从这几个值里选。

这里有个实操心得:工具名用驼峰,但 schema 里建议转成下划线。因为有些模型对下划线命名的工具识别更准,这是实测出来的,虽然没找到官方文档佐证,但多个模型上验证过,下划线命名的调用准确率确实高一些。

4.3 Agent 执行的完整链路

从用户发消息到拿到回答,完整链路是这样的:

第一步,Controller 接收请求,从SecurityUtils拿到LoginUser,构造AgentContext,塞进线程池执行。

第二步,AgentExecutor从 Redis 加载该用户的历史消息,和系统提示词、新消息拼成完整消息列表。

第三步,进入编排循环。调用ChatModel.chat(),拿到响应。

第四步,判断响应类型。如果是工具调用,遍历每个工具调用请求,从ToolRegistry找到对应工具,反射执行,结果包装成 tool 消息。

第五步,把 tool 消息追加到消息列表,回到第三步。

第六步,模型返回文本,循环结束。把这一轮的完整消息存回 Redis,返回给用户。

流式场景下,第三步到第六步是边生成边推送的。这里要注意,流式模式下工具调用的处理更复杂,因为工具调用的参数是分片到达的,要等所有分片拼完才能执行。我的做法是维护一个缓冲区,检测到finish_reason是tool_calls时才触发执行。

4.4 和 RuoYi 权限体系的对接实操

这块单独拎出来讲,因为最容易出问题。

工具执行前,我加了一个ToolPermissionChecker,它会检查工具方法上有没有@PreAuthorize或者自定义的@AgentToolPermission注解。有的话,走 Spring Security 的权限校验逻辑。这样 Agent 调用工具和用户直接调接口,权限判定完全一致。

数据权限方面,RuoYi 的@DataScope注解是作用在 Mapper 方法上的,工具里只要调用了带这个注解的 Service 方法,数据权限自动生效。我实测过,Agent 调用工具查出来的数据,和用户直接查出来的完全一致,没有越权。

注意:如果你的工具里有直接操作SqlSession或者手写 SQL 的地方,数据权限和租户隔离都会失效。这是血泪教训,我早期有个工具图省事用了JdbcTemplate,结果租户隔离直接穿透,测试环境没发现,差点上生产。所有工具的数据访问,一律走 MyBatis-Plus 的 Mapper。

4.5 对话记忆的存储设计

Redis 的 key 设计:agent:history:{tenantId}:{userId}:{sessionId}。value 是一个 List,存序列化后的消息对象。TTL 设 24 小时,过期自动清理。

为什么用 List 而不是 String?因为要支持追加和范围读取。用 Redis 的RPUSH追加,LRANGE读最近 N 条,效率很高。

消息对象的序列化我用的是 Jackson,但要注意,工具调用的消息结构比较复杂,序列化时要保留类型信息,否则反序列化回来变成 LinkedHashMap,再发给模型就格式错了。我的做法是给消息类加@JsonTypeInfo注解,明确类型。

5. 常见问题排查与避坑经验实录

5.1 模型不调用工具怎么办

这是最高频的问题。模型明明该调工具,却直接编了个答案。排查思路按顺序来:

先看工具描述是不是太模糊。描述里没写清楚使用场景,模型就不知道什么时候该用。改描述,加上"当用户询问 XXX 时使用此工具"。

再看系统提示词。系统提示词里要明确告诉模型"你有以下工具可用,遇到需要实时数据的问题必须调用工具,不要凭记忆回答"。这句话很关键,能显著提升工具调用率。

还不行的话,检查模型的 function calling 能力。有些小模型或者老模型对 function calling 支持不好,换模型试试。我实测下来,通义的 qwen-plus、qwen-max 在工具调用上表现稳定,一些开源小模型就差很多。

5.2 工具参数填错怎么处理

模型填错参数是常态,尤其是枚举值和日期格式。我的处理是在工具执行前做一层参数校验,校验失败不直接报错,而是把"参数 X 的值 Y 不合法,可选值是 Z"作为工具结果返回给模型,让它重新填。这样模型通常第二次就能填对。

日期格式统一用 ISO 8601,在工具描述里明确写"日期格式:yyyy-MM-dd"。别指望模型自己猜格式,一定要写死。

5.3 流式输出中断的问题

流式返回时,如果工具调用和文本输出混在一起,前端处理起来很麻烦。我的做法是,工具调用阶段不推送内容给前端,只推送一个"正在思考"的状态,等最终文本生成时才开始流式推送。这样前端逻辑简单,用户体验也清晰。

还有一个坑是 SSE 连接的超时。Nginx 默认 60 秒超时,Agent 推理如果超过这个时间连接就断了。解决办法是在 Nginx 配置里把proxy_read_timeout调大,或者定期发送心跳包。我两个都做了,双保险。

5.4 常见问题速查表

问题现象可能原因排查方向解决方案
模型不调工具描述模糊/提示词缺失检查工具 description 和系统提示词补充使用场景说明,强化提示词约束
工具参数错误schema 不清晰/枚举未列检查生成的 JSON Schema明确参数格式,枚举值写全
循环不终止无迭代上限/工具返回异常检查 max-iterations 配置设上限,异常作为结果返回
上下文超限历史消息过长检查消息列表长度裁剪历史,做摘要压缩
流式中断网关超时检查 Nginx/网关配置调大超时,加心跳
权限穿透工具绕过 Security检查工具数据访问方式统一走 Mapper,加权限校验
租户串数据手写 SQL 未带租户条件检查工具内 SQL禁用裸 SQL,走 MyBatis-Plus
序列化报错消息类型信息丢失检查 Jackson 配置加 @JsonTypeInfo

5.5 几个独家避坑技巧

技巧一:给工具加"试运行"模式。开发阶段,我加了一个开关,打开后工具不真正执行,只打印会被调用的工具和参数。这样调试 Agent 逻辑时不用真的查数据库,速度快很多,也能快速发现模型调用意图对不对。

技巧二:token 消耗要监控。Agent 循环调用模型,token 消耗是普通对话的好几倍。我在ChatResponse里统计了每次调用的 token 数,累加后记录到日志和监控。上线前一定要估算成本,我见过一个项目因为 Agent 死循环,一天烧掉几百块的。

技巧三:工具粒度要适中。工具太细,模型要调很多次才能完成任务,慢且贵;工具太粗,模型不好判断该不该调。我的经验是,一个工具对应一个明确的业务动作,比如"查订单""取消订单""改地址"分开,而不是搞一个"订单管理"大工具。

技巧四:系统提示词要版本化。提示词改动对 Agent 行为影响巨大,我把它存在数据库里,带版本号,可以随时回滚。别硬编码在代码里,改一次发一次版,太痛苦。

6. 性能优化与扩展方向

6.1 响应速度的优化手段

Agent 的响应速度是体验的关键。我做了几件事。

工具结果缓存:有些工具查询的数据变化不频繁,比如"查商品分类",可以缓存几分钟。我在ToolRegistry里给工具加了cacheable和cacheTtl属性,命中缓存直接返回,省一次数据库查询。

模型调用并行化:如果一轮里有多个独立的工具调用,并行执行。用CompletableFuture.allOf()等待全部完成,总耗时取决于最慢的那个,而不是累加。

首字延迟优化:流式模式下,系统提示词和工具定义这些固定内容,可以用模型的 prompt caching 能力缓存起来,减少首字延迟。通义和 OpenAI 都支持这个,能省不少时间。

预热:应用启动后,主动调一次模型,把连接池、DNS 解析这些预热好,避免第一个用户请求特别慢。

6.2 多 Agent 协作的扩展

单 Agent 能做的事情有限,复杂任务需要多 Agent 协作。我在现有架构上做了扩展,支持"主管 Agent + 专家 Agent"的模式。

主管 Agent 负责理解任务、拆解子任务、分派给专家 Agent。专家 Agent 各自有专属的工具集,比如订单 Agent 只有订单相关工具,售后 Agent 只有售后工具。主管 Agent 通过一个特殊的"调用专家"工具来分派任务。

这个模式的好处是,每个专家 Agent 的提示词和工具集都很聚焦,调用准确率高。坏处是链路变长,延迟增加。我的经验是,任务确实复杂、需要多领域知识时才用多 Agent,简单任务单 Agent 就够了,别为了炫技上多 Agent。

6.3 后续可以怎么扩展

一个方向是接入更多模型。现在支持三家,后面可以加更多,接口抽象已经做好了,加一个实现类就行。

另一个方向是工具的市场化。把工具注册表做成可插拔的,不同项目可以按需引入不同的工具包。比如电商项目引入电商工具包,OA 项目引入 OA 工具包。这个用 Spring 的自动配置就能实现。

还有就是评估体系。Agent 好不好用,得有数据说话。我在做的一个事情是,记录每次 Agent 执行的完整轨迹——用户问了什么、调了哪些工具、最终回答是什么、用户有没有追问。这些数据积累起来,可以用来评估 Agent 效果,也能用来做提示词和工具的迭代优化。

我个人在实际操作中的体会是,Agent 集成这件事,技术难度其实没有想象中那么高,难的是工程化和细节打磨。模型调用、工具注册这些核心逻辑,几天就能跑通,但要让它在生产环境稳定运行、权限不出问题、成本可控、体验流畅,需要大量的细节处理。上面讲的这些坑,每一个我都真实踩过,希望你看完能少走点弯路。最后再分享一个小技巧:上线前一定要做一轮"对抗测试",故意问一些刁钻的问题、诱导模型越权、让它陷入循环,看看系统的边界在哪里。这比任何单元测试都管用。

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

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

立即咨询