Files
YG_FT/frontend/src/views/eval/EvalCreateView.vue
wuyongtao 0c39f2f5b9 feat: 模型评测端到端闭环 — EvalRunner引擎 + 算力节点Job执行 + 结果回写
算力节点 (compute):
- 新建 eval_runner.py: 评测执行引擎,作为subprocess运行
  - 加载模型 + JSONL数据集 + 逐样本推理
  - BLEU/ROUGE/Cosine基础指标计算
  - LLM Judge评分(OpenAI兼容API调用)
  - 结果写入eval_results.json
- adapter.py: build_command新增engine=eval分支
- main.py: 新增/json模块导入,新增/compute/files/read端点,eval job校验

后端:
- platform.py: 重写startEval提交eval job到算力节点
  - 支持models表和trained_models表查找
  - 已合并模型不传adapter路径
- platform_store.py: 新增update_eval_task/running_eval_tasks/apply_eval_job_result
- sync.py: poller新增eval job同步,异步读取eval_results.json回写结果

前端:
- EvalCreateView/DimensionCreateView: eval模型过滤扩展(API类型+api_url)
- EvalCreateView: GPU过滤在线节点空闲GPU
- EvalTaskSetupStep: GPU value从数组index改为gpu.id
- BasicMetricSetupStep: ROUGE方法名修正(rouge_1→rouge1)
- EvalView: 新增5秒轮询刷新

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 19:34:41 +08:00

