From 63b28663f1703c8c71c40d6dba6d02104cf5b9c8 Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Sun, 23 Aug 2026 09:33:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(fam-edge):=20=E5=90=AF=E5=8A=A8=E6=97=B6?= =?UTF-8?q?=E9=A2=84=E7=83=AD=20Ollama=EF=BC=8C=E9=81=BF=E5=85=8D=E5=85=9C?= =?UTF-8?q?=E5=BA=95=E8=A7=A6=E5=8F=91=E6=97=B6=E5=86=B7=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E7=AD=89=201-2=20=E5=88=86=E9=92=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 排查"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失败/异常不上抛/超时 时长。 --- fam-edge/src/fam_edge/app.py | 17 +++++ .../fam_edge/model_adapters/ollama_adapter.py | 26 +++++++ fam-edge/tests/test_ollama_adapter.py | 73 +++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 fam-edge/tests/test_ollama_adapter.py 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