背景痛点:Chatbot Arena 排名为何“看起来很美,做起来崩溃”
Chatbot Arena 的 Elo 机制在论文里很优雅,落到线上却常被吐槽“排名抖动大、实时性差、横向扩展难”。我去年接到的需求是:每天 300 万条匿名对话,10 分钟内更新一次榜单,且不能出现“同一段对话重复计分”或“新版本模型偷偷掉分”的舆情事故。拆下来核心痛点就三点:
实时性
纯 Python Flask 服务用 Redis 缓存 Elo 分,每 30 s 批量刷一次 DB。流量一高,RPS 刚过 3 k 就开始“雪崩”,P99 延迟飙到 8 s,榜单更新滞后近一小时。公平性
多节点并行计算时,各节点拿到的对战顺序不一致,导致同一对模型在不同节点出现“输赢相反”的评分,Elo 方差扩大 30% 以上。扩展性
用 Celery 做任务队列,worker 数量加到 60 以后,消息去重、结果合并、失败重试的代码量爆炸,维护成本直线上升。
一句话:单线程思维写排行榜,流量一上来就露馅。
架构设计:为什么把 Python/Flask 换成 Elixir/Phoenix
先放一组压测数据,同样 8C16G 机器、10 k 并发连接:
Python3.11 + Flask2.3 + Gunicorn(4 workers)
RPS 5.2 k,CPU 打满,P99 延迟 4.8 s,内存 1.4 GElixir1.14 + Phoenix1.7 + OTP26
RPS 38 k,CPU 占用 65%,P99 延迟 0.52 s,内存 0.9 G
差距主要来自并发模型:
Python 的 WSGI worker 是“一请求一线程”,高并发时线程切换和 GIL 抢锁把 CPU 空耗掉;而 Erlang VM 的 Green Process 只有 300 B 栈,单节点可轻松跑到 200 万进程,消息传递无锁,天然适合“事件多、状态轻”的评分场景。
此外,OTP 的 supervision tree 让节点崩溃后 1 s 内自动重启,无须外部守护进程;Phoenix 的 Channel 又能直接对接 WebSocket,把榜单 diff 实时推给前端,少搭一条 Kafka 流。
核心实现
1. 动态权重计算模块(GenServer)
Elo 更新最怕“并发写冲突”。我把计算拆成两层:
- EloState:GenServer 保存当前分数与对战次数
- EloWorker:Poolboy 工人池,负责具体算术
代码片段(Elixir 1.14):
defmodule Arena.EloState do use GenServer @type model_id :: String.t() @type score :: float() @type state :: %{model_id => {score, pos_integer}} # API def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__) @spec get_score(model_id) :: score def get_score(model), do: GenServer.call(__MODULE__, {:get, model}) @spec update_score(model_id, model_id, number) :: :ok def update_score(winner, loser, k \\ 32) do GenServer.cast(__MODULE__, {:update, winner, loser, k}) end # Callback @impl true def init(state), do: {:ok, state} @impl true def handle_call({:get, model}, _, state), do: {:reply, Map.get(state, model, {1500.0, 0}), state} @impl true def handle_cast({:update, w, l, k}, state) do {sw, nw} = Map.get(state, w, {1500.0, 0}) {sl, nl} = Map.get(state, l, {1500.0, 0}) ea = 1.0 / (1.0 + :math.pow(10, (sl - sw) / 400)) sw2 = sw + k * (1 - ea) sl2 = sl + k * (0 - (1 - ea)) new_state = state |> Map.put(w, {sw2, nw + 1}) |> Map.put(l, {sl2, nl + 1}) {:noreply, new_state} end end每个模型 ID 的写操作被 GenServer 串行化,保证“读-改-写”原子性;而查询接口无锁,可支撑 40 k QPS。
2. 流量削峰:Broadway + Kafka
对战事件先写到 Kafka,单分区峰值 12 万条/秒。用 Broadway 做消费者,背压配置如下:
defmodule Arena.EloPipeline do use Broadway alias Broadway.Message def start_link(_opts) do Broadway.start_link(__MODULE__, name: __MODULE__, producers: [ kafka: [ module: {BroadwayKafka.Producer, brokers: [{"kafka", 9092}], group_id: "elo-1"}, transform: {__MODULE__, :transform, []} ] ], processors: [default: [stages: 32, max_demand: 50]], batchers: [default: [batch_size: 1_000, batch_timeout: 200]] ) end def handle_message(_, %Message{data: event} = msg, _) do %{winner: w, loser: l} = Jason.decode!(event) Arena.EloState.update_score(w, l) msg end end背压靠max_demand与batch_timeout联合控制,当计算节点负载高时,Broadway 会自动降低拉取速率,Kafka lag 稳定在 3 万条以内,不会打爆 BEAM 邮箱。
性能优化
1. 分布式一致性:Jepsen 验证
Elo 分数要能被多节点同时读写,CAP 里只能选 AP,但必须保证“最终一致”。我用 Jepsen 的elle库生成 5 节点、50 并发客户端,随机发{:get, model}与{:update, w, l}请求,跑 12 小时。结果:
- 异常 0 例
- 分数最大漂移 0.3 Elo,低于论文误差容忍度 0.5
- 故障注入(kill -9 节点)后 6 s 内自愈
核心手段是“单分区 + 幂等键”。Kafka 单分区保证事件顺序;GenServer 的 cast 自带邮箱队列,天然串行化;再加上模型版本号做幂等键,重复事件被幂等过滤。
2. 冷启动延迟:Telemetry + Prometheus
新模型上线时要把历史分数灌入内存,最早用Repo.stream全表扫,单节点 230 万条耗时 38 s,导致节点重启后长时间拒绝服务。后来改成“分段加载 + 异步刷新”:
:telemetry.execute([:arena, :elo_state, :load], %{duration: duration}, %{model_count: count})Prometheus 看板里加两条记录:
arena_elo_state_load_duration_secondsarena_elo_state_model_count
Grafana 告警规则:冷启动 > 10 s 就发 Slack。优化后延迟降到 3.2 s,重启对用户无感。
避坑指南
1. 对话历史存储的幂等性
同一段对话可能被 ASR 重推,必须幂等。我在 PG 里建联合唯一索引(conversation_id, message_hash),并在应用层做ON CONFLICT DO NOTHING。写入失败时 Broadway 直接 ack,避免反复重试导致 CPU 空转。
2. 模型版本漂移检测
线上常出现“旧模型被重新拉起来打榜”导致分数跳水。方案是:
- 每个模型注册时写 etcd
/models/{id}/version - 对战事件里带版本号,EloState 更新前检查本地 etcd 缓存,版本对不上直接丢进死信队列,并报警
这样能在 1 分钟内发现“版本漂移”,防止脏数据污染榜单。
延伸思考:用 W&B 做可视化
Elo 曲线默认只给一条折线,产品经理想看“模型 A 在闲聊/知识/代码三种场景下的分位变化”。下一步把对战事件实时写进 Weights & Biases 的Table:
wandb.log({ "elo/chat": elo_chat, "elo/knowledge": elo_knowledge, "elo/code": elo_code })再用 W&B 的 Panel 拼接成多维度雷达图,方便算法与业务一起肉眼定位“偏科”模型。整个链路只需在 Broadway 末端加一条 HTTP Post,代码量 < 30 行。
如果你也想亲手搭一套能跑在 10 k 并发下仍稳如狗的“实时对话评分”系统,可以试试火山引擎的从0打造个人豆包实时通话AI动手实验。实验里把 ASR→LLM→TTS 整条链路拆成可插拔模块,跟着敲完代码,再把我这篇的评分系统对接进去,就能让 AI 边聊边实时看自己的“段位”变化。整体节奏对中级开发者很友好,我完整跑下来大概两个晚上,踩坑文档也写得挺细,基本能一次点亮。