feat: 训练任务支持从最新 checkpoint 继续训练

- 后端: 新增 resume/checkpoints 端点,compute 客户端与适配器支持续训
- 计算节点: llama_factory 适配器断点续训支持及测试
- 前端: fine-tune API 封装与列表页续训/断点展示
This commit is contained in:
wuyongtao
2026-08-21 14:48:55 +08:00
parent 0233755859
commit 0b59c1ebee
6 changed files with 276 additions and 7 deletions

View File

@@ -521,6 +521,117 @@ async def _prepare_resource_on_node(store: Any, resource_type: str, resource_id:
return f"/data/yg-ft/{root_name}/{resource_id}" return f"/data/yg-ft/{root_name}/{resource_id}"
async def _archive_training_checkpoints(
store: Any,
task: dict[str, Any],
node: dict[str, Any],
job: dict[str, Any],
) -> dict[str, Any]:
"""Persist checkpoints from a stopped job so resume can use another node."""
task_id = str(task.get("id") or "")
output_dir = str(job.get("output_dir") or task.get("output_dir") or "")
checkpoints = job.get("checkpoints") or store.task_checkpoints(task_id)
if not task_id or not output_dir or not checkpoints:
return store.update_task(
task_id,
{
"checkpoint_archive_status": "not_available",
"checkpoint_archive_error": "停止时没有发现可用 checkpoint",
},
)
if not get_settings().minio_enabled:
return store.update_task(
task_id,
{
"checkpoint_archive_status": "local_only",
"checkpoint_archive_error": "MinIO 未启用,仅支持原算力节点本地继续",
},
)
try:
version_id = str(job.get("id") or task.get("compute_job_id") or task_id)
client = ComputeNodeClient(node["api_base_url"], timeout=900)
archived = await _archive_node_directory(
store,
client,
node,
output_dir,
"fine-tune",
task_id,
version_id,
f"training/{task_id}/checkpoints",
)
return store.update_task(
task_id,
{
"checkpoint_archive_status": "completed" if archived else "empty",
"checkpoint_archive_version_id": version_id,
"checkpoint_archive_object_ids": [str(item["id"]) for item in archived],
"checkpoint_archive_error": "" if archived else "checkpoint 目录为空",
},
)
except Exception as exc: # noqa: BLE001 - stopping must remain successful if archive is delayed
return store.update_task(
task_id,
{
"checkpoint_archive_status": "pending",
"checkpoint_archive_error": str(exc)[:2000],
},
)
async def _prepare_resume_checkpoint(
store: Any,
task: dict[str, Any],
checkpoint: dict[str, Any],
node: dict[str, Any],
) -> str | None:
"""Materialize the selected checkpoint on the node used for resuming."""
task_id = str(task.get("id") or "")
checkpoint_name = str(checkpoint.get("name") or Path(str(checkpoint.get("path") or "")).name)
if not task_id or not checkpoint_name:
return None
# Prefer the archived copy. This makes resume independent of the node that
# originally ran the task and preserves the MinIO-as-source-of-truth rule.
if get_settings().minio_enabled:
prefix = f"training/{task_id}/checkpoints/"
objects = [
item
for item in store.storage_objects_for_resource("fine-tune", task_id)
if str(item.get("object_key") or "").startswith(prefix)
and str(item.get("file_name") or "").startswith(f"{checkpoint_name}/")
]
if objects:
client = ComputeNodeClient(node["api_base_url"], timeout=900)
root = f"/data/yg-ft/fine-tunes/{task_id}/resume/{checkpoint_name}"
for item in objects:
file_name = str(item.get("file_name") or "")
relative = file_name[len(checkpoint_name) + 1 :]
await client.prepare_cache(
{
"resource_id": task_id,
"version_id": item.get("version_id"),
"download_url": get_object_storage().presigned_get(str(item["object_key"])),
"checksum_sha256": item.get("checksum_sha256") or "",
"byte_size": item.get("byte_size") or 0,
"relative_path": f"fine-tunes/{task_id}/resume/{checkpoint_name}/{relative}",
}
)
return root
# Compatibility fallback for installations without MinIO archives: only
# reuse the original path when the same node still owns the files.
original_node_id = str(task.get("compute_node_id") or "")
original_path = str(checkpoint.get("path") or "")
if original_node_id == str(node.get("id") or "") and original_path:
check = await ComputeNodeClient(node["api_base_url"]).check_paths(
[{"path": original_path, "type": "dir", "required": True}]
)
if check.get("valid"):
return original_path
return None
def _build_messages_payload(payload: dict[str, Any]) -> dict[str, Any]: def _build_messages_payload(payload: dict[str, Any]) -> dict[str, Any]:
"""Convert frontend inference payload to compute API messages format. """Convert frontend inference payload to compute API messages format.
@@ -2458,8 +2569,28 @@ async def stop_fine_tune(task_id: str, current_user: dict = Depends(get_current_
return pending return pending
node = _node_for_task(task) node = _node_for_task(task)
if task.get("compute_job_id") and node and get_settings().compute_mode != "simulator": if task.get("compute_job_id") and node and get_settings().compute_mode != "simulator":
job = await ComputeNodeClient(node["api_base_url"]).stop_job(task["compute_job_id"]) try:
return ok(store.apply_compute_job(task_id, job)) job = await ComputeNodeClient(
node["api_base_url"],
timeout=max(float(get_settings().compute_request_timeout_seconds), 30.0),
).stop_job(task["compute_job_id"])
except httpx.TimeoutException as exc:
raise fail(504, "停止训练超时,算力节点未及时响应,请稍后查看任务状态") from exc
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
# The process has already disappeared from the node. Make
# the local task terminal and let the poller release state.
return ok(store.stop_task(task_id))
raise fail(502, f"算力节点拒绝停止训练HTTP {exc.response.status_code}") from exc
except httpx.RequestError as exc:
raise fail(502, f"算力节点不可达,暂时无法停止训练:{exc}") from exc
updated = store.apply_compute_job(task_id, job)
# Stop is terminal for scheduling, but checkpoint archiving is
# best-effort so a slow MinIO service never turns a successful stop
# into a 500 response.
return ok(await _archive_training_checkpoints(store, updated, node, job))
if task.get("compute_job_id") and not node:
raise fail(409, "训练任务关联的算力节点不存在,无法安全停止远程进程")
return ok(store.stop_task(task_id)) return ok(store.stop_task(task_id))
except KeyError: except KeyError:
raise fail(404, "fine tune task not found") raise fail(404, "fine tune task not found")
@@ -2470,6 +2601,76 @@ async def stop_fine_tune_alt(task_id: str, current_user: dict = Depends(get_curr
return await stop_fine_tune(task_id, current_user) return await stop_fine_tune(task_id, current_user)
@router.post("/fine-tune/{task_id}/resume")
@op_log(module=OpModule.FINE_TUNE, action=OpAction.RETRY, target_type="fine_tune", target_name_param="task_id")
async def resume_fine_tune(
task_id: str,
payload: dict[str, Any] | None = Body(default=None),
current_user: dict[str, Any] = Depends(get_current_user),
) -> dict[str, Any]:
"""Resume a stopped/failed training task from its newest checkpoint."""
store = get_platform_store()
payload = payload or {}
try:
task = store.task(task_id)
except KeyError:
raise fail(404, "fine tune task not found")
if not has_resource_access("fine-tune", task_id, current_user, "execute"):
raise fail(403, "no permission to resume this task")
if task.get("status") not in {"stopped", "failed"}:
raise fail(409, "只有已停止或失败的训练任务可以继续")
checkpoints = store.task_checkpoints(task_id)
if not checkpoints:
raise fail(409, "没有可用的训练断点,请确认任务停止前已经生成 checkpoint")
checkpoint = max(checkpoints, key=lambda item: (int(item.get("step") or 0), str(item.get("create_time") or "")))
resume_payload = {
**task,
**payload,
"task_id": task_id,
"id": task_id,
"compute_node_id": payload.get("compute_node_id") or task.get("compute_node_id"),
"gpus": payload.get("gpus") or task.get("gpus") or [],
"strict_node_selection": True,
"resume_checkpoint_id": checkpoint.get("id"),
"resume_checkpoint_name": checkpoint.get("name"),
"resume_from_checkpoint": checkpoint.get("path"),
}
try:
# Select the requested/original node first, materialize the checkpoint,
# then run the normal model/dataset/GPU preflight against that node.
node, _ = store.prepare_compute_job_payload(task_id, resume_payload)
if get_settings().compute_mode == "simulator":
prepared_checkpoint = str(checkpoint.get("path") or "")
else:
prepared_checkpoint = await _prepare_resume_checkpoint(store, task, checkpoint, node)
if not prepared_checkpoint:
raise RuntimeError(
f"断点 {checkpoint.get('name') or checkpoint.get('path')} 不可用:原算力节点文件已不存在,且 MinIO 中没有可恢复副本"
)
resume_payload["resume_from_checkpoint"] = prepared_checkpoint
resume_payload["compute_node_id"] = node["id"]
preflight = await _fine_tune_preflight(
store,
task_id,
resume_payload,
validate=True,
sync_resources=True,
)
if not preflight.get("valid"):
errors = "; ".join(preflight.get("errors") or ["继续训练预检未通过"])
raise RuntimeError(errors)
store.reset_task_for_retry(task_id, resume_payload)
return ok(await _submit_fine_tune_task(store, resume_payload))
except RuntimeError as exc:
raise fail(409, f"继续训练预检未通过:{exc}") from exc
except httpx.TimeoutException as exc:
raise fail(504, "继续训练预检超时,请确认算力节点和 MinIO 服务可达") from exc
except Exception as exc: # noqa: BLE001 - keep the resource diagnosis visible to the user
store.mark_task_failed(task_id, str(exc))
raise fail(502, f"继续训练失败:{exc}") from exc
@router.post("/fine-tune/{task_id}/retry") @router.post("/fine-tune/{task_id}/retry")
@op_log(module=OpModule.FINE_TUNE, action=OpAction.RETRY, target_type="fine_tune", target_name_param="task_id") @op_log(module=OpModule.FINE_TUNE, action=OpAction.RETRY, target_type="fine_tune", target_name_param="task_id")
async def retry_fine_tune(task_id: str, payload: dict[str, Any] | None = Body(default=None), current_user: dict = Depends(get_current_user)) -> dict[str, Any]: async def retry_fine_tune(task_id: str, payload: dict[str, Any] | None = Body(default=None), current_user: dict = Depends(get_current_user)) -> dict[str, Any]:

View File

@@ -158,7 +158,12 @@ class ComputeNodeClient:
return _unwrap_dict(response.json()) return _unwrap_dict(response.json())
async def stop_job(self, job_id: str) -> dict[str, Any]: async def stop_job(self, job_id: str) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=self.timeout, headers=self.headers()) as client: # Compute API waits for the child process to exit (up to 10 seconds)
# before returning. The normal polling timeout is intentionally short,
# but is too aggressive for a stop request and used to surface as a
# platform 500 even when the node eventually stopped the job.
stop_timeout = max(float(self.timeout), 30.0)
async with httpx.AsyncClient(timeout=stop_timeout, headers=self.headers()) as client:
response = await client.post(_join_url(self.api_base_url, f"{self.route_prefix}/compute/jobs/{job_id}/stop")) response = await client.post(_join_url(self.api_base_url, f"{self.route_prefix}/compute/jobs/{job_id}/stop"))
response.raise_for_status() response.raise_for_status()
return _unwrap_dict(response.json()) return _unwrap_dict(response.json())

View File

@@ -336,6 +336,7 @@ def build_command(config: dict[str, Any], llama_factory_home: str = "/app/LLaMA-
_optional_arg(config, command, "--val_size", "val_size") _optional_arg(config, command, "--val_size", "val_size")
_optional_arg(config, command, "--max_samples", "max_samples") _optional_arg(config, command, "--max_samples", "max_samples")
_optional_arg(config, command, "--preprocessing_num_workers", "preprocessing_num_workers") _optional_arg(config, command, "--preprocessing_num_workers", "preprocessing_num_workers")
_optional_arg(config, command, "--resume_from_checkpoint", "resume_from_checkpoint")
_optional_bool_arg(config, command, "--fp16", "fp16") _optional_bool_arg(config, command, "--fp16", "fp16")
_optional_bool_arg(config, command, "--bf16", "bf16") _optional_bool_arg(config, command, "--bf16", "bf16")
quantization_bit = int(config.get("quantization_bit", 0) or 0) quantization_bit = int(config.get("quantization_bit", 0) or 0)

View File

@@ -25,6 +25,22 @@ def test_build_command_uses_explicit_validation_dataset_without_resplitting() ->
assert "--val_size" not in result.command assert "--val_size" not in result.command
def test_build_command_resumes_from_checkpoint() -> None:
result = build_command(
{
"base_model": "/models/qwen",
"dataset": "ygft_dataset_train",
"dataset_dir": "/datasets/example",
"output_dir": "/outputs/example",
"resume_from_checkpoint": "/data/yg-ft/fine-tunes/ft_1/resume/checkpoint-50",
}
)
assert result.command[result.command.index("--resume_from_checkpoint") + 1] == (
"/data/yg-ft/fine-tunes/ft_1/resume/checkpoint-50"
)
def _write(tmp_path, name: str, lines: list[dict]) -> object: def _write(tmp_path, name: str, lines: list[dict]) -> object:
path = tmp_path / name path = tmp_path / name
path.write_text( path.write_text(

View File

@@ -49,6 +49,15 @@ export interface FineTuneGpuStatus {
error?: string error?: string
} }
export interface FineTuneCheckpoint {
id: string
step: number
name: string
path: string
size_bytes?: number
create_time?: string
}
/** 训练任务列表 */ /** 训练任务列表 */
export const getFineTuneList = () => get<FineTuneTask[]>('/fine-tune') export const getFineTuneList = () => get<FineTuneTask[]>('/fine-tune')
@@ -89,6 +98,13 @@ export const updateFineTune = (id: string | number, data: Partial<FineTuneTask>)
/** 停止训练任务 */ /** 停止训练任务 */
export const stopFineTune = (id: string | number) => post(`/fine-tune/stop/${id}`) export const stopFineTune = (id: string | number) => post(`/fine-tune/stop/${id}`)
/** 从最新 checkpoint 继续训练 */
export const resumeFineTune = (id: string | number) => post(`/fine-tune/${id}/resume`)
/** 获取训练断点 */
export const getFineTuneCheckpoints = (id: string | number) =>
get<FineTuneCheckpoint[]>(`/fine-tune/${id}/checkpoints`)
/** 删除训练任务 */ /** 删除训练任务 */
export const deleteFineTune = (id: string | number) => del(`/fine-tune/${id}`) export const deleteFineTune = (id: string | number) => del(`/fine-tune/${id}`)

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import DataTablePage from '@/components/DataTablePage.vue' import DataTablePage from '@/components/DataTablePage.vue'
import ModelStatusTag from '@/components/ModelStatusTag.vue' import ModelStatusTag from '@/components/ModelStatusTag.vue'
import { usePolling } from '@/composables/usePolling' import { usePolling } from '@/composables/usePolling'
@@ -10,6 +10,7 @@ import {
getFineTuneList, getFineTuneList,
deleteFineTune, deleteFineTune,
stopFineTune, stopFineTune,
resumeFineTune,
getFineTuneProgress, getFineTuneProgress,
getFineTune, getFineTune,
} from '@/api/modules/fineTune' } from '@/api/modules/fineTune'
@@ -87,9 +88,29 @@ async function handleDelete(row: any) {
} }
async function handleStop(row: any) { async function handleStop(row: any) {
await ElMessageBox.confirm(
'停止后将保留已生成的 checkpoint可在资源可用时继续训练。是否停止当前任务',
'停止训练',
{ type: 'warning' },
)
await stopFineTune(row.id) await stopFineTune(row.id)
ElMessage.success('训练任务已停止') ElMessage.success('训练任务已停止,可继续训练')
loadData() await loadData()
}
async function handleResume(row: any) {
await ElMessageBox.confirm(
'系统将使用最新 checkpoint 继续训练,并重新检查基座模型、数据集、算力节点和 GPU 是否可用。是否继续?',
'继续训练',
{ type: 'info' },
)
try {
await resumeFineTune(row.id)
ElMessage.success('继续训练已提交')
await loadData()
} catch {
// 请求拦截器已展示后端返回的具体预检原因
}
} }
function viewLog(row: any) { function viewLog(row: any) {
@@ -217,7 +238,7 @@ onMounted(async () => {
<template #actions="{ row }"> <template #actions="{ row }">
<div class="action-buttons"> <div class="action-buttons">
<el-button <el-button
v-if="row.status === 'running'" v-if="['syncing', 'queued', 'running'].includes(row.status)"
type="warning" type="warning"
link link
size="small" size="small"
@@ -225,6 +246,15 @@ onMounted(async () => {
> >
<i class="fa fa-stop-circle-o" style="margin-right: 4px" /> 停止 <i class="fa fa-stop-circle-o" style="margin-right: 4px" /> 停止
</el-button> </el-button>
<el-button
v-if="row.status === 'stopped' || row.status === 'failed'"
type="success"
link
size="small"
@click="handleResume(row)"
>
<i class="fa fa-play-circle-o" style="margin-right: 4px" /> 继续
</el-button>
<el-button type="primary" link size="small" @click="viewLog(row)"> <el-button type="primary" link size="small" @click="viewLog(row)">
<i class="fa fa-file-text-o" style="margin-right: 4px" /> 日志 <i class="fa fa-file-text-o" style="margin-right: 4px" /> 日志
</el-button> </el-button>