500 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import PageCard from '@/components/PageCard.vue'
import EvalTaskSetupStep, { type EvalTaskSetupDraft } from './create/EvalTaskSetupStep.vue'
import EvalRuleSetupStep, { type EvalRuleSetupDraft } from './create/EvalRuleSetupStep.vue'
import BasicMetricSetupStep, { type BasicMetricSetupDraft } from './create/BasicMetricSetupStep.vue'
import StartEvalStep from './create/StartEvalStep.vue'
import { createDimension, startEval } from '@/api/modules/eval'
import { getTrainedModels, getModelList } from '@/api/modules/model'
import { getDatasetList } from '@/api/modules/dataset'
import { getSystemInfo } from '@/api/modules/system'
import { getComputeNodes, type ComputeNode } from '@/api/modules/compute'
import type { DatasetItem, Dimension, GpuInfo, ModelItem, TrainedModel } from '@/types'
type StepExposed = { validate: () => Promise<boolean> }
const router = useRouter()
const loading = ref(false)
const submitting = ref(false)
const currentStep = ref(0)
const taskStepRef = ref<StepExposed>()
const ruleStepRef = ref<StepExposed>()
const basicMetricStepRef = ref<StepExposed>()
const WIZARD_STEPS = [
{ title: '任务配置', description: '选择模型、算力与评测数据' },
{ title: '大模型评测指标', description: '配置评测模型、方式与标准' },
{ title: '基础评测指标', description: '选择 BLEU、ROUGE 等参考指标' },
{ title: '开始评测', description: '确认配置并启动评测任务' },
] as const
const trainedModels = ref<TrainedModel[]>([])
const evalDatasets = ref<DatasetItem[]>([])
const evalModels = ref<ModelItem[]>([])
const gpus = ref<GpuInfo[]>([])
const createdDimensionId = ref<string | number>('')
const taskForm = ref<EvalTaskSetupDraft>({
eval_task_name: '',
model_id: '',
gpu_id: '',
data_source: 'dataset',
dataset_id: '',
leaderboard: false,
})
const ruleForm = ref<EvalRuleSetupDraft>({
type: '',
description: '',
eval_model: '',
eval_method: '',
eval_prompt: '',
is_active: true,
is_default: false,
bleu_n: 1,
output_precision: 3,
score_min: 0,
score_max: 5,
pass_threshold: 3,
})
const basicMetricForm = ref<BasicMetricSetupDraft>({
bleu_enabled: false,
bleu_n: 4,
rouge_enabled: false,
rouge_methods: ['rouge_1', 'rouge_2'],
cosine_enabled: false,
output_precision: 3,
})
watch(
ruleForm,
() => {
createdDimensionId.value = ''
},
{ deep: true },
)
async function loadData() {
loading.value = true
const results = await Promise.allSettled([
getTrainedModels(),
getDatasetList(),
getSystemInfo(),
getModelList(),
getComputeNodes(),
])
if (results[0].status === 'fulfilled') trainedModels.value = results[0].value?.models || []
if (results[1].status === 'fulfilled') {
evalDatasets.value = (results[1].value || []).filter((dataset) => dataset.type === 'eval')
}
if (results[2].status === 'fulfilled') {
const allGpus: GpuInfo[] = results[2].value?.gpu || []
const nodes: ComputeNode[] = (results[4].status === 'fulfilled' ? results[4].value : []) || []
const onlineIds = new Set(nodes.filter((n) => n.enabled && n.scheduler_status === 'online').map((n) => n.id))
// Only show idle GPUs from online compute nodes
gpus.value = allGpus.filter(
(g) => g.status === 'idle' && (!g.node_id || onlineIds.has(g.node_id)),
)
}
if (results[3].status === 'fulfilled') {
evalModels.value = (results[3].value || []).filter(
(model) => model.purpose === 'evaluation' || (model.model_source === 'api' && !!model.api_url),
)
}
const failedCount = results.filter((result) => result.status === 'rejected').length
if (failedCount > 0) {
ElMessage.error(`部分基础数据加载失败(${failedCount} 项),请刷新页面后重试`)
}
loading.value = false
}
function buildDimensionPayload() {
const draft = ruleForm.value
const payload: Partial<Dimension> = {
name: `Custom_Dim_${Date.now()}`,
type: draft.type,
description: draft.description,
eval_model: draft.eval_model,
eval_method: draft.eval_method,
eval_prompt: draft.eval_prompt,
is_active: draft.is_active,
is_default: draft.is_default,
create_time: new Date().toISOString(),
}
if (draft.type === 'metric') {
payload.score_min = draft.score_min
payload.score_max = draft.score_max
payload.pass_threshold = draft.pass_threshold
}
return payload
}
async function resolveDimensionId() {
if (createdDimensionId.value !== '') return createdDimensionId.value
const created = await createDimension(buildDimensionPayload())
if (created?.id === undefined || created.id === null || created.id === '') {
throw new Error('评测维度已提交,但未返回维度 ID无法启动评测任务')
}
createdDimensionId.value = created.id
return created.id
}
async function handleSubmit() {
if (loading.value || submitting.value) return
submitting.value = true
try {
const dimensionId = await resolveDimensionId()
await startEval({
eval_task_name: taskForm.value.eval_task_name,
eval_type: 'custom',
model_id: taskForm.value.model_id,
gpu_id: taskForm.value.gpu_id,
dataset_id: taskForm.value.data_source === 'dataset' ? taskForm.value.dataset_id : '',
dimension_id: dimensionId,
data_source: taskForm.value.data_source,
leaderboard: taskForm.value.leaderboard,
basic_metrics: {
bleu: {
enabled: basicMetricForm.value.bleu_enabled,
ngram: basicMetricForm.value.bleu_n,
},
rouge: {
enabled: basicMetricForm.value.rouge_enabled,
methods: basicMetricForm.value.rouge_methods,
},
cosine: {
enabled: basicMetricForm.value.cosine_enabled,
},
output_precision: basicMetricForm.value.output_precision,
},
})
ElMessage.success('评测任务已创建并启动')
router.push('/model-eval')
} catch (error) {
const message = error instanceof Error ? error.message : '创建评测任务失败,请稍后重试'
ElMessage.error(message)
} finally {
submitting.value = false
}
}
async function handleNext() {
if (loading.value || submitting.value) return
if (currentStep.value === 0) {
const taskValid = await taskStepRef.value?.validate()
if (!taskValid) {
ElMessage.warning('请检查并完善任务配置')
return
}
}
if (currentStep.value === 1) {
const ruleValid = await ruleStepRef.value?.validate()
if (!ruleValid) {
ElMessage.warning('请检查并完善大模型评测指标')
return
}
}
if (currentStep.value === 2) {
const basicMetricValid = await basicMetricStepRef.value?.validate()
if (!basicMetricValid) {
ElMessage.warning('请检查并完善基础评测指标')
return
}
}
currentStep.value = Math.min(currentStep.value + 1, WIZARD_STEPS.length - 1)
}
function handleBack() {
if (submitting.value) return
currentStep.value = Math.max(currentStep.value - 1, 0)
}
function handleCancel() {
router.back()
}
onMounted(loadData)
</script>
<template>
<PageCard
title="新建评测任务"
subtitle="依次配置任务、大模型评测指标与基础评测指标,确认后开始评测"
>
<div class="create-wizard-layout">
<main v-loading="loading" class="wizard-main">
<div class="wizard-steps-container" aria-label="评测任务创建步骤">
<div class="custom-wizard-steps">
<template v-for="(step, index) in WIZARD_STEPS" :key="step.title">
<div
v-if="index !== 0"
class="step-connector"
:class="{ 'is-active': currentStep >= index }"
></div>
<div
class="step-item"
:class="{
'is-active': currentStep === index,
'is-completed': currentStep > index,
}"
:aria-current="currentStep === index ? 'step' : undefined"
:aria-label="`${index + 1}. ${step.title}${step.description}`"
>
<div class="step-node">
<div class="step-icon" aria-hidden="true">
<i v-if="currentStep > index" class="fa fa-check" />
<span v-else>{{ index + 1 }}</span>
</div>
<div class="step-text">
<div class="step-title">{{ step.title }}</div>
<div class="step-description">{{ step.description }}</div>
</div>
</div>
</div>
</template>
</div>
</div>
<div class="wizard-content">
<EvalTaskSetupStep
v-if="currentStep === 0"
ref="taskStepRef"
v-model="taskForm"
:trained-models="trainedModels"
:eval-datasets="evalDatasets"
:gpus="gpus"
:loading="loading"
:disabled="submitting"
/>
<EvalRuleSetupStep
v-else-if="currentStep === 1"
ref="ruleStepRef"
v-model="ruleForm"
:eval-models="evalModels"
:disabled="submitting"
/>
<BasicMetricSetupStep
v-else-if="currentStep === 2"
ref="basicMetricStepRef"
v-model="basicMetricForm"
:disabled="submitting"
/>
<StartEvalStep
v-else
:task="taskForm"
:llm-metric="ruleForm"
:basic-metrics="basicMetricForm"
:trained-models="trainedModels"
:eval-datasets="evalDatasets"
:eval-models="evalModels"
:gpus="gpus"
/>
</div>
</main>
<footer class="wizard-footer">
<el-button
v-if="currentStep > 0"
class="footer-back"
:disabled="submitting"
@click="handleBack"
>
<i class="fa fa-arrow-left footer-button-icon" />返回{{ WIZARD_STEPS[currentStep - 1].title }}
</el-button>
<el-button v-else class="footer-back" :disabled="submitting" @click="handleCancel">
取消
</el-button>
<el-button
v-if="currentStep < WIZARD_STEPS.length - 1"
type="primary"
:disabled="loading || submitting"
@click="handleNext"
>
下一步{{ WIZARD_STEPS[currentStep + 1].title }}<i
class="fa fa-arrow-right footer-button-icon is-right"
/>
</el-button>
<el-button
v-else
type="primary"
:loading="submitting"
:disabled="loading || submitting"
@click="handleSubmit"
>
开始评测
</el-button>
</footer>
</div>
</PageCard>
</template>
<style scoped>
.create-wizard-layout {
display: flex;
flex-direction: column;
min-height: 620px;
margin: -20px;
background: #fff;
}
.wizard-main {
flex: 1;
padding: 32px;
}
.wizard-steps-container {
margin-bottom: 32px;
padding-bottom: 24px;
border-bottom: 1px dashed #e2e8f0;
}
.custom-wizard-steps {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 1120px;
margin: 0 auto;
padding: 0 20px;
}
.step-item {
display: flex;
flex: none;
align-items: center;
}
.step-connector {
flex: 1;
height: 2px;
margin: 0 12px;
background: #e2e8f0;
transition: background-color 0.2s ease;
}
.step-connector.is-active {
background: #5146e5;
}
.step-node {
display: flex;
align-items: center;
gap: 12px;
}
.step-icon {
display: flex;
width: 30px;
height: 30px;
flex: 0 0 30px;
align-items: center;
justify-content: center;
box-sizing: border-box;
border: 2px solid #cbd5e1;
border-radius: 50%;
background: #fff;
color: #64748b;
font-size: 13px;
font-weight: 650;
transition: all 0.2s ease;
}
.step-title {
color: #64748b;
font-size: 15px;
font-weight: 600;
white-space: nowrap;
}
.step-description {
margin-top: 3px;
color: #94a3b8;
font-size: 12px;
white-space: nowrap;
}
.step-item.is-active .step-icon {
border-color: #5146e5;
background: #eef2ff;
color: #5146e5;
}
.step-item.is-active .step-title,
.step-item.is-completed .step-title {
color: #1e293b;
}
.step-item.is-completed .step-icon {
border-color: #5146e5;
background: #5146e5;
color: #fff;
}
.wizard-content {
max-width: 920px;
min-height: 400px;
margin: 0 auto;
}
.wizard-footer {
display: flex;
min-height: 64px;
flex-shrink: 0;
align-items: center;
justify-content: flex-end;
gap: 12px;
padding: 0 32px;
border-top: 1px solid #e2e8f0;
background: #fff;
box-shadow: 0 -4px 6px -1px rgb(15 23 42 / 2%);
}
.footer-back {
margin-right: auto;
}
.footer-button-icon {
margin-right: 6px;
}
.footer-button-icon.is-right {
margin-right: 0;
margin-left: 6px;
}
@media (max-width: 1100px) {
.step-description {
display: none;
}
}
@media (max-width: 760px) {
.wizard-main {
padding: 24px 20px;
}
.custom-wizard-steps {
padding: 0;
}
.step-connector {
margin: 0 8px;
}
.step-text {
display: none;
}
.wizard-footer {
padding: 0 20px;
}
}
</style>