一个 WebRTC 聊天室的四次演进

2022 年初我第一次把 free4chat 发到 V2EX,标题是「搭了一个 WebRTC 语音聊天室,效果惊人」。一台 1 核 1G 的 AWS 服务器,撑住了近千人的并发访问,负载几乎没动。

后来我把它重写了三次。不是第一版不能用——它一直跑得不错——而是每次项目都撞上了新的边界:想搞清楚 WebRTC,想少养服务器,想把基础设施彻底 serverless,最后发现托管并不会消灭复杂度,它只是把复杂度和控制权一起交给供应商。

最初的动机很简单:我需要一个随时能拉人开会的工具,不要账号,不要下载 App,分享一个链接就能聊。Zoom、腾讯会议功能都更强,但它们都要求先把人组织起来。free4.chat 到今天没变的核心价值是:不用先建立一个长期组织,需要协作时就创建一个临时房间,协作结束房间就消失。最开始房间里只有人,它看起来就是个匿名语音聊天室;现在参与者扩展成了人和 Agent,「临时、用完即走」这条产品基因没变。

这段历史我在 2026 年 5 月写过一版,当时第三版刚迁到 Cloudflare RealtimeKit,我对这套「Cloudflare 全栈」方案评价很高。8 月,一次生产环境的会话生命周期和计费异常改变了我的判断,free4chat 迁到了更底层的 Cloudflare Realtime SFU,文章也从「三次演进」更新为「四次演进」。RealtimeKit 那一章保留,因为它是一次真实发生的技术选择;但原文的推荐不再成立,下文会讲清楚为什么。

第四次迁移之后还发生了一件我没预料到的事:房间没变,房间里的参与者开始变了。free4chat 最早只连接人;现在同一个临时房间也能让人和独立运行的 Agent、甚至多个 Agent 直接协作。技术栈没有第五次重写,「临时房间」这个抽象只是在 Agent 时代长出了新用途。

先补两分钟 WebRTC 背景

WebRTC 本身不定义信令协议:它只管媒体传输,两端如何交换连接信息(SDP)由应用自己实现。信令和媒体分离,后面每次重写的核心问题都是这两部分分别由谁负责。

大多数用户在 NAT 后面。ICE 负责找路:先试本机地址,再试 STUN 探测到的公网映射地址,都不行就走 TURN 中继。STUN 几乎不占带宽;TURN 是实打实的中继服务器,一旦流量落到 TURN,带宽和可靠性成本就都归自己了。

媒体必须加密:两端用 DTLS 协商密钥,再用 SRTP 传音视频。所以服务端如果想录音、转码,或者让 Agent 直接开口说话,就得作为 WebRTC 节点进入媒体平面,光处理 HTTP 请求是不够的。

多人场景常见三种架构:P2P 网格在人数多时连接数和上行带宽爆炸;MCU 在服务端解码、混流、重编码,客户端轻松但服务器 CPU 和延迟高;SFU 只做选择性转发,不解码,是现代多人 WebRTC 的主流。free4chat 以纯音频为主、码率低,SFU 的成本尤其可控——但别把「SFU 不吃 CPU」当定律,视频、Simulcast 和大房间转发都是另一回事。

第一版:Go + Pion,自建 SFU

第一版基于 Pion 生态里的 kraken。Pion 是 Go 实现的完整 WebRTC 协议栈,ICE、DTLS、SRTP、SDP、DataChannel 都能直接控制。

graph LR
    A[Browser A] -->|WebSocket 信令| S[Go Server\nSFU + STUN/TURN]
    B[Browser B] -->|WebSocket 信令| S
    A <-->|DTLS/SRTP 媒体| S
    B <-->|DTLS/SRTP 媒体| S

整个后端跑在一台 1 核 1G 的 AWS 实例上。因为主要是低码率语音,SFU 不需要解码重编码,V2EX 发布那天近千人访问,负载仍然很低。

这版最大的优点是透明,出了问题可以从 ICE、SDP、DTLS 一路查到 RTP。缺点也清楚:单机是单点;STUN/TURN 要自己养;安全组和 UDP 端口要自己配;kraken 有连接资源释放不完整的问题;水平扩容得自己补协调层。

