同一个三请求序列,只差一个字段:9秒返回,和150秒超时放弃。差别不在模型、不在硬件,在于第三个请求里多写了一句 "keep_alive": "30m"。

测试环境是 Ollama 0.40.8,GitHub 发行版,纯 CPU,一个一次性 LXC 容器,两个小模型。配置为 OLLAMA_MAX_LOADED_MODELS=1、OLLAMA_NUM_PARALLEL=4、OLLAMA_KEEP_ALIVE=30m、OLLAMA_DEBUG=1。此时 qwen3:0.6b 已加载。

三个请求依次发出:

  • 对已加载模型发起一次长生成(num_predict 400);
  • 三秒后,对 gemma3:270m 发一个短请求,它需要那个唯一的槽位;
  • 两秒后,再对已加载模型发一个单 token 请求。

对照组里,第三步是普通请求,结果:第三步在 +5.129s 完成,长生成在 +7.646s 完成,另一个模型的请求在 +9.156s 拿到 http=200,之后 /api/ps 显示加载的是 gemma3:270m。

实验组里,第三步带上 "keep_alive":"30m",结果:第三步在 +5.113s 完成,长生成在 +7.610s 完成,另一个模型的请求在 +153.0s 返回 http=000——这是 curl 在 150 秒上限处放弃。之后 /api/ps 显示加载的仍是 qwen3:0.6b。

调度器的 debug 日志把过程写得很清楚:先出现 "max runners achieved, unloading one to make room",接着 "resetting model to expire immediately to make room",然后 "waiting for pending requests to complete and unload to occur",再是 "evaluating already loaded",最后是 "runner with non-zero duration has gone idle, adding timer"。健康的那次运行结尾则是 "runner with zero duration has gone idle, expiring to unload" 和 "unload completed"。

问题难被发现,是因为从常规检查角度看服务一切正常。/api/ps 显示模型已加载,对它的请求也照常返回,而说明真相的调度器消息是 debug 级别,只有开了 OLLAMA_DEBUG=1 才看得见。只有发往其他模型的请求会挂住,而且不报错:客户端就那么等着,直到自己的超时或者 keep_alive 到期。

触发条件是再普通不过的客户端行为。很多客户端和界面会在每个请求上都带 keep_alive。报告者因为一个客户端发 "2h" 丢了两小时,又因为一个健康探针丢了十三分钟。而单槽位恰恰是内存只装得下一个模型时的常规设置,大多数家用 GPU 主机都是这个情况。

它还取决于时序:带 keep_alive 的请求必须正好落在换出等待期间。轻负载下可能永远碰不上;但在对已加载模型的持续请求流里,它会一次又一次发生,另一个模型实际上永远加载不进来。

机制层面,一个槽位占满时,Ollama 选中已加载的 runner 作为淘汰对象,把它的剩余时间置零,让它最后一个请求结束后立刻卸载,等待中的请求就卡在那里。此时若来了一个同模型的请求,会由这个已加载的 runner 服务,而按报告对 server/sched.go 的解读,它的 keep_alive 覆盖了 runner 的会话时长。runner 随后空闲时不再被标记为到期,于是拿到一个普通的保活计时器留了下来,等待中的请求继续排队。

修复方案 #19190 的思路是阻止请求的 keep_alive 取消已经挂起的淘汰。该 PR 目前处于开放状态,未进入任何发行版,v0.40.8 到 v0.40.9-rc1 之间的提交信息里也没有提到它。

在那之前有两个办法。其一是手动释放卡住的换出,对已加载模型发一个 keep_alive 为 0 的请求:

curl http://127.0.0.1:11434/api/generate -d '{"model":"qwen3:0.6b","keep_alive":0}'

在测试中,这让等待的请求在 1.5 秒后完成,随后加载的是 gemma3:270m。其二是避开触发条件:不让客户端在每个请求上发 keep_alive,改为在服务端用 OLLAMA_KEEP_ALIVE 统一设置;或者在内存允许时给服务端多于一个槽位,这样第二个模型根本不需要触发淘汰。

工具包里的 check-ollama-eviction-stall.sh 会读取服务端的 OLLAMA_MAX_LOADED_MODELS 和已加载模型,如果服务端开了 debug 日志,还会统计那些被替换成保活计时器的淘汰次数。在测试服务器上它输出:Ollama 0.40.8、单槽位、已加载 qwen3:0.6b,标记为 AT RISK,并报告有 2 次卸载被保活计时器取代;加 --release 参数则直接发出卸载请求。两个槽位且无卡顿时,它报告 "Not at risk here" 并以 0 退出。

可以推广的习惯是:当一个按请求传入的选项和调度器的决策碰到同一个字段时,先问清楚谁说了算。这里客户端的偏好——"把这个模型留着"——悄悄覆盖了服务端自己的计划——"现在就卸载,有人在等",而为此付出代价的是另一个客户端的请求。像 keep_alive 这种单看无害的选项,恰恰因为没人把它当成对调度器的指令,才可能带来最意外的副作用。

还有一点:遇到不报错的挂起,把不同选项并排测一遍。同样的三个请求,只差一个字段,就是 9 秒和永远不返回的区别。