数据截至 (上游 commit 3dcf4cad0124)
本地推理运行时:llama.cpp router 与后端分发
30 秒导读: 别的桌面 AI 客户端只管把消息发给某个 API;Jan 还得亲自把模型跑起来。这一章讲的就是那一层:一个常驻的
llama-serverrouter 进程按需装卸模型,一套自动挑选/下载/校验 GPU 后端二进制的流程,加上硬件探测、GGUF 元数据解析、OOM 与后端崩溃的识别上抛。
本章是 Jan — 架构与原理 的第 4 章。骨架见 01-architecture-and-extensions,对话怎么跑完见 02-chat-turn-lifecycle。
1. 这是什么(零基础也能懂)
一句话定义: 本地推理运行时 = Jan 里负责「把一个 .gguf 权重文件变成一个能接受 HTTP 请求的模型服务」的那一层。
为什么它是 Jan 的差异化。 一个纯客户端(套壳)产品只需要一个 API key 和一个 fetch。Jan 要做的事多得多:
| 纯客户端只需要 | Jan 还额外要做 |
|---|---|
| 拿到 API key | 挑一个能在这台机器上跑的 llama.cpp 二进制(CUDA?Vulkan?纯 CPU?) |
| 发 HTTP 请求 | 下载它、解压、校验它依赖的 .so/.dll 都在 |
| 处理流式响应 | 启动子进程、等它 ready、把模型装进显存 |
| — | 显存不够时决定驱逐谁 |
| — | 进程炸了(OOM / CUDA error)要能识别并告诉用户 |
一句话直觉: 把 router 当成一台共享服务器,模型是服务器上的应用。Jan 不为每个应用单独开一台服务器,而是开一台常驻服务器,再用 HTTP 让它 deploy / undeploy 各个应用。
用起来什么样(用户视角): 用户在 Jan 里点一个模型 → 进度条转几秒 → 可以聊天了。底下发生的是:确认 router 活着 → 必要时先踢掉一个旧模型 → POST /models/load → 轮询到状态变 loaded → 开聊。
2. 顶层全景(它大概怎么转)
怎么读这张图: 从上往下是三层信任边界。上面是 TypeScript 扩展(跑在 WebView 里),中间是 Rust Tauri 插件(有系统权限),最下面是真正吃显存的 llama.cpp 子进程。
┌──────────────────────────────────────────────────────────────┐
│ ① 扩展层 (TS) extensions/llamacpp-extension/src/index.ts │
│ 决策:装哪个模型 / 踢谁 / 用哪个后端 / 生成 INI 预设 │
└───────────────┬──────────────────────────────────────────────┘
│ Tauri invoke("plugin:llamacpp|…")
┌───────────────▼──────────────────────────────────────────────┐
│ ② 插件层 (Rust) src-tauri/plugins/tauri-plugin-llamacpp/ │
│ 持有唯一的 RouterHandle;负责 spawn / 健康等待 / 强杀 │
│ 把 TS 的 load/unload 翻译成 router 的 HTTP 调用 │
└───────────────┬──────────────────────────────────────────────┘
│ spawn + HTTP 127.0.0.1:<随机端口>
┌───────────────▼──────────────────────────────────────────────┐
│ ③ llama-server (router 模式) —— 一个常驻子进程 │
│ 读 router.preset.ini;POST /models/load 时才 fork 出 │
│ 真正持有权重的 worker 子进程 │
└──────────────────────────────────────────────────────────────┘
部件一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
llamacpp_extension | 全部策略:后端选择、驱逐、导入、预设生成 | extensions/llamacpp-extension/src/index.ts:421 |
generatePreset | 把每个 model.yml 编译成 router 读的 INI | extensions/llamacpp-extension/src/preset.ts:75 |
RouterHandle / start_router | spawn 子进程、扫日志等 ready | src-tauri/plugins/tauri-plugin-llamacpp/src/router.rs:78,120 |
LlamacppState | 全局只放一个 Mutex<Option<RouterHandle>> + PID 镜像 | src-tauri/plugins/tauri-plugin-llamacpp/src/state.rs:14 |
post_load / wait_until_loaded | 把「装模型」翻译成 HTTP + 轮询 | .../src/commands.rs:42,75 |
verify_backend_installation | 校验后端二进制的动态库依赖齐不齐 | src-tauri/plugins/tauri-plugin-llamacpp/src/backend.rs:1048 |
get_devices_from_backend | 跑 llama-server --list-devices 解析设备表 | src-tauri/plugins/tauri-plugin-llamacpp/src/device.rs:23 |
主线走一遍(高层,不进代码):
- 应用启动 → 扩展
onLoad把「探测后端 + 起 router」丢到后台 promise 里,不阻塞 UI。 - 用户第一次点模型 →
load()先await那个后台 promise,确保 router 活着。 - 若已装载的聊天模型数达到上限 → 先 FIFO 踢掉最老的一个。
- 调 Rust → Rust 发
POST /models/load→ 轮询/models直到状态loaded。 - 聊天时每次请求前再走一次
ensure_session_ready(幂等),拿到port+api_key,直连/v1/chat/completions。
3. 核心原理(逐个机制)
3.1 单 router 多模型 —— 为什么不是「每模型一进程」
它要解决的小问题: 用户可能同时想留着一个聊天模型 + 一个 embedding 模型,还想随时切换。怎么管这些进程?
两条路的取舍。 老方案(以及 Jan 至今仍在 MLX 分支上用的方案)是每模型一进程:一个 HashMap<pid, Session>,每装一个模型就 spawn 一个 server、占一个端口。llamacpp 侧换成了一个常驻 router:
| 维度 | 每模型一进程(MLX 现状) | 单 router(llamacpp 现状) |
|---|---|---|
| 状态 | HashMap<i32, MlxBackendSession>(tauri-plugin-mlx/src/state.rs:24) | Mutex<Option<RouterHandle>>(tauri-plugin-llamacpp/src/state.rs:15) |
| 端口 | 每模型一个随机端口 | 全局一个端口,所有模型共用 |
| 切模型 | kill 旧进程 + spawn 新进程 | 一次 HTTP POST /models/unload + /models/load |
| 谁决定并发上限 | 扩展自己数 | --models-max 交给 server 兜底,扩展再叠一层软上限 |
| 崩溃面 | 单模型崩溃只影响自己 | router 崩了全挂(所以要 OOM 识别,见 §3.7) |
router 的好处在切换成本:模型 A→B 不需要重新拉起进程、重新协商端口、重新发 API key;而且所有模型共用同一个 port/api_key,前端只要拿一次 SessionInfo 就行(commands.rs:330 load_llama_model 返回的 SessionInfo 里 pid/port 全是 router 的)。
router 的启动参数是一个纯函数。 这个设计让它可单测:
// src-tauri/plugins/tauri-plugin-llamacpp/src/router.rs:89 router_args
"--models-preset", preset_path, "--models-max", models_max,
"--host", "127.0.0.1", "--port", port, "--api-key", api_key,
注意没有 -m / -hf:router 模式下不预加载任何模型,模型来自 preset 文件、按需装载(router.rs:2-6 的模块注释直说了这点)。测试 router_args_contains_required_flags(router.rs:1315)还专门断言 --no-models-autoload 不在参数里 —— 也就是 autoload 是开着的,不预载靠的是 preset 里每个 model 段的 load-on-startup = false(preset.ts:543)。
ready 的判定是扫日志,不是探端口。 start_router(router.rs:496)把 stdout/stderr 都接管,起两个 tokio 任务逐行读,命中 "server is listening on" / "starting the main loop" 等字样就往 channel 里推一个 true;主循环 tokio::select! 等这个信号,同时每 50ms 检查子进程是否已提前退出,超时(默认 60s,可由 LLAMA_ARG_TIMEOUT 覆盖)则 kill 并把 stderr 全文附在错误里(router.rs:679-718)。
关停有三档,按「有多急」升级:
try_graceful_stop_router(deadline) ← 温柔:先问谁在忙
│ GET /models → 挑出 status ∈ {loaded, loading}
│ GET /slots?model=X → 有 is_processing:true 的直接放弃
├─ 无人处理中 → 逐个 POST /models/unload → 轮询到空 → 终止进程 → Ok(())
└─ 到 deadline 还有忙的 → Err((handle, busy)) 把 handle 还给调用方
│
▼
stop_router() ← 中间层:上面失败就直接升级
│
▼
force_kill_router_tree() ← 强杀:先杀 router 再杀它的直接子进程
三个函数分别在 router.rs:763(try_graceful_stop_router)、router.rs:747(stop_router)、router.rs:991(force_kill_router_tree)。
「先杀父再杀子」是有意的。 force_kill_router_tree 的注释写得很直白:router 必须先死,否则它会在你清扫的过程中再 fork 出新的 worker(router.rs:988)。子进程 kill 失败也不当错误 —— router 自己的退出处理会并行回收它们(router.rs:1015)。
还有一条不持有 handle 的强杀路径。 force_kill_router_tree_by_pid(pid)(router.rs:963)只按 PID 扫 sysinfo 杀树、不 wait() 回收。它存在是因为 try_graceful_stop_router 失败时 handle 已经被交还给 state,而退出流程可能在别处;LlamacppState 里那个 router_pid: AtomicU32(state.rs:18)就是为这条路留的镜像,force_kill_router_tree 命令(commands.rs:650)会在 handle 拿不到时退化到按 PID 杀。
退出时的完整握手。 应用要退出 → handle_graceful_exit(src-tauri/src/lib.rs:170)以 1 秒的 deadline 循环调 try_graceful_stop_router;只要还有忙的模型就 emit llamacpp-busy-on-exit 事件、睡 2 秒再试。前端 LlamacppBusyOnExitDialog 弹窗给用户一个「强制退出」按钮,按下去才 force_kill_router_tree + confirm_exit(web-app/src/containers/dialogs/LlamacppBusyOnExitDialog.tsx:69-70)。
3.2 扩展侧的加载协议
它要解决的小问题: UI 上可能同时有三个地方触发「装载模型 X」(用户点了、RAG 要 embedding、上一条消息重发)。不能真的装三次。
思路: 三道闸门,依次是「已经 装好了吗 → 正在装吗 → router 活着吗」。
load(modelId)
├─ findSessionByModel(id) 命中? → throw 'Model already loaded!!'
├─ loadingModels.has(id)? → 直接返回那个 Promise(去重)
└─ performLoad(id) 存进 loadingModels,finally 里删掉
├─ ensureRouterReady() 确保 router 在
├─ evictChatIfAtCapacity(id) 腾位子(非 embedding 才做)
└─ loadLlamaModel(id) → Rust → HTTP
去重靠的是「存 Promise 而不是存布尔」(index.ts:2680-2702 load):
// 示意,非源码 —— 关键在于并发调用共享同一个 Promise
if (this.loadingModels.has(modelId)) return this.loadingModels.get(modelId)!
const p = this.performLoad(modelId, isEmbedding)
this.loadingModels.set(modelId, p)
try { return await p } finally { this.loadingModels.delete(modelId) }
重点看 finally:无论成功失败都清掉,失败后下次调用会真的重试,而不是永久卡在「正在装」。
ensureRouterReady 是「后台启动」与「立刻要用」之间的汇合点。 onLoad 里 router 的启动被塞进一个不 await 的 backgroundInit promise(index.ts:459-497),目的是让 UI 不被网络 IO 和 spawn 卡住。真要用的时候:
// extensions/llamacpp-extension/src/index.ts:2706 ensureRouterReady
if (this.backgroundInit) await this.backgroundInit.catch(() => undefined)
if (!(await this.getRouterInfo())) await this.startRouter()
.catch(() => undefined) 是关键 —— 后台启动失败(比如离线装不到后端)不该让后来的 load 直接炸,而是走下面那次直连重试。
backgroundInit 里还有一个「先跑哪个」的排序决策(index.ts:459-496):
| 情形 | 顺序 | 理由 |
|---|---|---|
| 本地已有可用后端 | startRouter() → configureBackends() → 后端变了才重启 router | 推理立刻可用,不等网络更新检查 |
| 全新安装(没后端) | configureBackends() → startRouter() | 没二进制根本起不来 |
startRouter 是幂等的。 它自己先探测有没有在跑的 router,有就先 stop_router(index.ts:697-706)。这是必需的,因为 Rust 侧 start_router 命令看到 guard.is_some() 会直接返回 "Router is already running."(commands.rs:456-458)。所以「重启 router」在扩展里就等于「再调一次 startRouter」—— 换后端后、改了影响预设的设置后、导入新模型后(index.ts:2601),用的都是这一招。
改设置后的重启是防抖的。 PRESET_AFFECTING_KEYS(index.ts:87,列了 ctx_size/n_gpu_layers/flash_attn 等 28 个键)里的任何一个变化都要重启 router 才生效,但用户拖滑块会连发几十次,所以 scheduleRouterRestart(index.ts:1747)用 600ms 的 setTimeout 把它们合成一次。
发请求那一刻还有一次兜底。 chat() 不信任「之前装过」,每次先 ensureHealthySession(index.ts:2983)→ Rust ensure_session_ready(commands.rs:383)→ 又走一遍 post_load。这之所以不慢,是因为 post_load 对「已经装了」的响应体做了容错:
// src-tauri/plugins/tauri-plugin-llamacpp/src/commands.rs:61
if !body.to_lowercase().contains("already") { /* 才算失败 */ }
load 的「完成」定义是轮询出来的,不是 HTTP 200。 POST /models/load 返回只代表开始装;wait_until_loaded(commands.rs:75)每 250ms 拉一次 /models,读该 model 的 status.value:
| 状态值 | 处理 |
|---|---|
loaded | 返回 Ok |
loading | 继续等 |
unloaded / sleeping 且 status.failed == true | 立刻报错,附上 exit_code |
| 其它 | 只 warn,继续等 |
超时 600s(commands.rs:72)。卸载侧对称:wait_until_unloaded(commands.rs:182)等到状态离开 loaded/loading,超时 30s —— 注释解释了这个数字:preset 的 stop-timeout 默认 10s,30s 是留的余量(commands.rs:174-176)。
findSessionByModel 其实是在问 router,不是查本地表。 find_session_by_model(commands.rs:402)拿 /models 里状态为 loaded 的 id 列表比对,命中就现编一个 SessionInfo 返回。这意味着扩展没有独立的会话状态,唯一真相在 router 那边 —— 这也是下一节能做「对账」的前提。
3.3 FIFO 驱逐:宁可多驱逐,也不破上限
它要解决的小问题: 用户把 models_max 设成 2,已经装了 A、B,现在要装 C。踢谁?什么时候踢?
思路: 踢最老的(FIFO),而且在装 C 之前先踢 —— 不能等 router 自己 OOM 了再说。
扩展维护一个 loadedChatOrder: string[](index.ts:375),每次成功装载就把该 id 移到队尾(index.ts:2734-2735)。但纯本地队列会漂:router 可能因为 OOM 扫荡自己卸了模型,或者用户从别处卸了。所以 evictChatIfAtCapacity(index.ts:2749)每次都先对账:
evictChatIfAtCapacity(incoming)
├─ userModelsMax <= 0 → 直接返回(0 = 不限)
├─ loaded = getLoadedModels() ← 问 router 要真实集合
│ └─ 失败 → 退化用本地 FIFO(注释:宁可多驱逐,也不违反上限)
├─ loadedChatOrder 过滤:只保留 router 也认的、且 ≠ incoming
└─ while (order.length >= userModelsMax) { shift 出队首 → unload }
三个细节值得抄:
>=而不是>。 因为 incoming 还没进队,留出的必须是它的位子。m !== incomingModelId的过滤。 防止把自己算进占用、导致多踢一个。- catch 分 支的取舍。
getLoadedModels()挂了(router 短暂无响应)时不是放弃驱逐,而是拿可能过时的本地队列硬踢 —— 注释原话是宁可 over-evict 也不 violate the cap(index.ts:2756-2757)。破上限的后果是 OOM 崩进程,多踢一个的后果只是下次多等几秒重装。 - 驱逐失败不阻断。
unloadLlamaModel返回success: false或抛异常都只logger.warn,循环继续(index.ts:2768-2781)。
embedding 模型不参与这套账。 performLoad 里 if (!isEmbedding) 才调驱逐(index.ts:2727-2729)。对应地,router 拿到的 --models-max 会比用户设的多 1:
// extensions/llamacpp-extension/src/index.ts:688-693 startRouter
const userModelsMax = modelsMax // 只约束 chat,给 evict 用
const embeddingSlotBonus = embeddingCount > 0 ? 1 : 0
if (modelsMax > 0 && embeddingSlotBonus > 0) modelsMax += embeddingSlotBonus
embeddingCount 是 generatePreset 数出来的(preset.ts:516-517,每遇到 embedding: true 的模型段 +1)。注释解释了为什么 bonus 恒为 +1 而不是按 embedder 个数:RAG 每次请求只 load() 一个 embedder,同时最多装一个(index.ts:683-688)。双上限因此形成:router 硬上限 = 用户值 + 1(留给 embedder),扩展软上限 = 用户值(只管 chat)。
3.4 后端(backend)选择与安装
它要解决的小问题: llama.cpp 的官方发布是几十个二进制变体(win/linux/mac × cuda11/12/13 / vulkan / hip / 纯 CPU × x64/arm64)。用户不该被问「你要哪个」。
四步漏斗。 每一步都在缩小候选集:
① 硬件能力 get_supported_features(cpu_extensions, gpus, os)
→ { avx2, avx512, cuda11/12/13, vulkan, hip }
│
② 平台可选集 determine_supported_backends(os, arch, features)
→ ["linux-cuda-12-common_cpus-x64", "linux-vulkan-…", …]
│
③ 与发布列表交集 listSupportedBackends() = 远程 GitHub release ∪ 本地已装
│
④ 优先级排序 prioritize_backends(candidates, has_enough_gpu_memory)
→ "b7037/linux-cuda-12-common_cpus-x64"
① 的关键是驱动版本比对,不是「有没有 GPU」。 get_supported_features(backend.rs:339)拿每张卡的 driver_version 跟一张硬编码的最低驱动表比:
| OS | CUDA 11 最低驱动 | CUDA 12 | CUDA 13 |
|---|---|---|---|
| linux | 450.80.02 | 525.60.13 | 580 |
| windows | 452.39 | 527.41 | 580 |
数字来源在注释里给了 NVIDIA 的 minor-version-compatibility 文档链接(backend.rs:355);其它 OS 直接返回「CUDA 和 HIP 都不支持」(backend.rs:359)。
② 是一张纯查表(backend.rs:205 determine_supported_backends),按 "{os}-{arch}" 分支往列表里 push,mac 只有 macos-arm64 / macos-x64 两项(Metal 内建,没有变体)。
④ 的优先级会因显存而翻转。 这是整段最巧的一处(backend.rs:536 prioritize_backends):
has_enough_gpu_memory | 优先级顺序(前几名) |
|---|---|
true | cuda13 → cuda12 → cuda11 → hip → vulkan → common_cpus → avx512 → … |
false | common_cpus → avx512 → avx2 → avx → noavx → arm64 → x64 → hip → vulkan |
阈值是任意一张卡 ≥ 6 GiB(index.ts:1352 determineBestBackend)。逻辑很实在:显存不够时装 CUDA 版毫无意义,还不如老老实实用 CPU 的 AVX 指令集 —— 于是 GPU 后端被踢到列表最末。
get_backend_category(backend.rs:612)把杂乱的 asset 名映射成这些类别。有个坑写在注释里:HIP 的判断必须排在 common_cpus/x64 之前,否则 *-hip-common_cpus-x64 会被误判成 CPU 类别(backend.rs:622-624)。
「推荐」只看远程,不看本地。 configureBackends(index.ts:966)算推荐值时特意只用 fetchRemoteBackends() 的结果,注释解释:用户「从文件安装」的自制后端不该污染推荐(index.ts:1002-1009);没有远程数据(离线或关了更新检查)时就不给推荐,而不是硬编一个。
用户偏好的粒度是「后端类型」,不是「版本/后端」全串。 getStoredBackendType() 只存 linux-cuda-12-common_cpus-x64 这半截,再用 findLatestVersionForBackend 去配最新版本(index.ts:1032-1035)。这样自动更新升 build 号时不会丢掉用户「我要 CUDA 不要 Vulkan」的意图。
自动更新链路 handleAutoUpdate(index.ts:1491)→ updateBackend(index.ts:1371)有三个防御:
isUpdatingBackend布尔锁,并发更新直接当 no-op 返回(index.ts:1374-1379)。- 先持久化设置,再改内存 config —— 注释明说是为了任何一步失败时 config 保持一致(
index.ts:1424-1425)。 - 清理旧版本
removeOldBackendVersions包在自己的 try 里,失败只 warn,不让更新回滚(index.ts:1464-1480)。Windows 上前后各插一段setTimeout睡眠,应对文件句柄未释放。
手工安装(installBackend)最麻烦的不是解压,是「摆平三种目录结构」。 一个正则从文件名里抠出 version 和 backend(index.ts:2131-2132,能吃 k_llama-main-b4314-… 这种带前缀的社区构建),解压后要把二进制归一到 build/bin/:
| 归档来源 | 解压后 llama-server 在哪 | 处理 |
|---|---|---|
| Jan 自己的 tarball | 已经在 build/bin/ | 不动 |
| 上游 Linux tarball | 嵌在 llama-bXXXX/ 下 | mv foundDir build/bin |
| 上游 Windows zip | 平铺在根目录 | 先 mv 到兄弟目录 .staging,再 mv 回 build/bin |
平铺那种要绕道 staging,是因为一个目录不能 rename 进自己的子树(index.ts:2195-2200)。整段坚持用一次 mv 而不是逐文件拷贝,注释给了理由:保住相对符号链接链 libggml.so → .so.0 → .so.0.10.0(index.ts:2193-2196)。
installCudaRuntime(index.ts:2238)是补丁式的:上游把 CUDA 运行时 DLL 单独打成 cudart-llama-bin-<backend>.zip,这个方法把它解压进所有同类型已装后端的 build/bin/。
依赖校验(verifyBackendDeps)是全流程里唯一「查而不治」的一步。 链路:index.ts:2816 → verify_backend_installation(backend.rs:1048)→ verify_backend_dependencies(backend.rs:979)→ analyze_out_of_process(deps_analyzer.rs:115)。三个设计点:
- 它跑在子进程里。
deps_analyzer.rs:1-3的模块注释:lddtree/goblin在畸形二进制上会 panic 甚至 segfault,放子进程里崩了不拖垮整个应用。实现方式是拿自己的 exe 再 spawn 一次,带上--internal-analyze-deps标志(deps_analyzer.rs:12,132-136),run_deps_analyzer_if_requested(deps_analyzer.rs:25)在启动早期识别这个标志、干完活直接exit。 - 平台差异被显式关掉。 macOS 整段跳过(注释:dyld 在加载时才解析,lddtree 的 Mach-O 处理在实测中崩过,
deps_analyzer.rs:107-111);Linux flatpak 也直接返回 verified(backend.rs:1064-1071)。 - 只扫 GPU 库、且做「跨二进制并集」。
gpu_backend_keyword(backend.rs:933)按后端名挑关键字(cuda / vulkan / hip),find_gpu_libs找到种子库,分析结果里某个库只要被任一二进制解析成功,就不算缺失(backend.rs:1023-1026)。is_virtual_windows_dll(deps_analyzer.rs:102)另外过滤掉api-ms-win-*这类内核虚拟 DLL —— 它们本来就不在磁盘上。
结果只是 emit 一个 onBackendVerificationFailed 事件;整个 verifyBackendDeps 包在 try 里,失败只 warn(注释:advisory only,index.ts:2834-2838)。调用点还特意排进 requestIdleCallback,让路给 router 和 UI(index.ts:1238-1243)。
3.5 硬件探测
它要解决的小问题: 「这台机器有哪几个可用推理设备、各多少显存」——而且答案必须和 llama.cpp 自己看到的一致。
思路:不自己探,问二进制。 get_devices_from_backend(device.rs:23)直接跑 llama-server --list-devices,30 秒超时,然后解析文本:
Available devices: ← parse_device_output 找这行做锚
CUDA0: NVIDIA GeForce RTX 4090 (24576 MiB, 24000 MiB free)
^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^ ^^^^^^^^^^^^^^
id name mem free
找不到 "Available devices:" 这行就报 DeviceListParseFailed 并把原始输出附上(device.rs:109-114)—— 宁可明确失败也不猜。
探测和推理共用同一套库路径准备。 device.rs:40-52 与 router.rs:541-561 是同一段逻辑:find_cuda_paths() / find_rocm_paths(),若二进制需要 CUDA/ROCm 却没找到就先 warn,最后 setup_library_path 把这些目录注入子进程环境。这保证了「能列出设备」≈「能跑起来」。
扩展侧的 getDevices 加了一层 AMD/Linux 修正。 llama.cpp 走 Vulkan 时会把 AMD 的 UMA(共享内存)报成 device-local,数字虚高;index.ts:3264-3326 因此在 Linux + AMD 时,用硬件插件的真实 VRAM(按 vulkan_info.index → GPU uuid → usage 表)覆盖 mem/free。整段包 try,失败就用原值。
前端的三个 store 各管一段:
| Hook | 存什么 | 文件 |
|---|---|---|
useHardware | 整机 CPU/GPU/RAM 快照 + 轮询开关,persist 到 localStorage | web-app/src/hooks/useHardware.ts:123 |
useLlamacppDevices | 设备列表 + 每个设备的 activated 勾选态 | web-app/src/hooks/useLlamacppDevices.ts:18 |
useVulkan | 一个持久化的布尔开关 | web-app/src/hooks/useVulkan.ts:15 |
useLlamacppDevices 里有个约定值得注意:device 设置为空串等于「全选」,而全部取消勾选写的是字符串 'none'(useLlamacppDevices.ts:45,88)。勾选态变化会直接写回 llamacpp provider 的 settings,而 device 是 PRESET_AFFECTING_KEYS 成员,于是自动触发 §3.2 那个防抖 router 重启。
3.6 模型导入与元数据
它要解决的小问题: 用户给了一个 URL 或本地路径,Jan 得回答一串问题:这文件是合法 GGUF 吗?是 embedding 模型吗?支持视觉吗?能用工具吗?跑得动吗?
导入的流水线(index.ts:2336 import):
校验 modelId ────► 只允许 [a-zA-Z0-9/_-.],且每段不得为 ''/'.'/'..' (防路径穿越)
│
下载/定位 ───────► https:// 的进 downloadItems(带 sha256 + size);本地路径直接用
│ 主模型 model.gguf / 视觉 mmproj.gguf / MTP 草稿 mtp.gguf
│
读 GGUF 元数据 ──► detectEmbeddingFromGgufMeta → embedding?
│ detectMtpLayersFromGgufMeta → MTP 头数?
│ general.name → 显示名(空格换 -)
│
写 model.yml ────► 含 embedding_check_v / mtp_check_v 版本戳
│
startRouter() ───► 重生成 preset,新模型才对 router 可见
下载失败被分成三类,处理完全不同(index.ts:2434-2482):
| 类别 | 判据(错误串包含) | 处理 |
|---|---|---|
| 取消/暂停 | cancelled / aborted / Download cancelled … | emit onFileDownloadStopped 后直接 return,不当错误 |
| 校验失败 | Hash verification failed / Size verification failed … | 先 abortImport 清干净,再 emit onModelValidationFailed |
| 其它 | — | emit onFileDownloadError |
abortImport(index.ts:2628)= 取消下载任务 + 删掉整个模型目录;pauseImport(index.ts:2646)只暂停,注释点明保留 .tmp 分片以便续传(index.ts:2651)。这是两个方法唯一但关键的区别。
元数据回答的四个问题:
- 是不是 embedding。
detectEmbeddingFromGgufMeta(util.ts:190)看架构是否在EMBEDDING_GGUF_ARCHS集合里。判定为 true 会顺带写死pooling: 'mean'、ubatch_size/batch_size: 2048(index.ts:2572-2574)。 - 支不支持多模态。
readMmprojCapabilities(index.ts:814)读 mmproj 的clip.has_vision_encoder/clip.has_audio_encoder。两个都为 false 时退化成{vision:true, audio:false}(index.ts:825)—— 老 mmproj 没这些键,当视觉处理比当废物强。结果在list()里变成模型的capabilities数组(index.ts:1978-1984)。 - 能不能用工具。
isToolSupported(index.ts:3439)判据朴素到有点粗糙:读tokenizer.chat_template,看字符串里有没有'tools'这个子串。 - 跑不跑得动。
isModelSupported(index.ts:3469→ Rustgguf/commands.rs:52)算模型权重 + KV cache + mmproj的总需求,返回三色:
| 结果 | 含义 |
|---|---|
GREEN | 权重 + KV cache 都装得进 VRAM |
YELLOW | 权重进得去但要借系统内存,或 KV cache 装不下 |
RED | 权重连总内存都装不下 |
Apple Silicon(macOS + arm64 + 无独显)走统一内存的单独分支(gguf/commands.rs:195 check_apple_silicon_compatibility),Rust 侧配了十来个不同内存档位的单测(gguf/commands.rs:277-391)。
validateGgufFile(index.ts:3484)只拦一种情况:架构是 clip。 因为 CLIP 是视觉编码器、不能当文本生成模型导入,错误文案直接告诉用户这点(index.ts:3497-3500)。
token 计数是估算,不是真分词。 getTokensCount(index.ts:3524)= 文本字符数 / 4 + 图像 token。图像那部分 calculateImageTokens(index.ts:3577)从 mmproj 的 clip.vision.projection_dim 除以 10 得每图 token 数(默认 256),再乘图片数,最后减去图片数 —— 注释说是扣掉那个多余的 <__image__> 占位 token(index.ts:3598)。读不到元数据就退化成固定 256(Gemma 的 siglip 值,index.ts:3605)。
MTP(Multi-Token Prediction,多 token 预测,即投机解码)的元数据链路。 它分两种形态:
形态 A:MTP 层内嵌在主 gguf 里
→ detectMtpLayersFromGgufMeta 读 "<arch>.nextn_predict_layers" > 0
→ 默认不开(用户显式勾 mtp: true 才生效)
形态 B:MTP 是一个独立的 draft gguf(mtp.gguf)
→ 导入时一并下载,读它自己的 nextn_predict_layers
→ 单独下了就是要用 —— 直接写 { mtp_model_path, mtp: true }
形态 B 的「默认开」写在注释里(index.ts:2569-2571)。detectMtpLayersFromGgufMeta(util.ts:208)先按 general.architecture 拼精确键,不中再遍历所有以 .nextn_predict_layers 结尾的键。
resolveMtpLayersConfig(index.ts:1862)是带版本戳的惰性回填:model.yml 里 mtp_check_v === MTP_CHECK_VERSION 就直接用缓存值,否则重读 GGUF 并把结果连同新版本戳写回。MTP_CHECK_VERSION/EMBEDDING_CHECK_VERSION 就是两个常量(index.ts:80-81),改动检测逻辑时 +1 即可让所有老模型重新体检。
UI 侧的 MTP 配对在另一处。 web-app/src/lib/mtp.ts 处理「Jan 模型目录里 MTP 草稿混在主模型的 quant 列表中」这个现实:isMtpQuant(mtp.ts:20)用正则 /(^|[-_.])mtp([-_.]|$)/i 保证 mtp 是独立词(不误伤 mtpx),pickMtpSibling(mtp.ts:33)三级降级挑搭档:
① 量化标签完全一致(Q8_0 主模型 → Q8_0-MTP)
↓ 未命中
② 不带量化标签的通用草稿
↓ 未命中
③ 任取第一个
3.7 OOM 与后端崩溃:识别、上抛、止血
它要解决的小问题: llama.cpp 崩溃时不会给你一个漂亮的 HTTP 错误 —— 它往 stderr 吐一行 C++ 报错然后进程就没了。前端只会看到流突然断掉。
思路:在读日志的那一刻就分类。 §3.1 里那两个日志读取任务除了找 ready 标志,还对每行做两次匹配:
// src-tauri/plugins/tauri-plugin-llamacpp/src/router.rs:22 is_oom_line
"erroroutofdevicememory" | "erroroutofhostmemory"
| ("failed to allocate" && "buffer of size")
is_backend_error_line(router.rs:47)覆盖面更广,而且每一类都在注释里写了「为什么要抓它」:
| 模式 | 抓的是什么 | 注释给的理由 |
|---|---|---|
cuda error: / ggml_assert( / ggml_vulkan…error / ggml_metal…error | 后端断言与 API 错误 | — |
devicelost / device lost | GPU 设备丢失 | 请求中途把子进程干掉,不抓就静默(router.rs:60-62) |
terminate called after throwing | 未捕获 C++ 异常 | 同上 |
corrupted size vs. prev_size、double free or corruption、malloc(): 、stack smashing detected … | glibc 堆损坏 / 栈保护 SIGABRT | 例如 mtmd 视频解码路径的原生内存 bug;不分类的话崩溃是静默的,加载会「看起来永远卡住」(router.rs:69-72) |
最后一类特别能说明这套东西是从线上事故长出来的,不是照着规范写的。
匹配到之后走 ErrorCallback,做两件事(commands.rs:460-478):
命中 oom/backend 行
├─ app.emit("llamacpp-router-oom" | "llamacpp-router-backend-error", line)
└─ tokio::spawn(unload_busy_router_models(port, api_key)) ← 主动止血
unload_busy_router_models(commands.rs:244)把所有非 unloaded 状态的模型都 POST /models/unload 一遍。理由很直接:已经 OOM 了,与其让 router 在半死状态里继续挣扎,不如全放掉、让用户重新装。
回调有 3 秒防抖(router.rs:598-606,stdout/stderr 各一份 last_error_at)。一次崩溃通常会连吐十几行匹配行,不防抖前端会被刷屏。
前端不是照单全收。 LlamacppOomListener(web-app/src/containers/dialogs/LlamacppOomListener.tsx:27)两个监听器都先过 hasActiveLlamacppRequest() 这道闸:
// web-app/src/containers/dialogs/llamacppRouterError.ts:9 hasActiveLlamacppRequest
if (useModelProvider.getState().selectedProvider !== 'llamacpp') return false
return app.currentStreamThreadId != null
|| Object.keys(app.abortControllers).length > 0 || …
注释解释了这道闸为什么必要:router 在启动/空闲时也会吐噪声,若无条件上屏,用户在 macOS 上明明选的是 MLX,却会看到 llamacpp 的错误 横幅(llamacppRouterError.ts:5-8)。
命中后做三件事:把错误串盖在最后一条 user 消息的 metadata 上(stampErrorOnLastUserMessage,llamacppRouterError.ts:23,这样刷新页面横幅还在)、clearActiveWork() abort 掉所有在飞的请求并清线程态(llamacppRouterError.ts:43)、写进 useAppState 的 oomError/backendError。线程页据此渲染横幅并禁掉输入(web-app/src/routes/threads/$threadId.tsx:1576-1598)。
4. 深入实现:preset 是这套设计的枢纽
router 本身不认识 Jan 的 model.yml。两者之间的桥是一个 INI 文件,由 generatePreset(preset.ts:75)在每次 startRouter 时重新生成(index.ts:660-665)。
产物长这样(<providerPath>/router.preset.ini):
[*]
ctx-size = 8192
cache-type-k = q8_0
[my-org/qwen3-8b]
model = /Users/x/Jan/data/llamacpp/models/my-org/qwen3-8b/model.gguf
mmproj = /Users/x/Jan/data/.../mmproj.gguf
spec-type = draft-mtp
spec-draft-model = /Users/x/Jan/data/.../mtp.gguf
temperature = 0.7
load-on-startup = false
四个值得学的取舍:
-
只写「与默认值不同」的项。 整个
[*]段是几十个if (config.x !== <llama.cpp 默认>),注释说默认值抄自上游tools/server/README.md(preset.ts:135-137)。好处是预设文件短、意图明确、上游改默认值时不会被 Jan 的陈旧值覆盖。 -
ctx-size与自动 fit 互斥。fit开着(默认)时完全不写ctx-size,因为显式值会压过 fit 的自动计算(preset.ts:161-173);fit 关着时用 8192 兜底,注释给的理由是 llama.cpp 自己的默认会加载模型的完整训练上下文,大上下文模型直接 OOM。 -
load-on-startup = false是「按需装载」的真正开关。 每个模型段末尾都写一行(preset.ts:543)。回看 §3.1:命令行上并没有--no-models-autoload,所以 autoload 其实是开的 —— 这也解释了getModelProps里那句注释:直接/props?model=X打一个未装载的模型会触发装载,所以必须先用getLoadedModels()过滤(index.ts:775-782)。 -
原子写。 先写
.tmp再mv,mv失败退化成直写目标(preset.ts:547-572)。router 可能正在读这个文件。
采样参数写进 preset 的含义(preset.ts:495-514):temperature/top-k/top-p 等成了服务端默认值,对所有请求生效 —— 包括通过本地 API 服务器进来的外部客户端(见 06-local-api-server-and-agent-loop);单次请求的 JSON 字段仍可覆盖。
MTP 段的门槛是三重与(preset.ts:468):用户开了 mtp: true 且 后端 build ≥ MTP_MIN_BUILD = 9193(preset.ts:56)且 确实有 MTP 层或草稿文件。build 号由 parseBuildNumber(index.ts:162)从版本串抠出来。同一个 build 号还决定另一件事:--no-webui 在上游 b9222 改名成 --no-ui,扩展按号选拼写再通过 default_args 传下去(index.ts:711-713)—— 所以 router_args 里刻意没有硬编这个 flag,注释写明了原因(router.rs:476-478)。
5. MLX 分支(对照,不深挖)
macOS 上 Jan 另有一条 MLX 路径,拿来对照能看清 router 设计换来了什么。
| 维度 | llamacpp | MLX |
|---|---|---|
| 服务端 | 上游 llama-server 二进制 | 自研 Swift 服务 mlx-server/(OpenAI 兼容 API) |
| 插件 | tauri-plugin-llamacpp | tauri-plugin-mlx |
| 状态 | Mutex<Option<RouterHandle>> 单个 | HashMap<i32, MlxBackendSession> 按 pid(tauri-plugin-mlx/src/state.rs:24) |
| 换模型 | HTTP unload + load | kill 进程 + spawn 进程 |
| 并发策略 | FIFO 驱逐到 userModelsMax | autoUnload 开着就把所有已装模型全卸掉(mlx-extension/src/index.ts:261-264) |
| 后端分发 | 下载 GitHub release、依赖校验、自动更新 | 无(Metal 内建,服务随应用一起构建) |
两边一模一样的地方只有一处:去重协议。 mlx-extension/src/index.ts:208 的 load 和 llamacpp 的 index.ts:2680 结构逐行对应 —— 同样的 findSessionByModel 前置检查、同样的 loadingModels Promise 缓存、同样的 finally 清理。可见 §3.2 那套是被抽象为「引擎的通用契约」的,而 router 是 llamacpp 独有的实现细节。
MLX 服务要求 macOS 14+、Apple Silicon、≥8GB 统一内存(mlx-server/README.md)。
6. 巧妙之处(可借鉴)
- 把「关停」拆成三个升级档,并让温柔那档能失败返还所有权。
try_graceful_stop_router返回Err((handle, busy))而不是吞掉 handle,调用方于是能重试、能提示用户、能升级强杀(router.rs:763)。退出流程正是靠这个签名做成了「循环重试 + 弹窗给用户选择」(src-tauri/src/lib.rs:170)。 - 对账优于记账。 驱逐前先问 router 要真实 loaded 集合来修正本地 FIFO,而不是相信自己的记录(
index.ts:2752-2763);问不到时明确选择「宁可多驱逐」。 - 崩溃分类器是可单测的纯函数。
is_oom_line/is_backend_error_line只吃一个小写字符串,于是classifies_backend_crash_lines(router.rs:1413)能把线上见过的每一种崩溃行写成断言,包括一条必须不匹配的正常请求日志。 - 把会 segfault 的第三方分析器关进子进程,而且复用自己的 exe。 不需要额外打包一个 helper 二进制,靠一个私有命令行标志切换角色(
deps_analyzer.rs:12,115-136)。 - 版本戳式的惰性元数据回填。
mtp_check_v/embedding_check_v让「改了检测逻辑」变成改一个常量,老模型下次被读到时自动重新体检(index.ts:80-81、index.ts:1870-1873)。 - 错误横幅盖在消息 metadata 上,而不是只放内存。 刷新后横幅仍在,用户不会以为「回答只是断了」(
llamacppRouterError.ts:23)。 - 优先级列表随显存翻转。 显存不足时把 CUDA/Vulkan 踢到 CPU 后端之后,而不是「有 GPU 就上 GPU」(
backend.rs:545-572)。
7. 边界与局限(诚实版)
- 单点故障。 所有模型共用一个 router 进程,router 一崩全崩。§3.7 那套 OOM 识别与止血扫荡,本质就是为这个取舍付的保险费。
isToolSupported的判据很糙。 只看 chat template 字符串里有没有'tools'(index.ts:3439),模板里出现这个词但不真支持工具调用的模型会被误判。- token 计数是 4 字符/token 的粗估。
getTokensCount(index.ts:3573)不做真分词,CJK 和代码上会有明显偏差;它服务的是 UI 上的用量提示,不是硬预算。 - 模型 id 的路径解析不是 Windows 感知的。
preset.ts:104-106自己标注了这点(用slice(modelsDir.length + 1)切前缀),并说明是与index.ts:list()保持一致的既有取舍。 - 依赖校验只是建议。 缺库只 emit 事件、只 warn,不阻止启动(
index.ts:2834-2838);真出问题时用户看到的是启动失败,而不是这条提示。 - macOS 上依赖分析整段跳过。 注释说 lddtree 的 Mach-O 处理在实测中崩过(
deps_analyzer.rs:107-108),所以 mac 用户拿不到这项预警。 - 手工安装的归档正则很窄。
index.ts:2131-2132只认[前缀-]llama[-main]-b<数字>[-hash][-cudart-llama]-bin-<backend>.(zip|tar.gz);上游改名或社区自定义命名会直接被拒。