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:
ericwyuan
2026-08-23 14:12:01 +08:00
parent 9dff19e6ac
commit 5caeb299a4
12 changed files with 310 additions and 614 deletions

View File

@@ -108,17 +108,3 @@ def test_rotated_keys_single_key_never_errors():
a = GeminiAdapter(_cfg())
for _ in range(3):
assert a._rotated_keys() == [(0, "key-primary")]
def test_chat_timeout_defaults_short_not_shared_with_video_timeout():
"""核心诉求: 问答是交互场景,不能沿用视频分析的 600s 超时——否则一个卡住
的 key/模型会让用户在聊天界面一直等,这正是"一直卡着"这个 bug 的根因。"""
a = GeminiAdapter(_cfg(timeout=600))
assert a.timeout == 600
assert a.chat_timeout == 20
assert a.chat_timeout != a.timeout
def test_chat_timeout_configurable():
a = GeminiAdapter(_cfg(chat_timeout=8))
assert a.chat_timeout == 8

View File

@@ -1,73 +0,0 @@
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

View File

@@ -1,109 +1,199 @@
import json
import pytest
from fam_edge.qa import QAOrchestrator
class _FakeRoleAdapter:
"""用于 __init__ 过滤逻辑测试,只需要 get_role(),不需要真的能 chat。"""
def __init__(self, provider_name, role):
self.provider_name = provider_name
self.role = role
def get_role(self):
return self.role
def _orchestrator(monkeypatch, cfg=None, token='tok'):
ai_gateway_cfg = {"base_url": "http://127.0.0.1:5100", "token": token, "timeout": 5}
if cfg:
ai_gateway_cfg.update(cfg)
monkeypatch.setattr(
"fam_edge.qa.load_config", lambda: {"ai_gateway": ai_gateway_cfg})
return QAOrchestrator()
def test_init_only_keeps_text_role_adapters_in_config_order(monkeypatch):
"""核心诉求: 问答链路只用 role='text' 的适配器(跟视频分析用的
role='vision' 完全隔离),且顺序沿用 config.yaml 里 models 数组的出现
顺序,不需要额外的 qa_order 配置。"""
fake_adapters = [
_FakeRoleAdapter("gemini", "vision"), # 视频分析用的 gemini-flash不该出现
_FakeRoleAdapter("nvidia", "vision"), # 视频分析用的 nvidia omni不该出现
_FakeRoleAdapter("nvidia", "text"), # 新的问答专用 nvidia 文字模型链
_FakeRoleAdapter("gemini", "text"), # 新的问答专用 gemini 非 flash 文字模型
_FakeRoleAdapter("ollama", "text"), # 本地兜底
]
monkeypatch.setattr("fam_edge.qa.load_config", lambda: {"models": []})
monkeypatch.setattr("fam_edge.qa.build_adapters", lambda models: fake_adapters)
def test_init_reads_base_url_and_token_from_config(monkeypatch):
qa = _orchestrator(monkeypatch, {"base_url": "http://example:5100/"}, token='secret')
assert qa.base_url == "http://example:5100"
assert qa.token == 'secret'
def test_init_resolves_token_from_env_var(monkeypatch):
monkeypatch.setenv("MY_GATEWAY_TOKEN", "resolved-secret")
qa = _orchestrator(monkeypatch, token='${MY_GATEWAY_TOKEN}')
assert qa.token == 'resolved-secret'
def test_init_defaults_base_url_when_unconfigured(monkeypatch):
monkeypatch.setattr("fam_edge.qa.load_config", lambda: {})
qa = QAOrchestrator()
assert [a.role for a in qa.adapters] == ["text", "text", "text"]
assert len(qa.adapters) == 3
assert qa.base_url == "http://127.0.0.1:5100"
class _FakeAdapter:
def __init__(self, provider_name, chunks=None, raises=False):
self.provider_name = provider_name
self._chunks = chunks or []
self._raises = raises
class _FakeResp:
"""模拟 requests.Response非流式用 status_code/json()/text
流式额外提供 iter_lines()(逐行 yield跟真实 SSE 消费方式一致)。"""
def chat_stream(self, prompt, max_tokens=512):
if self._raises:
raise RuntimeError("boom")
for c in self._chunks:
yield c
def __init__(self, status_code=200, payload=None, text='', lines=None):
self.status_code = status_code
self._payload = payload
self.text = text
self._lines = lines if lines is not None else []
self.encoding = None
def chat(self, prompt, max_tokens=512):
return ''.join(self._chunks) or None
def json(self):
return self._payload
def iter_lines(self, decode_unicode=True):
for line in self._lines:
yield line
def _orchestrator(adapters):
qa = QAOrchestrator.__new__(QAOrchestrator) # 跳过 __init__不需要真实 config/adapters
qa.adapters = adapters
return qa
def _capture_post(monkeypatch, resp):
calls = []
def fake_post(url, headers=None, json=None, timeout=None, stream=False):
calls.append({"url": url, "headers": headers, "json": json,
"timeout": timeout, "stream": stream})
return resp
monkeypatch.setattr("fam_edge.qa.requests.post", fake_post)
return calls
def test_run_qa_stream_first_provider_success():
qa = _orchestrator([_FakeAdapter("gemini", chunks=["", ""])])
def test_run_qa_success(monkeypatch):
qa = _orchestrator(monkeypatch)
resp = _FakeResp(payload={"choices": [{"message": {"content": "你好"}}],
"provider": "nvidia"})
calls = _capture_post(monkeypatch, resp)
answer, provider = qa.run_qa("hi", max_tokens=100)
assert answer == "你好"
assert provider == "nvidia"
assert calls[0]["json"] == {"messages": [{"role": "user", "content": "hi"}],
"max_tokens": 100, "stream": False}
assert calls[0]["headers"]["Authorization"] == "Bearer tok"
def test_run_qa_non_200_returns_none(monkeypatch):
qa = _orchestrator(monkeypatch)
resp = _FakeResp(status_code=503, text='{"error":{"message":"所有模型均不可用"}}')
_capture_post(monkeypatch, resp)
answer, provider = qa.run_qa("hi")
assert answer is None
assert provider is None
def test_run_qa_empty_answer_returns_none(monkeypatch):
qa = _orchestrator(monkeypatch)
resp = _FakeResp(payload={"choices": [{"message": {"content": ""}}], "provider": "gemini"})
_capture_post(monkeypatch, resp)
answer, provider = qa.run_qa("hi")
assert answer is None
assert provider is None
def test_run_qa_connection_error_returns_none(monkeypatch):
qa = _orchestrator(monkeypatch)
def _raise(*args, **kwargs):
raise ConnectionError("boom")
monkeypatch.setattr("fam_edge.qa.requests.post", _raise)
answer, provider = qa.run_qa("hi")
assert answer is None
assert provider is None
def _sse_lines(events):
lines = []
for e in events:
lines.append(f"data: {json.dumps(e, ensure_ascii=False)}")
lines.append("data: [DONE]")
return lines
def test_run_qa_stream_single_provider_success(monkeypatch):
qa = _orchestrator(monkeypatch)
lines = _sse_lines([
{"provider": "nvidia", "choices": [{"delta": {"content": ""}}]},
{"provider": "nvidia", "choices": [{"delta": {"content": ""}}]},
{"provider": "nvidia", "choices": [{"delta": {}}]},
])
resp = _FakeResp(lines=lines)
_capture_post(monkeypatch, resp)
events = list(qa.run_qa_stream("hi"))
types = [e["type"] for e in events]
assert types == ["provider_trying", "chunk", "chunk", "done"]
assert events[1]["text"] == ""
assert events[2]["text"] == ""
assert events[-1]["provider"] == "gemini"
def test_run_qa_stream_falls_back_when_first_yields_nothing():
"""核心诉求: 第一个 provider 一个字都没吐出来才允许换下一个——不是失败就切,
"完全没有产出"才切。"""
qa = _orchestrator([
_FakeAdapter("gemini", chunks=[]),
_FakeAdapter("nvidia", chunks=["答案"]),
])
events = list(qa.run_qa_stream("hi"))
types = [e["type"] for e in events]
assert types == ["provider_trying", "provider_failed", "provider_trying", "chunk", "done"]
assert events[-1]["provider"] == "nvidia"
def test_run_qa_stream_does_not_switch_after_partial_output():
"""核心诉求: 已经开始吐字之后中途失败,不能悄悄换下一个 provider 接着写
(会出现两段风格/内容不连贯的回答拼在一起)——直接结束这次生成。"""
class _PartialThenRaise:
provider_name = "gemini"
def chat_stream(self, prompt, max_tokens=512):
yield "先吐"
raise RuntimeError("connection reset")
def test_run_qa_stream_emits_provider_trying_once_per_change(monkeypatch):
"""provider 字段没变化时不该重复吐 provider_trying。"""
qa = _orchestrator(monkeypatch)
lines = _sse_lines([
{"provider": "nvidia", "choices": [{"delta": {"content": "a"}}]},
{"provider": "nvidia", "choices": [{"delta": {"content": "b"}}]},
])
resp = _FakeResp(lines=lines)
_capture_post(monkeypatch, resp)
events = list(qa.run_qa_stream("hi"))
trying = [e for e in events if e["type"] == "provider_trying"]
assert len(trying) == 1
assert trying[0]["provider"] == "nvidia"
qa = _orchestrator([_PartialThenRaise(), _FakeAdapter("nvidia", chunks=["不该被用到"])])
def test_run_qa_stream_no_chunks_yields_all_failed(monkeypatch):
qa = _orchestrator(monkeypatch)
resp = _FakeResp(lines=["data: [DONE]"])
_capture_post(monkeypatch, resp)
events = list(qa.run_qa_stream("hi"))
assert events == [{"type": "all_failed"}]
def test_run_qa_stream_non_200_yields_all_failed(monkeypatch):
qa = _orchestrator(monkeypatch)
resp = _FakeResp(status_code=503, text='{"error":{"message":"所有模型均不可用"}}')
_capture_post(monkeypatch, resp)
events = list(qa.run_qa_stream("hi"))
assert events == [{"type": "all_failed"}]
def test_run_qa_stream_connection_error_yields_all_failed(monkeypatch):
qa = _orchestrator(monkeypatch)
def _raise(*args, **kwargs):
raise ConnectionError("boom")
monkeypatch.setattr("fam_edge.qa.requests.post", _raise)
events = list(qa.run_qa_stream("hi"))
assert events == [{"type": "all_failed"}]
def test_run_qa_stream_error_chunk_stops_and_uses_partial_output(monkeypatch):
"""已经吐出过内容后遇到错误块:按"至少吐出过一块就算 done"处理,不是 all_failed。"""
qa = _orchestrator(monkeypatch)
lines = [
f"data: {json.dumps({'provider': 'gemini', 'choices': [{'delta': {'content': '先吐'}}]}, ensure_ascii=False)}",
f"data: {json.dumps({'error': {'message': 'boom'}}, ensure_ascii=False)}",
]
resp = _FakeResp(lines=lines)
_capture_post(monkeypatch, resp)
events = list(qa.run_qa_stream("hi"))
types = [e["type"] for e in events]
assert types == ["provider_trying", "chunk", "done"]
assert events[1]["text"] == "先吐"
assert events[-1]["provider"] == "gemini"
def test_run_qa_stream_all_providers_fail():
qa = _orchestrator([
_FakeAdapter("gemini", chunks=[]),
_FakeAdapter("nvidia", chunks=[], raises=True),
])
events = list(qa.run_qa_stream("hi"))
assert events[-1]["type"] == "all_failed"
assert "provider_failed" in [e["type"] for e in events]
def test_run_qa_stream_exception_treated_as_no_output():
qa = _orchestrator([_FakeAdapter("gemini", raises=True), _FakeAdapter("nvidia", chunks=["ok"])])
events = list(qa.run_qa_stream("hi"))
assert events[0] == {"type": "provider_trying", "provider": "gemini"}
assert events[1] == {"type": "provider_failed", "provider": "gemini"}
assert events[-1]["provider"] == "nvidia"
def test_run_qa_stream_sets_stream_true_and_utf8_encoding(monkeypatch):
qa = _orchestrator(monkeypatch)
resp = _FakeResp(lines=["data: [DONE]"])
calls = _capture_post(monkeypatch, resp)
list(qa.run_qa_stream("hi", max_tokens=222))
assert calls[0]["json"]["stream"] is True
assert calls[0]["json"]["max_tokens"] == 222
assert calls[0]["stream"] is True
assert resp.encoding == 'utf-8'