refactor(observability): Phase2.2 成本归因 trace_id列+token日志写入+LLM/MCP/媒体子span [v2026.07.19.3]
CI / gate (push) Has been cancelled

This commit is contained in:
QihuAI Design committed 2026-07-19 08:51:28 +08:00
1 parent 6aefdfa5c2
commit a7a57f9a03
6 files changed
+49 -22

No files matched your search

+11 -2
View File
@@ -3,11 +3,20 @@
# 版本号格式: vYYYY.MM.DD.N (年.月.日.当日递增序号,从 1 开始) # 版本号格式: vYYYY.MM.DD.N (年.月.日.当日递增序号,从 1 开始)
# 环境标签: prod = 正式版 | test = 测试版 # 环境标签: prod = 正式版 | test = 测试版
VERSION=v2026.07.19.2 VERSION=v2026.07.19.3
ENV=prod ENV=prod
DATE=2026-07-19 DATE=2026-07-19
BASE_IMAGE=qihu-aiflow:with-deps 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) ## 本版本变更 (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 目标达成) - Phase1 chat.py 巨石拆分收官:10070 行单体拆分为 chat_{utils,media,agents,skill,mcp,context,knowledge,service}.py 共 8 个子模块,controller 层 chat.py 降至 1352 行(<3000 目标达成)
+8 -4
View File
@@ -47,6 +47,7 @@ from ..mcp_client import list_mcp_tools, call_mcp_tool
from ..middleware.log_service import LogService from ..middleware.log_service import LogService
from ..capability_registry import resolve_auto_model from ..capability_registry import resolve_auto_model
from app.api.chat_utils import (_html_escape, _proxy_for_url, cst_now, logger) from app.api.chat_utils import (_html_escape, _proxy_for_url, cst_now, logger)
from app.core.observability import span, get_trace_id
def detect_media_intent(text: str) -> str | None: def detect_media_intent(text: str) -> str | None:
@@ -958,7 +959,8 @@ async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str
resp, err = await _agnes_post_json(api_url, headers, payload, timeout) async with span("media.agnes_call"):
resp, err = await _agnes_post_json(api_url, headers, payload, timeout)
@@ -1166,7 +1168,8 @@ async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str
resp, err = await _agnes_post_json(api_url, headers, payload, timeout) async with span("media.agnes_call"):
resp, err = await _agnes_post_json(api_url, headers, payload, timeout)
@@ -1486,7 +1489,7 @@ async def _generate_media_task(ai_msg_id: str, ai_model_id: str, prompt: str, us
_write_media_token_log(db, ai_model, user_id, msg.conversation_id if msg else None, prompt) _write_media_token_log(db, ai_model, user_id, msg.conversation_id if msg else None, prompt, trace_id=get_trace_id())
@@ -1516,7 +1519,7 @@ async def _generate_media_task(ai_msg_id: str, ai_model_id: str, prompt: str, us
pass pass
def _write_media_token_log(db, ai_model, user_id: str, conversation_id: str, prompt: str): def _write_media_token_log(db, ai_model, user_id: str, conversation_id: str, prompt: str, trace_id: str = None):
@@ -1601,6 +1604,7 @@ def _write_media_token_log(db, ai_model, user_id: str, conversation_id: str, pro
created_at=cst_now(), created_at=cst_now(),
trace_id=trace_id,
+20 -15
View File
@@ -16,6 +16,7 @@ import httpx
import os import os
import logging import logging
from ..database import get_db, SessionLocal from ..database import get_db, SessionLocal
from app.core.observability import span
from ..models import ( from ..models import (
@@ -791,7 +792,8 @@ async def _send_message_core(
try: try:
_tools = await list_mcp_tools(_srv) async with span("mcp.list_tools", server=_srv.get("name") if isinstance(_srv, dict) else None):
_tools = await list_mcp_tools(_srv)
except Exception as _e: except Exception as _e:
@@ -1192,35 +1194,36 @@ async def _send_message_core(
resp = await client.post( async with span("llm.completion", model=ai_model.id if ai_model else None):
resp = await client.post(
api_url, api_url,
headers={ headers={
"Authorization": f"Bearer {api_key}", "Authorization": f"Bearer {api_key}",
"Content-Type": "application/json" "Content-Type": "application/json"
}, },
json=request_body json=request_body
) )
@@ -1380,21 +1383,23 @@ async def _send_message_core(
if _srv: if _srv:
tool_result = await call_mcp_tool( async with span("mcp.call", name=_info.get("name") if isinstance(_info, dict) else None):
tool_result = await call_mcp_tool(
_srv, _info["name"], func_args _srv, _info["name"], func_args
) )
called_mcp_names.append(_srv.get("name", "")) called_mcp_names.append(_srv.get("name", ""))
else: else:
tool_result = _execute_skill_tool( async with span("skill.execute", name=func_name):
tool_result = _execute_skill_tool(
db, func_name, func_args, skills, req.content db, func_name, func_args, skills, req.content
) )
_sk = next((s for s in skills if s.get("func_name") == func_name), None) _sk = next((s for s in skills if s.get("func_name") == func_name), None)
+2 -1
View File
@@ -57,7 +57,7 @@ class LogService:
app_id: str = None, app_name: str = None, app_id: str = None, app_name: str = None,
conversation_id: str = None, prompt_tokens: int = 0, conversation_id: str = None, prompt_tokens: int = 0,
completion_tokens: int = 0, total_tokens: int = 0, completion_tokens: int = 0, total_tokens: int = 0,
cost: float = 0): cost: float = 0, trace_id: str = None):
"""记录Token消耗日志""" """记录Token消耗日志"""
db = SessionLocal() db = SessionLocal()
try: try:
@@ -73,6 +73,7 @@ class LogService:
completion_tokens=completion_tokens, completion_tokens=completion_tokens,
total_tokens=total_tokens, total_tokens=total_tokens,
cost=cost, cost=cost,
trace_id=trace_id,
) )
db.add(log) db.add(log)
db.commit() db.commit()
+1
View File
@@ -537,6 +537,7 @@ class TokenUsageLog(Base):
app_id = Column(String(36), default=None) app_id = Column(String(36), default=None)
app_name = Column(String(200), default=None) app_name = Column(String(200), default=None)
conversation_id = Column(String(36), default=None) conversation_id = Column(String(36), default=None)
trace_id = Column(String(64), default=None)
prompt_tokens = Column(Integer, default=0) prompt_tokens = Column(Integer, default=0)
completion_tokens = Column(Integer, default=0) completion_tokens = Column(Integer, default=0)
total_tokens = Column(Integer, default=0) total_tokens = Column(Integer, default=0)
@@ -0,0 +1,7 @@
-- Phase 2.2: add trace_id to token_usage_logs for cost attribution (idempotent)
SET @db = DATABASE();
SET @col_exists = (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = @db AND table_name = 'token_usage_logs' AND column_name = 'trace_id');
SET @sql = IF(@col_exists = 0, 'ALTER TABLE token_usage_logs ADD COLUMN trace_id VARCHAR(64) NULL DEFAULT NULL', 'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;