virtual-characters / DEVELOPMENT_GUIDE.md
ShadowInk's picture
Deploy Virtual Characters for Build Small Hackathon
005e075 verified
|
Raw
History Blame Contribute Delete
13.5 kB

A newer version of the Gradio SDK is available: 6.20.0

Upgrade

开发规格

目标

后续开发要围绕一个目标:做出低延迟、有角色存在感的多模态虚拟角色。

不要把 MVP 做成普通 chatbot,也不要把用户自定义角色创建器放在第一屏。

推荐文件结构

Virtual-characters/
  app.py
  requirements.txt
  README.md
  PROJECT_DESIGN.md
  RESEARCH_NOTES.md
  DEVELOPMENT_GUIDE.md
  src/
    character_registry.py
    persona_skills.py
    dialogue_engine.py
    stream_protocol.py
    stage_driver.py
    tts_engine.py
    vision_engine.py
    image_engine.py
  assets/
    characters/
    generated/
    live2d/

Gradio 页面结构

使用 gr.Blocks,不要只用一个 ChatInterface 包到底。

建议布局:

左侧:角色选择、角色简介、模式开关
中间:角色舞台 gr.HTML
右侧:Chatbot、输入框、Audio 输出
底部或折叠区:事件流、当前情绪、skill、模型调试信息

推荐组件:

  • gr.Radio / gr.Dropdown:选择角色。
  • gr.Chatbot:聊天历史。
  • gr.Textbox:文字输入。
  • gr.Audio(streaming=True, autoplay=True):播放 TTS 音频块。
  • gr.Image(sources=["upload", "webcam"]):上传图片或摄像头拍照。
  • gr.HTML:角色舞台。
  • gr.JSON / gr.Code:展示事件流和调试信息。
  • gr.State:保存当前角色、短期记忆、当前情绪、视觉观察、事件历史。

角色配置

每个内置角色是一套 CharacterPackage,不是单独一段 prompt。

{
  "id": "star_knight",
  "display_name": "星萤",
  "inspiration": "Firefly-like sci-fi heroine, originalized",
  "profile": {
    "identity": "星港失事后幸存的装甲驾驶员",
    "core_traits": ["温柔", "克制", "隐藏痛苦", "战斗时冷静"],
    "relationship_to_user": "把用户当成临时通讯频道里的同伴",
    "boundaries": ["不声称自己是官方角色", "不复述商业 IP 的完整剧情"]
  },
  "dialogue_style": {
    "tone": "轻声、真诚、偶尔停顿",
    "sentence_shape": "短句为主",
    "catchphrases": ["我还在。", "别担心,我会守住这里。"]
  },
  "skills": ["daily_chat", "emotional_support", "lore_hint", "battle_focus"],
  "voice": {
    "tts_model": "kokoro_or_other",
    "voice_preset": "soft_young_female",
    "pace": "slow",
    "emotion_strength": 0.6
  },
  "visual": {
    "mode": "static_image_or_live2d",
    "image_prompt": "original anime sci-fi girl, silver hair, teal eyes, light armor",
    "expressions": ["idle", "smile", "worried", "thinking", "battle_focus"]
  }
}

流式设计原则

不使用“完整 JSON 后处理”作为主链路

如果模型必须先完整生成:

{
  "reply_text": "...很长一段话...",
  "emotion": "...",
  "motion": "...",
  "voice": {}
}

那么页面必须等 JSON 结束才能知道角色该做什么。用户会看到角色呆住,TTS 也无法尽早开始。

主链路应改成 事件流协议:模型一边生成,后端一边解析,Gradio 一边 yield 更新 UI。

推荐协议:SSE + JSON 事件

外部接口优先使用 SSE,也就是 text/event-stream。每个 SSE frame 的 data: 里放一个完整 JSON 事件。这样浏览器、Modal、FastAPI 和 OpenAI-compatible 流式接口都更容易衔接。

内部日志和测试文件可以保存成 NDJSON。也就是一行一个 JSON 事件。NDJSON 适合落盘和调试,但不要把“模型必须原生输出合法 NDJSON”作为唯一方案。

示例:

{"type":"stage","expression":"thinking","motion":"look_down","duration_ms":600}
{"type":"voice","style":"soft","speed":0.92,"pitch":1.04}
{"type":"skill","name":"emotional_support"}
{"type":"text_delta","text":"嗯,"}
{"type":"stage","expression":"worried","motion":"gentle_blink"}
{"type":"text_delta","text":"我在听。你今天好像比平时更累一点。"}
{"type":"sentence_end"}
{"type":"text_delta","text":"先别急着解释,坐一会儿也可以。"}
{"type":"sentence_end"}
{"type":"stage","expression":"soft_smile","motion":"idle"}
{"type":"done"}

