diff --git a/fam-edge/src/fam_edge/app.py b/fam-edge/src/fam_edge/app.py index 3d4136f..9b925b4 100644 --- a/fam-edge/src/fam_edge/app.py +++ b/fam-edge/src/fam_edge/app.py @@ -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() diff --git a/fam-edge/src/fam_edge/model_adapters/ollama_adapter.py b/fam-edge/src/fam_edge/model_adapters/ollama_adapter.py index 2698c3a..7c3f1d3 100644 --- a/fam-edge/src/fam_edge/model_adapters/ollama_adapter.py +++ b/fam-edge/src/fam_edge/model_adapters/ollama_adapter.py @@ -33,6 +33,32 @@ class OllamaAdapter(BaseModelAdapter): 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: """GET /api/tags,检查模型是否可用""" try: diff --git a/fam-edge/tests/test_ollama_adapter.py b/fam-edge/tests/test_ollama_adapter.py new file mode 100644 index 0000000..52d5a92 --- /dev/null +++ b/fam-edge/tests/test_ollama_adapter.py @@ -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