fix(chat): 流式问答中文乱码 + 问答模型链换成专用文字大模型

## 乱码修复
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 已加载),并非没启动——用户观察到的现象另有原因。
This commit is contained in:
ericwyuan
2026-08-23 07:46:48 +08:00
parent 22bd4d4ff9
commit 9bba7b7e14
7 changed files with 130 additions and 9 deletions

View File

@@ -174,6 +174,9 @@ def chat_ask_stream():
timeout=(10, timeout), stream=True) timeout=(10, timeout), stream=True)
if resp.status_code != 200: if resp.status_code != 200:
raise Exception(f"HTTP {resp.status_code}") 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): for line in resp.iter_lines(decode_unicode=True):
if not line or not line.startswith('data: '): if not line or not line.startswith('data: '):
continue continue
@@ -200,7 +203,7 @@ def chat_ask_stream():
context_summary=context_summary, context_summary=context_summary,
queried_date=queried_date, queried_person=queried_person) 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'}) headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})

View File

@@ -169,6 +169,67 @@ models:
threshold: 5 threshold: 5
cooldown: 300 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-latest1M 上下文,跟 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仅参与智能问答兜底 # 本地模型:纯文本 qwen2.5:7b仅参与智能问答兜底
- provider: "ollama" - provider: "ollama"
role: "text" role: "text"

View File

@@ -148,7 +148,10 @@ def chat_ask():
return jsonify({"error": "缺少必填字段: prompt"}), 400 return jsonify({"error": "缺少必填字段: prompt"}), 400
prompt = data['prompt'] prompt = data['prompt']
max_tokens = int(data.get('max_tokens', 1024)) # 默认值从 1024 提到 30722026-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) answer, provider = get_qa().run_qa(prompt, max_tokens=max_tokens)
if answer is None: if answer is None:
@@ -171,13 +174,19 @@ def chat_ask_stream():
return jsonify({"error": "缺少必填字段: prompt"}), 400 return jsonify({"error": "缺少必填字段: prompt"}), 400
prompt = data['prompt'] prompt = data['prompt']
max_tokens = int(data.get('max_tokens', 1024)) # 默认值从 1024 提到 30722026-08-23问答链路现在优先用推理类模型
# NVIDIA Nemotron-3 系列),回答前会先输出一段思考过程再给最终答案,
# 1024 经常在思考阶段就被截断,永远看不到真正的回答。
max_tokens = int(data.get('max_tokens', 3072))
def generate(): def generate():
for event in get_qa().run_qa_stream(prompt, max_tokens=max_tokens): for event in get_qa().run_qa_stream(prompt, max_tokens=max_tokens):
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n" 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'}) headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})

View File

@@ -37,8 +37,11 @@ class BaseModelAdapter(ABC):
def __init__(self, provider_name: str, config: dict): def __init__(self, provider_name: str, config: dict):
self.provider_name = provider_name # 如 "ollama", "gemini" self.provider_name = provider_name # 如 "ollama", "gemini"
self.config = config self.config = config
# 角色: vision=视觉分析, text=智能问答兜底(本地模型); 默认 vision # 角色: vision=视觉分析, text=智能问答; 默认 vision
self.role = config.get('role', '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) # hook(provider, model, started_at, duration_sec, success, error)
self.model_call_hook = None self.model_call_hook = None

View File

@@ -494,6 +494,10 @@ class GeminiAdapter(BaseModelAdapter):
logger.warning(f"Gemini {key_label} [{model}] 流式问答 HTTP {resp.status_code}") logger.warning(f"Gemini {key_label} [{model}] 流式问答 HTTP {resp.status_code}")
resp.close() resp.close()
continue continue
# Gemini 响应体固定是 UTF-8但 Content-Type 没带 charset 参数,
# requests 会自己猜编码(猜错会把中文变成乱码)——强制指定,
# 不依赖 requests 的自动嗅探。
resp.encoding = 'utf-8'
try: try:
for line in resp.iter_lines(decode_unicode=True): for line in resp.iter_lines(decode_unicode=True):
if not line or not line.startswith('data: '): if not line or not line.startswith('data: '):

View File

@@ -1,9 +1,19 @@
""" """
QA - 智能问答编排 QA - 智能问答编排
run_qa(prompt): 按 models 顺序尝试 chat(),首个成功返回 (answer, provider)。 问答链路2026-08-23 重构)跟视频分析链路完全独立,不再复用视频分析用的
顺序 = vision 模型(Gemini -> NVIDIA) + text 模型(Ollama 兜底)。 Gemini flash / NVIDIA omni 模型:
即 Gemini -> NVIDIA -> Ollama 三级降级。 role='text' 的适配器才参与问答,按 config.yaml 里 models 数组的出现顺序
依次尝试 chat()/chat_stream()首个成功即用。role='vision' 的适配器
(视频分析用的 Gemini flash-latest、NVIDIA omni完全不参与问答。
当前链路config.yaml 里对应 usage 标记,仅供人读,编排逻辑只看 role+顺序):
1. NVIDIA 文字模型链usage=qa_primarynemotron-3-ultra-550b-a55b ->
nemotron-3-super-120b-a12b -> gpt-oss-120b同一个 NvidiaVisionAdapter
实例内部 model_chain 依次降级,见该适配器 chat()
2. Gemini 非 flash 文字模型usage=qa_primarygemini-pro-latest ->
gemini-2.5-pro
3. 本地 Ollama qwen2.5:7busage=qa_fallback兜底
""" """
from typing import Optional, Tuple from typing import Optional, Tuple
@@ -17,7 +27,10 @@ logger = setup_logger('fam-edge.qa')
class QAOrchestrator: class QAOrchestrator:
def __init__(self): def __init__(self):
self.config = load_config() 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, def run_qa(self, prompt: str,
max_tokens: int = 1024) -> Tuple[Optional[str], Optional[str]]: max_tokens: int = 1024) -> Tuple[Optional[str], Optional[str]]:

View File

@@ -1,6 +1,34 @@
from fam_edge.qa import QAOrchestrator 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: class _FakeAdapter:
def __init__(self, provider_name, chunks=None, raises=False): def __init__(self, provider_name, chunks=None, raises=False):
self.provider_name = provider_name self.provider_name = provider_name