diff --git a/fam-core/src/fam_core/chat_handler/chat_handler.py b/fam-core/src/fam_core/chat_handler/chat_handler.py index 25587a2..c67b34d 100644 --- a/fam-core/src/fam_core/chat_handler/chat_handler.py +++ b/fam-core/src/fam_core/chat_handler/chat_handler.py @@ -174,6 +174,9 @@ def chat_ask_stream(): timeout=(10, timeout), stream=True) if resp.status_code != 200: raise Exception(f"HTTP {resp.status_code}") + # 甲骨文那边的响应体固定是 UTF-8,但 Content-Type 不一定带 charset + # 参数,requests 会自己猜编码——猜错就是中文乱码,强制指定跳过嗅探。 + resp.encoding = 'utf-8' for line in resp.iter_lines(decode_unicode=True): if not line or not line.startswith('data: '): continue @@ -200,7 +203,7 @@ def chat_ask_stream(): context_summary=context_summary, queried_date=queried_date, queried_person=queried_person) - return Response(stream_with_context(generate()), mimetype='text/event-stream', + return Response(stream_with_context(generate()), mimetype='text/event-stream; charset=utf-8', headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}) diff --git a/fam-edge/config/config.yaml b/fam-edge/config/config.yaml index 5513313..b7514b4 100644 --- a/fam-edge/config/config.yaml +++ b/fam-edge/config/config.yaml @@ -169,6 +169,67 @@ models: threshold: 5 cooldown: 300 + # 问答专用 NVIDIA 文字模型链(2026-08-23 新增,跟上面视频分析用的 omni 模型 + # 完全独立):用户要求问答不用 flash/omni,优先找 NVIDIA 免费文字模型里上下文 + # 最大的几个。实测(2026-08-23)在当前账号可用、非 deprecated 的候选里: + # nemotron-3-ultra-550b-a55b: 1M 上下文,561B,最强,free endpoint 已验证可调 + # nemotron-3-super-120b-a12b: 1M 上下文,124B,同为 Nemotron-3 系列备份 + # openai/gpt-oss-120b: 131K 上下文,117B,不同厂商备份(Nemotron 系列整体 + # 出问题时的多样性兜底) + # 淘汰原因记录:meta/llama-3.1-70b-instruct、nvidia/llama-3.3-nemotron- + # super-49b-v1.5 均已收到 "will be deprecated on 08/25/2026" 通知,不用; + # nvidia/llama-3.1-nemotron-ultra-253b-v1、mistralai/mistral-large-2-instruct、 + # nvidia/nemotron-4-340b-instruct、moonshotai/kimi-k2.6 在 /v1/models 目录 + # 里能看到,但实测调用 chat.completions 返回 404 "Not found for account" + # (免费层没有这些模型的调用权限,文档列出不代表能调)。 + - provider: "nvidia" + role: "text" + usage: "qa_primary" + enabled: true + model_name: "nvidia/nemotron-3-ultra-550b-a55b" + fallback_models: + - "nvidia/nemotron-3-super-120b-a12b" + - "openai/gpt-oss-120b" + base_url: "https://integrate.api.nvidia.com/v1" + api_key: "${NVIDIA_API_KEY}" + timeout: 600 + # 这几个都是"推理"模型,回答前会先输出一段思考过程再给最终答案,比普通模型 + # 更费 token/更慢,问答超时给宽松一点(不是简单文字模型那种 20s 就该出结果) + chat_timeout: 45 + switch_interval_sec: 3 + circuit_breaker: + enabled: true + threshold: 5 + cooldown: 300 + + # 问答专用 Gemini 非 flash 文字模型(2026-08-23 新增):用户明确要求问答不用 + # flash,这里走 gemini-pro-latest(1M 上下文,跟 gemini-flash-latest 同样的 + # "-latest" 别名习惯,自动跟最新 pro 版本),失败再退 gemini-2.5-pro。 + # key 复用视频分析同一批(各自独立 Google Cloud 项目,配额互不影响)。 + - provider: "gemini" + role: "text" + usage: "qa_primary" + enabled: true + model_name: "gemini-pro-latest" + fallback_models: + - "gemini-2.5-pro" + api_key: "${GEMINI_API_KEY}" + extra_api_keys: + - "${GEMINI_API_KEY_2}" + - "${GEMINI_API_KEY_3}" + - "${GEMINI_API_KEY_4}" + key_labels: + - "智能摄像头-1" + - "智能摄像头-2" + - "智能摄像头-3" + - "智能摄像头-4" + timeout: 60 + chat_timeout: 30 + circuit_breaker: + enabled: true + threshold: 5 + cooldown: 300 + # 本地模型:纯文本 qwen2.5:7b,仅参与智能问答兜底 - provider: "ollama" role: "text" diff --git a/fam-edge/src/fam_edge/api_gateway/api_gateway.py b/fam-edge/src/fam_edge/api_gateway/api_gateway.py index f488328..3c9a41b 100644 --- a/fam-edge/src/fam_edge/api_gateway/api_gateway.py +++ b/fam-edge/src/fam_edge/api_gateway/api_gateway.py @@ -148,7 +148,10 @@ def chat_ask(): return jsonify({"error": "缺少必填字段: prompt"}), 400 prompt = data['prompt'] - max_tokens = int(data.get('max_tokens', 1024)) + # 默认值从 1024 提到 3072(2026-08-23):问答链路现在优先用推理类模型 + # (NVIDIA Nemotron-3 系列),回答前会先输出一段思考过程再给最终答案, + # 1024 经常在思考阶段就被截断,永远看不到真正的回答。 + max_tokens = int(data.get('max_tokens', 3072)) answer, provider = get_qa().run_qa(prompt, max_tokens=max_tokens) if answer is None: @@ -171,13 +174,19 @@ def chat_ask_stream(): return jsonify({"error": "缺少必填字段: prompt"}), 400 prompt = data['prompt'] - max_tokens = int(data.get('max_tokens', 1024)) + # 默认值从 1024 提到 3072(2026-08-23):问答链路现在优先用推理类模型 + # (NVIDIA Nemotron-3 系列),回答前会先输出一段思考过程再给最终答案, + # 1024 经常在思考阶段就被截断,永远看不到真正的回答。 + max_tokens = int(data.get('max_tokens', 3072)) def generate(): for event in get_qa().run_qa_stream(prompt, max_tokens=max_tokens): yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n" - return Response(generate(), mimetype='text/event-stream', + # mimetype 显式带 charset=utf-8:响应体本身一直是 UTF-8,但不声明的话下游 + # (fam-core 转发这一跳、或任何用 requests 消费这个流的客户端)会自己猜 + # 编码,猜错就是中文乱码——跟 gemini_adapter.py chat_stream() 那个坑同源。 + return Response(generate(), mimetype='text/event-stream; charset=utf-8', headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}) diff --git a/fam-edge/src/fam_edge/model_adapters/base_adapter.py b/fam-edge/src/fam_edge/model_adapters/base_adapter.py index 0af2860..ee00222 100644 --- a/fam-edge/src/fam_edge/model_adapters/base_adapter.py +++ b/fam-edge/src/fam_edge/model_adapters/base_adapter.py @@ -37,8 +37,11 @@ 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=智能问答; 默认 vision 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 diff --git a/fam-edge/src/fam_edge/model_adapters/gemini_adapter.py b/fam-edge/src/fam_edge/model_adapters/gemini_adapter.py index 0b26208..2efb7da 100644 --- a/fam-edge/src/fam_edge/model_adapters/gemini_adapter.py +++ b/fam-edge/src/fam_edge/model_adapters/gemini_adapter.py @@ -494,6 +494,10 @@ class GeminiAdapter(BaseModelAdapter): 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: '): diff --git a/fam-edge/src/fam_edge/qa.py b/fam-edge/src/fam_edge/qa.py index 7cf8eb1..49aa535 100644 --- a/fam-edge/src/fam_edge/qa.py +++ b/fam-edge/src/fam_edge/qa.py @@ -1,9 +1,19 @@ """ QA - 智能问答编排 -run_qa(prompt): 按 models 顺序尝试 chat(),首个成功返回 (answer, provider)。 -顺序 = vision 模型(Gemini -> NVIDIA) + text 模型(Ollama 兜底)。 -即 Gemini -> NVIDIA -> Ollama 三级降级。 +问答链路(2026-08-23 重构)跟视频分析链路完全独立,不再复用视频分析用的 +Gemini flash / NVIDIA omni 模型: + role='text' 的适配器才参与问答,按 config.yaml 里 models 数组的出现顺序 + 依次尝试 chat()/chat_stream(),首个成功即用。role='vision' 的适配器 + (视频分析用的 Gemini flash-latest、NVIDIA omni)完全不参与问答。 + +当前链路(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,兜底) """ from typing import Optional, Tuple @@ -17,7 +27,10 @@ logger = setup_logger('fam-edge.qa') class QAOrchestrator: def __init__(self): self.config = load_config() - self.adapters = build_adapters(self.config.get('models', [])) + 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'] def run_qa(self, prompt: str, max_tokens: int = 1024) -> Tuple[Optional[str], Optional[str]]: diff --git a/fam-edge/tests/test_qa.py b/fam-edge/tests/test_qa.py index 56e005d..28b9923 100644 --- a/fam-edge/tests/test_qa.py +++ b/fam-edge/tests/test_qa.py @@ -1,6 +1,34 @@ 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 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) + qa = QAOrchestrator() + assert [a.role for a in qa.adapters] == ["text", "text", "text"] + assert len(qa.adapters) == 3 + + class _FakeAdapter: def __init__(self, provider_name, chunks=None, raises=False): self.provider_name = provider_name