diff --git a/.cursor/rules/api-failover-stability.mdc b/.cursor/rules/api-failover-stability.mdc new file mode 100644 index 00000000..e1c934be --- /dev/null +++ b/.cursor/rules/api-failover-stability.mdc @@ -0,0 +1,29 @@ +--- +description: 卡若AI API 接口排队与故障切换稳定性规则 +alwaysApply: true +--- + +# 卡若AI API 稳定性规则 + +## 目标 +- 所有 AI 请求优先走可用接口,单接口超时/报错时自动切换下一接口。 +- 全部接口都失败时,必须触发邮件告警并返回可读降级回复。 + +## 接口排队规则(按顺序) +- 使用 `OPENAI_API_BASES` 配置接口队列(逗号分隔)。 +- 对应密钥用 `OPENAI_API_KEYS`(可选);若缺省则回退 `OPENAI_API_KEY`。 +- 对应模型用 `OPENAI_MODELS`(可选);若缺省则回退 `OPENAI_MODEL`。 + +## 故障切换规则 +- 单接口失败条件:超时、网络错误、HTTP 非 200、响应体不可解析。 +- 每次请求必须按队列顺序逐个尝试,直到成功或队列耗尽。 +- 队列耗尽后:发送告警邮件到 `ALERT_EMAIL_TO`(默认 `zhiqun@qq.com`),并返回降级回复,不得直接空响应。 + +## 告警规则 +- SMTP 默认:`SMTP_HOST=smtp.qq.com`、`SMTP_PORT=465`。 +- 必填:`SMTP_USER`、`SMTP_PASS`、`ALERT_EMAIL_TO`(可不填则默认收件箱)。 +- 为防刷屏,告警需带冷却时间(默认 300 秒)。 + +## 安全规则 +- 不在代码与规则里写死明文密钥。 +- 仅记录必要错误摘要,不在日志输出完整 token。 diff --git a/02_卡人(水)/水桥_平台对接/飞书管理/脚本/.feishu_tokens.json b/02_卡人(水)/水桥_平台对接/飞书管理/脚本/.feishu_tokens.json index edd5c05b..c1f3818b 100644 --- a/02_卡人(水)/水桥_平台对接/飞书管理/脚本/.feishu_tokens.json +++ b/02_卡人(水)/水桥_平台对接/飞书管理/脚本/.feishu_tokens.json @@ -1,6 +1,6 @@ { - "access_token": "u-4Tr54dmqV8lE_qtfG76A2Il5mMMBk1irW8aaVBM00wO2", - "refresh_token": "ur-4iCTU0PcheAVQUi_Z43c9El5koO5k1MpV8aaIQw00wCn", + "access_token": "u-6IgXw46WF09ryzyx_zjXmEl5kqo5k1WrNUaaEAM00xO6", + "refresh_token": "ur-7A8zuMj6t7gaO8JsTXVY8zl5mqU5k1OrhUaaUxQ00BCi", "name": "飞书用户", - "auth_time": "2026-02-25T09:19:23.848992" + "auth_time": "2026-02-25T12:00:51.797153" } \ No newline at end of file diff --git a/运营中枢/scripts/karuo_ai_gateway/README.md b/运营中枢/scripts/karuo_ai_gateway/README.md index 80c972d2..e27dfc0b 100644 --- a/运营中枢/scripts/karuo_ai_gateway/README.md +++ b/运营中枢/scripts/karuo_ai_gateway/README.md @@ -15,9 +15,36 @@ uvicorn main:app --host 0.0.0.0 --port 8000 - `OPENAI_API_KEY`:OpenAI 或兼容 API 的密钥,配置后使用真实 LLM 生成回复。 - `OPENAI_API_BASE`:兼容接口地址,默认 `https://api.openai.com/v1`。 - `OPENAI_MODEL`:模型名,默认 `gpt-4o-mini`。 +- `OPENAI_API_BASES`:接口队列(逗号分隔),例如 `https://a.example.com/v1,https://b.example.com/v1`。 +- `OPENAI_API_KEYS`:队列密钥(逗号分隔,可选)。若未配置,回退 `OPENAI_API_KEY`。 +- `OPENAI_MODELS`:队列模型(逗号分隔,可选)。若未配置,回退 `OPENAI_MODEL`。 +- `ALERT_EMAIL_TO`:全部接口失败时的告警收件人(默认 `zhiqun@qq.com`)。 +- `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS`:SMTP 告警配置(QQ 邮箱默认 `smtp.qq.com:465`)。 - `KARUO_GATEWAY_CONFIG`:网关配置路径(默认 `config/gateway.yaml`)。 - `KARUO_GATEWAY_SALT`:部门 Key 的 salt(用于 sha256 校验;不写入仓库)。 +### 接口排队与自动切换(稳定性) + +网关会按顺序尝试接口队列: + +1. 优先使用 `OPENAI_API_BASES`(可配多个) +2. 任一接口超时/异常/非 200 时,自动切换下一接口 +3. 全部失败时:发送告警邮件并返回降级回复(不中断对话) + +示例: + +```bash +export OPENAI_API_BASES="https://api.openai.com/v1,https://openrouter.ai/api/v1" +export OPENAI_API_KEYS="sk-xxx,sk-yyy" +export OPENAI_MODELS="gpt-4o-mini,openai/gpt-4o-mini" + +export ALERT_EMAIL_TO="zhiqun@qq.com" +export SMTP_HOST="smtp.qq.com" +export SMTP_PORT="465" +export SMTP_USER="zhiqun@qq.com" +export SMTP_PASS="你的QQ邮箱授权码" +``` + ## 部门/科室鉴权与白名单(推荐启用) 网关支持“每部门一个 Key + 技能白名单”,用于: diff --git a/运营中枢/scripts/karuo_ai_gateway/config/gateway.example.yaml b/运营中枢/scripts/karuo_ai_gateway/config/gateway.example.yaml index d007260e..0450201e 100644 --- a/运营中枢/scripts/karuo_ai_gateway/config/gateway.example.yaml +++ b/运营中枢/scripts/karuo_ai_gateway/config/gateway.example.yaml @@ -30,6 +30,17 @@ llm: api_key_env: OPENAI_API_KEY api_base_env: OPENAI_API_BASE model_env: OPENAI_MODEL + # 队列模式(可选):逗号分隔多个上游接口,按顺序自动切换 + api_bases_env: OPENAI_API_BASES + api_keys_env: OPENAI_API_KEYS + models_env: OPENAI_MODELS + # 全部接口失败时邮件告警(可选) + alert_email_to_env: ALERT_EMAIL_TO + smtp_host_env: SMTP_HOST + smtp_port_env: SMTP_PORT + smtp_user_env: SMTP_USER + smtp_pass_env: SMTP_PASS + alert_cooldown_seconds: 300 timeout_seconds: 60 max_tokens: 2000 diff --git a/运营中枢/scripts/karuo_ai_gateway/main.py b/运营中枢/scripts/karuo_ai_gateway/main.py index 710c4f53..1f4d9eaf 100644 --- a/运营中枢/scripts/karuo_ai_gateway/main.py +++ b/运营中枢/scripts/karuo_ai_gateway/main.py @@ -9,6 +9,8 @@ import time import json import hashlib import hmac +import smtplib +from email.message import EmailMessage from typing import Any, Dict, List, Optional, Tuple import yaml @@ -96,13 +98,13 @@ def _get_api_key_from_request(request: Request, cfg: Dict[str, Any]) -> str: - X-Karuo-Api-Key: (原生网关方式) - Authorization: Bearer (OpenAI 兼容客户端常用) """ + auth = request.headers.get("authorization", "").strip() + if auth.lower().startswith("bearer "): + return auth[7:].strip() header_name = _auth_header_name(cfg) api_key = request.headers.get(header_name, "").strip() if api_key: return api_key - auth = request.headers.get("authorization", "").strip() - if auth.lower().startswith("bearer "): - return auth[7:].strip() return "" @@ -209,6 +211,102 @@ def _llm_settings(cfg: Dict[str, Any]) -> Dict[str, Any]: return (cfg or {}).get("llm") or {} +def _split_csv_env(value: str) -> List[str]: + if not value: + return [] + s = value.replace("\n", ",").replace(";", ",") + return [x.strip() for x in s.split(",") if x.strip()] + + +def _build_provider_queue(llm_cfg: Dict[str, Any]) -> List[Dict[str, str]]: + """ + 构建 LLM 接口队列: + 1) 优先读取 OPENAI_API_BASES / OPENAI_API_KEYS / OPENAI_MODELS(逗号分隔) + 2) 若未配置队列,则回退到单接口 OPENAI_API_BASE / OPENAI_API_KEY / OPENAI_MODEL + """ + base_env = llm_cfg.get("api_base_env", "OPENAI_API_BASE") + key_env = llm_cfg.get("api_key_env", "OPENAI_API_KEY") + model_env = llm_cfg.get("model_env", "OPENAI_MODEL") + bases_env = llm_cfg.get("api_bases_env", "OPENAI_API_BASES") + keys_env = llm_cfg.get("api_keys_env", "OPENAI_API_KEYS") + models_env = llm_cfg.get("models_env", "OPENAI_MODELS") + + single_base = os.environ.get(base_env, "https://api.openai.com/v1").strip() + single_key = os.environ.get(key_env, "").strip() + single_model = os.environ.get(model_env, "gpt-4o-mini").strip() or "gpt-4o-mini" + + bases = _split_csv_env(os.environ.get(bases_env, "")) + keys = _split_csv_env(os.environ.get(keys_env, "")) + models = _split_csv_env(os.environ.get(models_env, "")) + + providers: List[Dict[str, str]] = [] + if bases: + for i, b in enumerate(bases): + key = keys[i] if i < len(keys) and keys[i] else single_key + model = models[i] if i < len(models) and models[i] else single_model + if not b or not key: + continue + providers.append({"base_url": b.rstrip("/"), "api_key": key, "model": model}) + elif single_key: + providers.append({"base_url": single_base.rstrip("/"), "api_key": single_key, "model": single_model}) + return providers + + +def _send_provider_alert(cfg: Dict[str, Any], errors: List[str], prompt: str, matched_skill: str, skill_path: str) -> None: + """ + 当所有 LLM 接口都失败时,发邮件告警(支持 QQ SMTP)。 + 环境变量: + - ALERT_EMAIL_TO(默认 zhiqun@qq.com) + - SMTP_HOST(默认 smtp.qq.com) + - SMTP_PORT(默认 465) + - SMTP_USER / SMTP_PASS + """ + llm_cfg = _llm_settings(cfg) + to_env = llm_cfg.get("alert_email_to_env", "ALERT_EMAIL_TO") + smtp_host_env = llm_cfg.get("smtp_host_env", "SMTP_HOST") + smtp_port_env = llm_cfg.get("smtp_port_env", "SMTP_PORT") + smtp_user_env = llm_cfg.get("smtp_user_env", "SMTP_USER") + smtp_pass_env = llm_cfg.get("smtp_pass_env", "SMTP_PASS") + + to_addr = os.environ.get(to_env, "zhiqun@qq.com").strip() + smtp_host = os.environ.get(smtp_host_env, "smtp.qq.com").strip() or "smtp.qq.com" + smtp_port = int(str(os.environ.get(smtp_port_env, "465") or "465").strip()) + smtp_user = os.environ.get(smtp_user_env, "").strip() + smtp_pass = os.environ.get(smtp_pass_env, "").strip() + + # 避免因邮件配置不完整影响主流程 + if not (to_addr and smtp_user and smtp_pass): + return + + cooldown = int(llm_cfg.get("alert_cooldown_seconds", 300) or 300) + now = int(time.time()) + last_ts = int(getattr(app.state, "_last_provider_alert_ts", 0) or 0) + if last_ts and now - last_ts < cooldown: + return + + app.state._last_provider_alert_ts = now + + msg = EmailMessage() + msg["Subject"] = "【卡若AI网关告警】全部LLM接口不可用" + msg["From"] = smtp_user + msg["To"] = to_addr + safe_prompt = (prompt or "").strip() + if len(safe_prompt) > 200: + safe_prompt = safe_prompt[:200] + "..." + body = ( + "卡若AI 网关检测到:本次请求所有上游接口都失败。\n\n" + f"时间戳: {now}\n" + f"匹配技能: {matched_skill} ({skill_path})\n" + f"用户问题片段: {safe_prompt}\n\n" + "错误列表:\n- " + "\n- ".join(errors[:10]) + ) + msg.set_content(body) + + with smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=15) as s: + s.login(smtp_user, smtp_pass) + s.send_message(msg) + + def build_reply_with_llm(prompt: str, cfg: Dict[str, Any], matched_skill: str, skill_path: str) -> str: """调用 LLM 生成回复(OpenAI 兼容)。未配置则返回模板回复。""" bootstrap = load_bootstrap() @@ -218,26 +316,37 @@ def build_reply_with_llm(prompt: str, cfg: Dict[str, Any], matched_skill: str, s "先简短思考并输出,再给执行要点,最后必须带「[卡若复盘]」块(含目标·结果·达成率、过程 1 2 3、反思、总结、下一步)。" ) llm_cfg = _llm_settings(cfg) - api_key = os.environ.get(llm_cfg.get("api_key_env", "OPENAI_API_KEY")) - base_url = os.environ.get(llm_cfg.get("api_base_env", "OPENAI_API_BASE"), "https://api.openai.com/v1") - if api_key: + providers = _build_provider_queue(llm_cfg) + if providers: + errors: List[str] = [] + for idx, p in enumerate(providers, start=1): + try: + import httpx + + r = httpx.post( + f"{p['base_url']}/chat/completions", + headers={"Authorization": f"Bearer {p['api_key']}", "Content-Type": "application/json"}, + json={ + "model": p["model"], + "messages": [{"role": "system", "content": system}, {"role": "user", "content": prompt}], + "max_tokens": int(llm_cfg.get("max_tokens", 2000)), + }, + timeout=float(llm_cfg.get("timeout_seconds", 60)), + ) + if r.status_code == 200: + data = r.json() + return data["choices"][0]["message"]["content"] + errors.append(f"provider#{idx} status={r.status_code} body={r.text[:120]}") + except Exception as e: + errors.append(f"provider#{idx} exception={type(e).__name__}: {str(e)[:160]}") + + # 所有接口失败:邮件告警 + 降级回复 try: - import httpx - r = httpx.post( - f"{base_url.rstrip('/')}/chat/completions", - headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, - json={ - "model": os.environ.get(llm_cfg.get("model_env", "OPENAI_MODEL"), "gpt-4o-mini"), - "messages": [{"role": "system", "content": system}, {"role": "user", "content": prompt}], - "max_tokens": int(llm_cfg.get("max_tokens", 2000)), - }, - timeout=float(llm_cfg.get("timeout_seconds", 60)), - ) - if r.status_code == 200: - data = r.json() - return data["choices"][0]["message"]["content"] - except Exception as e: - return _template_reply(prompt, matched_skill, skill_path, error=str(e)) + _send_provider_alert(cfg, errors, prompt, matched_skill, skill_path) + except Exception: + # 告警失败不影响主流程,继续降级 + pass + return _template_reply(prompt, matched_skill, skill_path, error=" | ".join(errors[:3])) return _template_reply(prompt, matched_skill, skill_path) diff --git a/运营中枢/参考资料/卡若AI_API接口排队与故障切换规则.md b/运营中枢/参考资料/卡若AI_API接口排队与故障切换规则.md new file mode 100644 index 00000000..0c66b5e6 --- /dev/null +++ b/运营中枢/参考资料/卡若AI_API接口排队与故障切换规则.md @@ -0,0 +1,69 @@ +# 卡若AI API 接口排队与故障切换规则 + +## 1. 本机已识别的 AI 接口配置入口 + +- 网关代码入口:`运营中枢/scripts/karuo_ai_gateway/main.py` +- 网关说明文档:`运营中枢/scripts/karuo_ai_gateway/README.md` +- 网关配置样例:`运营中枢/scripts/karuo_ai_gateway/config/gateway.example.yaml` + +当前支持的接口变量(不含明文密钥): + +- 单接口:`OPENAI_API_BASE` / `OPENAI_API_KEY` / `OPENAI_MODEL` +- 队列接口:`OPENAI_API_BASES` / `OPENAI_API_KEYS` / `OPENAI_MODELS` +- 告警邮箱:`ALERT_EMAIL_TO` / `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS` + +--- + +## 2. 规则目标 + +1. 任一接口超时或异常,自动切换到下一个接口。 +2. 只要队列中有一个接口可用,必须返回正常回复。 +3. 全部接口不可用时,自动发邮件到 `zhiqun@qq.com`,并返回降级回复,不能空响应。 + +--- + +## 3. 可直接使用的配置模板 + +```bash +# 1) 接口队列(按顺序) +export OPENAI_API_BASES="https://api.openai.com/v1,https://openrouter.ai/api/v1,https://your-backup-api/v1" + +# 2) 对应密钥(顺序与上面一致;可先只填一个,会回退到 OPENAI_API_KEY) +export OPENAI_API_KEYS="sk-main,sk-backup,sk-third" + +# 3) 对应模型(可选,不填则回退 OPENAI_MODEL) +export OPENAI_MODELS="gpt-4o-mini,openai/gpt-4o-mini,gpt-4o-mini" + +# 4) 单接口兜底(建议保留) +export OPENAI_API_BASE="https://api.openai.com/v1" +export OPENAI_API_KEY="sk-main" +export OPENAI_MODEL="gpt-4o-mini" + +# 5) 全挂告警邮件 +export ALERT_EMAIL_TO="zhiqun@qq.com" +export SMTP_HOST="smtp.qq.com" +export SMTP_PORT="465" +export SMTP_USER="zhiqun@qq.com" +export SMTP_PASS="你的QQ邮箱授权码" +``` + +--- + +## 4. 执行逻辑(网关内置) + +1. 读取 `OPENAI_API_BASES` 队列。 +2. 按顺序逐个请求上游接口。 +3. 某个接口成功(HTTP 200)即返回结果,不再继续重试后续接口。 +4. 失败(超时/异常/非 200)则自动切到下一接口。 +5. 若全部失败: + - 发送告警邮件(默认带 300 秒冷却,避免刷屏); + - 返回可读降级回复,保证前端有响应。 + +--- + +## 5. 验证清单 + +1. 停掉第一个接口或改错第一个 key,确认仍能正常回复(证明切换生效)。 +2. 同时让全部接口不可用,确认收到 `zhiqun@qq.com` 告警。 +3. 查看网关响应:不应出现空白回复或长时间卡死。 + diff --git a/运营中枢/工作台/gitea_push_log.md b/运营中枢/工作台/gitea_push_log.md index 1359246c..c652768b 100644 --- a/运营中枢/工作台/gitea_push_log.md +++ b/运营中枢/工作台/gitea_push_log.md @@ -139,3 +139,4 @@ | 2026-02-25 10:23:07 | 🔄 卡若AI 同步 2026-02-25 10:22 | 更新:水桥平台对接、运营中枢工作台 | 排除 >20MB: 13 个 | | 2026-02-25 10:26:04 | 🔄 卡若AI 同步 2026-02-25 10:26 | 更新:水桥平台对接、水溪整理归档 | 排除 >20MB: 13 个 | | 2026-02-25 11:03:16 | 🔄 卡若AI 同步 2026-02-25 11:03 | 更新:水桥平台对接、运营中枢工作台 | 排除 >20MB: 13 个 | +| 2026-02-25 11:52:39 | 🔄 卡若AI 同步 2026-02-25 11:52 | 更新:水溪整理归档、运营中枢、运营中枢工作台 | 排除 >20MB: 13 个 | diff --git a/运营中枢/工作台/代码管理.md b/运营中枢/工作台/代码管理.md index 86fc8aeb..3f5ae58d 100644 --- a/运营中枢/工作台/代码管理.md +++ b/运营中枢/工作台/代码管理.md @@ -142,3 +142,4 @@ | 2026-02-25 10:23:07 | 成功 | 成功 | 🔄 卡若AI 同步 2026-02-25 10:22 | 更新:水桥平台对接、运营中枢工作台 | 排除 >20MB: 13 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) | | 2026-02-25 10:26:04 | 成功 | 成功 | 🔄 卡若AI 同步 2026-02-25 10:26 | 更新:水桥平台对接、水溪整理归档 | 排除 >20MB: 13 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) | | 2026-02-25 11:03:16 | 成功 | 成功 | 🔄 卡若AI 同步 2026-02-25 11:03 | 更新:水桥平台对接、运营中枢工作台 | 排除 >20MB: 13 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) | +| 2026-02-25 11:52:39 | 成功 | 成功 | 🔄 卡若AI 同步 2026-02-25 11:52 | 更新:水溪整理归档、运营中枢、运营中枢工作台 | 排除 >20MB: 13 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) |