fix(llm): 重评直连OCG上游绕过hermes agent运行时 + 全线切flash + prompt输出纪律
事故根因(2026-07-21): hermes gateway /v1/chat/completions 非透传, 每个请求创建带工具的agent会话。一次603288重评螺旋35分钟/44次 terminal调用/输入153k token, 客户端超时后服务端空转, 重试叠加 新会话自我DDoS。 - llm_client 重写: OCG直连为主(key运行时从hermes config.yaml ocg-key6读取, 不落盘), gateway兜底(agent模式仅应急) - REASSESS_MODEL: pro -> flash (A/B实测新prompt下质量差距微弱, flash快40%) - prompt输出纪律: 【建议仓位】不可省略(非买入写'不新建仓'), 禁止structured_data/XML/JSON块, 禁止寒暄开场白 - batch main 双通道预检(全挂才退出)
This commit is contained in:
@@ -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. 禁止输出 <structured_data> 或任何 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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -513,7 +513,13 @@ def main():
|
||||
【综合结论】(买入/关注/观望/卖出)
|
||||
【操作建议】
|
||||
【建议止损】
|
||||
【建议止盈】"""
|
||||
【建议止盈】
|
||||
【建议仓位】⚠️不可省略,非"买入"时写"不新建仓"
|
||||
|
||||
⚠️ 输出纪律(必须遵守):
|
||||
1. 直接以【维持或修改】开头,禁止任何寒暄、开场白、分隔线
|
||||
2. 禁止输出 <structured_data> 或任何 XML/JSON/代码块
|
||||
3. 所有【】节标题一个都不能少"""
|
||||
_full_analysis_text = None
|
||||
try:
|
||||
_llm_result = call_llm(_prompt, max_tokens=4096, timeout=150, retries=1, backoff=20)
|
||||
|
||||
Reference in New Issue
Block a user