☰
纯Java实现Agent编排:RuoYi-Vue-Plus集成AI引擎实战
2026/10/13 8:18:29 网站建设 项目流程

1. 为什么要在 RuoYi-Vue-Plus 里塞进一个 AI 编排引擎

1.1 从"后台管理系统"到"AI 应用底座"的定位转变

RuoYi-Vue-Plus 这套脚手架在国内 Java 圈子里算是老熟人了。多租户、数据权限、代码生成、工作流、分布式锁、缓存管理,基本上一个中后台系统该有的轮子它都给你备齐了。很多团队拿它当起点,两三天就能搭出一个能跑的业务骨架。但这两年有个明显的变化:越来越多的需求不再是"增删改查加报表",而是"能不能让系统自己判断一下""能不能接个大模型帮我处理这批工单""能不能让几个 AI 角色协作把这份合同审一遍"。

这些需求本质上不是传统 CRUD 能覆盖的,它们要的是Agent 编排能力——也就是让多个具备不同职责的智能体(Agent)按照一定流程协作,中间还能调用工具、读写数据库、做条件分支。AgentScope 就是干这个的,它提供了一套 Agent 抽象、消息传递机制和工具调用框架。问题是,AgentScope 的原生生态偏 Python,而 RuoYi-Vue-Plus 是纯 Java 的。硬要在一个 Java 后端里跑 Python 服务,运维复杂度直接翻倍:多一套运行时、多一套依赖管理、多一条网络链路、多一堆故障点。

所以"把 AgentScope Harness 装进 RuoYi-Vue-Plus"这件事的核心诉求就很清楚了:在不引入 Python 运行时的前提下,让 Java 后端具备 Agent 编排能力,并且复用 RuoYi-Vue-Plus 已有的租户、权限、缓存、日志体系。Harness 在这里的角色可以理解为一层"适配骨架"——它不重写 AgentScope 的编排思想,而是把这套思想用 Java 的线程模型、Spring 容器和现有基础设施重新落地。

适合读这篇的人大概有三类:一是手上已经有 RuoYi-Vue-Plus 项目、想加 AI 能力但不想动架构的 Java 后端;二是想理解 Agent 编排在 Java 侧到底怎么落地、有哪些坑的架构同学;三是评估"到底要不要上 Python 微服务"的技术负责人。我会把选型逻辑、核心实现、踩过的坑都摊开讲,代码和配置尽量给到能直接抄的程度。

1.2 纯 Java 路线 vs Python 微服务路线,到底怎么选

先把选型这件事说透,因为这是整个项目里最容易拍脑袋决定、后面又最后悔的地方。我见过两种典型做法,各有各的代价。

第一种是Python 微服务路线:AgentScope 跑在一个独立的 Python 服务里,Java 侧通过 HTTP 或消息队列调用。好处是能直接用 AgentScope 的完整能力,社区更新也能跟上。坏处是:你得维护两套技术栈,Python 服务的部署、扩缩容、日志采集、链路追踪都要单独搞一套;跨语言调用还有序列化损耗和超时问题;最要命的是权限和租户上下文传递——Java 侧的登录态、租户 ID、数据权限规则,要完整传到 Python 侧再传回来,中间任何一环丢了都会出数据越权的事故。

第二种就是纯 Java 路线:把 Agent 编排的核心逻辑用 Java 重写,Harness 作为适配层,把 AgentScope 的编排语义映射到 Java 的 ExecutorService、CompletableFuture、Spring 事件机制上。代价是要自己实现一部分编排逻辑,AgentScope 的一些高级特性可能覆盖不到。但换来的是:单一技术栈、单一部署单元、租户和权限上下文天然贯通、调试链路短。

我最终选的是纯 Java 路线,判断依据其实就一条:这个系统的 AI 能力是"嵌在业务里"的,不是"独立对外服务"的。Agent 要读的是当前租户的数据,要遵守当前用户的数据权限,要写回业务表。这种强耦合场景下,跨语言边界的成本远大于收益。如果哪天 AI 能力要独立成对外 API 服务,再拆出去也不迟。