优点:

  • stage 可以先到,角色先抬头、思考、眨眼。
  • voice 可以先到,TTS 知道第一句该用什么语气。
  • text_delta 可以流式更新聊天框。
  • sentence_end 可以触发分句 TTS,用户不必等整段话结束。
  • done 用于收尾和状态归档。

推荐实现:

LLM / planner / rules
  -> 后端统一转成 CharacterEvent
  -> Modal/FastAPI 用 SSE 输出
  -> Gradio handler 消费事件并 yield 多个组件状态
  -> 调试面板把事件流另存为 NDJSON

这样即使底层模型只会普通 token streaming,后端也可以在句子边界、关键词、初始规划结果里补充 stagevoiceskill 事件。

事件类型

必须支持:

  • stage:控制角色舞台。
  • voice:控制 TTS 参数。
  • skill:说明本轮使用的 persona skill。
  • text_delta:追加回复文本。
  • sentence_end:触发当前句子的 TTS。
  • vision_note:可选,说明当前视觉输入的理解结果。
  • debug:可选,只展示在调试面板。
  • done:结束事件。
  • error:模型输出损坏或工具失败。

事件字段:

{
  "type": "stage",
  "expression": "worried",
  "motion": "gentle_blink",
  "intensity": 0.7,
  "duration_ms": 800
}
{
  "type": "voice",
  "style": "soft",
  "speed": 0.92,
  "pitch": 1.04,
  "energy": 0.45
}
{
  "type": "text_delta",
  "text": "我在听。"
}

Gradio 流式输出实现

Gradio 支持用 Python generator 做流式输出。事件处理函数不要 return 一次性结果,而是多次 yield

输出组件建议:

outputs = [
    chatbot,
    character_stage,
    audio_output,
    event_debug,
    state,
]

伪代码:

def respond_stream(user_text, chat_history, character_id, state):
    ctx = build_context(user_text, chat_history, character_id, state)

    partial_text = ""
    sentence_buffer = ""
    current_voice = default_voice(character_id)
    stage_state = make_stage_state(character_id, expression="listening")

    yield update(chat_history, stage_state, None, {"type": "stage", "expression": "listening"}, state)

    for event in llm_event_stream(ctx):
        if event["type"] == "stage":
            stage_state = apply_stage_event(stage_state, event)
            yield update(chat_history, stage_state, None, event, state)

        elif event["type"] == "voice":
            current_voice = merge_voice(current_voice, event)
            yield update(chat_history, stage_state, None, event, state)

        elif event["type"] == "text_delta":
            partial_text += event["text"]
            sentence_buffer += event["text"]
            chat_history = set_assistant_partial(chat_history, partial_text)
            yield update(chat_history, stage_state, None, event, state)

        elif event["type"] == "sentence_end":
            audio_chunk = synthesize_sentence(sentence_buffer, current_voice)
            sentence_buffer = ""
            stage_state = apply_stage_event(stage_state, {"type": "stage", "motion": "talk"})
            yield update(chat_history, stage_state, audio_chunk, event, state)

        elif event["type"] == "done":
            stage_state = apply_stage_event(stage_state, {"type": "stage", "expression": "idle"})
            yield update(chat_history, stage_state, None, event, state)

注意:

  • Gradio generator 需要启用 queue。
  • gr.Audio(streaming=True, autoplay=True) 可以接收后端逐块 yield 的音频。
  • 音频块最好长度稳定,并且大于 1 秒,避免播放不稳定。
  • 摄像头和麦克风输入可以用 .stream(),但第一版建议限制 time_limitstream_every,避免占满队列。

LLM 输出策略

方案 A:单模型直接输出事件流

一个模型直接输出 JSON event lines,再由后端包装成 SSE。

优点:

  • 架构简单。
  • 情绪、文字和动作天然同源。

缺点:

  • 小模型可能输出非法 JSON 行。
  • 需要严格 prompt、解析器和修复策略。

只适合实验。不要把它作为唯一生产路径。

方案 B:快规划器 + 文本流模型

先用一个很短的规划 prompt 生成首批控制事件:

{"type":"stage","expression":"thinking","motion":"look_at_user"}
{"type":"voice","style":"soft","speed":0.95}
{"type":"skill","name":"emotional_support"}

然后主模型流式输出文字,后端用规则或轻量分类器在分句处补充情绪事件。

优点:

  • 页面响应更快。
  • 首屏角色很快有动作。
  • 对 JSON 合法性要求更低。

缺点:

  • 情绪和文本可能不完全一致。

建议第一版采用 B 的变体:先快速输出一个初始 stage/voice/skill,再让主模型输出文本;后端在分句处补充情绪事件。这样响应最快,也最不容易被非法 JSON 卡住。

方案 C:纯文本流 + 后处理情绪分类