第一版让我把 WebRTC 的底层摸了一遍,也让我意识到:对个人项目来说,媒体服务器本身会持续吃掉维护时间。

第二版:Elixir + Membrane

第二版换成 Elixir + Membrane。Go 本身没问题,我只是想试试 Erlang/OTP 的进程模型和集群能力对实时系统是不是更自然。事实是适合:每个房间、连接、媒体管道都是独立进程,Supervisor 负责故障隔离,多节点靠 Erlang Distribution 互联,不需要为了跨节点感知再引入一层 Redis Pub/Sub。这一版还用 Phoenix Channels 加了文字聊天,应用状态和集群扩展比第一版优雅不少。

但媒体还是自建的。coturn、EC2、安全补丁、节点部署、监控、端口映射,一件也没少。这些在公司里是标准基础设施工作,在业余项目里却会不断打断产品开发本身。这就是第三次重写的直接原因。

第三版:Cloudflare RealtimeKit,把媒体托管出去

第三版的问题很直接:连 SFU 都不养了,会怎样?

当时的答案是 Cloudflare RealtimeKit(源自 Dyte)。前端和 API 跑在 Workers 上,限流用 KV,房间状态和 AI 会话用 Durable Objects,媒体、STUN/TURN 和 WebRTC 会话全部交给 RealtimeKit。这版是用 OpenCode + Claude Sonnet 4.6 在一个周末完成的——前两版是一行行手写的,到这一版,AI 辅助开发已经开始实质改变个人项目的开发速度。

当时选 RealtimeKit 的理由完全成立:不用再维护 SFU 服务器和 coturn,不用管公网 UDP 端口和安全组,SDK 已经处理了大量参与者、track 和会议生命周期,其他服务又都在同一个平台里。对个人开发者,少一个服务器、少一套部署、少一个监控面板,就是少一份认知负担。原文因此给了它很高的评价,把它当作「个人项目、快速验证、不想管服务器」的推荐方案。

这个推荐现在撤回。

即使没有后面的事故,RealtimeKit 也有一个天然取舍:它是高层的会议平台,而不是底层的 SFU API。应用拿到了参与者、预设、会议这些抽象,也交出了媒体层的控制权——比如让服务端 AI 以 WebRTC 节点的身份加入房间、直接控制 track,在高层 API 下能做的事有限。这本来只是便利性换控制权的正常交易,改变判断的是 2026 年 8 月的一次生产事故。

2026 年 8 月:让我停用 RealtimeKit 的生产事故

先说清楚边界:Cloudflare Engineering 至今仍在调查,完整的工程根因还没有公开结论。下面只写我在自己的生产应用里能复现、能保存证据的事实。

事故发生时,RealtimeKit 仍处于 Beta。当时的定价说明写明 Beta 期间免费,并列出了正式计费后的 participant-minute 价格:Audio/Video Participant 按 $0.002/min、Audio-Only Participant 按 $0.0005/min。但我的账单里出现了 RealtimeKit Participant 费用,第一个账期 $147.62,之后还在涨。因为费用持续增长,8 月 21 日我把生产媒体流量紧急迁离 RealtimeKit,切到 Realtime SFU。到 2026 年 8 月底,RealtimeKit 已结束 Beta,现在的定价页直接列出这两个费率。

麻烦在于:产品已经不用 RealtimeKit 了,账单还在涨。8 月 22、23 日每天仍有接近 $29 的 Audio/Video Participant 费用。起初我以为是账单延迟,直到通过 RealtimeKit API 全量检查应用状态才发现问题:应用里共有 2,440 个会议、2,609 个会话,其中 12 个会话还标记为 LIVE,这 12 个里面恰好有 10 个 live participants,而它们对应的会议早已全部 INACTIVE。最老的残留会话从 8 月 1 日挂到 8 月 25 日都没结束,而生产客户端早就离开,也不再产生新的 RealtimeKit 流量。

