A newer version of the Gradio SDK is available: 6.20.0
开发规格
目标
后续开发要围绕一个目标:做出低延迟、有角色存在感的多模态虚拟角色。
不要把 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,后端也可以在句子边界、关键词、初始规划结果里补充 stage、voice、skill 事件。
事件类型
必须支持:
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_limit和stream_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:
- LLM 输出
voice事件。 - LLM 流式输出
text_delta。 - 后端遇到中文句号、问号、叹号、换行,或模型输出
sentence_end。 - 后端把当前句子送入 TTS。
- TTS 产出音频块后立即 yield 到
gr.Audio(streaming=True, autoplay=True)。 - 角色舞台进入
talkmotion,音频结束后回 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
Image和Audio支持.stream()输入事件,适合摄像头和麦克风低频流。 - Gradio
.stream()应设置time_limit和stream_every,避免单用户长期占用队列。 - Modal
fastapi_endpoint支持 FastAPIStreamingResponse,可以用text/event-stream直接发 SSE。