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(ListMessage 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 分成三层基础层rolesystem/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 MapString, Object metadata; // 省略 getter/setter }注意租户 ID 一定要进 Message不要依赖 ThreadLocal 跨线程传递。异步编排场景下 ThreadLocal 会失效这是血泪教训。3. 核心环节的落地实现3.1 大模型客户端的接入与统一封装大模型客户端这块不同厂商的 API 差异不小但核心就三件事发请求、收响应、处理异常。我建议在 Harness 里做一层统一封装把厂商差异挡在外面Agent 只面向统一接口编程。封装接口大概长这样public interface ChatClient { String call(String prompt); String call(ListMessage messages); String callWithTools(ListMessage messages, ListTool 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(ListMessage 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(MapString, Object params, AgentContext context) { // 第一步校验租户 checkTenant(context.getTenantId()); // 第二步校验权限 checkPermission(context.getUserId(), getRequiredPermission()); // 第三步参数校验 validateParams(params); // 第四步执行 return doInvoke(params, context); } protected abstract Object doInvoke(MapString, Object params, AgentContext context); protected abstract String getRequiredPermission(); }这个模板方法模式的好处是权限校验逻辑写一次所有工具自动继承。新增工具的时候开发者只需要关心业务逻辑不用重复写校验代码也就不会漏。租户隔离这块工具内部访问数据库时一定要走 RuoYi-Vue-Plus 已有的数据权限机制不要自己拼 SQL。因为框架的数据权限拦截器会自动给 SQL 加上租户条件你手写的 SQL 绕过了拦截器就等于绕过了隔离。提示工具执行前务必校验租户和权限这是 AI 应用最容易出安全事故的地方。Agent 不会自觉遵守权限得靠代码强制。3.3 编排器的实现从线性流程到条件分支编排器决定了 Agent 之间的流转。最简单的编排是一条直线Agent A 输出给 Agent BB 输出给 CC 结束。但实际业务里经常需要条件分支——比如合同审查如果金额小于阈值就走快速通道大于阈值就走详细审查。我用一个轻量的图结构来表达编排public class Orchestrator { private final MapString, Agent agents; private final MapString, FunctionMessage, String routers; public Message run(String startAgent, ListMessage input, AgentContext context) { String current startAgent; ListMessage history new ArrayList(input); while (current ! null) { Agent agent agents.get(current); Message output agent.execute(history, context); history.add(output); // 根据路由函数决定下一个 Agent FunctionMessage, 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, ListMessage 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 的行为不像传统代码那样确定出问题时光看结果根本猜不出中间发生了什么只有完整日志才能还原现场。这个日志平时看着冗余出问题时就是救命稻草。