“千人联机世界模型 RhOS-World: Khora 正式发布”这个标题,我第一眼看到时,注意到的不是“发布”两个字,而是“千人联机”这四个字。过去讨论世界模型,多数技术文章还停留在单智能体在仿真环境里做状态预测和规划,比如用一段视频预测下一帧,或者在网格环境里让 agent 预演几步再行动。RhOS-World: Khora 这个名称传递的信号是:世界模型正在从“单机推理组件”走向“多人共享世界状态”的平台化形态。
这里不尝试复述官方公告,也不替任何项目背书,而是从工程实践视角拆解这类系统最核心的技术难题:世界状态如何表达、预测模型如何切入实时循环、多用户并发如何保持一致、千人规模下如何压测和排错。适合对世界模型概念有初步了解、想从事 AI 实时平台或仿真系统开发的人阅读。文末给出的最小原型和排查清单可以直接作为起步骨架。
1. 世界模型不是“会说话的大模型”,先把它放在仿真和规划的坐标系里
1.1 世界模型在解决什么问题
通俗理解:大模型处理的是“文本世界”,你给它一句话,它预测下一个词;世界模型处理的是“环境世界”,你给它一个当前状态和一个动作,它预测环境下一时刻会变成什么样。两者都叫模型,但解决的问题完全不同。
技术定义:世界模型是学习环境转移函数的模型,即给定当前状态 $s_t$ 和动作 $a_t$,建模 $P(s_{t+1} \mid s_t, a_t)$。更完整的系统还会学习观测编码器、奖励模型和策略解码器。早期 World Models 论文把环境压缩成低维隐变量,再在隐空间里做规划;后来 IRIS、DreamerV3 等把这类思想扩展到视觉输入和长时程决策。
把“世界模型”放回 RhOS-World: Khora 这个场景里,它不会只是一个论文里跑通的小环境。千人联机意味着不能只由一个 agent 在自己的隐空间里预测未来,必须让一千个参与者共享同一套世界状态,并且模型的预测结果要实时影响所有参与者看到的画面。这个差异决定了系统架构不可能照搬单机实验。
1.2 世界模型和大模型的区别
很多人最容易把世界模型和大语言模型混在一起,因为当前很多大模型产品已经能生成图片、视频和 3D 场景。但从建模目标看,两者差异明显。
| 对比维度 | 大语言模型 | 世界模型 |
|---|---|---|
| 输入本质 | 文本 token 序列 | 环境状态、观测、动作序列 |
| 建模目标 | 预测下一个 token | 预测下一个环境状态或观测 |
| 输出形式 | 文本、代码、结构化内容 | 状态编码、下一帧观测、规划结果 |
| 典型使用方式 | 对话、检索、生成、工具调用 | 强化学习、仿真、规划、控制 |
| 对实时性要求 | 相对宽松,秒级可接受 | 高,通常需要毫秒到百毫秒级响应 |
大语言模型擅长把知识压缩成可生成的文本表示,世界模型则更关注“环境动态规律”。如果一个系统既用大模型做用户对话,又用世界模型做环境模拟,它们其实是两个独立服务,只不过可能通过同一套调度框架被组织起来。RhOS-World: Khora 如果按命名理解,核心资产应该在“世界模型”,也就是环境状态动态预测这一层。
1.3 世界模型在“千人联机”里承担什么角色
世界模型在联机系统里不一定直接画画面,也不一定等同于游戏引擎。它更像一个“环境大脑”:每个 tick 接收所有用户动作,计算状态迁移,再把结果交给渲染层和业务逻辑层。在这个意义上,世界模型被当成一种“动态服务”使用,必须满足三个条件。
第一是高吞吐推理。千人同时在线,每个 tick 都可能产生近千个动作输入,推理服务必须支持批量处理,否则算力就会成为瓶颈。第二是支持并发预测。不同玩家可能处在不同区域,状态更新不能全局串行。第三是模型版本可回滚。模型升级后如果预测行为突变,线上必须能快速切回旧版本,否则所有用户会同时看到异常。
这三个条件直接决定了后端架构的设计方向。
2. 从单机到千人联机:四个架构问题比模型本身更难
2.1 单机世界模型的最简闭环
先看单机场景下的世界模型用法。一个最简单的训练好的世界模型,推理循环通常长这样:
# 单机世界模型推理循环(示意) state = env.reset() while True: action = policy(state) # world model 预测下一状态 next_state = world_model.predict(state, action) reward = env.reward(state, action) state = next_state在这个循环里,模型、策略、环境全都跑在同一个进程。没有网络延迟,没有并发写入,没有状态冲突。这种实验环境非常适合验证模型能力,却完全不能回答“千人联机”带来的工程问题。
2.2 千人同时在线时,四个问题会同时出现
第一个问题是状态一致性。A 用户移动了物体,B 用户必须在一个可感知的时间窗口内看到同一结果。如果每个客户端各自运行一个世界模型,输入顺序稍有不同,世界状态就会发散。必须有一个权威来源,让所有人都以同一份世界状态为准。
第二个问题是并发更新。同一 tick 内,1000 个用户同时提交动作,世界状态却只有一个。动作按什么顺序应用?如果 A 和 B 同时抢同一个物体,谁成功?这需要在状态服务层设计冲突处理规则,而不是把问题丢给模型。
第三个问题是实时性。深度学习模型单次推理可能需要几十毫秒到几百毫秒,而联机场景通常需要 10Hz 到 30Hz 的状态更新。推理必须批量、异步、可降级,不能因为某个用户请求超时,就让整个世界停住。
第四个问题是可扩展性。一块 GPU 很难在一个 tick 内为 1000 个用户各自推理,可能需要按区域分片,多个推理服务并行处理。分布式的引入又会带来新的数据一致性和部署复杂度。
2.3 架构上从一个模型变成一个系统
单机世界模型是把模型当作“函数调用”,千人联机世界模型则必须把模型变成“服务”。核心变化有两个。
第一,状态从“模型内部隐变量”变成“可广播的世界快照”。单机场景里,状态只存在于模型内存中;联机场景里,状态必须结构化成 JSON、Protobuf 或类似格式,能够被存储、传输、对比和回滚。第二,模型从“同步调用”变成“异步批量推理服务”。客户端发来动作,状态服务先接收,再批量请求推理集群,拿到结果后统一更新世界并广播。
这个思路和游戏网络同步中的“客户端预测 + 服务器权威 + 定期快照 + 插值渲染”非常接近。不同点在于,下一状态不是固定写死的游戏逻辑,而是模型推理结果。模型一旦有随机性或版本差异,同步问题会比传统游戏更明显。
3. 核心模块怎么设计:一份可落地的参考方案
下面的设计用于解释工程思路,不是任何项目的官方架构。实际落地时,模块名称、依赖版本和部署方式都要按团队技术栈重新确认。
3.1 整体模块划分
一个千人联机世界模型平台,按职责可以拆成五个模块。
| 模块 | 职责 | 关键技术点 |
|---|---|---|
| 客户端层 | 采集用户操作、渲染画面、本地预测 | 输入上报、快照插值、预测回滚 |
| 同步网关 | 维护长连接、广播快照、处理重连 | WebSocket、连接管理、消息去重 |
| 世界状态服务 | 维护权威状态、tick 调度、冲突处理 | 状态存储、版本号、事件队列 |
| 推理集群 | 世界模型并行推理、批量处理 | GPU 推理、批处理、模型版本管理 |
| 存储层 | 保存快照历史、用户状态、模型配置 | Redis 缓存、对象存储、数据库 |
一次完整的 tick 流程可以这样理解:用户操作通过同步网关进入世界状态服务,状态服务把当前状态和动作组装成批处理请求,发送给推理集群;推理集群返回预测出的下一状态;状态服务应用结果后,把新的全量快照或增量更新广播给所有相关客户端;客户端接收快照后插值渲染。
3.2 世界状态的数据结构
世界状态必须设计成可序列化、可传输、可版本化的结构。一个简化的快照可以是这样的:
{ "tick": 1024, "model_version": "2025.06.rhos", "time_ms": 1718000000123, "players": [ {"id": "u1001", "x": 12.3, "y": 44.0, "z": 0.0, "action_id": 772} ], "entities": [ {"id": "e1", "type": "box", "state": {"pos": [1.0, 2.0]}} ], "environment": { "weather": "clear", "global_seed": 42 } }这里要有意识地保留三个字段。tick必须单调递增,客户端靠它判断快照是否乱序或过期。model_version非常关键,模型升级后,不同客户端可能加载了不同版本的本地预测模型,只有带上版本号,才能判断为什么本地预测与服务器结果偏差变大。action_id用来标记客户端操作是否被服务器正确应用,排错时可以快速定位“用户按了键但状态没变化”的问题。
真实项目中,状态字段可能远不止这些,但设计原则是一致的:每个字段都要有明确的消费方和生命周期,不要把所有临时变量都塞进世界状态。
3.3 推理服务接口设计
推理集群对外暴露的核心接口是批处理预测接口。请求结构可以这样设计:
{ "batch_id": "b-0001", "tick": 1023, "inputs": [ { "player_id": "u1001", "state_encoding": [0.1, 0.2, 0.3], "action": {"move": [1, 0]} }, { "player_id": "u1002", "state_encoding": [0.4, 0.5, 0.6], "action": {"move": [0, 1]} } ] }响应结构:
{ "batch_id": "b-0001", "tick": 1024, "outputs": [ { "player_id": "u1001", "next_state_encoding": [0.2, 0.3, 0.4], "confidence": 0.98 } ] }接口设计有三个重点。第一,永远批量请求,不要为每个玩家单独调用模型接口,否则 GPU 利用率会非常低。第二,state_encoding是模型需要的隐状态编码,不一定是完整场景数据,状态服务负责把玩家坐标、实体属性等原始信息编码成模型输入,再把模型输出解码成世界快照。第三,接口要带超时和降级策略,模型推理失败时,服务端可以回退到上一个快照或走简化物理规则,不能让整个系统卡死。
3.4 状态同步与客户端预测
在线系统里,网络延迟是客观存在的。一种常用的补偿方案是“服务器权威 + 客户端预测”。
服务器每个 tick 广播权威快照,客户端在等待服务器结果的间隙,先用本地动作预测一个临时状态,让画面保持流畅。当服务器快照到达后,客户端对比本地预测和权威状态。如果差异很小,就直接对齐;如果差异过大,需要回滚到最近一个可信快照,再做平滑修正。
这段逻辑不复杂,但很容易被忽略。实际项目中,我会把“客户端本地预测模型”设计成一个小型蒸馏模型,尽量轻量,只做短期预测。它不需要像服务器模型那样精确,目标是让画面在几十毫秒内不卡顿。真正决定世界走向的,必须是服务器权威状态。
4. 一个最小原型:把世界模型服务、状态同步和千人吞吐跑通
这一节将搭建一个最小可运行的原型骨架。它不追求完整功能,只验证一件事:世界状态服务、批量推理、模拟客户端三者能不能在一个 tick 循环里跑通。
4.1 环境准备
以常见环境为例,需要 Python 3.10 以上版本,以及几个通用依赖。这里没有使用任何特定项目的私有组件,落地前建议重新确认版本。
python -m venv .venv source .venv/bin/activate pip install pydantic fastapi uvicorn numpy如果计划接入真实模型推理,再按推理框架补充依赖,比如 PyTorch 或 ONNX Runtime。原型阶段可以先写一个模拟推理函数,重点把并发和同步逻辑跑通。
4.2 最小目录结构
rhos_world_khora/ ├── server.py # 服务启动入口 ├── world_state.py # 世界状态和 tick 循环 ├── inference.py # 批量推理服务 ├── sync_gateway.py # 同步网关抽象 └── simulator.py # 模拟客户端批量压测原型阶段保持五个文件就足够。不要一上来就拆几十个文件,否则出了问题很难定位。
4.3 世界状态和 tick 循环
世界状态服务是这个系统的心脏。每个 tick 做三件事:接收动作、调用推理、更新状态并广播。
import asyncio from dataclasses import dataclass, field from typing import Dict, List @dataclass class WorldState: tick: int = 0 players: Dict[str, dict] = field(default_factory=dict) async def tick(self, actions: List[dict], inference_service): # 1. 打包当前状态和动作 batch = [] for act in actions: player_id = act["player_id"] state = self.players.get(player_id, {}) batch.append({ "player_id": player_id, "state_encoding": state.get("encoding", []), "action": act["action"], }) # 2. 批量推理 outputs = await inference_service.predict(batch) # 3. 应用预测结果 for output in outputs: pid = output["player_id"] self.players[pid] = { "encoding": output["next_state_encoding"], "confidence": output["confidence"], } self.tick += 1 return self.snapshot() def snapshot(self) -> dict: return { "tick": self.tick, "players": self.players, }这里有一个关键点:tick里不能直接同步调用模型。模型推理如果是 CPU 或 GPU 密集计算,会阻塞事件循环,导致所有客户端连接都卡住。正确做法是把推理放到线程池或独立进程中执行。
4.4 批量推理服务
推理服务负责把异步接口和真实模型隔离开。原型阶段用一个模拟函数代替模型。
import asyncio from typing import List class InferenceService: def __init__(self, model_fn=None): self.model_fn = model_fn or self._dummy_model async def predict(self, batch: List[dict]) -> List[dict]: loop = asyncio.get_running_loop() # 把阻塞推理丢到线程池,避免阻塞事件循环 outputs = await loop.run_in_executor(None, self.model_fn, batch) return outputs @staticmethod def _dummy_model(batch: List[dict]) -> List[dict]: results = [] for item in batch: # 模拟模型推理:状态编码原样返回,并加一个固定偏移 enc = item["state_encoding"] results.append({ "player_id": item["player_id"], "next_state_encoding": [v + 0.1 for v in enc], "confidence": 0.99, }) return results在实际项目中,_dummy_model会被替换成加载好的 PyTorch 模型或 ONNX Runtime 推理器。注意模型加载应该放在进程启动阶段,不要在每个请求里重新加载。
4.5 运行验证
用模拟客户端验证基本流程。模拟脚本创建多个客户端会话,每个客户端持续发送动作并接收快照。
import asyncio import random async def user_session(client_id: int, queue: asyncio.Queue): for step in range(50): action = {"player_id": f"u{client_id}", "action": {"move": [1, 0]}} await queue.put(action) await asyncio.sleep(1 / 30) async def main(): world = WorldState() inference = InferenceService() queue = asyncio.Queue() for i in range(1000): asyncio.create_task(user_session(i, queue)) for _ in range(200): actions = [] while not queue.empty(): actions.append(queue.get_nowait()) if not actions: await asyncio.sleep(0.05) continue snapshot = await world.tick(actions, inference) if world.tick % 20 == 0: print(f"tick={snapshot['tick']} players={len(snapshot['players'])}") asyncio.run(main())这个脚本只验证流程,不代表真实的千人压力。它的作用是确认:并发动作能进入队列,状态服务能批量推理,tick 能持续推进。
5. 压测千人联机:关键指标、脚本与扩容判断
原型跑通后,下一步就要回答“能不能扛住一千人”。压测不要只盯着能不能启动,要把指标拆开看。
5.1 需要观测的关键指标
| 指标 | 含义 | 学习环境参考值 | 生产环境关注点 |
|---|---|---|---|
| 状态同步频率 | 每秒广播多少个 tick | 20 Hz 以上 | 与模型推理速度直接相关 |
| 端到端延迟 P50 | 动作发出到快照返回的中位延迟 | 小于 100ms | 网络、队列、推理各占多少 |
| 端到端延迟 P95 | 长尾延迟 | 小于 200ms | 是否存在阻塞或慢请求 |
| 推理批量吞吐 | 每秒处理的玩家动作数量 | 视资源而定 | 是否接近 GPU 算力上限 |
| 快照大小 | 每个 tick 广播的数据量 | 越小越好 | 千兆网络下带宽是否耗尽 |
| 错误率 | 超时、重连、丢消息比例 | 极低 | 是否有雪崩风险 |
压测的核心不是追求绝对数字,而是找到延迟增长曲线从平滑变为陡峭的拐点。这个拐点就是系统容量边界。
5.2 一个简单的并发压测脚本
可以用 asyncio 模拟大量客户端,持续发送操作,并统计响应时间。
import asyncio import time async def pressure_client(client_id: int, state: dict, inference, latencies: list): for _ in range(50): action = {"player_id": f"u{client_id}", "action": {"move": [1, 0]}} t0 = time.perf_counter() await state.tick([action], inference) latencies.append((time.perf_counter() - t0) * 1000) await asyncio.sleep(1 / 30) async def run_pressure(): state = { "players": {f"u{i}": {"encoding": [0.0, 0.0]} for i in range(1000)} } inference = InferenceService() latencies = [] tasks = [pressure_client(i, state, inference, latencies) for i in range(1000)] await asyncio.gather(*tasks) latencies.sort() p50 = latencies[len(latencies) // 2] p95 = latencies[int(len(latencies) * 0.95)] print(f"p50={p50:.1f}ms p95={p95:.1f}ms total={len(latencies)}") asyncio.run(run_pressure())这个脚本把每次动作都当成一个独立 tick 处理,实际系统会做批量合并。但它能快速暴露一个真问题:如果不做批量,随机时刻到达的动作会让 tick 碎片化,延迟和吞吐都会恶化。真实实现应该把时间窗口内的动作聚合后一起推理。
5.3 瓶颈判断与扩容思路
压测结果出现异常时,先看资源消耗落在哪里。
如果 CPU 使用率高,世界状态服务和数据编解码可能成了瓶颈,需要优化序列化格式或增加状态服务副本。如果 GPU 利用率高,模型推理是瓶颈,优先做批量优化、模型量化或者切分模型。如果网络带宽高,快照太大,需要改成增量快照,只广播发生变化的部分。如果延迟增长但各项资源都不饱和,很可能存在全局锁、串行队列或者事件循环阻塞,需要用 profiling 工具排查。
扩容不是简单加机器。世界状态如果集中在一个服务里,加再多的推理集群也绕不开单点瓶颈。千人规模的合理做法是按区域分片,每个分片维护自己的世界状态,跨分片交互通过事件网关转发。RhOS-World: Khora 这类带模型推理的系统,分片后还要给每个分片分配独立的推理资源,避免一个区域的高负载影响其他区域。
6. 常见问题排查:从现象倒推根因
6.1 用户看到的世界互相“穿越”
现象:不同用户看到的同一实体位置不同,A 移动后 B 的屏幕没有变化。
可能原因:客户端各自运行了自己的世界模型,没有服务器权威状态;或者服务器广播了快照,但客户端在插值渲染时没有按tick排序。
检查方式:在客户端打印最近三个快照的tick和实体坐标,确认到达顺序;在服务器端确认每个 tick 是否只发布一次最终状态。
处理建议:让服务器成为唯一权威状态源。客户端本地预测只用于渲染补偿,不能作为世界事实。客户端收到快照后,必须忽略tick <= 当前已应用 tick的旧数据。
6.2 所有用户都感觉卡顿,延迟缓慢上升
现象:刚开始正常,几分钟后所有用户操作都变得迟钝,服务器 CPU 并不高。
可能原因:异步代码里混入了同步推理调用,导致事件循环被阻塞。另一个常见原因是广播队列积压,客户端消费速度跟不上服务器生产速度。
检查方式:查看事件循环延迟,Python 中可以用asyncio的调试模式或loop.slow_callback_duration参数;查看同步网关的队列长度和消息积压量。
处理建议:模型推理必须放进线程池或独立进程执行。广播要支持批量合并,多个玩家在同一 tick 内可以共享一份快照,不要给每个玩家单独发送全量数据。
6.3 模型升级后,同一状态预测结果突然变化
现象:发布新模型后,用户物体位置突然跳变,客户端本地预测频繁回滚。
可能原因:新旧模型版本对同一个状态编码给出了不同预测,而客户端还在用旧模型的本地预测结果。
检查方式:在快照中检查model_version字段;对比新旧模型在相同输入下的预测输出。
处理建议:发布新模型时必须同时下发模型版本号。客户端发现本地预测模型版本与服务器版本不一致,应当关闭本地预测,直接使用服务器快照插值。线上模型升级前,要先在影子环境跑同一批历史输入,评估状态输出差异。
6.4 压测时网关内存暴涨
现象:模拟 1000 个客户端时,同步网关内存持续上升,直到 OOM。
可能原因:广播风扇过大,每个 tick 都给所有用户发送全量快照;客户端处理速度慢,网关积压消息;或者消息没有清理机制。
检查方式:监控网关待发送队列长度;在压测脚本中模拟客户端是否真正消费消息,还是只发送不接收。
处理建议:把全量广播改成增量广播,只发送有变化的实体。网关增加积压水位告警,当队列超过阈值时丢弃旧状态或断连慢客户端。生产环境还要对未认证连接做频率限制,防止恶意压垮服务。
7. 生产环境最佳实践与扩展方向
7.1 学习环境与生产环境的差异
原型代码只能证明流程能跑通,距离生产还有明显差距。
| 维度 | 学习环境 | 生产环境 |
|---|---|---|
| 模型 | 模拟推理或单卡模型 | 多副本推理集群、模型灰度 |
| 配置 | 写在代码里 | 外置配置中心、动态更新 |
| 日志 | 控制台 print | 结构化日志、链路追踪 |
| 监控 | 无 | CPU、GPU、延迟、队列水位、告警 |
| 安全 | 无 | 用户鉴权、接口限流、数据加密 |
| 回滚 | 重启即可 | 模型版本回滚、状态快照恢复、旧包保留 |
| 数据 | 内存态即可 | 定期持久化、备份恢复方案 |
生产环境的每一步都比学习环境多一层保障,不能等项目上线后再补。
7.2 发布前的检查清单
发布一个千人联机世界模型服务,至少需要确认这些事项。
- 模型版本号是否与快照结构中的
model_version对齐。 - 推理服务是否有超时、重试和降级策略。
- 客户端能否识别模型版本不一致,并安全关闭本地预测。
- 压测数据是否覆盖真实用户行为,不只是固定动作循环。
- 同步网关是否有消息积压告警和连接数上限。
- 世界状态是否有定期快照,模型异常后能否恢复到最近的稳定 tick。
- 发布流程是否支持先灰度,再全量。
- 回滚后,客户端是否需要强制刷新重新同步。
7.3 扩展方向
千人规模的下一步,可以从三个方向展开。
第一,世界分片。把一个大地图划分成多个区域,每个区域独立运行世界状态服务和推理实例,跨区域玩家通过事件转发交互。这能把“千人一台服务器”拆成“每片 200 人”,大幅降低单点压力。
第二,客户端蒸馏模型。服务器模型可以在线裁剪出一个小模型,部署到客户端本地。客户端预测越准,等待服务器快照时的画面越平滑,回滚概率越低。
第三,长期记忆机制。当前世界模型通常只建模短期内状态迁移,缺少对长期事件和用户行为历史的记忆。可以把历史关键事件压缩成记忆 token,在推理时作为额外输入,让模型对长期一致性的判断更稳定。
回到“千人联机世界模型 RhOS-World: Khora”这个命名本身。它给工程团队的提示很清楚:世界模型不再只是论文里的隐空间玩具,而是要扛住并发、延迟、一致性和版本演进的实时系统。对开发者来说,与其争论它是否真的支持一千人,不如先搭一个骨架:定义世界状态、封装推理服务、跑通 tick 循环、再做压测。先把这四件事做扎实,再谈多模态输入、长期记忆和更复杂的物理规则,都会顺手很多。