feat: 模型推理端到端闭环 — 真实流式推理 + 释放/删除 + GPU 状态同步

后端 (platform.py + platform_store.py):
- 新增 _build_messages_payload() 转换前端格式为 OpenAI messages
- 新增 _stream_chat_proxy() SSE 流式代理到算力节点
- 新增 _unload_from_compute_node() 真正释放算力节点 GPU 显存
- 重写 model_compare_load: 从假 PID/端口改为真正调用算力节点加载模型
- 修复 model_compare_unload: 调用 _unload_from_compute_node 释放 GPU
- 修复 model_compare_delete: 先释放 GPU 再删除记录
- 修复 model_compare_stream_chat: 从 mock 改为 StreamingResponse 代理
- 修复 model_chat_local/stream: 消息格式转换 + 路径修正
- PlatformStore 新增 _inference_nodes 追踪,gpus() 同步推理占用状态
- preload/unload 端点标记/清除推理节点占用

算力节点 (compute):
- inference.py: 适配新版 LLaMA-Factory API (get_infer_args 4 返回值、ChatModel args dict、stream_chat 新签名)
- inference.py: unload() 增加 gc.collect + torch.cuda.empty_cache + synchronize 彻底释放显存
- main.py: inference/load 移除 HTTPException(500),错误以 200 正常返回

前端:
- InferenceChatView: 真实模式下走 SSE 流式推理,mock 模式保留兼容
- InferenceCreateView: 调用 preloadLocalModel + createCompare 真实创建推理任务,失败回退 mock
- InferenceListView: 「停止」改为「释放」,删除前先释放算力节点,改进错误提示
- compare.ts: 新增 streamChatReal() fetch SSE,preload 超时提升至 5 分钟
- useStreamChat.ts: send() 支持 useMock 参数,真实模式调用 streamChatReal
- GPU 选择过滤: 仅显示在线算力节点上的空闲 GPU

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
wuyongtao
2026-07-28 17:29:16 +08:00
parent f917a025e1
commit c7c9ed925b
10 changed files with 331 additions and 113 deletions

View File