这组数字和账单几乎精确吻合:10 个 participant × 1,440 分钟/天 × $0.002/min = $28.80/天,而 8 月 23 日实际账单是 $28.76/天,只差 $0.04。

为了止损,我先保存了完整的 Apps、Meetings、Sessions 和 active-session API 响应,再用官方 kick-all API 清掉全部残留参与者。清理后 live participants 归零,2,440 个会议全部 INACTIVE,历史数据保留给 Cloudflare Engineering 调查。还有一个分类问题:我用的是 Voice 预设,按文档应归入 Audio-Only,但账单里这些分钟全部计成了 Audio/Video。两个问题在同一个工单里升级给了 Realtime Engineering。

10 个残留的 LIVE 参与者对应约 $28.8/天 的 participant-minute 费用,这不能证明内部根因,但吻合度摆在那里。

这次事故让我重新审视按分钟计费的模式。按时间收费本身没问题,问题是当「参与者是否在线」由服务端会话状态决定时,生命周期必须有强制超时、状态对账和计费保护,否则客户端已经退出,计费时钟还在走。而托管服务的用户很难从自己的应用监控里发现这种状态漂移——我的真实流量已经停止,会议是 INACTIVE,服务端却把 LIVE 参与者留了好几天甚至几周。

所以在 Cloudflare 给出明确根因和防护之前,我不再推荐把 RealtimeKit 用在需要可预测 participant-minute 计费的生产项目里。

第四版:Cloudflare Realtime SFU,把应用语义拿回来

这次没有回到 Go/Pion,也没有重建 Elixir 集群,而是留在 Cloudflare,把媒体层从高层 RealtimeKit 换成更底层的 Cloudflare Realtime SFU。媒体基础设施仍然 serverless,但房间、参与者、会话、track 和应用生命周期重新由 free4chat 自己控制。

graph TB
    UA[Human Browser A] <-->|WebRTC audio / screen| SFU[Cloudflare Realtime SFU]
    UB[Human Browser B] <-->|WebRTC audio / screen| SFU
    UA <-->|DataChannel files / images| UB

    UA --> W[Cloudflare Worker]
    UB --> W
    W --> TS[Turnstile\njust-in-time]
    W --> RS[RoomSession\nDurable Object]
    W --> SFU

    AR[Local Agent Runtime\nGo + in-process Pion] <-->|MCP room protocol| W
    AR <-->|optional granted media| SFU
    AR <-->|ACP| H[Codex / Claude / Pi / Hermes / ...]

    RS -->|presence / text / collab / expiry| UA
    RS -->|presence / text / collab / expiry| UB
    RS -->|bounded room context| AR

浏览器直连 Realtime SFU 处理语音和屏幕共享;Worker 负责认证和创建 SFU 会话,Turnstile 不再挡住整个页面,只在创建新会话前做一次即时验证。房间状态由一个可休眠的 Durable Object(RoomSession)管理:在线状态、静音、文字消息和表情、断线重连、协作事件、随房间销毁的临时附件,以及房间过期。人与人的大文件仍走 DataChannel,不进永久数据库;Agent 需要的小文件可以作为临时附件在房间生命周期内暂存,房间删除就一起消失。

Agent 侧也不再是早期那种托管在服务端的机器人。free4chat 有一个自包含的本地 Go Runtime,内部用 Pion 处理可选的实时媒体,通过 MCP 加入房间,通过 ACP 连接 Codex、Claude、Pi、Hermes、OpenCode 这些已有的 Agent 运行环境(Harness)。Runtime 负责参与者租约、事件等待、断线重连这些协作生命周期;模型、工具、凭据、权限和私有记忆仍然属于用户自己的运行环境。

为什么这次不回到自建 SFU?因为前两版的问题还在:我不想重新养媒体服务器、TURN 和安全补丁。RealtimeKit 和 Realtime SFU 的区别在于责任边界:前者把会议、参与者、会话的生命周期也一起托管;后者更像一个可编程的媒体交换机,Cloudflare 负责全球媒体节点和 WebRTC 基础设施,房间和业务语义归我。第四版代码量比 RealtimeKit 版多,但出异常时我能直接看到应用认为谁在线、SFU 会话是什么、track 怎么发布订阅,也能自己决定什么时候清理。

