☰
基于Python异步事件驱动与WebSocket的Live2D虚拟助手实现
2026/10/16 23:05:07 网站建设 项目流程

简介:本资源是一个面向AI开发者与虚拟助手构建者的Python智能体框架,聚焦于可视化对话代理的快速落地,解决传统文本交互缺乏情感表达与沉浸感的问题。框架深度集成Live2D模型驱动与动态渲染能力,支持角色表情、肢体动作及语音同步,结合异步任务调度、事件驱动架构与WebSocket全双工通信,实现低延迟、高并发的实时人机交互体验,适用于虚拟客服、教育陪伴、游戏NPC等场景。压缩包共570个文件,含34个核心Python模块(含asyncio/WebSocket/渲染调度逻辑)、193个JSON配置(模型参数与动作定义)、208个MTN动作文件、33个MP3语音资源及26个PNG贴图素材,另有说明文档与附赠设计指南,整体体积53.96MB。目前已有51人学习下载,开发者可直接基于Agent-main模块结构进行二次开发,快速接入自有LLM与语音合成服务,并通过预置动作集(如tap_head、idle、breast等)实现角色行为编排。 去年年底我开始重构自己的智能体框架,核心目标只有一个:让对话代理不再是一坨冷冰冰的JSON返回,而是能在网页里以Live2D形象出现,能眨眼、能呼吸、能跟着语气切换表情,甚至开口说话时口型对得上。整个方案选型定为“Python异步任务调度 + 事件驱动架构 + WebSocket实时通信”,前端渲染走Live2D Cubism SDK,后端统一调度智能体推理。这篇文章把我完整的设计思路、代码骨架、踩坑记录全部摊开讲,想给对话代理加可视化形象、或者想自己搭一套“会说话”的虚拟助手的同学,可以直接照着复现。

先说结论:这套方案跑通之后,效果比我预想的好很多——用户输入一句话,前端模型250ms内进入“思考”状态,1秒左右开始说话,说话时口型、头部摆动和情绪表情都是动态的,而不是播放一段固定动画。整个链路里最难的不是Live2D本身,而是后端事件流怎么设计,才能让异步任务的状态变化平滑地反映到模型动作上。

1. 整体架构设计与技术选型思路

1.1 需求拆解:一个会说话的虚拟助手需要哪些核心模块

很多人拿到类似需求第一反应是“用Live2D做个模型,然后接个聊天API不就完了”,真做起来会发现完全不是这么回事。一个能自然对话、有形象、并且实时响应的虚拟助手,至少要拆成三层:

  • 决策层:也就是智能体本身,负责意图识别、上下文管理、调用LLM接口、可能的RAG检索。这一层吞吐的是文本和结构化指令,完全不关心形象。
  • 事件通道层:负责把决策层的状态变化(开始思考、开始说话、情绪变化、出错)实时推给前端。这一层必须有低延迟、双向通信能力。
  • 形象表现层:前端Live2D渲染,负责把后端事件映射成模型参数(表情、口型、头部角度),驱动模型动起来。

这三个层次如果耦合在一起,代码会迅速腐化。我第一版就是因为把对话状态直接写死在Live2D的callback里,导致换一个模型、加一个情绪维度就要改一堆逻辑。重构之后的核心原则是:后端只发“语义事件”,前端只消费“表现指令”,两者之间通过WebSocket连接,事件协议用一套带类型的JSON。

1.2 技术选型:为什么是Python、asyncio和WebSocket

技术上有人会问:Live2D是前端的活,后端用什么都行,为什么单独强调Python异步任务调度?

我的理由有三点,都是实际踩过之后沉淀下来的:

  1. 智能体生态基本都在Python。无论是直接调OpenAI、接入LangChain还是自己写RAG,Python的异步客户端、向量库、工具链都是最全的。用其他语言要么自己造轮子,要么把REST接口包一层,链路一长延迟就上来了。
  2. 对话场景是天然的IO密集型。智能体推理过程中,大部分时间在等LLM接口返回、等向量数据库查询、等外部工具响应,线程池方案在并发一高就会遇到资源浪费和锁竞争。asyncio单线程事件循环正好匹配这种“等IO多、算CPU少”的场景。
  3. WebSocket能同时解决双向通信和事件流推送。HTTP轮询做“开始思考/开始说话”这类状态同步太笨拙,SSE虽然能单向推送,但前端调试、发送指令、处理ping/pong都不如WebSocket顺手。

