10 月 27 日那个检查点,把「一次生成成功」从碰运气变成了常态
做 3D 代码生成这件事,评估口径很容易跑偏。社区里几乎所有的讨论都停留在“这图好看不好看”“这次生成的樱花树有没有氛围”——但对于一个输出「单文件 HTML + 内联 JavaScript + 远程加载 Three.js」的模型来说,好看只是必要条件,能不能一次跑通才是可用性本身。
仓库 README.md 在 10 月 27 日检查点这一节里给出的,恰恰是这句最不像营销话、也最难造假的表述:一次生成成功率(one-shot success)显著提升,场景整体质量同步提升,部分场景仍需重试。没有百分比,没有 benchmark,只有一张罗马场景在 10 月 25、26、27 三个预览版本下的对比图。
这篇就围绕这个检查点,把三件事讲清楚:为什么“一次成型率”是这类任务唯一诚实的指标;仓库里有哪些可验证的差异证据;以及怎么用仓库自带的参数,在你自己的机器上把这个数字量出来——顺便说清“一次成型之后,剩下的活到底还有多少”。
一、“一次成型”为什么比“平均质量”更重要
Three.js 场景生成是一个**全或无(all-or-nothing)**的产物形态,这一点由交付方式决定,而不是由模型决定:
- 产物是一个自包含 HTML 文件,用户双击就打开。没有编译期,没有类型检查,没有任何机会在“出错之前”拦住问题;
- 错误被完整推迟到运行时。一个拼错的变量名、一个没定义的
THREE.Something、一次addons导入失败,浏览器不会给你降级方案,你得到的就是一块黑屏或一片空白; - 渲染循环里一旦抛异常并中断
requestAnimationFrame链,画面会冻结在第一帧,连“部分可见”的安慰都没有。
这就意味着并不存在“70 分作品”这种东西。一个平均观感 90 分、但每三次有一次黑屏的模型,实际可用性远低于平均观感 80 分、但十次十次能跑起来的模型。而“平均质量”这个指标会严重掩盖这一点,因为它把黑屏排除在统计之外了。
成本结构也在推同一个方向。按 README.md 的系统提示与示例提示词,模型输出的是一个包含完整场景构建代码、光照、相机与交互逻辑的文件,训练数据的每条样本还带完整 thinking trace(数据集共 4,146 条,3,933 训练 / 213 验证)。也就是说:一次采样是重成本,重试是成倍的重成本,而人工修正的成本最高——你得先读懂它的变量命名习惯,才能改对。所以取pass@1(单次采样成功率)而不是pass@k,不是学术洁癖,而是生产口径。
二、检查点前后的可观测差异,以及仓库里能查到的证据
先看 README 原文说了什么,这决定了哪些结论能说、哪些不能说:
| 项目 | 10 月 27 日检查点后的状态 |
|---|---|
| 权重覆盖范围 | Q4_K_M与Q8_0两个 GGUF 均包含更新后的检查点,旧版本需重新下载 |
| 一次成功率 | “improved substantially”(显著提升) |
| 场景整体质量 | 同步提升 |
| 残余问题 | 部分场景仍可能需要重试或修正 |
| 生成参数 | 保持不变 |
| 顺带修复 | 部分用户反馈的“层级相关”问题(layer-related issue) |
其中最有信息量的一行是“生成参数保持不变”。这句话排除了一个最常见的误判:提升不是靠调采样换来的。temperature、top-p、top-k、repeat penalty 在检查点前后完全一致,所以行为差异只能来自权重本身。这比任何“我们优化了解码策略”的说法都更可信,因为它可被外部证伪——你自己拿旧文件和新文件跑同一组提示词就能看到。
第二个证据是罗马场景的三日对比图,它把“质量演化”做成了同一提示词、同一视角下的横向切片:
这里有个容易被忽略的细节,值得单独点出来:README 里画廊的十张成品图并不是 10 月 27 日之后的产物。这十张图在仓库里以 SVG 形式内联了原始 PNG,PNG 的tEXtCreation Time元数据写得很清楚——九张拍摄于 2026 年 10 月 25 日 02:11–02:35(-03 时区),日落村庄那张更早,是 10 月 23 日 16:14。
也就是说,画廊证明的是检查点之前的质量下限已经可用;它不能用来支撑“10 月 27 日之后更好”这个论断——那个论断只有roma3.png和 README 的定性描述在支撑。这个区分很重要:一个认真的技术判断,必须知道自己在引用哪一段证据。
三、把一次成型率量出来:直接沿用生产线参数
官方没有公布数字,所以这件事只能自己做。好消息是仓库把该固定的一切都固定下来了,你只需要搭一个最小抽样台。
第一步:锁定参数。参数直接抄 generation_config.json,不要自己发明:
| 参数 | 仓库默认值 | 备注 |
|---|---|---|
do_sample | true | 默认就是采样,不是贪心 |
temperature | 0.6 | README 接受区间 0.6–0.8 |
top_p | 0.95 | README 接受区间 0.90–0.95 |
top_k | 20 | README 接受区间 20–40 |
repetition_penalty | 1.0 | 即关闭重复惩罚,不要“顺手加一点” |
eos_token_id | [248046, 248044] | 两个终止符 |
这里有两个坑必须提前说明,否则你的统计会统计到运行时而不是模型:
repetition_penalty是 1.0,也就是不启用。很多本地推理前端会自作主张给代码任务设 1.05–1.1。对自然语言这是去重,对长代码文件这是改名机——它会把重复出现的box、mesh、group惩罚成box2、meshB,直接制造出 README「Known issues」里点名的那类重复/错位变量。统计一次成型率时把它固定为 1.0。eos_token_id是两个 token。如果你的运行时只在一个终止符上停,输出会多吐一段,或者把<|endoftext|>之后的残片一起塞进 HTML。<script>之后多出来的东西里只要有一个非法字符,页面就整块挂掉——这是harness 的 bug,不是模型的 bug。
第二步:固定提示词与种子。用 README 里现成的系统提示,它本身就是最强的结构约束:
Use only the Three.js core ES module from https://cdnjs.cloudflare.com/ajax/libs/three.js/0.160.0/three.module.min.js. Do not import addons or legacy examples/js paths; implement any camera controls directly inside the HTML.然后是采样脚本。下面这段跑在 LM Studio / llama.cpp 的 OpenAI 兼容端点上,同一提示词 × 不同 seed,输出全部落盘:
# sample_once.py —— 固定参数,只让 seed 变化 import json, pathlib, requests SYSTEM = open("prompt/system.txt").read().strip() OUT = pathlib.Path("samples"); OUT.mkdir(exist_ok=True) def gen(tag, prompt, seed): r = requests.post("http://127.0.0.1:1234/v1/chat/completions", json={ "model": "qwen3.5-4b-3d-coder", "messages": [ {"role": "system", "content": SYSTEM}, {"role": "user", "content": prompt}, ], "temperature": 0.6, "top_p": 0.95, "top_k": 20, "repeat_penalty": 1.0, "seed": seed, "max_tokens": 16384, }, timeout=600).json() text = r["choices"][0]["message"]["content"] # 模型偶尔仍会带 markdown 围栏,剥掉后再当作 HTML 使用 if text.lstrip().startswith("```"): text = text.lstrip().split("\n", 1)[1].rsplit("```", 1)[0] (OUT / f"{tag}-seed{seed}.html").write_text(text, encoding="utf-8") return len(text) PROMPTS = { "canal": open("prompt/canal-lock.txt").read(), "atc": open("prompt/atc-tower.txt").read(), "aerie": open("prompt/dragonrider-aerie.txt").read(), } for tag, p in PROMPTS.items(): for seed in range(1, 6): print(tag, seed, gen(tag, p, seed))第三步:判定。判“成功”不能靠肉眼看截图,否则你又会滑回主观评分。三个客观条件同时满足才算通过:
- 没有
pageerror,没有console.error(THREE.WebGLRenderer: Context Lost、xxx is not defined都会在这里露出来); - 页面里存在
<canvas>且尺寸非零; - 画面不是纯色。用 Puppeteer 直接对 canvas 元素截图,再统计颜色多样性——纯黑屏和“只画清屏色”的失败样例会在这里被一键筛掉。
// check.js —— 对 canvas 截图做颜色多样性统计 const puppeteer = require('puppeteer'); const sharp = require('sharp'); async function check(file) { const browser = await puppeteer.launch({args: ['--use-gl=swiftshader']}); const page = await browser.newPage(); const errors = []; page.on('pageerror', e => errors.push(e.message)); page.on('console', m => m.type() === 'error' && errors.push(m.text())); await page.setViewport({width: 1280, height: 800}); await page.goto('file://' + file); await new Promise(r => setTimeout(r, 4000)); // 等待首帧与贴图/几何构建完成 const el = await page.$('canvas'); if (!el) { await browser.close(); return {ok: false, why: 'no canvas'}; } const {data, info} = await sharp(await el.screenshot()) .resize(160, 100).raw().toBuffer({resolveWithObject: true}); const uniq = new Set(); for (let i = 0; i < data.length; i += info.channels * 4) { // 抽稀 uniq.add((data[i] << 16) | (data[i + 1] << 8) | data[i + 2]); } await browser.close(); const ok = errors.length === 0 && uniq.size > 64; // 阈值按场景复杂度调 return {ok, why: errors[0] || `uniq=${uniq.size}`}; }把 15 个样本的ok加起来除以 15,就是这一次运行的pass@1。关键在于同一批提示词、同一批 seed、同一套参数,分别在旧检查点和新检查点上各跑一遍——只有这样,你得到的才是“检查点差异”,而不是“我这次心情好,提示词写得更细”。
顺带一个必须记住的开关:LM Studio 里要关掉 multi-token prediction(MTP)。README 已经用加粗标了这条,但它给的是结论,不是原因。原因在仓库文件里,我们留到第五节说。
四、一次成型之后,剩下的活还有多少
README 的「Known issues」只有一句话,但信息量极大:
The bugs observed so far have generally involved duplicate variable names, rather than major structural problems.
翻译成工程语言:失败模式集中在“局部命名冲突”,而不是“结构建错”。这句话决定了返工的性质——你拿到的不是一堆需要重画的几何体,而是一份骨架正确、只需改几行的代码。这也是为什么“一次成型率”一旦上来,整体体验会产生质变:剩下的工作从“重新生成”变成了“修三行”。
但为什么一个专门训练的模型会反复栽在变量名上?把系统提示和交付形态放在一起看,答案是结构性的:
- 禁止 addons、禁止旧版
examples/js路径,意味着相机控制、轨道交互都得在同一个文件里手写。手写就意味着大量let/const声明挤在同一个顶层作用域里; - 自包含单文件,意味着不能拆模块、不能用命名空间隔离,所有函数共享一个巨大的作用域;
- 场景规模大(针脚阵列、斗拱、栏杆立柱这类重复结构天然需要循环与工厂函数),意味着命名空间会被反复使用。
三者叠加,就是「作用域膨胀 × 长上下文」。所以这类 bug 不是"模型不够聪明",而是交付形态本身在惩罚命名。这也解释了一个看起来矛盾的现象:细节密度已经相当高的场景(比如前面那张插槽图)可以一次生成成功,而结构更简单的场景偶尔会因为一个重名的group直接黑屏。
在这个认知下,“剩下的活”可以分成四类,代价差别很大:
| 类别 | 典型症状 | 处理方式 | 代价 |
|---|---|---|---|
| 阻塞级运行时错误 | xxx is not defined、重复声明、黑屏 | 改一两行,重跑 | 低,但必须有人看一眼 |
| 约束违规 | 偷偷 import 了 addons / examples 路径 | 删掉并补手写控制逻辑 | 中,可能牵动相机 |
| 视觉级缺陷 | 光照平、材质糊、构图拥挤 | 调提示词(布局段落写细) | 中,需重新采样 |
| 性能级缺陷 | 几何体数量爆炸导致帧率崩 | 合并几何、减少 draw call | 高,建议人工重写该段 |
换句话说:10 月 27 日那个检查点解决的是第一类的发生率,而不是把四类全清了。README 那句“some scenes can still require another attempt or a correction”就是这个意思,而且它诚实地把“重试”和“修正”并列了——这两种成本本来就不是一回事。
五、一个 4B 凭什么撑住长文件:配置里的几个数字
打开 config.json,能读到比模型卡更有意思的东西。先看总量:
"metadata": { "total_parameters": 4539265536, "total_size": 9078531072 }来自 model.safetensors.index.json。四点五亿?不,是 45.39 亿参数、9.08 GB(FP16),分四个分片。所以名字里的“4B”是基座规格,实际含视觉塔后是 4.54B——这个差异在你算显存预算时会体现出来。
再看层结构,这是它能吃下长文件的关键:
- 32 层,其中 24 层是
linear_attention,只有 8 层是full_attention,full_attention_interval: 4,也就是每四层插一个全注意力层。权重索引里的计数完全对得上:linear_attn张量 216 个(24 层 × 9),self_attn48 个(8 层 × 6)。 - 全注意力层是
num_attention_heads: 16/num_key_value_heads: 4,4:1 的 GQA;head_dim: 256。 partial_rotary_factor: 0.25,只有四分之一的维度加旋转位置编码;mrope_interleaved: true,mrope_section: [11, 11, 10]——这是多模态基座留下的时间/高/宽三分位置编码,在文本代码任务里主要退化为普通位置信号。max_position_embeddings: 262144,即 256K。
算一笔账:每 token 每层 KV 是2 × 4 × 256 × 2B = 4 KB,只有 8 个全注意力层需要缓存,所以每 token 约 32 KB。跑满 256K 上下文理论上要 8 GiB KV——对一个 9 GB 的 FP16 权重来说这仍然不便宜,但相比 32 层全注意力(每 token 128 KB、满配 32 GiB)已经是数量级的差别。这就是“24 层线性注意力”在长代码生成场景里的真实价值:它让“一次吐完一个几千行的场景文件”在消费级硬件上成为可能,而不是一个理论指标。
然后是那个 MTP 陷阱的根因。text_config里明明白白写着:
"mtp_num_hidden_layers": 1, "mtp_use_dedicated_embeddings": false但把 353 + 146 + 146 + 78 个张量名全过一遍,没有任何一个以mtp开头的键。也就是说,配置声明了 MTP 分支、权重文件里却没有对应的参数。运行时如果按 config 去实例化这一层并启用多 token 预测,它拿不到权重,行为就退化为乱码、截断或直接无响应。
社区里流传的“LM Studio 必须关 MTP”因此不是一个玄学偏方,而是配置与权重不一致时的必然结果,而且它和量化格式(Q4_K_M/Q8_0)毫无关系——两个 GGUF 都会中招,因为它们源自同一份权重与同一份配置。同理,如果你用transformers自己搭推理,看到architectures是Qwen3_5ForConditionalGeneration(一个条件生成类),也别急着去找 MTP 的加载逻辑——按 generation_config.json 的默认值走,反而最稳。
六、把“一次成型”变成常态的操作清单
把上面所有事实收敛成一份可以直接照做的清单:
- 参数照抄,不要发明。
temperature 0.6、top_p 0.95、top_k 20、repetition_penalty 1.0。想探索就把 0.6–0.8 / 0.90–0.95 / 20–40 当区间,别把 repeat penalty 调到 1.05 以上。 - 关掉 MTP。无论 LM Studio 还是自建服务,只要你的运行时认 config 里的
mtp_num_hidden_layers,就得关。这是配置与权重不一致导致的,不是模型缺陷。 - 系统提示保持那两句不动。锁死 Three.js 0.160.0 核心 ES 模块、禁止 addons、控制逻辑写在 HTML 内。它不是风格建议,而是把“import 失败”这一类整屏黑从可能性里删掉。
- 用户提示按“主题 + 布局关系 + 渲染风格”三段写。README 的示例就是这个套路的范本——先给对象,再把空间关系写成可执行的约束(“a broad sheltered yard connects water, shade, and feeding areas through accessible paths”),最后统一收口到渲染语言(“a deliberate 3D voxel model built from visible cubic blocks, with clear depth, readable silhouettes”)。第三段是最容易被省掉、也最影响一次成型率的一段。
- 不要关 thinking。训练数据里每条样本都带完整 thinking trace,说明推理链是分布的一部分。用 chat_template.jinja 时注意:
enable_thinking=false会注入一个空的<think>\n\n</think>\n\n块,等于让模型在“没有推理过程”的形态下生成,这和它被训练的样子不一致。 - 失败要聚类,不要重试到底。记录每次失败的第一条
pageerror,按错误类型分组。如果九成失败都是重名变量,说明你的瓶颈在提示词的空间描述或文件规模;如果失败集中在THREE.* is not defined,说明有人在偷偷用 addons——这两类问题的修法完全不同。
最后回到标题。10 月 27 日那个检查点值得被单独讨论,不是因为它把 3D 生成推到了什么新高度,而是因为它把一个几乎不可用的东西变成了可用的东西:当一次成型率足够高,你才敢把它放进“打开就能看”的工作流里,才敢让不懂 JavaScript 的人去写提示词。至于剩下的那部分活——重名变量、偶发的约束违规、需要人工收尾的性能问题——它们依然存在,而且短期内不会消失。诚实地说,这个模型的当前状态是“一次生成基本能跑,跑起来之后还需要一点收拾”,而这恰恰就是一个 4B 模型在 2026 年应该达到的位置。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考