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*(?:)?", "", 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.")