计费也更容易理解了。Realtime SFU 按实际从 Cloudflare 边缘发给客户端的数据量计费:$0.05/GB,每月前 1,000 GB 免费,客户端到 Cloudflare 的方向不收费。按流量不一定更便宜,视频和高码率照样有可观的流出流量;但我更喜欢它的风险模型——没有媒体数据发送,就没有还在走的时间计费时钟,计费单位更接近可观测的网络资源。以 free4chat 现在的语音规模,免费额度也远大于实际用量。

四个版本横向对比

只看开发速度,RealtimeKit 仍然最快;只看控制权,自建 Pion 仍然最彻底。对我现在的场景,Realtime SFU 平衡最好:不养媒体服务器,也不把应用的会话状态完全交给高层托管服务。

房间没变,参与者变了

第四版上线后,我原本只想继续实验「让 AI 进实时房间」。做下去才发现,比起给聊天室加一个 AI 功能,有意思得多的是把参与者这个抽象从人扩展到了独立运行的 Agent。

最早做 AI 机器人,思路还是传统的「模型 + 提示词 + 工具调用」:服务端起一个模型,听房间里的文本,然后回复。这种方案能做演示,但有用的 Agent 早就不只是一个模型——Codex、Claude Code、Pi、Hermes 有自己的模型、上下文和记忆、工具、本地文件和运行环境、已登录的账号凭据,还有自己的安全策略和人工审批边界。如果 free4chat 把这些能力重新托管一遍,既重复,也破坏了「临时、轻量」的产品边界。

所以方向反过来:不搬走 Agent,只让它们临时连进来。现在的房间同时支持三种协作:人与人、人与 Agent、Agent 与 Agent。Agent 可以创建房间、公布一个很小的能力列表、发现其他参与者、发出协作请求;目标方自己决定接不接,干完活再返回结果和文件。但公布能力不等于授权,协作请求也不是远程函数调用——真正干活的是目标 Agent 自己,用的是它自己的运行环境、工具和权限策略。

原来的人与人实时聊天没有消失,它只是更一般的参与者模型里的一个特例。free4chat 因此更像一个很薄的临时协作空间:房间负责让参与者临时发现彼此、交换明确共享的上下文、请求与结果、文件和媒体;智能、工具、凭据、权限和持久记忆都留在参与者自己手里。压缩成一句话:房间提供临时的协作空间,能力由参与者自带。

真实使用中已经验证的事

共享实时转写

一个被人类参与者显式启动的流程:授权一台具备 STT 能力的本地 Runtime Host 订阅房间音频,用用户本地配置的语音凭据做流式识别,把带说话人归属的文本提交为房间级的实时转写。这份转写成为房间内临时的共享上下文,随房间消失。

转写被提交,并不会自动唤醒房间里的常驻 Agent。只有当某个 Agent 被明确点名时,它的运行环境才会在下一次对话里把这份转写带进上下文。另外,直接用 MCP 客户端连房间 API 本来就能读到这份转写,所以「点名」不是访问控制边界,而是常驻运行环境要不要被激活的开关。

跨机器的 Agent 协作

真实生产环境里跑通过这样一条链路:Mac mini 上的 Agent A 发起一个结构化协作请求,把要处理的文件作为附件放进房间;MacBook 上独立运行的 Agent B 从房间里读到这个附件,在自己的运行环境、工具和权限下完成本地处理,生成一个真实的结果文件,再通过房间把关联结果和文件交还给 A。整个过程没有共享文件系统,没有剪贴板中转,没有借道 S3、scp 或 Git 传文件,没有中央规划器,free4chat 自己也不拥有任何「Agent 大脑」。

这件事值得记录,是因为它验证的恰好只是房间本身:请求和文件经由房间流动,计算和决策始终发生在两台各自独立的机器上。接下来的产品问题也很具体:怎么让这条已经跑通的跨机器协作,从终端更容易进入,而不是每次都先打开浏览器。

