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,79 +1,136 @@
|
||||
"""
|
||||
QA - 智能问答编排
|
||||
QA - 智能问答代理客户端(2026-08-23 问答链路整体抽离到独立 ai-gateway 服务后重写)
|
||||
|
||||
问答链路(2026-08-23 重构)跟视频分析链路完全独立,不再复用视频分析用的
|
||||
Gemini flash / NVIDIA omni 模型:
|
||||
role='text' 的适配器才参与问答,按 config.yaml 里 models 数组的出现顺序
|
||||
依次尝试 chat()/chat_stream(),首个成功即用。role='vision' 的适配器
|
||||
(视频分析用的 Gemini flash-latest、NVIDIA omni)完全不参与问答。
|
||||
原来的问答编排本体(NVIDIA 文字模型链 -> Gemini 非 flash 文字模型 -> 本地
|
||||
Ollama 兜底,含 key 轮换/熔断/降级)已经整个搬到独立的 ai-gateway 服务
|
||||
(OpenAI 兼容协议 /v1/chat/completions),跟视频分析业务解耦,别的项目也能
|
||||
直接用 OpenAI SDK 接入。fam-edge 这边现在只是一个转发客户端:调 ai-gateway,
|
||||
把它的 OpenAI 格式响应翻译回 fam-edge 原有的 (answer, provider) / 流式事件
|
||||
字典契约,上层 api_gateway.py 的 /api/edge/chat/ask(/stream) 端点和 fam-core
|
||||
的调用方完全不用改。
|
||||
|
||||
当前链路(config.yaml 里对应 usage 标记,仅供人读,编排逻辑只看 role+顺序):
|
||||
1. NVIDIA 文字模型链(usage=qa_primary):nemotron-3-ultra-550b-a55b ->
|
||||
nemotron-3-super-120b-a12b -> gpt-oss-120b(同一个 NvidiaVisionAdapter
|
||||
实例内部 model_chain 依次降级,见该适配器 chat())
|
||||
2. Gemini 非 flash 文字模型(usage=qa_primary):gemini-pro-latest ->
|
||||
gemini-2.5-pro
|
||||
3. 本地 Ollama qwen2.5:7b(usage=qa_fallback,兜底)
|
||||
多 provider 之间失败降级(一个模型没吐出任何内容才换下一个、已经开始吐字后
|
||||
中途失败不悄悄换源)现在整个发生在 ai-gateway 内部,对这个客户端不可见——
|
||||
本客户端只会看到最终成功 provider 的分块流,或者全部失败时的空流。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
from .logger import setup_logger
|
||||
from .config_loader import load_config
|
||||
from .model_adapters.adapter_factory import build_adapters
|
||||
|
||||
logger = setup_logger('fam-edge.qa')
|
||||
|
||||
|
||||
class QAOrchestrator:
|
||||
def __init__(self):
|
||||
self.config = load_config()
|
||||
all_adapters = build_adapters(self.config.get('models', []))
|
||||
# 只有 role='text' 的适配器参与问答;按 config.yaml 里的出现顺序决定
|
||||
# 降级顺序,不需要额外的 qa_order 配置——顺序即优先级。
|
||||
self.adapters = [a for a in all_adapters if a.get_role() == 'text']
|
||||
cfg = load_config().get('ai_gateway', {})
|
||||
self.base_url = (cfg.get('base_url') or 'http://127.0.0.1:5100').rstrip('/')
|
||||
self.token = self._resolve_token(cfg.get('token', ''))
|
||||
self.timeout = cfg.get('timeout', 60)
|
||||
|
||||
def _resolve_token(self, raw: str) -> str:
|
||||
if raw.startswith('${') and raw.endswith('}'):
|
||||
return os.environ.get(raw[2:-1], '')
|
||||
return raw
|
||||
|
||||
def _headers(self) -> dict:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
return headers
|
||||
|
||||
def run_qa(self, prompt: str,
|
||||
max_tokens: int = 1024) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""依次尝试各适配器的 chat(),返回 (answer, provider)。"""
|
||||
for adapter in self.adapters:
|
||||
try:
|
||||
answer = adapter.chat(prompt, max_tokens=max_tokens)
|
||||
except Exception as e:
|
||||
logger.warning(f"QA {adapter.provider_name} 异常: {e}")
|
||||
continue
|
||||
if answer:
|
||||
logger.info(f"QA 命中 provider={adapter.provider_name}")
|
||||
return answer, adapter.provider_name
|
||||
logger.info(f"QA {adapter.provider_name} 无返回,降级下一模型")
|
||||
return None, None
|
||||
"""调 ai-gateway 非流式接口,返回 (answer, provider)。"""
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{self.base_url}/v1/chat/completions",
|
||||
headers=self._headers(),
|
||||
json={"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": max_tokens, "stream": False},
|
||||
timeout=self.timeout)
|
||||
except Exception as e:
|
||||
logger.warning(f"QA ai-gateway 请求异常: {e}")
|
||||
return None, None
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"QA ai-gateway 返回 {resp.status_code}: {resp.text[:200]}")
|
||||
return None, None
|
||||
try:
|
||||
data = resp.json()
|
||||
answer = data["choices"][0]["message"]["content"]
|
||||
except Exception as e:
|
||||
logger.warning(f"QA ai-gateway 响应解析失败: {e}")
|
||||
return None, None
|
||||
if not answer:
|
||||
return None, None
|
||||
provider = data.get("provider")
|
||||
logger.info(f"QA 命中 provider={provider}")
|
||||
return answer, provider
|
||||
|
||||
def run_qa_stream(self, prompt: str, max_tokens: int = 1024):
|
||||
"""流式版:依次尝试各适配器的 chat_stream(),yield 结构化事件字典。
|
||||
"""流式版:转发 ai-gateway 的 SSE 分块,翻译回原有事件字典契约。
|
||||
|
||||
事件类型:
|
||||
{"type":"provider_trying","provider":p} 开始尝试这个 provider
|
||||
{"type":"chunk","provider":p,"text":t} 这个 provider 吐出的文本增量
|
||||
{"type":"provider_failed","provider":p} 这个 provider 一个字都没吐出就失败,换下一个
|
||||
{"type":"done","provider":p} 成功结束(这个 provider 至少吐出过一块)
|
||||
{"type":"all_failed"} 所有 provider 都失败
|
||||
|
||||
跟 run_qa 一样"仅在还没吐出任何文本时才允许换下一个 provider"——一旦
|
||||
开始给用户看字了,中途失败就结束这次生成,不再悄悄换源接着写。
|
||||
{"type":"provider_trying","provider":p} 流里第一次看到这个 provider
|
||||
{"type":"chunk","provider":p,"text":t} 文本增量
|
||||
{"type":"done","provider":p} 成功结束(至少吐出过一块)
|
||||
{"type":"all_failed"} 请求失败或没有任何文本产出
|
||||
"""
|
||||
for adapter in self.adapters:
|
||||
yield {"type": "provider_trying", "provider": adapter.provider_name}
|
||||
got_any = False
|
||||
try:
|
||||
for chunk in adapter.chat_stream(prompt, max_tokens=max_tokens):
|
||||
if chunk:
|
||||
got_any = True
|
||||
yield {"type": "chunk", "provider": adapter.provider_name, "text": chunk}
|
||||
except Exception as e:
|
||||
logger.warning(f"QA {adapter.provider_name} 流式异常: {e}")
|
||||
if got_any:
|
||||
logger.info(f"QA 流式命中 provider={adapter.provider_name}")
|
||||
yield {"type": "done", "provider": adapter.provider_name}
|
||||
return
|
||||
logger.info(f"QA {adapter.provider_name} 流式无返回,降级下一模型")
|
||||
yield {"type": "provider_failed", "provider": adapter.provider_name}
|
||||
yield {"type": "all_failed"}
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{self.base_url}/v1/chat/completions",
|
||||
headers=self._headers(),
|
||||
json={"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": max_tokens, "stream": True},
|
||||
timeout=self.timeout, stream=True)
|
||||
except Exception as e:
|
||||
logger.warning(f"QA ai-gateway 流式请求异常: {e}")
|
||||
yield {"type": "all_failed"}
|
||||
return
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"QA ai-gateway 流式返回 {resp.status_code}: {resp.text[:200]}")
|
||||
yield {"type": "all_failed"}
|
||||
return
|
||||
# 响应体固定 UTF-8,但 Content-Type 不一定带 charset,requests 会自己猜
|
||||
# 编码——猜错就是中文乱码,强制指定跳过嗅探(同源坑见 gemini_adapter 历史修复)。
|
||||
resp.encoding = 'utf-8'
|
||||
|
||||
current_provider = None
|
||||
got_any = False
|
||||
try:
|
||||
for line in resp.iter_lines(decode_unicode=True):
|
||||
if not line or not line.startswith('data: '):
|
||||
continue
|
||||
payload = line[len('data: '):]
|
||||
if payload == '[DONE]':
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(payload)
|
||||
except ValueError:
|
||||
continue
|
||||
if 'error' in chunk:
|
||||
logger.warning(f"QA ai-gateway 流式错误: {chunk['error']}")
|
||||
break
|
||||
provider = chunk.get('provider')
|
||||
if provider and provider != current_provider:
|
||||
current_provider = provider
|
||||
yield {"type": "provider_trying", "provider": provider}
|
||||
choices = chunk.get('choices') or []
|
||||
if not choices:
|
||||
continue
|
||||
text = (choices[0].get('delta') or {}).get('content')
|
||||
if text:
|
||||
got_any = True
|
||||
yield {"type": "chunk", "provider": current_provider, "text": text}
|
||||
except Exception as e:
|
||||
logger.warning(f"QA ai-gateway 流式读取异常: {e}")
|
||||
|
||||
if got_any:
|
||||
logger.info(f"QA 流式命中 provider={current_provider}")
|
||||
yield {"type": "done", "provider": current_provider}
|
||||
else:
|
||||
yield {"type": "all_failed"}
|
||||
|
||||
Reference in New Issue
Block a user