Files
YG_FT/frontend/src/views/eval/EvalCreateView.vue
wuyongtao 0292bf5138 fix: 模型评测异步加载等待与多节点路由修复
- eval_runner 等待异步模型加载完成(InferenceSession.wait_until_loaded),
  修复 "model load failed: unknown"
- 评测算力节点选择:优先页面选择的节点 / 模型所在节点(_select_eval_node),
  多节点时不再派发到不可达节点导致连接超时
- 前端评测 GPU 选择改为节点感知(节点:GPU 复合值),透传 compute_node_id,
  并检查 startEval 结果展示真实错误
- 大模型评价(judge)使用模型记录的真实 API 模型名(api_model),
  避免用平台内部名调用 LLM API 导致 HTTP 400
- 新增后端节点选择与 compute wait_until_loaded 单元测试

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 18:21:16 +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 { getComputeGpus } 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(),
getModelList(),
getComputeGpus(),
])
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') {
evalModels.value = (results[2].value || []).filter(
(model) => model.purpose === 'evaluation' || (model.model_source === 'api' && !!model.api_url),
)
}
if (results[3].status === 'fulfilled') {
gpus.value = ((results[3].value || []) as unknown as GpuInfo[]).filter((g) => g.status === 'idle')
}
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()
// GPU 选择为「节点:GPU序号」复合值解析出节点与 GPU 序号,
// 多算力节点时必须把节点信息传给后端,否则会派发到错误的算力节点
const [gpuNodeId, gpuIndex] = String(taskForm.value.gpu_id).split(':')
const evalResult: any = await startEval({
eval_task_name: taskForm.value.eval_task_name,
eval_type: 'custom',
model_id: taskForm.value.model_id,
gpu_id: Number(gpuIndex) || 0,
compute_node_id: gpuNodeId || '',
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,
},
})
if (evalResult?.status === 'failed' || evalResult?.error) {
ElMessage.error(`评测启动失败:${evalResult?.error || '请检查算力节点与模型路径'}`)
return
}
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>