ShadowInk's picture
Upload complete Space runtime files
6bcddd0 verified
Raw
History Blame Contribute Delete
9.68 kB
import asyncio
import json
import os
import time
import re
from threading import Thread
from typing import Any
import modal
APP_NAME = "virtual-characters-llm"
MODEL_ID = os.environ.get("VC_LLM_MODEL", "google/gemma-4-12B-it")
MODEL_REVISION = os.environ.get("VC_LLM_REVISION")
GPU = os.environ.get("VC_LLM_GPU", "L40S")
MODEL_DIR = "/root/.cache/huggingface"
HF_SECRET_NAME = os.environ.get("VC_HF_SECRET_NAME", "hf-token")
HF_SECRETS = [] if os.environ.get("VC_SKIP_HF_SECRET") == "1" else [modal.Secret.from_name(HF_SECRET_NAME)]
image = (
modal.Image.from_registry("nvidia/cuda:12.9.0-devel-ubuntu22.04", add_python="3.12")
.entrypoint([])
.uv_pip_install(
"accelerate>=1.8.0",
"fastapi[standard]>=0.115.0",
"huggingface-hub>=0.36.0",
"librosa>=0.10.2",
"pillow>=11.0.0",
"torch>=2.7.0",
"torchvision>=0.22.0",
"transformers>=4.57.0",
)
.env(
{
"HF_HUB_CACHE": MODEL_DIR,
"HF_XET_HIGH_PERFORMANCE": "1",
}
)
)
hf_cache = modal.Volume.from_name("vc-hf-cache", create_if_missing=True)
app = modal.App(APP_NAME, image=image)
def _sse(event: dict[str, Any]) -> bytes:
return f"data: {json.dumps(event, ensure_ascii=False)}\n\n".encode("utf-8")
def _build_messages(payload: dict[str, Any]) -> list[dict[str, Any]]:
character = payload.get("character", {})
character_name = character.get("display_name") or character.get("id") or "角色"
user_text = payload.get("text") or payload.get("message") or ""
vision_note = payload.get("vision_note")
system_prompt = f"""
你是虚拟角色系统的对话引擎。当前角色:{character_name}
你必须保持角色设定,并用中文优先回答。
输出时自然、有角色感、简短。不要声称自己是真实商业角色或官方角色。
后端会把你的文本切成事件流,所以你只需要输出角色要说的话,不要输出 JSON。
""".strip()
content: list[dict[str, Any]] = []
if vision_note:
content.append({"type": "text", "text": f"视觉观察:{vision_note}"})
for image_url in payload.get("image_urls", []) or []:
content.append({"type": "image", "url": image_url})
for audio_url in payload.get("audio_urls", []) or []:
content.append({"type": "audio", "audio": audio_url})
for video_url in payload.get("video_urls", []) or []:
content.append({"type": "video", "video": video_url})
content.append({"type": "text", "text": user_text})
return [
{"role": "system", "content": system_prompt},
{"role": "user", "content": content},
]
def _guess_stage_event(text: str) -> dict[str, Any] | None:
if any(word in text for word in ["累", "难过", "害怕", "担心", "压力"]):
return {"type": "stage", "expression": "worried", "motion": "gentle_blink", "intensity": 0.7}
if any(word in text for word in ["开心", "喜欢", "太好了", "谢谢"]):
return {"type": "stage", "expression": "smile", "motion": "soft_sway", "intensity": 0.6}
if any(word in text for word in ["为什么", "怎么", "什么", "?", "?"]):
return {"type": "stage", "expression": "thinking", "motion": "look_at_user", "intensity": 0.5}
return None
def _strip_empty_thought_prefix(text: str) -> str:
return re.sub(r"^\s*(?:<\|channel\>)?thought\s*(?:<channel\|>)?", "", text, count=1)
@app.cls(
gpu=GPU,
scaledown_window=60 * 3,
timeout=60 * 20,
secrets=HF_SECRETS,
volumes={MODEL_DIR: hf_cache},
)
class PersonaLLM:
def _ensure_loaded(self):
if getattr(self, "model", None) is not None:
return
import torch
from transformers import AutoModelForMultimodalLM, AutoProcessor
kwargs: dict[str, Any] = {"dtype": "auto", "device_map": "auto"}
if MODEL_REVISION:
kwargs["revision"] = MODEL_REVISION
self.processor = AutoProcessor.from_pretrained(MODEL_ID, revision=MODEL_REVISION)
self.model = AutoModelForMultimodalLM.from_pretrained(MODEL_ID, **kwargs)
self.model.eval()
self.torch = torch
@modal.method()
def health(self) -> dict[str, Any]:
return {"ok": True, "model": MODEL_ID, "gpu": GPU, "loaded": getattr(self, "model", None) is not None}
@modal.method()
def generate_text(self, user_text: str, character: dict[str, Any] | None = None, max_new_tokens: int = 120) -> dict[str, Any]:
self._ensure_loaded()
started = time.perf_counter()
payload = {
"text": user_text,
"character": character or {"display_name": "星萤"},
"max_new_tokens": max_new_tokens,
"temperature": 0.75,
"top_p": 0.95,
}
messages = _build_messages(payload)
try:
inputs = self.processor.apply_chat_template(
messages,
tokenize=True,
return_dict=True,
return_tensors="pt",
add_generation_prompt=True,
enable_thinking=False,
).to(self.model.device)
except TypeError:
inputs = self.processor.apply_chat_template(
messages,
tokenize=True,
return_dict=True,
return_tensors="pt",
add_generation_prompt=True,
).to(self.model.device)
input_len = int(inputs["input_ids"].shape[-1])
with self.torch.inference_mode():
outputs = self.model.generate(
**inputs,
max_new_tokens=max_new_tokens,
do_sample=True,
temperature=0.75,
top_p=0.95,
)
new_token_ids = outputs[0][input_len:]
text = self.processor.decode(new_token_ids, skip_special_tokens=True)
text = _strip_empty_thought_prefix(text).strip()
elapsed = time.perf_counter() - started
return {
"text": text,
"output_tokens": int(new_token_ids.shape[-1]),
"remote_s": round(elapsed, 3),
}
@modal.fastapi_endpoint(method="POST")
async def persona_events(self, request):
from fastapi.responses import StreamingResponse
payload = await request.json()
return StreamingResponse(
self._event_stream(payload),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
def _event_stream(self, payload: dict[str, Any]):
from transformers import TextIteratorStreamer
self._ensure_loaded()
max_new_tokens = int(payload.get("max_new_tokens", 160))
messages = _build_messages(payload)
yield _sse({"type": "stage", "expression": "listening", "motion": "look_at_user", "intensity": 0.4})
yield _sse({"type": "voice", "style": "soft", "speed": 0.96, "energy": 0.45})
yield _sse({"type": "skill", "name": payload.get("skill") or "daily_chat"})
try:
inputs = self.processor.apply_chat_template(
messages,
tokenize=True,
return_dict=True,
return_tensors="pt",
add_generation_prompt=True,
enable_thinking=False,
).to(self.model.device)
except TypeError:
inputs = self.processor.apply_chat_template(
messages,
tokenize=True,
return_dict=True,
return_tensors="pt",
add_generation_prompt=True,
).to(self.model.device)
streamer = TextIteratorStreamer(self.processor.tokenizer, skip_prompt=True, skip_special_tokens=True)
generate_kwargs = {
**inputs,
"streamer": streamer,
"max_new_tokens": max_new_tokens,
"do_sample": True,
"temperature": float(payload.get("temperature", 0.8)),
"top_p": float(payload.get("top_p", 0.95)),
}
thread = Thread(target=self.model.generate, kwargs=generate_kwargs)
thread.start()
buffer = ""
sentence_buffer = ""
last_stage_at = 0.0
first_chunk = True
terminators = "。!?!?;;\n"
for chunk in streamer:
if not chunk:
continue
if first_chunk:
chunk = _strip_empty_thought_prefix(chunk)
first_chunk = False
if not chunk:
continue
buffer += chunk
sentence_buffer += chunk
yield _sse({"type": "text_delta", "text": chunk})
now = time.monotonic()
if now - last_stage_at > 1.0:
stage = _guess_stage_event(buffer[-80:])
if stage:
last_stage_at = now
yield _sse(stage)
if any(mark in chunk for mark in terminators):
text = sentence_buffer.strip()
if text:
yield _sse({"type": "sentence_end", "text": text})
sentence_buffer = ""
thread.join(timeout=2)
if sentence_buffer.strip():
yield _sse({"type": "sentence_end", "text": sentence_buffer.strip()})
yield _sse({"type": "done"})
@app.local_entrypoint()
def main(prompt: str = "你好,今天有点累。请用角色口吻回复我。", max_new_tokens: int = 80):
print("Checking remote health...")
print(PersonaLLM().health.remote())
print("Use `modal deploy modal_apps/modal_llm.py` to deploy the SSE endpoint.")