整体架构数据流如下:用户在前端说话或输入文本,前端把消息通过WebSocket发到后端;后端先往事件总线投递一个“用户消息”事件,调度器创建/恢复会话任务,任务里串行执行意图识别、上下文组装、LLM调用;每个关键节点都向同一个WebSocket连接广播事件;前端收到事件后,操作Live2D模型对象切换表情或触发说话动作。

1.3 模块划分与依赖关系

我最后落地的模块划分是下面这个结构,每个模块只暴露接口不暴露实现:

模块职责关键技术点
agent_core会话管理、上下文存储、LLM调用异步客户端、上下文压缩
event_bus进程内事件发布订阅asyncio.Queue、Handler注册表
scheduler异步任务调度与并发控制优先级队列、信号量、超时控制
ws_serverWebSocket连接管理、心跳、协议编解码连接池、session绑定、ping/pong
live2d_control前端模型控制层Cubism Model参数更新

这个结构的好处是:agent_core完全不知道Live2D的存在,它只负责产生事件;live2d_control完全不知道后端是怎么调度的,只负责消费事件。任何一层替换都不影响其他层。后面我实际把LLM从一个厂商切到另一个厂商,只动了agent_core,Live2D表现层一点没改。

2. Live2D模型交互与动态渲染:从加载到“活过来”

2.1 模型资源准备与Cubism模型加载流程