模型只流文本。后端每完成一句话,用轻量分类器或规则推断情绪。

优点:最稳。

缺点:不够“模型自己输出情绪”,角色动作也会滞后。

只作为 fallback。

解析和容错

模型事件流、Modal SSE、规则补充事件都必须经过 stream_protocol.py

职责:

  • 解析 SSE data: payload 或内部 NDJSON。
  • 丢弃或修复不合法事件。
  • 对未知字段做忽略,不让 UI 崩。
  • 对缺失字段补默认值。
  • 限制事件频率,防止 stage 抖动。
  • 把模型输出事件归一化为前端可消费状态。

容错策略:

非法 JSON 行 -> 作为 debug 记录,不进入舞台
未知 type -> debug
text_delta 缺 text -> 丢弃
stage 缺 expression/motion -> 使用当前状态
voice 参数越界 -> clamp
长时间没有 text_delta -> 显示 thinking 状态
模型结束但没有 done -> 后端补 done

角色舞台协议

后端不直接控制 Live2D 细节。后端只输出抽象状态:

{
  "expression": "worried",
  "motion": "gentle_blink",
  "mouth": "talking",
  "gaze": "user",
  "intensity": 0.7
}

stage_driver.py 负责映射:

  • 静态头像/CSS:切图、晃动、嘴部光效。
  • 2.5D HTML:表情层、CSS transform、嘴型动画。
  • Live2D:expression、motion、parameter、hotkey。

这样以后替换表现层时,不需要改对话模型。

TTS 流式策略

不要等全文结束再 TTS。使用分句级 TTS:

  1. LLM 输出 voice 事件。
  2. LLM 流式输出 text_delta
  3. 后端遇到中文句号、问号、叹号、换行,或模型输出 sentence_end
  4. 后端把当前句子送入 TTS。
  5. TTS 产出音频块后立即 yield 到 gr.Audio(streaming=True, autoplay=True)
  6. 角色舞台进入 talk motion,音频结束后回 idle 或下一句 motion。

第一版可以不做真正逐 token TTS。分句 TTS 已经能显著降低等待感。

摄像头流式策略

第一版不做持续实时视频理解。推荐:

  • 上传图片:稳定必做。
  • 摄像头拍照:可选。
  • 低频 stream:实验功能。

如果做 .stream()

  • stream_every 不要太小,建议先 1-3 秒。
  • time_limit 必须设置。
  • VLM 不要每帧都跑,可以抽样或节流。
  • 视觉结果写入 state.last_vision_note,下一轮对话使用。

生图策略

生图只在后台资产生成或用户明确触发时运行。

流程:

CharacterPackage.visual.image_prompt
  -> image_engine
  -> 角色头像/半身像/背景
  -> assets/generated/
  -> stage_driver 使用

用户自定义角色:

上传参考图/描述
  -> VLM 提取抽象视觉元素
  -> LLM 原创化 visual prompt
  -> 生图
  -> 保存为新 CharacterPackage

MVP 开发顺序

Day 1:角色库和事件流

  • CharacterPackage
  • 建 3 个内置角色。
  • 实现 llm_event_stream 的 mock 版本。
  • 实现 stream_protocol.py
  • Gradio 页面能流式显示文本和事件调试。

Day 2:角色舞台和 TTS

  • 实现 CharacterStage(gr.HTML)
  • stage 支持 listening、thinking、talking、happy、worried。
  • 接分句 TTS。
  • sentence_end 后能播放音频并让嘴部动。

Day 3:真实模型、视觉输入、生图

  • 接真实 LLM 或 Inference Provider。
  • 上传图片后用 VLM 生成 vision_note
  • 接生图生成默认头像或重绘头像。

Day 4:打磨和演示

  • 调整内置角色差异。
  • 优化流式延迟。
  • 加载一个 Live2D 或 2.5D 可动表现层作为加分项。
  • 准备 README、示例对话和演示脚本。

必须避免

  • 等完整 JSON 结束后才更新 UI。
  • 每轮对话都跑生图。
  • 直接克隆商业角色声音。
  • 公开 Space 直接复刻商业角色图像、台词和官方设定。
  • 把摄像头实时流作为第一版主链路。
  • 让前端执行模型生成的任意 JavaScript。

官方能力依据

  • Gradio 支持 Python generator 流式输出,可以反复 yield 组件值。
  • Gradio Audio(streaming=True, autoplay=True) 支持后端逐块 yield 音频。
  • Gradio ImageAudio 支持 .stream() 输入事件,适合摄像头和麦克风低频流。
  • Gradio .stream() 应设置 time_limitstream_every,避免单用户长期占用队列。
  • Modal fastapi_endpoint 支持 FastAPI StreamingResponse,可以用 text/event-stream 直接发 SSE。