对比维度纯 Java 路线Python 微服务路线
技术栈数量1 套2 套
租户/权限上下文传递天然贯通需显式序列化传递,易丢失
部署复杂度低,随主应用发布高,独立部署与扩缩容
编排能力完整度覆盖常用场景完整
调试链路单进程内,短跨进程,长
适合场景AI 嵌入业务流AI 独立对外服务

提示:选型不要只看"能力完整度",要看"能力用在哪"。嵌入业务流的 AI,上下文一致性比功能丰富度重要得多。

2. Harness 适配层的整体设计思路

2.1 核心抽象:把 Agent 编排拆成四个可替换的部件

AgentScope 的编排思想说穿了不复杂:有若干 Agent,每个 Agent 有自己的角色设定和可用工具;Agent 之间通过消息通信;有一个调度者决定谁先说话、谁后说话、什么时候结束。Harness 要做的,就是把这套东西用 Java 表达出来,并且每个部件都能替换。

我把它拆成了四个核心抽象:

  • Agent 抽象:一个 Agent 就是一个可执行单元,输入是消息列表,输出是一条新消息。它内部可能调用大模型,也可能只是做规则判断。
  • Message 抽象:Agent 之间传递的消息,包含角色、内容、元数据。元数据里塞租户 ID、会话 ID、追踪 ID。
  • Tool 抽象:Agent 能调用的工具,本质是一个带参数校验的 Java 方法。工具执行要受当前租户和数据权限约束。
  • Orchestrator 抽象:编排器,决定消息在 Agent 之间的流转顺序。最简单的是一条直线,复杂点的是带条件分支的图。

为什么这么拆?因为这四个部件的变更频率完全不同。Agent 的提示词天天改,Tool 随着业务加,Message 结构基本稳定,Orchestrator 的流程偶尔调整。拆开之后,改提示词不用动编排逻辑,加工具不用动 Agent 定义,各改各的,互不干扰。

2.2 为什么用 Spring 容器管理 Agent 生命周期

一个很自然的疑问是:Agent 到底该 new 出来还是交给 Spring 管?我的答案是交给 Spring,而且这个决定影响很大。

原因有三。第一,Agent 依赖大模型客户端、依赖工具、依赖缓存,这些都是 Spring Bean,交给容器注入最省事。第二,Agent 可能需要被多个业务流程复用,单例管理能避免重复初始化。第三,也是最重要的——Agent 的执行需要感知租户上下文,而租户上下文在 RuoYi-Vue-Plus 里是通过 ThreadLocal 和拦截器维护的,只有走 Spring 的调用链才能保证上下文正确传递。

具体做法是给每个 Agent 定义一个 Bean,用@Component标注,构造器注入它需要的依赖。Orchestrator 也是一个 Bean,注入它要编排的 Agent 列表。这样整个编排链路都在 Spring 容器里,租户上下文、事务、日志切面都能正常生效。

@Component public class ContractReviewAgent implements Agent { private final ChatClient chatClient; private final ToolRegistry toolRegistry; public ContractReviewAgent(ChatClient chatClient, ToolRegistry toolRegistry) { this.chatClient = chatClient; this.toolRegistry = toolRegistry; } @Override public Message execute(List<Message> history, AgentContext context) { // 从 context 中取租户信息,构造提示词 String prompt = buildPrompt(history, context); String reply = chatClient.call(prompt); return Message.assistant(reply, context.getSessionId()); } }

2.3 消息模型设计:让上下文在 Agent 之间不丢

消息模型看着简单,其实是整个 Harness 里最容易埋雷的地方。我一开始图省事,Message 里只放了 role 和 content,结果跑多轮编排的时候发现:租户 ID 丢了、会话 ID 对不上、工具调用的中间结果没法回溯。

后来重新设计,Message 分成三层:

  • 基础层:role(system/user/assistant/tool)、content(文本内容)、timestamp。
  • 上下文层:tenantId、userId、sessionId、traceId。这一层保证每个 Agent 执行时都能拿到正确的上下文。
  • 扩展层:一个 Map,放工具调用参数、中间结果、自定义标记。

上下文层为什么必须跟着消息走,而不是从 ThreadLocal 现取?因为编排过程中可能有异步执行——某个 Agent 在独立线程里跑,ThreadLocal 就断了。把上下文固化进 Message,跟着消息流转,才能保证异步场景下不丢。

public class Message { private String role; private String content; private Long tenantId; private String sessionId; private String traceId; private Map<String, Object> metadata; // 省略 getter/setter }

注意:租户 ID 一定要进 Message,不要依赖 ThreadLocal 跨线程传递。异步编排场景下 ThreadLocal 会失效,这是血泪教训。

3. 核心环节的落地实现

3.1 大模型客户端的接入与统一封装

大模型客户端这块,不同厂商的 API 差异不小,但核心就三件事:发请求、收响应、处理异常。我建议在 Harness 里做一层统一封装,把厂商差异挡在外面,Agent 只面向统一接口编程。

封装接口大概长这样:

public interface ChatClient { String call(String prompt); String call(List<Message> messages); String callWithTools(List<Message> messages, List<Tool> tools); }

实现类里处理具体的 HTTP 调用、鉴权、重试、超时。这里有几个参数必须认真调:

  • 超时时间:大模型响应慢是常态,连接超时设 5 秒,读取超时至少 60 秒。设太短会频繁超时,设太长会拖垮线程池。
  • 重试策略:只对网络类异常重试,对参数错误、鉴权失败不要重试,重试次数 2 次足够,且要退避。
  • 并发限制:大模型接口通常有 QPS 限制,用信号量控制并发,避免把对方打挂。
@Component public class OpenAiCompatibleClient implements ChatClient { private final Semaphore semaphore = new Semaphore(10); private final RestClient restClient; @Override public String call(List<Message> messages) { try { semaphore.acquire(); // 构造请求体,发送,解析响应 return doCall(messages); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new AgentException("调用被中断", e); } finally { semaphore.release(); } } }

信号量设多少合适?我的经验是按大模型接口的 QPS 上限的 70% 来设。比如对方允许 15 QPS,那就设 10,留点余量应对突发。设满了容易触发限流,设少了吞吐上不去。

3.2 工具调用的权限校验与租户隔离

工具调用是 Harness 里最需要小心的部分,因为 Agent 调工具本质上是在"替用户执行操作"。如果权限校验没做好,Agent 可能读到别的租户的数据,或者执行了当前用户无权执行的操作。

我的做法是在 Tool 抽象里强制带上权限校验钩子:

public abstract class BaseTool implements Tool { @Override public Object invoke(Map<String, Object> params, AgentContext context) { // 第一步:校验租户 checkTenant(context.getTenantId()); // 第二步:校验权限 checkPermission(context.getUserId(), getRequiredPermission()); // 第三步:参数校验 validateParams(params); // 第四步:执行 return doInvoke(params, context); } protected abstract Object doInvoke(Map<String, Object> params, AgentContext context); protected abstract String getRequiredPermission(); }

这个模板方法模式的好处是,权限校验逻辑写一次,所有工具自动继承。新增工具的时候,开发者只需要关心业务逻辑,不用重复写校验代码,也就不会漏。

租户隔离这块,工具内部访问数据库时,一定要走 RuoYi-Vue-Plus 已有的数据权限机制,不要自己拼 SQL。因为框架的数据权限拦截器会自动给 SQL 加上租户条件,你手写的 SQL 绕过了拦截器,就等于绕过了隔离。

提示:工具执行前务必校验租户和权限,这是 AI 应用最容易出安全事故的地方。Agent 不会"自觉"遵守权限,得靠代码强制。

3.3 编排器的实现:从线性流程到条件分支

编排器决定了 Agent 之间的流转。最简单的编排是一条直线:Agent A 输出给 Agent B,B 输出给 C,C 结束。但实际业务里经常需要条件分支——比如合同审查,如果金额小于阈值就走快速通道,大于阈值就走详细审查。

我用一个轻量的图结构来表达编排:

public class Orchestrator { private final Map<String, Agent> agents; private final Map<String, Function<Message, String>> routers; public Message run(String startAgent, List<Message> input, AgentContext context) { String current = startAgent; List<Message> history = new ArrayList<>(input); while (current != null) { Agent agent = agents.get(current); Message output = agent.execute(history, context); history.add(output); // 根据路由函数决定下一个 Agent Function<Message, String> router = routers.get(current); current = router == null ? null : router.apply(output); } return history.get(history.size() - 1); } }

路由函数是关键。它接收当前 Agent 的输出,返回下一个 Agent 的名字,返回 null 表示结束。这样条件分支就变成了一个普通的 Java 函数,想怎么判断就怎么判断,比配置文件灵活得多。

但这里有个坑:循环编排一定要设最大轮次。我见过一个案例,两个 Agent 互相"客气",A 说"请你确认",B 说"请你先确认",无限循环把线程池占满了。加一个最大轮次限制,比如 20 轮,超了就强制结束并记录告警。

3.4 异步执行与线程池配置

Agent 执行是 IO 密集型任务——大部分时间在等大模型响应。这种场景下,线程池配置和 CPU 密集型完全不同。

核心参数这么定:

  • 核心线程数:按并发请求量估,一般 10 到 20 起步。
  • 最大线程数:核心数的 2 到 3 倍,应对突发。
  • 队列容量:不要用无界队列,用有界队列,比如 200,防止任务堆积把内存撑爆。
  • 拒绝策略:用 CallerRunsPolicy,让提交任务的线程自己执行,起到背压作用。
@Bean("agentExecutor") public ThreadPoolExecutor agentExecutor() { return new ThreadPoolExecutor( 16, 48, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(200), new ThreadFactoryBuilder().setNameFormat("agent-pool-%d").build(), new ThreadPoolExecutor.CallerRunsPolicy() ); }

为什么用 CallerRunsPolicy 而不是直接丢弃?因为 Agent 任务通常对应一个用户请求,丢弃了用户就看不到结果。让提交线程自己跑,虽然会阻塞,但至少任务不丢,而且能自然形成背压,逼着上游降低提交速度。

4. 踩过的坑与排查实录

4.1 租户上下文丢失的三种典型场景

这是我在这个项目里踩得最深的坑,前后遇到三次,每次表现都不一样。

场景一:异步线程里取不到租户 ID。Agent 在独立线程执行,ThreadLocal 里的租户信息是空的。解决办法是把租户 ID 固化进 Message 和 AgentContext,执行时从上下文取,不从 ThreadLocal 取。

场景二:工具调用时租户 ID 被覆盖。多个请求并发时,如果工具内部用了可变的静态变量存租户 ID,会互相覆盖。解决办法是租户 ID 全程作为参数传递,不用任何共享可变状态。

场景三:编排结束后清理不彻底。请求结束后没清理 ThreadLocal,线程被复用时会带上上一个请求的租户 ID。解决办法是在拦截器的 afterCompletion 里强制清理。

问题现象根本原因解决方式
异步线程租户为空ThreadLocal 跨线程失效租户 ID 进 Message 上下文
并发时租户错乱共享可变状态被覆盖全程参数传递,禁用共享状态
请求间租户串味ThreadLocal 未清理拦截器统一清理

4.2 大模型响应超时与重试的边界

超时和重试看着简单,但边界没划清楚会出大问题。我一开始对所有异常都重试,结果参数错误也重试,白白浪费了三次调用配额。

正确的做法是分类处理:

  • 网络超时、连接被拒:可重试,退避重试 2 次。
  • HTTP 429(限流):可重试,但要等更久,退避时间加倍。
  • HTTP 400(参数错误):不重试,直接抛异常,这是代码问题。
  • HTTP 401/403(鉴权失败):不重试,配置问题,重试也没用。
private boolean shouldRetry(Exception e) { if (e instanceof SocketTimeoutException) return true; if (e instanceof HttpStatusException) { int code = ((HttpStatusException) e).getStatusCode(); return code == 429 || code >= 500; } return false; }

退避策略用指数退避,第一次等 1 秒,第二次等 2 秒,第三次等 4 秒。别用固定间隔,固定间隔在对方限流时容易雪上加霜。

4.3 工具调用参数校验的常见疏漏

Agent 生成工具调用参数时,格式不一定规范。我遇到过 Agent 把数字参数写成字符串、把必填参数漏掉、把枚举值写错的情况。如果工具内部不做校验,轻则报错,重则写脏数据。

校验要分三层:

  1. 类型校验:参数类型对不对,数字是不是数字。
  2. 范围校验:数值在不在合理区间,字符串长度超没超。
  3. 业务校验:比如查询的订单 ID 是不是当前租户的。

第三层最容易被忽略,但最重要。Agent 可能生成一个格式完全正确、但属于别的租户的 ID,这时候类型和范围校验都过得去,只有业务校验能拦住。

注意:工具参数校验不能只做格式校验,必须做业务归属校验。Agent 生成的参数"看起来对"不代表"有权访问"。

4.4 编排死循环与最大轮次保护

前面提过 Agent 互相"客气"导致死循环,这里展开说下怎么防。

第一道防线是最大轮次限制,硬性截断。第二道防线是检测重复消息——如果连续两条消息内容高度相似,判定为循环,提前结束。第三道防线是超时控制,整个编排流程设一个总超时,比如 5 分钟,超了强制结束。

public Message run(String startAgent, List<Message> input, AgentContext context) { long deadline = System.currentTimeMillis() + 300_000L; int round = 0; String current = startAgent; while (current != null) { if (++round > MAX_ROUNDS) { throw new AgentException("编排超过最大轮次"); } if (System.currentTimeMillis() > deadline) { throw new AgentException("编排超时"); } // ... 执行逻辑 } }

三道防线叠加,基本能兜住绝大多数异常情况。别嫌麻烦,生产环境里死循环一次就够你排查半天。

5. 性能调优与上线后的观察

5.1 提示词缓存与重复调用削减

上线后发现一个现象:很多请求的提示词高度相似,尤其是系统提示词部分,几乎完全一样。每次调用都重新传一遍,既浪费 token 又增加延迟。

优化思路是把提示词拆成"静态部分"和"动态部分"。静态部分(角色设定、输出格式要求)缓存起来,动态部分(用户输入、上下文)每次拼接。有些大模型接口支持提示词缓存,命中缓存的部分计费更低、响应更快。

实测下来,这个优化能把平均响应时间降 15% 到 25%,token 成本降 20% 左右。具体降多少取决于静态部分占比,占比越高效果越明显。

5.2 监控指标该看哪些

AI 应用的监控和传统应用不太一样,除了常规的 QPS、响应时间、错误率,还要盯几个特有指标:

  • Token 消耗量:按租户、按 Agent 维度统计,防止某个租户把配额用光。
  • 工具调用成功率:工具调用失败率高,说明 Agent 生成的参数有问题,或者工具本身有 bug。
  • 编排轮次分布:轮次异常高,可能有循环倾向。
  • 大模型响应时间 P99:平均值没意义,要看长尾,长尾才是用户体验的瓶颈。

这些指标我建议都打到现有的监控体系里,和业务指标放一起看。AI 出问题往往不是孤立的,经常和业务流量高峰相关。

5.3 成本控制的几个实操手段

大模型调用是要花钱的,不控制的话账单会很吓人。几个实操手段:

  • 按租户设配额:每个租户每天有 token 上限,超了就拒绝,防止单个租户拖垮整体。
  • 分级模型:简单任务用便宜的小模型,复杂任务才用大模型。判断逻辑可以放在编排器里。
  • 结果缓存:相同输入短时间内重复请求,直接返回缓存结果。
  • 精简提示词:定期 review 提示词,删掉冗余部分,能省不少 token。

配额这块要提前和业务方对齐,别等上线了才发现某个租户把额度用超了,临时加限制又影响体验。

6. 一些个人体会

这套东西从设计到上线大概花了三周,中间返工了两次,主要都返在租户上下文和权限校验上。现在回头看,如果重来一次,我会在动手写 Agent 之前,先把 Message 的上下文结构和 Tool 的权限校验模板定死,这两个是地基,地基不稳后面全是补丁。

另外有个感受挺深:纯 Java 做 Agent 编排,能力上确实不如 Python 生态丰富,但对于"AI 嵌入业务系统"这个场景,够用,而且省心。技术选型没有绝对的好坏,只有合不合适。你的系统是 Java 的,业务逻辑是 Java 的,权限体系是 Java 的,那编排逻辑也用 Java 写,链路最短,故障点最少。

最后分享一个小技巧:调试 Agent 编排的时候,把每一轮的消息和工具调用都完整打日志,包括输入输出和耗时。Agent 的行为不像传统代码那样确定,出问题时光看结果根本猜不出中间发生了什么,只有完整日志才能还原现场。这个日志平时看着冗余,出问题时就是救命稻草。

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

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

立即咨询