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 把数字参数写成字符串、把必填参数漏掉、把枚举值写错的情况。如果工具内部不做校验,轻则报错,重则写脏数据。
校验要分三层:
- 类型校验:参数类型对不对,数字是不是数字。
- 范围校验:数值在不在合理区间,字符串长度超没超。
- 业务校验:比如查询的订单 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 的行为不像传统代码那样确定,出问题时光看结果根本猜不出中间发生了什么,只有完整日志才能还原现场。这个日志平时看着冗余,出问题时就是救命稻草。