☰
Spring Boot后台接入多Agent:RuoYi集成AgentScope Harness实战
2026/10/22 10:45:09 网站建设 项目流程

如果你正维护一套 Java 技术栈的运营后台,又突然被要求“给系统接上 AI,最好还能跑多 Agent 协同”,大概率会和我遇到同样的问题:RuoYi-Vue-Plus 这类 Spring Boot 管理平台,权限、菜单、代码生成都很顺手,但真要承载 Agent 编排、LLM 调用、会话记忆、任务流调度这些能力时,靠硬编码和简单 HTTP 调用会很快失控。我这段时间做的核心工作,就是想办法把 AgentScope Harness 装进 RuoYi-Vue-Plus,让整套系统既能保留 Java 后端的稳定事务能力,又能借助 Harness 拿到真正的多 Agent 运行能力,形成一套“纯 Java 业务平台 + 外部 Agent 运行时”的架构。下面把选型思路、对接细节、绕过的圈子和最后沉淀下来的方案完整记录下来。

1. Java 后台接入 AI,卡点不在模型,而在编排层

先说一个很多人刚开始容易误解的点:给 Java 后台接入 AI,“接入大模型”其实是最容易的一步。现在各家模型服务都提供标准 OpenAI 风格接口,Java 用 Spring 的 RestClient 也能轻松拼出对话请求。真正麻烦的是多 Agent 场景。

1.1 管理后台的 Java 底色决定了集成路线

RuoYi-Vue-Plus 是典型的企业级后台脚手架,底层是 Spring Boot,前端 Vue,带权限、部门、角色、菜单、定时任务、代码生成这一整套。这类平台的特点是:业务都是“表单 + 列表 + 审批流”,数据敏感,权限体系复杂,通常还要迁就老数据库。你不可能因为它要接 AI,就把核心业务拆成微服务,也不可能在 JVM 里塞一个 Python Agent 运行时。

我刚接到需求时也想过,直接在系统里写一个 Java 工具类,把大模型调用、上下文拼接、多轮对话都封装进去。初期做个聊天机器人没问题,但做到后面问题就来了:

  • 多 Agent 协作时,每个 Agent 需要独立的系统提示词、工具列表、上下文窗口,这些配置放 Java 里得靠硬编码。
  • Agent 之间的调用链、重试、暂停、恢复,靠手写状态机极其痛苦。
  • 长任务执行过程中需要逐步输出过程信息,HTTP 请求很难把“半成品结果”逐步推给前端。
  • 日志审计、Token 统计、任务轨迹,每一个都值得造轮子,但又都不是业务核心。

所以我最后定的路线很清晰:Java 端继续只做业务编排、权限管控、数据持久化,真正的 Agent 执行部分交给独立部署的 AgentScope Harness。

1.2 多 Agent 运行时的核心诉求

可以把 Harness 理解成一个“Agent 运行车间”。它负责把多个 Agent 组织起来,执行一个完整任务,比如“客服问答、工单分类、售后建议生成”这种链路。一个任务包含:

  • Agent 注册与模型配置。
  • Agent 之间的调用关系,比如顺序执行、分支判断、并行调用。
  • 每轮调用的工具集合。
  • 任务状态管理,比如 pending、running、success、failed、cancelled。
  • 过程日志和 Token 计费数据。

这些跑在 Harness 内,天然比你在 Java 里手搓框架要成熟。而 RuoYi-Vue-Plus 作为“业务前台”,职责则是接收用户请求、校验权限、记录任务主数据、展示执行状态和结果。

把这两者分开,是集成方案的第一条原则。

2. 远程 Harness:我给 Agent 单独开了个“运行车间”

选型阶段最纠结的是连接方式。我梳理了三种主流方案,各有优缺点,最终选择的是远程 HTTP 模式。

2.1 方案对比:进程内包、远程 HTTP、消息队列

我当时做了个简单对比表,基本能说明问题:

方案集成成本隔离性流式支持运维复杂度适用场景
进程内嵌 Python 引擎低,但 JVM 内混运行不现实差一般极高不推荐
JVM 内自写 Agent 编排中高,功能全靠自己造中可实现中仅限极简对话
远程 HTTP 调用 Harness中,契约清晰好好用中低推荐,我采用的方案
消息队列 + Harness 消费者较高,需要 MQ 运维好难做流式高适合大量异步批任务

进程内嵌这个想法第一时间被我放弃。AgentScope Harness 本身是一套独立运行时,有自己的进程生命周期、并发调度、模型连接池和日志体系。你不可能把一个多进程、多线程、还带长连接的服务塞进 Tomcat 容器里,更别说 Java 和 Python 之间的 GIL、线程模型、内存回收机制完全不同,混在一起出事很难定位。与其强行融合,不如把它当作一个独立的“引擎节点”,Java 只是它的调度方。

2.2 我选的远程 Harness 边界与控制流

架构落地后,边界是这样的:

  • RuoYi-Vue-Plus 负责:用户认证、租户隔离、任务单管理、前端接口、结果归档。
  • AgentScope Harness 负责:Agent 编排、模型调用、工具执行、流式事件输出、任务生命周期管理。

控制流上,我设计了两个方向:

同步控制流用于创建任务和查询状态。Java 端向 Harness 的 HTTP 接口提交一个任务请求,Harness 返回一个全局唯一的任务 ID,Java 端把任务 ID 和业务单号绑定,存到本地业务表里。

异步事件流用于状态和过程消息推送。Harness 在执行过程中会向外推送任务状态变更和 Agent 步骤日志,Java 端通过 Webhook 回调或者拉取接口,把最新状态同步进 RuoYi 的任务表,再通过 WebSocket 或 SSE 推给前端用户。

这么做的直接好处是,RuoYi 的业务事务与 Harness 的执行过程不再强耦合。哪怕 Harness 重启,Java 端早就把任务记录持久化了,恢复后可以继续查询状态,不至于丢数据。

2.3 HTTP 契约与代码层封装

远程连接最忌讳把调用代码散落在 Controller 里。我在 RuoYi 的服务层单独封装了一个 AgentTaskClient,所有与 Harness 的交互都走这一个组件。

代码大概长这样:

@Service public class AgentTaskClient { @Value("${agent.harness.base-url}") private String baseUrl; private final RestTemplate restTemplate; public AgentTaskClient(RestTemplate restTemplate) { this.restTemplate = restTemplate; } public AgentSubmitResult submit(AgentTaskRequest request) { String url = baseUrl + "/api/v1/tasks"; HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity<AgentTaskRequest> entity = new HttpEntity<>(request, headers); ResponseEntity<AgentSubmitResult> response = restTemplate.exchange(url, HttpMethod.POST, entity, AgentSubmitResult.class); return response.getBody(); } public AgentTaskStatus getStatus(String taskId) { String url = baseUrl + "/api/v1/tasks/" + taskId + "/status"; return restTemplate.getForObject(url, AgentTaskStatus.class); } public void cancel(String taskId) { String url = baseUrl + "/api/v1/tasks/" + taskId + "/cancel"; restTemplate.postForObject(url, null, Void.class); } }

封装之后,业务代码里面只需要关心 AgentTaskRequest 和 AgentTaskStatus 这两个对象,不需要关心 HTTP 细节。

这里有个特别容易出问题的点:请求和响应里的字段命名。Java 端默认是驼峰命名,而 Harness 的协议可能使用蛇形命名。如果两边不约定清楚,就会出现字段全程为 null,但任务其实运行正常的情况。我的做法是,在 AgentTaskRequest 上用 JSON 注解显式声明字段名,比如:

@Data public class AgentTaskRequest { @JsonProperty("agent_key") private String agentKey; @JsonProperty("session_id") private String sessionId; @JsonProperty("user_input") private String userInput; }

