尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

反AI的15秒实时对决:真人投票排名系统设计与实现

反AI的15秒实时对决:真人投票排名系统设计与实现 当短视频平台的信息流已经被 AI 推荐和 AI 评分完全接管时一个刚出现在 Show HN 上的项目偏偏打出了not AI的旗号Appealr15 秒实时对决排名完全由真人投票决定。它没有用大模型去判断“谁更好”没有冷冰冰的算法给内容打分而是把判断权还给了真实的人。这个反直觉的选择在 AI 应用开发几乎已经默认“先上模型再说”的背景下反而成了一个值得拆解的样本。我的判断很明确Appealr 的本质不是一个小游戏而是对“AI 是否应该成为所有内容排序的唯一裁判”的一次产品化反驳。当 AI 生成内容越来越廉价、AI 评分越来越趋同的时候人类投票产生的不只是结果还有一种 AI 无法替代的社区感。这篇文章会从产品逻辑讲到架构方案从“为什么不用 AI”讲到“如果真的要用工程手段实现 15 秒实时对决该怎么做”适合正在做 AI 工具、社区产品、实时互动应用或者单纯想在 AI 大模型时代找产品差异点的开发者。你读完这篇文章能带走三样东西第一理解 Appealr 这类“人类投票排序”产品的核心判断第二掌握一个实时投票对决系统的完整实现思路包括 WebSocket、Redis 和防重复投票第三一份关于“什么场景该用 AI 排序、什么场景该保留真人判断”的工程决策清单。1. 当所有产品都在用 AI 打分Appealr 为什么反着来过去几年内容平台的排序几乎被 AI 完全接管。新内容上线先过一遍模型打分进入流量池之后再根据点击率、完播率、互动率实时调整权重。这套机制的好处非常明显可批量处理、可实时反馈、可精细化运营。对于一个每天产生百万条内容的平台人工审核和人工排序在成本和效率上都不现实AI 打分成为默认选项几乎是必然的。但 AI 打分也有代价。算法优化的目标通常是留存、时长、转化率这些可量化指标这导致被推到用户面前的内容越来越“符合预期”却越来越缺少惊喜。用户看到的不是“我认为好的”而是“模型预测我会喜欢的”。审美在一个平台上被反复强化最后所有人的口味都趋同。这种趋势在 AI 生成内容爆发之后变得更严重当模型可以批量生产图片、视频和文案时评分模型又对它们打出一致的分数平台里就充满了“标准化的好看、标准化的有趣”。Appealr 的反向选择恰恰切中了这个矛盾。它表明上看是放弃了 AI 评分其实是重新定义了“排序”在这个场景中的意义AI 排序追求的是效率最大化人类投票追求的是共识可见性。在一个 15 秒的对决里用户投出的一票不仅是在“选 A 或选 B”更是在参与一个公共判断过程。产品越强调人类投票越容易形成社区认同感因为用户会觉得自己影响了结果而 AI 分数永远给不了这种参与感。2. 15 秒对决Appealr 的产品机制与底层逻辑Appealr 的核心机制是 15 秒 live face-offs。从产品形态上看它把两个候选内容放到同一个“擂台”上让观众在规定时间内投票最终根据真人票数排出名次。这种二元对决的模式并不新鲜但把“15 秒”和“实时”放在一起产品体验就完全不一样了。15 秒是一个很有讲究的设计。它足够短用户不需要花长时间思考“A 和 B 到底谁更好”下意识判断在几秒内就能完成这大大降低了投票的心理成本它又足够长能容纳一种紧张的节奏感——倒计时在跳票数在涨胜负随时可能反转。这种短时间内的高密度互动恰恰是 AI 排序无法提供的体验。AI 给出的是一个静态的分数而 15 秒对决更像一场现场比赛结果由正在观看的每一个人共同塑造。face-off 的二元对比也非常聪明。如果让用户给内容打 1 到 5 分用户的打分标准会漂移甲觉得 4 分才算好乙觉得 3 分已经很好最终数据需要大量归一化处理。而“二选一”把问题简化到了最低限度用户几乎不需要思考评判标准只需要表达直觉偏好。这种机制下产生的结果天然是“相对排名”而不是“绝对分数”更贴近人类社交场景中的真实选择逻辑。实时性是这个产品最核心的技术约束。用户投完票之后如果结果要等 5 秒甚至更久才更新紧张感就消失了。这意味着服务端必须把“投票事件”近乎立即推送给所有在线的观众同时保证票数计算的一致性。这种实时交互需求在技术复杂度上远高于传统的内容评分系统也是本文将实现部分重点放在 WebSocket 和 Redis 上的原因。3. 实时投票比 AI 评分更难架构挑战拆解很多人会想当然地认为AI 评分看起来比人类投票“高级”技术难度一定更大。但从工程角度看实时人类投票系统的难点更集中、更尖锐。AI 内容评分通常可以做成异步批处理。新内容进入系统后模型打分结果写入数据库用户查询时直接读取不需要低延迟的写入链路。即便模型推理速度是几百毫秒甚至几秒也不影响用户体感。实时投票则完全相反用户在前端点下按钮的瞬间票数变化就要出现在所有人的屏幕上这是从“离线计算”到“在线状态同步”的转变。第一道坎是实时匹配。两个候选内容需要在极短的时间内被撮合到一起生成一场对决并把对决信息推送给所有正在等待的观众。这个功能如果用数据库轮询实现不仅效率低还会因为网络延迟和数据库压力导致匹配失败。更合理的方案是使用内存队列或 Redis 的 List 结构来维护“待出场内容池”匹配服务从池子两端取出内容发起对决。第二道坎是防重复投票。一场 15 秒的对决里同一个用户理论上只能投一次票。如果客户端按钮可以无限点击票数会被刷爆。常规做法是引入幂等机制服务端按“用户 对决 ID”生成唯一标记第一次投票时写入重复请求直接忽略。这个标记还要和对决的 15 秒生命周期保持一致不能对已过期的对决继续计票。第三道坎是结果广播。票数变化只写入数据库是不够的服务端必须把最新的票数或排名状态实时推送到所有在线客户端。常见的实现方式有 WebSocket、SSE 和长轮询。对于这种高频、双向的互动场景WebSocket 是最自然的选择配合 STOMP 协议还能做到按主题广播让服务端的代码结构更干净。最后一道坎是榜单计算。如果每投一票都实时去 MySQL 里做一次排序数据库会承受很大的压力。更稳的做法是用 Redis 的有序集合 ZSet以内容 ID 为成员、票数为分数每投一票就ZINCRBY一次查询 Top N 时直接ZREVRANGE。整个过程都在内存中完成既快又能保证一致性。4. 技术选型WebSocket、Redis 与事件驱动基于上面的挑战一个最小可用的实时对决系统可以这样选型前端通过 SockJS 建立 WebSocket 连接使用 STOMP 协议收发消息后端用 Spring Boot 提供 WebSocket 端点和业务接口Redis 承担临时状态存储、幂等标记和实时榜单三份工作MySQL 或 PostgreSQL 负责最终内容数据和票数的持久化。这个选型里最有意思的点是 Redis 的角色。Redis 不只是缓存它在实时系统中同时承担了队列、状态存储和排行榜计算三种职责。用 List 维护候选内容池用 String 保存每场对决的双方信息和过期时间用 SetIfAbsent 实现投票幂等用 ZSet 计算实时排名。一个组件解决多个问题这在实时应用架构里是非常典型的做法。从成本角度看Appealr 这个形态也比 AI 排序方案便宜得多。一个基于大模型的评分系统需要模型部署、GPU 推理、监控和调优而人类投票系统只需要处理真实用户的点击事件计算的规模和成本反而更可控。这也解释了为什么很多社区类产品在早期阶段都愿意用“人工运营 简单计数”启动而不是一上来就堆 AI 模型——成本结构更健康产品验证也更快。5. 完整示例实现一个最小 15 秒对决投票系统为了把上面的思路落到可以运行的代码我基于 Spring Boot 3.x、JDK 17 和 Redis 搭建了一个最小 Demo。它可以实现这样的流程系统从候选内容池中随机取出两个内容生成一场 15 秒对决用户通过 WebSocket 收到对决信息并投票票数写入 Redis 排行榜后端可以通过接口查询当前 Top N。5.1 项目结构与依赖先创建 Spring Boot 项目依赖只需要三个核心组件Web、WebSocket 和 Redis。?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.4/version relativePath/ /parent groupIdcom.example/groupId artifactIdappealr-demo/artifactId version1.0.0/version nameappealr-demo/name description15s realtime face-off voting demo/description properties java.version17/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency /dependencies build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId /plugin /plugins /build /project对应的配置文件也很简单只需要连接本机 Redis 并开启 WebSocket 支持。版本可以按实际环境调整本文重点演示的是工程思路。# 文件路径src/main/resources/application.yml spring: application: name: appealr-demo data: redis: host: localhost port: 6379 server: port: 80805.2 WebSocket 配置Spring 的 WebSocket 消息能力依赖EnableWebSocketMessageBroker。这个配置类做了两件事注册浏览器连接入口/ws以及设置消息代理规则。客户端通过 SockJS 连接这个端点发送到/app/前缀的消息会进入业务方法服务端主动推送的消息则发到/topic/主题。// 文件路径src/main/java/com/example/appealr/config/WebSocketConfig.java package com.example.appealr.config; import org.springframework.context.annotation.Configuration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; Configuration EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { Override public void configureMessageBroker(MessageBrokerRegistry config) { config.enableSimpleBroker(/topic); config.setApplicationDestinationPrefixes(/app); } Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint(/ws).withSockJS(); } }这里真正容易踩坑的地方是端点路径必须前后端一致。前端连接的是/ws后端注册的也是/ws如果任何一端写错SockJS 协商阶段就会失败浏览器控制台里会看到 404 或加载失败报错。另外/topic和/app这两个前缀不要搞反/topic是供客户端订阅的广播频道/app是客户端发给服务端业务方法的路由前缀。5.3 对决匹配服务匹配服务负责从候选池中随机取出两个内容生成一场对决。这里使用 Redis 的 List 作为候选内容队列leftPop每次取出一条内容取满两条就创建一场对决。匹配信息写入 Redis 的 String 键并设置 15 秒过期时间这样超过 15 秒的对决就会自动失效保证投票窗口的有效性。// 文件路径src/main/java/com/example/appealr/service/MatchService.java package com.example.appealr.service; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; import java.time.Duration; import java.util.UUID; Service public class MatchService { private final StringRedisTemplate redis; private final SimpMessagingTemplate messaging; public MatchService(StringRedisTemplate redis, SimpMessagingTemplate messaging) { this.redis redis; this.messaging messaging; } public void createMatch() { String contentA redis.opsForList().leftPop(queue:candidates); String contentB redis.opsForList().leftPop(queue:candidates); if (contentA null || contentB null || contentA.equals(contentB)) { return; } String matchId UUID.randomUUID().toString().substring(0, 8); redis.opsForValue().set( match: matchId :a, contentA, Duration.ofSeconds(15) ); redis.opsForValue().set( match: matchId :b, contentB, Duration.ofSeconds(15) ); MatchMessage message new MatchMessage(matchId, contentA, contentB); messaging.convertAndSend(/topic/match, message); } public record MatchMessage(String matchId, String contentA, String contentB) {} }中间的状态信息是“对决产生后要立刻推送给观众”所以这里没有返回值而是直接通过SimpMessagingTemplate把消息广播到/topic/match。所有订阅了这个主题的客户端都会收到新对决的消息。注意这里只是演示匹配逻辑真实项目里还要考虑候选内容池如何填充、匹配策略是随机的还是按照相似度匹配、并发匹配时如何防重等等。5.4 投票控制器投票是整个系统的核心入口。控制器接收 STOMP 消息做三件事幂等校验、更新 Redis 键、更新排行榜。幂等校验用的是setIfAbsent同一个用户对同一场对决的投票标记只能写入一次。这里的关键点是标记的过期时间也应该设置为 15 秒左右因为对决生命周期结束后这个标记就没有意义了留太久反而会占用 Redis 内存。// 文件路径src/main/java/com/example/appealr/controller/VoteController.java package com.example.appealr.controller; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.stereotype.Controller; import java.time.Duration; Controller public class VoteController { private final StringRedisTemplate redis; public VoteController(StringRedisTemplate redis) { this.redis redis; } MessageMapping(/vote) public void vote(VoteMessage message) { String matchId message.matchId(); String voteFor message.voteFor(); String userId message.userId(); Boolean firstVote redis.opsForValue().setIfAbsent( vote: matchId : userId, 1, Duration.ofSeconds(15) ); if (Boolean.FALSE.equals(firstVote)) { return; } String target A.equals(voteFor) ? redis.opsForValue().get(match: matchId :a) : redis.opsForValue().get(match: matchId :b); if (target null) { return; } redis.opsForZSet().incrementScore(rank:content, target, 1); redis.opsForValue().increment(match: matchId :votes); } public record VoteMessage(String matchId, String voteFor, String userId) {} }投票成功之后票数累加到rank:content这个 ZSet 上以内容 ID 为成员以票数为分数。这里没有写数据库是因为实时榜的设计目标就是内存优先最终票数可以由后台任务异步落库。投票接口必须保持轻量任何一次额外的数据库写入都可能成为性能瓶颈。5.5 排行榜查询排行榜服务的代码非常短核心就是reverseRangeWithScores从 ZSet 中取出分数最高的前 N 个成员。在 Redis 中有序集合天然支持按分数排序所以不需要在业务代码里做任何排序操作。// 文件路径src/main/java/com/example/appealr/service/RankingService.java package com.example.appealr.service; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import java.util.List; Service public class RankingService { private final StringRedisTemplate redis; public RankingService(StringRedisTemplate redis) { this.redis redis; } public ListRankEntry top(int n) { return redis.opsForZSet() .reverseRangeWithScores(rank:content, 0, n - 1) .stream() .map(tuple - new RankEntry(tuple.getValue(), tuple.getScore().longValue())) .toList(); } public record RankEntry(String contentId, long votes) {} }在实际项目中这个接口一般会配合定时任务使用每 5 秒或 10 秒推送一次最新榜单到/topic/ranking让页面上的排行榜保持同步。推送频率不是越高越好太高了会浪费带宽和客户端渲染性能通常 3 到 10 秒一次就足够了。5.6 前端页面浏览器端使用 SockJS 建立连接通过 STOMP 客户端订阅/topic/match主题。当服务端推送新的对决消息时页面上会展示两个候选项用户点击按钮后把投票消息发到/app/vote。!DOCTYPE html html langzh head meta charsetUTF-8 title15s Face-off Demo/title script srchttps://cdn.jsdelivr.net/npm/sockjs-client1/dist/sockjs.min.js/script script srchttps://cdn.jsdelivr.net/npm/stomp/stompjs/bundles/stomp.umd.min.js/script /head body h315 秒实时对决/h3 div idpanel正在等待对决.../div button idvoteA onclickvote(A)选 A/button button idvoteB onclickvote(B)选 B/button script const socket new SockJS(/ws); const stomp Stomp.over(socket); const userId user- Math.random().toString(36).substring(2, 10); let currentMatchId null; stomp.connect({}, function () { stomp.subscribe(/topic/match, function (msg) { const data JSON.parse(msg.body); currentMatchId data.matchId; document.getElementById(panel).innerHTML 对决 data.matchId data.contentA vs data.contentB 15 秒内投出你的一票; }); }); function vote(choice) { if (!currentMatchId) return; stomp.publish({ destination: /app/vote, body: JSON.stringify({ matchId: currentMatchId, voteFor: choice, userId: userId }) }); } /script /body /html前端代码里值得注意的点是 STOMP 客户端版本差异。不同版本的stomp/stompjs在 API 上有细微区别比如Stomp.over(socket)这种写法在 v5 和 v7 中都能工作但接线方式略有不同。如果控制台报错优先检查库是否加载成功以及页面使用的连接地址是否和后端端点一致。6. 运行与验证假设你已经启动了本机 Redis并且已经把上面的代码放到 Spring Boot 项目里启动命令非常简单mvn spring-boot:run启动成功后访问http://localhost:8080打开前端页面在浏览器控制台可以看到 SockJS 连接建立成功的日志。如果连接失败可以先执行下面这个命令验证 SockJS 信息端点是否可用curl http://localhost:8080/ws/info正常情况下会返回类似{enterprise:false,websocket:true,origins:[*:*],cookie_needed:false,heartbeat:{...}}的 JSON。这个结果说明 WebSocket 端点已经就绪。接着用 Redis 客户端往候选队列里塞几条测试内容redis-cli rpush queue:candidates content-001 content-002 content-003 content-004然后重新加载前端页面系统会从队列里弹出两个内容生成一场对决页面上应该显示出 “对决 xxxxxxxxcontent-001 vs content-002” 这样的信息。点击按钮投票后执行下面命令查看排行榜票数会实时变化redis-cli zrevrange rank:content 0 -1 WITHSCORES如果排行榜没有变化第一步应该检查 Redis 中的投票幂等键是否已经存在。删除对应的vote:*键重试一次同时检查投票消息是否真的到达了后端——最简单的办法是在VoteController.vote()方法第一行加一行日志输出如果日志没打印说明消息路由配置有问题问题出在/app/vote前缀或方法参数反序列化上。7. 人类投票与 AI 排序的工程对比什么时候该用谁看到这里你可能已经意识到人类投票和 AI 排序并不是简单的替代关系而是一组不同适用条件下的工程选择。下面这个表格可以帮助你快速判断。对比维度人类实时投票AI 自动排序结果质量判据主观共识接近审美直觉模型打分贴近业务指标实时延迟毫秒级反馈结果即时可见通常需要推理和异步计算扩展成本成本在线性增长依赖真实用户活跃度成本集中在模型部署和推理规模越大越省一致性结果分散可能反直觉结果稳定行为可预期冷启动难度需要社区氛围和用户参与模型训练需要大量标注数据社区归属感强用户在影响结果弱用户只是被推荐对象反作弊压力高需要幂等和风控需要对抗模型攻击和数据污染从这张表可以得出一个基本判断如果你的产品本身就是“社区共识”的一部分比如创意比赛、设计评选、用户生成内容的竞技场人类投票会让人更有参与感结果也更有说服力。如果产品目标是在海量内容中快速给出一个“基本靠谱”的排序AI 仍然是性价比更高的方案。但在实际操作里这两个方案不是非此即彼的。更成熟的设计是混合模式AI 负责前期筛选和路由把那些明显低质量或重复的内容先过滤掉再由人类投票决定真正有价值的头部内容。这样既利用了 AI 的规模化处理能力又保住了人类判断在关键节点的信任感。Appealr 的启示不在于“打死不用 AI”而在于把人类判断放在了产品决策的主路径上让它成为体验的一部分而不是后台的辅助指标。8. 实战中容易踩的坑实时投票系统看起来代码量不大但真正的挑战都在边界情况里。下面这些是我认为最值得注意的问题建议在开发前先想清楚。问题现象可能原因排查方式解决方案WebSocket 连接失败前后端端点路径不一致或端口被占用访问/ws/info看返回查看浏览器 Network 面板统一端点为/ws确认服务端端口票数没有变化Redis key 名不一致或幂等键残留用redis-cli keys vote:*检查统一 key 前缀给幂等键设置过期时间对决过期后仍能投票服务端只靠前端倒计时限制后端未校验检查match:*键是否存在投票时从 Redis 获取双方内容取不到就拒绝排行榜迟迟不出客户端订阅主题错误检查 STOMP 订阅地址开头是否为/topic/对齐主题名统一常量高并发下单挑 Redis 连接数被打满每个投票都是独立请求连接池配置过小查看 Redis 连接数和慢日志使用 Pipeline 合并操作调大连接池这里真正需要认真处理的是幂等和过期时间。对于实战场次投票请求可能因为网络重试到达两次如果幂等键的过期时间小于 15 秒用户可能在最后几秒又投了一票。更稳妥的做法是把幂等键过期时间设置成比赛窗口的两倍左右比如 30 秒这样既能防止短时间重复请求又不会积压无用的数据。同时不要忘了把 Redis 里的最终票数定期同步到数据库。Redis 是内存存储一旦进程重启或内存淘汰策略触发票数历史可能会丢失。生产环境至少要有一个定时任务把 ZSet 的分数批量写入 MySQL并做好 Redis 和数据库的对账机制。9. 总结AI 越强真人判断越值钱Appealr 的 15 秒对决模式本身不算颠覆性创新但它的产品立意是清晰的在 AI 生成内容最密集的时期把“真人投票”这个标题前置本身就是一种态度表达。它告诉我们不是所有产品都该用 AI 来做最终裁决用户愿意亲自参与表达喜好的意愿仍然是很多社区产品的核心驱动力。从开发者的角度看这个项目最大的参考价值在于做 AI 应用开发不等于一切都交给大模型。实时互动的产品形态配合 WebSocket 和 Redis 这类基础设施完全可以实现不依赖 AI 的高质量用户体验。AI 可以帮忙做匹配、做分发、做初筛但在“谁是更好的那个内容”这种主观判断上把权力交给真实用户反而可能成就产品的差异化。如果你也想做一个类似的对决或投票产品建议从这个小 Demo 跑起来开始先验证 15 秒对决的核心流程是否顺畅再考虑增加用户账号体系、内容审核、反作弊和持久化存储。下一步可以深入研究的方向包括基于 Elo 积分的匹配算法、匿名投票的防作弊设计、以及如何用 AI 辅助生成候选内容但依然保留人类投票的最终裁决权。记住一句话AI 越强真人判断的空间就越值钱关键是你有没有在体验里给用户留下那个“出手”的机会。
返回列表