Live2D模型的底层驱动靠的是Cubism SDK,模型文件本身不是视频也不是骨骼动画,而是一套“网格 + 纹理 + 参数”的组合。最核心的入口文件是.model3.json(Cubism 3及以上格式,Cubism 2用的是.model.json),它会引用贴图、物理参数文件physics3.json、表情文件expressions/*.exp3.json、动画文件motions/*.motion3.json。

注意:模型文件路径不能有中文,否则部分Cubism运行时加载纹理时会直接报404。我一开始没注意,模型放在assets/虚拟形象/目录下,Web端加载出来脸部纹理全是黑的,排查了半天。

前端我用的方案是pixi-live2d-display+pixi.js,这个库把Cubism Web SDK封装成了PixiJS的一个显示对象,模型加载干净利落。核心加载代码很简单:

import { Live2DModel } from 'pixi-live2d-display'; const app = new PIXI.Application({ view: document.getElementById('canvas'), autoStart: true, backgroundColor: 0x000000, width: window.innerWidth, height: window.innerHeight }); const model = await Live2DModel.from('/models/miyo/miyo.model3.json'); model.scale.set(0.25); model.anchor.set(0.5, 0.5); model.position.set(app.screen.width / 2, app.screen.height / 2); app.stage.addChild(model);

模型资源获取渠道,我实际用下来靠谱的有三个:一是Live2D官方Cubism SDK自带的示例模型(合规且免费);二是一些作者明确开放使用的Live2D模型(比如Nizima上标注free的);三是自己用Cubism Editor制作,适合有美术功底的同学。用别人模型前一定要看使用规约,很多免费模型要求注明出处、禁止二次传播。

2.2 动态渲染的核心机制:更新与绘制分离

Live2D模型看起来“活”,靠的不是播放预设动画,而是每一帧都在做“更新-绘制”两个阶段。更新阶段把所有参数(头部角度、眼球位置、嘴型开合、呼吸幅度)算好,绘制阶段根据参数驱动网格变形并渲染。

运行时做三件关键事情:

第一,建立帧循环。我用的requestAnimationFrame驱动,传入deltaTime(两帧间隔秒数)给模型:

function tick(now) { const deltaTime = (now - lastTime) / 1000; lastTime = now; model.update(deltaTime); app.renderer.render(app.stage); requestAnimationFrame(tick); } requestAnimationFrame(tick);

deltaTime非常关键。Live2D内置的呼吸、头发摆动、物理效果都依赖这个值,如果直接传固定值,动画在不同刷新率屏幕上会忽快忽慢。实测60Hz和144Hz屏幕下,传真实deltaTime后模型动作节奏一致。

第二,理解参数就是灵魂。模型里最常用的是PARAM_ANGLE_X(头部左右转动)、PARAM_ANGLE_Y(头部上下)、PARAM_EYE_BALL_X/Y(眼球转动)、PARAM_MOUTH_OPEN_Y(嘴巴张开度)、PARAM_BREATH(呼吸)。这些参数名都是Cubism的约定,但不是每个模型都完全一致,切换模型前最好遍历一次模型的参数列表看看哪些存在。

第三,动态渲染的性能开销控制。Live2D是GPU渲染,模型一多或纹理过大就会明显掉帧。我实测单个模型在普通集成显卡上60fps没问题,但如果页面同时还有视频流、动画特效,建议把PIXI.Application的帧率限制到30fps,或者按视口距离单独控制模型更新。减少阴影、粒子等Pixi特效也有帮助。

2.3 交互映射:把后端事件变成模型动作

这是整个项目里最有意思的部分。后端智能体产生的不是“动一下”这种指令,而是“思考中”“高兴”“开始说话”这类语义事件,前端要做的是一张“事件转参数”的映射表。

我设计了一套轻量级表现指令,每个事件都是一个JSON:

{ "event": "agent.speak.start", "sessionId": "abc123", "payload": { "tone": "happy", "amplitude": "low" } }

前端收到后这样处理:

ws.onmessage = (msg) => { const data = JSON.parse(msg.data); switch (data.event) { case 'agent.thinking': model.expression('think'); model.internalModel.parameters.setValue('PARAM_ANGLE_X', 8); break; case 'agent.speak.start': currentTone = data.payload.tone; speaking = true; break; case 'agent.speak.end': speaking = false; break; } };

口型是高频事件。如果后端每推送一条文本就更新一次嘴型,会非常生硬。更自然的做法是用TTS音频的振幅估算嘴型开合度:拿到音频数据后,每帧计算音量大小,映射到PARAM_MOUTH_OPEN_Y。我没有直接读音频流,而是让后端在TTS生成时把音频的RMS均方根值数组一起放进事件里,前端用这段数值驱动口型:

const mouthValue = Math.min(1, Math.max(0, rmsArray[currentIndex] * 2.5)); model.internalModel.parameters.setValue('PARAM_MOUTH_OPEN_Y', mouthValue);

除了表情口型,情绪状态机也很重要。我给智能体设计了neutral / happy / sad / think / surprised五个基础情绪,后端在回复生成时会附带一个情感标签,前端按标签切换到对应预设表情组合。这样模型就有了“人格”,而不仅仅是“会张嘴的图片”。

3. Python异步任务调度与事件驱动架构的实现

3.1 为什么不用多线程,而用事件驱动

写智能体后端时,最先冒出来的方案是“来一个请求就开一个线程”,这在大并发的聊天场景里必出问题。对话任务大部分时间都阻塞在LLM接口或数据库IO上,线程被白白占住;而且Python有GIL,真到CPU密集阶段线程切换开销也不小。

事件驱动完全不同:整个进程只有一个主线程跑asyncio事件循环,IO操作全部非阻塞,等网络返回前可以继续处理别的任务。一个中等配置的服务器用异步方案撑几千个WebSocket连接没有任何压力,线程方案到几百个就要开始优化。

我的调度器核心逻辑分三层:

  • 事件总线:全局唯一的发布订阅中心,任何模块都能发布事件,也能注册自己关心的事件回调。
  • 任务队列:每个会话对应一个异步任务,任务内部是状态机,触发条件满足后执行下一步。
  • 运行器:负责任务创建、并发限制、超时回收。

3.2 事件总线与任务队列的核心代码实现

事件总线我用asyncio.Queue实现,所有事件先入队,再由内置的消费者统一派发:

import asyncio from collections import defaultdict from dataclasses import dataclass, field from typing import Any, Callable @dataclass class Event: type: str data: dict = field(default_factory=dict) session_id: str = "default" class EventBus: def __init__(self): self._queue = asyncio.Queue() self._handlers = defaultdict(list) self._running = False def subscribe(self, event_type: str, handler: Callable[[Event], None]): self._handlers[event_type].append(handler) async def publish(self, event: Event): await self._queue.put(event) async def start(self): self._running = True while self._running: event = await self._queue.get() handlers = self._handlers.get(event.type, []) for handler in handlers: try: if asyncio.iscoroutinefunction(handler): await handler(event) else: handler(event) except Exception as e: print(f"[event_bus] handler error: {e}") async def stop(self): self._running = False

这里有几个设计上的考虑。第一,事件总线用内存队列就够,不需要引入消息中间件。因为事件只在单个进程内流转,一旦以后要做多实例部署,再平滑迁移到Redis订阅或Kafka也不难。第二,handler是逐个await执行的,顺序可以预期,避免多消费者并发导致不同会话事件交叉。第三,publish只负责入队,几乎不会阻塞,生产者的调用体验很轻快。

任务队列我用的asyncio.PriorityQueue,同一个会话的任务按优先级排列,系统级事件(断线、错误)永远比普通对话事件先处理:

class TaskScheduler: def __init__(self, max_concurrency: int = 50): self._queue = asyncio.PriorityQueue() self._semaphore = asyncio.Semaphore(max_concurrency) self._tasks = set() async def submit(self, session_id: str, priority: int, coro): await self._queue.put((priority, session_id, asyncio.create_task(coro))) async def run(self): while True: _, session_id, task = await self._queue.get() self._tasks.add(task) async def guarded(task): async with self._semaphore: await task asyncio.create_task(guarded(task)).add_done_callback(self._tasks.discard)

信号量Semaphore(max_concurrency=50)限制同时执行的任务数,避免100个用户同时提问时把所有LLM连接打满。很多异步初学者会忽略这个,结果就是并发一高,接口超时、数据库连接池爆掉,事故现场很难看。

3.3 异步调度中的阻塞避让与内存管理

asyncio最关键的一条铁律是:事件循环里绝不能出现CPU密集的同步阻塞。LLM接口是网络IO,没问题;但RAG里如果有本地PDF解析、向量化、文本清洗这类CPU密集型操作,直接放在协程里会卡住整个进程,表现为“其他所有用户的请求都迟到了”。

我的处理方式分两类:

  • IO密集:用异步客户端(openai.AsyncOpenAI、httpx.AsyncClient),天然不阻塞。
  • CPU密集:通过loop.run_in_executor(None, func)丢到线程池执行,await等结果,事件循环不卡:
import asyncio async def parse_document(path: str): loop = asyncio.get_running_loop() result = await loop.run_in_executor(None, heavy_parse_function, path) return result

内存管理也要上心。每个会话都有上下文窗口,如果不限制长度,长期运行的助手上下文会越攒越大,最后事件循环里一个正则或字符串操作都要跑几十毫秒。我在会话层做了token预算,超过的用滑动窗口裁剪旧消息,同时把最终摘要写回上下文,保证长对话不丢主线。

4. WebSocket实时通信:连接管理、心跳与消息协议

4.1 通信选型对比:为什么非WebSocket不可

我一开始用过SSE,当时想着反正是后端单向推事件,SSE更轻。但实际联调发现问题不少:前端要发送指令还是得走HTTP POST,等于一条用户消息被拆成两个通道;断线重试逻辑要自己实现;部分SSE网关会缓冲响应导致实时性下降。

WebSocket的优势在于一条TCP连接上双向传输,握手时用HTTP升级,之后就是长连接帧协议,延迟可以做到毫秒级。和HTTP轮询相比,它省去了每次请求的头部开销;和SSE相比,它天然支持双向。Live2D的交互场景(用户发消息、后端推状态、前端回执)用WS一个字:顺。

4.2 服务端实现:用Python WebSockets库搭建连接管理

后端我用fastapi+websockets搭的WS服务,fastapi自带WebSocket支持,但底层还是uvicorn+websockets/wsproto。贴上我实际使用的版本:

from fastapi import FastAPI, WebSocket, WebSocketDisconnect app = FastAPI() class ConnectionManager: def __init__(self): self.active_connections: dict[str, WebSocket] = {} async def connect(self, session_id: str, ws: WebSocket): await ws.accept() self.active_connections[session_id] = ws def disconnect(self, session_id: str): self.active_connections.pop(session_id, None) async def send_json(self, session_id: str, message: dict): ws = self.active_connections.get(session_id) if ws is not None: await ws.send_json(message) manager = ConnectionManager() @app.websocket("/ws/{session_id}") async def websocket_endpoint(ws: WebSocket, session_id: str): await manager.connect(session_id, ws) try: while True: data = await ws.receive_text() # 解析前端上行的消息事件,交给事件总线 await event_bus.publish(Event(type="user.message", data={"text": data}, session_id=session_id)) except WebSocketDisconnect: manager.disconnect(session_id)

这样一个/ws/{session_id}端点把连接与业务session绑定,后续所有推送都能精准路由到对应的前端页面。session_id由前端连接时带上,后端用它识别是哪个用户,也方便多开页面时互不干扰。

注意:并发推送相同连接时,websockets库的send不是线程安全的,如果两个协程同时往一个连接写数据,会抛RuntimeError: cannot call send from a handler。我在ConnectionManager里给每个session加了一个asyncio.Lock,发送前先获取锁,写完了释放。这个坑不遇到还好,一遇到就是线上事故。

4.3 心跳保活与断线重连机制

WebSocket连接看似稳定,但中间任何一层代理(Nginx、云负载均衡)都可能在空闲一段时间后掐掉连接。服务端要主动发送ping帧,客户端收到后回pong帧,让链路保持活跃。

我实测下来的参数:服务端每30秒ping一次,连续3次没收到pong就判定连接死亡,主动清理会话资源。前端在onclose里做指数退避重连,首次1秒,之后2秒、4秒、8秒……封顶30秒。重连成功后前端要重新发送一个client.hello事件,后端就会把当前会话状态(是否正在说话、情绪状态)重新推给前端,这样页面刷新后模型不会变成“失忆状态”。

4.4 消息协议设计:一套覆盖LLM全链路的JSON规范

消息协议是前后端共同遵守的“法律”,我建议在一开始就定死,不然后面全是shit。我的每个消息包是统一JSON结构:

{ "type": "event", "event": "agent.speak.delta", "sessionId": "abc123", "ts": 1701234567.123, "payload": {} }
  • type:event(事件)、command(指令)、ack(回执)。
  • event:事件名,全小写下划线。
  • payload:事件专属数据,不同事件结构不同。

后端向Live2D前端推送的核心事件我整理成了表:

事件方向含义关键payload字段
agent.thinking后→前开始思考无
agent.speak.start后→前开始说话tone,amplitudes
agent.speak.delta后→前说话中chunk
agent.speak.end后→前说话结束无
agent.react后→前情绪切换emotion
agent.error后→前出错message
user.message前→后用户发言text
client.hello前→后前端握手state

这套协议跑得很顺,核心原因是每个事件都是幂等的——前端哪怕漏掉一个agent.speak.start,再收到agent.speak.delta也能正确进入说话状态。实际运行中丢包偶有发生,幂等性救了大忙。

5. 端到端集成实操与效果调优记录

5.1 环境准备与依赖版本

这一节给出我在Ubuntu 22.04 + Python 3.11 + Node 18.17(前端构建)下的实际依赖清单。

后端依赖:

fastapi==0.110.0 uvicorn[standard]==0.27.1 websockets==12.0 openai==1.12.0 pydantic==2.5.3

前端我是Vite + PixiJS,关键依赖:

pixi.js==7.3.2 pixi-live2d-display==0.4.0

Live2D Cubism SDK本体由pixi-live2d-display自动带,不用单独下载。

提醒:pixi-live2d-display对PixiJS的版本有要求,用8.x的Pixi会出现兼容问题。我锁在7.x稳妥。Cubism SDK如果是Cubism 4模型也兼容,Cubism 5的某些新特性则要等库更新。

5.2 分步联调:从静态模型到对话驱动

我建议不要一把梭,按照下面四步逐步集成,每步完成都有可验证的成果:

第一步:静态模型渲染。先不管后端,把Live2D模型在页面渲染出来,拖动、缩放、眨眼都正常。验证点是页面Console无报错、模型纹理正常、帧率稳定。

第二步:本地WS连接。写一个简单的Python WS服务,前端连接成功后,服务端每2秒推送一个agent.react事件,模型表情跟着切换。这个阶段验证的是WS通路和前端事件处理函数。

第三步:接入智能体调度。后端完整接入EventBus和调度器,用户发消息后,可以观察到串行事件流:thinking→speak.start→speak.delta→speak.end。前端日志里能看到正在跑Live2DModel.update的调用。

第四步:TTS和口型联动。让后端生成TTS并把振幅数组放进speak.start事件里,前端按照预定的采样率播放音频并驱动嘴型。走到这步,虚拟助手已经能从“念稿子”变成“有表情地说话”了。

5.3 实测数据与体验优化

我在一台4核8G的云服务器(CPU为主,无独立显卡)上跑了50路模拟连接,后端内存稳定在280MB左右,CPU占用峰值约60%,主要耗在LLM响应解析和事件队列分发上。前端单模型渲染稳定在55fps以上,加入实时口型驱动后降到48fps,不影响观感。如果同时开视频背景或大量特效,确实会掉到30fps以下,所以我给前端做了动态降帧:

if (fps < 35) { app.ticker.maxFPS = 30; } else { app.ticker.maxFPS = 60; }

还有两个提升体验的小细节。一是模型上下浮动(呼吸感)不要用写死的正弦波,而是叠加一点随机噪声,会让模型更“活”;二是鼠标移动时让眼睛焦点跟随鼠标,这用的是Live2D自带的FocusController,体验提升非常明显。我加了鼠标跟踪后,测试同事第一反应是“这模型刚在看我”,比任何表情切换都有效。

6. 常见问题与排查技巧实录

6.1 典型问题速查表

跑这套系统期间我记了一本“踩坑账”,挑出现频率最高的几个列成表格,方便你对照排查:

现象可能原因解决方案
Live2D模型加载后一片黑/花屏纹理路径含中文、CORS配置错误、Pixi版本不兼容路径改英文,给静态资源加Cross-Origin-Resource-Policy,锁定Pixi 7.x
模型说话时嘴巴幅度过大、怪异PARAM_MOUTH_OPEN_Y映射系数太高把振幅映射系数调到2.0~3.0之间,加上下限截断
WS连接几秒后被断开服务端没有发心跳或代理空闲超时服务端30秒ping一次,前端实现指数退避重连
多用户同时提问时响应变慢没有限制并发任务,打满LLM连接或数据库连接池用Semaphore限制最大并发任务数
后端推事件顺序不对多个handler并发执行,产生竞态EventBus消费者改成单线程顺序派发,保证同session事件有序
前端页面切后台再回来,模型“卡死”requestAnimationFrame被浏览器暂停,帧率统计失控页面visibilitychange时重置lastTime

6.2 我在联调中踩过的三个深坑

第一个坑是事件乱序。第一次加并发时,我把每个事件都asyncio.create_task单独派发,导致同一个会话的speak.start和speak.delta可能被不同协程抢着处理,前端表现就是“模型还没进说话状态,嘴就开始动了”。后来EventBus改为单消费者顺序遍历handler,同一事件类型的多个handler还是串行执行,乱序问题立刻消失。

第二个坑是Live2D参数不存在。有些模型没有PARAM_ANGRY这类表情参数,直接setValue会静默失败或者抛异常,导致表情切换没效果还不报错。我加了参数检查函数,先判断参数ID在不在模型的parameters.ids里面再赋值,这样切换不同模型时表情缺失也不会崩。

第三个坑是WS并发发送报错。多协程同时往同一个WebSocket连接发数据,websockets库直接抛异常,而且异常发生在异步上下文里,特别难抓。给每个连接加asyncio.Lock是正解,同时我在ConnectionManager.send_json里加了异常兜底,发送失败就清理连接,防止脏连接残留。

6.3 排障方法论:三层定位法

如果线上还是出了诡异问题,我的定位顺序是“网络层 → 调度层 → 渲染层”,不要跳层:

  • 网络层:先看WS连接是否健康。用浏览器DevTools的Network面板查WS帧时间线,能看到每帧的收发时间;后端打印每帧收到的时间戳和发送的时间戳,对比差值,如果在10ms内说明网络没问题。
  • 调度层:在EventBus的publish和handler入口各加一行结构化日志,事件全链路都有trace。我用了sessionId + 事件名 + 耗时一行日志,排障时直接过滤某个sessionId,几秒钟就能定位到哪个环节掉链子。
  • 渲染层:前端打印出每个事件到达时模型参数的最终值。这一步能确认是事件没到,还是到了参数没生效。

这套方法论帮我解决过至少80%的联调问题。剩下20%基本是资源文件路径、浏览器缓存这类低级错误,清理缓存或重新构建就好。

写在最后的一点个人体会

这个项目我从第一版“能用”迭代到“好用”,最大的心得是:架构的分层边界比代码技巧重要得多。Live2D和智能体是完全不同的两个领域,如果不把事件协议定义清楚,两边工程师(或者我自己前后端两个角色)很容易互相踩脚。现在这套事件协议不仅服务于对话,还被我扩展到了“定时主动推荐”“节日彩蛋”等场景,后端发一个事件,前端模型就自动配合演出,根本不用改渲染代码。如果后面有时间,我打算把多模态输入(比如摄像头检测用户表情)也接入事件总线,让模型对用户的表情做出反应,那才是真正的“双向互动”。这套框架的代码骨架我会持续迭代,后续有新的突破再开一篇详细写。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询