简介:面向AI模型评估场景的后端设计源码,主体采用Java构建服务端核心框架,并加入Python脚本处理与模型评估相关的数据处理逻辑,适合后端开发工程师、AI平台研发人员以及高校实验平台建设者阅读与二次开发。压缩包共76个文件,约143KB,其中Java源文件66个,构成系统主逻辑;另有Python脚本2个、文本说明2个,以及Dockerfile、pom.xml、YAML等配置文件,分别承担容器化部署、Maven工程管理与运行环境定义。已有496人学习,可作为从零搭建模型评估平台的可运行参考。研读源码可了解Java与Python混合集成的模块划分方式,掌握Docker标准化部署流程、Maven依赖管理和配置文件组织技巧,整体结构清晰、代码量适中,便于按照框架、评估逻辑与部署配置等模块逐层拆解,快速建立对AI评估平台后端整体设计思路的完整认知。无论是用于企业内部模型评测工具开发,还是高校科研平台基础构建,都可以从中获得直接落地的后端设计参考。
1. 模型评估平台后端:Java 与 Python 各管一段,而不是二选一
多做过评估脚本的人都有感觉:算法用 Python 跑完指标,把结果发到群里,模型迭代到第三版就没人说得清当时用的阈值和依赖版本。把评估流程平台化时,一个 Python 服务撑不住权限、任务编排和结果追溯,而 Java 直接调 torch、sklearn 又很别扭。常见做法是拆成两层后端:Java 负责平台侧,管账号、模型版本、评估任务和状态流转;Python 负责评估引擎,只做模型加载、推理和指标计算。两条服务通过 HTTP 或异步任务协作,既能复用算法团队现成的 Python 代码,又能让 Java 后端同事牢牢掌控任务生命周期。下面把这类平台的后端设计源码拆开讲。
2. 用 Java Spring Boot 构建评估平台的主后端
2.1 按业务边界拆分模块,Java 不碰算法细节
评估平台主后端如果只用一个 Controller 承接所有逻辑,后面很容易变成“大泥球”。我建议先用包结构把边界定死:
src/main/java/com/example/aieval/ ├── api/ # REST 接口层:接收前后端请求 │ └── EvalJobController.java ├── core/ # 领域模型和业务规则 │ ├── model/EvalJob.java │ ├── model/EvalResult.java │ └── service/EvalJobService.java └── infra/ # 技术基础设施适配 ├── repository/EvalJobRepository.java └── client/PythonEvalClient.java这样划分后,api 只负责入参校验和 HTTP 响应,core 只表达“评估任务”的状态变化,infra 里替换数据库或 Python 客户端时不会影响业务代码。真实项目里还会有 auth、scheduler、report 等模块,但核心这四层足够撑起第一版。
2.2 核心实体字段:模型版本与数据集路径必须和任务解耦
评估平台里“任务”是中心概念。一个评估任务需要记录“用什么模型版本、在哪个数据集上、跑出了什么指标”。Java 侧最基本的实体可以这样写:
@Entity @Table(name = "eval_job") public class EvalJob { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false, length = 64) private String jobName; @Column(name = "model_id", nullable = false) private Long modelId; @Column(name = "dataset_id", nullable = false) private Long datasetId; @Column(nullable = false, length = 20) private String status; // WAITING / RUNNING / SUCCESS / FAILED @Column(length = 512) private String errorMessage; @Column(nullable = false) private LocalDateTime createdAt; }model_id 和 dataset_id 指向另外两张表,这样模型文件版本、数据集清洗脚本可以单独迭代,评估任务不会跟着老数据被覆盖。status 字段建议用短字符串而不是数字枚举,部署后直接在数据库里查状态比查码表方便。注意 errorMessage 要限制长度,Python 侧抛出来的堆栈可能很长,超长后 MySQL 在严格模式下会直接报 Data too long。
指标结果不建议和 eval_job 挤在同一张表里,我一般会为指标项建一张窄表,方便后续做模型版本对比。
CREATE TABLE eval_job_metric ( id BIGINT AUTO_INCREMENT PRIMARY KEY, job_id BIGINT NOT NULL, metric_key VARCHAR(50) NOT NULL, metric_value DECIMAL(18, 6), created_at DATETIME NOT NULL, KEY idx_metric_job_id (job_id) );metric_key 存 accuracy、precision、recall、f1、auc 这类固定英文名,metric_value 统一用 DECIMAL(18,6) 保存。为什么不用 longtext 存整个 JSON?因为对比两个模型版本时,SQL 直接 join 和按 metric_key 过滤会比解析 JSON 快得多,也更容易接到报表系统。这里每一个 job_id 对应一批指标行,既不会丢失原始结果,又保留了对每个指标单独设阈值告警的可能。
状态流转是整个 Java 后端的骨架,不同阶段要允许不同操作。
| status 值 | 触发时机 | 下一步动作 |
|---|---|---|
| WAITING | 前端创建任务,尚未调用 Python | Java 调度线程领取任务 |
| RUNNING | Java 收到 Python 开始处理 | 更新进度,允许前端轮询 |
| SUCCESS | Java 保存指标结果后 | 生成报告,发送通知 |
| FAILED | 超时/异常/回调失败 | 记录 error_message,支持重跑 |
2.3 用 @Async + 状态流转把任务交给 Python 侧
Java 侧不能直接在 Controller 里同步执行模型评估,一个模型在 GPU 上可能跑几个小时,HTTP 连接根本等不起。常见做法是先用线程池异步发起调用,把任务状态从 WAITING 改成 RUNNING。示例:
@Service public class EvalJobService { private final EvalJobRepository jobRepository; private final PythonEvalClient pythonEvalClient; @Async("evalTaskExecutor") public void dispatch(EvalJob job) { jobRepository.updateStatus(job.getId(), "RUNNING"); try { String engineTaskId = pythonEvalClient.submit(job); jobRepository.updateEngineTaskId(job.getId(), engineTaskId); } catch (Exception e) { jobRepository.updateStatus(job.getId(), "FAILED"); } } }注意一个容易踩的坑:如果从同一个类内部直接调用 dispatch,@Async注解不会生效,因为它走的是 this 调用而不是 Spring 代理链路。正确做法是从 Controller 注入 EvalJobService,再调用 service.dispatch(job)。如果确实需要同类调用,可以把 dispatch 拆到另一个 Service 里。
线程池需要单独配置,不能直接复用系统默认的 SimpleAsyncTaskExecutor。
@Bean("evalTaskExecutor") public Executor evalTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(200); executor.setThreadNamePrefix("eval-job-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); return executor; }线程池参数按任务量和数据量调整:如果是单个模型文件 200MB、推理 10 分钟的评估,核心线程 4、队列 200 就够;如果评估任务短平快,例如 20 秒一个,队列要调大,否则 CPU 线程切换会成为瓶颈。CallerRunsPolicy 保证队列满时由提交线程执行,不会把任务直接丢弃。
submit方法在 Java 侧并没有真正去算模型,它只是把本次任务需要的模型路径、数据集路径、特征列等信息封装成请求发到 Python 侧,真正跑推理和指标计算的逻辑在第 3 章。
3. 用 Python FastAPI 封装模型评估计算引擎
3.1 给算法侧一个最小可用的 /eval/run 接口
算法工程师交付模型时通常是一个目录:模型文件、评估脚本、依赖清单。要把这些塞进平台,通常会在 Python 侧起一个 FastAPI 服务,只暴露一个接口,把“加载模型、跑预测、算指标”做进同一个函数。这样 Java 后端不需要知道是 sklearn 还是 PyTorch,只需要传路径。
from fastapi import FastAPI, HTTPException from pydantic import BaseModel import pandas as pd import joblib app = FastAPI(title="model-eval-engine") class EvalRequest(BaseModel): model_path: str data_path: str feature_columns: list[str] label_column: str @app.post("/eval/run") def run_eval(req: EvalRequest): try: model = joblib.load(req.model_path) df = pd.read_csv(req.data_path) X = df[req.feature_columns] y_true = df[req.label_column] y_pred = model.predict(X) y_prob = model.predict_proba(X)[:, 1] if hasattr(model, "predict_proba") else y_pred return compute_metrics(y_true, y_pred, y_prob) except Exception as e: raise HTTPException(status_code=500, detail=str(e))model_path 和 data_path 都用平台侧的绝对路径,Python 进程只读数据,不负责上传。feature_columns 从 Java 侧传入而不是让 Python 自动猜列,可以避免数据集清洗后列名变化导致评估悄悄跑偏。predict_proba 可能取不到,比如部分模型没有该方法,所以用 hasattr 保护。这里如果直接 joblib.load 每次重复读大模型,内存会撑不住,我一般会加一个模型缓存:
_model_cache = {} def get_model(model_path): if model_path not in _model_cache: _model_cache[model_path] = joblib.load(model_path) return _model_cache[model_path]缓存模型后,同一个模型文件被多个评估任务复用时能省下大量加载时间。注意缓存要有上限,避免模型版本一多把内存打爆。简单做法是用 collections.OrderedDict 做 LRU 淘汰,复杂场景直接交给缓存中间件。Python 服务以多 worker 启动时,每个 worker 会有一份独立缓存,不能指望跨进程共享。
3.2 指标计算参数:阈值、正类位置与平均方式
评估分类模型时,最常踩的坑是“精确率、召回率算出来对不上”。问题几乎都出在阈值和平均方式上。下面这个函数是二分类版:
import numpy as np from sklearn.metrics import ( accuracy_score, precision_score, recall_score, f1_score, roc_auc_score, confusion_matrix ) def compute_metrics(y_true, y_pred, y_prob): # 默认正类标为1,概率列取第二列 tn, fp, fn, tp = confusion_matrix(y_true, y_pred).ravel() return { "accuracy": round(float(accuracy_score(y_true, y_pred)), 6), "precision": round(float(precision_score(y_true, y_pred)), 6), "recall": round(float(recall_score(y_true, y_pred)), 6), "f1": round(float(f1_score(y_true, y_pred)), 6), "auc": round(float(roc_auc_score(y_true, y_prob)), 6), "confusion_matrix": { "tn": int(tn), "fp": int(fp), "fn": int(fn), "tp": int(tp) } }precision_score 默认 pos_label=1,并且 average=binary,如果你的评估数据集正类不是 1,就要显式传参。比如二分类标签是 positive/negative,需要用 pos_label="positive";多分类场景则要改成 average="macro" 或 micro。多个参数组合会影响指标解释,建议将这些参数全部放进 EvalRequest 并落库。
我不推荐在 Java 侧重新实现一遍这些指标公式,同样一个 F1,不同计算方式会有微小差异,算法团队最终还是会用 sklearn 的结果。Java 后端只需要把 Python 返回的数值原样保存。这里需要确认几个关键参数:
| 参数 | 常用值 | 说明 |
|---|---|---|
| threshold | 0.5 | 低于阈值判为负类,敏感业务要单独调 |
| average | binary / macro / micro | 多分类时对每个类别的加权方式 |
| pos_label | 1 或具体标签 | 二分类必须确认正类是谁 |
3.3 返回统一 JSON 结构,避免 Java 侧写解析脚本
Python 引擎的输出必须稳定。有团队直接返回 DataFrame 转成的 JSON,字段名是中文或带空格,Java 侧每次解析都要处理特殊情况。更省事的做法是固定外层结构:
{ "jobId": "eval-20250601-001", "metrics": { "accuracy": 0.92, "precision": 0.91, "recall": 0.88, "f1": 0.89, "auc": 0.96, "confusion_matrix": {"tn": 120, "fp": 10, "fn": 15, "tp": 155} }, "modelInfo": { "model_id": 1001, "model_version": "v3" }, "engineVersion": "1.4.0" }jobId 由 Java 生成传过来,Python 原样返回,便于后期把结果落到 eval_job_metric 表。engineVersion 是 Python 引擎自己的版本号,每次改动指标逻辑都需要加版本,避免 Java 侧缓存旧结果后看不出是哪个版本算的。指标数值统一保留 6 位但不是字符串,Java 后端可以直接映射为 BigDecimal。这个 JSON 结构一确定,Java 侧就能用 DTO 接收,不需要任何手写解析。
4. 打通 Java 与 Python:任务分发、回调与异常恢复
4.1 同步调用 Python 引擎时,必须配置连接池和超时
最直接的方案是 Java 走 HTTP POST 调 Python FastAPI。用 RestTemplate 或 WebClient 都行,但默认连接管理很粗糙。我会配置一个单独的超时时间和连接池:
@Configuration public class PythonClientConfig { @Bean public RestTemplate pythonRestTemplate() { HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(); factory.setConnectTimeout(3000); factory.setConnectionRequestTimeout(3000); factory.setReadTimeout(600000); return new RestTemplate(factory); } }connectTimeout 只代表 TCP 建连时间,Python 服务没启动时 3 秒就能快速报错;readTimeout 才是评估执行时间上限,这里设置 600000 毫秒,也就是 10 分钟,超过后客户端会抛 ResourceAccessException。真实场景还要在网关层把对应的 socket 超时调大,否则网关先断,Java 侧拿到空响应会误判为失败。
然后在 service 中实现 submit:
public String submit(EvalJob job) { String url = pythonEngineBaseUrl + "/eval/run"; Map<String, Object> payload = new HashMap<>(); payload.put("model_path", filePathResolver.resolve(job.getModelId())); payload.put("data_path", filePathResolver.resolve(job.getDatasetId())); HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload); ResponseEntity<JsonNode> resp = pythonRestTemplate.postForEntity(url, entity, JsonNode.class); if (resp.getStatusCode().is2xxSuccessful()) { return resp.getBody().path("jobId").asText(); } throw new IllegalStateException(); }filePathResolver.resolve 负责把 model_id 和 dataset_id 转成 Python 进程能读的绝对路径。路径解析不能直接让前端传路径,否则等于开放文件读取漏洞。如果 Python 服务返回 500,Java 侧不要直接吞异常,应该把响应体里的 detail 记录到 eval_job.error_message 里,方便定位。
4.2 用任务表做状态机:重试只找 WAITING / FAILED
同步调用一旦失败,不能只靠异常处理。更可靠的方案是让 Java 侧有一个 eval_job 表不停扫表。服务重启后,扫描 status = 'RUNNING' 且 updated_at 超过 5 分钟的任务,把它们标记为 FAILED,再根据任务创建记录里的 retry_times 决定是否重新入队。
-- 找出疑似宕机的评估任务 UPDATE eval_job SET status = 'FAILED', error_message = 'timeout or engine down' WHERE status = 'RUNNING' AND updated_at < NOW() - INTERVAL 10 MINUTE;这条 SQL 是补偿机制,不是主要触发路径。正常流程里 Python 侧算完后会回调 Java 的 /eval/callback 接口,把最终指标传回来,Java 再把任务状态改为 SUCCESS。如果 Python 侧已经算出结果但回调失败,Job 会一直卡在 RUNNING,所以回调接口要考虑幂等。
Java 回调接口可以这样写:
@PostMapping("/eval/callback") public ResponseEntity<Void> callback(@RequestBody EvalResultRequest result) { if (!jobRepository.existsById(result.getJobId())) { return ResponseEntity.notFound().build(); } if ("SUCCESS".equals(jobRepository.getStatus(result.getJobId()))) { return ResponseEntity.ok().build(); // 幂等处理 } jobRepository.saveMetrics(result.getJobId(), result.getMetrics()); jobRepository.updateStatus(result.getJobId(), "SUCCESS"); return ResponseEntity.ok().build(); }先判断任务是否已成功,避免 Python 重试导致重复写指标。重试次数设置 2 次足够,如果 Python 评估到一半崩溃,无法从断点继续,那就让整个任务失败并告警,而不是无限重试。
4.3 文件落地路径约定与模型版本隔离
Java 和 Python 必须对文件路径有统一约定,否则模型文件找不着。常见做法是统一放在挂载盘下:
/data/ai-eval/ ├── datasets/{dataset_id}/{version}/data.csv ├── models/{model_id}/{version}/model.pkl └── output/{job_id}/metrics.json所有路径由 Java 侧拼好后传给 Python,Python 不做 os.listdir 扫描。路径里带上 dataset_id 和 model_id 可以避免同名文件被覆盖,输出目录以 job_id 隔离也方便后续清理。有一个很容易忽略的点:Python 进程的工作目录可能和 Java 不同,所以传过去的必须是绝对路径,并且 Python 侧代码里不要用相对路径拼接。这里常见错误映射如下:
| Python 错误 | 含义 | Java 侧处理 |
|---|---|---|
| FileNotFoundError | 路径不对 | 检查 filePathResolver 拼接结果 |
| ValueError: columns mismatch | 特征列不匹配 | 对比数据集版本与特征列配置 |
| CUDA out of memory | 显存不足 | 减少 Python worker,或串行执行 |
5. 让评估平台可复现、可追踪:参数固定与版本校验技巧
5.1 每次评估生成一份依赖与超参快照
建议在任务发起时,Java 侧把 eval_request.json 保存在输出目录,内容包括 feature_columns、label_column、threshold、average、模型版本、数据集版本、Python 引擎版本。Python 侧跑完指标后,再执行下面的命令把依赖冻到结果目录:
pip freeze > /data/ai-eval/output/{job_id}/requirements.txt这份文件不要全量冻,因为 base 环境可能有很多无关包;用 pipreqs 或 pip-compile 生成只和评估引擎相关的锁文件更好。这样半年后回看一个高分结果,能立刻知道跑分用的 numpy、pandas 版本。如果不做这步,一个依赖版本升级就可能让 AUC 小数点后第四位发生变化。
5.2 校验文件指纹,避免模型与数据集被悄悄替换
模型文件或数据集如果被误修改,评估分数会变,但任务记录里根本看不出来。实用的技巧是在 Java 侧记录文件哈希值,Python 侧加载前再算一次:
import hashlib def sha256_of(path): h = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(8192), b""): h.update(chunk) return h.hexdigest()如果两个哈希不一致,Python 接口直接返回 424 Failed Dependency。为减少大文件计算开销,可以先校验模型配置文件,或者只校验前 64KB。真正要求严格的生产环境,建议用 SHA-256,性能损失不明显,但哈希结果不容易被碰撞。这个指纹建议单独建一张 model_file_checksum 表,每次模型发布时写入,而不是临时在任务里计算。
5.3 并行评估时的常用参数组合与排障顺序
多个评估任务同时跑时,需要关注的参数不只是线程数,还有文件句柄和显存。这里给出常用的三档组合:
| 场景 | Java 线程池 | Python worker | 文件句柄限制 |
|---|---|---|---|
| 小模型、秒级推理 | core=8, max=16, queue=500 | uvicorn --workers 2 | ulimit -n 10240 |
| 中模型、分钟级推理 | core=4, max=8, queue=100 | uvicorn --workers 1 | ulimit -n 65535 |
| 大模型、显存占用高 | core=2, max=4, queue=50 | 单进程 + 排队锁 | 独立目录评估 |
显存型任务不要盲目开多 worker,否则会导致 CUDA out of memory。遇到这种报错,先看 GPU 显存占用,再降 Python worker 数,最后调整 Java 队列长度。如果任务失败后 Java 侧一直拿到 500,优先检查 Python 日志里有没有 Permission denied,这多半是文件路径映射权限不对,而不是模型问题。
本文还有配套的精品资源,点击获取