这个项目的定位一开始就很明确:做一个真正能开会用的在线视频会议系统,不是花架子 demo。技术栈选型定在 SpringBoot + Vue,核心功能包括多人在线会议(视频、语音、屏幕投屏)、内置 AI 会议助手(集成 DeepSeek 大模型)、支持自定义规则的敏感词过滤,以及会议内的即时通讯。整套做下来之后我大致评估了一下,如果认真落地,基本可以覆盖中小团队日常会议、在线培训、远程协作答疑这类场景。
先说项目适合谁看。如果你刚接触 WebRTC,想知道前端音视频和后端信令服务器是怎么配合的;如果你接过大模型 API,但不知道怎么把会议上下文组织成真正有用的纪要;或者你想在自己的聊天、评论系统里加一个不卡顿的敏感词过滤模块,这篇内容都能对上号。我会把设计思路、关键实现、踩坑记录完整摊开,重点放在“为什么这样做”,而不只是堆代码。整个系统做下来最大的体会是:单看每个功能都不算难,难的是把它们拼在一起还能稳定工作,所以在拆解时我会按模块讲,再补充联调层面的教训。
1. 项目整体设计与技术选型:为什么是这一套组合
1.1 在线会议系统最麻烦的三个核心问题
做会议系统之前,我先列了一下这类产品绕不开的难点,把问题想清楚再动手,比盲目搭框架省事得多。
第一个是音视频链路。多人开会意味着同时处理多路视频流、音频流和屏幕分享流,这都是实时媒体数据,不像普通 HTTP 请求那样可以随便丢包重传。这里涉及 WebRTC 的点对点连接、媒体协商、ICE 收集等一整套机制,前后端如何分工直接决定系统能不能跑通。
第二个是信令同步。开会不是把几个摄像头画面拉一路就行的,主持人踢人、禁言、有人中途加入、有人断线重连、屏幕共享被中断,这些“控制动作”必须实时通知到每个客户端。信令通道和媒体通道要分离,一个管控制消息,一个管音视频数据,两者还要在一个房间里保持状态一致。
第三个是内容管理。人一多,会议聊天里就会混进广告、辱骂、垃圾链接,甚至有人恶意刷屏。如果等到会议录屏存档的时候再人工审核,基本等于没审。这就要在消息链路里内嵌一层过滤能力,而且不能拖慢聊天、不能阻断正常发言。
所以整体架构我直接分成四块:前端 Vue 负责采集音视频流、渲染会议画面和维护界面状态;后端 SpringBoot 负责房间管理、用户鉴权、敏感词过滤、消息转发;WebSocket 通道承载即时通讯和 WebRTC 信令;AI 助手独立对接 DeepSeek 大模型 API,异步处理会议转写、纪要生成和实时问答。每一块职责清晰,联调的时候定位问题也快。
1.2 SpringBoot + Vue 的选型理由,以及我没选什么
选 SpringBoot 而不是 Node.js 或者 Go,不是因为别的语言做不了,而是这个项目要的服务端能力太“传统”:用户登录鉴权、MySQL 数据存储、房间生命周期的 CRUD、WebSocket 长连接管理,这些 SpringBoot 生态都有非常成熟的方案,团队协作起来上手成本也低。对于会议系统来说,后端稳定性和可维护性的优先级高于极致性能,SpringBoot 在这点上很合适。
Vue 的选择同理。会议页面的 UI 状态极其复杂:一个人什么时间加入、什么时间退出、谁在说话、谁开启了屏幕共享、聊天消息来了要实时滚动、AI 纪要要增量渲染。Vue 的响应式数据模型 + Composition API 对这种高频状态更新天然友好,组件拆分也容易。
我当时也纠结过要不要直接用云厂商的实时音视频 SDK,把 WebRTC 的部分全外包。后来评估下来,对这个项目来说,自研 WebRTC 链路更可控,也能把信令和即时通讯通道合并复用。而且会议系统这类产品的核心壁垒就在媒体链路的稳定性和低延迟控制,完全依赖第三方方案,后面想自定义互动逻辑(比如讲师标注、双流画面布局)会很被动。当然,如果是要做超大规模的直播型会议,自研的代价会很大,那时候用成熟的 SFU 方案是更理性的选择,这个我们后面细说。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 多人在线会议的视频、语音、投屏怎么落地
2.1 WebRTC 与服务器中转模型,先搞清楚再动手
先花两分钟交代基础,免得后面代码看不懂。WebRTC 的媒体数据是 P2P 直连的,也就是两个浏览器之间直接传音视频流,不走服务器中转,这样延迟最低、服务器带宽成本也最低。但 P2P 不是随便就能连上的,两个客户端需要先交换 SDP(媒体描述信息)和 ICE 候选(可能的网络路径),这个“交换”的过程需要后端做一个信令服务器帮忙牵线。所以后端在 WebRTC 里干的是“媒人”的活,不是“搬运工”。
两个人开会用 P2P 没问题,但五个人开会,如果每个人都要和其他四个人建立 P2P 连接,那每个客户端要处理 4 条上行流、4 条下行流,带宽和 CPU 都撑不住。所以多人会议必须做转发。标准做法叫 SFU(Selective Forwarding Unit),服务器只转发每个客户端的流给其他人,不做混流处理。比如张三说话,服务器把他的音频流推给李四、王五。这样每个客户端只需要上传一路流,下行流虽然多,但比全互联少一个数量级。
我这个项目落地时用了一个折中方案:业务刚起步时,后端不做完整的 SFU 实现,而是借助浏览器内置能力,在房间内做有限数量的 P2P 连接,同时在服务端实现了信令管理、流权限控制和媒体质量统计。如果后面真要把并发撑到几十人以上,再引入成熟的 SFU 服务组件,信令层保持不变,前端也可以平滑过渡。这样的演进路线在早期项目里非常实用。
2.2 信令流程与房间管理:核心代码结构
信令我用 WebSocket 实现,消息类型主要分三类:房间类(加入、离开、踢人)、媒体类(Offer、Answer、ICE 候选)、控制类(禁言、共享开关)。SpringBoot 端用 WebSocketHandler 统一接收和分发消息。
我贴一下后端房间管理的核心骨架,这部分的思路比代码本身更重要:
java复制// 简化版:房间管理服务
public class MeetingRoomService {
private final ConcurrentMap<String, Room> rooms = new ConcurrentHashMap<>();
public void joinRoom(String roomId, String userId, String userName) {
Room room = rooms.computeIfAbsent(roomId, k -> new Room(roomId));
Participant p = new Participant(userId, userName);
room.addParticipant(p);
// 通知房间内其他人:有新成员加入
broadcast(roomId, MessageType.USER_JOIN, p.getBriefInfo());
}
public void relaySignal(String roomId, String fromUserId,
String targetUserId, String signalData) {
// 信令转发:把 A 的 offer/candidate 转给 B
sendToUser(targetUserId, MessageType.SIGNAL, signalData);
}
}
前端 Vue 侧用 RTCPeerConnection 建立连接,流程比较固定:发起方创建 offer,通过 WebSocket 发给信令服务器,服务器转发给目标用户;目标用户用 setRemoteDescription 接收 offer,再创建 answer 返回给发起方,最后双方交换 ICE 候选。这里有个容易踩的坑:ICE 候选的交换必须在连接建立之前持续进行,任何一方的候选先到了,都要等另外一方。我建议在信令消息里带上 from 和 to 字段,保证每个消息都能找到目标连接,避免串流。
2.3 屏幕共享(投屏)实现:一条音视频轨就够了
屏幕共享的原理并不复杂,本质是给 RTCPeerConnection 再添加一条媒体轨道(track),这条轨道的内容来自 navigator.mediaDevices.getDisplayMedia(),而普通摄像头画面来自 getUserMedia()。混流显示的时候,把屏幕流的画面区域放大,把摄像头窗口叠加到角落就可以了。
实际做的时候要注意三个点。
第一,getDisplayMedia 在不同浏览器的兼容性不一样,Chrome 和 Edge 支持最好,移动端 Safari 的支持参差不齐,所以前端要做能力检测,遇到不支持的浏览器直接给出降级提示,不要让用户点了没反应。
第二,屏幕共享的帧率不要拉满。录屏画面对带宽消耗极大,我实际测试时,把屏幕流帧率限制在 15fps、分辨率控制在 1280x720,画面清晰度对大多数人来说已经足够,但带宽消耗能减少一半以上。设置方法很简单,在 getDisplayMedia 的约束里写 frameRate: 15 就行。
第三,屏幕共享和摄像头推流是两条轨道,接收方在渲染时要分别绑定 video 元素。我在 Vue 端用动态组件维护了一个“流列表”,每个流对应一个 video 标签,远端流通过 srcObject = stream 绑定。这个结构后期扩展多画面模式、主持人视图切换都比较方便。
下面给出前端获取屏幕流的示例:
javascript复制async function startScreenShare(peerConnection) {
const screenStream = await navigator.mediaDevices.getDisplayMedia({
video: { frameRate: 15, width: 1280, height: 720 },
audio: false, // 投屏一般不分享系统声音
});
screenStream.getVideoTracks().forEach((track) => {
peerConnection.addTrack(track, screenStream);
});
}
3. 集成 DeepSeek 大模型作为 AI 会议助手:知行合一
3.1 别把大模型当成“问答盒子”,AI 助手是会议的第二大脑
很多人接大模型 API,就是在聊天框里发一句话、等一个回复,然后循环。但会议助手不能这么做,它的价值在于“全程在听”:会议进行中能实时生成要点摘要,会议结束时能产出结构化纪要,有人提问时能基于前面的讨论内容回答。这需要把会议的状态和上下文持续喂给大模型,而不是临时拼一个 prompt。
我这个项目里,AI 助手处理了几个任务:实时摘要(每 3 分钟把这段时间的讨论内容压缩成要点)、总结待办事项、回答参会者的文字提问。三个任务共用同一个 DeepSeek 大模型接口,但上下文构建方式完全不同。实时摘要要的是“这一段聊了什么”,所以只取最近的转写文本片段;待办提取要的是“谁负责什么、截止时间”,所以在完整会议记录里做信息抽取;问答则要把历史会议记录和当前问题拼在一起做检索增强。
实现大模型接入时,我用了一个独立的异步任务队列,不直接写在 WebSocket 消息处理线程里。因为大模型接口响应速度不可控,如果是同步调用,一次请求可能阻塞会议里的其他控制消息。异步任务的好处是:收到会议转写文本后,先把 task 丢进队列,AI 服务慢慢算,算完再通过 WebSocket 把结果推回前端。用户感知是“AI 摘要稍等几秒刷新一次”,不会影响其他功能。
3.2 上下文怎么组织:转写切片与分层窗口
这是全项目最值得分享的设计,我反复调了好几版才稳定。问题在于:把一小时的会议记录全塞给大模型,token 会爆掉,成本也高;只塞最后一句话,模型完全不知道上下文,回答驴唇不对马嘴。
我最终采用的方案是“分层窗口”设计:
- 基础上下文:会议主题、参会人名单、当前发言者。
- 短期窗口:最近 10 分钟内的转写内容,用于实时的摘要和问答。
- 长期记忆:会议前 30 分钟自动沉淀的结构化要点列表。
大模型接口调用时,把短期窗口作为主要参考,把长期要点作为附加背景,基础信息拼在最前面。这个结构有两点好处:一是实时问答能覆盖“刚才说过什么”这类需要短期记忆的问题,二是 token 消耗被严格限制住了,不会随会议时长线性增长。
这里提醒一句:如果要做超长会议(两小时以上),可以在每次摘要生成完成后,把新摘要追加进长期要点列表,并丢弃过期的原始转写原文。这本质上是一个“压缩-沉淀-再压缩”的过程,能保证大模型的输入窗口始终在可控范围内。
3.3 流式输出与超时容错
DeepSeek API 支持流式返回,前端用 SSE(Server-Sent Events)接收大模型增量输出。好处是用户在界面上能看到“打字机效果”,不用干等十几秒。我在后端做了一层适配:大模型返回的流式数据,由后端统一包装后通过 WebSocket 推给前端。为什么不用 http 直连?因为会议前端已经有稳定的 WebSocket 通道,复用它能少维护一套协议。
大模型调用要防“坏味道”:一是超时,默认请求超时设成 30 秒,超过直接放弃这次生成,并在前端提示“AI 助手暂时不可用”;二是并发限制,我用信号量控制最多同时 3 个大模型请求,避免会议高峰期把下游 API 打爆;三是内容长度限制,设置 max_tokens,防止模型自言自语输出一大段废话。这几个参数实测下来,能在不稳定网络环境下保住会议体验的底线。
4. 可自定义规则的敏感词过滤系统:拦截要快,但不能乱杀
4.1 匹配算法选型:为什么用 DFA 而不是直接遍历
先明确一个概念:这里的敏感词不是特指某一类词,而是业务上需要拦截的所有关键词,比如辱骂、广告、外链、联系方式、竞品词、刷屏语等。不同企业、不同会议主题,需要拦截的词都不一样,所以这个系统必须支持自定义规则。
实现上最容易想到的是 contains 遍历,但会议聊天是高频消息,一条消息 50 个字,词库 2000 个词,最坏情况要做 10 万次字符串匹配,压力测试下 CPU 直接飙升。群里同时发言时,卡顿感非常明显。
我最终实现了 DFA(确定性有限状态自动机)匹配。思路是构建一棵前缀树,比如词库里同时有“垃圾”和“垃圾广告”,自动机只需要匹配到“垃”这个第一个字,后续状态沿树向下走,到一个终止节点就算命中一个敏感词。这种方式的时间复杂度是 O(n),n 是消息长度,和词库大小无关,词库再大也不影响匹配速度。实际测试中,2000 条消息并发过滤,单条平均耗时不到 1 毫秒。
我贴一段核心的 Java 过滤实现:
java复制public class SensitiveWordFilter {
private final TrieNode root = new TrieNode();
public void loadWords(Collection<String> words) {
for (String word : words) {
TrieNode node = root;
for (char c : word.toCharArray()) {
node = node.children.computeIfAbsent(c, k -> new TrieNode());
}
node.end = true;
}
}
public String filter(String text, char replace) {
StringBuilder result = new StringBuilder();
for (int i = 0; i < text.length(); i++) {
TrieNode node = root;
int j = i;
while (j < text.length() && node.children.containsKey(text.charAt(j))) {
node = node.children.get(text.charAt(j));
if (node.end) {
// 命中敏感词,替换 [i, j] 区间
for (int k = i; k <= j; k++) {
result.append(replace);
}
i = j;
break;
}
j++;
}
if (result.length() <= jIndexCheck(text, i, result)) {
// 未命中时保留原字符
appendChar(result, text.charAt(i));
}
}
return result.toString();
}
// 简化处理:实际用指针计数,避免边界干扰
}
这段代码逻辑上还有边界处理可以优化,但核心思想是明确的:构建好前缀树之后,每个字都只尝试一次跳转,匹配效率极高。
4.2 自定义规则与词库管理的落地细节
光有算法不够,管理手段才是产品体验的关键。我做了一套词库管理后台,支持两种类别的规则:
- 全局词库:系统预设的公共词库,覆盖常见的辱骂、垃圾广告、恶意链接格式。
- 会议级词库:每个会议可以单独配置拦截词,比如培训场景屏蔽竞品名称、招聘场景屏蔽联系方式。
每条规则还可以设置命中后的处理动作:替换成 *、直接拦截不发送、或者仅记录不发警告。这是为了应对不同场景:有些词属于“严格禁止”级别,比如辱骂,直接拦住;有些词属于“软提醒”级别,比如过度营销言论,替换符号后放行,让其他参会者看得懂,保留了讨论上下文。
自定义规则还有一个关键设计:白名单优先级。比如企业内部讨论提到某些产品名,正好命中了竞品词库,但这种场景不该拦截。所以白名单的处理逻辑是:先判断是否命中白名单,命中则直接放行,不进入敏感词匹配流程。白名单要支持词组匹配,不能只支持单字。
词库管理前后端的信息流也很简单:SpringBoot 提供一个 SensitiveWordController,包含词库的增删改查和批量导入;Vue 管理端调用接口后,服务端会刷新内存中的 DFA 树。为了避免每次增删都全量重建,我采用了版本号机制:词库变更后版本号 +1,过滤服务下次使用时按最新版本加载,单次重建耗时在毫秒级,可以忽略不计。
4.3 过滤时机与降级策略:别让过滤拖垮会议
过滤时机要分层,我在三个节点都接入了过滤:
- 客户端发送聊天消息前做一次轻量预检,命中直接不让发,提升用户体验;
- 服务端收到消息后再做一次完整过滤,防止有人绕过前端直接调接口;
- 消息入库前做一次审计记录,方便后续追溯。
这里最重要的设计是降级策略。过滤服务是辅助功能,万一 DFA 树加载失败或者词库服务重启,不能把整个会议聊天拖死。当过滤器状态为不可用时,我做一个降级开关:消息直接放行,只记录日志,标记为“未过滤”。同时监控系统会报警,管理员可以手动关闭会议级过滤。这个设计让我后来在线上排查问题时非常舒服,遇到过滤异常时,只需要调降级开关,不是从源头阻断整条链路。
5. 即时通讯模块,以及前后端联调的关键实现
5.1 WebSocket 消息通道:信令和聊天共用还是分开?
项目里即时通讯主要负责会议内聊天,包括全体消息、私聊、系统通知(比如某人加入、某人禁音)。一开始我犯过一个错:把 WebRTC 信令和聊天消息分成两条 WebSocket 连接,后面发现问题反而多了——前端要维护两个连接状态,断线重连逻辑写两遍,而且两条连接之间消息顺序可能不一致(比如先收到“某人退出的聊天通知”,再收到对应的信令断开消息),体验很割裂。
后来我把它们合并成一条连接,消息体里用 type 字段区分:
SIGNAL:WebRTC 信令(offer/answer/ice)CHAT:普通聊天消息SYSTEM:系统通知AI_UPDATE: AI 助手推送的纪要/摘要
这样做的好处非常明显:消息有序、连接唯一、断线重连只需要写一套逻辑。后端处理时,CHAT 类型会先经过敏感词过滤模块再广播,SYSTEM 直接广播,SIGNAL 按消息里的目标用户转发。为了不让广播风暴打垮服务器,一个房间内的广播我用并发安全的 ConcurrentHashMap 管理 WebSocket session,批量发送时分组推送,避免逐条循环发送。
5.2 消息确认与离线消息:容易被忽略的一环
会议聊天有一个典型场景:某参会者 Wi-Fi 网络抖动,几秒内重连成功,这期间别人发的消息他可能丢了。为了不让这种体验砸口碑,我给聊天消息加了一个简单的确认机制。
前端发送 CHAT 消息后不立即上屏,而是等服务端回一个 CHAT_ACK(回包里带消息 ID)再上屏。如果 5 秒没收到 ACK,前端标记为“发送失败”,用户可以手动重发。服务端在广播前先做两件事:敏感词过滤(不合格的就不广播了)和持久化。确实会存在一小段网络抖动导致的消息丢失,但因为会议场景对实时性要求高于完整性,这个方案在成本和体验之间取了平衡。
持久化方面,我用了 MySQL,表结构主要围绕会议 ID 和用户 ID 建索引。聊天记录不仅用于会后审计,还能作为大模型批量生成会议纪要的原料。这里有一个设计建议:聊天表的 content 字段要存储过滤前和过滤后两个版本,过滤前的版本留给管理员审查,过滤后的版本展示给普通用户。这个设计在后期做内容合规复查时帮了大忙。
5.3 Vue 端状态同步:把“谁在开会”画清楚
Vue 前端最重要的部分不是组件样式,而是状态同步逻辑。我用了一个 useMeetingStore 的 Composition API 模块统一管理:参会人列表、每个参会人的媒体状态(摄像头开关、麦克风开关、共享状态)、聊天消息列表、AI 助手状态。
后端 WebSocket 每推来一个 USER_JOIN 或 USER_LEAVE 消息,useMeetingStore 就更新参会人列表,再由 Vue 的响应式系统自动触发画面重新布局。这里有个典型问题:会议室画面布局频繁变化会导致 video 元素重新挂载,画面闪黑,体验很差。解决方法是给每个参会人绑定一个固定的 streamId,video 元素用 v-for 渲染时以 streamId 作为 key,这样同一个人的流只更新内容,不重建元素。
状态同步还有一个细节:主持人操作(踢人、全员禁音)在前端要立即响应。我用了一个本地乐观更新策略:操作成功后先改本地状态,同时给后端发指令,后端广播给其他人。如果后端拒绝了(比如权限不足),再回滚本地状态。这种模式在会议控制场景下体验明显优于“等后端指令回来再更新”。
6. 常见问题排查与实操避坑(可直接对照)
6.1 音视频链路和浏览器环境问题
会议系统 80% 的问题出在音视频链路上,这里列一下实际踩过的坑。
第一个是麦克风或摄像头无法启动。前端调用 getUserMedia 失败时,浏览器会返回 NotAllowedError,但用户经常不知道是权限问题还是设备占用问题。我的处理是:捕获错误时把 name 字段透传给用户,并附上一段提示文案:“请检查浏览器权限是否允许访问麦克风/摄像头,并确认设备未被其他应用占用”。同时后端会议页提供“重新尝试获取权限”按钮,实测能解决一半以上的求助问题。
第二个是回声和啸叫。会议室里开着扬声器说话,远端听到剧烈的回声,这个问题不解决会让人直接退出会议。WebRTC 自带了回声消除(echoCancellation),但默认不保证所有浏览器都开启。前端获取音频流约束时要显式设置:
javascript复制const stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
video: { width: 1280, height: 720 }
});
但要注意,软件开关和实际硬件效果存在差异,如果发现回声没被消除,只能靠用户戴耳机解决。产品上最好直接提示:“会议中请佩戴耳机以避免回声”。
第三个是内网和外网的环境差异。两个不同内网的用户基于 P2P 直连时,大概率会失败,需要借助 STUN/TURN 服务进行网络穿透和转发。早期测试时我在内网环境下一切正常,上线后才发现跨网络用户经常连不上。最终我在后端配置了可用的 TURN 服务作为兜底,真实开会时依赖 P2P,穿透失败自动切换到 TURN 转发。这里提醒一下,如果你们只是做内部场景的会议系统(比如公司局域网),TURN 配置可以省掉;但只要面向公网,这一项不能少,否则就是“一半人连不上”的结果。
6.2 AI 助手调用超时与 token 控制问题
AI 助手最容易出的问题是大模型接口在会议高峰期的响应变慢,甚至超时。我遇到过一次典型状况:会议进行到 40 分钟,转写任务积压,AI 摘要的推送延迟了 10 多分钟,用户体验直接崩了。
这里给出三个排查方向:
第一,确认任务队列消费速度是否匹配生产速度。会议转写是持续产生的,但大模型接口是有限速的,所以队列必须设置积压上限。我实现的队列容量为 200,超过上限直接丢弃转写片断,只记录当前会议的关键状态,等下一批数据到来时再恢复。对于摘要功能来说,丢一段 10 秒的转写远没有“全部卡死”严重。
第二,控制大模型的 max_tokens。我一开始给实时摘要设置了过大的输出上限,导致模型返回很慢,后来把实时摘要的输出长度控制在 300 token 以内,响应速度明显改善。定期更新一份“输出长度对照表”放在代码注释里,方便后续调整。
第三,后端调用大模型 API 时要加上重试机制,但重试次数不能多。我使用的是最多 2 次重试,每次间隔指数退避(1 秒、2 秒),超过就放弃。会议场景下,AI 助手降级成不可用状态好过一直转圈。
6.3 敏感词过滤的误杀与性能问题
过滤系统上线后,遇到过两次典型问题。
一次是误杀导致的“正常发言被拦截”。原因是一个词库词条太长,把正常句子里的连续几个字误识别成敏感词。比如词库里有“免费领取”,正常发言里出现“免费领取名额”就全部替换成星号,但业务上这是正常话术。解决方式是:给规则增加一个“支持词边界匹配”选项,比如命中时需要前后是空格或标点才能生效。这个设置可以避免很多肉眼看不到的误杀。
另一次是性能问题。词库从几百个词增长到 3000 个词之后,服务端每次消息过滤耗时从 0.3 毫秒涨到了 0.8 毫秒左右,虽然不算严重,但在高频场景下要尽早用 DFA 解法(其实就是把所有规则构建成前缀树,匹配时一次性跳转)。如果词库到了上万级别,可以考虑用多级索引或者把常用词库直接缓存在 Redis 中,过滤服务读取时不用每次重建 DFA 树。
最后汇总一个常见问题速查表,方便大家直接对照。
| 问题现象 | 可能原因 | 解决方向 |
|---|---|---|
| 会议画面连不通 | 网络穿透失败,缺少 TURN 服务 | 配置 STUN/TURN,启用 ICE 候选中继模式 |
| 有画面但没声音 | 浏览器音频权限未授权,或音量过低 | 显式设置 getUserMedia 音频约束,检查系统音量 |
| AI 摘要迟迟不更新 | 大模型接口超时,队列积压 | 缩短 max_tokens,设置队列上限和超时降级 |
| 聊天消息被误替换 | 词库规则过长且无边界限制 | 增加词边界匹配,调整会议级词库 |
| 与会者状态不同步 | 信令和聊天消息使用多通道导致乱序 | 统一使用单 WebSocket 连接并按类型分发 |
| 屏幕共享卡顿 | 采集帧率过高,带宽不足 | 设置 15fps、分辨率和码率上限 |
我在实际使用中最深的一层体会是:这类系统的价值往往不在某个单独功能,而在于功能之间的协作关系。会议中产生的语音、文字、屏幕共享流,要经过 WebRTC、WebSocket、内容过滤、AI 分析这样一条链路,任何一个环节的稳定性都会影响整体体验。所以做的时候,多预留一点降级方案,多一点监控日志,比把每个功能都做到极致更有用。文章里的代码是简化版,完整工程里还有更细致的状态机和重试逻辑,如果你也在做类似的会议系统,欢迎带着具体问题来交流。
