feat(fam-edge): 启动时预热 Ollama,避免兜底触发时冷启动等 1-2 分钟

排查"Ollama 模型没有常驻内存":systemd 里 OLLAMA_KEEP_ALIVE=-1 其实一直配置
正确,但这个环境变量只保证"加载过之后不因为空闲被换出",不负责启动时主动
预加载。查 model_calls 表,ollama provider 从来没有一条调用记录——因为它是
问答链路最后一级兜底,前面 NVIDIA/Gemini 一直调用成功,从未真正轮到它,
自然也就从未被加载进内存过。

新增 OllamaAdapter.warm_up():送一条 num_predict=1 的最小请求强制模型加载,
超时给够 180s(冷启动实测能到 1-2 分钟)。app.py 启动时在后台线程调用,不
阻塞主服务启动;找不到 ollama 配置或预热失败都只记警告,不影响服务本身。

实测部署验证:重启后 36 秒完成预热,ollama ps 显示 qwen2.5:7b 已加载,
expires_at 显示不过期(keep_alive=-1 生效)——真正需要兜底的那一刻不会再有
冷启动延迟。

新增 test_ollama_adapter.py 4 个用例覆盖预热成功/HTTP失败/异常不上抛/超时
时长。
This commit is contained in:
ericwyuan
2026-08-23 09:33:49 +08:00
parent bdf561a415
commit 63b28663f1
3 changed files with 116 additions and 0 deletions

View File

@@ -8,6 +8,7 @@ FAM-Edge 主应用 - Flask 单进程(新架构 v2.1
"""
import os
import sys
import threading
from flask import Flask, jsonify
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@@ -18,6 +19,7 @@ from .api_gateway.api_gateway import api_bp
from . import state
from .video_queue import VideoQueue
from .person_service import PersonService
from .model_adapters.ollama_adapter import OllamaAdapter
logger = setup_logger('fam-edge.app')
@@ -47,6 +49,21 @@ try:
except Exception as e:
logger.error(f"后台服务启动失败: {e}", exc_info=True)
# 启动时后台预热 Ollama问答链路最末位兜底实测从未被自然触发过
# OLLAMA_KEEP_ALIVE=-1 只保证加载后不换出、不负责主动预加载)。后台线程跑,
# 不阻塞 gunicorn worker 启动;找不到 ollama 配置项或预热失败都只记警告。
try:
_ollama_cfg = next(
(m for m in load_config().get('models', []) if m.get('provider') == 'ollama'),
None)
if _ollama_cfg and _ollama_cfg.get('enabled', False):
threading.Thread(
target=lambda: OllamaAdapter(_ollama_cfg).warm_up(),
daemon=True, name='ollama-warmup').start()
logger.info("Ollama 预热任务已在后台启动")
except Exception as e:
logger.warning(f"Ollama 预热任务启动失败(不影响主服务): {e}")
if __name__ == '__main__':
cfg = load_config()

View File

@@ -33,6 +33,32 @@ class OllamaAdapter(BaseModelAdapter):
enabled=cb_cfg.get('enabled', False) # 本地模型默认不启用
)
def warm_up(self) -> bool:
"""启动时主动送一次最小请求,把模型强制加载进内存。
背景OLLAMA_KEEP_ALIVE=-1systemd 环境变量已配置)只保证"一旦加载过
就不再因为空闲被换出"但不会在服务启动时主动预加载——Ollama 现在只在
问答链路最末位兜底(前面 NVIDIA/Gemini 一直成功的话永远轮不到它),
实测 model_calls 表里从来没有一条 ollama 记录,说明模型从未被加载过。
真正需要兜底的那一刻才现加载,用户会等上首次冷启动的 ~1-2 分钟
(见 README 6.2 冷启动实测数据)。启动时主动预热一次,之后就一直
常驻内存,兜底真正触发时不再有冷启动延迟。
"""
try:
resp = requests.post(
f"{self.base_url}/api/generate",
json={"model": self.model_name, "prompt": "hi", "stream": False,
"options": {"num_predict": 1}},
timeout=180, # 冷启动可能到 1-2 分钟,给足时间
)
if resp.status_code == 200:
logger.info(f"Ollama 模型预热完成: {self.model_name}")
return True
logger.warning(f"Ollama 预热失败: HTTP {resp.status_code} {resp.text[:200]}")
except Exception as e:
logger.warning(f"Ollama 预热异常(不影响服务启动,问答兜底时会正常现加载): {e}")
return False
def health_check(self) -> bool:
"""GET /api/tags检查模型是否可用"""
try:

View File

@@ -0,0 +1,73 @@
from fam_edge.model_adapters.ollama_adapter import OllamaAdapter
def _cfg(**overrides):
base = {
"provider": "ollama",
"role": "text",
"model_name": "qwen2.5:7b",
"base_url": "http://localhost:11434",
"circuit_breaker": {"enabled": False},
}
base.update(overrides)
return base
class _FakeResp:
def __init__(self, status_code=200, text=""):
self.status_code = status_code
self.text = text
def json(self):
return {"response": "ok"}
def test_warm_up_success(monkeypatch):
calls = {}
def fake_post(url, json=None, timeout=None):
calls["url"] = url
calls["json"] = json
calls["timeout"] = timeout
return _FakeResp(200)
monkeypatch.setattr(
"fam_edge.model_adapters.ollama_adapter.requests.post", fake_post)
a = OllamaAdapter(_cfg())
assert a.warm_up() is True
assert calls["url"] == "http://localhost:11434/api/generate"
assert calls["json"]["model"] == "qwen2.5:7b"
# 只为触发加载,不需要真的生成长文本
assert calls["json"]["options"]["num_predict"] == 1
def test_warm_up_http_error_returns_false(monkeypatch):
monkeypatch.setattr(
"fam_edge.model_adapters.ollama_adapter.requests.post",
lambda url, json=None, timeout=None: _FakeResp(500, "boom"))
a = OllamaAdapter(_cfg())
assert a.warm_up() is False
def test_warm_up_exception_does_not_raise(monkeypatch):
"""核心诉求: 预热失败(比如 Ollama 服务当时没起来)不能抛异常影响主服务
启动,只应该记警告日志、返回 False。"""
def raise_err(url, json=None, timeout=None):
raise ConnectionError("refused")
monkeypatch.setattr(
"fam_edge.model_adapters.ollama_adapter.requests.post", raise_err)
a = OllamaAdapter(_cfg())
assert a.warm_up() is False
def test_warm_up_uses_generous_timeout_for_cold_start(monkeypatch):
"""核心诉求: 冷启动实测能到 1-2 分钟,预热请求的超时不能沿用问答的短超时。"""
captured = {}
def fake_post(url, json=None, timeout=None):
captured["timeout"] = timeout
return _FakeResp(200)
monkeypatch.setattr(
"fam_edge.model_adapters.ollama_adapter.requests.post", fake_post)
a = OllamaAdapter(_cfg(timeout=20)) # chat() 用的短超时
a.warm_up()
assert captured["timeout"] >= 120