refactor(fam-edge): 问答链路抽离到独立 ai-gateway 服务,fam-edge 改为转发客户端
原本嵌在 fam-edge 里的问答模型降级链(NVIDIA 文字模型 -> Gemini 非 flash 文字 模型 -> 本地 Ollama 兜底,含 key 轮换/熔断)跟视频分析业务无关,是通用能力, 抽成独立 ai-gateway 服务(OpenAI 兼容协议),除了 fam-edge 自己,别的项目也能 直接接入。 - qa.py 重写为 HTTP 转发客户端,调 ai-gateway 的 /v1/chat/completions,翻译回 原有 run_qa/run_qa_stream 契约,api_gateway.py 和 fam-core 调用方零改动 - 删除 model_adapters/ollama_adapter.py 及其测试(问答专用,视频分析不需要本地模型) - gemini_adapter.py / nvidia_adapter.py 移除 chat()/chat_stream() 及问答专用超时 (只保留视频分析用的 analyze_video) - app.py 移除 Ollama 预热逻辑(现在由 ai-gateway 自己负责) - config.yaml 移除 3 个问答专用 model 条目,新增 ai_gateway 客户端配置块 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,12 @@
|
||||
"""模型适配器包"""
|
||||
from .base_adapter import BaseModelAdapter
|
||||
from .circuit_breaker import CircuitBreaker
|
||||
from .ollama_adapter import OllamaAdapter
|
||||
from .gemini_adapter import GeminiAdapter
|
||||
from .adapter_factory import build_adapter, build_adapters, register_adapter
|
||||
|
||||
__all__ = [
|
||||
"BaseModelAdapter",
|
||||
"CircuitBreaker",
|
||||
"OllamaAdapter",
|
||||
"GeminiAdapter",
|
||||
"build_adapter",
|
||||
"build_adapters",
|
||||
|
||||
@@ -9,15 +9,16 @@
|
||||
from typing import List
|
||||
|
||||
from .base_adapter import BaseModelAdapter
|
||||
from .ollama_adapter import OllamaAdapter
|
||||
from .gemini_adapter import GeminiAdapter
|
||||
from .nvidia_adapter import NvidiaVisionAdapter
|
||||
from ..logger import setup_logger
|
||||
|
||||
logger = setup_logger('fam-edge.adapter_factory')
|
||||
|
||||
# ollama 已于 2026-08-23 移除:本地模型只在问答链路里当兜底用,问答已经整个
|
||||
# 抽离到独立的 ai-gateway 服务(含它自己的 ollama 适配器),fam-edge 这边
|
||||
# 只剩视频分析(vision 角色),不再需要注册纯文本本地模型。
|
||||
_ADAPTER_REGISTRY = {
|
||||
"ollama": OllamaAdapter,
|
||||
"gemini": GeminiAdapter,
|
||||
"nvidia": NvidiaVisionAdapter,
|
||||
}
|
||||
|
||||
@@ -37,11 +37,9 @@ class BaseModelAdapter(ABC):
|
||||
def __init__(self, provider_name: str, config: dict):
|
||||
self.provider_name = provider_name # 如 "ollama", "gemini"
|
||||
self.config = config
|
||||
# 角色: vision=视觉分析, text=智能问答; 默认 vision
|
||||
# 角色: vision=视觉分析, text=智能问答(问答链路已抽离到 ai-gateway,
|
||||
# 这里目前只有 vision 在用;text 角色留给尚未清理的旧 person_service)
|
||||
self.role = config.get('role', 'vision')
|
||||
# usage: 纯文档/编排层筛选用的标记(如 "qa_primary"/"qa_fallback"),
|
||||
# 不影响本适配器自身行为;QAOrchestrator 据此挑选参与问答链路的适配器。
|
||||
self.usage = config.get('usage', '')
|
||||
# 模型调用统计回调(由编排层注入):
|
||||
# hook(provider, model, started_at, duration_sec, success, error)
|
||||
self.model_call_hook = None
|
||||
|
||||
@@ -3,8 +3,12 @@ GeminiAdapter - Google Gemini 云端 VLM 适配器
|
||||
|
||||
provider_name = "gemini"
|
||||
模型: gemini-flash-latest
|
||||
角色: vision (整视频直出结构化 JSON) + 智能问答
|
||||
角色: vision (整视频直出结构化 JSON)
|
||||
健康检查: GET /v1beta/models?key=...
|
||||
|
||||
问答(chat/chat_stream)已于 2026-08-23 抽离到独立的 ai-gateway 服务
|
||||
(跟视频分析业务无关,是通用能力),这里不再实现,fam-edge 自己的问答请求
|
||||
转发给 ai-gateway(见 qa.py)。
|
||||
熔断器: 启用
|
||||
整视频分析: 用 Files API 上传完整视频 -> generateContent 直出结构化 JSON
|
||||
(本地不切片、不抽帧;Gemini 原生支持长视频)
|
||||
@@ -50,7 +54,7 @@ logger = setup_logger('fam-edge.gemini_adapter')
|
||||
|
||||
|
||||
class GeminiAdapter(BaseModelAdapter):
|
||||
"""Gemini 云端 VLM 适配器 (整视频直出结构化 JSON + 文本问答)"""
|
||||
"""Gemini 云端 VLM 适配器 (整视频直出结构化 JSON,不参与问答)"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
super().__init__("gemini", config)
|
||||
@@ -74,10 +78,6 @@ class GeminiAdapter(BaseModelAdapter):
|
||||
self.key_labels.append(str(label))
|
||||
self.api_key = self.api_keys[0] if self.api_keys else '' # 向后兼容单 key 用法
|
||||
self.timeout = config.get('timeout', 600)
|
||||
# 问答(chat)专用超时——跟视频分析的 timeout 分开,不能共用 600s:
|
||||
# 智能问答是同步等待用户看结果的交互场景,一个 key/模型卡住不该让用户等
|
||||
# 10 分钟,超时应该短、快速降级到下一个 key/模型/provider
|
||||
self.chat_timeout = config.get('chat_timeout', 20)
|
||||
# 模型级独立超时(最终值,不参与编排层 ×N 放大): {model_name: seconds}
|
||||
# 例: {"gemini-flash-lite-latest": 90}(按实测耗时 ×4 配置)
|
||||
self.model_timeouts = {
|
||||
@@ -401,128 +401,6 @@ class GeminiAdapter(BaseModelAdapter):
|
||||
camera = load_config().get('gdrive_sync', {}).get('camera_name', '')
|
||||
return build_video_prompt(known_members, event_start_time, camera)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 智能问答:纯文本
|
||||
# ------------------------------------------------------------------
|
||||
def chat(self, prompt: str, max_tokens: int = 512) -> Optional[str]:
|
||||
if self._cb.is_open():
|
||||
logger.warning("Gemini 熔断器 OPEN,跳过问答")
|
||||
return None
|
||||
if not self.api_keys:
|
||||
logger.warning("Gemini API Key 未配置,跳过问答")
|
||||
return None
|
||||
try:
|
||||
result = self._generate_text(prompt, max_tokens=max_tokens, temperature=0.3)
|
||||
except Exception as e:
|
||||
logger.error(f"Gemini 问答异常: {e}")
|
||||
result = None
|
||||
if result:
|
||||
self._cb.record_success()
|
||||
else:
|
||||
self._cb.record_failure()
|
||||
return result
|
||||
|
||||
def _generate_text(self, text: str, max_tokens: int, temperature: float) -> Optional[str]:
|
||||
"""纯文本 generateContent,按 key 轮换(同 analyze_video 共用一套轮转起点)
|
||||
× 模型 fallback 链依次尝试。"""
|
||||
for idx, api_key in self._rotated_keys():
|
||||
key_label = self.key_labels[idx]
|
||||
for model in self.model_chain:
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{self._base_url}/models/{model}:generateContent?key={api_key}",
|
||||
json={"contents": [{"parts": [{"text": text}]}],
|
||||
"generationConfig": {
|
||||
"temperature": temperature,
|
||||
"maxOutputTokens": max_tokens}},
|
||||
timeout=self.chat_timeout
|
||||
)
|
||||
except requests.Timeout:
|
||||
logger.warning(f"Gemini {key_label} [{model}] 问答超时({self.chat_timeout}s)")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"Gemini {key_label} [{model}] 问答异常: {e}")
|
||||
continue
|
||||
if resp.status_code == 200:
|
||||
cands = resp.json().get('candidates', [])
|
||||
out = ''.join(
|
||||
p.get('text', '')
|
||||
for p in (cands[0].get('content', {}) if cands else {}).get('parts', [])
|
||||
).strip() if cands else ''
|
||||
if out:
|
||||
return out
|
||||
elif resp.status_code == 429:
|
||||
logger.warning(f"Gemini {key_label} [{model}] 429,切换下一模型/Key")
|
||||
continue
|
||||
return None
|
||||
|
||||
def chat_stream(self, prompt: str, max_tokens: int = 512):
|
||||
"""流式问答:逐块 yield 文本增量。用于聊天界面边生成边显示,不用等全量
|
||||
返回再展示——之前整段等待是"卡住没反馈"体验差的根源之一。
|
||||
|
||||
按 key 轮换 × 模型链依次尝试,但只在"这次尝试还没吐出任何文本"时才允许
|
||||
换下一个 key/模型;一旦已经开始吐字给用户看了,中途出错就直接结束这次
|
||||
生成(不再悄悄换 provider 接着写,否则会出现两段风格/内容不连贯的回答
|
||||
拼在一起,比直接告知"生成中断"更让人困惑)。
|
||||
"""
|
||||
if self._cb.is_open():
|
||||
logger.warning("Gemini 熔断器 OPEN,跳过问答(流式)")
|
||||
return
|
||||
if not self.api_keys:
|
||||
logger.warning("Gemini API Key 未配置,跳过问答(流式)")
|
||||
return
|
||||
got_any = False
|
||||
for idx, api_key in self._rotated_keys():
|
||||
key_label = self.key_labels[idx]
|
||||
for model in self.model_chain:
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{self._base_url}/models/{model}:streamGenerateContent"
|
||||
f"?alt=sse&key={api_key}",
|
||||
json={"contents": [{"parts": [{"text": prompt}]}],
|
||||
"generationConfig": {
|
||||
"temperature": 0.3, "maxOutputTokens": max_tokens}},
|
||||
timeout=self.chat_timeout, stream=True,
|
||||
)
|
||||
except requests.Timeout:
|
||||
logger.warning(f"Gemini {key_label} [{model}] 流式问答超时({self.chat_timeout}s)")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"Gemini {key_label} [{model}] 流式问答异常: {e}")
|
||||
continue
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"Gemini {key_label} [{model}] 流式问答 HTTP {resp.status_code}")
|
||||
resp.close()
|
||||
continue
|
||||
# Gemini 响应体固定是 UTF-8,但 Content-Type 没带 charset 参数,
|
||||
# requests 会自己猜编码(猜错会把中文变成乱码)——强制指定,
|
||||
# 不依赖 requests 的自动嗅探。
|
||||
resp.encoding = 'utf-8'
|
||||
try:
|
||||
for line in resp.iter_lines(decode_unicode=True):
|
||||
if not line or not line.startswith('data: '):
|
||||
continue
|
||||
chunk = line[len('data: '):]
|
||||
try:
|
||||
obj = json.loads(chunk)
|
||||
except ValueError:
|
||||
continue
|
||||
cands = obj.get('candidates', [])
|
||||
text = ''.join(
|
||||
p.get('text', '')
|
||||
for p in (cands[0].get('content', {}) if cands else {}).get('parts', []))
|
||||
if text:
|
||||
got_any = True
|
||||
yield text
|
||||
except Exception as e:
|
||||
logger.warning(f"Gemini {key_label} [{model}] 流式读取中断: {e}")
|
||||
finally:
|
||||
resp.close()
|
||||
if got_any:
|
||||
self._cb.record_success()
|
||||
return # 已经开始吐字,不管这次是否读完都不再换 provider
|
||||
self._cb.record_failure()
|
||||
|
||||
def get_timeout(self) -> int:
|
||||
return self.timeout
|
||||
|
||||
|
||||
@@ -3,9 +3,12 @@ NvidiaVisionAdapter - NVIDIA NIM 云端 VLM 适配器
|
||||
|
||||
provider_name = "nvidia"
|
||||
模型: nvidia/nemotron-3-nano-omni-30b-a3b-reasoning(唯一实测确认可用的视频理解模型)
|
||||
角色: vision (整视频直出结构化 JSON) + 智能问答
|
||||
角色: vision (整视频直出结构化 JSON)
|
||||
SDK: openai (NIM 兼容 OpenAI API 规范)
|
||||
|
||||
问答(chat/chat_stream)已于 2026-08-23 抽离到独立的 ai-gateway 服务
|
||||
(跟视频分析业务无关,是通用能力),这里不再实现。
|
||||
|
||||
整视频分析实测结论(2026-08-21 用真实短视频逐个探测):
|
||||
- nemotron-3-nano-omni-30b-a3b-reasoning: video_url 只认 base64 data URI
|
||||
(`data:video/mp4;base64,<...>`),Assets API 的 asset_id 引用方式对它直接 500
|
||||
@@ -44,7 +47,7 @@ except ImportError:
|
||||
|
||||
|
||||
class NvidiaVisionAdapter(BaseModelAdapter):
|
||||
"""NVIDIA NIM 云端 VLM 适配器 (整视频单次调用; 文本问答)
|
||||
"""NVIDIA NIM 云端 VLM 适配器 (整视频单次调用,不参与问答)
|
||||
|
||||
多模型降级链(类似 Gemini flash -> flash-lite):
|
||||
- model_chain = [model_name] + fallback_models
|
||||
@@ -62,8 +65,6 @@ class NvidiaVisionAdapter(BaseModelAdapter):
|
||||
self.api_key = self._resolve_key(config.get('api_key', ''))
|
||||
self.base_url = config.get('base_url', 'https://integrate.api.nvidia.com/v1')
|
||||
self.timeout = config.get('timeout', 600)
|
||||
# 问答专用超时,跟视频分析分开——交互式问答不该等到跟视频分析一样久
|
||||
self.chat_timeout = config.get('chat_timeout', 20)
|
||||
self.max_base64_mb = float(config.get('max_base64_mb', 20))
|
||||
# 模型级独立超时(最终值,不参与编排层 ×N 放大): {model_name: seconds}
|
||||
self.model_timeouts = {
|
||||
@@ -194,34 +195,6 @@ class NvidiaVisionAdapter(BaseModelAdapter):
|
||||
camera = load_config().get('gdrive_sync', {}).get('camera_name', '')
|
||||
return build_video_prompt(known_members, event_start_time, camera)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 智能问答:纯文本
|
||||
# ------------------------------------------------------------------
|
||||
def chat(self, prompt: str, max_tokens: int = 2048) -> Optional[str]:
|
||||
if self._cb.is_open():
|
||||
logger.warning("NVIDIA 熔断器 OPEN,跳过问答")
|
||||
return None
|
||||
if self._client is None:
|
||||
logger.warning("NVIDIA 客户端未初始化,跳过问答")
|
||||
return None
|
||||
for model in self.model_chain:
|
||||
try:
|
||||
resp = self._client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0.3,
|
||||
max_tokens=max_tokens,
|
||||
timeout=self.chat_timeout
|
||||
)
|
||||
content = resp.choices[0].message.content
|
||||
if content:
|
||||
self._cb.record_success()
|
||||
return content.strip()
|
||||
except Exception as e:
|
||||
logger.warning(f"NVIDIA [{model}] 问答异常: {e}")
|
||||
self._cb.record_failure()
|
||||
return None
|
||||
|
||||
def get_timeout(self) -> int:
|
||||
return self.timeout
|
||||
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
"""
|
||||
OllamaAdapter - 本地模型适配器(仅智能问答兜底)
|
||||
|
||||
provider_name = "ollama"
|
||||
模型: qwen2.5:7b(纯文本)
|
||||
角色: text(智能问答兜底;Gemini 与 NVIDIA 均失败时启用)
|
||||
健康检查: GET /api/tags
|
||||
不参与视觉分析、不参与视频结构化输出(云端 VLM 直出)
|
||||
"""
|
||||
import requests
|
||||
from typing import Dict, Optional
|
||||
|
||||
from .base_adapter import BaseModelAdapter
|
||||
from .circuit_breaker import CircuitBreaker
|
||||
from ..logger import setup_logger
|
||||
|
||||
logger = setup_logger('fam-edge.ollama_adapter')
|
||||
|
||||
|
||||
class OllamaAdapter(BaseModelAdapter):
|
||||
"""Ollama 本地 VLM 适配器"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
super().__init__("ollama", config)
|
||||
self.base_url = config.get('base_url', 'http://localhost:11434')
|
||||
self.model_name = config.get('model_name', 'llava-phi3')
|
||||
self.timeout = config.get('timeout', 240)
|
||||
self.num_predict = config.get('num_predict', 500)
|
||||
cb_cfg = config.get('circuit_breaker', {})
|
||||
self._cb = CircuitBreaker(
|
||||
threshold=cb_cfg.get('threshold', 5),
|
||||
cooldown=cb_cfg.get('cooldown', 900),
|
||||
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:
|
||||
resp = requests.get(f"{self.base_url}/api/tags", timeout=10)
|
||||
if resp.status_code == 200:
|
||||
models = resp.json().get('models', [])
|
||||
model_names = [m.get('name', '') for m in models]
|
||||
# 兼容 llava-phi3:latest 等后缀
|
||||
has_model = any(self.model_name in name for name in model_names)
|
||||
if has_model:
|
||||
logger.info(f"Ollama 健康检查通过: 模型 {self.model_name} 可用")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Ollama 健康检查失败: 模型 {self.model_name} 未找到,可用模型: {model_names}")
|
||||
return False
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Ollama 健康检查异常: {e}")
|
||||
return False
|
||||
|
||||
def analyze_video(self, video_path: str,
|
||||
known_members_context: str,
|
||||
event_start_time: str = '') -> Optional[Dict]:
|
||||
"""Ollama 为纯文本模型,不参与视频分析,返回 None(降级链不会选它做视频)。"""
|
||||
logger.info("Ollama 为纯文本模型,跳过视频分析")
|
||||
return None
|
||||
|
||||
def get_timeout(self) -> int:
|
||||
return self.timeout
|
||||
|
||||
def get_circuit_breaker(self) -> CircuitBreaker:
|
||||
return self._cb
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 智能问答:纯文本(本地模型,仅作 Gemini/NVIDIA 全失败时的兜底)
|
||||
# ------------------------------------------------------------------
|
||||
def chat(self, prompt: str, max_tokens: int = 512) -> Optional[str]:
|
||||
if self._cb.is_open():
|
||||
logger.warning("Ollama 熔断器 OPEN,跳过问答")
|
||||
return None
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{self.base_url}/api/generate",
|
||||
json={
|
||||
"model": self.model_name,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.3, "num_predict": max_tokens}
|
||||
},
|
||||
timeout=self.timeout
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
output = resp.json().get('response', '').strip()
|
||||
if output:
|
||||
self._cb.record_success()
|
||||
return output
|
||||
self._cb.record_failure()
|
||||
else:
|
||||
logger.error(f"Ollama 问答失败: {resp.status_code} {resp.text[:200]}")
|
||||
self._cb.record_failure()
|
||||
except requests.Timeout:
|
||||
logger.error(f"Ollama 问答超时 ({self.timeout}s)")
|
||||
self._cb.record_failure()
|
||||
except Exception as e:
|
||||
logger.error(f"Ollama 问答异常: {e}")
|
||||
self._cb.record_failure()
|
||||
return None
|
||||
Reference in New Issue
Block a user