Macmini本地模型跑分测试:页面测速与TTS可视化的极简复现指南
💡 给读者的提示:
- 给人类读者:本文记录了两个用于 MacBook 视频录制与演示的纯前端 HTML 页面。涉及到带数字标号的术语(如
[^1]、[^2])代表 AI/系统底层细节,可直接跳过或简单扫一眼,不影响理解核心思路。- 给Claude Code 等AI代码助手:请严格遵照文中的数据结构、网络拓扑、流式缓冲解析逻辑与 CSS 定位约束进行 100% 精确复现。
工具一:本地大模型测速终端
本机跑大模型对话,用的是Ollama和一个叫omlx的本地推理服务,两个都装着。要录视频演示“这个模型在这台机器上有多快”,需要一个能实时显示三个关键指标的界面:首字延迟(问完问题多久开始回答)、生成速度(字往外冒得快不快)、模型加载耗时(模型从没准备好到能用要多久)。这不是一个日常使用的产品,是一次性的录屏道具——没有登录、没有历史记录,打开就能用,关了就没了。
两套后端,一套界面
Ollama和omlx是两个各自独立的软件,各自定义了一套自己的接口,谁也不认谁。但从页面使用者的角度看,不管选哪一个,要做的事情都一样:先问它“你现在有哪些模型能用”,选中一个开始聊天,需要的时候把某个模型手动塞进内存(预热)或者踢出去(卸载)。有点像去不同银行的柜台办业务——流程、单据、系统各家都不一样,但你要做的事情永远是那几件:取号、填单、办完走人。页面顶部有个下拉框负责切换后端,切换之后同一套界面、同一套按钮,用户完全感觉不出背后连的是哪一套系统——这层“翻译”是靠页面内部把两边不同的接口调用统一封装起来实现的。
下面这张表是两边具体怎么“问”的技术对照,照着抄就行,不需要死记可以跳到下一节:
| 用途 | Ollama | omlx |
|---|---|---|
| 拉模型列表 | GET /api/tags→.models[].name |
GET /v1/models→.data[].id |
| 补模型详情 | POST /api/showbody{model:n}→.details.family/parameter_size/quantization_level |
无,spec直接标“MLX” |
| 对话(流式) | POST /api/chat,NDJSON逐行 |
POST /v1/chat/completions,SSE(data:前缀,[DONE]结束) |
| 取增量文本 | j.message?.content |
j.choices?.[0]?.delta?.content |
| 思维链 | j.message?.thinking(独有) |
无 |
| 预热 | POST /api/generatebody{model,prompt:"",keep_alive:"30m"} |
POST /v1/models//load |
| 卸载 | 同上,keep_alive:0 |
POST /v1/models//unload |
| 显存驻留 | GET /api/ps→.models[].size_vram |
GET /v1/models/status→.models[].loaded |
Ollama那边多一道手续:光问“有哪些模型”只能拿到名字,分不出哪些是真的能拿来聊天的模型、哪些是“嵌入模型”(一种只把文字转成一串数字、不会真的回答问题的模型)。所以还要对每个名字再多问一句“你支持对话吗”,靠这一步把嵌入模型从列表里滤掉。
三个指标是怎么测出来的——这是最容易做错的地方
后端接通了,模型也选好了,接下来才是这个页面真正的看点:右上角那几个不断跳动的数字。说人话,这三个指标分别是:问完问题到蹦出第一个字,中间等了多久(首字延迟);字往外冒的速度快不快(生成速度);模型从“没准备好”到“能用”花了多久(加载耗时)。听起来直白,但测准它们,中间有两个真实踩过的坑。
首字延迟最好算:发请求前记一个时间点,等到流式响应里真正冒出第一个字的那一刻再记一个时间点,两者相减、换算成秒,就是首字延迟。
生成速度稍微绕一点,因为用了两档算法,服务端给的准确值出来之后会覆盖前端自己估的值:
- 前端会先自己估一个:每收到一个字就计一次数,每100毫秒算一次“目前收到的字数÷已经过去的秒数”,这个数字图的是“看着在跳”的录屏效果,不是精确值。
- 等到这一轮回答说完了,Ollama会在流式响应的最后一行附带两个真实数字:生成了多少个token、实际花了多少纳秒。用这两个数字重新算一次,就是权威的生成速度,直接覆盖掉前面前端自己估的那个数字,界面上还会标一个“服务端实测”的小角标,告诉观众这个数字不是估的。
这里有个源码注释里专门标出来的真实坑:处理流式数据是一行一行读的,但读到最后,缓冲区里剩下的最后一小段文字,可能还没等到换行符就已经是全部内容了——而这一段,往往正好就是带着“权威生成速度”数据的那一行。 如果收尾时漏掉这一步,界面上的生成速度就会永远停在前端估算的那个不准的数字上,跟Ollama自己后台记录的真实值对不上:
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop(); // 最后一段可能不完整,先留着
for (const raw of lines) handle(raw);
}
handle(buf); // 收尾:把最后一行残留吃掉,不然权威数字永远漏
模型加载耗时这个数字也有个小机关:只有当加载花的时间超过0.3秒才会显示出来。原因是——就算模型已经在内存里“热”着了,系统汇报的加载耗时也不会是严格的0,而是几毫秒的噪声。如果不设这个门槛,界面上每聊一轮天就会闪一下“模型加载中”,观众会误以为每次都在重新加载模型,其实只是噪声在捣乱。
预热与卸载:把“加载”和“生成”拆开测
横向对比多个模型时最容易翻车的地方是:一个模型是热的(刚测过,还在内存里),另一个是冷的,两边的生成速度就没法比了——因为冷启动那几秒到十几秒,模型在做的事情是“把自己加载进内存”,跟“回答问题”完全是两回事,混在一起测出来的速度是失真的。页面提供两个按钮解决这件事:
- 预热:发一个空白问题(不生成任何实际内容),让模型先加载进内存并常驻半小时。之后再测,测的就是纯生成速度。
- 卸载:把模型从内存里踢出去,下一次运行就一定是冷启动,用来单独演示“加载一个模型要多久”这件事。
顺带一提:上下文长度(也就是模型一次能“记住”多少字)改了之后,模型会按新长度重新加载一遍,显存占用也会跟着大幅波动——上下文调到最大档时,能吃掉50GB以上的显存,这个数字本身在视频里就很有说服力。思考模式开关关掉后,某些模型(比如Qwen3.x系列)不会再先吐一大段“思考过程”再回答,直接给结果,感知速度会快很多。
serve.py:全篇最有意思的一处设计
界面上的功能聊得差不多了,但还有一个问题——这东西要是只能在跑Ollama的这台Mac自己身上打开,用处就打了对折。问题是这样的:页面里如果直接写死“去连本机的Ollama”,双击HTML在Mac自己身上打开完全没问题;但如果想在iPad上打开同一个页面(比如录屏时想演示“任意设备都能连”),iPad浏览器里访问的“本机”指的是iPad自己,不是真正跑着Ollama的这台Mac mini,必然连不上。而Ollama出于安全考虑,只允许“本机自己”访问,不能直接把它开放给局域网里的其他设备。
解法是:serve.py除了当一个普通的静态文件服务器,顺带做了一层“传话”的活——它自己既能被局域网里任何设备访问,又知道怎么去找到本机上真正的Ollama和omlx。iPad访问的其实是这台Mac的IP地址,Mac自己的这个小程序接到请求后,转手再去问真正的Ollama要答案,答案原样传回给iPad。iPad全程不需要知道“Ollama”这回事,只需要知道这台Mac的地址。
技术上,serve.py维护一张转发表:
UPSTREAM = {"/ollama/": "http://127.0.0.1:11434/", "/omlx/": "http://127.0.0.1:8811/"}
页面自己会判断当前是“直接双击打开的”还是“经由这个小程序打开的”,来决定是直连本机还是走转发:
const LAN = location.protocol !== "file:";
const OLLAMA = LAN ? "/ollama" : "http://127.0.0.1:11434";
const OMLX = LAN ? "/omlx" : "http://127.0.0.1:8811";
这里有个关键约束,一旦做错,整个体验就毁了:转发必须“一行一行”实时进行,不能等对方把话说完了再一次性转达。 大模型的回答是一个字一个字蹦出来的(这也是终端界面打字机效果的来源),如果转发程序偷懒,攒够一大段数据再一次性发出去,打字机效果就会变成一顿一顿地“甩”出一大段字,观感全毁:
for line in up:
self.wfile.write(line)
self.wfile.flush()
其余是一些容易被忽略但很重要的工程细节:错误信息要原样透传(后端报什么错,界面上就显示什么错,不要被代理这层吞掉或者改写成一个笼统的“出错了”);服务器要支持并发处理多个连接(不然一个人在聊天,另一个请求会被卡住进不来);启动时会自动列出这台机器所有可用的局域网地址,方便直接挑一个念给别人连。这整个小程序的定位很明确——录完视频关掉就完了,不是要长期开着的服务,脚本自己也在启动提示里写明了“开着的时候,局域网里任何人都能通过它操作你的本地模型”,提醒自己用完随手关掉。
界面长什么样
页面自上而下四块:
- 顶栏:切换后端、选模型(现查现填,不是写死在代码里的,这样以后本机装卸模型不用改代码)、调上下文长度、预热/卸载按钮、思考模式开关、字号调节、深浅主题切换、清屏。
- 数字看板:整个界面的主角,四个大字号的格子分别显示首字延迟、生成速度、已生成字数、模型加载耗时——字号特意做得很大,为的是录屏时一眼就能看清。
- 显存驻留条:告诉你现在内存里正驻留着哪个模型、占了多少显存,横向对比多个模型时用来确认“起跑线一致”(两个都是热的,或者都是冷的,不能一冷一热)。
- 终端主体:像真的命令行一样,一问一答滚动显示,思维链单独用一种颜色区分开,光标会像真终端一样闪烁。
底部有14个预设的问题按钮,点一下直接把对应的问题发出去,方便录视频时不用现场打字、也不会因为临场想不出好问题而冷场。这14个不是随便凑的例句,每一条都盯着一个具体的能力或者一种容易翻车的场景,凑在一起大致能把“这个模型强在哪、弱在哪”摸个遍:
| 场景 | 要看的是什么 |
|---|---|
| 打招呼 | 最短的输入,专门看首字延迟这一个指标 |
| 中译英 | 最基础的通用翻译能力 |
| 英翻泰 | 翻译方向反过来,专门试某个内置指令就是“翻成泰语”的翻译模型 |
| 写代码 | 输出格式够不够稳定,会不会写着写着就乱了 |
| 改bug | 既要读懂一段代码,又要定位出问题在哪,是读+推理的组合 |
| 数学推理 | 观察思维链——像deepseek-r1这类模型,会先自己叨叨一大段推理过程再给答案 |
| 长文生成 | 考验持续输出的稳定性,不同模型的生成速度差距在这条最容易看出来 |
| 长文摘要 | 输入长、输出短,考的是“读得进去”而不是“写得出来” |
| 超长输入 | 大约1200字的长输入,专门配合上下文长度那个下拉框,对比4K/32K/128K几档设置下模型的表现差别 |
| 超长翻译 | 翻译模型最吃重的场景:输入输出都很长,最消耗资源 |
| JSON输出 | 考指令遵循能力,小模型最容易在这种“必须按格式输出”的要求上翻车 |
| 表格排版 | 让模型输出Markdown表格,小模型经常把表格排乱 |
| 中文创作 | 考中文语感,底子是英文语料训练出来的模型这条容易露怯 |
| 角色扮演 | 考人设能保持多久不崩 |
这些预设问题的内容特意都选了跟本机配置、个人信息完全无关的中性话题——因为这些字会直接出现在录屏画面里,模型是本地跑的不代表画面里出现的文字内容就适合被别人看到。
主题是这个页面比较讲究的一处细节:深色主题是经典的绿字终端配色;浅色主题不是随手选的配色,而是刻意挑了一套经典的浅色配色方案,再把强调色换成了红白机(Famicom)配色——两者的米色底子几乎撞色,不是巧合。而且浅色主题的正文颜色特意压得比原版配色更深,因为原版那种偏低对比度的配色在屏幕上看着舒服,但录屏经过视频压缩之后会糊成一片,这里故意牺牲一点“屏幕上好看”,换取“录出来的视频看得清”——这是“设计要考虑最终输出介质”的一个具体例子。
工具二:语音模型实测可视化(TTS生成耗时对比)
第二个工具解决的是完全不同性质的问题:不是要让数字看着在跳,而是要对比两个本地TTS语音合成模型(CosyVoice 3和IndexTTS-2.5)生成同一句话各要多久,做成一个1920×1080的录屏演示页。这里最难的不是技术实现,而是怎么让完全不懂“实时倍率”这个概念的观众,一眼就看懂“哪个模型更快、快多少”——正常人不会关心一堆小数点后四位的数字,但会看得懂两根进度条谁先冲到终点。
核心的可视化比喻——这是整个页面的灵魂
源码顶部的注释把这个比喻写得很清楚,直接引用过来:
跑道X轴 = 墙钟时间,两条跑道共用同一根时间轴波形长度 = 生成出来的音频有多长游标跑多远 = 生成这段音频花了多久游标冲过波形末端的那一截,就是“比实时慢出来的时间”——不用解释,看那截斜纹有多长就完了
说得再直白一点:两个模型各占一条跑道,跑道里画着一段真实的语音波形——波形有多长,代表这句话本身念出来要多久。同时有一根游标,跟着真实的生成耗时往前跑。因为生成永远比“念这句话”本身花的时间更长(两个模型都做不到实时生成),游标注定会跑过波形的终点。游标跑过波形终点之后,多跑的那一截路,用斜纹标出来——这一截斜纹有多长,直观地就是“这个模型比正常说话慢了多少”,完全不需要用文字解释什么是实时倍率。
实测数据和页面代码是分开的
工程上的一个小习惯:所有实测出来的数字(生成耗时、音频长度、模型体积……)都放在源码最上面一个变量里,跟下面几百行负责画图、动画的代码完全分开。以后测了新模型、或者在另一台机器上重新测一遍,只需要改这一小块数字,不用去碰任何渲染逻辑:
const RUN = {
text: "这是TuTu在本地跑的一段生成语音测试,全程离线,不花一分钱~~",
chars: 29,
date: "2026-08-22",
models: [
{ id:"cosy", name:"CosyVoice 3", vendor:"阿里",
note:"0.5B · 18 种中文方言 · 零样本音色克隆",
size:"4.6 GB", load:11.31,
gen:7.598, audio:6.440, rtf:1.1799, sr:24000,
wav:"cosyvoice.wav", accent:"#2b7fff" },
{ id:"index", name:"IndexTTS-2.5", vendor:"B站",
note:"零样本声音克隆 · 官方按 N 卡 6GB 显存设计",
size:"5.5 GB", load:null,
gen:12.089, audio:6.060, rtf:1.9947, sr:22050,
wav:"indextts.wav", accent:"#f5325b" }
]
};
这份数据来自本机跑的一个测速脚本:两个模型用同一句测试文本、在同一台机器上测,都提前预热过(第一次运行时的额外开销不计入生成耗时,避免“冷启动”污染结果)。页面拿到这些数字之后自己算结论:用较慢模型的生成耗时除以较快模型的生成耗时,得出“CosyVoice快了大约1.6倍”这句结论;另外也按实时倍率单独算了一次比值(约1.69倍),因为两个模型生成出来的音频长度本身不完全一样,两种算法算出的倍数不是一回事,页面把两个数字都摆出来,不含糊其辞。
画面上的波形也不是装饰性的假图案,是真的把两个wav音频文件解码出来、取真实的声音强弱画出来的,而且两条波形按照各自的最大音量统一“拉平”过——不这么做的话,其中一个模型如果录得比较响,波形看起来会显得“更大”,但那只是音量的差异,跟速度快慢没有关系,容易误导观众。
固定1920×1080再整体缩放,不用响应式布局
源码注释原话:“录屏页不能靠视口比例自适应:字号一浮动,不同分辨率下排版就变了。固定设计尺寸再整体缩放,1080p、4K录出来构图完全一致。”
具体做法是:页面主体固定死是1920像素宽、1080像素高的一块内容,不管浏览器窗口实际多大,都靠JS算一个缩放比例,把这一整块内容按比例缩小或放大,塞进当前窗口——字号、间距、圆角这些细节的相对比例,永远跟设计稿保持一致,不会因为窗口变了就走样。
居中这件事也有讲究:如果用常见的弹性布局(flex/grid)去让这块1920像素宽的内容居中,一旦浏览器窗口比1920窄,弹性布局会把这块内容当成“装不下、溢出的东西”,直接顶到左上角,而不是居中显示。这里改用了另一种定位方式(先把内容的左上角挪到屏幕正中央,再靠一个“往回退半个自身尺寸”的动作精确对齐中心点),这种方式跟“整体缩放”互不冲突,才能保证不管窗口多大,内容永远精确居中。
交互怎么设计的
人话版的行为是这样:点“开始”之后,两条跑道同时开始计时,谁先生成完谁先“冲线”(有个小动画提示一下),两条跑道都跑完之后,下面会浮现出“谁快多少倍”的结论文字。点“试听”按钮可以听到两个模型真实生成出来的语音,这个和上面的计时动画是两件独立的事——可以一边看动画跑,一边点试听听声音,互不干扰。键盘上空格键控制开始/重置,R键强制重置,数字1/2/4切换播放速度倍数。
实现细节:每条跑道有“未开始/进行中/已完成”三种状态,驱动对应的视觉变化(比如完成时卡片边框会变色、有个短暂的高亮闪烁);计时用的是真实流逝的墙钟时间乘以当前选的播放倍速;试听功能走的是音频文件自己的真实播放进度,跟上面说的“计时动画”是两套完全独立的时间轴,故意这么分开设计,是为了让“看动画”和“听声音”这两件事可以自由组合。
直接双击打开为什么会出问题
浏览器出于安全考虑,双击直接打开本地HTML文件时,会拦掉网页读取同目录下其他文件(比如wav音频)的请求,也没法用某些播放相关的功能——所以这个页面如果直接双击打开,波形会是假的占位图案,点试听也听不到声音。页面自己检测到这种情况后,会在底部弹出一条提示:
直接双击打开的(file://),浏览器会拦掉音频读取——波形是占位的、试听也不出声。请用
./serve.command起服务后从http://127.0.0.1:8022打开。
即便音频真的读取失败,页面也留了个兜底方案:自动生成一个形状随机但看起来还算合理的假波形,保证哪怕音频文件丢了,整个演示流程依然能跑起来,只是波形不是真的。
要正常打开,得用同目录下的serve.command——双击运行它,会在本机起一个最简单的静态文件服务器,把页面变成用http://地址打开而不是直接双击。这个脚本特意把服务开放给了整个局域网(不只是本机自己),因为这个页面的使用场景就是要能从iPad、手机或者别的电脑上打开来演示;同时它足够安全——因为它只是把这几个文件原样发出去,不代理、不连接任何本地服务、不涉及密钥,用完关掉终端窗口,这个临时服务也就随之消失了。
最后
这两条都是“拿真实数据说话”的路子——不摆假动画,让代码自己去问接口、自己去解码音频文件,照实画出来。以后本机再装新的推理后端,或者又测了新的TTS模型,理论上应该只要改一小块数据、接一下新接口就能接着用,不用重新搭一遍。想着以后本机跑的模型攒得够多了,说不定能凑一个常驻的实时排行榜出来。