## 乱码修复 Gemini 流式接口 resp.iter_lines(decode_unicode=True) 没有显式设置 resp.encoding,requests 自己猜编码猜错了(Gemini 的 SSE 响应 Content-Type 不带 charset 参数),导致中文变成典型的 UTF-8 被当 Latin-1 解码的乱码。 fam-core 转发这一跳的 requests.iter_lines 调用有同样的坑,一并修了。 两处响应的 Content-Type 都补上 charset=utf-8,减少下游再犯同样错误的机会。 顺手用 latin-1/utf-8 往返把 NAS chat_history 里已经存了乱码的 3 条历史记录 恢复成正常文字。 ## 问答模型链重新设计(跟视频分析完全独立) 用户要求问答不再用 flash/omni,优先用 NVIDIA 免费文字模型里上下文最大的几个, 不行再退 Gemini 非 flash 文字模型,最后本地 Ollama 兜底。 - base_adapter 新增 usage 字段(纯标记,不影响行为) - qa.py 的 QAOrchestrator 只挑 role='text' 的适配器参与问答(按 config 出现 顺序决定降级顺序),role='vision' 的视频分析适配器(gemini-flash-latest / nvidia omni)不再参与问答,两条链路彻底解耦 - 新增问答专用 NVIDIA 文字模型链(同一适配器内部 model_chain 降级): nemotron-3-ultra-550b-a55b(1M上下文/561B) -> nemotron-3-super-120b-a12b (1M上下文/124B) -> openai/gpt-oss-120b(131K上下文/117B)。三个都用真实 API 调用验证过当前账号可访问;同时验证过 llama-3.1-70b-instruct 和 nemotron-super-49b-v1.5 都收到"08/25/2026 起弃用"通知,排除; nemotron-ultra-253b/mistral-large-2/nemotron-4-340b/kimi-k2.6 在目录里 能看到但实测调用返回 404"账号无权限",排除 - 新增问答专用 Gemini 非 flash 文字模型:gemini-pro-latest -> gemini-2.5-pro (1M 上下文,复用视频分析同一批多 key 轮换) - /api/edge/chat/ask(/stream) 默认 max_tokens 从 1024 提到 3072:新链路 优先用的都是"推理"模型,回答前会先吐一段思考过程,1024 经常在思考阶段 就被截断,用户永远看不到真正答案 新增 test_qa.py::test_init_only_keeps_text_role_adapters_in_config_order 验证角色过滤+顺序正确。 本地检查过 Ollama 服务本身:systemd enabled+running(8h 正常运行, qwen2.5:7b 已加载),并非没启动——用户观察到的现象另有原因。
80 lines
4.0 KiB
Python
80 lines
4.0 KiB
Python
"""
|
||
QA - 智能问答编排
|
||
|
||
问答链路(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
|
||
|
||
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']
|
||
|
||
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
|
||
|
||
def run_qa_stream(self, prompt: str, max_tokens: int = 1024):
|
||
"""流式版:依次尝试各适配器的 chat_stream(),yield 结构化事件字典。
|
||
|
||
事件类型:
|
||
{"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"——一旦
|
||
开始给用户看字了,中途失败就结束这次生成,不再悄悄换源接着写。
|
||
"""
|
||
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"}
|