From a7a57f9a03607e21c3c14ac3f2f9b924a15e5173 Mon Sep 17 00:00:00 2001 From: QihuAI Design Date: Sun, 19 Jul 2026 08:51:28 +0800 Subject: [PATCH] =?UTF-8?q?refactor(observability):=20Phase2.2=20=E6=88=90?= =?UTF-8?q?=E6=9C=AC=E5=BD=92=E5=9B=A0=20trace=5Fid=E5=88=97+token?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E5=86=99=E5=85=A5+LLM/MCP/=E5=AA=92=E4=BD=93?= =?UTF-8?q?=E5=AD=90span=20[v2026.07.19.3]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 13 +- backend/python/app/api/chat_media.py | 3274 ++++++------- backend/python/app/api/chat_service.py | 4085 +++++++++-------- backend/python/app/middleware/log_service.py | 3 +- backend/python/app/models.py | 1 + .../020_add_trace_id_token_usage_logs.sql | 7 + 6 files changed, 3705 insertions(+), 3678 deletions(-) create mode 100644 migrations/020_add_trace_id_token_usage_logs.sql diff --git a/VERSION b/VERSION index e2dac30..3b4e73c 100644 --- a/VERSION +++ b/VERSION @@ -3,11 +3,20 @@ # 版本号格式: vYYYY.MM.DD.N (年.月.日.当日递增序号,从 1 开始) # 环境标签: prod = 正式版 | test = 测试版 -VERSION=v2026.07.19.2 +VERSION=v2026.07.19.3 ENV=prod DATE=2026-07-19 BASE_IMAGE=qihu-aiflow:with-deps -BACKUP_IMAGE=qihu-aiflow:backup-20260719-002 +BACKUP_IMAGE=qihu-aiflow:backup-20260719-003 + +## 本版本变更 (v2026.07.19.3 / prod) +- Phase2.2 成本归因:token_usage_logs 新增 trace_id 列(迁移 020_add_trace_id_token_usage_logs.sql,幂等 ALTER),打通「链路 -> 成本」归集 +- _write_media_token_log(媒体图片/视频)注入 get_trace_id(),每条媒体 TokenUsageLog 现可回溯到请求链路 +- LogService.log_token_usage 增加 trace_id 参数(为后续文本 LLM token 记账接通成本归因预留) +- chat_service / chat_media 在 LLM 补全、MCP 工具拉取/调用、技能执行、AGNES 媒体生成处加 span 子链路(async with span),一次请求可下钻耗时分布 +- 注意:_generate_media_task 为后台 fire-and-forget,其 span 会因处于 asyncio.create_task 表达式内而非法,已移除;真实媒体耗时由 chat_media 内 media.agnes_call span 覆盖 +- 纯增量埋点,不改接口返回结构与业务行为;pytest 9 passed + vitest 4 passed 门禁绿 +- 部署前已按 SOP 备份 rollback 点 qihu-aiflow:backup-20260719-003 ## 本版本变更 (v2026.07.19.1 / prod) - Phase1 chat.py 巨石拆分收官:10070 行单体拆分为 chat_{utils,media,agents,skill,mcp,context,knowledge,service}.py 共 8 个子模块,controller 层 chat.py 降至 1352 行(<3000 目标达成) diff --git a/backend/python/app/api/chat_media.py b/backend/python/app/api/chat_media.py index dd3e2b5..feaa7d7 100644 --- a/backend/python/app/api/chat_media.py +++ b/backend/python/app/api/chat_media.py @@ -1,1635 +1,1639 @@ -# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig). -# Source of truth: scripts/phase1_extract.py. Do not hand-merge back. - -from fastapi import APIRouter, Depends, HTTPException, Query, Response -from sqlalchemy.orm import Session -from sqlalchemy import and_, desc, asc, func -from pydantic import BaseModel, Field -from typing import Optional -import uuid -from datetime import datetime, timezone, timedelta -import re -import json -import asyncio -import math -import httpx -import os -import logging -from ..database import get_db, SessionLocal -from ..models import ( - - - - User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog, - - - - KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill, - - - - EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation, - - - - MCPServer, - - - - gen_uuid - - - -) -from ..auth import get_current_user -from ..platform_knowledge import get_platform_knowledge -from ..mcp_client import list_mcp_tools, call_mcp_tool -from ..middleware.log_service import LogService -from ..capability_registry import resolve_auto_model -from app.api.chat_utils import (_html_escape, _proxy_for_url, cst_now, logger) - -def detect_media_intent(text: str) -> str | None: - - - - """根据用户输入判断是否为 图像/视频/语音 生成请求,返回对应能力或 None。 - - - - - - - - 用于 Auto 模式:用户未指定具体模型时,依据意图自动匹配平台各类能力的默认模型。 - - - - 疑问句(含「吗/呢/怎么/能否」或结尾问号)不触发,避免「你能生成视频吗」被误判。 - - - - """ - - - - if not text: - - - - return None - - - - t = text.lower().strip() - - - - # 疑问句不触发媒体生成 - - - - if t.endswith(("?", "?")) or any(q in t for q in ("吗", "呢", "怎么", "如何", "能否", "是否可以", "可以吗", "会不会", "why", "how")): - - - - return None - - - - video_kw = ("视频", "短片", "影片", "vide", "动画视频", "一段视频", "做个视频", "生成视频", - - - - "视频生成", "来段视频", "拍个视频", "做段视频", "视频剪辑", "做视频", - - - - "生成一个视频", "制作一个视频", "帮我做视频", "制作视频", - - - - "生成短片", "做短片", "制作短片", "一段短片") - - - - image_kw = ("图片", "图像", "插画", "海报", "照片", "头像", "壁纸", "漫画", "表情包", "logo", "图标", - - - - "imag", "画一张", "画张", "画一只", "画只", "画个", "画幅", - - - - "来张", "来一张", "来幅", "发张图", "发个图", - - - - "生成图片", "生成一张", "生成一幅", "生成图", "图像生成", - - - - "做一张", "做张图", "做张", "出张图", "出一张图", - - - - "帮我画", "给我画", "请画", "绘制", "作画", "ai绘画", "ai画图", - - - - "画个图", "画张图", "画一幅图", "一张图", "一幅画", "生成一幅", - - - - "画画", "画一下", "画出来", "生成个图", "生成一张图", "生成个图片", - - - - "做图片", "做个图", "制图", "整张图", "整张图出来", "搞张图", - - - - "帮我生成图片", "帮我生成图像", "给我生成图片", "给我生成图像", - - - - "生成一张图片", "生成一个图片", "制作一张图", "制作一张图片", - - - - "来一张图片", "画一张图片", "画一幅", "画一副") - - - - audio_kw = ("语音", "配音", "朗读", "播报", "念一段", "有声", "tts", "音频", "生成语音", - - - - "读一下", "念一下", "语音合成") - - - - if any(k in t for k in video_kw): - - - - return "video" - - - - if any(k in t for k in image_kw): - - - - return "image" - - - - if any(k in t for k in audio_kw): - - - - return "audio" - - - - return None - -def detect_ambiguous_creative_intent(text: str): - - - - """检测「模糊创作意图」:用户使用了创作类动词(制作/创作/做/生成/画/写/设计…), - - - - 但未明确产出形式(文案/图片/视频/其他)。此时应与用户确认方向,而不是直接调用大模型 - - - - (某些模型会对这类开放式创作请求返回 "high risk" 拒答)。 - - - - - - - - 返回 None 表示无需确认;否则返回 {"options": [...]}。 - - - - """ - - - - if not text: - - - - return None - - - - t = text.strip() - - - - tl = t.lower() - - - - # 已有明确媒体意图(图像/视频/语音)则无需确认 - - - - if detect_media_intent(t): - - - - return None - - - - # 疑问句不触发 - - - - if tl.endswith(("?", "?")) or any(q in tl for q in ("吗", "呢", "怎么", "如何", "能否", "是否可以", "可以吗", "会不会", "why", "how", "什么", "哪些")): - - - - return None - - - - # 已明确给出产出形式(文字类/其他类)则无需确认 - - - - explicit_medium = ( - - - - "文案", "文章", "诗歌", "诗", "小说", "故事", "代码", "程序", "脚本", "总结", "翻译", - - - - "邮件", "报告", "段落", "正文", "文字", "写作", "ppt", "演示", "思维导图", "表格", - - - - "图表", "数据", - - - - ) - - - - if any(m in tl for m in explicit_medium): - - - - return None - - - - # 创作类动词 - - - - creative_verbs = ( - - - - "制作", "创作", "做个", "做一个", "搞一个", "产出", "构思", "设想", - - - - "打造", "塑造", "生成", "帮我做", "帮我生成", "帮我画", "帮我写", "请制作", - - - - "请创作", "请生成", "请画", "请写", "来一个", "来一张", "来一幅", "做一张", - - - - "做张", "写一首", "写一段", "写个", - - - - ) - - - - if not any(v in t for v in creative_verbs): - - - - return None - - - - # 模糊:有创作动词但无明确产出形式 → 提供方向选择 - - - - options = [ - - - - {"key": "text", "label": "文案", "desc": "文章 / 故事 / 诗歌等文字内容", "medium": "文案"}, - - - - {"key": "image", "label": "图片", "desc": "插画 / 海报 / 照片等图像", "medium": "图片"}, - - - - {"key": "video", "label": "视频", "desc": "短片 / 动画等视频", "medium": "视频"}, - - - - {"key": "other", "label": "其他", "desc": "语音 / 代码 / 其他形式", "medium": "其他"}, - - - - ] - - - - return {"options": options} - -async def _optimize_media_prompt(raw_prompt: str, media_type: str, db=None) -> str: - - - - """使用平台文本模型对用户原始输入做「媒体提示词增强优化」。 - - - - - - - - AGNES 图像/视频模型对 prompt 质量高度敏感: - - - - - 用户在多轮对话中说"帮我生成一张刚才讨论的产品图"包含大量会话噪音 - - - - - AGNES 需要的是**详细的、描述性的、英文优先或中英混合**的视觉提示词 - - - - - 缺乏构图/风格/光影/质量等指导词时,生成结果与预期差异巨大 - - - - - - - - 本函数把用户原文交给文本 LLM 精炼为符合 AGNES 预期的优质 prompt。 - - - - 失败时回退到原始输入(不做额外引导,保证必达)。 - - - - """ - - - - if not raw_prompt or len(raw_prompt.strip()) < 2: - - - - return raw_prompt - - - - # 如果原始输入已经很长(>200 字)且英文为主,可能本身就是一个不错的 prompt,直接返回 - - - - stripped = raw_prompt.strip() - - - - ascii_chars = sum(1 for c in stripped if ord(c) < 128) - - - - if len(stripped) > 200 and ascii_chars / max(len(stripped), 1) > 0.6: - - - - return stripped - - - - # 用文本 LLM 做翻译增强 - - - - try: - - - - from ..capability_registry import resolve_auto_model as _resolve_auto - - - - from ..models import ModelProvider as MP - - - - from ..database import SessionLocal as SL - - - - import httpx as _httpx - - - - - - - - # 独立数据库会话,避免与主流程竞争 - - - - _db = db - - - - _should_close = False - - - - if _db is None: - - - - _db = SL() - - - - _should_close = True - - - - try: - - - - text_model = _resolve_auto("text", _db) - - - - if not text_model: - - - - return stripped - - - - provider = _db.query(MP).filter(MP.id == text_model.provider_id).first() - - - - api_key = text_model.api_key or (provider.api_key if provider else None) - - - - api_url = text_model.endpoint - - - - if not api_url and provider and provider.api_base_url: - - - - api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions" - - - - if not (api_key and api_url): - - - - return stripped - - - - - - - - prompt_zh = ( - - - - "你是一个专业的AI图像/视频提示词工程师。请将以下用户需求提炼成" - - - - f"一段高质量的{'图像' if media_type == 'image' else '视频'}生成" - - - - "提示词。要求:\n" - - - - "1. 用英文书写(AGNES模型对英文响应最佳),核心关键词可保留中文;\n" - - - - "2. 添加构图/风格/光影/色彩/质量描述(如 highly detailed, 8K, " - - - - "professional lighting, masterpiece, cinematic等);\n" - - - - "3. 去掉与视觉无关的对话噪音;\n" - - - - "4. 只输出最终prompt文本,不要任何解释、不要引号包裹、不要\"Prompt:\"前缀。\n\n" - - - - f"用户需求:{stripped}" - - - - ) - - - - messages = [ - - - - {"role": "system", "content": "You are a professional prompt engineer. Output ONLY the final prompt text, no explanations."}, - - - - {"role": "user", "content": prompt_zh}, - - - - ] - - - - # max_tokens 调大到 1024:mimo 等推理模型会把 token 消耗在思考链(reasoning_content) - - - - # 上,512 时 content 常被截断为空;并关闭推理(enable_thinking=False),让模型直接输出提示词。 - - - - # 对不支持该参数的模型至多优化失败 → 下方 except 兜底退化为原始提示词,不阻断生成。 - - - - body = {"model": text_model.name, "messages": messages, "max_tokens": 1024, - - - - "temperature": 0.7, "enable_thinking": False} - - - - async with _httpx.AsyncClient( - - - - timeout=30.0, proxy=_proxy_for_url(api_url), trust_env=False - - - - ) as client: - - - - resp = await client.post( - - - - api_url, - - - - headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, - - - - json=body, - - - - ) - - - - if resp.status_code == 200: - - - - data = resp.json() - - - - opt = data.get("choices", [{}])[0].get("message", {}).get("content", "").strip() - - - - if opt and len(opt) > 5: - - - - return opt - - - - finally: - - - - if _should_close: - - - - _db.close() - - - - except Exception: - - - - pass - - - - return stripped - -def _friendly_media_err(kind: str, raw: str) -> str: - - - - """把 AGNES 原始错误转译为对用户友好的中文。 - - - - - - - - AGNES 常见错误:429 限频(每分钟1次视频) / Server disconnected(连接重置) / - - - - publish_video_queue_failed + context deadline exceeded(队列超时)。这些都不是 - - - - 平台代码问题,统一给出可操作的提示,不把原始 HTTP/JSON 甩给用户。 - - - - """ - - - - r = (raw or "").lower() - - - - if "429" in raw or "rate limit" in r: - - - - return (f"⚠️ {kind}生成请求过于频繁(AGNES 限制每分钟 1 次)," - - - - f"请稍等 1 分钟后再试。") - - - - if "server disconnected" in r or "连接" in raw or "reset" in r: - - - - return f"⚠️ {kind}生成服务暂时连接失败,请稍后重试。" - - - - if "publish_video" in r or "queue" in r or "deadline" in r or "context" in r: - - - - return f"⚠️ {kind}生成队列繁忙(AGNES 处理超时),请稍后重试。" - - - - return f"⚠️ {kind}生成失败(请稍后重试):{(raw or '')[:120]}" - -async def _agnes_post_json(api_url: str, headers: dict, payload: dict, timeout) -> tuple: - - - - """直连 AGNES 发 POST,对 5xx / 连接中断做指数退避重试。 - - - - - - - - AGNES 视频/图像队列极不稳定:经常返回 500 publish_video_queue_failed / - - - - context deadline exceeded,或连接被重置(Server disconnected)。这类错误是 AGNES - - - - 侧瞬时抖动,重试通常立刻成功。4xx(参数错误)不重试,直接返回。 - - - - 返回 (resp, err):成功(resp.status_code<500) 时 err=None;耗尽重试时 resp=None。 - - - - """ - - - - last_err = None - - - - for attempt in range(3): - - - - try: - - - - async with httpx.AsyncClient( - - - - timeout=timeout, proxy=_proxy_for_url(api_url), trust_env=False - - - - ) as client: - - - - resp = await client.post(api_url, headers=headers, json=payload) - - - - if resp.status_code < 500: - - - - return resp, None - - - - last_err = f"HTTP {resp.status_code}: {resp.text[:200]}" - - - - except Exception as e: - - - - last_err = str(e)[:200] - - - - if attempt < 2: - - - - await asyncio.sleep(2 * (attempt + 1)) - - - - return None, last_err - -async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str: - - - - """图像/视频生成模型统一入口:按 capabilities 调用 AGNES 等媒体生成 API。 - - - - 返回可直接在 AI 聊天中渲染的富文本(含 /