宁可写全,不要靠默认序列化推断,这套契约一旦定下来,后面扩展也轻松。

3. 把 Agent 任务当成一种新的“业务数据”来设计

很多人在做集成的时候,只想着把任务提交过去、拿到结果就完事,结果就是任务记录在数据库里没有任何痕迹,出了问题根本追溯不了。正确的做法是:把 Agent 任务看成一种和“订单”“工单”同等级别的业务数据,认真设计表结构和状态流转。

3.1 任务表字段设计与通用结构

我在 RuoYi 里新增了一张 agent_task 表,字段比普通业务表稍微多几个,核心字段如下:

  • task_id:Harness 返回的任务唯一 ID。
  • biz_code:业务单号,比如工单号或咨询单号。
  • agent_key:使用哪个 Agent 编排流程。
  • input_payload:用户提交的原始请求,JSON 格式存储。
  • output_payload:最终结果,JSON 格式存储。
  • status:任务状态,包括 pending、running、success、failed、cancelled。
  • current_progress:当前执行进度,比如 0 到 100。
  • trace_id:链路追踪 ID,把 RuoYi 日志和 Harness 日志串联起来。
  • user_id 和 tenant_id:权限隔离字段,谁发起的任务,属于哪个租户。
  • total_tokens:Token 总消耗,用于后续计费和成本分析。
  • error_msg:失败时的错误摘要。
  • create_time、update_time:常规审计字段。

建表 SQL 可以按照 RuoYi 的规范写,重点是最起码要给 status、tenant_id、user_id 建索引。因为列表页需要频繁查询用户的任务,如果没索引,数据量上来以后查询会非常难受。

3.2 租户与权限透传

RuoYi-Vue-Plus 自带租户体系,但 Harness 那边默认是不了解你的租户模型的。你不把租户信息传过去,就会出现一个租户查到另一个租户任务的风险。我的做法是在提交任务时,把 tenant_id 塞进请求头,由 Harness 侧的网关层统一校验,同时也在业务表里存一份,双保险。

HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.set("X-Tenant-Id", tenantId); headers.set("X-User-Id", userId);

这里有个细节,RuoYi 的权限拦截器一般只拦截 /admin 或者特定路径,如果你给 Harness 单独设立了网关路由,一定要确保网关层也校验了租户头,不能只依赖业务表。权限这种事,多做一层永远比少做一层安全。

3.3 状态流转模型与回写

我在 Service 层做了一个状态机,避免各处随意更新状态:

  • 提交后立刻写入 pending。
  • 收到 running 回调或轮询到 running,改为 running,并记录开始时间。
  • 收到成功回调,改为 success,并写入 output_payload、total_tokens。
  • 收到失败回调,改为 failed,并写入 error_msg。
  • 用户主动取消且 Harness 确认取消,改为 cancelled。

为什么要走状态机?因为实际运行中会收到重复回调,比如网络重发导致同样的成功事件推了两次,如果你不是幂等处理,状态就可能从 success 被错误覆盖成 running。我通过 task_id 更新,并且只允许单向流转:cancelled 和 success 是终态,其他状态不能再变更。这一条极其重要。

4. 流式输出:RuoYi 前端的连接重建与消息推送

多 Agent 任务和普通接口有个很大的体验差异:执行时间可能长达几十秒甚至几分钟。如果让用户盯着空白页面等最终结果,体验非常差。所以流式输出是必须做的。

4.1 SSE 要过的三关

我选择用 SSE(Server-Sent Events)而不是 WebSocket,主要是因为 SSE 是单向文本流,实现简单,适合这种“服务端往浏览器推送执行过程”的场景。但 SSE 在 RuoYi 里要过三个关卡:

第一关是 Spring MVC 的支持。RuoYi 的 Controller 里需要返回 SseEmitter,并且设置一个合理的超时时间,比如 5 分钟。

