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:
@@ -8,6 +8,7 @@ FAM-Edge 主应用 - Flask 单进程(新架构 v2.1)
|
|||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
from flask import Flask, jsonify
|
from flask import Flask, jsonify
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
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 . import state
|
||||||
from .video_queue import VideoQueue
|
from .video_queue import VideoQueue
|
||||||
from .person_service import PersonService
|
from .person_service import PersonService
|
||||||
|
from .model_adapters.ollama_adapter import OllamaAdapter
|
||||||
|
|
||||||
logger = setup_logger('fam-edge.app')
|
logger = setup_logger('fam-edge.app')
|
||||||
|
|
||||||
@@ -47,6 +49,21 @@ try:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"后台服务启动失败: {e}", exc_info=True)
|
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__':
|
if __name__ == '__main__':
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
|
|||||||
@@ -33,6 +33,32 @@ class OllamaAdapter(BaseModelAdapter):
|
|||||||
enabled=cb_cfg.get('enabled', False) # 本地模型默认不启用
|
enabled=cb_cfg.get('enabled', False) # 本地模型默认不启用
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def warm_up(self) -> bool:
|
||||||
|
"""启动时主动送一次最小请求,把模型强制加载进内存。
|
||||||
|
|
||||||
|
背景:OLLAMA_KEEP_ALIVE=-1(systemd 环境变量已配置)只保证"一旦加载过
|
||||||
|
就不再因为空闲被换出",但不会在服务启动时主动预加载——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:
|
def health_check(self) -> bool:
|
||||||
"""GET /api/tags,检查模型是否可用"""
|
"""GET /api/tags,检查模型是否可用"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
73
fam-edge/tests/test_ollama_adapter.py
Normal file
73
fam-edge/tests/test_ollama_adapter.py
Normal 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
|
||||||
Reference in New Issue
Block a user