diff --git a/deploy/profile-scripts/batch_reassess.py b/deploy/profile-scripts/batch_reassess.py index d7a3d546..dcff8e7a 100644 --- a/deploy/profile-scripts/batch_reassess.py +++ b/deploy/profile-scripts/batch_reassess.py @@ -16,7 +16,7 @@ from datetime import datetime # ── 共享 LLM 客户端 + DB 工具(profile-scripts 硬链到同目录)── sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, "/home/hmo/MoFin") -from llm_client import call_llm, REASSESS_MODEL, gateway_alive +from llm_client import call_llm, REASSESS_MODEL, gateway_alive, ocg_alive from mofin_db import snapshot_strategy_history DB = "/home/hmo/MoFin/data/mofin.db" @@ -266,13 +266,18 @@ PE={data.get('pe','?')}(最新财报) 市值={data.get('mcap','?')}亿 【建议止损】数字 【建议止盈】数字 -【建议仓位】只有综合结论为"买入"时才输出此项。仓位计算公式: +【建议仓位】⚠️不可省略。综合结论非"买入"时写"不新建仓";为"买入"时按以下公式: 基础仓位按RR确定:RR<1.5→不推荐,RR1.5~3→8%,RR3~5→12%,RR5+→15% 大盘偏弱×0.8,大盘偏强×1.15 蓝筹/白马×1.2,成长×0.85,题材/短线×0.6 最终仓位范围:5%~20% 同时考虑:现金{cash}元足够买多少手。 -输出格式:"X%(理由:一句话说明为什么这个仓位)""" +输出格式:"X%(理由:一句话说明为什么这个仓位)" + +⚠️ 输出纪律(必须遵守): +1. 直接以【维持或修改】开头,禁止任何寒暄、开场白、分隔线 +2. 禁止输出 或任何 XML/JSON/代码块 +3. 所有【】节标题一个都不能少""" def parse_response(text): """从LLM回复中提取策略参数""" result = {"signal": "", "entry_low": 0, "entry_high": 0, "stop_loss": 0, "take_profit": 0, "position": ""} @@ -420,10 +425,16 @@ def process_stock(code, force_today=False): return True def main(): - # ── Gateway 预检:不可用则立即退出(不阻塞 cron)── - if not gateway_alive(): - print("[FATAL] Hermes Gateway 不可用,退出(检查 http://127.0.0.1:8643/v1/models)") + # ── 双通道预检:OCG直连 + hermes gateway 兜底,全挂才退出 ── + _ocg_ok = ocg_alive() + _gw_ok = gateway_alive() + if not _ocg_ok and not _gw_ok: + print("[FATAL] OCG上游与hermes gateway均不可用,退出") sys.exit(1) + if not _ocg_ok: + print("[WARN] OCG直连不可用,将使用gateway兜底(agent运行时,较慢)") + if not _gw_ok: + print("[WARN] hermes gateway不可用,仅使用OCG直连") codes = [] force_today = "--today" in sys.argv diff --git a/deploy/profile-scripts/llm_client.py b/deploy/profile-scripts/llm_client.py index 883d9918..3c14d52b 100644 --- a/deploy/profile-scripts/llm_client.py +++ b/deploy/profile-scripts/llm_client.py @@ -1,16 +1,20 @@ #!/usr/bin/env python3 -"""llm_client.py — 共享 LLM 客户端(重试 + gateway 预检) +"""llm_client.py — 共享 LLM 客户端(直连上游 + gateway 兜底) -所有重评脚本统一通过此模块调用 LLM gateway,避免重复的 HTTP/重试逻辑。 +⚠️ 架构说明(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 - if not gateway_alive(): - print("Gateway 不可用,退出") - sys.exit(1) + from llm_client import call_llm, REASSESS_MODEL, gateway_alive, ocg_alive result = call_llm(prompt) if result["ok"]: - full_text = result["content"] + text = result["content"] """ import json @@ -19,16 +23,74 @@ import urllib.request import urllib.error # ── 常量:所有重评调用统一使用 ── -REASSESS_MODEL = "deepseek-v4-pro" +# 2026-07-21 A/B 实测:flash 与 pro 在新 prompt 下质量差距微弱,flash 快 ~40% +REASSESS_MODEL = "deepseek-v4-flash" + +# 主通道: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 + + +_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_BASE = "http://127.0.0.1:8643/v1/models" -AUTH = "Bearer hermes123" +GATEWAY_MODELS = "http://127.0.0.1:8643/v1/models" +GATEWAY_AUTH = "Bearer hermes123" + + +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): - """快速预检 gateway 是否存活。失败立刻返回 False,不阻塞。""" + """快速预检 hermes gateway 是否存活。失败立刻返回 False,不阻塞。""" try: - req = urllib.request.Request(GATEWAY_BASE, headers={"Authorization": AUTH}) + 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: @@ -37,20 +99,19 @@ def gateway_alive(timeout=5): def call_llm(prompt, model=None, max_tokens=4096, timeout=150, retries=1, backoff=20, system=None): - """调用 LLM gateway,带重试和结构化日志。 + """调用 LLM:OCG 直连优先,hermes gateway 兜底。带重试和结构化日志。 Args: prompt: 用户消息内容 model: 模型名(默认 REASSESS_MODEL) max_tokens: 最大输出 token 数 timeout: 单次调用超时(秒) - retries: 超时/5xx/连接错误时的重试次数 + retries: 每个通道的失败重试次数 backoff: 重试间隔(秒) system: 可选 system message Returns: - {ok: bool, content: str, error: str|None, model: str, - elapsed: float, attempts: int} + {ok, content, error, model, elapsed, attempts, channel} 永远不抛异常到调用方。 """ model_name = model or REASSESS_MODEL @@ -58,76 +119,55 @@ def call_llm(prompt, model=None, max_tokens=4096, timeout=150, 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() - for attempt in range(retries + 1): - t0 = time.monotonic() - try: - req = urllib.request.Request( - GATEWAY, - data=payload, - headers={ - "Content-Type": "application/json", - "Authorization": AUTH, - } - ) - opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) - resp = opener.open(req, timeout=timeout) - elapsed = time.monotonic() - t0 + channels = [] + if OCG_HEADERS: + channels.append(("ocg", OCG_URL, OCG_HEADERS)) + channels.append(("gateway", GATEWAY, { + "Content-Type": "application/json", + "Authorization": GATEWAY_AUTH, + })) - body = json.loads(resp.read().decode()) - if "choices" not in body: - msg = f"API响应无choices字段: {str(body)[:200]}" - print(f" [LLM] 尝试{attempt+1}/{retries+1} 失败({elapsed:.1f}s): {msg}", flush=True) - if attempt < retries: - time.sleep(backoff) - continue + total_attempts = 0 + last_err = "未知错误" + t_start = time.monotonic() - content = body["choices"][0]["message"]["content"] - print(f" [LLM] 尝试{attempt+1}/{retries+1} 成功, {elapsed:.1f}s, " - f"输出{len(content)}字, model={model_name}", flush=True) - return { - "ok": True, - "content": content, - "error": None, - "model": model_name, - "elapsed": elapsed, - "attempts": attempt + 1, - } - - except urllib.error.URLError as e: - elapsed = time.monotonic() - t0 - err_msg = str(e) - print(f" [LLM] 尝试{attempt+1}/{retries+1} 连接失败({elapsed:.1f}s): {err_msg[:120]}", flush=True) + 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: + 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) - else: - return { - "ok": False, "content": "", "error": f"连接失败(重试{retries}次): {err_msg[:200]}", - "model": model_name, "elapsed": elapsed, "attempts": attempt + 1, - } + # 当前通道重试耗尽 → 切下一通道 + if (ch_name, ch_url, ch_headers) != channels[-1]: + print(f" [LLM] {ch_name} 通道不可用,切换兜底通道...", flush=True) - except Exception as e: - elapsed = time.monotonic() - t0 - err_msg = str(e) - print(f" [LLM] 尝试{attempt+1}/{retries+1} 失败({elapsed:.1f}s): {err_msg[:120]}", flush=True) - if attempt < retries: - print(f" [LLM] 等待{backoff}s后重试...", flush=True) - time.sleep(backoff) - else: - return { - "ok": False, "content": "", "error": f"调用失败(重试{retries}次): {err_msg[:200]}", - "model": model_name, "elapsed": elapsed, "attempts": attempt + 1, - } - - # Unreachable return { - "ok": False, "content": "", "error": "未预期的调用结束", - "model": model_name, "elapsed": 0, "attempts": retries + 1, + "ok": False, "content": "", + "error": f"双通道均失败(共{total_attempts}次): {last_err[:200]}", + "model": model_name, "elapsed": time.monotonic() - t_start, + "attempts": total_attempts, "channel": None, } diff --git a/deploy/profile-scripts/per_stock_reassess.py b/deploy/profile-scripts/per_stock_reassess.py index 1979b0da..e138a370 100644 --- a/deploy/profile-scripts/per_stock_reassess.py +++ b/deploy/profile-scripts/per_stock_reassess.py @@ -513,7 +513,13 @@ def main(): 【综合结论】(买入/关注/观望/卖出) 【操作建议】 【建议止损】 -【建议止盈】""" +【建议止盈】 +【建议仓位】⚠️不可省略,非"买入"时写"不新建仓" + +⚠️ 输出纪律(必须遵守): +1. 直接以【维持或修改】开头,禁止任何寒暄、开场白、分隔线 +2. 禁止输出 或任何 XML/JSON/代码块 +3. 所有【】节标题一个都不能少""" _full_analysis_text = None try: _llm_result = call_llm(_prompt, max_tokens=4096, timeout=150, retries=1, backoff=20)