@PostMapping("/agent/stream") public SseEmitter stream(@RequestBody AgentStreamRequest request) { SseEmitter emitter = new SseEmitter(300000L); agentTaskService.executeAndPush(request, emitter); return emitter; }

第二关是安全过滤链。RuoYi 的 Spring Security 配置一般会拦截所有请求,如果你新加的 SSE 接口没有加入白名单,就会被权限过滤器挡住。这里要看你的业务要求,如果允许登录用户访问,就不需要加白名单,但要在 Security 配置中放行该接口的匿名访问或者确保携带 Token。我的建议是保持登录鉴权,使用 Header 传入 Token,这样安全性和长连接可以共存。

第三关是代理服务器缓冲。如果你前端访问域名走的是 Nginx,Nginx 默认会缓冲 SSE 响应,直到响应结束才发给浏览器,这样你就收不到过程事件。需要在 Nginx 配置里关掉该接口的缓冲:

location /prod-api/agent/stream { proxy_set_header Connection ''; proxy_http_version 1.1; proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; }

这一条非常关键,我第一次没设置 proxy_buffering off,前端永远只能收到最后一次消息,还以为是自己代码写错了。

4.2 断线重连与消息补推

前端 EventSource 自带断线重连,但默认行为比较简单。我遇到的情况是:Harness 执行时间特别长,用户中间切了网络,重新连接后,之前的过程消息已经丢了,前端只会显示空白一段时间。

解决办法是在 RuoYi 后端维护一份任务过程消息的缓存,比如存 Redis 列表。SSE 断线重连时,前端带着 lastEventId 重新建立连接,后端从 Redis 里把 lastEventId 之后的消息一次性补推过去。

List<String> missedEvents = redisTemplate.opsForList().range("agent:events:" + taskId, lastIndex, -1); for (String event : missedEvents) { emitter.send(event); }

省事的同时也要注意清理策略,任务结束之后把 Redis 里的 key 设置过期时间,比如 24 小时,防止缓存堆积。

4.3 前端侧怎么接

前端我用的是原生 EventSource,原因是不需要额外引入依赖。RuoYi-Vue-Plus 的请求封装基于 axios,但 SSE 不能直接用 axios 的普通请求方式,需要单独处理。

