好久没写点正经的东西了,上一篇还是折腾容器的事。这两天读了一个叫LiteReality-Agent的项目,是用手机扫一遍房间,然后端到端生成一个可编辑、可渲染、门窗抽屉都能动的三维场景。
本来只想看看它怎么调模型的,结果被它的工程组织方式吸引了,索性做个笔记。主要记录它的流水线结构、agent的关进笼子的方式,以及几处让我后背发凉的坑。

先说问题在哪
手机扫房间在2026年已经不难了,Apple的RoomPlan走一圈就能给出一串RGB帧、深度、ARKit相机位姿,以及一个room.usdz,里面有墙面、门窗洞口和一组粗糙的物体包围盒。
难的是这堆东西离「能用的三维场景」还差得远。
RoomPlan给的是物体包围盒加类别标签,做图形要的是有真实几何和材质的网格;
给的是不可编辑的usdz,要的是能改、能重编译、能导出的场景源;
给的是静态几何,要的是门会转、抽屉会拉、洗碗机门会翻下来;
给的是「大概像个房间」,要的是「就是这间屋子」。
最后一条最容易被忽略。多数生成式场景重建的验收标准是「看起来像个合理的房间」,而不是「和我扫的那间对得上」。这个项目整条设计都在咬后者。
顺手数了一下规模: src/下183个python文件,28219行;tests/下41个test_*.py;100个提交;要求python >=3.10,<3.13;Apache-2.0;版本号还是0.0.1,Development Status写的Alpha。
一个两万八千行的alpha项目,架构文档能和代码逐条对得上,这事本身就不多见。
一条命令
支持的入口只有一个CLI。
没有扫描数据也能试,官方放了示例房间。
跑完之后直接在浏览器里走进去。
产物是通用格式,room_preview/Room.glb材质已烘焙、动画clip完整,Blender/Unity/Unreal/Web都能吃;room_preview/Room.blend是同一个房间的Blender场景;room/是可编辑的源,整个房间在这里定义。
五个stage,两个phase
架构文档第一句就把全貌摊开了。
五个公开stage声明在pipeline/stages.py里,十几行看完。
每个stage带两样东西: 前置依赖元组,和一个is_complete(context)谓词。这两个撑起了整个可恢复语义,后面会看到。
确定性与agentic的分界
这是整个项目最重要的一条线。scene_init是确定性的——读扫描、检测、生成物体、拼成种子房间,同样输入出同样东西,没有模型在里面拍脑袋做布局决策。realism_authoring是agentic的——agent拿着种子房间,对着原始扫描的照片看,然后改代码,直到房间和照片对上。
两半可以各自单独跑。
--polish在authoring之后追加三个可选pass: 物体精修、材质、模型驱动的质量检查。--live开一个浏览器视图,边跑边看房间被搭起来,旁边是agent的完整trace。
把这条线画明,调试时永远知道该骂谁: 布局歪了是seed的问题,材质不对是agent的问题。很多把LLM塞进流水线的项目坏在这条线是糊的。
PipelineRunner
pipeline/runner.py只有154行,但把可恢复流水线这件事做对了。状态落在run/<scene>/.litereality/pipeline.json,原子写。
先写.tmp再replace,中途断电不会留下半个JSON。基本功,但很多人不做。
force会级联失效下游
|
|
强制重跑reconstruct,那么seed、author、publish的完成记录全部作废。这是对的,上游变了下游的「已完成」就是谎话。见过太多流水线在这里偷懒,结果拿旧的authoring结果配新几何,产出一个谁也解释不了的场景。
磁盘状态可以反过来认领内存状态
|
|
直接跑litereality stage author,runner发现seed没在状态文件里,但磁盘上Room.py确实躺着,那就承认它,标成REUSED。这让状态文件丢了不再是灾难,磁盘产物本身就是事实来源。
环境变量的兼容桥被关在编排边界
一部分从旧代码移植来的stage还在读全局环境变量。作者没有逐个去改,而是在唯一一处用patch.dict把环境注进去,stage一返回立刻还原调用方的进程环境。
这是我很欣赏的处理历史债的姿态: 把脏东西收敛到一个点,并在注释里写明它为什么在那里。既不假装它不存在,也不为了洁癖去做一次高风险大重构。
CLI层的状态输出也干净。
房间是一段可读的程序
这是整个项目最聪明的一个决定: 房间的源不是JSON,不是usdz,是一个python程序。
格式契约如下。
编译产物单独放,随时可重新生成。
Room.py里嵌的是可读的几何: 墙的端点、门窗洞口、地板天花板高度、物体包围盒。程序化物体保留可编辑的Blender构建代码和纹理配方,神经网络生成的静态资产保留源GLB。
为什么这个决定重要? 因为它让「agent编辑场景」退化成了「agent编辑代码」——一个LLM已经很擅长的问题。不需要发明场景编辑DSL,不需要给agent一堆move_object(id, x, y, z)工具,agent就用它最熟的Read/Edit/Write改python文件,然后重新编译看结果。room_ops包拥有这个表示,以及所有不依赖流水线状态的操作: 读写manifest、编译Blender和GLB、渲染、导出、起可行走的viewer。公开API就两行。
还有一条纪律: import room_ops不会启动Blender,只有显式的compile/render操作才会。
agent的能力工具是个闭集
agent手里除了Read/Edit/Write/Glob,还有六个领域能力工具,agent/tools/default_registry.py是唯一真值来源。
fetch_material: 从Poly Haven抓真实PBR材质集(diffuse+rough+normal),可选重新着色select_views: 为房间、某面墙或某个物体挑最合适的采集帧,绝不让agent猜帧号render: 针对一个目标层出「渲染图 ‖ 照片」对照,自动取景并重新编译grid: 在表面拼图上画公制标尺,读出装置的(u, z)米坐标而不是估critic: VLM打分,对着目标给{pass, score, issues},是判官check_collisions: 真网格穿插和包含检测,object↔object、穿墙、出房间、离地,还有机器人风格的铰接门窗检查,每条都附一个公制的挪动或缩放修正建议
关键设计在select_views和grid这两个: 凡是agent容易「合理地猜错」的量,都变成一次工具调用去读。帧号、米制坐标,这些东西LLM编起来毫无阻力,而且编出来的值看着完全合理。做成可查询的工具,比在prompt里写「请不要猜」有效得多。critic是个VLM judge,输入图像和一句目标描述,输出结构化的通过与否加问题列表。有了它authoring才成为闭环,而不是一次开环生成。
另外compile不是注册工具,它是render内部用的共享代码。agent只能通过render顺带编译,不能单独调编译——少一个工具,少一条走错的路。
退役的工具也留了记录
agent/tools/README.md里有个「retired tools」小节,写着旧的闭环驱动器(agent/harness/loop.py,一套11个原语加3个复合的闭集配裸API循环)已经删除,退役的原语被停放在legacy/下仅供参考,活跃路径没有任何东西import它们。
把「我们试过什么、为什么不用了、残骸在哪」写进文档,比只留下当前状态有价值得多。半年后有人问「为什么不做成闭集工具」,答案在仓库里。
harness和model是两个正交旋钮
agent/providers/base.py开头就点明的抽象,我认为是这个项目最有普适价值的一条。
harness agent循环: 文件工具、权限模型、MCP接线、hooks、事件流
model 循环里的那颗脑子(HARNESS_MODEL、LR_PROCEDURAL_MODEL…)
选择靠配置解析,优先级是LR_<ROLE>_PROVIDER > LR_AGENT_PROVIDER > claude。支持两个harness: claude走claude_agent_sdk,codex走codex exec。
harness不是功能等价的,而且这件事被显式建模了
每个harness声明一个supports集合,调用方分支判断而不是假设。
hooks: 能在工具调用真正执行前拒绝或引导它,用于step budget的优雅收尾inproc_tools: 能力工具跑在本进程,不用stdio MCP子进程cost: 会报告这次session花了多少钱skills: 原生加载.claude/skills包restricted: 工具白名单真的被执行(Codex永远有shell权限)
源码注释里那句话写得很好。
Harnesses are NOT feature-equivalent, and pretending otherwise is how a step budget silently stops existing.
具体差异: Claude Code在进程内托管能力工具、能用PreToolUse hook引导session收尾、报告成本、遵守工具白名单、把文件读取暴露成可观测的Read调用。Codex跑在进程外,能力工具通过agent/tools/mcp_server.py走stdio MCP桥接,step budget退化成硬停,shell权限没法收回,不报成本。
然后是我最喜欢的部分——providers.describe()把这些差距打印到stage头部。
配套的注释是。
A missing capability must be visible in the log. …a run that quietly lost its graceful landing looks identical to one that never had it.
降级运行绝不能长得像正常运行。这条我准备直接搬到自己项目里。
step budget的优雅落地
SessionSpec里这两个字段设计得很细。
不是到点就砍,而是到点前留一段预算,把能力工具关掉,逼agent用剩下的步数把编辑落盘。做过长agent循环的人都懂这个痛: 硬停最恶劣的形态是agent刚渲染完、正准备改文件,被掐了,一轮全废。
事件流被归一化了
TextBlock、ToolUseBlock、ToolResultBlock、AgentMessage、SessionResult,字段名故意对齐claude_agent_sdk的block形状,这样ToolNarrator、AgentTrace、拼图覆盖率检查、Room.py checkpointer这些消费者一套代码吃两个harness。AgentMessage.raw保留harness自己的原始对象,所以raw trace sidecar是逐字保真的。normalise_blocks对不认识的block类型原样透传而不是丢掉。
Unknown blocks are kept rather than dropped: consumers ignore what they do not recognise, but the raw trace should never lose something because this mapping had not heard of it.
还有一条带血的注释,在ToolResultBlock上。
Carries
tool_use_id, never the tool’s name — see tool_narration.py for what reading a name off this block cost us.
「从这个block上读工具名,让我们付了什么代价」。这种注释比任何设计文档都值钱。
复杂度路由: TRELLIS还是procedural
物体重建不是一条路走到黑,classify_complexity.py给每个物体做路由。
这个划分很务实。TRELLIS擅长有机形状,但它给不了你正确的关节;而桌子、储物柜、洗碗机、电视、水槽这类东西几何规则,但运动方式必须对。
procedural路线的核心洞见写在models/object_generation/README.md里。
RoomPlan only detects a fixed set of object categories, so we hand-author a detailed spec per category — geometry, materials, and exactly how each part moves.
因为RoomPlan的类别集是封闭有限的,所以「每个类别手写一份详细规格」是可完成的工作量,不是无底洞。category_specs.py是16KB纯领域知识,定义了每类物体的几何、材质,以及每个部件具体怎么动。生成时把类别规格、该物体真实的RoomPlan尺寸、干净参考图一起注入agent prompt。
the dishwasher door drops to horizontal, the drawer pulls out along +Y, the cabinet door swings on its outer vertical edge
洗碗机门翻到水平、抽屉沿+Y拉出、柜门绕外侧竖边旋转。这些不是模型猜的,是规格里写死的。
产物里每个运动部件带glTF node extras,有articulation_type(revolute或prismatic)、articulation_axis、limit_min和limit_max,仿真器可以直接读。这就是「可交互」三个字的落点,不是「能点击」,是物理仿真意义上的关节。
顺带,这条路线的驱动是一个Claude Code skill(image-to-articulated-glb),装在articulated-glb-agent/.claude/skills/下,再被python launcher批量调用。用skill封装「图到铰接GLB」这个能力,这个组合挺有启发性。
最后一道闸门不含模型
agent会犯错,所以收尾是纯几何的。pipeline/room_qc/checks.py的docstring直接列清了要报什么。
below_floor/above_ceiling: 物体穿出地板或天花板floating/sunk: 落地家具没落在地上outside_room: 物体中心跑到房间轮廓外wall_clash: 家具插进墙板object_clash: 两件家具互相穿插fixture_over_opening: 墙面装置压在门窗洞口上
关键在下一句: 正确的重叠不报。台下式水槽本来就在台面里、嵌入式烤箱本来就在橱柜里、椅子本来就塞在桌子下,这些在EXPECTED_CONTAINMENT和PASSTHROUGH里白名单化。
Everything is pure arithmetic over AABBs, so it’s fast, exact, and needs no LLM.
一个agentic项目最后用纯AABB算术把关,这个层次感是对的: 能用算术判定的事情不要交给模型。报出来的问题由room_qc/fix.py挪动家具解决。
而且盒体运算只有一份,住在每轮agent调用都会用的那个工具里(agent/tools/check_collisions/source/geometry.py),checks.py只拥有「报告」这件事,因为「房间算不算过关」是流水线的决定。同一套几何判定,agent自查和流水线终检共用,不可能出现两套标准。
两个静默失效的教训
第一个是python-fcl不能做可选依赖。pyproject.toml里有一段异常长的注释。
QC true-mesh collision. NOT optional: publish runs
room_qc.correcton every default run and only records a warning if it exits non-zero, so a missing FCL turned the deterministic clash gate into a silent no-op.
缺了FCL,那道确定性碰撞闸门就变成空操作,而且只留一条warning。作者的判断是预编译wheel覆盖所有支持的平台,代价是几MB和零编译,比发一个悄悄什么都不干的闸门便宜得多。
第二个是不烘焙材质,发布出去的是另一个房间。publish/__init__.py里的注释。
a procedural wall/floor/ceiling material (
two_tone_mat,carpet_mat,ceiling_tile_mat) has no glTF representation, so it exports with neither a texture nor a baseColorFactor and renders WHITE. Flat-RGB and fetched-image materials survive either way, which is why this stayed invisible until a room used procedural ones.
程序化材质在glTF里没有表示,导出后既没纹理也没baseColorFactor,渲染成纯白。而平色和贴图材质两种路径都活得下来,所以这个bug一直隐身,直到某个房间真的用了程序化材质。修法是publish时显式调api.bake_room(),让agent看到的每张渲染图和最终产物走同一条烘焙路径。
这两条读的时候都有点后背发凉。验证通道自己失效是最难发现的一类bug,因为所有指示灯都是绿的。
配置
LiteRealitySettings用pydantic-settings,加载顺序是进程环境 > .env > models.env > 类型化默认值。models.env是仓库自带的模型默认值,.env是你机器上的密钥和路径,每行用${VAR:-default}所以shell里已有的值一定赢。
一次性在组合边界解析完,然后apply_environment()用setdefault铺到环境里,不覆盖调用方的shell。
两个校验细节值得看。未知harness必须在这里炸,而不是在付费session深处。
LR_AGENT_PROVIDER=codexx多打一个x,会静默fall through到claude默认值,跑错agent还花你的钱。
半个token pair不能算「已配置」。
只填了MODAL_TOKEN_ID没填secret,如果算作配置了Modal,运行时就会选择Modal然后在真正调用时失败,而不是回退或者告诉你缺什么。
还有一个挺可爱的兼容处理: models.env被python-dotenv读,而dotenv不展开用作默认值的$VAR,所以${VAR:-$OTHER}会原样到达变成字面量$other。代码把以$开头当作未设置。
models.env
这个文件写得像文档,一个地方选完所有模型。
LR_AGENT_PROVIDER: 哪个agent harness,默认claudeHARNESS_MODEL: 写和改Room.py的编辑者,也驱动classify和VLM reader,默认claude-opus-5,这是主要旋钮HARNESS_CRITIC_MODEL: VLM critic,默认跟随HARNESS_MODELLR_CHAIR_JUDGE_MODEL: 椅子类型判官(框架、扶手、软包、底座属性)LR_PROCEDURAL_MODEL: 铰接GLB的程序化agentLR_COMPLETENESS_MODEL: 完整性闸门,参考图对渲染图判「有没有缺件」LR_OPENAI_IMAGE_MODEL: 参考图生成,默认gpt-image-2LR_DINO_MODEL/LR_DINO_EMBED_MODEL: GroundingDINO检测与DINOv2分组嵌入
LR_COMPLETENESS_MODEL的注释很诚实。
Runs once per build attempt per object and sits on the critical path. …Lowering it is tempting but two-sided: too lenient ships objects missing parts, too strict costs a full agent rebuild.
降配是双向风险: 太松就发出缺零件的物体,太严就白烧一次完整重建。把这种权衡写在配置项旁边,而不是留给后人试错。
另外注释里明确写了claude-fable-5太贵且并不比claude-opus-5强,所以默认不用。这种基于实测的取舍记录,比任何benchmark表都实用。
运行时隔离
TRELLIS和GroundingDINO需要GPU,项目的默认选择是托管在Modal上。
理由说得很直白: 本机不跑重活,一台没独显的Apple Silicon Mac就够,而且检测能并行铺开到多个容器,不用在一张卡后面排队。有自己的Linux GPU就走deploy/local-gpu.md。
绑定逻辑在models/registry.py,短得可以全文引用。
分层很清楚: model包拥有一条推理路径,runtime拥有「在哪执行」。deploy/modal/里是托管模型的包装,应用代码永远不import它。重依赖也隔离在自己的环境里(--extra detect和--extra gen3d),不污染轻量的agent循环环境。
顺带,CLI和单元测试不会启动DINO、TRELLIS、Blender,也不发起付费调用。这条保证让「随手跑一下测试」成为可能。
工程纪律
这部分其实是我读完最想聊的。
依赖方向被测试执法
|
|
箭头从调用方指向依赖。models、room_ops、可复用agent永远不import pipeline。这条规则不是写在文档里靠自觉,是tests/test_architecture.py在跑的。
架构文档还专门写了不存在什么。
There are no top-level
services,adapters, orsharedpackages. There is also no nestedpipeline/stagespackage.
声明不存在的东西,是防止架构熵增的好办法。下一个人想加shared/之前,会先在文档里撞到这句话。
测试默认离线且快
|
|
注释是「The default run must stay offline and fast, so it is worth running before every commit.」三个marker把「需要Blender」「需要真实扫描」「要花钱」分层剥离,默认套件保持离线,所以它值得在每次提交前跑。加上--strict-markers,打错marker名字会直接失败而不是静默失效——又一个防静默的例子。
安全的本地验证只有三条命令。
根目录还有个24440字节的sanity.py,配SANITY_DEEP=1做深度检查。对一个依赖Blender加三个模型加一个云运行时加两个agent CLI的项目来说,把「你的环境到底行不行」做成一个可执行的自检,是省下无数issue的投资。
唯一副本,测试钉死
agent/tools/README.md里说明了哪些原语是共享而非某个工具独有的,以及为什么。
config.py: harness路径和旋钮,四个工具加pipeline的evidence.py都要scan.py:scan_from_room和config_for,tests/test_scan_inference.py钉死只存在一份overlay.py: 墙面投影,compose和select_views都要image_selection/: 表面几何加正视比较stitch_wall_image/: 矫正后的墙面拼图,四处使用
一句话交代动机: 「a per-tool copy is a bug waiting to happen」。写个测试来保证某个函数全仓库只有一份,这个手法我以前没想到过,但确实是对付「复制一份改改」这种腐化的最直接办法。
另外有些包装是故意的: compile包着room_ops.compile_room(格式自己的编译器),fetch_material包着compile/fetch_textures(因为textures.json是Room格式契约的一部分)。文档里标了deliberately。把「这里看起来该合并但我们没合并」写清楚,省掉后人一次好心的错误重构。
抛开三维重建,能抄的几点
- 把确定性的和agentic的显式分层,不让模型参与能用算术解决的决策,最后一道闸门用纯几何。
- agent容易「合理地猜错」的量做成工具去读。帧号、米制坐标,LLM编这些毫无阻力且编得很合理。
- 把场景或配置变成代码,让「编辑」退化成「改代码」,直接复用LLM最强的能力。
- harness和model是两个正交旋钮,而且harness之间不是功能等价的,用
supports集合显式建模差异。 - 降级运行绝不能长得像正常运行,缺失的能力必须打进日志。
- 长agent循环要留优雅落地的预算,到点前关掉能力工具。
- 配置错误在组合边界炸掉,不要漏进付费session。
- 警惕验证通道自己失效,这类问题只能靠「改输入看输出是否真的变」来抓。
- 文档要写「不存在什么」和「我们试过什么」。
- 把权衡写在配置项旁边,替后人省掉一轮试错。
上手清单
需要的东西: uv;Blender 5.x(测试于5.1,BLENDER_PATH指向安装目录而不是可执行文件);OpenAI API key用于参考图生成,通常每个场景不到一美元;一个已登录的agent CLI在PATH上,claude是默认,codex也支持;以及一个跑GPU的地方,Modal账号(推荐,免费额度够,Mac也能用)或者显存不小于24GB的Linux机器。平台上macOS的Apple Silicon和Linux都测过。
扫描端是免费的LiteReality Scanner,走一圈就够。
局限
免得读起来像软文,说说不好的地方。
版本还是0.0.1,Development Status是Alpha,技术报告没出。整条推理链路挂在claude或codex的登录态上,不是纯API key就能起,这对复现和CI是个真实的摩擦点。成本也不透明,参考图生成有报价,但authoring那一大段跑在订阅制CLI上,注释里明说not metered,所以很难算清一个场景的真实开销,Codex harness干脆不报成本。
Codex路线是明确的二等公民,没有pre-tool hook、没有工具白名单、不报成本,文档诚实标出来了,但不用Claude的话体验是降级的。物体精修更是只支持Claude,因为render_object工具是围绕活跃session状态按物体动态构建的,stdio桥接没有registry条目可以重建。作者的处理是报明确的错然后失败,而不是在没有唯一自检工具的情况下硬跑,这个选择我赞成。
不过话说回来,这个项目真正的贡献我觉得不是「用LLM做三维重建」,而是演示了一套把agent关进笼子的工程范式。笼子的骨架是可恢复的确定性流水线、代码化的领域表示、闭集能力工具、不含模型的终检闸门,以及对降级路径的强制可见性。agent在笼子里干它最擅长的事——看图、改代码、迭代,笼子保证它改不出可验证边界之外的东西。
两万八千行代码、41个测试文件、100个提交,做到架构文档和代码逐条对得上,注释里还留着「这个bug让我们付了什么代价」,这种密度的工程自觉,2026年的agentic项目里不算常见。
看完之后手有点痒,感觉自己那几个项目的日志该重写了。