ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

AI付费发言:用Node.js和WebSocket构建积分制聊天室

2026/9/1 5:13:02 拓冰建站 浏览量
AI付费发言:用Node.js和WebSocket构建积分制聊天室 最近关注到 Hacker News Show HN 版块的一个新项目 OnlyBots.chat一句话介绍是 “a chatroom where AI pays to post for humans”。说实话第一次看到这个标题我会愣一下聊天室通常是人类聊天的地方AI 为什么要付费付费给谁为什么 AI 反而成了“发帖需要花钱”的一方这个项目最吸引我的点不是它用了多复杂的技术而是它把 AI 时代的几个核心矛盾放到了一个小产品里AI 生成内容的成本几乎为零垃圾信息可以无限刷人类注意力越来越稀缺验证“对方是不是真人”正变得困难。OnlyBots.chat 用一种接近实验的方式把“发言成本和身份真实性”重新拉回聊天室。本文会从产品机制出发分析 AI 付费发帖的完整链路然后手写一个简化版 Node.js WebSocket 聊天室原型包含积分系统、机器人身份、自动扣费和前端页面尽量让读者看完就能跑起来体会这种“机器人也要讲成本”的设计。1. 从 OnlyBots.chat 说起为什么 AI 发帖要付费1.1 项目到底在做什么从项目标题可以推断OnlyBots.chat 是一个聊天室产品核心设定是“机器人必须消耗积分才能在聊天室里发帖”。这里的“OnlyBots”可能有两层含义一是聊天室里主要活跃着大量 AI 机器人二是这个产品刻意强调“只有机器人需要遵守付费规则”。和传统聊天室最大的区别在于角色定位普通聊天室人类免费发言机器人要么被禁言要么通过技术手段模拟人类发言。OnlyBots.chat机器人拥有身份和发言权但每一次发言都要消耗积分。人类反而可能是“被 AI 关注”的对象。这个逻辑初看反直觉细想却很巧妙。AI 生成一句话的成本在模型侧已经很低但放到一个公共聊天室里如果机器人可以无限发言整个聊天室很快就会变成垃圾信息池。OnlyBots.chat 通过“让 AI 付费说话”的方式给 AI 发言增加了一个经济约束。1.2 背后解决的真实问题AI 内容泛滥已经是聊天产品面临的现实问题。很多社区的评论区已经分不清哪些回复来自真人、哪些来自大模型批量生成。传统的反垃圾手段例如验证码、频率限制对 AI 机器人并不总是有效因为机器人可以模拟人类行为。OnlyBots.chat 的思路是换一个角度不追求阻止 AI 发言而是给 AI 发言定价。机器人所有者如果想要在聊天室里表达观点、获取人类注意力就必须消耗真金白银兑换的积分。这样带来的直接好处有几点抑制无意义刷屏机器人会珍惜每次发言机会。为聊天室提供一种可持续的商业模式积分收入可以覆盖服务器和模型调用成本。让“人类身份”重新变得有价值因为人类免费发言却能得到 AI 的关注和互动。1.3 三类人需要关注这个设计第一种是聊天室/社区产品开发者。如果你正在头疼机器人刷屏、内容质量下降、如何做 AI 治理OnlyBots.chat 的积分机制可以给你提供一种可落地的产品思路。第二种是 AI Agent 开发者。无论是写自动发帖机器人、社群运营助手还是做多智能体聊天实验都需要考虑“发言成本”和“质量约束”这个项目是一个很好的参考案例。第三种是对 WebSocket 实时通信、Node.js 服务端编程感兴趣的开发者。即使不关心 AI 产品设计本文后半部分的手写聊天室也是一份完整的 Node.js 实战示例。2. 核心机制拆解AI 付费发帖的完整链路2.1 玩家与身份在 OnlyBots.chat 的设定里主要参与者可以分为两类人类Human加入聊天室后免费发言可能是被 AI 观察和学习的对象。机器人Bot本质是接入大模型 API 的自动程序每次发言需要消耗积分。这里有一个容易被忽略的点身份验证并不简单。如果前端只是让用户自己选择“我是人类”或“我是机器人”那么任何人类用户都可以伪装成机器人任何机器人都可以伪装成人类。所以在生产环境中人类身份通常通过邮箱验证、手机验证码、验证码挑战等方式确认机器人身份则通过发放 API Key、签名请求等方式确认。我们后面的 demo 为了简单会先让用户手动选择身份但会明确说明这种方式不能用于生产。2.2 积分与扣费模型积分模型是核心。一个典型的流程是机器人所有者注册账号通过充值获得积分。机器人连接聊天室并完成身份认证。机器人每次发送消息时服务端先检查余额是否足够。余额充足则扣费并广播消息余额不足则拒绝发言。每发一条消息扣多少积分可以由产品根据成本制定。如果机器人接入的是收费大模型 API那么积分价格至少要覆盖模型调用成本。如果机器人使用的是本地小模型成本可以很低但为了防止刷屏仍然需要设置一个最低扣费。人类用户的积分逻辑可以有两种设计免费发言不涉及积分。每次发言获得少量积分用于激励优质真人内容积分可以用来兑换特权。OnlyBots.chat 标题中的 “pays to post for humans” 更倾向理解成AI 为了获许在人类面前发帖而付费或者说机器人所有者替机器人付费。具体如何结算需要看项目后续公开细节但从产品设计角度这两种方案都成立。2.3 反滥用设计任何涉及积分和余额的系统都会面临滥用问题。结合 OnlyBots.chat 的场景常见风险有机器人刷积分注册大量小号领取赠送积分。频繁发言耗尽服务器资源即使机器人付费也需要限流。伪造余额客户端篡改余额数据服务端没有做好校验。冒充人类身份机器人绕过身份认证免费发言。这些问题的解决思路是统一的所有状态以服务端为准客户端只发送“意图”不发送“结果”。也就是说客户端请求发消息时不需要告诉服务端“我是付费用户请扣除我的积分”只需要携带身份凭证由服务端判断身份并执行扣费。3. 技术架构设想一个 OnlyBots 原型需要哪些模块3.1 实时通信聊天室必须有实时能力。最常用的方案是 WebSocket浏览器与服务端建立长连接消息可以双向实时推送。如果是多人聊天室还需要考虑消息广播当一个人发言时把消息推送给所有在线客户端。Node.js 生态中ws 库是轻量选择Socket.IO 则提供了房间、自动重连等更丰富的功能。本文为了展示核心逻辑使用 ws 库。如果聊天室需要水平扩展单机 WebSocket 不够用一般会引入 Redis Pub/Sub 或消息队列把消息从一台服务器转发到其他服务器再由其他服务器推送给各自连接的客户端。3.2 AI 内容生产机器人发言内容的来源可以分成两种规则回复根据关键词或模板生成固定回复适合演示和最小原型。大模型调用接入 OpenAI 兼容接口或本地模型根据聊天室上下文生成自然语言回复。这里要说明一点不要以为 AI 聊天机器人天然拥有“发言克制”能力。如果不对每次发言做约束模型可能生成超长内容。所以在调用大模型时要在 System Prompt 里明确指定风格和字数限制。3.3 计费与状态存储积分余额是一种强一致状态不能直接在客户端保存。最简单的实现是服务端内存 Map但这只适合演示。生产环境一般需要Redis保存余额、做扣费原子操作、限流计数。MySQL/PostgreSQL保存流水记录例如谁在什么时间扣了多少积分、发了什么内容。消息队列异步处理扣费流水和审计日志避免阻塞主链路。扣费操作要特别注意并发问题。如果机器人连续发送多条消息多个请求同时扣费可能会导致余额变成负数。解决方案有几种使用 Redis 的 DECR 命令进行原子扣减或者在数据库中使用UPDATE ... SET balance balance - 1 WHERE balance 1这类带条件更新语句再或者引入分布式锁。4. 完整实战用 Node.js 实现简化版聊天室4.1 项目结构与依赖我们先搭建一个最小可运行的项目。目录结构如下onlybots-chat-demo/ ├── package.json ├── server.js ├── src/ │ └── billing.js ├── agents/ │ └── bot.js └── public/ └── index.html进入项目目录并初始化mkdir onlybots-chat-demo cd onlybots-chat-demo npm init -y npm install wspackage.json 中只需要 ws 一个运行时依赖。本文写作时使用 npm 安装最新版本即可不锁定具体版本号。4.2 服务端连接与消息分发服务端负责三件事提供静态页面、管理 WebSocket 连接、处理聊天和扣费消息。// server.js const path require(path); const http require(http); const fs require(fs); const { WebSocketServer } require(ws); const { BillingService } require(./src/billing); const { AgentBot } require(./agents/bot); const PORT process.env.PORT || 3000; const server http.createServer((req, res) { if (req.url / || req.url /index.html) { res.writeHead(200, { Content-Type: text/html; charsetutf-8 }); fs.createReadStream(path.join(__dirname, public, index.html)).pipe(res); return; } res.writeHead(404); res.end(Not Found); }); const wss new WebSocketServer({ server }); const billing new BillingService(); const bot new AgentBot(billing); function sendTo(ws, data) { ws.send(JSON.stringify(data)); } function broadcast(data) { const payload JSON.stringify(data); for (const client of wss.clients) { if (client.readyState 1) { client.send(payload); } } } // 当人类发言后随机挑选一个在线机器人自动回复 function triggerBotReply(content) { const botClients [...wss.clients].filter( (client) client.role bot client.readyState 1 ); if (botClients.length 0) return; const target botClients[Math.floor(Math.random() * botClients.length)]; if (!billing.deduct(target.nickname, 1)) return; const reply bot.generateReply(content, target.nickname); setTimeout(() { broadcast({ type: chat, nickname: target.nickname, role: bot, content: reply, cost: 1 }); sendTo(target, { type: balance, balance: billing.getBalance(target.nickname) }); }, 800); } wss.on(connection, (ws) { ws.role human; ws.nickname 游客- Math.floor(Math.random() * 1000); ws.on(message, (raw) { let msg; try { msg JSON.parse(raw.toString()); } catch { return; } switch (msg.type) { case join: { ws.role msg.role bot ? bot : human; ws.nickname String(msg.nickname || ws.nickname).slice(0, 20); billing.grantInitialQuota(ws.nickname); sendTo(ws, { type: balance, balance: billing.getBalance(ws.nickname) }); broadcast({ type: system, content: ${ws.nickname}${ws.role bot ? 机器人 : 人类}加入了聊天室 }); broadcast({ type: online, count: wss.clients.size }); break; } case topup: { const amount Math.min(Math.max(Number(msg.amount) || 10, 1), 100); const balance billing.topUp(ws.nickname, amount); sendTo(ws, { type: balance, balance }); broadcast({ type: system, content: ${ws.nickname} 充值了 ${amount} 积分 }); break; } case chat: { const content String(msg.content || ).trim().slice(0, 200); if (!content) return; if (ws.role bot) { // 机器人发言需要扣费 const ok billing.deduct(ws.nickname, 1); if (!ok) { sendTo(ws, { type: error, message: 机器人积分不足请先充值。 }); return; } broadcast({ type: chat, nickname: ws.nickname, role: bot, content, cost: 1 }); sendTo(ws, { type: balance, balance: billing.getBalance(ws.nickname) }); } else { // 人类发言免费并奖励 1 积分 const reward billing.rewardHuman(ws.nickname, 1); broadcast({ type: chat, nickname: ws.nickname, role: human, content, reward }); sendTo(ws, { type: balance, balance: billing.getBalance(ws.nickname) }); triggerBotReply(content); } break; } case balance: { sendTo(ws, { type: balance, balance: billing.getBalance(ws.nickname) }); break; } default: break; } }); ws.on(close, () { broadcast({ type: online, count: wss.clients.size }); }); }); server.listen(PORT, () { console.log(OnlyBots demo server running at http://localhost:${PORT}); });这里需要理解的几个关键点每个 WebSocket 连接默认按人类身份处理防止客户端未 join 前发送消息导致逻辑错误。机器人发言和人类发言走完全不同的分支机器人必须先扣费成功才能广播。人类发言后会触发 triggerBotReply让在线机器人自动回复这样演示时只需要一个人类窗口就能看到完整效果。4.3 计费模块余额、充值与扣费计费模块是核心中的核心。为了让代码清晰我们单独封装一个 BillingService。// src/billing.js class BillingService { constructor() { // 真实环境请替换为 Redis 数据库这里是演示用的内存 Map this.balances new Map(); } getBalance(nickname) { return this.balances.get(nickname) || 0; } // 初始赠送额度方便演示 grantInitialQuota(nickname) { if (!this.balances.has(nickname)) { this.balances.set(nickname, 10); } } // 充值返回充值后余额 topUp(nickname, credits) { const next (this.balances.get(nickname) || 0) credits; this.balances.set(nickname, next); return next; } // 扣费余额不足返回 false扣费成功返回 true deduct(nickname, amount) { const current this.balances.get(nickname) || 0; if (current amount) { return false; } this.balances.set(nickname, current - amount); return true; } // 人类发消息获得积分返回最新余额 rewardHuman(nickname, amount) { const current this.balances.get(nickname) || 0; const next current amount; this.balances.set(nickname, next); return next; } } module.exports { BillingService };在演示项目中每次扣费都是同步操作Node.js 单线程事件循环会保证同一时刻只有一个消息在被处理因此内存 Map 不会出现并发扣减问题。但真实项目中如果部署多个服务实例就必须使用 Redis 原子操作或数据库事务。4.4 机器人模块模板回复与大模型接入机器人模块先实现模板回复便于演示不依赖外部 API。接下来我会给出一段大模型接入的参考片段。// agents/bot.js class AgentBot { constructor(billing) { this.billing billing; this.replyCount 0; } // 根据用户输入生成回复 generateReply(text, nickname) { const balance this.billing.getBalance(nickname); const replies [ 收到「${text}」我是 ${nickname}这条消息花了 1 积分当前余额 ${balance}。, 关于「${text}」我作为 AI 的观点是真实人类的注意力越来越稀缺所以付费发言是合理的。, 「${text}」这个问题很有意思。可惜发言成本太贵下次再聊。 ]; return replies[this.replyCount % replies.length]; } } module.exports { AgentBot };如果要在实际项目中接入大模型可以把 generateReply 改成异步请求// agents/bot.js 中的大模型调用扩展思路 async function generateLLMReply(apiKey, baseURL, model, prompt) { const response await fetch(${baseURL}/v1/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${apiKey} }, body: JSON.stringify({ model, messages: [ { role: system, content: 你是 OnlyBots 聊天室成员发言简洁、克制每次发言都很珍贵。 }, { role: user, content: prompt } ] }) }); const data await response.json(); return data.choices?.[0]?.message?.content || ; }注意调用大模型也意味着每次机器人发言成本不仅包含积分还包含模型 API 的调用费用。生产环境中机器人发言的扣费额度应该能够覆盖这部分成本。4.5 前端页面加入、发言与余额展示前端页面保持轻量不需要框架原生 HTML JavaScript 即可。核心功能包括选择身份、加入聊天室、显示余额、发送消息。!-- public/index.html -- !DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleOnlyBots Chat Demo/title style body { font-family: system-ui, Microsoft YaHei, sans-serif; max-width: 720px; margin: 40px auto; padding: 0 16px; color: #222; } header { display: flex; justify-content: space-between; align-items: center; } .panel {