去年我做现场活动时被坑过一次:数字人主持人的台词全是提前录好的,观众临时抛个问题,它在台上只能尴尬地复读预设好的几段话。那次之后我就一直琢磨,能不能让数字人的每一句台词都实时生成——AI 现场编、现场说,观众问什么它答什么。今年拿到 Vidu S1 实时数字人的接入资格后,我第一件事就是把台词链路改成 WebSocket 实时传输:AI 生成台词、语音合成、数字人口型与动作,全程不落地视频文件。这篇文章记录的,就是这套方案的完整落地过程,包括 WebSocket 网关如何设计、AI 台词流如何与数字人对接、延迟怎么优化,以及我踩过的 1006 断线、App 连不上这类典型 WebSocket 坑。如果你也想接一个“话是 AI 现编”的数字人,这篇应该能帮你省掉不少弯路。
1. Vidu S1 实时数字人不是“视频点播”:它要的是一条双向流
1.1 实时数字人与录播视频的本质区别
很多人第一次接触数字人,脑子里想的还是“渲染一个视频,然后播出去”。那套方案适合录播口播、带货切片,但根本不适合实时互动。原因很简单:视频渲染耗时太长,从输入一句话到输出一帧画面,中间隔着一个完整的推理与合成流程,这个延迟动辄几十秒,根本扛不住现场交互。
Vidu S1 这类实时数字人走的是另一条路:它不是一个视频文件,而是一个常驻的渲染客户端。你可以把它理解成一个“实时演员”——只要通过 WebSocket 把台词和情绪指令持续喂进去,它会动态生成口型、表情、肢体动作和语音,整个过程没有“先渲染完再播放”的概念。这就像直播和录播的区别:录播可以慢慢剪,直播必须现场出图。
所以接入 Vidu S1 的第一步,不是去找它的视频输出接口,而是搞清楚它的 WebSocket 消息流怎么用。我接入时把消息大致分成三类:下行指令(服务端发给数字人)、上行事件(数字人回给服务端)、双向心跳(保活探测)。具体的字段编号和枚举值各家 SDK 略有差异,以 Vidu S1 官方文档为准,但消息模型的骨架是通用的。
1.2 我给数字人发消息的类型与结构
我自己的服务端与 Vidu S1 之间,实际跑起来大概有这几类消息:
| 消息方向 | 消息类型 | 作用 |
|---|---|---|
| 下行 | speak_text | 告诉数字人“这句话需要用语音播报”,携带文本内容、情绪标签、语速参数 |
| 下行 | speak_audio | 传入外部 TTS 合成的音频帧,让数字人按帧播放并同步口型 |
| 下行 | interrupt | 打断当前播报,立刻停止说话,常用于用户插话场景 |
| 下行 | session_ctrl | 设置会话参数,比如音量、角色、语言 |
| 上行 | speaking_start | 数字人开始发声,服务端可以据此切换状态 |
| 上行 | speaking_end | 当前句播报结束,服务端可以安排下一句 |
| 上行 | audio_progress | 播放进度反馈,用于字幕同步和异常检测 |
| 双向 | heartbeat | 保活探测,防止中间链路把空闲连接断开 |
这里有个关键点:speak_text和speak_audio是两条路线。如果 Vidu S1 走内置语音合成,你只需要发文本和参数,它的模型会自动生成语音和口型;如果你对外播音色有硬性要求,比如想用特定的真人克隆音色,就得走外部 TTS,然后把音频帧推给数字人。我的项目里最终选了后者,因为活动客户对音色一致性要求很高,但这也意味着我要额外处理音频编码、帧率、缓存和同步问题,这部分后面单独展开。
1.3 为什么不用轮询,也不用 SSE
接入之前我其实犹豫过:既然只是把 AI 台词推给数字人,用 HTTP 轮询行不行?SSE(Server-Sent Events)行不行?事实证明都不合适。
- HTTP 轮询:客户端定期去拉取“有没有新台词”,延迟取决于轮询间隔。间隔设 1 秒,观众就会明显感到数字人反应慢;间隔设 100 毫秒,服务端要被无效请求打爆。而且轮询是单向的,服务端想主动打断数字人,还得等下一次轮询窗口。
- SSE:解决了“服务端主动推送”的问题,但它本质还是单向的(客户端通过额外 HTTP 请求上传数据),而且对二进制帧的支持比较弱。推文本还行,推音频帧就很痛苦。另外 SSE 在浏览器端有连接数限制,做多路并发容易踩坑。
- WebSocket:真正的全双工,一条连接既推文本又推二进制音频帧,延迟可以做到很低;有标准的心跳机制,适合长连接场景。
用一个简单的对比就能看明白:
| 能力 | HTTP 轮询 | SSE | WebSocket |
|---|---|---|---|
| 服务端主动推送 | 基本不行 | 支持 | 支持 |
| 客户端上行实时消息 | 支持,但延迟高 | 需要额外请求 | 支持 |
| 二进制音频帧传输 | 低效 | 低效 | 原生支持 |
| 连接开销 | 高频请求开销大 | 较低 | 一次握手 |
| 双向实时性 | 差 | 中 | 高 |
所以结论很直接:凡是“AI 生成台词 + 数字人实时发声”的场景,WebSocket 就是最合适的选择。
2. WebSocket 网关设计:会话管理、鉴权与消息路由
2.1 连接生命周期与四态状态机
如果只是写一个 Demo,把客户端连上然后把消息转发给 Vidu S1,半小时就能跑通。但一上生产就发现,问题全都出在“连接生命周期”上:连接什么时候建立、哪些消息允许发送、数字人正在说话时能不能插播新台词、服务端重启后客户端怎么恢复……
我给每条 Vidu S1 连接设计了一个四态状态机:
- idle(空闲):连接建立成功,鉴权通过,数字人等待指令。
- thinking(思考中):服务端已经把文本交给 LLM,正在等待生成结果。此时数字人保持聆听状态。
- speaking(播报中):正在发声。此时再来的新台词不能直接下发,只能进入待播队列。
- interrupted(被打断):用户插话或系统紧急止播,需要清理 TTS 和 LLM 缓冲,回到 idle 后再处理后续指令。
这个状态机解决了一个核心问题:**消息到达的顺序和状态必须一致,不能乱。**WebSocket 保证的是传输层的顺序,但业务层如果不做状态管理,就会出现“上一句还没说完、下一句已经开播”的叠音事故。我早期就吃过这个亏——测试时发现同一个数字人嘴里同时冒出两句台词,听起来像两个人吵架,后来加了状态机才解决。
2.2 鉴权机制:握手时验一次,连接内不再重复验证
WebSocket 的鉴权方式和 HTTP 不太一样。HTTP 每个请求都可以带 token,服务端逐个验证;WebSocket 是长连接,不可能每条消息都做一次完整鉴权,那样性能开销太大。我的做法是“握手时一次性验证,连接内用会话标识关联身份”。
具体来说,客户端在 WebSocket URL 的 query 参数上带 token:
wss://api.example.com/ws/vidu?token=eyJhbGciOi...服务端在 Upgrader 回调里校验 token,验过了才完成握手,验不过直接返回 403。这里有一个 B 端集成时经常纠结的点:能不能把 token 放在 Header 里?浏览器原生 WebSocket API 不允许自定义 Header,所以如果你要兼容浏览器,query 参数是最稳妥的方案;如果是原生 App 或后端服务,可以用 Header 或子协议传递 token,但优势并不明显,反而增加了客户端代码复杂度。
另外一个容易踩的坑是:**token 泄露到日志里。**很多 WebSocket 框架打印连接信息时会把完整 URL 打出来,如果 URL 里带 token,就等于把凭证写进了日志。我专门在日志打印前做了 URL 脱敏,把 query 里的 token 字段替换成***,这个细节在安全审计时很加分。
至于“长连接数据量大了之后 token 过期怎么办”——我的方案是:服务端主动推送session_expired事件,客户端收到后自动重连并刷新 token。而不是在每条消息里都校验 token,那样既啰嗦又浪费 CPU。
2.3 生产级网关的骨架:我用 Go 实现的核心结构
技术栈选型上,参考了团队现状和生态成熟度,最终用 Go 写网关,核心组件是gin和gorilla/websocket。下面是一个简化但能上生产的骨架:
package main import ( "log" "net/http" "time" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" ) var upgrader = websocket.Upgrader{ // 生产环境必须校验 Origin,防止跨站 WebSocket 劫持 CheckOrigin: func(r *http.Request) bool { return verifyOrigin(r) }, } type Client struct { conn *websocket.Conn send chan []byte state string // idle / thinking / speaking / interrupted } func handleWS(c *gin.Context) { token := c.Query("token") if !validateToken(token) { c.AbortWithStatus(http.StatusUnauthorized) return } conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { log.Println("upgrade error:", err) return } client := &Client{conn: conn, send: make(chan []byte, 64), state: "idle"} hub.register <- client go client.writeLoop() go client.readLoop() } func (c *Client) readLoop() { defer func() { hub.unregister <- c c.conn.Close() }() for { _, msg, err := c.conn.ReadMessage() if err != nil { log.Println("read error:", err) return } // 业务消息统一交给 dispatcher 处理 dispatcher.dispatch(c, msg) } } func (c *Client) writeLoop() { ticker := time.NewTicker(15 * time.Second) defer ticker.Stop() for { select { case msg, ok := <-c.send: if !ok { c.conn.WriteMessage(websocket.CloseMessage, []byte{}) return } if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil { return } case <-ticker.C: // 心跳保活 if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { return } } } }这段代码里有两个地方值得展开说。
第一,读循环和写循环必须分离。WebSocket 协议不推荐在同一个 goroutine 里同时读写,因为WriteMessage是并发不安全的,而且一个方向阻塞会影响另一个方向。分离之后,上行消息和下行推送互不干扰。
第二,发送队列必须带缓冲。send的缓冲大小我一开始设成了 8,结果一遇到 AI 台词高峰期就丢消息。后来调整为 64,并配合背压机制:如果队列满了,说明下游消费不过来,宁可暂停发送也不要无限堆积导致内存爆炸。这种细节在压测时才能暴露出来,等上了真实活动再调就晚了。
如果你们团队是 Java 技术栈,思路完全一致。Spring Boot 的WebSocketHandler负责连接管理,ConcurrentHashMap维护会话;Netty 则用ChannelGroup管理连接,自己实现编解码。性能上限和工程复杂度差不多,主要看团队的熟悉程度。
2.4 消息路由:一条上行消息如何变成多个下行流
实际业务里,AI 生成的台词不只是发给 Vidu S1 一条路。它还要写进字幕屏、存进会话日志、可能还要推给监控面板做实时展示。如果每个下游都在业务代码里 for 循环发送,代码会越来越乱。
我参考了消息总线的思路:网关内部维护一个轻量事件总线,dispatcher收到消息后,先解析消息类型,再把它广播给订阅了该类型的所有处理器。
type Dispatcher struct { handlers map[string][]Handler } func (d *Dispatcher) Register(eventType string, h Handler) { d.handlers[eventType] = append(d.handlers[eventType], h) } func (d *Dispatcher) Dispatch(msg Message) { for _, h := range d.handlers[msg.Type] { go h.Handle(msg) } }这样做的最大收益是:**新增一个下游只需要注册一个 Handler,改动集中在启动阶段,业务逻辑完全不用动。**后来客户临时要求把台词同步到现场提词器,我就加了一个提词器 Handler,半小时搞定。
3. “每一句台词都是 AI 现编”的链路:LLM 流式输出到 TTS 再到数字人
3.1 完整链路:从用户问题到数字人开口
先画一条完整的数据流,后面所有优化都围绕这条链路:
用户提问 / 节目脚本 -> 服务端组装 Prompt -> LLM 流式生成(token 流) -> 按句子边界切分 -> 句子进入 TTS 合成(文本转音频) -> 音频帧通过 WebSocket 推给 Vidu S1 -> Vidu S1 播放音频并同步口型与动作这条链路里最容易出问题的,是“LLM 流式输出”和“TTS 输入”之间的衔接。LLM 是逐字往外吐的,但 TTS 通常需要完整的句子才能合成出稳定的语音。你不可能每来一个 token 就调用一次 TTS,那样合成的语音会有严重的顿挫感;你也不可能等整段话全部生成完才合成,那样延迟会高到无法接受。
3.2 句子切分策略:流水线式生成,而不是等全部生成
我的方案是做一个“句子缓冲器”:LLM 输出的 token 不断累积,每次检测到句号、问号、感叹号等句子边界符时,就把当前累积的文本作为一个完整句子,送入 TTS 队列。
func (b *SentenceBuffer) Push(text string) { b.buf += text for { idx := findSentenceBoundary(b.buf) if idx < 0 { break } sentence := b.buf[:idx] b.buf = b.buf[idx:] b.OnSentence(sentence) // 交给 TTS 合成 } }这里有一个经验值:**单句长度不要超过 60~80 个汉字。**太短的句子会频繁打断播放,听起来很碎片;太长的句子会拖长“首句等待时间”,观众会觉得数字人反应慢。另外,如果遇到引号、冒号、破折号这类标点,要特殊处理,不能硬切。
这样做的好处是流水线作业:第一句在 TTS 合成时,LLM 已经生成了第二句;第一句推给数字人播放时,第二句已经合成了一半。从用户视角看,数字人几乎是“边说边想”,而不是“想完再说”。
3.3 TTS 与数字人音频输入的匹配
数字人的口型同步依赖音频驱动。如果走speak_audio路线,TTS 产出的音频格式必须能被 Vidu S1 消费。我第一次对接时直接推了 MP3,结果数字人的口型跟声音明显对不上——问题出在 MP3 是压缩格式,播放器需要缓冲整段才能解码,口型驱动拿不到精确的音频时间戳。
正确做法是尽量用低延迟的音频编码,比如PCM 或 Opus,并且按帧推送。我当时的策略是:
- TTS 合成结果输出为 16kHz / 16bit / 单声道的 PCM 数据;
- 每 20ms 的音频数据打包成一帧;
- 帧内带上时间戳序号,方便 Vidu S1 侧做缓冲和同步。
每帧 20ms 这个数值不是拍脑袋定的。太短(比如 5ms)会导致 WebSocket 消息数量爆炸,网络开销抵消了低延迟优势;太长(比如 100ms)会让口型落后明显,看起来像配音电影没对齐。20ms 是一个在“网络开销”和“同步精度”之间比较平衡的点,大多数实时音视频 SDK 也是按这个粒度来打包的。
实际上,如果你不想处理外部音频,Vidu S1 也支持直接speak_text走内置 TTS。这个方案的优点是省心,缺点是你无法控制音色和语速细节。我的项目因为有“必须用客户指定主播音色”的硬需求,才走了外置音频这条路。如果只是做内部工具或验证概念,建议先用speak_text跑通全链路,再决定要不要上speak_audio。
3.4 实时打断(Barge-in):观众说话,数字人必须立刻闭嘴
活动场景下,观众随时可能插话。如果不做打断机制,数字人还在喋喋不休地讲上一段,主持人就得提高嗓门盖过它,现场体验一片混乱。
我的打断逻辑分三层:
- 客户端检测到用户开口(通过麦克风音量阈值或语音识别),立即向服务端发
interrupt_request消息; - 服务端收到后,先给 Vidu S1 发
interrupt下行消息,数字人停止播放当前音频; - 服务端同时取消 LLM 生成任务(调用上下文取消接口),清空 TTS 待合成队列和待播队列,状态机切回 idle。
这里有个很多人忽略的细节:**打断之后不能立刻把新问题发给 LLM。**因为数字人刚停止说话时,麦克风采集到的音频里可能还残留着它自己的声音,直接识别会识别出“上一句话的尾巴”。我后来在打断后加了一个 200ms 的静默窗口,让音频输入稳定下来再开启新一轮识别,误触发率明显下降。
4. 延迟控制:从首字 token 到数字人开口,逐毫秒拆解
4.1 延迟构成:到底哪些环节在拖后腿
实时数字人体验好不好,说白了就是延迟问题。我实测下来,观众对“数字人开口时间”的感知大概是这样的:
- <300ms:几乎感受不到延迟,流畅得像真人;
- 300~800ms:能感觉到轻微停顿,但可以接受;
- 800~1500ms:明显卡顿,观众会怀疑 AI 是不是没听懂;
- >1500ms:翻车,现场观众开始起哄。
一次完整的响应,延迟由这么几段累加而成:
| 延迟环节 | 典型耗时 | 优化空间 |
|---|---|---|
| 语音识别(ASR) | 200~400ms | 取决于识别服务,换更快的模型 |
| LLM 首 token 生成 | 300~800ms | 优化 Prompt 长度、选快模型 |
| TTS 合成 | 100~300ms | 流式 TTS,按句切分 |
| 网络传输 | 10~50ms | 就近部署 WebSocket 服务 |
| Vidu S1 渲染 | 30~100ms | 客户端侧优化 |
所以你会发现,**真正的大头在 LLM 首 token 和 ASR,而不是 WebSocket 本身。**如果纯看传输,WebSocket 的毫秒级延迟根本不是瓶颈;瓶颈是你把文本交给大模型之后,它“开口说话”之前的思考时间。
4.2 首 token 优化:Prompt 别写小说
很多人用 LLM 喜欢把 System Prompt 写特别长,背景、角色、语气、禁忌事无巨细。这在离线场景没问题,但在实时数字人场景,Prompt 越长,首 token 生成越慢。
我做了三件事:
- 把 System Prompt 压缩到 1.5k token 以内,只保留角色设定、语气风格、禁忌这三个核心块,其他细节挪到 RAG 知识库里按需检索;
- 历史对话只带最近三轮,更早的内容做摘要后再拼接,避免上下文无限膨胀;
- 生成参数里把 max_tokens 设个上限(比如单次回复 200 token 左右),防止模型生成太长导致 TTS 积压。
这里多说一句:虽然 Prompt 短了会损失一些“人设丰富度”,但在实时交互里,“反应快”比“话多”重要得多。观众不会注意数字人少用了一个金句,但会很在意它迟迟不开口。
4.3 流水线重叠:上一句在播,下一句已经在合成
我把整个生成过程做成了三阶段流水线:
阶段 A:LLM 生成句子1 -> 句子2 -> 句子3 ... 阶段 B:TTS 合成句子1 -> 句子2 -> 句子3 ... 阶段 C:数字人播放句子1 -> 句子2 -> 句子3 ...三个阶段并行执行,每句话只需要在一个阶段里串行等待,整体延迟大幅下降。第一次优化后,用户的感知延迟从 1.8 秒降到了 0.9 秒左右。
但流水线也有副作用:**如果你不控制节奏,AI 会在你问完一个问题后滔滔不绝说五分钟。**所以我给单次回复设了一个总时长上限(比如 30 秒),达到上限后即使 LLM 还在生成,服务端也会强制结束,让数字人把最后一句话说完就闭嘴。
4.4 心跳与网络参数:延迟抖动比高延迟更致命
前面讲的都是“平均延迟”,但真实场景更怕的是“抖动”——就是延迟忽高忽低。WebSocket 长连接如果长时间空闲,中间的路由器、Nginx、云厂商的负载均衡都可能悄悄断开连接,导致丢消息。
心跳机制是必需品。我的配置是:
- 每 15 秒服务端发一次 Ping;
- 客户端收到后回 Pong;
- 如果连续 2 次 Pong 没收到(也就是 30 秒),服务端主动断开并触发重连流程。
关于心跳间隔,很多人直接抄 RespChat 的 30 秒,但我实测在部分云环境里,15 秒更稳妥。因为有些网络设备对空闲连接的空闲超时设得很短,30 秒都来不及保活。当然,心跳太频繁也会浪费流量,15 秒是一个实践下来比较稳的值。
另外还有一个不算 WebSocket 本身的坑:**服务端和 Vidu S1 客户端之间的网络距离。**我第一版把网关部署在北方的机房,但 Vidu S1 的渲染客户端跑在南方现场,结果延迟从 20ms 飙到 80ms,还伴随明显抖动。后来把网关迁到现场所在区域的云节点,问题立刻缓解。实时数字人这种场景,服务端离客户端越近越好,别省那点跨区域部署的钱。
5. 线上必踩的坑:1006 断线、App 连不上、鉴权混乱
5.1 close 1006 的排查链路:先分清是哪一层断的
WebSocket 的 close code 1006 代表“连接被异常关闭”,也就是说客户端和服务端都没有主动发送 Close 帧,但连接就是没了。这个错误在实时数字人场景里出现频率极高,排查起来也最容易让人抓狂。
我分享一套完整的排查链路:
第一步:确认是哪一端先断的。
服务端日志里查有没有close记录。如果服务端收到了客户端的关闭事件,说明是客户端或中间层先断开;如果服务端完全没有相关日志,连接就断了,那大概率是中间链路(Nginx、云负载均衡、运营商网络)的问题。
第二步:检查反向代理的超时配置。
如果 WebSocket 前面挂了 Nginx,必须显式设置超时参数:
proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 300s; proxy_send_timeout 300s;Nginx 默认的proxy_read_timeout是 60 秒,也就是说连接空闲 60 秒后就会被 Nginx 掐断。如果你没有心跳,或者心跳间隔大于 60 秒,就会出现“每隔一分钟断一次”的规律性断线——这种规律本身就是很好的诊断信号。
第三步:看有没有错误日志。
我遇到过一个典型错误:stream disconnected before completion: failed to send websocket request: io。这个错误描述的是“在发送完整的 WebSocket 请求之前,底层流就断开了”,通常意味着 TLS 握手失败、连接被远端拒绝、或者代理层异常。排查这个问题的顺序是:先用wscat直连测试是不是服务端问题;再检查证书链;最后看代理配置。
第四步:检查心跳。
没有心跳的 WebSocket 连接在长时间空闲时必断,只是时间早晚的问题。我见过一个客户现场一直 1006,排查了半天发现是心跳消息的数据格式不对,服务端不识别,直接当作非法消息静默丢弃。所以心跳不是“发出去就行”,服务端必须确认能收到并正确处理。
5.2 断线重连与状态恢复:别让数字人“失忆”
断线重连不难,难的是重连之后怎么恢复状态。最简单粗暴的做法是:断开后重新连接,数字人回到 idle 状态,等待新指令。但如果是活动进行到一半断线,上一句话只播了一半,重连后数字人就会“失忆”——观众听到的是半句话戛然而止,然后沉默,然后才开始回答新问题,体验非常割裂。
我的方案是实现“断点续播”:
- 每条下发给 Vidu S1 的指令都带一个自增序列号
seq; - 服务端把最近未确认的指令缓存 30 秒(按
seq索引); - 客户端重连时带上
last_seq和session_id; - 服务端找到
last_seq之后未确认的指令,重新下发。
这样即使断线,恢复后也能把刚才没说完的话补上,而不是从零开始。重连策略本身用指数退避:1 秒、2 秒、4 秒、8 秒……封顶 30 秒。过快的重连会在服务端引发“重连风暴”,太慢又会让空窗期过长。
5.3 H5 能连,打包成 App 连不上:跨端 WebSocket 的经典问题
这个坑在热词里反复出现,我也踩过。用浏览器打开 H5 页面,WebSocket 连接一切正常;但打包成 Android App 后,同样的地址就是连不上。
常见的根因有三个:
**第一,Android 9(API 28)开始默认禁止明文流量。**如果你的 WebSocket 地址是ws://而不是wss://,App 会直接拒绝连接。解决办法是使用wss://,或者在AndroidManifest.xml里给应用配置android:usesCleartextTraffic="true"。但后者有安全隐患,只建议在内网测试环境使用。
**第二,原生网络库和 WebView 的行为不一致。**H5 用的是浏览器自带 WebSocket 实现,App 可能走了 OkHttp、Flutter 的 WebSocket 插件或 uni-app 的封装层。不同实现对于 Header、子协议、代理设置的支持程度不一样。我遇到过 OkHttp 默认不跟随重定向,导致服务端返回 301 时 App 直接失败。
**第三,证书验证问题。**测试环境用了自签名证书,浏览器打开会显示风险提示,但点过去还能继续;App 的证书校验更严格,直接拒绝连接。解决办法是测试阶段把服务端证书正式化,或者把自签名证书加到客户端的信任库。
排查这类问题,别在代码里东猜西猜,直接抓包看链路。用 Charles 或 Wireshark 看 App 发出的请求和收到的响应,错误原因一目了然。
5.4 鉴权与连接的边界问题
鉴权踩过的坑主要有四个:
- **token 过期时连接不会自动断开。**WebSocket 是一次握手鉴权,token 过期后如果没人管,这条连接会一直存活。我的处理办法是:服务端做定时任务检查会话创建时间,超过 token 剩余有效期就主动下发
session_expired并断开。 - **query 参数太长会 414。**有些系统的 token 本身很长,再加上 session 信息,URL 可能超过某些网关的限制。早期我用 Nginx 时默认
large_client_header_buffers不够,直接 414。后来把部分参数挪到了 WebSocket 子协议或者首条消息里。 - **URL 里带 token 会泄漏到日志。**这个前面说过,一定脱敏。
- **权限变更无法实时生效。**因为鉴权只在握手时执行,如果用户中途被踢下线,连接还是活的。所以我在处理“禁止发言”“权限变更”这类操作时,除了改数据库,还会主动调用网关的 disconnect 接口断开对应会话。
5.5 并发与单机上限:瓶颈不在 WebSocket,而在下游
很多人关心 WebSocket 单机能抗多少连接。理论上一台 8 核 16G 的机器,用 Go 写的网关可以维持 5 万到 10 万条空闲连接,但这只是“挂着不出事”的数字。真实场景里,每条活跃连接背后都在跑 LLM 调用和 TTS 合成,真正的瓶颈永远在下游。
我做了一次压测:20 路数字人同时说话,每路每 5 秒产生一句台词。网关的 CPU 和内存都稳得住,但 TTS 服务的排队时间从 50ms 涨到了 1.2 秒,LLM 服务的响应也开始明显变慢。这说明如果你要做大规模并发,重点是给下游加缓存、限流、队列,而不是盲目扩 WebSocket 网关。
一个实用的建议:**给每条连接做 token 级的并发限制。**无论上游来了多少指令,每条 Vidu S1 连接同一时刻只允许一个 LLM 生成任务、一个 TTS 任务、一个播放任务。这既保护了下游系统,也避免了数字人自己“忙不过来”导致的语音叠合。
6. 调试、压测与下一步扩展
6.1 调试工具:WebSocket King、wscat、浏览器 Network
调试 WebSocket 协议的工具有很多,我实际用得最多的三个:
- WebSocket King:客户端调试利器,能手动输入消息、查看帧内容、设置 Heartbeat。我经常用它手动给 Vidu S1 发一条
speak_text,验证数字人的口型和动作是否符合预期。这个工具非常适合快速验证协议字段。 - wscat:命令行工具,适合在服务器上快速测试 WebSocket 服务是否正常。比如怀疑 Nginx 配置有问题时,直接在服务器上跑一句:
wscat -c wss://api.example.com/ws/vidu?token=test如果 wscat 能连接,说明服务端和网络链路基本没问题,接下来再排查客户端代码。
- 浏览器 Network 面板:调试 H5 端时特别好用。Chrome DevTools 的 Network 面板里有 Messages 标签,能实时看到每一个 WebSocket 帧的记录,包括发送和接收的时间戳。定位“哪一条消息之后连接断了”非常直观。
6.2 压测思路:不只是“能连上”,还要测“蹩脚场景”
我给这套系统做了三轮压测,每一轮侧重点不同:
- 连接压力测试:模拟 5000 个客户端同时连接,保持心跳,观察服务端连接数、CPU、内存、断线率。主要排查的是资源泄漏和连接管理问题。
- 消息潮水测试:模拟 50 路数字人同时高频接收台词,每路每 1 秒来一条消息。这轮测出来问题很多,比如发送队列缓冲太小丢消息、TTS 排队时间过长、下游 LLM 触发限流。
- 断线重建测试:随机杀掉 10% 的客户端连接,观察服务端能否正常清理资源,以及剩余客户端能否正常使用。重点排查的是 goroutine 泄漏和半开连接。
压测脚本不用写得很复杂,用 Go 或者 Node.js 写个循环模拟器即可:
func simulateClients(n int) { for i := 0; i < n; i++ { go func(id int) { conn, _, err := websocket.DefaultDialer.Dial(url, nil) if err != nil { log.Printf("client %d dial error: %v", id, err) return } defer conn.Close() for { // 周期性发送心跳或者业务消息 conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"heartbeat"}`)) time.Sleep(2 * time.Second) } }(i) } }压测最重要的不是最终“能不能连”,而是看失败率、延迟分位数和资源占用曲线。如果 P99 延迟涨得很厉害,说明系统在极端情况下的稳定性不够。
6.3 下一步可以扩展的方向
这套“WebSocket + LLM + 数字人”的架构跑通之后,可玩性其实很高。我自己列了几个后续方向:
- 多角色切换:通过
session_ctrl动态切换数字人的角色设定、音色、服装和场景,实现“一个直播间多个数字人轮番上场”。 - 情绪与语气控制:在
speak_text消息里带 emotion 标签,比如happy、serious、sympathetic,让 LLM 在生成台词时就带上情绪提示,TTS 侧也做对应的情感合成。 - 字幕同步:把
speak_text的句子文本和音频帧序号对应起来,推给前端字幕系统,实现“数字人说到哪、字幕亮到哪”。 - 让数字人真正“听见”观众:在 WebSocket 上行通道里增加实时语音流,配合 ASR 实现连续对话,而不是一问一答的节奏。
对我来说,这套方案最有价值的并不是技术本身,而是让我想清楚了一件事:**实时数字人的本质不是“渲染引擎”,而是一个实时对话系统的出口。**它需要语音识别、语义理解、文本生成、语音合成、口型同步这些能力全部协同工作,而 WebSocket 恰恰是串联这一切的骨架。只要骨架搭得稳,往上加什么能力都不慌。