@@ -31,6 +31,62 @@ def _select_first_online_node(store: Any) -> dict[str, Any] | None:
return None
def _build_messages_payload(payload: dict[str, Any]) -> dict[str, Any]:
"""Convert frontend inference payload to compute API messages format.
Accepts both:
- OpenAI-style: {messages: [{role, content}, ...], temperature, ...}
- Frontend-style: {user_question, system_prompt, temperature, ...}
"""
if payload.get("messages"):
messages = payload["messages"]
# messages already in OpenAI format; pass through with optional system prompt
if payload.get("system_prompt") and not any(m.get("role") == "system" for m in messages):
messages = [{"role": "system", "content": payload["system_prompt"]}] + list(messages)
else:
messages = []
if payload.get("system_prompt"):
messages.append({"role": "system", "content": payload["system_prompt"]})
question = payload.get("user_question") or payload.get("question") or ""
if question:
messages.append({"role": "user", "content": question})
return {
"messages": messages,
"temperature": float(payload.get("temperature", 0.7)),
"top_p": float(payload.get("top_p", 0.95)),
"max_new_tokens": int(payload.get("max_tokens", 2048)),
"do_sample": bool(payload.get("do_sample", True)),
}
async def _stream_chat_proxy(payload: dict[str, Any]) -> StreamingResponse:
"""Common SSE streaming proxy: convert payload → forward to compute node → stream back."""
store = get_platform_store()
node = _select_first_online_node(store)
if not node:
return StreamingResponse(
iter(['data: {"error": "no online compute node available for inference"}\n\n']),
media_type="text/event-stream",
)
client = ComputeNodeClient(node["api_base_url"])
compute_payload = _build_messages_payload(payload)
async def stream_proxy():
async with httpx.AsyncClient(timeout=300) as http:
url = f"{node['api_base_url'].rstrip('/')}{client.route_prefix}/inference/chat/stream"
try:
async with http.stream("POST", url, json=compute_payload, headers=client.headers()) as resp:
if resp.status_code >= 400:
yield f'data: {{"error": "compute node returned {resp.status_code}"}}\n\n'.encode()
return
async for chunk in resp.aiter_bytes():
yield chunk
except Exception as exc:
yield f'data: {{"error": "stream proxy failed: {exc}"}}\n\n'.encode()
return StreamingResponse(stream_proxy(), media_type="text/event-stream")
def fail(status_code: int, message: str) -> HTTPException:
return HTTPException(status_code=status_code, detail={"code": status_code, "message": message, "data": None})
@@ -1022,8 +1078,27 @@ async def model_compare_detail(task_id: str) -> dict[str, Any]:
raise fail(404, "compare task not found")
async def _unload_from_compute_node() -> dict[str, Any]:
"""Best-effort unload the inference model from the first online compute node."""
store = get_platform_store()
# Clear all inference tracking — only one model can be loaded at a time
for node in store.compute_nodes():
store.mark_inference_unloaded(node["id"])
node = _select_first_online_node(store)
if not node:
return {"unloaded": False, "error": "no online compute node"}
try:
client = ComputeNodeClient(node["api_base_url"])
result = await client._request("POST", "/inference/unload", json_data={})
return result
except Exception as exc:
return {"unloaded": False, "error": str(exc)}
@router.delete("/model-compare/{task_id}")
async def model_compare_delete(task_id: str) -> dict[str, Any]:
# 删除前先释放算力节点上的模型
await _unload_from_compute_node()
get_platform_store().delete_compare_task(task_id)
return ok({"deleted": task_id})
@@ -1053,26 +1128,56 @@ async def model_compare_update_load_status(task_id: str, payload: dict[str, Any]
@router.post("/model-compare/{task_id}/load")
async def model_compare_load(task_id: str) -> dict[str, Any]:
"""真正加载模型到算力节点(不再使用假 PID/端口)。"""
try:
task = get_platform_store().compare_task(task_id)
store = get_platform_store()
task = store.compare_task(task_id)
models = task.get("models") or []
if isinstance(models, str):
try:
models = json.loads(models)
except json.JSONDecodeError:
models = []
loaded_models = [
{
"model_id": item.get("model_id"),
"model_name": item.get("model_name"),
"status": "ready",
"pid": 45000 + index,
"port": item.get("port") or 18000 + index,
# 选取在线算力节点
node = _select_first_online_node(store)
if not node:
return ok({"status": "failed", "error": "no online compute node"})
client = ComputeNodeClient(node["api_base_url"])
loaded_models = []
for item in models:
if not isinstance(item, dict):
continue
model_path = item.get("model_path", "")
if not model_path:
# 尝试从模型库获取路径
model_id = item.get("model_id", "")
try:
db_model = store.model(model_id)
model_path = db_model.get("path", "")
except KeyError:
pass
if not model_path:
loaded_models.append({**item, "status": "error", "error": "model_path not found"})
continue
# 真正调用算力节点加载模型
load_payload = {
"model_name_or_path": model_path,
"template": item.get("template", "qwen"),
}
for index, item in enumerate(models)
if isinstance(item, dict)
]
return ok(get_platform_store().update_compare_task(task_id, {"status": "loaded", "load_status": {"loaded_models": loaded_models}}))
if item.get("adapter_path"):
load_payload["adapter_name_or_path"] = item["adapter_path"]
try:
result = await client._request("POST", "/inference/load", json_data=load_payload)
if result.get("loaded"):
store.mark_inference_loaded(node["id"])
loaded_models.append({**item, "status": "ready"})
else:
loaded_models.append({**item, "status": "error", "error": result.get("error", "load failed")})
except Exception as exc:
loaded_models.append({**item, "status": "error", "error": str(exc)})
status = "loaded" if any(m.get("status") == "ready" for m in loaded_models) else "failed"
updated = store.update_compare_task(task_id, {"status": status, "load_status": {"loaded_models": loaded_models}})
return ok(updated)
except KeyError:
raise fail(404, "compare task not found")
@@ -1080,7 +1185,11 @@ async def model_compare_load(task_id: str) -> dict[str, Any]:
@router.post("/model-compare/{task_id}/unload")
async def model_compare_unload(task_id: str) -> dict[str, Any]:
try:
return ok(get_platform_store().update_compare_task(task_id, {"status": "pending", "load_status": {"loaded_models": []}}))
store = get_platform_store()
# 真正释放算力节点上的模型资源
unload_result = await _unload_from_compute_node()
updated = store.update_compare_task(task_id, {"status": "pending", "load_status": {"loaded_models": []}})
return ok({"task": updated, "unload": unload_result})
except KeyError:
raise fail(404, "compare task not found")
@@ -1092,18 +1201,23 @@ async def model_compare_start_model(task_id: str, payload: dict[str, Any] = Body
@router.post("/model-compare/chat-with-port")
async def model_compare_chat_with_port(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
question = ""
for message in payload.get("messages") or []:
if message.get("role") == "user":
question = str(message.get("content") or "")
content = f"当前后端已收到推理请求:{question[:120]}"
return ok({"response": content, "content": content})
"""Proxy non-streaming chat to the compute node running the inference model."""
store = get_platform_store()
node = _select_first_online_node(store)
if not node:
return ok({"response": "no online compute node available for inference", "request": payload})
try:
client = ComputeNodeClient(node["api_base_url"])
result = await client._request("POST", "/inference/chat", json_data=_build_messages_payload(payload))
return ok(result)
except Exception as exc:
return ok({"response": f"inference failed: {exc}", "request": payload})
@router.post("/model-compare/stream-chat")
async def model_compare_stream_chat(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
question = payload.get("user_question") or payload.get("question") or ""
return ok({"response": f"当前后端已收到流式推理请求:{str(question)[:120]}"})
async def model_compare_stream_chat(payload: dict[str, Any] = Body(...)) -> StreamingResponse:
"""Stream chat from the compute node (SSE proxy)."""
return await _stream_chat_proxy(payload)
@router.post("/model-chat/batch")
@@ -1120,7 +1234,7 @@ async def model_chat_local(payload: dict[str, Any] = Body(...)) -> dict[str, Any
return ok({"response": "no online compute node available for inference", "request": payload})
try:
client = ComputeNodeClient(node["api_base_url"])
result = await client._request("POST", "/inference/chat", json_data=payload)
result = await client._request("POST", "/inference/chat", json_data=_build_messages_payload(payload))
return ok(result)
except Exception as exc:
return ok({"response": f"inference failed: {exc}", "request": payload})
@@ -1129,28 +1243,15 @@ async def model_chat_local(payload: dict[str, Any] = Body(...)) -> dict[str, Any
@router.post("/model-chat/local/chat/stream")
async def model_chat_local_stream(payload: dict[str, Any] = Body(...)) -> StreamingResponse:
"""Stream chat from the compute node."""
store = get_platform_store()
node = _select_first_online_node(store)
if not node:
return StreamingResponse(
iter(['data: {"error": "no online compute node"}\n\n']),
media_type="text/event-stream",
)
client = ComputeNodeClient(node["api_base_url"])
async def stream_proxy():
async with httpx.AsyncClient(timeout=300) as http:
url = f"{node['api_base_url'].rstrip('/')}/modelTF/inference/chat/stream"
async with http.stream("POST", url, json=payload, headers=client.headers()) as resp:
async for chunk in resp.aiter_bytes():
yield chunk
return StreamingResponse(stream_proxy(), media_type="text/event-stream")
return await _stream_chat_proxy(payload)
@router.post("/model-chat/local/preload")
async def model_chat_local_preload(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
"""Load a model on the compute node for inference."""
model_path = (payload.get("model_name_or_path") or "").strip()
if not model_path:
return ok({"loaded": False, "error": "model_name_or_path is required"})
store = get_platform_store()
node = _select_first_online_node(store)
if not node:
@@ -1158,6 +1259,8 @@ async def model_chat_local_preload(payload: dict[str, Any] = Body(...)) -> dict[
try:
client = ComputeNodeClient(node["api_base_url"])
result = await client._request("POST", "/inference/load", json_data=payload)
if result.get("loaded"):
store.mark_inference_loaded(node["id"])
return ok(result)
except Exception as exc:
return ok({"loaded": False, "error": str(exc)})
@@ -1167,6 +1270,9 @@ async def model_chat_local_preload(payload: dict[str, Any] = Body(...)) -> dict[
async def model_chat_local_unload() -> dict[str, Any]:
"""Unload the inference model from the compute node."""
store = get_platform_store()
# Clear all inference tracking
for n in store.compute_nodes():
store.mark_inference_unloaded(n["id"])
node = _select_first_online_node(store)
if not node:
return ok({"unloaded": False, "error": "no online compute node"})
@@ -1196,6 +1302,9 @@ async def model_chat_local_status() -> dict[str, Any]:
@router.post("/model-chat/trained/preload")
async def model_chat_trained_preload(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
"""Load a trained model (base + adapter) on the compute node for inference."""
model_path = (payload.get("model_name_or_path") or "").strip()
if not model_path:
return ok({"loaded": False, "error": "model_name_or_path is required"})
store = get_platform_store()
node = _select_first_online_node(store)
if not node:
@@ -1203,6 +1312,8 @@ async def model_chat_trained_preload(payload: dict[str, Any] = Body(...)) -> dic
try:
client = ComputeNodeClient(node["api_base_url"])
result = await client._request("POST", "/inference/load", json_data=payload)
if result.get("loaded"):
store.mark_inference_loaded(node["id"])
return ok(result)
except Exception as exc:
return ok({"loaded": False, "error": str(exc)})

View File

@@ -293,6 +293,19 @@ class PlatformStore:
self.database_url = _psycopg_url(database_url or settings.database_url)
self.ensure_schema()
self.ensure_seed_data()
# Track which compute nodes have an active inference model loaded
self._inference_nodes: set[str] = set()
# ── inference node tracking ────────────────────────────────────
def mark_inference_loaded(self, node_id: str) -> None:
self._inference_nodes.add(node_id)
def mark_inference_unloaded(self, node_id: str) -> None:
self._inference_nodes.discard(node_id)
def is_inference_loaded(self, node_id: str) -> bool:
return node_id in self._inference_nodes
@contextmanager
def connect(self) -> Iterator["PgConnection"]:
@@ -2698,6 +2711,11 @@ class PlatformStore:
)
busy = task is not None and task.get("status") == "running"
reserved = task is not None and task.get("status") in {"syncing", "queued"}
# Also mark GPU as busy if an inference model is loaded on this node
inference_busy = self.is_inference_loaded(row["node_id"])
if inference_busy and not busy:
busy = True
reserved = False
memory_used = round(row["memory_total_gb"] * (0.72 if busy else 0.18 if reserved else 0.04), 1)
gpu_percent = 86 if busy else 22 if reserved else 3
memory_total = float(row["memory_total_gb"] or 0)