把 AgentScope Harness 装进 RuoYi-Vue-Plus,这句描述乍一听挺拧巴:AgentScope 生态里的 Harness 是 Python 那套多智能体评测组件,RuoYi-Vue-Plus 又是 Java 系后台管理界的常青树,两者语言不通、定位也不一样。但正是这种“拧巴”才值得写一篇文章——它代表了一类很真实的需求:公司已经有了成熟的管理后台、账号体系、权限框架,现在想在上面长出 AI Agent 的评测、管理和审计能力,又不想推翻现有 Java 工程去重建一个 Python 平台。所谓“纯 Java AI 平台”,指的不是让 Java 去执行大模型推理,而是让 Java 后端负责调度、存储、权限与审计,让 Python 侧的 AgentScope Harness 专心干评测的活。这篇文章就是我自己实操这条路的完整记录,适合正在用 RuoYi-Vue-Plus 做支撑系统、手里握着一堆 Agent 场景却苦于没有评测管理界面的团队参考。
1. 兼容层思维:AgentScope Harness 和 RuoYi 各自该干什么
1.1 Harness 带来的不是“评分”,而是“场景编排”
很多人一听到 Harness 就以为是套测试框架,无非跑几个用例然后算通过率。实际用过之后你会发现,它真正的价值是把多智能体评测里的脏活累活标准化了:一个评测场景包含用户角色定义、交互轮次上限、环境工具的模拟、终止条件、指标采集点等等。你自己用脚本写,写到后面往往变成“为了这套场景的代码,快比被测 Agent 本身还复杂”。
Harness 帮你把这些编排逻辑收敛成场景配置和数据记录,然后替你产出结构化结果:任务是否完成、工具调用是否正确、对话轮次、耗时、失败时卡在哪一步。这是团队在做 Agent 质检时最缺的东西。但问题也在这里——Harness 的默认使用方式偏向命令行和 Python 脚本,是个“实验室工具”,不是“管理平台”。相当于引擎很好,却没有仪表盘和方向盘。
1.2 RuoYi-Vue-Plus 在这个平台里扮演的角色
RuoYi-Vue-Plus 这套框架强在工程底座:用户与角色权限、菜单管理、操作日志、定时任务、代码生成,开箱即用。公司里很多内部系统都是基于它二次开发的,运营、算法、测试同学的账号和权限早就沉淀在里面。如果为了 AI 评测再单独搭一个前端加一套登录体系,等于把已经有的地基推倒重来。
所以我的判断是:RuoYi 侧只做三件事——把评测任务当作业务数据进行管理、把 Python 服务当作一个不可见的执行引擎来调度、把评测结果转成报表和明细给业务方看。AgentScope Harness 的命令行能力全部封装在 Python 服务进程里,对 Java 侧暴露的只是一组 REST 接口。两边各管各的,Java 不需要 import 任何 Python 库,Python 也不需要知道 RuoYi 的权限模型。
1.3 一个典型的使用闭环
我们当时的闭环是这样:算法同学写好 Agent 配置后,在 RuoYi 的管理页面里发起一次评测任务,选择目标场景集和 Agent 版本,提交后 Java 后端落库并调用 Python 服务。Python 侧把评测任务丢进线程池异步跑,跑完后把 JSON 结果返回;Java 轮询到结果后写入业务表,前端展示通过率、工具准确率、失败样例。整个过程中测试人员只跟 Vue 页面打交道,完全感觉不到背后还有个 Python 引擎。这就是“纯 Java 平台”对使用者的意义——技术栈边界藏在内部,体验是统一的。
2. 技术选型与架构边界:为什么用 HTTP 而不是 MQ 或 gRPC
2.1 服务拆分与部署拓扑
先明确部署形态。Python 评测服务作为独立进程跑在一台内网机器(或容器)上,和 RuoYi 后端服务通过网络互通,两边都不需要暴露公网端口。数据库仍然只有 RuoYi 在写,Python 服务不直接碰 MySQL,评测中间状态先落在自己的内存和临时文件里,最终结构化结果通过接口返回给 Java 侧落库。Redis 两边共用,但用途很克制:Java 侧用 Redisson 做并发控制和分布式锁,Python 侧可以用来做任务去重和简单的状态缓存。
这样的拓扑有一个好处:Python 服务挂掉不影响 RuoYi 主流程,任务状态停在“执行中”,重试或重启后可以重新提交;反过来 RuoYi 重启也不影响正在跑的评测,评测完的结果在 Python 侧临时文件里等着被再次拉取。
2.2 三种通信方案的取舍
做方案评审的时候,组里有人提议上 RabbitMQ,也有人提出 gRPC 性能更好。我把三种方案放在一起对比过,最终选了最朴素的 REST。选择逻辑是这样的:
| 方案 | 优势 | 成本与问题 |
|---|---|---|
| REST over HTTP | 两边都自然支持,JSON 调试直观,curl 就能验证 | 长任务需要轮询或回调,语义朴素 |
| 消息队列 | 天然异步、削峰,任务提交即忘 | 需要多维护一套中间件;结果回传仍要接口配合;排查链路多一跳 |
| gRPC | 强类型契约、性能好、流式支持强 | proto 维护和跨语言生成代码麻烦;联调时抓包不像 HTTP 那么直观 |
关键是看调用模式:Java 侧每次提交评测任务时,需要立刻知道“这个请求有没有被接受”,这本质上是请求/响应语义,不需要事件总线。评测任务虽然耗时长,但并发量不大,峰值也就几十个任务,队列带来的削峰价值发挥不出来。gRPC 的性能优势在这种低频任务下根本体现不出来,反而让 Java 和 Python 两边的依赖管理变复杂。REST 虽然“无聊”,但足够稳,出了故障也容易定位,这就是我当时选它的理由。
2.3 调用链路的完整时序
整个调用流程固定成四个阶段:提交、查询、拉取、落库。
提交阶段,Java 生成业务任务编号,插入一条状态为“等待中”的记录,再调用 Python 的创建任务接口。Python 接口只做两件事:校验 API Key、把任务放进线程池,然后立刻返回一个任务 ID。这个接口必须快,我给它设的 Java 侧超时是 5 秒。
查询阶段,Java 用一个异步线程(或者 Spring 的 @Async)每隔 2 秒轮询一次 Python 的任务状态接口,最多轮询到任务变为 SUCCESS 或 FAILED。轮询的好处是 Python 侧不需要实现回调,Java 侧也不怕回调地址配错。
拉取阶段,状态成功后 Java 调用结果接口,拿到完整的评测 JSON,解析后拆成指标表和明细记录入 MySQL。最后把任务状态改成成功,前端看到的就是一份可查询的评测报告。
3. Python 侧改造:把 Harness 包装成可被 Java 调用的服务
3.1 环境准备与依赖
Python 侧我建议用 3.10 或以上版本,创建独立虚拟环境,避免污染机器上其他 Python 环境。依赖就三类:AgentScope 本体、AgentScope Harness、FastAPI 和 Uvicorn。安装命令没什么特别的,唯一要注意的是版本锁定——Python 这边依赖升级很频繁,评测结果格式可能随版本变化,所以我把核心依赖写死在小版本范围,防止某天升级后 Java 侧解析 JSON 出问题。
评测场景的配置放在独立目录里,每个场景一个 JSON 文件,里面包含场景名称、用户模拟器配置、最大轮次、可用工具列表和终止条件。Java 侧提交任务时只需要传场景 ID,Python 侧自己去目录加载配置,这样双方约定的数据结构尽量小,依赖面就窄。
3.2 接口协议设计
我给 Python 服务设计的接口一共五个,全部带 x-api-key 请求头校验:
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/v1/scenarios | 返回可用场景列表,供 RuoYi 页面下拉选择 |
| POST | /api/v1/tasks | 创建评测任务,立即返回 task_id |
| GET | /api/v1/tasks/{task_id} | 查询任务状态与进度 |
| GET | /api/v1/tasks/{task_id}/result | 拉取评测结果 JSON |
| POST | /api/v1/tasks/{task_id}/cancel | 取消正在等待或运行中的任务 |
任务状态统一用字符串枚举:PENDING、RUNNING、SUCCESS、FAILED、CANCELED。这里有个细节:Python 返回的状态枚举最好和 Java 侧的字典表对齐,别各写各的缩写,否则联调时光翻译状态就够折腾。
3.3 异步执行与状态保持
Harness 本身的评测过程是同步阻塞的:给一个场景配置,跑若干轮对话,收集指标,输出结果。所以服务化改造的核心就一句话:把同步调用包进线程池,让 HTTP 接口立刻返回。我用的 ThreadPoolExecutor,线程数按机器核数和模型 API 的限流情况配置,一般 4 到 8 个。每个任务在提交时拿到一个 UUID,Python 侧维护一个任务状态字典,key 是 task_id,value 是状态和关联的临时文件路径。评测线程跑完后把结果写进临时 JSON 文件,把状态改成 SUCCESS。
这个设计很朴素,但够用。需要注意一个致命点:进程重启后内存里的任务状态全没了。所以生产上我在 Python 侧把任务状态定期同步到 Redis,启动时如果发现 Redis 里有未完成任务,就标记为 FAILED 并记录原因“服务重启导致中断”,让 Java 侧可以感知并提示用户重跑。
3.4 关键代码骨架
from fastapi import FastAPI, Header, HTTPException from pydantic import BaseModel from concurrent.futures import ThreadPoolExecutor import uuid, json, pathlib app = FastAPI() executor = ThreadPoolExecutor(max_workers=4) API_KEY = "在这里配置和Java侧一致的密钥" class TaskRequest(BaseModel): scenario_id: str agent_config: dict user_proxy_config: dict | None = None def run_eval(task_id: str, req: TaskRequest): # 这里调用 agentscope_harness 的评测入口 # 将结果写入 /tmp/eval_results/{task_id}.json # 完成后更新 task_states[task_id] = "SUCCESS" pass @app.post("/api/v1/tasks") async def create_task(req: TaskRequest, x_api_key: str = Header(...)): if x_api_key != API_KEY: raise HTTPException(status_code=401) task_id = str(uuid.uuid4()) task_states[task_id] = "PENDING" executor.submit(run_eval, task_id, req) return {"task_id": task_id, "status": "PENDING"} @app.get("/api/v1/tasks/{task_id}") async def get_task(task_id: str, x_api_key: str = Header(...)): state = task_states.get(task_id) if state is None: raise HTTPException(status_code=404, detail="task not found") return {"task_id": task_id, "status": state}实际跑评测的函数里,记得把 Harness 的日志级别调低,否则每个任务动辄打几百行日志,排查问题的时候有用的信息全被淹没了。
4. RuoYi-Vue-Plus 侧落地:表结构、服务封装与权限控制
4.1 后端表设计与 MyBatis-Plus 实体
RuoYi-Vue-Plus 的特点是 MyBatis-Plus 加代码生成,所以我先建了两张表,然后用代码生成器生成实体和 Mapper,省掉大量手写 CRUD。
create table ai_eval_task ( id bigint primary key, task_no varchar(32) not null comment '业务任务编号', scenario_id varchar(64) not null comment '场景ID', agent_version varchar(64) comment 'Agent版本标识', status varchar(16) not null comment 'PENDING/RUNNING/SUCCESS/FAILED', progress int default 0 comment '进度0-100', create_by varchar(64), create_time datetime, finish_time datetime, unique key uk_task_no (task_no) ) engine=InnoDB default charset=utf8mb4 comment '评测任务表'; create table ai_eval_result ( id bigint primary key, task_id bigint not null, task_no varchar(32) not null, metrics_json json comment '指标汇总JSON', detail_json json comment '逐轮对话与工具调用明细', raw_report_path varchar(255) comment 'Python侧原始报告路径', create_time datetime ) engine=InnoDB default charset=utf8mb4 comment '评测结果表';表设计上要克制,不要试图把所有指标都拆成独立字段,因为不同场景的指标集差异很大。把指标汇总放进 JSON 字段,用 MyBatis-Plus 的 JacksonTypeHandler 做自动序列化,查询展示时再按前端需要解析,比维护一张属性松散的宽表靠谱得多。
4.2 调用 Harness 的服务层封装
Java 侧我直接用 Hutool 的 HttpRequest 封装 HTTP 调用,理由很简单:项目里本来就用 Hutool,不需要再引一个 HTTP 客户端依赖。创建任务的代码大概长这样:
public AiEvalTask createAiEvalTask(AiEvalTaskDTO dto) { String taskNo = IdUtil.getSnowflakeNextIdStr(); AiEvalTask task = new AiEvalTask(); task.setTaskNo(taskNo); task.setScenarioId(dto.getScenarioId()); task.setStatus("PENDING"); // 先落库,保证业务上有据可查 aiEvalTaskMapper.insert(task); Map<String, Object> body = new HashMap<>(); body.put("scenario_id", dto.getScenarioId()); body.put("agent_config", dto.getAgentConfig()); String resp = HttpRequest.post(agentScopeUrl + "/api/v1/tasks") .header("x-api-key", agentScopeApiKey) .body(JSONUtil.toJsonStr(body)) .timeout(5000) .execute().body(); String pyTaskId = JSONUtil.parseObj(resp).getStr("task_id"); // 更新本表保存python侧任务ID,后续轮询用它 task.setPyTaskId(pyTaskId); aiEvalTaskMapper.updateById(task); return task; }轮询逻辑放在独立的异步方法里,用 @Async 注解,提交任务后立即触发。Java 侧轮询要设置上限,我设的 30 分钟,超过后把任务标记 FAILED,原因写“评测超时”。这个兜底非常重要,否则 Python 侧线程卡死时 Java 侧会永远挂着一个“运行中”的任务。
4.3 鉴权与安全边界处理
既然叫平台,权限就不能漏。RuoYi-Vue-Plus 的控制器上加 @SaCheckPermission 注解,只有拥有对应权限的账号才能创建评测任务、查看结果、取消任务。同时,Python 服务的 URL 和 API Key 只配置在 Java 后端的配置中心/配置文件里,前端永远不可能直接拿到。这样即便有人抓到了整条接口链路,也只能看到 RuoYi 的受控接口,而 Python 服务不监听对外网卡,内网也要求来源 IP 在白名单内。
还有一个容易被忽略的点:操作日志。RuoYi 自带的日志注解 @Log 可以直接记录谁在什么时间发起了哪个评测场景,这是合规审计需要的。AI 平台的评测会涉及模型输出内容,必须有“谁跑过什么”的痕迹,别等出了问题再补。
5. 联调阶段最值得记录的四个坑
5.1 JSON 类型差异:Python 的 None、NaN 与 Java 的严格解析
第一次联调就翻车在数据类型上。Python 里 float('nan') 在 json.dumps 时默认会输出 NaN,FastAPI 序列化响应时也可能带出这种值,而 Jackson 在解析 JSON 里的 NaN 时直接抛异常。评测指标里恰好就有这类数据——某个工具调用的耗时异常时,Python 侧代码写的是 float('nan') 占位。解决办法是两件事同时做:Python 侧在写入 JSON 前把 NaN 统一替换成 null;Java 侧给 ObjectMapper 开启允许 NaN 的配置作为最后一道防线。
另一个高频问题时间格式。Python 的 datetime 序列化默认是 ISO 8601 字符串,带 T 和时区后缀;Java 的 LocalDateTime 默认解析需要指定格式。与其在两边反复调格式,不如在 Python 侧统一转成 "yyyy-MM-dd HH:mm:ss" 这种字符串,Java 侧按同一个格式解析,谁都不占谁便宜。
5.2 长耗时任务的超时、轮询与幂等控制
创建任务接口可以快,但评测本身经常要跑几分钟。刚开始我把 Java 侧 HTTP 超时设置成 30 秒,于是每次跑超过 30 秒的任务,Java 这边就报 SocketTimeoutException,可 Python 那边任务其实还在跑。后来我定了两条规矩:所有“提交”类接口 Java 侧超时控制在 5 秒以内,因为提交动作本身不该慢;所有“查询、拉取”类接口超时放宽到 30 秒,因为涉及文件读取和状态检查。评测执行一律异步,绝不让 HTTP 请求长时间占着连接等待评测完成。
幂等控制同样重要。用户手滑点了两次提交,产生两个重复评测任务,白费算力不说,结果还会打架。我在 Java 侧用任务编号的唯一索引加 Redisson 的分布式锁做防重:同一个业务单号在 Redis 锁未释放前,第二次提交直接提示“任务已存在”。Python 侧也做了对账逻辑,同一个 agent_version 加 scenario_id 在短时间窗口内重复提交时直接复用已有结果。
5.3 并发评测的资源争抢问题
线程池的 max_workers 不是越大越好。我们压测发现,8 个并发评测同时跑时,如果每个评测都再调用同一个外部模型 API,很快会触发限流,表现为大量任务失败。后来把线程数降到 4,并且加了一个简单的令牌桶:每秒最多允许两个评测任务真正进入 Harness 执行,其余在队列里等待。模型 API 的限流策略各家不一样,务必提前问清楚你的模型供应商每秒允许多少请求,然后据此设置评测并发,别等到一堆 FAILED 再排查。
机器资源也要观察。Harness 评测过程不算太吃显存,因为推理都在模型服务那边,但 CPU 内存占用还是有的。每个评测任务至少预留 1GB 内存,多轮对话的上下文一旦拉长,内存会涨得很快。建议给 Python 服务加一个简单的内存监控接口,超过阈值时停止接收新任务,返回 503 让 Java 侧提示用户稍后再试。
5.4 Vue 前端的“假实时”刷新
RuoYi-Vue-Plus 的前端是 Vue 3,任务列表页需要展示运行状态。最开始后端同学说“你刷新一下页面就有了”,但测试同学用起来非常痛苦。我的做法是在任务详情页加一个轮询定时器,每 3 秒调一次查询接口,组件卸载时记得清掉定时器。这个方案成熟简单,比上 WebSocket 省事得多,评测任务本来就不是高实时场景,3 秒的延迟完全无感。
6. 实测效果与还能怎么往深做
6.1 一次内部评测的实测数据
整套链路跑通后,我们拿一个内部的知识库问答 Agent 做了一次完整评测:48 个场景,12 种 Agent 配置,总计 576 个任务,分批串行执行,约 40 分钟全部跑完。结果是任务完成率 86%,工具调用准确率 79%,平均单轮响应耗时 2.8 秒。最有价值的不是这三个数字,而是 Harness 输出的失败明细帮我们定位到了三个共性问题:有两个场景的终止条件设置太苛刻、一个工具的参数校验逻辑在 Agent 传错格式时不给第二次机会、一个 Prompt 模板在长上下文时出现重复追认现象。这些问题之前靠人工测试根本没发现。
6.2 值得继续做的方向
第一个方向是流式化。现在评测过程是“跑完才能看结果”,如果改用 SSE 把对话过程实时推给前端,测试同学能边跑边看 Agent 的表现,对问题场景的理解会深很多。第二个方向是定时评测。RuoYi 自带 Quartz 定时任务,完全可以配置成每晚自动跑一遍核心场景集,第二天上班直接看通过率变化趋势,相当于给 Agent 上了“夜间自动质检”。第三是把评测结果回传 Agent 的开发流程,让算法同学每次改完 Prompt 都能先跑一轮回归,减少“改一处坏一片”的回归事故。
最后说一句我自己的体会:这套集成里最难的从来不是技术,而是忍住不要把事情做复杂。Python 和 Java 之间不需要追求“深度集成”,一条 REST 接口加上明确的数据结构,已经足够让两个生态各司其职、和平共处。等哪天评测场景真多到撑不住 HTTP 轮询了,再考虑换更重的方案也不迟。