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:
wuyongtao
2026-08-04 16:59:34 +08:00
parent 250e060271
commit 0271942ba5
21 changed files with 1272 additions and 245 deletions

View File

@@ -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>