语音链路

语音这块目前完成了两件事,并且都在真实使用中用过。一是上面说的房间级实时转写;二是 Agent 语音回复:人类参与者显式授权后,Agent 运行环境的文本回复经 TTS 合成,由 Runtime 里的 Pion track 重新发布回 Cloudflare SFU,房间里的人直接就能听到。也就是说「语音 → 识别 → Agent 运行环境 → 文本 → 合成 → 语音」这条基线已经存在;转写是协作的基础设施,不自动发言,也不留档。

实时语音到语音的模型(OpenAI、豆包这类)仍然可以作为独立实验,去试更低延迟、自然打断和连续对话,但它不在当前产品路径的承诺里。就算做了,它也不该取代 Agent 运行环境——更有意思的形态是语音 Agent 负责跟人自然对话,遇到真活儿再通过房间找到带本地工具的 Agent 发起协作请求,拿到结果用语音转告人。

free4chat 不想成为的东西

企业协作产品从一个长期工作区开始:组织、账号、权限、频道、历史、搜索都围绕它生长。它们当然更强,但 free4chat 想解决的是一个更薄的问题:我现在就需要这几个人、几个 Agent 一起做一件事,能不能不先把所有人迁进同一个组织和平台?

所以 free4chat 刻意不去做中央 Agent 编排、规划器和调度器、永久项目工作区、集中记忆或知识库、凭据保管箱,也不做 Agent 市场,更不做 Slack 替代品。参与者的智能、凭据、私有记忆、项目历史和编排都留在他们自己那里,free4chat 只提供那间临时房间。Agent 可以继续跑在笔记本、手机沙盒、Mac mini、VPS 或公司环境里,只在需要合作的那段时间进同一个房间。这个模型如果成立,它更像 Agent 时代的临时局域网:不把计算搬到一台机器上,只给彼此独立的参与者一个低成本、短生命周期的协作网络。

总结

四个版本做下来,我对技术选型的判断反而更保守了:没有最好的技术栈,只有边界最匹配的。第一版让我摸清 WebRTC 底层,第二版让我体验 OTP 对实时系统的表达力,第三版让我看到「零运维」的吸引力,第四版补上最重要的一课:托管服务不会消灭运维风险,它只是重新分配责任。 第四版之后的 Human + Agent 实验还让我看到另一件事:一个简单抽象会随外部环境变化突然获得新用途——free4chat 的房间本来只为临时把人连起来,Agent 出现后,它开始临时连接拥有不同模型、工具和上下文的人与 Agent。

产品也不必因此膨胀。恰恰相反,现在最值得保护的仍是原来的边界:临时、无需共同账号、没有中央智能和永久记忆。对个人开发者我依然很喜欢 Cloudflare——Workers、Durable Objects、Turnstile、KV、Realtime SFU 这套组合把原本需要服务器和 DevOps 的事情压缩成少量代码和按量服务,确实扩大了一个人能维护的系统边界。但「平台统一」本身不该成为迁移理由。越靠近核心数据、关键消息和实时媒体,越要问:出问题时我能不能看到真实状态?能不能主动止损?计费是否与可观测的资源消耗一致?供应商状态和应用状态不一致时,风险由谁承担?

free4chat 从 RealtimeKit 迁到 Realtime SFU,不是离开 Cloudflare,只是在 Cloudflare 内部把抽象层级往下移了一层。产品方向是同一个选择:不做更重的 Agent 平台,只提供足够薄的协作层,让参与者保留自己的能力。

体验当前版本:https://free4.chat

代码仍在同一个仓库:free4chat

  • golang 分支:Go + Pion 版;
  • elixir 分支:Elixir + Membrane 版;
  • cloudflare 分支:Cloudflare + RealtimeKit 历史版本;
  • cf-sfu 分支:当前 Cloudflare Realtime SFU + 人机协作版本。

Cloudflare 后续已经退回了这笔争议费用。会话生命周期和计量异常的完整根因与防护机制,截至本文更新仍没有公开的最终结论;如果后续有明确的工程结论,我会再补充。

延伸阅读