feat: 模型推理异步加载与对话链路修复,同步基线
模型推理全异步化改造: - 计算节点 InferenceSession 改为后台线程异步加载模型,load 立即返回, 加载期间事件循环保持响应(/inference/status 与 /health 不阻塞) - 后端模型加载改为异步派发 + 轮询对账器(reconcile_inference_loads), 任务状态由 starting 自动推进到 ready/error,解决多节点启动超时 (timeout of 120000ms exceeded) - 推理删除/卸载改为任务感知 + 短超时,删除先删记录再 best-effort 卸载, 不再被不可达节点阻塞;同节点新模型替换旧任务标记失效 - 流式对话透传 task_id/node_id 路由到真正加载模型的算力节点, useStreamChat 解析 SSE 错误帧以干净文案展示 - 对话历史按任务 id 本地持久化,退出重进可恢复;移除页脚提示文本 - 新增后端推理异步加载与计算节点异步状态机单元测试 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import PageCard from '@/components/PageCard.vue'
|
||||
import { getModelList, getTrainedModels } from '@/api/modules/model'
|
||||
import { getSystemInfo } from '@/api/modules/system'
|
||||
import { getComputeNodes, type ComputeNode } from '@/api/modules/compute'
|
||||
import { createCompare, preloadLocalModel, preloadTrainedModel } from '@/api/modules/compare'
|
||||
import { createCompare, loadCompare } from '@/api/modules/compare'
|
||||
import type { ModelItem, TrainedModel, GpuInfo } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -27,6 +27,8 @@ interface SelectableModel {
|
||||
name: string
|
||||
source: 'database' | 'trained'
|
||||
model_path: string
|
||||
compute_node_id?: string
|
||||
compute_node_name?: string
|
||||
merged?: boolean
|
||||
merging?: boolean
|
||||
disabled?: boolean
|
||||
@@ -51,6 +53,8 @@ const trainedOptions = computed<SelectableModel[]>(() =>
|
||||
name: m.name,
|
||||
source: 'trained',
|
||||
model_path: m.merged_path || m.base_model_path || '',
|
||||
compute_node_id: m.compute_node_id,
|
||||
compute_node_name: m.compute_node_name,
|
||||
merged: m.merged,
|
||||
merging: m.merging,
|
||||
disabled: m.merged === false,
|
||||
@@ -80,7 +84,7 @@ const form = reactive({
|
||||
/** 选中的模型 key(单选) */
|
||||
model_key: '',
|
||||
/** 使用的 GPU */
|
||||
gpu_id: 0,
|
||||
gpu_key: '',
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
@@ -90,6 +94,13 @@ const rules: FormRules = {
|
||||
|
||||
/** 当前选中的模型对象 */
|
||||
const selectedModel = computed(() => modelMap.value[form.model_key])
|
||||
const selectedGpu = computed(() => idleGpus.value.find((g) => `${g.node_id || ''}:${g.id ?? 0}` === form.gpu_key))
|
||||
|
||||
watch(selectedModel, (model) => {
|
||||
if (!model?.compute_node_id) return
|
||||
const gpu = idleGpus.value.find((item) => item.node_id === model.compute_node_id)
|
||||
if (gpu) form.gpu_key = `${gpu.node_id || ''}:${gpu.id ?? 0}`
|
||||
})
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formRef.value) return
|
||||
@@ -101,62 +112,47 @@ async function handleSubmit() {
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
startupStatus.value = '正在启动模型服务...'
|
||||
startupStatus.value = '正在创建推理任务...'
|
||||
try {
|
||||
// Step 1: 将模型加载到算力节点
|
||||
const preloadPayload = {
|
||||
model_name_or_path: m.model_path,
|
||||
model_name: m.name,
|
||||
template: 'qwen',
|
||||
}
|
||||
let preloadResult: any
|
||||
if (m.source === 'trained') {
|
||||
preloadResult = await preloadTrainedModel(preloadPayload)
|
||||
} else {
|
||||
preloadResult = await preloadLocalModel(preloadPayload)
|
||||
}
|
||||
|
||||
if (preloadResult && (preloadResult as any).error) {
|
||||
ElMessage.warning(`模型加载失败:${(preloadResult as any).error}`)
|
||||
submitting.value = false
|
||||
startupStatus.value = ''
|
||||
if (!m.model_path) {
|
||||
ElMessage.warning('当前模型未配置算力节点可访问路径,请先在模型管理中维护模型路径')
|
||||
return
|
||||
}
|
||||
|
||||
// Step 2: 创建推理任务记录
|
||||
// Step 1: 创建推理任务记录
|
||||
const taskResult = await createCompare({
|
||||
name: form.name || m.name,
|
||||
description: form.description,
|
||||
status: 'pending',
|
||||
models: [
|
||||
{
|
||||
model_id: String(m.id),
|
||||
model_name: m.name,
|
||||
model_path: m.model_path,
|
||||
source: m.source,
|
||||
gpu_id: form.gpu_id,
|
||||
gpu_id: selectedGpu.value?.id ?? 0,
|
||||
node_id: selectedGpu.value?.node_id || m.compute_node_id,
|
||||
node_name: selectedGpu.value?.node_name || m.compute_node_name,
|
||||
},
|
||||
],
|
||||
})
|
||||
const taskId = taskResult?.id || 'unknown'
|
||||
|
||||
ElMessage.success('模型已启动')
|
||||
router.push({
|
||||
path: `/model-inference/chat/${taskId}`,
|
||||
query: {
|
||||
model: m.name,
|
||||
source: m.source,
|
||||
model_path: m.model_path,
|
||||
},
|
||||
})
|
||||
// Step 2: 统一通过推理任务加载接口异步派发模型加载,状态会落到列表记录中。
|
||||
startupStatus.value = '正在启动模型服务,首次加载可能需要数分钟...'
|
||||
const loadResult: any = await loadCompare(taskId)
|
||||
if (loadResult?.status === 'failed' || loadResult?.error) {
|
||||
ElMessage.warning(`模型加载失败:${loadResult?.error || '请检查算力节点日志'}`)
|
||||
router.push('/model-inference')
|
||||
return
|
||||
}
|
||||
|
||||
// 加载为异步派发,回到列表页可看到“启动中 → 已就绪”的状态流转
|
||||
ElMessage.success('模型加载中,就绪后即可对话')
|
||||
router.push('/model-inference')
|
||||
} catch (e: any) {
|
||||
// 真实 API 失败时回退到 mock 模式(方便无算力节点的开发调试)
|
||||
const m = selectedModel.value!
|
||||
const reason = e?.message || e?.toString() || '未知错误'
|
||||
ElMessage.warning(`推理服务启动失败:${reason},进入 mock 演示模式`)
|
||||
router.push({
|
||||
path: '/model-inference/chat/mock',
|
||||
query: { model: m.name },
|
||||
})
|
||||
ElMessage.warning(`推理服务启动失败:${reason}`)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
startupStatus.value = ''
|
||||
@@ -181,7 +177,10 @@ async function loadData() {
|
||||
gpus.value = sys?.gpu || []
|
||||
computeNodes.value = nodes || []
|
||||
// 默认选中第一个空闲 GPU
|
||||
if (idleGpus.value.length > 0) form.gpu_id = idleGpus.value[0].id ?? 0
|
||||
if (idleGpus.value.length > 0) {
|
||||
const firstGpu = idleGpus.value[0]
|
||||
form.gpu_key = `${firstGpu.node_id || ''}:${firstGpu.id ?? 0}`
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -229,12 +228,12 @@ onMounted(loadData)
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="GPU">
|
||||
<el-select v-model="form.gpu_id" style="width: 400px">
|
||||
<el-select v-model="form.gpu_key" style="width: 400px">
|
||||
<el-option
|
||||
v-for="g in idleGpus"
|
||||
:key="g.id ?? 0"
|
||||
:label="`${g.name} (GPU${g.id ?? 0}) [空闲]`"
|
||||
:value="g.id ?? 0"
|
||||
:key="`${g.node_id || ''}:${g.id ?? 0}`"
|
||||
:label="`${g.node_name || g.node_code || '算力节点'} / ${g.name} (GPU${g.id ?? 0}) [空闲]`"
|
||||
:value="`${g.node_id || ''}:${g.id ?? 0}`"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
Reference in New Issue
Block a user