最近一段时间,我一直在用三台 Mac mini 部署、组网并验证本地开源大模型:选模型、加载模型、提供 API,再看请求怎样进入多机分片,以及长上下文、多轮对话和并发下到底会发生什么。
一开始,我很自然地认为:上下文越大越好,三台 Mac mini 合起来跑一份尽可能大的模型也越好。后来发现,这两个判断只回答了“能不能跑”。长上下文会挤占 KV Cache 和运行时内存;多机分片会带来通信、调度和恢复成本,也不会让并发能力随着机器数量线性增长。
业务真正需要的不是一份很大、很复杂、却反应迟缓的模型,而是一项稳定、快速、延迟可预测的服务。第一次加载为什么慢?首 Token 和后续输出为什么是两种速度?KV Cache 为什么会失效?三台机器都在线,为什么请求还是会卡住,服务又为什么常常需要重启?本文基于一段三机现场复盘这些问题。
本次复盘的三机环境
| 节点 | 设备与统一内存 | 当时角色 | 备注 |
|---|---|---|---|
| 主节点 | M4 Pro,64GB | 常用 API 入口 | 对外 API 常用端口为 52415 |
| 工作节点 A | M4 Pro,48GB | 模型分片与推理 | 参与协同计算 |
| 工作节点 B | M4 Pro,48GB | 模型分片与推理 | 参与协同计算 |
这是一组历史现场配置。三台机器曾以 Exo v1.0.71 的 MlxRing + Pipeline 方式协同推理;入口节点不等于全部计算发生的位置,模型分片、缓存和通信压力仍要以实际 placement 为准。
1. 先有一把尺子:大模型的“快”到底指什么
讨论大模型速度时,不能只看“每秒多少 Token”。它解释不了为什么有些请求迟迟等不到首个输出,也解释不了为什么并发一上来,每个用户都觉得变慢。
从用户体验看,耗时分成冷启动和单次请求两部分:
- 首次加载(冷启动):权重从磁盘、共享存储或远端目录进入运行时,模型实例建立,必要的分片和通信资源就绪。它只发生在模型实例首次建立、重建或从冷态恢复时,不属于每一轮对话,也不是标准 TTFT 的一部分。
- TTFT(Time To First Token,首 Token 延迟):模型已就绪后,每次请求从发出到第一个输出 Token 抵达的时间。它受排队、上下文处理、缓存命中、prefill(预填充)、网络和调度影响。
- TPOT / TBT(每个输出 Token 的间隔):每次请求在首 Token 之后,模型生成下一个 Token 的平均时间。它主要描述 decode 阶段的速度。
Prefill:模型开口前,先把上下文算完
把一条请求交给模型时,里面可能已经有 system prompt、历史对话、项目资料和工具结果。模型不会像人一样“扫一眼”这些内容后立刻回答,而是要先把这整段输入依次经过所有层的计算,为每个 Token 建立后续注意力所需的状态。这段集中完成准备工作的过程,就是 prefill。
它完成之前,模型还不能生成第一个 Token;输入越长,需要计算的内容越多,用户等待首个输出的时间就越长。计算结果会以 KV Cache 的形式保留下来。如果下一轮请求仍保留相同前缀,模型可以复用这些已经算好的状态,只处理新增部分;缓存失效或换到另一份实例后,则需要重新 prefill。
与之相对,decode 是首 Token 出来之后的逐 Token 生成过程。prefill 主要受输入长度影响,decode 主要决定持续输出时的流畅度。把两者混成“模型速度”,会让后续的优化方向完全相反。
一条回答总共生成 N 个 Token 时,可以粗略写成:
端到端时延 ≈ TTFT + (N - 1) × TPOT
首 Token 很快、后续生成很慢,和首 Token 等很久、后续输出流畅,是两类问题。前者多半要看 decode、批处理和计算/通信带宽;后者要看排队、prefill、上下文长度和缓存。系统吞吐量是所有并发请求单位时间内完成的总 Token 数,也不能代替单个用户的体验。
因此,日志里持续出现 prefill 进度时,系统可能只是在处理很长的输入,并不一定已经死锁。
2. 一次本地大模型请求,究竟经历了什么?
先把一次请求的路径展开,后面的故障就更容易放回正确的位置。
| |
这里要分清两件事。
第一,模型权重加载和请求的 prefill不是一回事。模型权重加载,是把模型已经训练好的参数从本地磁盘、共享存储或模型目录读入运行时;多机时,还要把这些参数按分片放到相应节点。它让模型从“尚未服务”变成“可以计算”。prefill 则发生在每一轮请求中:模型为当前输入的所有 Token 建立注意力状态。一个模型已经加载完成,第一次长上下文请求仍然可能很慢。若前缀缓存命中,系统只需从第一个新增 Token 开始处理;若不命中,则要重新处理失配位置之后的整段输入。
第二,API 入口和实际执行位置也不是一回事。多机集群常有一个统一入口,但权重层、缓存和计算可能分布在多台机器上。只盯着入口机器的 CPU、内存或日志,往往会错过真正承压的节点。
对 Agent 来说,这条链路会反复循环。工具调用的结果会回填进上下文,下一轮再查缓存、prefill、decode。它的速度不只取决于模型每秒生成多少 Token,也取决于这条循环能否稳定地跑下去。
3. 第一次部署:模型加载为什么这么慢
本地部署里很容易把“模型目录已经存在”当成“模型已经可用”。实际上至少还有四个状态:模型文件是否在、进程是否启动、runner 是否就绪、真实请求是否已经返回。这里的 runner 指实际加载模型或模型分片、执行推理的进程。
在三机现场中,模型目录规范、离线变量、共享存储可见性和运行进程都曾成为问题。模型可能已经下载完成,但实际 Exo 进程仍指向旧目录;也可能三台机器都能看到目录,却在共享存储并发读取时出现抖动。把“文件存在”当作“模型已加载”,会让排障从一开始就偏离。
首次加载慢通常由几类工作叠加:读取权重、解析模型配置、在各节点建立实例、按拓扑放置分片,以及为推理通信准备资源。若模型大到需要跨机切分,任何一个节点的磁盘、网络或内存压力都会延长这条冷启动路径。
冷启动验收不能只看页面上有没有模型名称。至少要确认:进程是否监听、模型实例是否创建、各 runner 是否就绪、流式请求能否拿到结果。它们分别回答服务在不在、模型是否准备好、集群是否真的协同、用户是否真的能用。
4. 三台机器连起来之后:系统不再只是一台更大的 Mac
多机推理解决的是单机内存装不下的问题,也把一个进程变成了多条相互依赖的链路。
这段实验里,至少要分开看四层:
- API 与界面层:统一入口、Dashboard、OpenAI 兼容请求。
- 控制面:节点发现、心跳、选主和任务调度。
- 推理数据面:节点间传递激活值、执行 collective、完成分片推理。
- 模型存储面:本地 SSD、SMB 或 NAS 上的权重与清单。
它们不一定走同一条网络,也不一定以同一种节奏失败。Thunderbolt 或 RDMA 可用,只能说明推理数据面可能通畅;控制面仍可能因为以太网或发现机制抖动而失联。反过来,Dashboard 能连上,也不能证明分片节点能完成一次 collective。
所以,三台机器在界面上都在线,实际请求却可能停在加载、prefill 或等待状态。此时应看模型实际放在哪些节点、runner 是否就绪、任务卡在哪个阶段,不能把入口节点当成所有计算发生的地方。
5. 为什么会慢:把“慢”拆成不同问题
“慢”至少可能指四件事。
一是冷启动慢。 模型实例尚未就绪,权重、分片或通信资源正在建立。这种慢发生在请求真正进入模型计算之前。
二是 prefill 慢。 输入越长,模型需要为越多 Token 建立状态。长项目上下文、长 RAG 文档和多轮历史都会主要拉高 TTFT。尤其在缓存未命中时,系统必须重新处理整个前缀。
三是 decode 慢。 首 Token 出来后,模型仍要自回归地一个 Token 一个 Token 生成。这里更受模型规模、量化、计算带宽、批处理和跨机通信影响。
四是排队或队头阻塞。 一个长请求占据 runner 后,后来的短请求也可能被迫等待。三机联合分片并不自动等于三条独立的并发通道;如果它们共同服务一份模型,一次请求仍会占用多个节点的协作资源。
对于 Agent,还要把工具执行时间算进来。模型暂停等待本地命令、MCP 服务或外部工具返回时,缓存并不会因此永远保留;下一轮请求是否还能复用前缀,仍取决于缓存是否存活、是否被挤出,以及是否回到原来的计算实例。
这些区分会直接改变排障顺序。持续的 prefill 日志更像长输入计算;多个请求都没进入 runner,要先看调度和实例状态;首 Token 已经很快但流式输出稀疏,就该看 decode 和通信。一次“感觉很慢”,不能同时解释四类问题。
6. KV Cache:让多轮对话变快,也让系统变脆弱
Transformer 在处理输入时,会为每层已处理 Token 保存注意力计算需要的 Key 和 Value。下一轮请求若仍以同一段前缀开始,系统可以复用这些状态,避免从头 prefill。这就是 KV Cache。它复用的不是“意思相近的历史”,而是已经计算过、并且前缀 Token 序列仍能对齐的注意力状态。
它对 Agent 尤其重要:system prompt、项目背景、对话历史和工具记录会在多轮中反复出现。缓存命中时,TTFT 会下降;缓存未命中时,系统又回到完整 prefill。
但缓存并不是一个抽象的“加速开关”。它有三个现实边界:
- 容量边界:在 Apple Silicon 的统一内存中,权重、KV Cache 和运行时空间共享同一份预算。上下文和并发增加时,缓存会与模型本身争夺空间。
- 位置边界:在 Exo 这类多机环境里,缓存可绑定具体 runner 或 instance。统一 API 入口不代表不同实例之间共享缓存。
- 一致性边界:上下文的前缀、Chat Template、工具调用记录或历史顺序发生变化,都可能使缓存无法复用。多轮状态一旦分叉,后续请求可能重新走完整 prefill,甚至在分布式同步上暴露问题。
缓存也有存活边界:热缓存通常存在时间窗口,但真正的淘汰常常由内存压力决定,而不是由一个固定倒计时决定。高峰期或长上下文请求到来时,旧缓存可能在预期时间之前被挤出;下一轮再回来时,体感上就像模型“突然又变慢了”。
在历史现场里,可观察到请求级的 cached_tokens,但没有现成的全局“缓存还剩多少 GB、还能容纳多少会话”的仪表盘。这是一个很重要的边界:单次命中率能告诉我们该请求是否复用了前缀,却不能证明整个集群的缓存容量健康。
遇到 KV Cache 问题,不能只问“缓存为什么爆了”。至少要把下面三种情况分开:
- 容量不够,缓存被淘汰。 典型信号是内存接近上限、日志出现 cache eviction,或者在长上下文和高并发后原本能命中的会话突然全部变慢。此时应先缩短上下文、降低并发或释放其他模型实例,再观察缓存是否稳定。
- 前缀变了,缓存没有命中。 典型信号是新请求仍落在同一实例,但
cached_tokens明显下降或归零。应逐项对照 system prompt、历史消息、工具结果、Chat Template 和模型版本,找出第一个变化的位置。 - 请求换了实例,原缓存不在这里。 典型信号是上下文没有变化,但请求被调度到另一份 instance 或另一组 runner。此时不是缓存内容错误,而是缓存仍留在原实例;需要检查请求路由、placement 和会话亲和策略。
这三种情况的处理方式不同。先判定是哪一种,再改上下文、并发或路由;否则很容易用“清缓存再重试”掩盖真正的问题。
7. 为什么节点会不一致,又为什么总需要重启
多机系统里的“节点不一致”,往往不是某台机器真的消失,而是不同层面对它给出了不同判断:控制面认为它过期,存储面仍可见,某个遗留 runner 还占着内存,客户端却仍在等待旧任务的结果。
这段 Exo 实验曾遇到过选主抖动、API 暂停、遗留 runner、任务取消记录干扰负载判断等现象。固定主节点可以在特定环境下缓解选主抖动,但它不是无代价的修复:主节点故障后,恢复会转为人工责任。
重启之所以看起来有效,是因为它会清空一部分暂态:旧进程、旧 runner、未完成任务、被占用的端口和内存状态。但重启不能修复版本参数混用、共享存储不稳定、RDMA 链路不可用或负载选择逻辑错误。把“重启后好了”当成根因分析,会让同一个问题在下一次压力下重演。
更可靠的做法是区分三种结论:已经在目标机验证的事实、日志或源码支持但尚未闭环的根因、以及只是为了恢复服务的临时绕过。把它们分开写,经验才经得起复查。
8. 从反复救火到可重复的排障方法
经历这些故障后,我把检查顺序固定了下来。
第一步是确认真实运行环境:版本、checkout、实际进程、虚拟环境和模型目录。不要把另一台机器上的路径、旧版本的参数或历史截图当成当前事实。
第二步是确认服务与拓扑:进程是否监听、API 是否可达、节点是否被发现、模型实际放在哪里、runner 是否就绪。
第三步是确认数据路径:模型存储是否可读、网络协商是否正常、数据面通信是否真的可用。一个“已开启”的 RDMA 开关,不等同于所有端口和所有节点都能协同。
第四步才进入真实请求:分别测试流式与非流式、短上下文与长上下文、单轮与多轮、取消与并发。这样才能知道故障处在首次加载、prefill、decode、缓存、调度还是客户端协议。
最后,每次结论都要写清边界。模型、版本、节点拓扑和负载都会变化;比起保存一条看似永远有效的命令,更重要的是保留一套重新确认事实的检查顺序。
9. 长上下文并发的边界:调度也无法把硬件完全吃满
一个并发会话不是一个固定大小的任务。它会同时占用几种不同资源:长输入在 prefill 阶段集中消耗计算;已处理的上下文持续占用 KV Cache;持续生成时又主要消耗 decode 所需的计算和内存带宽;多机环境还要占用节点间通信。不同会话的输入长度、输出长度、缓存命中情况和结束时间都不同,因此很难像整齐的方块一样,恰好把一张卡、一台机器或一组分片节点填满。
这会带来几组无法同时满足的取舍:
- 想提高批处理吞吐,通常要等待更多请求凑成批次,TTFT 就会变长;
- 想保留长上下文的 KV Cache,就会压缩能承载的新会话数量;
- 想把同一会话持续路由回原实例以复用缓存,就会削弱最平均的负载均衡;
- 想让所有请求都立即开始生成,长 prefill、持续 decode 和跨机通信又会相互争抢资源。
调度能减少浪费:把相近请求放进同一批次,优先处理短任务,维持会话亲和路由,或限制单个会话的上下文和并发数。但它不能让有限的显存或统一内存无限容纳 KV Cache,也不能让同一组计算单元同时在 prefill、decode 和通信阶段保持最高效率。并发继续增加后,总吞吐量可能还会上升,单个会话的 TTFT、TPOT 和排队时间却会变差,缓存淘汰也会更频繁。
这不是某个框架“调度不够聪明”,而是长上下文推理的基本边界:高吞吐、低延迟、长上下文和高并发,不能在有限硬件上无限叠加。更强的芯片会把边界往外推,却不会取消这些取舍。
这也说明 Agent 的上下文组织和 Harness 会影响真实成本。请求格式稳定、前缀可复用、工具循环可预测时,缓存亲和和批处理才更容易发挥作用;任意长度的长上下文、频繁变化的前缀和不可预测的工具链,则会让理论吞吐很难落到实际服务上。跑测的目标不是证明硬件能被压到多满,而是找出服务开始失去可预测性的那条线。
10. 真实业务场景如何选择模型
选模型不能只看参数规模或标称上下文长度。模型是否适合业务,至少要看四件事:能不能完成目标任务,长上下文下能不能保持有效理解,部署后首 Token 和持续输出是否够快,以及并发、多轮工具调用和重启之后能不能稳定服务。
公开榜单和基准测试可以用来初筛模型能力,例如通用推理、代码生成或软件工程任务上的表现;但它们替代不了业务验收。真正需要复测的是自己的提示词、工具协议、项目上下文和成功标准。上下文更长、参数更多的模型,未必比能力足够、响应更快、运行更稳定的模型更适合业务。
实际选择时,可以先用目标任务筛选能力,再确认真正需要的有效上下文,最后在目标硬件和目标并发下测试冷启动、TTFT、TPOT、吞吐、内存与恢复行为。业务需要的通常不是最大、最复杂的模型,而是一项稳定、快速、延迟可预测的模型服务。
结语:Model Infra 是一套状态系统
这次本地大模型部署带来的最大变化,是不再把模型理解成一个下载好的文件,或一个已经监听端口的 API。
一次回答的背后,有权重何时进入内存、输入如何被编译为 Token、缓存能否复用、分片落在哪些节点、请求是否排队、工具结果如何回填,以及节点失败后状态如何恢复。它们共同决定模型是“跑起来”,还是“真的可以被持续使用”。
从这个角度看,Model Infra 不是离应用很远的底层名词。它就是每一次“为什么这么慢”“为什么缓存没了”“为什么三台机器在线却不能回答”“为什么重启后又好了”背后的共同语言。
当我们能把这些问题放回同一条请求旅程中,部署经历才不再是一串零散的坑,而会变成理解大模型系统的入口。