agent-Specialization/utils/conversation_manager/token_mixin.py
JOJO 3a4ea67e26 feat(stats): token 统计新增缓存命中追踪与命中率展示
- utils/token_usage.py:usage 归一化补全缓存命中字段提取,覆盖
  prompt_tokens_details/input_tokens_details.cached_tokens(OpenAI 系)、
  顶层 prompt_cache_hit_tokens(DeepSeek)、顶层 cached_tokens(Kimi/Step)、
  cache_read_input_tokens(Anthropic 系)、cachedContentTokenCount(Gemini);
  Anthropic 语义下把缓存读/写加回总输入以统一口径,normalize 保持幂等
- 对话级统计新增 total_cached_input_tokens 与 cache_exempt_input_tokens
  (首轮/深度压缩后首轮未命中缓存的输入视为冷启动成本,豁免出命中率分母;
  压缩通过 cache_cold_start_pending 标记在下一次真实调用时判定)
- token_update 广播与 token-statistics 接口同步携带新字段
- TokenDrawer 面板新增「累积缓存输入」「缓存命中率」(前端按 缓存/(总输入-豁免) 换算)
- 深色模式下「当前上下文」数字由灰色 --accent 改为 --text-primary(白)
- 附 cache_research/ 各厂商缓存字段调研文档(代码注释引用)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 12:16:48 +08:00

160 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# utils/conversation_manager.py - 对话持久化管理器集成Token统计
import json
import os
import time
import tempfile
import threading
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Any
from dataclasses import dataclass
try:
from config import DATA_DIR, HOST_WORKSPACES_FILE
except ImportError:
import sys
from pathlib import Path
project_root = Path(__file__).resolve().parents[1]
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
from config import DATA_DIR, HOST_WORKSPACES_FILE
@dataclass
class ConversationMetadata:
"""对话元数据"""
id: str
title: str
created_at: str
updated_at: str
project_path: Optional[str]
project_relative_path: Optional[str]
thinking_mode: bool
total_messages: int
total_tools: int
run_mode: str = "fast"
model_key: Optional[str] = None
has_images: bool = False
has_videos: bool = False
status: str = "active" # active, archived, error
class TokenMixin:
"""ConversationManager token mixin 能力 mixin。"""
def update_token_statistics(
self,
conversation_id: str,
input_tokens: int,
output_tokens: int,
total_tokens: int,
current_context_tokens: Optional[int] = None,
cached_input_tokens: int = 0,
cache_cold_start_pending: bool = False,
) -> bool:
"""
更新对话的Token统计
Args:
conversation_id: 对话ID
input_tokens: 输入Token数量
output_tokens: 输出Token数量
total_tokens: 本次请求的总Token数量prompt+completion
current_context_tokens: 当前上下文长度(用于压缩阈值判断)
cached_input_tokens: 本次请求命中缓存的输入Token数量
cache_cold_start_pending: 置位「压缩后待判定」标记(深度压缩后调用时传入)
Returns:
bool: 更新是否成功
"""
try:
conversation_data = self.load_conversation(conversation_id)
if not conversation_data:
print(f"⚠️ 无法找到对话 {conversation_id}跳过Token统计")
return False
# 确保Token统计结构存在
if "token_statistics" not in conversation_data:
conversation_data["token_statistics"] = self._initialize_token_statistics()
# 更新统计数据
token_stats = conversation_data["token_statistics"]
# ── 冷启动豁免:首轮/压缩后首轮若未命中缓存,其输入属于“建立缓存”成本,
# 豁免出命中率分母cache_exempt_input_tokens若命中则说明缓存延续正常处理。
# 判断需在累加之前进行(首轮判定依赖累加前的 total_input_tokens 为 0
if input_tokens > 0:
is_first_call = token_stats.get("total_input_tokens", 0) == 0
cold_start_pending = bool(token_stats.get("cache_cold_start_pending", False))
if is_first_call or cold_start_pending:
if cached_input_tokens <= 0:
token_stats["cache_exempt_input_tokens"] = (
token_stats.get("cache_exempt_input_tokens", 0) + int(input_tokens)
)
# 压缩后首轮消费标记(首轮不涉及该标记,置 False 无副作用)
token_stats["cache_cold_start_pending"] = False
token_stats["total_input_tokens"] = token_stats.get("total_input_tokens", 0) + input_tokens
token_stats["total_output_tokens"] = token_stats.get("total_output_tokens", 0) + output_tokens
token_stats["total_tokens"] = token_stats.get("total_tokens", 0) + total_tokens
token_stats["total_cached_input_tokens"] = token_stats.get("total_cached_input_tokens", 0) + max(0, int(cached_input_tokens or 0))
# 置位压缩后待判定标记(深度压缩重置统计时传入)
if cache_cold_start_pending:
token_stats["cache_cold_start_pending"] = True
if current_context_tokens is None:
# 兼容旧调用:未显式传入时,默认以输入 token 作为当前上下文长度
current_context_tokens = input_tokens
token_stats["current_context_tokens"] = max(0, int(current_context_tokens or 0))
token_stats["updated_at"] = datetime.now().isoformat()
# 保存更新
self._save_conversation_file(conversation_id, conversation_data)
print(f"📊 Token统计已更新: +{input_tokens}输入, +{output_tokens}输出 "
f"(总计: {token_stats['total_input_tokens']}输入, {token_stats['total_output_tokens']}输出)")
return True
except Exception as e:
print(f"⌘ 更新Token统计失败 {conversation_id}: {e}")
return False
def get_token_statistics(self, conversation_id: str) -> Optional[Dict]:
"""
获取对话的Token统计
Args:
conversation_id: 对话ID
Returns:
Dict: Token统计数据
"""
try:
conversation_data = self.load_conversation(conversation_id)
if not conversation_data:
return None
validated = self._validate_token_statistics(conversation_data)
token_stats = validated.get("token_statistics", {})
result = {
"total_input_tokens": token_stats.get("total_input_tokens", 0),
"total_output_tokens": token_stats.get("total_output_tokens", 0),
"total_tokens": token_stats.get("total_tokens", 0),
"total_cached_input_tokens": token_stats.get("total_cached_input_tokens", 0),
"cache_exempt_input_tokens": token_stats.get("cache_exempt_input_tokens", 0),
"current_context_tokens": token_stats.get("current_context_tokens", 0),
"updated_at": token_stats.get("updated_at"),
"conversation_id": conversation_id
}
return result
except Exception as e:
print(f"⌘ 获取Token统计失败 {conversation_id}: {e}")
return None
def get_current_context_tokens(self, conversation_id: str) -> int:
"""获取最近一次请求的上下文token"""
stats = self.get_token_statistics(conversation_id)
if not stats:
return 0
return stats.get("current_context_tokens", 0)