- token.sensenova.cn托管同款deepseek-v4-flash,实测3.2s vs OCG 45s(开盘高峰) - 通道顺序: sensenova→OCG key池→gateway; 空输出直接切下一通道(SN无pro)
325 lines
14 KiB
Python
325 lines
14 KiB
Python
#!/usr/bin/env python3
|
||
"""llm_client.py — 共享 LLM 客户端(直连上游 + gateway 兜底)
|
||
|
||
⚠️ 架构说明(2026-07-21 事故后重写):
|
||
hermes gateway(:8643) 的 /v1/chat/completions 不是透传,而是完整 agent 运行时——
|
||
每个请求都会创建一个带工具(terminal/websearch/patch 等)的 agent 会话。
|
||
曾导致:一次重评请求螺旋 35 分钟、44 次 terminal 调用、输入累积到 153k token,
|
||
客户端 150s 超时后服务端继续空转,重试又叠加新会话,网关被自己人打满。
|
||
|
||
因此重评等批量分析调用【直连 OCG 上游】(裸 completion,无 agent),
|
||
hermes gateway 仅作兜底。监控仍可用 gateway_alive() 观察网关健康。
|
||
|
||
用法:
|
||
from llm_client import call_llm, REASSESS_MODEL, gateway_alive, ocg_alive
|
||
result = call_llm(prompt)
|
||
if result["ok"]:
|
||
text = result["content"]
|
||
"""
|
||
|
||
import json
|
||
import time
|
||
import urllib.request
|
||
import urllib.error
|
||
|
||
# ── 常量:所有重评调用统一使用 ──
|
||
# 2026-07-21 A/B 实测:flash 与 pro 在新 prompt 下质量差距微弱,flash 快 ~40%
|
||
REASSESS_MODEL = "deepseek-v4-flash"
|
||
# flash 对某些 prompt 会稳定返回空内容(688617 实测三连空),pro 能正常输出。
|
||
# 空输出时自动升级到 pro 重试一次。
|
||
FALLBACK_MODEL = "deepseek-v4-pro"
|
||
|
||
# 主通道:OCG 上游直连(与 hermes providers.ocg-key6 同源)
|
||
# key 运行时从 hermes config 读取(SSOT,不落盘到代码库)
|
||
OCG_URL = "https://opencode.ai/zen/go/v1/chat/completions"
|
||
_HERMES_CONFIG = "/home/hmo/.hermes/profiles/position-analyst/config.yaml"
|
||
|
||
|
||
def _load_ocg_key():
|
||
"""从 hermes config.yaml 读取 ocg-key6(单点数据源)。读不到则禁用直连通道。"""
|
||
import re
|
||
try:
|
||
with open(_HERMES_CONFIG, encoding="utf-8") as f:
|
||
text = f.read()
|
||
m = re.search(r'ocg-key6:\s*\n\s*api_key:\s*(\S+)', text)
|
||
if m:
|
||
return m.group(1)
|
||
except Exception as e:
|
||
print(f" [LLM] 无法从 hermes config 读取 ocg-key6: {e}", flush=True)
|
||
return None
|
||
|
||
|
||
def _load_key_pool():
|
||
"""从 hermes config.yaml 解析全部 OCG key,按 AgentsMeeting /api/keys 的
|
||
实时状态排序(可用优先、用量低优先)。失败回退到单 key6。
|
||
返回 [(key_id, api_key), ...],按优先级排序。"""
|
||
import re
|
||
fallback = [("key6", _load_ocg_key())] if _load_ocg_key() else []
|
||
try:
|
||
with open(_HERMES_CONFIG, encoding="utf-8") as f:
|
||
text = f.read()
|
||
# 解析 providers 下的 ocg 块: name + api_key
|
||
blocks = re.findall(r'^\s{2}(ocg[\w\-]*):\s*\n\s*api_key:\s*(\S+)', text, re.M)
|
||
if not blocks:
|
||
return fallback
|
||
# provider名 → key_id 映射(ocg-keyN→keyN, ocg-3→key3, ocg-hy3→key1, ocg-new→key2)
|
||
name2id = {"ocg-key6": "key6", "ocg-key5": "key5", "ocg-key4": "key4",
|
||
"ocg-3": "key3", "ocg-hy3": "key1", "ocg-new": "key2"}
|
||
kv = {}
|
||
for name, key in blocks:
|
||
kid = name2id.get(name)
|
||
if kid and kid != "key7":
|
||
kv[kid] = key
|
||
if not kv:
|
||
return fallback
|
||
# 查询 AgentsMeeting /api/keys 实时状态
|
||
import json as _json
|
||
status = {}
|
||
try:
|
||
req = urllib.request.Request("http://127.0.0.1:5803/api/keys")
|
||
with urllib.request.build_opener(urllib.request.ProxyHandler({})).open(req, timeout=5) as r:
|
||
data = _json.loads(r.read().decode())
|
||
for k in data.get("keys", []):
|
||
m_ok = k.get("monthly", {}).get("status") == "ok"
|
||
r_ok = k.get("rolling", {}).get("status") == "ok"
|
||
usage = k.get("monthly", {}).get("usage_percent", 99)
|
||
status[k["key_id"]] = (m_ok and r_ok, usage)
|
||
except Exception:
|
||
# API 不可达时默认全可用、key6 优先
|
||
order = ["key6", "key5", "key4", "key2", "key1", "key3"]
|
||
return [(k, kv[k]) for k in order if k in kv]
|
||
# 可用且用量低优先;key6 在用量相同时优先(沿用现有配置)
|
||
scored = []
|
||
for kid, key in kv.items():
|
||
ok, usage = status.get(kid, (True, 50))
|
||
if ok:
|
||
scored.append((usage - (2 if kid == "key6" else 0), kid, key))
|
||
scored.sort()
|
||
pool = [(kid, key) for _, kid, key in scored]
|
||
return pool or fallback
|
||
except Exception as e:
|
||
print(f" [LLM] key池加载失败,回退单key: {e}", flush=True)
|
||
return fallback
|
||
|
||
|
||
_KEY_POOL = None
|
||
_KEY_POOL_TS = 0
|
||
|
||
|
||
def _get_key_pool():
|
||
"""key 池缓存 5 分钟。LLM_KEY_OFFSET 环境变量:并发 worker 起始 key 错开,
|
||
避免 N 个进程同时压同一个首选 key(2026-07-24 并发分片配套)。"""
|
||
global _KEY_POOL, _KEY_POOL_TS
|
||
import time as _t, os as _os
|
||
if not _KEY_POOL or (_t.time() - _KEY_POOL_TS) > 300:
|
||
_KEY_POOL = _load_key_pool()
|
||
_KEY_POOL_TS = _t.time()
|
||
if _KEY_POOL:
|
||
_off = int(_os.environ.get("LLM_KEY_OFFSET", "0") or 0)
|
||
if _off and len(_KEY_POOL) > 1:
|
||
_off = _off % len(_KEY_POOL)
|
||
_KEY_POOL = _KEY_POOL[_off:] + _KEY_POOL[:_off]
|
||
print(f" [LLM] key池: {[k for k, _ in _KEY_POOL]}" + (f" (offset={_off})" if _off else ""), flush=True)
|
||
return _KEY_POOL
|
||
|
||
|
||
_OCG_KEY = _load_ocg_key()
|
||
OCG_HEADERS = {
|
||
"Content-Type": "application/json",
|
||
"Authorization": f"Bearer {_OCG_KEY}",
|
||
"User-Agent": "curl/8.5.0", # OCG UA 风控要求(与 hermes 配置一致)
|
||
} if _OCG_KEY else None
|
||
|
||
# 兜底通道:hermes gateway(注意:会走 agent 运行时,仅应急)
|
||
GATEWAY = "http://127.0.0.1:8643/v1/chat/completions"
|
||
GATEWAY_MODELS = "http://127.0.0.1:8643/v1/models"
|
||
GATEWAY_AUTH = "Bearer hermes123"
|
||
|
||
# ── SenseNova 备用通道(2026-07-24 老爸:并发要快)──
|
||
# token.sensenova.cn 托管同款 deepseek-v4-flash,实测 3.2s vs OCG 45s(开盘高峰)。
|
||
# 公测免费,与 OCG 不同上游、不共享拥塞。不支持 deepseek-v4-pro 升级(404)。
|
||
# key 从 hermes config 的 sense-nova provider 读取(SSOT,不落盘)。
|
||
SN_URL = "https://token.sensenova.cn/v1/chat/completions"
|
||
|
||
|
||
def _load_sn_key():
|
||
"""从 hermes config.yaml 读 sense-nova 的 api_key。读不到则禁用该通道。"""
|
||
import re
|
||
try:
|
||
with open(_HERMES_CONFIG, encoding="utf-8") as f:
|
||
text = f.read()
|
||
m = re.search(r'sense-nova:\s*\n\s*api_key:\s*(\S+)', text)
|
||
if m:
|
||
return m.group(1)
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
|
||
_SN_KEY = _load_sn_key()
|
||
SN_HEADERS = {
|
||
"Content-Type": "application/json",
|
||
"Authorization": f"Bearer {_SN_KEY}",
|
||
"User-Agent": "curl/8.5.0",
|
||
} if _SN_KEY else None
|
||
|
||
|
||
def _post(url, headers, payload, timeout):
|
||
"""单次 POST,返回 (ok, content_or_error)。"""
|
||
req = urllib.request.Request(url, data=payload, headers=headers)
|
||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||
resp = opener.open(req, timeout=timeout)
|
||
body = json.loads(resp.read().decode())
|
||
if "choices" not in body:
|
||
return False, f"API响应无choices字段: {str(body)[:200]}"
|
||
return True, body["choices"][0]["message"]["content"]
|
||
|
||
|
||
def ocg_alive(timeout=8):
|
||
"""OCG 上游可达性快检(极小请求)。key 缺失直接 False。"""
|
||
if not OCG_HEADERS:
|
||
return False
|
||
try:
|
||
payload = json.dumps({
|
||
"model": REASSESS_MODEL,
|
||
"messages": [{"role": "user", "content": "ping"}],
|
||
"max_tokens": 1,
|
||
}).encode()
|
||
_post(OCG_URL, OCG_HEADERS, payload, timeout)
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def gateway_alive(timeout=5):
|
||
"""快速预检 hermes gateway 是否存活。失败立刻返回 False,不阻塞。"""
|
||
try:
|
||
req = urllib.request.Request(GATEWAY_MODELS,
|
||
headers={"Authorization": GATEWAY_AUTH})
|
||
urllib.request.build_opener(urllib.request.ProxyHandler({})).open(req, timeout=timeout)
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def call_llm(prompt, model=None, max_tokens=4096, timeout=150,
|
||
retries=1, backoff=20, system=None):
|
||
"""调用 LLM:OCG 直连优先,hermes gateway 兜底。带重试和结构化日志。
|
||
|
||
Args:
|
||
prompt: 用户消息内容
|
||
model: 模型名(默认 REASSESS_MODEL)
|
||
max_tokens: 最大输出 token 数
|
||
timeout: 单次调用超时(秒)
|
||
retries: 每个通道的失败重试次数
|
||
backoff: 重试间隔(秒)
|
||
system: 可选 system message
|
||
|
||
Returns:
|
||
{ok, content, error, model, elapsed, attempts, channel}
|
||
永远不抛异常到调用方。
|
||
"""
|
||
model_name = model or REASSESS_MODEL
|
||
# OCG/deepseek 对过小 max_tokens 会短路返回空(2026-07-22 实测 max_tokens=64 必空)
|
||
if max_tokens < 512:
|
||
max_tokens = 512
|
||
messages = []
|
||
if system:
|
||
messages.append({"role": "system", "content": system})
|
||
messages.append({"role": "user", "content": prompt})
|
||
payload = json.dumps({
|
||
"model": model_name,
|
||
"messages": messages,
|
||
"max_tokens": max_tokens,
|
||
}).encode()
|
||
|
||
# ── 构建通道列表:SenseNova(快,同款model) → key池(OCG直连) → gateway 兜底 ──
|
||
# 2026-07-24:OCG开盘高峰45s+/call且空输出高发,SenseNova托管同款deepseek-v4-flash实测3.2s
|
||
channels = []
|
||
if SN_HEADERS:
|
||
channels.append(("sensenova", SN_URL, SN_HEADERS))
|
||
pool = _get_key_pool()
|
||
for kid, key in pool:
|
||
if key:
|
||
channels.append((f"ocg:{kid}", OCG_URL, {
|
||
"Content-Type": "application/json",
|
||
"Authorization": f"Bearer {key}",
|
||
"User-Agent": "curl/8.5.0",
|
||
}))
|
||
channels.append(("gateway", GATEWAY, {
|
||
"Content-Type": "application/json",
|
||
"Authorization": GATEWAY_AUTH,
|
||
}))
|
||
|
||
total_attempts = 0
|
||
last_err = "未知错误"
|
||
t_start = time.monotonic()
|
||
|
||
for ch_name, ch_url, ch_headers in channels:
|
||
for attempt in range(retries + 1):
|
||
total_attempts += 1
|
||
t0 = time.monotonic()
|
||
try:
|
||
ok, result = _post(ch_url, ch_headers, payload, timeout)
|
||
elapsed = time.monotonic() - t0
|
||
if ok:
|
||
# ── 空输出视为失败:先同 key 升 FALLBACK_MODEL(pro),仍空则不返回,
|
||
# 落到外循环切下一个 key(2026-07-22 实测:空输出会"假成功"阻断轮换)──
|
||
if not result.strip():
|
||
# SenseNova 不托管 deepseek-v4-pro(404),空输出直接切下一通道
|
||
if ch_name == "sensenova":
|
||
print(f" [LLM] {ch_name} 空输出视为失败,切换下一通道...", flush=True)
|
||
last_err = "empty content"
|
||
continue
|
||
if model_name != FALLBACK_MODEL and ch_name.startswith("ocg"):
|
||
print(f" [LLM] {ch_name} {model_name} 空输出({elapsed:.1f}s),"
|
||
f"升级 {FALLBACK_MODEL} 重试...", flush=True)
|
||
esc_payload = json.dumps({
|
||
"model": FALLBACK_MODEL, "messages": messages,
|
||
"max_tokens": max_tokens,
|
||
}).encode()
|
||
try:
|
||
ok2, result2 = _post(ch_url, ch_headers, esc_payload, timeout)
|
||
total_attempts += 1
|
||
if ok2 and result2.strip():
|
||
print(f" [LLM] 升级 {FALLBACK_MODEL} 成功, 输出{len(result2)}字", flush=True)
|
||
return {
|
||
"ok": True, "content": result2, "error": None,
|
||
"model": FALLBACK_MODEL,
|
||
"elapsed": time.monotonic() - t_start,
|
||
"attempts": total_attempts, "channel": ch_name + "+esc",
|
||
}
|
||
print(f" [LLM] {ch_name} 升级 {FALLBACK_MODEL} 仍空", flush=True)
|
||
except Exception as e2:
|
||
print(f" [LLM] 升级 {FALLBACK_MODEL} 异常: {str(e2)[:100]}", flush=True)
|
||
# 仍空 → 不 return,作为本通道失败处理,外循环切下一个 key
|
||
last_err = "empty content"
|
||
print(f" [LLM] {ch_name} 空输出视为失败,切换下一 key...", flush=True)
|
||
continue
|
||
print(f" [LLM] {ch_name} 尝试{attempt+1}/{retries+1} 成功, "
|
||
f"{elapsed:.1f}s, 输出{len(result)}字, model={model_name}", flush=True)
|
||
return {
|
||
"ok": True, "content": result, "error": None,
|
||
"model": model_name, "elapsed": time.monotonic() - t_start,
|
||
"attempts": total_attempts, "channel": ch_name,
|
||
}
|
||
last_err = result
|
||
print(f" [LLM] {ch_name} 尝试{attempt+1}/{retries+1} 失败({elapsed:.1f}s): {result[:120]}", flush=True)
|
||
except Exception as e:
|
||
elapsed = time.monotonic() - t0
|
||
last_err = str(e)
|
||
print(f" [LLM] {ch_name} 尝试{attempt+1}/{retries+1} 异常({elapsed:.1f}s): {last_err[:120]}", flush=True)
|
||
if attempt < retries:
|
||
print(f" [LLM] 等待{backoff}s后重试...", flush=True)
|
||
time.sleep(backoff)
|
||
# 当前通道重试耗尽 → 切下一通道
|
||
if (ch_name, ch_url, ch_headers) != channels[-1]:
|
||
print(f" [LLM] {ch_name} 通道不可用,切换兜底通道...", flush=True)
|
||
|
||
return {
|
||
"ok": False, "content": "",
|
||
"error": f"双通道均失败(共{total_attempts}次): {last_err[:200]}",
|
||
"model": model_name, "elapsed": time.monotonic() - t_start,
|
||
"attempts": total_attempts, "channel": None,
|
||
}
|