const es = new EventSource(`/prod-api/agent/stream?taskId=${taskId}`, { withCredentials: true }) es.onmessage = (event) => { const data = JSON.parse(event.data) if (data.type === 'progress') { updateProgress(data.value) } else if (data.type === 'tool_call') { updateToolLog(data.content) } else if (data.type === 'done') { es.close() } } es.onerror = () => { // EventSource 会自动重连,不需要主动关闭 }

需要注意,前端如果放在若依的 iframe 或 Tab 页里,切换 Tab 时 SSE 连接不会被自动关闭,会产生无用连接。我在页面销毁钩子里手动调用了 es.close(),避免对后端造成连接堆积。

5. 集成踩坑实录:三次排查,把心态都查稳了

任何集成项目都躲不开“看起来哪里都对,但就是跑不通”的时期。我记录三个印象最深的坑,每个都是完整排查链路,希望能帮你少走弯路。

5.1 中文乱码和序列化字段不一致

第一次跑通提交任务时,Harness 返回的 Agent 回答里中文全部变成乱码,同时 input_payload 里的字段丢失。那天我排查了三小时,最后发现是两层问题叠加。

第一层是 HTTP 请求头缺少编码声明。RuoYi 的 RestTemplate 底层默认使用 ISO-8859-1,如果你没有显式设置 UTF-8,中文参数在传输过程中就坏掉了。排查方法很简单:先看服务端日志里收到的原始字符串,再对比数据库中保存的输入,发现从 Controller 传到 Service 还是正常,从 Service 发给 Harness 之后才变乱码,定位到 RestTemplate 的编码。

restTemplate.getMessageConverters().forEach(converter -> { if (converter instanceof StringHttpMessageConverter) { ((StringHttpMessageConverter) converter).setDefaultCharset(StandardCharsets.UTF_8); } });

第二层是字段命名不一致。Harness 的接口文档里写的是 agent_key,我 Java 端默认序列化成了 agentKey,服务端不识别,导致 Agent 配置一直用默认值。这个问题靠打印请求 JSON 才发现,所以在 2.3 节我特意说了 JsonProperty 的重要性。

5.2 反向代理超时:任务还在跑,前端却收到 504

第二次踩坑更隐蔽。任务是能提交了,但执行时间一旦超过 60 秒,前端就收到 504,页面提示网关超时。一开始我以为是自己代码超时,但看 Harness 日志发现任务还在正常执行,最终结果也生成了,只是 ruoyi 前端等不到回调。

排查链路是逐步确认的:

  1. 先直接访问后端接口,耗时 90 秒,接口正常返回,说明 Spring Boot 没问题。
  2. 再走前端域名访问,60 秒整返回 504,基本断定是 Nginx 层的 proxy_read_timeout。
  3. 查看 Nginx 配置确认默认 60 秒,调整为 3600 秒后恢复正常。

这里还要注意,如果 Harness 执行时间真的会超过几分钟,更好的做法是把“同步等待”改成“异步任务 + 轮询”。因为浏览器、Nginx、中间代理每一层都有超时,你不可能把所有超时时间都调到无限长。推荐模式是:提交任务后立即返回 taskId,前端通过 SSE 或轮询获取进度,不要在 HTTP 请求里同步等最终结果。

5.3 孤儿任务进程与优雅停机

第三个坑是运维层面的。起初我直接通过进程脚本拉起 Harness,但 RuoYi 应用重启的时候,Harness 并不会跟着自动关闭,也不会自动清理己方超时任务。结果就是服务器上残留好几个孤儿 Harness 进程,占用 GPU 显存和内存,有一个还在持续调用模型,产生了不必要的费用。

后来我在 RuoYi 的服务关闭钩子里加了一个清理逻辑:应用停机时,调用 Harness 的管理接口,把当前租户还在 running 的任务标记为 interrupted,并关闭进程。同时配合进程守护,保证 Harness 异常退出后能自动拉起。

@PreDestroy public void shutdown() { List<String> runningTasks = agentTaskMapper.selectRunningTaskIds(); for (String taskId : runningTasks) { agentTaskClient.cancel(taskId); } }

这一点如果忽略了,长时间运行后会因为资源泄漏导致系统越来越卡,而且很难排查。

6. 稳定跑起来之后,我对这套集成的一些个人总结

项目跑稳之后,我复盘过一轮,觉得有几个判断是值得坚持的。

第一个判断是隔离而不是融合。Java 平台和 Harness 各管各的,通过 HTTP 契约协作,虽然运行时会有一次网络开销,但换来的维护性是值得的。RuoYi-Vue-Plus 的升级、Harness 的升级,都可以各自独立进行,不受对方版本影响。

第二个判断是任务数据必须落在自己的库里。很多人觉得 Agent 执行结果是临时的,直接返回给前端就行,但业务平台不一样,每个任务都要有审计、追溯、统计。把 Agent 任务当成正式业务表来设计,后续做报表、做对账、做成本分析都会非常轻松。

第三个判断是流式和异步必须一开始就做。如果第一版用同步 HTTP 写死,后面再改成 SSE 会动到接口协议、前端代码、权限配置,改动面很大。我就因为前期偷懒,后面为了兼容老接口多写了一个同步包装层,属于典型的“省小钱花大钱”。

最后分享一个自己用得很顺手的小技巧:在任务表里增加一个 trace_id,每次提交 Harness 任务时生成一个 UUID,RuoYi 日志里打印业务单号、trace_id、task_id 三者对应关系。出问题的时候,用 trace_id 就能把用户请求、后端处理、Harness 执行三段日志串成一条完整链路。这个习惯帮我省了至少十次排查时间,强烈建议集成时从一开始就加上。

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

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

立即咨询