From a37e90686285e8e39195c0819d9b2ba1ebf326eb Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Fri, 1 May 2026 12:56:23 +0800 Subject: [PATCH 1/7] =?UTF-8?q?fix=EF=BC=9A=E4=BF=AE=E6=94=B9=E9=83=A8?= =?UTF-8?q?=E5=88=86=E6=A8=A1=E5=9E=8B=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/replyer/maisaka_generator_base.py | 1 + src/learners/jargon_miner.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/chat/replyer/maisaka_generator_base.py b/src/chat/replyer/maisaka_generator_base.py index 168928bf..dc055c4e 100644 --- a/src/chat/replyer/maisaka_generator_base.py +++ b/src/chat/replyer/maisaka_generator_base.py @@ -83,6 +83,7 @@ class BaseMaisakaReplyGenerator: self.express_model = llm_client_cls( task_name="replyer", request_type=request_type, + session_id=getattr(chat_stream, "session_id", "") if chat_stream is not None else "", ) self._personality_prompt = self._build_personality_prompt() diff --git a/src/learners/jargon_miner.py b/src/learners/jargon_miner.py index c05666e0..bccdde1a 100644 --- a/src/learners/jargon_miner.py +++ b/src/learners/jargon_miner.py @@ -23,7 +23,7 @@ from .expression_utils import is_single_char_jargon logger = get_logger("jargon") -llm_inference = LLMServiceClient(task_name="replyer", request_type="jargon.inference") +llm_inference = LLMServiceClient(task_name="utils", request_type="jargon.inference") class JargonEntry(TypedDict): From badd4988b6e9a85eff9d2fb76de6efc5a9f25e59 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Fri, 1 May 2026 13:00:27 +0800 Subject: [PATCH 2/7] feat: add llm cache diagnostics --- src/services/llm_cache_stats.py | 1507 +++++++++++++++++++++++++++++++ src/services/llm_service.py | 126 ++- 2 files changed, 1628 insertions(+), 5 deletions(-) create mode 100644 src/services/llm_cache_stats.py diff --git a/src/services/llm_cache_stats.py b/src/services/llm_cache_stats.py new file mode 100644 index 00000000..e6b1c268 --- /dev/null +++ b/src/services/llm_cache_stats.py @@ -0,0 +1,1507 @@ +"""LLM prompt cache statistics and local dynamic-diff diagnostics.""" + +from dataclasses import dataclass, field +from datetime import datetime +from html import escape +from math import erf, sqrt +from pathlib import Path +from threading import RLock +from typing import Any, Dict, List, Tuple + +import json +import time +import uuid + +from src.common.logger import get_logger + +logger = get_logger("llm_cache_stats") + +FOCUSED_TASK_NAMES = {"replyer", "planner"} +EXCLUDED_REQUEST_TYPES = { + "A_Memorix.ChatSummarization", + "expression.learner", + "maisaka_reply_effect_judge", +} +REPORT_INTERVAL_SECONDS = 300 +REPORT_INTERVAL_CALLS = 50 +SUMMARY_LIMIT = 5 +PROMPT_CACHE_POOL_SIZE = 128 +CACHE_STATS_DIR = Path("logs") / "llm_cache_stats" +REPORT_FILE_NAME = "report.html" +SESSION_REPORT_FILE_NAME = "sessions.html" +SNIPPET_LIMIT = 160 +PROCESS_STARTED_AT = datetime.now().isoformat(timespec="seconds") +RUN_ID = f"{datetime.now():%Y%m%d%H%M%S}-{uuid.uuid4().hex[:8]}" + + +@dataclass(slots=True) +class LLMCacheStat: + """Aggregated prompt cache stats for one task/request/model call site.""" + + task_name: str + request_type: str + model_name: str + session_id: str = "" + calls: int = 0 + cache_reported_calls: int = 0 + prompt_tokens: int = 0 + prompt_cache_hit_tokens: int = 0 + prompt_cache_miss_tokens: int = 0 + theoretical_prompt_cache_hit_tokens: int = 0 + theoretical_prompt_cache_miss_tokens: int = 0 + theoretical_compared_calls: int = 0 + theoretical_cache_pool_hits: int = 0 + common_prefix_rate_total: float = 0.0 + suspected_context_sliding_calls: int = 0 + sliding_dropped_messages_total: int = 0 + sliding_aligned_messages_total: int = 0 + dynamic_diff_counts: Dict[str, int] = field(default_factory=dict) + + @property + def prompt_cache_total_tokens(self) -> int: + return self.prompt_cache_hit_tokens + self.prompt_cache_miss_tokens + + @property + def prompt_cache_hit_rate(self) -> float: + total_tokens = self.prompt_cache_total_tokens + if total_tokens <= 0: + return 0.0 + return self.prompt_cache_hit_tokens / total_tokens * 100 + + @property + def theoretical_prompt_cache_total_tokens(self) -> int: + return self.theoretical_prompt_cache_hit_tokens + self.theoretical_prompt_cache_miss_tokens + + @property + def theoretical_prompt_cache_hit_rate(self) -> float: + total_tokens = self.theoretical_prompt_cache_total_tokens + if total_tokens <= 0: + return 0.0 + return self.theoretical_prompt_cache_hit_tokens / total_tokens * 100 + + @property + def prompt_cache_hit_rate_delta(self) -> float: + return self.prompt_cache_hit_rate - self.theoretical_prompt_cache_hit_rate + + def _format_top_dynamic_diff_paths(self) -> str: + if not self.dynamic_diff_counts: + return "" + top_items = sorted( + self.dynamic_diff_counts.items(), + key=lambda item: (-item[1], item[0]), + )[:SUMMARY_LIMIT] + return "; ".join(f"{path} ({count})" for path, count in top_items) + + def to_dict(self) -> Dict[str, int | str | float]: + return { + "task_name": self.task_name, + "request_type": self.request_type, + "model_name": self.model_name, + "session_id": self.session_id, + "calls": self.calls, + "cache_reported_calls": self.cache_reported_calls, + "prompt_tokens": self.prompt_tokens, + "prompt_cache_hit_tokens": self.prompt_cache_hit_tokens, + "prompt_cache_miss_tokens": self.prompt_cache_miss_tokens, + "prompt_cache_hit_rate": round(self.prompt_cache_hit_rate, 2), + "theoretical_prompt_cache_hit_tokens": self.theoretical_prompt_cache_hit_tokens, + "theoretical_prompt_cache_miss_tokens": self.theoretical_prompt_cache_miss_tokens, + "theoretical_compared_calls": self.theoretical_compared_calls, + "theoretical_cache_pool_hits": self.theoretical_cache_pool_hits, + "theoretical_prompt_cache_hit_rate": round(self.theoretical_prompt_cache_hit_rate, 2), + "prompt_cache_hit_rate_delta": round(self.prompt_cache_hit_rate_delta, 2), + "avg_common_prefix_rate": round(self.common_prefix_rate_total / self.calls, 2) if self.calls else 0.0, + "suspected_context_sliding_calls": self.suspected_context_sliding_calls, + "avg_sliding_dropped_messages": ( + round(self.sliding_dropped_messages_total / self.suspected_context_sliding_calls, 2) + if self.suspected_context_sliding_calls + else 0.0 + ), + "avg_sliding_aligned_messages": ( + round(self.sliding_aligned_messages_total / self.suspected_context_sliding_calls, 2) + if self.suspected_context_sliding_calls + else 0.0 + ), + "top_dynamic_diff_paths": self._format_top_dynamic_diff_paths(), + } + + +@dataclass(slots=True) +class _TheoreticalCacheMatch: + hit_tokens: int + miss_tokens: int + hit_rate: float + compared: bool + pool_size: int + best_match_rank: int + best_prompt_text: str | None + common_prefix_chars: int + + +@dataclass(slots=True) +class _DynamicDiff: + path: str + previous_value: str + current_value: str + + +@dataclass(slots=True) +class _PromptCacheDiagnostics: + current_message_count: int = 0 + best_match_message_count: int = 0 + common_prefix_messages: int = 0 + common_suffix_messages: int = 0 + common_prefix_rate: float = 0.0 + prompt_growth_chars: int = 0 + longest_aligned_message_overlap: int = 0 + aligned_previous_start_index: int = 0 + aligned_current_start_index: int = 0 + suspected_context_sliding: bool = False + sliding_dropped_head_messages: int = 0 + sliding_aligned_messages: int = 0 + sliding_new_tail_messages: int = 0 + current_first_message_role: str = "" + best_first_message_role: str = "" + current_last_message_role: str = "" + best_last_message_role: str = "" + + +@dataclass(slots=True) +class _LLMCacheStatsStore: + stats: Dict[Tuple[str, str, str, str], LLMCacheStat] = field(default_factory=dict) + prompt_pools: Dict[Tuple[str, str, str, str], List[str]] = field(default_factory=dict) + total_calls: int = 0 + run_id: str = RUN_ID + process_started_at: str = PROCESS_STARTED_AT + calls_in_run: int = 0 + last_report_at: float = 0 + calls_since_report: int = 0 + lock: RLock = field(default_factory=RLock) + + +_store = _LLMCacheStatsStore() + + +def _normalize_request_type(request_type: str) -> str: + normalized = str(request_type or "").strip() + return normalized or "unknown" + + +def _normalize_model_name(model_name: str) -> str: + normalized = str(model_name or "").strip() + return normalized or "unknown" + + +def _normalize_session_id(session_id: str) -> str: + normalized = str(session_id or "").strip() + return normalized or "unknown" + + +def _normalize_cache_tokens( + *, + prompt_tokens: int, + prompt_cache_hit_tokens: int, + prompt_cache_miss_tokens: int, +) -> tuple[int, int, bool]: + hit_tokens = max(int(prompt_cache_hit_tokens or 0), 0) + miss_tokens = max(int(prompt_cache_miss_tokens or 0), 0) + has_cache_report = hit_tokens > 0 or miss_tokens > 0 + + if miss_tokens == 0 and hit_tokens > 0: + miss_tokens = max(prompt_tokens - hit_tokens, 0) + elif hit_tokens == 0 and miss_tokens == 0 and prompt_tokens > 0: + # Some providers do not return cache details. Treat it as all miss, while keeping reported_calls separate. + miss_tokens = prompt_tokens + + return hit_tokens, miss_tokens, has_cache_report + + +def _longest_common_prefix_length(left: str, right: str) -> int: + max_length = min(len(left), len(right)) + for index in range(max_length): + if left[index] != right[index]: + return index + return max_length + + +def _calculate_theoretical_cache_match( + *, + prompt_tokens: int, + prompt_text: str | None, + prompt_pool: List[str], +) -> _TheoreticalCacheMatch: + """Estimate local theoretical cache hit by matching against the whole prompt pool.""" + + if not prompt_text: + return _TheoreticalCacheMatch(0, 0, 0.0, False, 0, 0, None, 0) + if not prompt_pool: + return _TheoreticalCacheMatch(0, prompt_tokens, 0.0, True, 0, 0, None, 0) + + best_prefix_length = 0 + best_match_rank = 0 + best_prompt_text: str | None = None + # rank=1 means the newest cached prompt in this local pool. + for rank, cached_prompt_text in enumerate(reversed(prompt_pool), start=1): + prefix_length = _longest_common_prefix_length(cached_prompt_text, prompt_text) + if prefix_length > best_prefix_length: + best_prefix_length = prefix_length + best_match_rank = rank + best_prompt_text = cached_prompt_text + + overlap_rate = best_prefix_length / len(prompt_text) if prompt_text else 0.0 + theoretical_hit_tokens = min(prompt_tokens, round(prompt_tokens * overlap_rate)) + theoretical_miss_tokens = max(prompt_tokens - theoretical_hit_tokens, 0) + return _TheoreticalCacheMatch( + theoretical_hit_tokens, + theoretical_miss_tokens, + overlap_rate * 100, + True, + len(prompt_pool), + best_match_rank, + best_prompt_text, + best_prefix_length, + ) + + +def _summarize_value(value: Any) -> str: + if isinstance(value, str): + normalized = value.replace("\n", "\\n") + else: + normalized = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str) + if len(normalized) > SNIPPET_LIMIT: + return normalized[:SNIPPET_LIMIT] + "..." + return normalized + + +def _find_first_structural_diff(previous_value: Any, current_value: Any, path: str = "root") -> _DynamicDiff | None: + if type(previous_value) is not type(current_value): + return _DynamicDiff( + f"{path}.__type__", + type(previous_value).__name__, + type(current_value).__name__, + ) + + if isinstance(previous_value, dict): + previous_keys = set(previous_value) + current_keys = set(current_value) + for key in sorted(previous_keys | current_keys): + key_path = f"{path}.{key}" + if key not in previous_value: + return _DynamicDiff(key_path, "", _summarize_value(current_value[key])) + if key not in current_value: + return _DynamicDiff(key_path, _summarize_value(previous_value[key]), "") + nested_diff = _find_first_structural_diff(previous_value[key], current_value[key], key_path) + if nested_diff is not None: + return nested_diff + return None + + if isinstance(previous_value, list): + max_length = max(len(previous_value), len(current_value)) + for index in range(max_length): + index_path = f"{path}[{index}]" + if index >= len(previous_value): + return _DynamicDiff(index_path, "", _summarize_value(current_value[index])) + if index >= len(current_value): + return _DynamicDiff(index_path, _summarize_value(previous_value[index]), "") + nested_diff = _find_first_structural_diff(previous_value[index], current_value[index], index_path) + if nested_diff is not None: + return nested_diff + return None + + if previous_value == current_value: + return None + + if isinstance(previous_value, str) and isinstance(current_value, str): + diff_index = _longest_common_prefix_length(previous_value, current_value) + return _DynamicDiff( + f"{path}@char{diff_index}", + _summarize_value(previous_value[diff_index:]), + _summarize_value(current_value[diff_index:]), + ) + + return _DynamicDiff(path, _summarize_value(previous_value), _summarize_value(current_value)) + + +def _diagnose_dynamic_diff(previous_prompt_text: str | None, current_prompt_text: str | None) -> _DynamicDiff: + if not current_prompt_text: + return _DynamicDiff("prompt_text.unavailable", "", "") + if not previous_prompt_text: + return _DynamicDiff("cache_pool.empty", "", _summarize_value(current_prompt_text)) + + try: + previous_payload = json.loads(previous_prompt_text) + current_payload = json.loads(current_prompt_text) + except json.JSONDecodeError: + diff_index = _longest_common_prefix_length(previous_prompt_text, current_prompt_text) + return _DynamicDiff( + f"raw_prompt@char{diff_index}", + _summarize_value(previous_prompt_text[diff_index:]), + _summarize_value(current_prompt_text[diff_index:]), + ) + + diff = _find_first_structural_diff(previous_payload, current_payload) + if diff is None: + return _DynamicDiff("identical", "", "") + return diff + + +def _load_prompt_payload(prompt_text: str | None) -> dict[str, Any] | None: + if not prompt_text: + return None + try: + payload = json.loads(prompt_text) + except json.JSONDecodeError: + return None + return payload if isinstance(payload, dict) else None + + +def _extract_prompt_messages(prompt_text: str | None) -> list[dict[str, Any]]: + payload = _load_prompt_payload(prompt_text) + if payload is None: + return [] + messages = payload.get("messages") + return [message for message in messages if isinstance(message, dict)] if isinstance(messages, list) else [] + + +def _message_fingerprints(messages: list[dict[str, Any]]) -> list[str]: + return [json.dumps(message, ensure_ascii=False, sort_keys=True, default=str) for message in messages] + + +def _count_common_prefix_items(left_items: list[str], right_items: list[str]) -> int: + common_count = 0 + for left_item, right_item in zip(left_items, right_items, strict=False): + if left_item != right_item: + break + common_count += 1 + return common_count + + +def _count_common_suffix_items(left_items: list[str], right_items: list[str]) -> int: + common_count = 0 + max_count = min(len(left_items), len(right_items)) + while common_count < max_count and left_items[-common_count - 1] == right_items[-common_count - 1]: + common_count += 1 + return common_count + + +def _find_longest_message_alignment(previous_items: list[str], current_items: list[str]) -> tuple[int, int, int]: + best_overlap = 0 + best_previous_start = 0 + best_current_start = 0 + for previous_start in range(len(previous_items)): + for current_start in range(len(current_items)): + overlap = 0 + while ( + previous_start + overlap < len(previous_items) + and current_start + overlap < len(current_items) + and previous_items[previous_start + overlap] == current_items[current_start + overlap] + ): + overlap += 1 + if overlap > best_overlap: + best_overlap = overlap + best_previous_start = previous_start + best_current_start = current_start + return best_overlap, best_previous_start, best_current_start + + +def _get_message_role(messages: list[dict[str, Any]], index: int) -> str: + if not messages: + return "" + try: + value = messages[index].get("role", "") + except IndexError: + return "" + return str(value or "") + + +def _diagnose_prompt_cache_details( + *, + previous_prompt_text: str | None, + current_prompt_text: str | None, + common_prefix_chars: int, +) -> _PromptCacheDiagnostics: + current_messages = _extract_prompt_messages(current_prompt_text) + previous_messages = _extract_prompt_messages(previous_prompt_text) + current_items = _message_fingerprints(current_messages) + previous_items = _message_fingerprints(previous_messages) + current_prompt_length = len(current_prompt_text or "") + previous_prompt_length = len(previous_prompt_text or "") + common_prefix_rate = common_prefix_chars / current_prompt_length * 100 if current_prompt_length > 0 else 0.0 + + common_prefix_messages = _count_common_prefix_items(previous_items, current_items) + common_suffix_messages = _count_common_suffix_items(previous_items, current_items) + aligned_overlap, aligned_previous_start, aligned_current_start = _find_longest_message_alignment( + previous_items, + current_items, + ) + suspected_context_sliding = ( + aligned_previous_start > aligned_current_start + and aligned_overlap > common_prefix_messages + ) + sliding_dropped_head_messages = aligned_previous_start - aligned_current_start if suspected_context_sliding else 0 + + return _PromptCacheDiagnostics( + current_message_count=len(current_messages), + best_match_message_count=len(previous_messages), + common_prefix_messages=common_prefix_messages, + common_suffix_messages=common_suffix_messages, + common_prefix_rate=common_prefix_rate, + prompt_growth_chars=current_prompt_length - previous_prompt_length, + longest_aligned_message_overlap=aligned_overlap, + aligned_previous_start_index=aligned_previous_start, + aligned_current_start_index=aligned_current_start, + suspected_context_sliding=suspected_context_sliding, + sliding_dropped_head_messages=sliding_dropped_head_messages, + sliding_aligned_messages=aligned_overlap if suspected_context_sliding else 0, + sliding_new_tail_messages=( + max(len(current_messages) - aligned_current_start - aligned_overlap, 0) + if suspected_context_sliding + else 0 + ), + current_first_message_role=_get_message_role(current_messages, 0), + best_first_message_role=_get_message_role(previous_messages, 0), + current_last_message_role=_get_message_role(current_messages, -1), + best_last_message_role=_get_message_role(previous_messages, -1), + ) + + +def _get_usage_log_path(now: datetime) -> Path: + return CACHE_STATS_DIR / f"usage_{now:%Y%m%d}.jsonl" + + +def _get_report_path() -> Path: + return CACHE_STATS_DIR / REPORT_FILE_NAME + + +def _get_session_report_path() -> Path: + return CACHE_STATS_DIR / SESSION_REPORT_FILE_NAME + + +def _iter_usage_log_paths() -> list[Path]: + if not CACHE_STATS_DIR.exists(): + return [] + return sorted(CACHE_STATS_DIR.glob("usage_*.jsonl")) + + +def _read_usage_events() -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + for file_path in _iter_usage_log_paths(): + try: + lines = file_path.read_text(encoding="utf-8").splitlines() + except OSError: + continue + for line in lines: + if not line.strip(): + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(event, dict): + events.append(event) + return events + + +def _write_json_line(file_path: Path, payload: Dict[str, int | str | float | bool]) -> None: + CACHE_STATS_DIR.mkdir(parents=True, exist_ok=True) + with file_path.open("a", encoding="utf-8") as file: + file.write(json.dumps(payload, ensure_ascii=False) + "\n") + + +def _format_int(value: int | str | float) -> str: + return f"{int(value):,}" + + +def _format_rate(value: int | str | float) -> str: + return f"{float(value):.2f}%" + + +def _calculate_rate(hit_tokens: int, miss_tokens: int) -> float: + total_tokens = hit_tokens + miss_tokens + return hit_tokens / total_tokens * 100 if total_tokens > 0 else 0.0 + + +def _normal_cdf(value: float) -> float: + return 0.5 * (1.0 + erf(value / sqrt(2.0))) + + +def _confidence_from_z_score(z_score: float) -> float: + p_value = 2.0 * (1.0 - _normal_cdf(abs(z_score))) + return max(0.0, min(100.0, (1.0 - p_value) * 100.0)) + + +def _format_significance_label(confidence: float, *, min_confidence: float = 95.0) -> str: + return "显著" if confidence >= min_confidence else "不显著" + + +def _calculate_two_proportion_confidence( + *, + current_hit: int, + current_total: int, + baseline_hit: int, + baseline_total: int, +) -> float: + if current_total <= 0 or baseline_total <= 0: + return 0.0 + current_rate = current_hit / current_total + baseline_rate = baseline_hit / baseline_total + pooled_rate = (current_hit + baseline_hit) / (current_total + baseline_total) + standard_error = sqrt(pooled_rate * (1.0 - pooled_rate) * (1.0 / current_total + 1.0 / baseline_total)) + if standard_error <= 0: + return 0.0 + return _confidence_from_z_score((current_rate - baseline_rate) / standard_error) + + +def _calculate_sample_variance(*, value_total: float, square_total: float, count: int) -> float: + if count <= 1: + return 0.0 + return max((square_total - (value_total * value_total / count)) / (count - 1), 0.0) + + +def _calculate_mean_difference_confidence( + *, + current_mean: float, + current_variance: float, + current_count: int, + baseline_mean: float, + baseline_variance: float, + baseline_count: int, +) -> float: + if current_count <= 1 or baseline_count <= 1: + return 0.0 + standard_error = sqrt(current_variance / current_count + baseline_variance / baseline_count) + if standard_error <= 0: + return 0.0 + return _confidence_from_z_score((current_mean - baseline_mean) / standard_error) + + +def _normalize_event_run_id(event: dict[str, Any]) -> str: + run_id = str(event.get("run_id") or "").strip() + return run_id or "legacy" + + +def _aggregate_usage_events_by_run(events: list[dict[str, Any]]) -> list[dict[str, int | str | float]]: + grouped: dict[str, dict[str, int | str | float]] = {} + for event in events: + run_id = _normalize_event_run_id(event) + item = grouped.setdefault( + run_id, + { + "run_id": run_id, + "process_started_at": str(event.get("process_started_at") or ""), + "first_seen_at": str(event.get("created_at") or ""), + "last_seen_at": str(event.get("created_at") or ""), + "calls": 0, + "prompt_tokens": 0, + "prompt_cache_hit_tokens": 0, + "prompt_cache_miss_tokens": 0, + "theoretical_prompt_cache_hit_tokens": 0, + "theoretical_prompt_cache_miss_tokens": 0, + "common_prefix_rate_total": 0.0, + "common_prefix_rate_square_total": 0.0, + "suspected_context_sliding_calls": 0, + }, + ) + created_at = str(event.get("created_at") or "") + if created_at: + if not item["first_seen_at"] or created_at < str(item["first_seen_at"]): + item["first_seen_at"] = created_at + if created_at > str(item["last_seen_at"]): + item["last_seen_at"] = created_at + item["calls"] = int(item["calls"]) + 1 + item["prompt_tokens"] = int(item["prompt_tokens"]) + int(event.get("prompt_tokens") or 0) + item["prompt_cache_hit_tokens"] = int(item["prompt_cache_hit_tokens"]) + int( + event.get("prompt_cache_hit_tokens") or 0 + ) + item["prompt_cache_miss_tokens"] = int(item["prompt_cache_miss_tokens"]) + int( + event.get("prompt_cache_miss_tokens") or 0 + ) + item["theoretical_prompt_cache_hit_tokens"] = int(item["theoretical_prompt_cache_hit_tokens"]) + int( + event.get("theoretical_prompt_cache_hit_tokens") or 0 + ) + item["theoretical_prompt_cache_miss_tokens"] = int(item["theoretical_prompt_cache_miss_tokens"]) + int( + event.get("theoretical_prompt_cache_miss_tokens") or 0 + ) + item["common_prefix_rate_total"] = float(item["common_prefix_rate_total"]) + float( + event.get("theoretical_common_prefix_rate") or 0.0 + ) + if bool(event.get("suspected_context_sliding", False)): + item["suspected_context_sliding_calls"] = int(item["suspected_context_sliding_calls"]) + 1 + + result: list[dict[str, int | str | float]] = [] + for item in grouped.values(): + calls = int(item["calls"]) + hit_tokens = int(item["prompt_cache_hit_tokens"]) + miss_tokens = int(item["prompt_cache_miss_tokens"]) + theoretical_hit_tokens = int(item["theoretical_prompt_cache_hit_tokens"]) + theoretical_miss_tokens = int(item["theoretical_prompt_cache_miss_tokens"]) + item["prompt_cache_hit_rate"] = round(_calculate_rate(hit_tokens, miss_tokens), 2) + item["theoretical_prompt_cache_hit_rate"] = round( + _calculate_rate(theoretical_hit_tokens, theoretical_miss_tokens), + 2, + ) + item["avg_common_prefix_rate"] = round(float(item["common_prefix_rate_total"]) / calls, 2) if calls else 0.0 + result.append(item) + + return sorted(result, key=lambda item: str(item["first_seen_at"])) + + +def _get_previous_run_id(run_stats: list[dict[str, int | str | float]], current_run_id: str) -> str: + run_ids = [str(item["run_id"]) for item in run_stats] + if current_run_id not in run_ids: + return "" + current_index = run_ids.index(current_run_id) + if current_index <= 0: + return "" + return run_ids[current_index - 1] + + +def _aggregate_usage_events_by_call_site( + events: list[dict[str, Any]], + *, + run_id: str, + include_session: bool = True, +) -> dict[tuple[str, ...], dict[str, int | str | float]]: + grouped: dict[tuple[str, ...], dict[str, int | str | float]] = {} + for event in events: + if _normalize_event_run_id(event) != run_id: + continue + base_key = ( + str(event.get("task_name") or ""), + str(event.get("request_type") or ""), + str(event.get("model_name") or ""), + ) + key = ( + *base_key, + _normalize_session_id(str(event.get("session_id") or "")), + ) if include_session else base_key + item = grouped.setdefault( + key, + { + "task_name": key[0], + "request_type": key[1], + "model_name": key[2], + "session_id": key[3] if include_session else "", + "calls": 0, + "prompt_cache_hit_tokens": 0, + "prompt_cache_miss_tokens": 0, + "theoretical_prompt_cache_hit_tokens": 0, + "theoretical_prompt_cache_miss_tokens": 0, + "common_prefix_rate_total": 0.0, + "common_prefix_rate_square_total": 0.0, + "suspected_context_sliding_calls": 0, + }, + ) + item["calls"] = int(item["calls"]) + 1 + item["prompt_cache_hit_tokens"] = int(item["prompt_cache_hit_tokens"]) + int( + event.get("prompt_cache_hit_tokens") or 0 + ) + item["prompt_cache_miss_tokens"] = int(item["prompt_cache_miss_tokens"]) + int( + event.get("prompt_cache_miss_tokens") or 0 + ) + item["theoretical_prompt_cache_hit_tokens"] = int(item["theoretical_prompt_cache_hit_tokens"]) + int( + event.get("theoretical_prompt_cache_hit_tokens") or 0 + ) + item["theoretical_prompt_cache_miss_tokens"] = int(item["theoretical_prompt_cache_miss_tokens"]) + int( + event.get("theoretical_prompt_cache_miss_tokens") or 0 + ) + prefix_rate = float(event.get("theoretical_common_prefix_rate") or 0.0) + item["common_prefix_rate_total"] = float(item["common_prefix_rate_total"]) + prefix_rate + item["common_prefix_rate_square_total"] = float(item["common_prefix_rate_square_total"]) + prefix_rate * prefix_rate + if bool(event.get("suspected_context_sliding", False)): + item["suspected_context_sliding_calls"] = int(item["suspected_context_sliding_calls"]) + 1 + + for item in grouped.values(): + calls = int(item["calls"]) + prefix_total = float(item["common_prefix_rate_total"]) + prefix_square_total = float(item["common_prefix_rate_square_total"]) + item["prompt_cache_hit_rate"] = round( + _calculate_rate(int(item["prompt_cache_hit_tokens"]), int(item["prompt_cache_miss_tokens"])), + 2, + ) + item["theoretical_prompt_cache_hit_rate"] = round( + _calculate_rate( + int(item["theoretical_prompt_cache_hit_tokens"]), + int(item["theoretical_prompt_cache_miss_tokens"]), + ), + 2, + ) + item["avg_common_prefix_rate"] = round(prefix_total / calls, 2) if calls else 0.0 + item["common_prefix_rate_variance"] = round( + _calculate_sample_variance( + value_total=prefix_total, + square_total=prefix_square_total, + count=calls, + ), + 4, + ) + return grouped + + +def _render_run_rows(run_stats: list[dict[str, int | str | float]], current_run_id: str) -> str: + rows: list[str] = [] + for item in reversed(run_stats[-12:]): + current_marker = "当前" if str(item["run_id"]) == current_run_id else "" + rows.append( + "" + f"{escape(current_marker)}" + f"{escape(str(item['run_id']))}" + f"{escape(str(item['process_started_at']))}" + f"{escape(str(item['first_seen_at']))}" + f"{escape(str(item['last_seen_at']))}" + f"{_format_int(item['calls'])}" + f"{_format_int(item['prompt_tokens'])}" + f"{_format_rate(item['prompt_cache_hit_rate'])}" + f"{_format_rate(item['theoretical_prompt_cache_hit_rate'])}" + f"{_format_rate(item['avg_common_prefix_rate'])}" + f"{_format_int(item['suspected_context_sliding_calls'])}" + "" + ) + return "\n".join(rows) + + +def _render_run_comparison_rows( + *, + current_by_call_site: dict[tuple[str, ...], dict[str, int | str | float]], + previous_by_call_site: dict[tuple[str, ...], dict[str, int | str | float]], + include_session: bool, +) -> str: + rows: list[str] = [] + keys = sorted(set(current_by_call_site) | set(previous_by_call_site)) + for key in keys: + current_item = current_by_call_site.get(key, {}) + previous_item = previous_by_call_site.get(key, {}) + current_api = float(current_item.get("prompt_cache_hit_rate") or 0.0) + previous_api = float(previous_item.get("prompt_cache_hit_rate") or 0.0) + current_theory = float(current_item.get("theoretical_prompt_cache_hit_rate") or 0.0) + previous_theory = float(previous_item.get("theoretical_prompt_cache_hit_rate") or 0.0) + current_prefix = float(current_item.get("avg_common_prefix_rate") or 0.0) + previous_prefix = float(previous_item.get("avg_common_prefix_rate") or 0.0) + rows.append( + "" + f"{escape(key[0])}" + f"{escape(key[1])}" + f"{escape(key[2])}" + + (f"{escape(key[3])}" if include_session and len(key) > 3 else "") + + + f"{_format_int(current_item.get('calls', 0))}" + f"{_format_int(previous_item.get('calls', 0))}" + f"{_format_rate(current_api)}" + f"{_format_rate(previous_api)}" + f"{_format_rate(current_api - previous_api)}" + f"{_format_rate(current_theory)}" + f"{_format_rate(previous_theory)}" + f"{_format_rate(current_theory - previous_theory)}" + f"{_format_rate(current_prefix)}" + f"{_format_rate(previous_prefix)}" + f"{_format_rate(current_prefix - previous_prefix)}" + f"{_format_int(current_item.get('suspected_context_sliding_calls', 0))}" + f"{_format_int(previous_item.get('suspected_context_sliding_calls', 0))}" + "" + ) + return "\n".join(rows) + + +def _format_run_time_label(run_stat: dict[str, int | str | float] | None) -> str: + if not run_stat: + return "" + first_seen_at = str(run_stat.get("first_seen_at") or "").strip() + last_seen_at = str(run_stat.get("last_seen_at") or "").strip() + process_started_at = str(run_stat.get("process_started_at") or "").strip() + if first_seen_at and last_seen_at and first_seen_at != last_seen_at: + return f"{first_seen_at} -> {last_seen_at}" + if first_seen_at: + return first_seen_at + return process_started_at + + +def _get_previous_run_stats( + run_stats: list[dict[str, int | str | float]], + current_run_id: str, +) -> list[dict[str, int | str | float]]: + return [ + item + for item in run_stats + if str(item["run_id"]) != current_run_id + ] + + +def _render_run_significance_controls( + run_stats: list[dict[str, int | str | float]], + current_run_id: str, +) -> str: + previous_run_stats = _get_previous_run_stats(run_stats, current_run_id) + if not previous_run_stats: + return ( + "
" + "No previous runs to compare." + "
" + ) + + option_payload = [ + { + "run_id": str(item["run_id"]), + "time_label": _format_run_time_label(item), + "calls": int(item.get("calls") or 0), + } + for item in previous_run_stats + ] + option_json = escape(json.dumps(option_payload, ensure_ascii=False), quote=True) + max_index = len(previous_run_stats) - 1 + return ( + "
" + "" + "" + "" + "
" + "
Baseline run
" + "
" + "
" + "
" + "
" + ) + + +def _render_run_significance_script() -> str: + return """ + +""" + + +def _build_run_significance_rows( + *, + usage_events: list[dict[str, Any]], + run_stats: list[dict[str, int | str | float]], + current_run_id: str, + include_session: bool, +) -> str: + current_by_call_site = _aggregate_usage_events_by_call_site( + usage_events, + run_id=current_run_id, + include_session=include_session, + ) + rows: list[str] = [] + previous_run_stats = _get_previous_run_stats(run_stats, current_run_id) + for previous_run_stat in previous_run_stats: + previous_run_id = str(previous_run_stat["run_id"]) + baseline_time = _format_run_time_label(previous_run_stat) + previous_by_call_site = _aggregate_usage_events_by_call_site( + usage_events, + run_id=previous_run_id, + include_session=include_session, + ) + keys = sorted(set(current_by_call_site) & set(previous_by_call_site)) + for key in keys: + current_item = current_by_call_site[key] + previous_item = previous_by_call_site[key] + current_hit = int(current_item.get("prompt_cache_hit_tokens") or 0) + current_miss = int(current_item.get("prompt_cache_miss_tokens") or 0) + previous_hit = int(previous_item.get("prompt_cache_hit_tokens") or 0) + previous_miss = int(previous_item.get("prompt_cache_miss_tokens") or 0) + current_total = current_hit + current_miss + previous_total = previous_hit + previous_miss + current_api = _calculate_rate(current_hit, current_miss) + previous_api = _calculate_rate(previous_hit, previous_miss) + api_confidence = _calculate_two_proportion_confidence( + current_hit=current_hit, + current_total=current_total, + baseline_hit=previous_hit, + baseline_total=previous_total, + ) + current_calls = int(current_item.get("calls") or 0) + previous_calls = int(previous_item.get("calls") or 0) + current_prefix = float(current_item.get("avg_common_prefix_rate") or 0.0) + previous_prefix = float(previous_item.get("avg_common_prefix_rate") or 0.0) + prefix_confidence = _calculate_mean_difference_confidence( + current_mean=current_prefix, + current_variance=float(current_item.get("common_prefix_rate_variance") or 0.0), + current_count=current_calls, + baseline_mean=previous_prefix, + baseline_variance=float(previous_item.get("common_prefix_rate_variance") or 0.0), + baseline_count=previous_calls, + ) + rows.append( + f"" + f"{escape(previous_run_id)}" + f"{escape(baseline_time)}" + f"{escape(key[0])}" + f"{escape(key[1])}" + f"{escape(key[2])}" + + (f"{escape(key[3])}" if include_session and len(key) > 3 else "") + + + f"{_format_int(current_calls)}" + f"{_format_int(previous_calls)}" + f"{_format_rate(current_api - previous_api)}" + f"{_format_rate(api_confidence)}" + f"{escape(_format_significance_label(api_confidence))}" + f"{_format_rate(current_prefix - previous_prefix)}" + f"{_format_rate(prefix_confidence)}" + f"{escape(_format_significance_label(prefix_confidence))}" + f"{_format_int(current_item.get('suspected_context_sliding_calls', 0))}" + f"{_format_int(previous_item.get('suspected_context_sliding_calls', 0))}" + "" + ) + + if not rows: + return ( + "当前 run 还没有可与历史 run 比较的同类调用点," + "或历史数据缺少 run_id。" + ) + return "\n".join(rows) + + +def _render_stat_rows(stats: List[Dict[str, int | str | float]], *, include_session: bool) -> str: + rows: list[str] = [] + for item in stats: + rows.append( + "" + f"{escape(str(item['task_name']))}" + f"{escape(str(item['request_type']))}" + f"{escape(str(item['model_name']))}" + + (f"{escape(str(item.get('session_id', '')))}" if include_session else "") + + + f"{_format_rate(item['prompt_cache_hit_rate'])}" + f"{_format_rate(item['theoretical_prompt_cache_hit_rate'])}" + f"{_format_rate(item['prompt_cache_hit_rate_delta'])}" + f"{_format_int(item['prompt_cache_hit_tokens'])}" + f"{_format_int(item['prompt_cache_miss_tokens'])}" + f"{_format_int(item['theoretical_prompt_cache_hit_tokens'])}" + f"{_format_int(item['theoretical_prompt_cache_miss_tokens'])}" + f"{_format_int(item['prompt_tokens'])}" + f"{_format_int(item['calls'])}" + f"{_format_int(item['cache_reported_calls'])}" + f"{_format_int(item['theoretical_compared_calls'])}" + f"{_format_int(item['theoretical_cache_pool_hits'])}" + f"{_format_rate(item['avg_common_prefix_rate'])}" + f"{_format_int(item['suspected_context_sliding_calls'])}" + f"{item['avg_sliding_dropped_messages']}" + f"{item['avg_sliding_aligned_messages']}" + f"{escape(str(item.get('top_dynamic_diff_paths', '')))}" + "" + ) + return "\n".join(rows) + + +def _aggregate_stats_snapshot( + stats_snapshot: List[Dict[str, int | str | float]], + *, + include_session: bool, +) -> List[Dict[str, int | str | float]]: + grouped: dict[tuple[str, ...], dict[str, int | str | float]] = {} + for item in stats_snapshot: + base_key = ( + str(item.get("task_name") or ""), + str(item.get("request_type") or ""), + str(item.get("model_name") or ""), + ) + key = (*base_key, str(item.get("session_id") or "")) if include_session else base_key + target = grouped.setdefault( + key, + { + "task_name": base_key[0], + "request_type": base_key[1], + "model_name": base_key[2], + "session_id": str(item.get("session_id") or "") if include_session else "", + "calls": 0, + "cache_reported_calls": 0, + "prompt_tokens": 0, + "prompt_cache_hit_tokens": 0, + "prompt_cache_miss_tokens": 0, + "theoretical_prompt_cache_hit_tokens": 0, + "theoretical_prompt_cache_miss_tokens": 0, + "theoretical_compared_calls": 0, + "theoretical_cache_pool_hits": 0, + "common_prefix_rate_weighted_total": 0.0, + "suspected_context_sliding_calls": 0, + "sliding_dropped_weighted_total": 0.0, + "sliding_aligned_weighted_total": 0.0, + "top_dynamic_diff_paths": "", + }, + ) + calls = int(item.get("calls") or 0) + sliding_calls = int(item.get("suspected_context_sliding_calls") or 0) + target["calls"] = int(target["calls"]) + calls + target["cache_reported_calls"] = int(target["cache_reported_calls"]) + int(item.get("cache_reported_calls") or 0) + target["prompt_tokens"] = int(target["prompt_tokens"]) + int(item.get("prompt_tokens") or 0) + target["prompt_cache_hit_tokens"] = int(target["prompt_cache_hit_tokens"]) + int(item.get("prompt_cache_hit_tokens") or 0) + target["prompt_cache_miss_tokens"] = int(target["prompt_cache_miss_tokens"]) + int(item.get("prompt_cache_miss_tokens") or 0) + target["theoretical_prompt_cache_hit_tokens"] = int(target["theoretical_prompt_cache_hit_tokens"]) + int( + item.get("theoretical_prompt_cache_hit_tokens") or 0 + ) + target["theoretical_prompt_cache_miss_tokens"] = int(target["theoretical_prompt_cache_miss_tokens"]) + int( + item.get("theoretical_prompt_cache_miss_tokens") or 0 + ) + target["theoretical_compared_calls"] = int(target["theoretical_compared_calls"]) + int( + item.get("theoretical_compared_calls") or 0 + ) + target["theoretical_cache_pool_hits"] = int(target["theoretical_cache_pool_hits"]) + int( + item.get("theoretical_cache_pool_hits") or 0 + ) + target["common_prefix_rate_weighted_total"] = float(target["common_prefix_rate_weighted_total"]) + ( + float(item.get("avg_common_prefix_rate") or 0.0) * calls + ) + target["suspected_context_sliding_calls"] = int(target["suspected_context_sliding_calls"]) + sliding_calls + target["sliding_dropped_weighted_total"] = float(target["sliding_dropped_weighted_total"]) + ( + float(item.get("avg_sliding_dropped_messages") or 0.0) * sliding_calls + ) + target["sliding_aligned_weighted_total"] = float(target["sliding_aligned_weighted_total"]) + ( + float(item.get("avg_sliding_aligned_messages") or 0.0) * sliding_calls + ) + if include_session: + target["top_dynamic_diff_paths"] = item.get("top_dynamic_diff_paths", "") + + result: list[dict[str, int | str | float]] = [] + for item in grouped.values(): + calls = int(item["calls"]) + sliding_calls = int(item["suspected_context_sliding_calls"]) + hit_tokens = int(item["prompt_cache_hit_tokens"]) + miss_tokens = int(item["prompt_cache_miss_tokens"]) + theoretical_hit_tokens = int(item["theoretical_prompt_cache_hit_tokens"]) + theoretical_miss_tokens = int(item["theoretical_prompt_cache_miss_tokens"]) + item["prompt_cache_hit_rate"] = round(_calculate_rate(hit_tokens, miss_tokens), 2) + item["theoretical_prompt_cache_hit_rate"] = round( + _calculate_rate(theoretical_hit_tokens, theoretical_miss_tokens), + 2, + ) + item["prompt_cache_hit_rate_delta"] = round( + float(item["prompt_cache_hit_rate"]) - float(item["theoretical_prompt_cache_hit_rate"]), + 2, + ) + item["avg_common_prefix_rate"] = ( + round(float(item["common_prefix_rate_weighted_total"]) / calls, 2) if calls else 0.0 + ) + item["avg_sliding_dropped_messages"] = ( + round(float(item["sliding_dropped_weighted_total"]) / sliding_calls, 2) if sliding_calls else 0.0 + ) + item["avg_sliding_aligned_messages"] = ( + round(float(item["sliding_aligned_weighted_total"]) / sliding_calls, 2) if sliding_calls else 0.0 + ) + result.append(item) + return result + + +def _render_html_report(stats_snapshot: List[Dict[str, int | str | float]], *, include_session: bool = False) -> str: + updated_at = datetime.now().isoformat(timespec="seconds") + visible_stats_snapshot = _aggregate_stats_snapshot(stats_snapshot, include_session=include_session) + usage_events = _read_usage_events() + run_stats = _aggregate_usage_events_by_run(usage_events) + current_run_id = _store.run_id + previous_run_id = _get_previous_run_id(run_stats, current_run_id) + current_by_call_site = _aggregate_usage_events_by_call_site( + usage_events, + run_id=current_run_id, + include_session=include_session, + ) + previous_by_call_site = ( + _aggregate_usage_events_by_call_site( + usage_events, + run_id=previous_run_id, + include_session=include_session, + ) if previous_run_id else {} + ) + sorted_by_rate = sorted( + visible_stats_snapshot, + key=lambda item: ( + float(item["prompt_cache_hit_rate"]), + -int(item["prompt_cache_miss_tokens"]), + ), + ) + low_stats = sorted_by_rate[:SUMMARY_LIMIT] + high_stats = list(reversed(sorted_by_rate[-SUMMARY_LIMIT:])) + all_stats = sorted( + visible_stats_snapshot, + key=lambda item: ( + str(item["task_name"]), + str(item["request_type"]), + str(item["model_name"]), + ), + ) + total_calls = sum(int(item["calls"]) for item in visible_stats_snapshot) + total_prompt_tokens = sum(int(item["prompt_tokens"]) for item in visible_stats_snapshot) + total_hit_tokens = sum(int(item["prompt_cache_hit_tokens"]) for item in visible_stats_snapshot) + total_theoretical_hit_tokens = sum(int(item["theoretical_prompt_cache_hit_tokens"]) for item in visible_stats_snapshot) + total_miss_tokens = sum(int(item["prompt_cache_miss_tokens"]) for item in visible_stats_snapshot) + total_theoretical_miss_tokens = sum(int(item["theoretical_prompt_cache_miss_tokens"]) for item in visible_stats_snapshot) + total_cache_tokens = total_hit_tokens + total_miss_tokens + total_theoretical_cache_tokens = total_theoretical_hit_tokens + total_theoretical_miss_tokens + overall_hit_rate = total_hit_tokens / total_cache_tokens * 100 if total_cache_tokens > 0 else 0.0 + overall_theoretical_hit_rate = ( + total_theoretical_hit_tokens / total_theoretical_cache_tokens * 100 + if total_theoretical_cache_tokens > 0 + else 0.0 + ) + session_head = "Session" if include_session else "" + report_title = "LLM Prompt Cache Stats By Session" if include_session else "LLM Prompt Cache Stats" + peer_report_link = ( + f"Overview report" + if include_session + else f"Session detail report" + ) + table_head = ( + f"TaskRequestModel{session_head}API hitTheory hit" + "DeltaAPI hit tokAPI miss tokTheory hit tokTheory miss tok" + "Prompt tokCallsReportedComparedPool hits" + "Avg prefixSliding callsAvg dropped msgAvg aligned msg" + "Top dynamic diff paths" + ) + run_table_head = ( + "Run IDProcess startedFirst eventLast event" + "CallsPrompt tokAPI hitTheory hitAvg prefix" + "Sliding calls" + ) + run_compare_head = ( + f"TaskRequestModel{session_head}Current callsPrevious calls" + "Current APIPrevious APIAPI delta" + "Current TheoryPrevious TheoryTheory delta" + "Current PrefixPrevious PrefixPrefix delta" + "Current SlidingPrevious Sliding" + ) + run_significance_head = ( + f"Baseline runBaseline timeTaskRequestModel{session_head}" + "Current callsBaseline calls" + "API deltaAPI confidenceAPI significant" + "Prefix deltaPrefix confidencePrefix significant" + "Current slidingBaseline sliding" + ) + + return f""" + + + + {escape(report_title)} + + + +

{escape(report_title)}

+
Updated at: {escape(updated_at)}. Current run: {escape(current_run_id)}. Process started at: {escape(_store.process_started_at)}. Grouped by task_name / request_type / model_name{escape(' / session_id' if include_session else '')}. Local prompt pool size: {PROMPT_CACHE_POOL_SIZE}. {peer_report_link}
+
+
Calls
{_format_int(total_calls)}
+
Prompt tokens
{_format_int(total_prompt_tokens)}
+
API hit tokens
{_format_int(total_hit_tokens)}
+
API hit rate
{_format_rate(overall_hit_rate)}
+
Theory hit tokens
{_format_int(total_theoretical_hit_tokens)}
+
Theory hit rate
{_format_rate(overall_theoretical_hit_rate)}
+
+

Run Comparison

+ + {run_table_head} + {_render_run_rows(run_stats, current_run_id)} +
+

Current vs Previous Run By Call Site

+ + {run_compare_head} + {_render_run_comparison_rows(current_by_call_site=current_by_call_site, previous_by_call_site=previous_by_call_site, include_session=include_session)} +
+

Current vs Every Previous Run Significance

+ {_render_run_significance_controls(run_stats, current_run_id)} + + {run_significance_head} + {_build_run_significance_rows(usage_events=usage_events, run_stats=run_stats, current_run_id=current_run_id, include_session=include_session)} +
+

Low API Hit Rate

+ + {table_head} + {_render_stat_rows(low_stats, include_session=include_session)} +
+

High API Hit Rate

+ + {table_head} + {_render_stat_rows(high_stats, include_session=include_session)} +
+

All Call Sites

+ + {table_head} + {_render_stat_rows(all_stats, include_session=include_session)} +
+ {_render_run_significance_script()} + + +""" + + +def _write_html_report(stats_snapshot: List[Dict[str, int | str | float]]) -> None: + CACHE_STATS_DIR.mkdir(parents=True, exist_ok=True) + _get_report_path().write_text(_render_html_report(stats_snapshot, include_session=False), encoding="utf-8") + _get_session_report_path().write_text(_render_html_report(stats_snapshot, include_session=True), encoding="utf-8") + + +def _write_usage_event(event: Dict[str, int | str | float | bool]) -> None: + try: + _write_json_line(_get_usage_log_path(datetime.now()), event) + except Exception as exc: + logger.warning(f"写入 LLM prompt cache 明细失败: {exc}") + + +def _write_report(stats_snapshot: List[Dict[str, int | str | float]]) -> None: + try: + _write_html_report(stats_snapshot) + except Exception as exc: + logger.warning(f"写入 LLM prompt cache HTML 报告失败: {exc}") + + +def record_llm_cache_usage( + *, + task_name: str, + request_type: str, + model_name: str, + session_id: str = "", + prompt_tokens: int, + prompt_cache_hit_tokens: int, + prompt_cache_miss_tokens: int, + prompt_text: str | None = None, +) -> None: + """Record one LLM prompt cache usage event.""" + + normalized_task_name = str(task_name or "").strip() + if normalized_task_name not in FOCUSED_TASK_NAMES: + return + + normalized_request_type = _normalize_request_type(request_type) + if normalized_request_type in EXCLUDED_REQUEST_TYPES: + return + + normalized_model_name = _normalize_model_name(model_name) + normalized_session_id = _normalize_session_id(session_id) + normalized_prompt_tokens = max(int(prompt_tokens or 0), 0) + hit_tokens, miss_tokens, has_cache_report = _normalize_cache_tokens( + prompt_tokens=normalized_prompt_tokens, + prompt_cache_hit_tokens=prompt_cache_hit_tokens, + prompt_cache_miss_tokens=prompt_cache_miss_tokens, + ) + + with _store.lock: + key = (normalized_task_name, normalized_request_type, normalized_model_name, normalized_session_id) + prompt_pool = _store.prompt_pools.get(key, []) + cache_match = _calculate_theoretical_cache_match( + prompt_tokens=normalized_prompt_tokens, + prompt_text=prompt_text, + prompt_pool=prompt_pool, + ) + dynamic_diff = _diagnose_dynamic_diff(cache_match.best_prompt_text, prompt_text) + prompt_diagnostics = _diagnose_prompt_cache_details( + previous_prompt_text=cache_match.best_prompt_text, + current_prompt_text=prompt_text, + common_prefix_chars=cache_match.common_prefix_chars, + ) + if prompt_text: + next_prompt_pool = [*prompt_pool, prompt_text] + if len(next_prompt_pool) > PROMPT_CACHE_POOL_SIZE: + next_prompt_pool = next_prompt_pool[-PROMPT_CACHE_POOL_SIZE:] + _store.prompt_pools[key] = next_prompt_pool + + stat = _store.stats.get(key) + if stat is None: + stat = LLMCacheStat( + task_name=normalized_task_name, + request_type=normalized_request_type, + model_name=normalized_model_name, + session_id=normalized_session_id, + ) + _store.stats[key] = stat + + stat.calls += 1 + stat.prompt_tokens += normalized_prompt_tokens + stat.prompt_cache_hit_tokens += hit_tokens + stat.prompt_cache_miss_tokens += miss_tokens + stat.theoretical_prompt_cache_hit_tokens += cache_match.hit_tokens + stat.theoretical_prompt_cache_miss_tokens += cache_match.miss_tokens + stat.common_prefix_rate_total += prompt_diagnostics.common_prefix_rate + if prompt_diagnostics.suspected_context_sliding: + stat.suspected_context_sliding_calls += 1 + stat.sliding_dropped_messages_total += prompt_diagnostics.sliding_dropped_head_messages + stat.sliding_aligned_messages_total += prompt_diagnostics.sliding_aligned_messages + stat.dynamic_diff_counts[dynamic_diff.path] = stat.dynamic_diff_counts.get(dynamic_diff.path, 0) + 1 + if has_cache_report: + stat.cache_reported_calls += 1 + if cache_match.compared: + stat.theoretical_compared_calls += 1 + if cache_match.hit_tokens > 0: + stat.theoretical_cache_pool_hits += 1 + _store.total_calls += 1 + _store.calls_since_report += 1 + _store.calls_in_run += 1 + + api_hit_rate = hit_tokens / (hit_tokens + miss_tokens) * 100 if hit_tokens + miss_tokens > 0 else 0.0 + event = { + "created_at": datetime.now().isoformat(timespec="seconds"), + "run_id": _store.run_id, + "process_started_at": _store.process_started_at, + "call_index_in_run": _store.calls_in_run, + "task_name": normalized_task_name, + "request_type": normalized_request_type, + "model_name": normalized_model_name, + "session_id": normalized_session_id, + "prompt_tokens": normalized_prompt_tokens, + "prompt_chars": len(prompt_text or ""), + "prompt_cache_hit_tokens": hit_tokens, + "prompt_cache_miss_tokens": miss_tokens, + "prompt_cache_hit_rate": round(api_hit_rate, 2), + "theoretical_prompt_cache_hit_tokens": cache_match.hit_tokens, + "theoretical_prompt_cache_miss_tokens": cache_match.miss_tokens, + "theoretical_prompt_cache_hit_rate": round(cache_match.hit_rate, 2), + "theoretical_cache_pool_size": cache_match.pool_size, + "theoretical_best_match_rank": cache_match.best_match_rank, + "theoretical_common_prefix_chars": cache_match.common_prefix_chars, + "theoretical_common_prefix_rate": round(prompt_diagnostics.common_prefix_rate, 2), + "current_message_count": prompt_diagnostics.current_message_count, + "best_match_message_count": prompt_diagnostics.best_match_message_count, + "common_prefix_messages": prompt_diagnostics.common_prefix_messages, + "common_suffix_messages": prompt_diagnostics.common_suffix_messages, + "prompt_growth_chars": prompt_diagnostics.prompt_growth_chars, + "longest_aligned_message_overlap": prompt_diagnostics.longest_aligned_message_overlap, + "aligned_previous_start_index": prompt_diagnostics.aligned_previous_start_index, + "aligned_current_start_index": prompt_diagnostics.aligned_current_start_index, + "suspected_context_sliding": prompt_diagnostics.suspected_context_sliding, + "sliding_dropped_head_messages": prompt_diagnostics.sliding_dropped_head_messages, + "sliding_aligned_messages": prompt_diagnostics.sliding_aligned_messages, + "sliding_new_tail_messages": prompt_diagnostics.sliding_new_tail_messages, + "current_first_message_role": prompt_diagnostics.current_first_message_role, + "best_first_message_role": prompt_diagnostics.best_first_message_role, + "current_last_message_role": prompt_diagnostics.current_last_message_role, + "best_last_message_role": prompt_diagnostics.best_last_message_role, + "prompt_cache_hit_rate_delta": round(api_hit_rate - cache_match.hit_rate, 2), + "dynamic_diff_path": dynamic_diff.path, + "dynamic_diff_previous": dynamic_diff.previous_value, + "dynamic_diff_current": dynamic_diff.current_value, + "cache_reported": has_cache_report, + "theoretical_compared": cache_match.compared, + } + stats_snapshot = [stat.to_dict() for stat in _store.stats.values()] + + now = time.time() + should_update_report = ( + _store.last_report_at <= 0 + or _store.calls_since_report >= REPORT_INTERVAL_CALLS + or now - _store.last_report_at >= REPORT_INTERVAL_SECONDS + ) + if should_update_report: + _store.last_report_at = now + _store.calls_since_report = 0 + stats_snapshot_to_report = stats_snapshot + else: + stats_snapshot_to_report = [] + + _write_usage_event(event) + if stats_snapshot_to_report: + _write_report(stats_snapshot_to_report) + log_llm_cache_stats_summary(stats_snapshot_to_report) + + +def get_llm_cache_stats_snapshot() -> List[Dict[str, int | str | float]]: + """Return current in-process LLM prompt cache stats.""" + + with _store.lock: + return [stat.to_dict() for stat in _store.stats.values()] + + +def reset_llm_cache_stats() -> None: + """Reset in-process stats. Intended for tests and local debugging.""" + + with _store.lock: + _store.stats.clear() + _store.prompt_pools.clear() + _store.total_calls = 0 + _store.calls_in_run = 0 + _store.last_report_at = 0 + _store.calls_since_report = 0 + + +def log_llm_cache_stats_summary(stats_snapshot: List[Dict[str, int | str | float]] | None = None) -> None: + """Log current highest and lowest prompt cache hit-rate call sites.""" + + snapshot = stats_snapshot or get_llm_cache_stats_snapshot() + if not snapshot: + return + + sorted_stats = sorted( + snapshot, + key=lambda item: ( + float(item["prompt_cache_hit_rate"]), + -int(item["prompt_cache_miss_tokens"]), + ), + ) + low_stats = sorted_stats[:SUMMARY_LIMIT] + high_stats = list(reversed(sorted_stats[-SUMMARY_LIMIT:])) + + def _format_stat(item: Dict[str, int | str | float]) -> str: + return ( + f"{item['task_name']}/{item['request_type']}/{item['model_name']}: " + f"api_hit_rate={float(item['prompt_cache_hit_rate']):.2f}%, " + f"theory_hit_rate={float(item['theoretical_prompt_cache_hit_rate']):.2f}%, " + f"delta={float(item['prompt_cache_hit_rate_delta']):.2f}%, " + f"avg_prefix={float(item['avg_common_prefix_rate']):.2f}%, " + f"sliding_calls={item['suspected_context_sliding_calls']}, " + f"top_dynamic={item.get('top_dynamic_diff_paths', '')}, " + f"hit={item['prompt_cache_hit_tokens']}, " + f"miss={item['prompt_cache_miss_tokens']}, " + f"prompt={item['prompt_tokens']}, " + f"calls={item['calls']}, " + f"reported={item['cache_reported_calls']}" + ) + + logger.info( + "LLM prompt cache 统计摘要\n" + "低命中调用点:\n- " + "\n- ".join(_format_stat(item) for item in low_stats) + "\n" + "高命中调用点:\n- " + "\n- ".join(_format_stat(item) for item in high_stats) + ) diff --git a/src/services/llm_service.py b/src/services/llm_service.py index 264d2dd2..92da545f 100644 --- a/src/services/llm_service.py +++ b/src/services/llm_service.py @@ -6,6 +6,8 @@ from typing import Any, Dict, List, Tuple +import hashlib +import inspect import json from src.common.data_models.embedding_service_data_models import EmbeddingResult @@ -26,6 +28,7 @@ from src.llm_models.payload_content.message import Message, MessageBuilder, Role from src.llm_models.payload_content.tool_option import ToolCall from src.llm_models.utils_model import LLMOrchestrator from src.services.embedding_service import EmbeddingServiceClient +from src.services.llm_cache_stats import record_llm_cache_usage from src.services.service_task_resolver import ( get_available_models as _get_available_models, resolve_task_name as _resolve_task_name, @@ -46,7 +49,7 @@ class LLMServiceClient: - `embed_text`(兼容入口,推荐改用 `EmbeddingServiceClient`) """ - def __init__(self, task_name: str, request_type: str = "") -> None: + def __init__(self, task_name: str, request_type: str = "", session_id: str = "") -> None: """初始化 LLM 服务门面。 Args: @@ -55,6 +58,7 @@ class LLMServiceClient: """ self.task_name = _resolve_task_name(task_name) self.request_type = request_type + self.session_id = str(session_id or "").strip() self._orchestrator = LLMOrchestrator(task_name=self.task_name, request_type=request_type) @staticmethod @@ -85,6 +89,70 @@ class LLMServiceClient: return LLMImageOptions() return options + @staticmethod + def _serialize_message_for_cache_stats(message: Message) -> Dict[str, Any]: + parts: list[dict[str, Any]] = [] + for part in message.parts: + if hasattr(part, "text"): + parts.append({"type": "text", "text": part.text}) + continue + + image_base64 = getattr(part, "image_base64", "") + image_digest = hashlib.sha256(image_base64.encode("utf-8")).hexdigest() if image_base64 else "" + parts.append( + { + "type": "image", + "format": getattr(part, "image_format", ""), + "size": len(image_base64), + "sha256": image_digest, + } + ) + + return { + "role": str(message.role.value if hasattr(message.role, "value") else message.role), + "parts": parts, + "tool_call_id": message.tool_call_id, + "tool_name": message.tool_name, + "tool_calls": [ + { + "id": tool_call.call_id, + "name": tool_call.func_name, + "arguments": tool_call.args, + "extra_content": tool_call.extra_content, + } + for tool_call in (message.tool_calls or []) + ], + } + + @classmethod + def _build_cache_stats_prompt_text( + cls, + *, + messages: List[Message], + tool_options: Any, + response_format: Any, + ) -> str: + payload = { + "messages": [cls._serialize_message_for_cache_stats(message) for message in messages], + "tool_options": tool_options or [], + "response_format": response_format, + } + return json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str) + + def _record_cache_stats(self, result: LLMResponseResult, prompt_text: str | None = None) -> None: + """记录当前调用的 prompt cache 统计。""" + + record_llm_cache_usage( + task_name=self.task_name, + request_type=self.request_type, + model_name=result.model_name, + session_id=self.session_id, + prompt_tokens=result.prompt_tokens, + prompt_cache_hit_tokens=result.prompt_cache_hit_tokens, + prompt_cache_miss_tokens=result.prompt_cache_miss_tokens, + prompt_text=prompt_text, + ) + async def generate_response( self, prompt: str, @@ -100,7 +168,12 @@ class LLMServiceClient: LLMResponseResult: 统一文本生成结果。 """ active_options = self._normalize_generation_options(options) - return await self._orchestrator.generate_response_async( + prompt_text = self._build_cache_stats_prompt_text( + messages=[MessageBuilder().add_text_content(prompt).build()], + tool_options=active_options.tool_options, + response_format=active_options.response_format, + ) + result = await self._orchestrator.generate_response_async( prompt=prompt, temperature=active_options.temperature, max_tokens=active_options.max_tokens, @@ -109,6 +182,8 @@ class LLMServiceClient: raise_when_empty=active_options.raise_when_empty, interrupt_flag=active_options.interrupt_flag, ) + self._record_cache_stats(result, prompt_text=prompt_text) + return result async def generate_response_with_messages( self, @@ -125,8 +200,22 @@ class LLMServiceClient: LLMResponseResult: 统一文本生成结果。 """ active_options = self._normalize_generation_options(options) - return await self._orchestrator.generate_response_with_message_async( - message_factory=message_factory, + prompt_text_holder: dict[str, str] = {} + + def cache_stats_message_factory(client: BaseClient, model_info: Any = None) -> List[Message]: + if len(inspect.signature(message_factory).parameters) >= 2: + messages = message_factory(client, model_info) + else: + messages = message_factory(client) + prompt_text_holder["prompt_text"] = self._build_cache_stats_prompt_text( + messages=messages, + tool_options=active_options.tool_options, + response_format=active_options.response_format, + ) + return messages + + result = await self._orchestrator.generate_response_with_message_async( + message_factory=cache_stats_message_factory, temperature=active_options.temperature, max_tokens=active_options.max_tokens, tools=active_options.tool_options, @@ -134,6 +223,8 @@ class LLMServiceClient: raise_when_empty=active_options.raise_when_empty, interrupt_flag=active_options.interrupt_flag, ) + self._record_cache_stats(result, prompt_text=prompt_text_holder.get("prompt_text")) + return result async def generate_response_for_image( self, @@ -154,7 +245,30 @@ class LLMServiceClient: LLMResponseResult: 统一文本生成结果。 """ active_options = self._normalize_image_options(options) - return await self._orchestrator.generate_response_for_image( + image_digest = hashlib.sha256(image_base64.encode("utf-8")).hexdigest() if image_base64 else "" + prompt_text = json.dumps( + { + "messages": [ + { + "role": "user", + "parts": [ + {"type": "text", "text": prompt}, + { + "type": "image", + "format": image_format, + "size": len(image_base64), + "sha256": image_digest, + }, + ], + } + ], + "tool_options": [], + "response_format": None, + }, + ensure_ascii=False, + sort_keys=True, + ) + result = await self._orchestrator.generate_response_for_image( prompt=prompt, image_base64=image_base64, image_format=image_format, @@ -162,6 +276,8 @@ class LLMServiceClient: max_tokens=active_options.max_tokens, interrupt_flag=active_options.interrupt_flag, ) + self._record_cache_stats(result, prompt_text=prompt_text) + return result async def transcribe_audio(self, voice_base64: str) -> LLMAudioTranscriptionResult: """执行音频转写请求。 From 88b895a925b2676e482d1aeb058b18cdcac0848c Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Fri, 1 May 2026 13:00:54 +0800 Subject: [PATCH 3/7] perf: stabilize maisaka prompt cache --- src/maisaka/chat_loop_service.py | 79 ++++++++++++++++++--------- src/maisaka/history_post_processor.py | 78 ++++++++++---------------- src/maisaka/reasoning_engine.py | 9 +-- src/maisaka/runtime.py | 32 ++++++++++- 4 files changed, 117 insertions(+), 81 deletions(-) diff --git a/src/maisaka/chat_loop_service.py b/src/maisaka/chat_loop_service.py index 81d66129..63ec38e8 100644 --- a/src/maisaka/chat_loop_service.py +++ b/src/maisaka/chat_loop_service.py @@ -41,6 +41,11 @@ from .display.prompt_cli_renderer import PromptCLIVisualizer from .visual_mode_utils import resolve_enable_visual_planner TIMING_GATE_TOOL_NAMES = {"continue", "no_reply", "wait"} +REQUEST_TYPE_BY_REQUEST_KIND = { + "planner": "maisaka_planner", + "timing_gate": "maisaka_timing_gate", +} +CONTEXT_SELECTION_CACHE_STABILITY_RATIO = 2.0 @dataclass(slots=True) @@ -212,7 +217,7 @@ class MaisakaChatLoopService: self._chat_system_prompt = f"{self._personality_prompt}\n\nYou are a helpful AI assistant." else: self._chat_system_prompt = chat_system_prompt - self._llm_chat = LLMServiceClient(task_name="planner", request_type="maisaka_planner") + self._llm_chat_clients: dict[str, LLMServiceClient] = {} @property def personality_prompt(self) -> str: @@ -220,6 +225,30 @@ class MaisakaChatLoopService: return self._personality_prompt + @staticmethod + def _resolve_llm_request_type(request_kind: str) -> str: + """根据 Maisaka 请求类型解析 LLM 统计口径。""" + + normalized_request_kind = str(request_kind or "").strip() + return REQUEST_TYPE_BY_REQUEST_KIND.get( + normalized_request_kind, + f"maisaka_{normalized_request_kind}" if normalized_request_kind else "maisaka_planner", + ) + + def _get_llm_chat_client(self, request_kind: str) -> LLMServiceClient: + """获取当前请求类型对应的 planner LLM 客户端。""" + + request_type = self._resolve_llm_request_type(request_kind) + llm_client = self._llm_chat_clients.get(request_type) + if llm_client is None: + llm_client = LLMServiceClient( + task_name="planner", + request_type=request_type, + session_id=self._session_id, + ) + self._llm_chat_clients[request_type] = llm_client + return llm_client + @staticmethod def _get_runtime_manager() -> Any: """获取插件运行时管理器。 @@ -321,7 +350,13 @@ class MaisakaChatLoopService: @staticmethod def _build_time_block() -> str: - """构建当前时间提示块。""" + """构建静态时间提示块。""" + + return "当前时间会在每次请求末尾以用户消息形式提供。" + + @staticmethod + def _build_current_time_user_message() -> str: + """构建追加到请求末尾的当前时间消息。""" return f"当前时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" @@ -446,7 +481,11 @@ class MaisakaChatLoopService: messages.append(llm_message) normalized_injected_messages: List[Message] = [] - for injected_message in injected_user_messages or []: + final_user_messages = [ + *(injected_user_messages or []), + self._build_current_time_user_message(), + ] + for injected_message in final_user_messages: normalized_message = str(injected_message or "").strip() if not normalized_message: continue @@ -458,31 +497,10 @@ class MaisakaChatLoopService: ) if normalized_injected_messages: - insertion_index = self._resolve_injected_user_messages_insertion_index(messages) - messages[insertion_index:insertion_index] = normalized_injected_messages + messages.extend(normalized_injected_messages) return messages - @staticmethod - def _resolve_injected_user_messages_insertion_index(messages: Sequence[Message]) -> int: - """计算 injected meta user messages 在请求中的插入位置。 - - 规则与 deferred attachment 更接近: - - 从尾部向前寻找最近的 stopping point; - - stopping point 为 assistant 消息或 tool 结果消息; - - 找到后插入到其后面; - - 若不存在 stopping point,则退回到 system 消息之后。 - """ - - for index in range(len(messages) - 1, -1, -1): - message = messages[index] - if message.role in {RoleType.Assistant, RoleType.Tool}: - return index + 1 - - if messages and messages[0].role == RoleType.System: - return 1 - return 0 - async def chat_loop_step( self, chat_history: List[LLMContextMessage], @@ -575,7 +593,8 @@ class MaisakaChatLoopService: tool_definitions=list(all_tools), ) - generation_result = await self._llm_chat.generate_response_with_messages( + llm_chat = self._get_llm_chat_client(request_kind) + generation_result = await llm_chat.generate_response_with_messages( message_factory=message_factory, options=LLMGenerationOptions( tool_options=all_tools if all_tools else None, @@ -654,7 +673,11 @@ class MaisakaChatLoopService: chat_history, request_kind=request_kind, ) - effective_context_size = max(1, int(max_context_size or global_config.chat.max_context_size)) + base_context_size = max(1, int(max_context_size or global_config.chat.max_context_size)) + effective_context_size = max( + base_context_size, + int(base_context_size * CONTEXT_SELECTION_CACHE_STABILITY_RATIO), + ) selected_indices: List[int] = [] counted_message_count = 0 @@ -690,9 +713,11 @@ class MaisakaChatLoopService: selected_history, _ = normalize_tool_result_order(selected_history) tool_message_count = sum(1 for message in selected_history if isinstance(message, ToolResultMessage)) normal_message_count = len(selected_history) - tool_message_count + stability_text = f"|cache_window {base_context_size}->{effective_context_size}" selection_reason = ( f"实际发送 {len(selected_history)} 条消息" f"|消息 {normal_message_count} 条|tool {tool_message_count} 条" + f"{stability_text}" ) return ( selected_history, diff --git a/src/maisaka/history_post_processor.py b/src/maisaka/history_post_processor.py index 5b3a125d..aa038f08 100644 --- a/src/maisaka/history_post_processor.py +++ b/src/maisaka/history_post_processor.py @@ -3,11 +3,11 @@ from dataclasses import dataclass from math import ceil -from .context_messages import AssistantMessage, LLMContextMessage +from .context_messages import LLMContextMessage from .history_utils import drop_leading_orphan_tool_results, drop_orphan_tool_results, normalize_tool_result_order -EARLY_TRIM_RATIO = 0.3 -TRIM_THRESHOLD_RATIO = 1.2 +TRIM_TARGET_RATIO = 1.0 +TRIM_THRESHOLD_RATIO = 2.0 @dataclass(slots=True) @@ -36,21 +36,16 @@ def process_chat_history_after_cycle( compact_removed_count = 0 trim_threshold = ceil(max_context_size * TRIM_THRESHOLD_RATIO) if remaining_context_count > trim_threshold: - removed_early_message_count = _remove_early_history_messages(processed_history) - processed_history, removed_after_message_trim_count, moved_after_message_trim_count = ( - _normalize_history_structure(processed_history) + target_context_count = max(1, int(max_context_size * TRIM_TARGET_RATIO)) + removed_early_message_count = _trim_history_to_context_target( + processed_history, + target_context_count=target_context_count, ) - removed_assistant_thought_count = _remove_early_assistant_thoughts(processed_history) - processed_history, removed_after_thought_trim_count, moved_after_thought_trim_count = ( - _normalize_history_structure(processed_history) + processed_history, removed_after_trim_count, moved_after_trim_count = _normalize_history_structure( + processed_history ) - compact_removed_count = ( - removed_early_message_count - + removed_after_message_trim_count - + removed_assistant_thought_count - + removed_after_thought_trim_count - ) - moved_tool_result_count += moved_after_message_trim_count + moved_after_thought_trim_count + compact_removed_count = removed_early_message_count + removed_after_trim_count + moved_tool_result_count += moved_after_trim_count remaining_context_count = sum(1 for message in processed_history if message.count_in_context) removed_count = normalized_removed_count + compact_removed_count @@ -78,42 +73,27 @@ def _normalize_history_structure( ) -def _remove_early_history_messages(chat_history: list[LLMContextMessage]) -> int: - """移除最早 30% 的全部历史消息。""" +def _trim_history_to_context_target( + chat_history: list[LLMContextMessage], + *, + target_context_count: int, +) -> int: + """移除最早的一段历史,直到普通上下文消息数量降到目标值以内。""" + + remaining_context_count = sum(1 for message in chat_history if message.count_in_context) + if remaining_context_count <= target_context_count: + return 0 + + remove_count = 0 + for message in chat_history: + remove_count += 1 + if message.count_in_context: + remaining_context_count -= 1 + if remaining_context_count <= target_context_count: + break - remove_count = int(len(chat_history) * EARLY_TRIM_RATIO) if remove_count <= 0: return 0 del chat_history[:remove_count] return remove_count - - -def _remove_early_assistant_thoughts(chat_history: list[LLMContextMessage]) -> int: - """移除最早 30% 的非工具 assistant 思考内容。""" - - candidate_indexes = [ - index - for index, message in enumerate(chat_history) - if isinstance(message, AssistantMessage) - and not message.tool_calls - and message.source_kind != "perception" - and bool(message.content.strip()) - ] - remove_count = int(len(candidate_indexes) * EARLY_TRIM_RATIO) - if remove_count <= 0: - return 0 - - removed_indexes = set(candidate_indexes[:remove_count]) - filtered_history: list[LLMContextMessage] = [] - removed_total = 0 - for index, message in enumerate(chat_history): - if index in removed_indexes: - removed_total += 1 - continue - filtered_history.append(message) - - chat_history[:] = filtered_history - return removed_total - - diff --git a/src/maisaka/reasoning_engine.py b/src/maisaka/reasoning_engine.py index ab7484a2..037f6618 100644 --- a/src/maisaka/reasoning_engine.py +++ b/src/maisaka/reasoning_engine.py @@ -52,7 +52,7 @@ if TYPE_CHECKING: logger = get_logger("maisaka_reasoning_engine") -TIMING_GATE_CONTEXT_LIMIT = 24 +TIMING_GATE_CONTEXT_DROP_HEAD_RATIO = 0.7 TIMING_GATE_MAX_TOKENS = 384 TIMING_GATE_MAX_ATTEMPTS = 3 TIMING_GATE_TOOL_NAMES = {"continue", "no_reply", "wait"} @@ -124,7 +124,6 @@ class MaisakaReasoningEngine: async def _run_timing_gate_sub_agent( self, *, - context_message_limit: int, system_prompt: str, tool_definitions: list[dict[str, Any]], ) -> Any: @@ -134,7 +133,10 @@ class MaisakaReasoningEngine: """ return await self._runtime.run_sub_agent( - context_message_limit=context_message_limit, + context_message_limit=self._runtime._max_context_size, + drop_head_context_count=int( + self._runtime._max_context_size * TIMING_GATE_CONTEXT_DROP_HEAD_RATIO, + ), system_prompt=system_prompt, request_kind="timing_gate", interrupt_flag=None, @@ -255,7 +257,6 @@ class MaisakaReasoningEngine: invalid_tool_text = "" for attempt_index in range(TIMING_GATE_MAX_ATTEMPTS): response = await self._run_timing_gate_sub_agent( - context_message_limit=TIMING_GATE_CONTEXT_LIMIT, system_prompt=self._build_timing_gate_system_prompt(), tool_definitions=get_timing_tools(), ) diff --git a/src/maisaka/runtime.py b/src/maisaka/runtime.py index a3db92bf..a96eaf11 100644 --- a/src/maisaka/runtime.py +++ b/src/maisaka/runtime.py @@ -45,6 +45,7 @@ from .context_messages import ( from .display.display_utils import build_tool_call_summary_lines, format_token_count from .display.prompt_cli_renderer import PromptCLIVisualizer from .display.stage_status_board import remove_stage_status, update_stage_status +from .history_utils import drop_leading_orphan_tool_results from .reasoning_engine import MaisakaReasoningEngine from .reply_effect import ReplyEffectTracker from .reply_effect.image_utils import extract_visual_attachments_from_sequence @@ -583,6 +584,7 @@ class MaisakaHeartFlowChatting: self, *, context_message_limit: int, + drop_head_context_count: int = 0, system_prompt: str, request_kind: str = "sub_agent", extra_messages: Optional[Sequence[LLMContextMessage]] = None, @@ -598,7 +600,10 @@ class MaisakaHeartFlowChatting: request_kind=request_kind, max_context_size=context_message_limit, ) - sub_agent_history = list(selected_history) + sub_agent_history = self._drop_head_context_messages( + selected_history, + drop_head_context_count, + ) if extra_messages: sub_agent_history.extend(list(extra_messages)) @@ -616,6 +621,31 @@ class MaisakaHeartFlowChatting: tool_definitions=[] if tool_definitions is None else tool_definitions, ) + @staticmethod + def _drop_head_context_messages( + chat_history: Sequence[LLMContextMessage], + drop_context_count: int, + ) -> list[LLMContextMessage]: + """从已选上下文头部丢弃指定数量的普通上下文消息。""" + + if drop_context_count <= 0: + return list(chat_history) + + first_kept_index = 0 + dropped_context_count = 0 + while ( + first_kept_index < len(chat_history) + and dropped_context_count < drop_context_count + ): + message = chat_history[first_kept_index] + if message.count_in_context: + dropped_context_count += 1 + first_kept_index += 1 + + trimmed_history = list(chat_history[first_kept_index:]) + trimmed_history, _ = drop_leading_orphan_tool_results(trimmed_history) + return trimmed_history + async def _run_reply_effect_judge(self, prompt: str) -> str: """运行回复效果观察器使用的临时 LLM 评审。""" From 2238c34eca92da61d59a8496852334e8def1b2c6 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Fri, 1 May 2026 13:19:07 +0800 Subject: [PATCH 4/7] =?UTF-8?q?feat=EF=BC=9A=E7=BC=93=E5=AD=98=E8=B0=83?= =?UTF-8?q?=E8=AF=95=E4=BF=A1=E6=81=AF=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config/config.py | 2 +- src/config/official_configs.py | 9 +++++++++ src/services/llm_cache_stats.py | 13 +++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/config/config.py b/src/config/config.py index 252b4a05..dcd5e7eb 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -56,7 +56,7 @@ BOT_CONFIG_PATH: Path = (CONFIG_DIR / "bot_config.toml").resolve().absolute() MODEL_CONFIG_PATH: Path = (CONFIG_DIR / "model_config.toml").resolve().absolute() LEGACY_ENV_PATH: Path = (PROJECT_ROOT / ".env").resolve().absolute() MMC_VERSION: str = "1.0.0" -CONFIG_VERSION: str = "8.9.19" +CONFIG_VERSION: str = "8.9.20" MODEL_CONFIG_VERSION: str = "1.14.3" logger = get_logger("config") diff --git a/src/config/official_configs.py b/src/config/official_configs.py index 41f469af..ba11426a 100644 --- a/src/config/official_configs.py +++ b/src/config/official_configs.py @@ -1324,6 +1324,15 @@ class DebugConfig(ConfigBase): ) """是否记录 Replyer 请求体,默认关闭""" + enable_llm_cache_stats: bool = Field( + default=False, + json_schema_extra={ + "x-widget": "switch", + "x-icon": "chart-no-axes-column", + }, + ) + """是否记录 LLM prompt cache 调试统计,默认关闭""" + class ExtraPromptItem(ConfigBase): platform: str = Field( diff --git a/src/services/llm_cache_stats.py b/src/services/llm_cache_stats.py index e6b1c268..1d322ba4 100644 --- a/src/services/llm_cache_stats.py +++ b/src/services/llm_cache_stats.py @@ -182,6 +182,16 @@ class _LLMCacheStatsStore: _store = _LLMCacheStatsStore() +def _is_llm_cache_stats_enabled() -> bool: + """读取调试配置,默认关闭 LLM prompt cache 统计。""" + + try: + from src.config.config import global_config + return bool(global_config.debug.enable_llm_cache_stats) + except Exception: + return False + + def _normalize_request_type(request_type: str) -> str: normalized = str(request_type or "").strip() return normalized or "unknown" @@ -1313,6 +1323,9 @@ def record_llm_cache_usage( ) -> None: """Record one LLM prompt cache usage event.""" + if not _is_llm_cache_stats_enabled(): + return + normalized_task_name = str(task_name or "").strip() if normalized_task_name not in FOCUSED_TASK_NAMES: return From 7e5bc5a54338c2a4a5d58914f6abe6982e652c87 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Fri, 1 May 2026 14:52:19 +0800 Subject: [PATCH 5/7] =?UTF-8?q?pref=EF=BC=9A=E4=BF=AE=E6=94=B9changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelogs/changelog.md | 576 +++++++++++++++++--------- changelogs/mai_next_design.md | 732 ---------------------------------- changelogs/mai_next_todo.md | 191 --------- 3 files changed, 390 insertions(+), 1109 deletions(-) delete mode 100644 changelogs/mai_next_design.md delete mode 100644 changelogs/mai_next_todo.md diff --git a/changelogs/changelog.md b/changelogs/changelog.md index b8d00f43..504cd23e 100644 --- a/changelogs/changelog.md +++ b/changelogs/changelog.md @@ -1,121 +1,168 @@ -# Changelog +# [1.0.0] - 2026-5-2 + +## 核心功能更新 + +### MaiSaka / 回复系统 + +- 原生支持多模态模型、工具调用、多轮工具调用与 MCP。 +- 升级 replyer 回复器,统一群聊与私聊回复链路,并支持多模态回复。 +- 优化能力选择、工具调用和回复生成的稳定性。 +- 支持单独的上下文长度与回复频率控制。 + +### 记忆系统 / A_Memorix + +- 引入并主线化 A_Memorix 长期记忆系统,替代旧记忆链路。 +- 超级优化 A-Mem 记忆算法,显著提升长期记忆检索、写回、迁移和反馈修正能力。 +- 优化长期记忆控制台体验,完善记忆管理、知识库反馈和相关 WebUI 接口。 + +### 插件系统 / Runtime + +- 提供独立插件开发 SDK,并重构插件系统为 plugin_runtime。 +- 插件组件支持按群聊类型启用、按 session_id 启用,以及黑名单/白名单控制。 +- 支持插件对 LLM provider 进行兼容适配。 + +### WebUI / API + +- WebUI 新增或重构聊天组件、人格与表情配置表单、知识库相关界面。 +- API 响应模型与文档完成部分重构。 +- 日志系统新增上限和配置,避免日志持续膨胀。 -## [1.0.0-pre.1] - 2026-4-19 -### 核心功能更新 -### MaiSaka系统 -原生支持多模态模型 -原生支持工具调用,多轮调用和mcp -升级的replyer回复器,同样支持多模态 -统一群聊与私聊回复链路 -### 记忆系统革新 -引入 A_Memorix 长期记忆系统,替代旧记忆链路 -支持记忆检索、写回、迁移、反馈修正和管理界面 -### 全新插件系统 -提供独立的插件开发SDK -重构插件系统为 plugin_runtime,提供 RPC、Hook、能力注册、运行时隔离、配置校验、批量重载与旧能力迁移。 -### 全面重构和修复 -新增 platform_io 消息平台抽象与消息中间层,统一消息路由、出站追踪和旧驱动兼容。 -新增统一 services 服务层,集中管理 LLM、生成器、发送、数据库、记忆、Embedding 与 HTML 渲染等能力。 -引入 MCP 与统一工具系统,插件工具和 MCP 工具统一调度,并优化工具展示、索引、重试和失败留档。 -WebUI 后端完成模块化重构,新增统一 WebSocket、插件管理、记忆管理、知识库、配置和监控相关 API。 -配置系统升级,支持旧配置自动迁移、字段类型安全校验、多模态模型配置和更细的工具/回复参数。 -优化表情包、图片、表达方式和黑话学习系统,提升识别、缓存、发送、学习与调用稳定性。 -清理旧插件系统、旧记忆系统、旧回复链路、旧工具系统、旧 WebUI 构建产物和多个废弃内置插件。 -!!预发布版本WebUI暂时不可用 +## 完整更新清单 -完整更新清单 -核心架构 -大规模重构核心运行结构,新增 src/services 服务层,包括 LLM、生成器、发送、消息、数据库、记忆、HTML 渲染、Embedding 等服务。 -新增统一的 platform_io 消息平台抽象,提供驱动、路由、去重、出站追踪、插件驱动和旧版驱动兼容。 -引入新的消息中间层和网关设计,为插件、适配器、主程序之间的消息流转建立统一基础。 -重构数据模型,新增聊天目标、规划动作、回复生成结果、LLM 服务请求等模型。 -新增数据库迁移管理器,支持迁移进度记录、表级/记录级追踪和旧数据兼容。 -统一机器人识别逻辑,支持多平台场景,包括 WebUI。 +### 核心架构 -MaiSaka / 回复系统 -新增并持续完善 maisaka 主回复链路,逐步接管群聊与私聊回复逻辑。 -新增 planner / replyer / timing / subagent 等运行结构,支持 wait 打断、防抖、重试和状态监控。 -新增 Maisaka 实时聊天流监控、阶段状态面板、控制台工具调用展示、prompt log HTML 预览。 -回复器支持多模态与非多模态统一行为,新增模型 visual 参数,避免非多模态模型误传图片。 -支持复杂消息、转发消息、图片原始数据解析、URL 图片浏览、表情包类消息标记。 -优化上下文压缩,显示实时上下文占用,压缩早期 assistant 信息。 -新增聊天特定额外 prompt、多语言 prompt、prompt 独立文件管理、用户自定义 prompt 与覆盖能力。 -新增工具索引展开方式,压缩工具描述,提高工具调用成功率,修复无参工具、孤儿工具、Gemini tool 等问题。 -新增回复后打分追踪器,用于记录和分析回复效果。 -优化回复频率控制、引用回复概率、打字时间、重复思考、wait 行为和 replyer 空回复处理。 +- 大规模重构核心运行结构,新增 `src/services` 服务层,包括 LLM、生成器、发送、消息、数据库、记忆、HTML 渲染、Embedding 等服务。 +- 新增统一的 `platform_io` 消息平台抽象,提供驱动、路由、去重、出站追踪、插件驱动和旧版驱动兼容。 +- 引入新的消息中间层和网关设计,为插件、适配器、主程序之间的消息流转建立统一基础。 +- 重构数据模型,新增聊天目标、规划动作、回复生成结果、LLM 服务请求、API 响应等模型。 +- 新增数据库迁移管理器,支持迁移进度记录、表级/记录级追踪和旧数据兼容。 +- 统一机器人识别逻辑,支持多平台场景,包括 WebUI。 -记忆系统 / A_Memorix -新增并主线化 A_Memorix 长期记忆系统,包含运行时、检索、存储、管理界面和迁移脚本。 -新增记忆测试、检索工具、记忆服务、记忆自动化钩子与写回链路。 -支持将旧 LPMM/旧记忆数据迁移到新长期记忆系统。 -优化记忆检索速度、token 消耗、时间信息、上下文检索方式和人物事实提取。 -新增记忆反馈修正、知识库反馈详情、图存储持久化、总结导入、embedding 维度控制等回归测试。 -移除旧 memory_system 中的大量检索工具与聊天总结逻辑,改由新服务层和 A_Memorix 承担。 +### MaiSaka / 回复系统 -插件系统 / Runtime -大规模替换旧 plugin_system,新增 plugin_runtime。 -新增插件能力注册、组件注册、事件分发、Hook 分发、API 注册、Supervisor、Runner、RPC Server/Client。 -支持插件 manifest 校验、包式插件导入、临时 sys.path 管理、导入保护和模块访问控制。 -新增插件配置版本管理、配置归一化、运行时配置校验、批量插件重载。 -新增插件依赖流水线、HTML 渲染服务、插件 SDK 集成增强。 -新增旧数据库 peewee 兼容层,初步重构插件 database API。 -新增插件侧消息网关能力、出站追踪、会话 ID 计算和适配器回执消息 ID 更新。 -修复 Windows 平台插件运行时信号处理、DLL 导入隔离、包式导入、重载机制等问题。 -限制 maibot-plugin-sdk 版本范围,并升级到 2.3.0 相关适配。 +- 新增并持续完善 maisaka 主回复链路,逐步接管群聊与私聊回复逻辑。 +- 新增 planner / replyer / timing / subagent 等运行结构,支持 wait 打断、防抖、重试和状态监控。 +- 新增 Maisaka 实时聊天流监控、阶段状态面板、控制台工具调用展示、prompt log HTML 预览。 +- 精简表达选择逻辑,表达方式模型改为 replyer,并支持开启表达方式简化模式。 +- 回复器支持多模态与非多模态统一行为,新增模型 visual 参数,避免非多模态模型误传图片。 +- 支持复杂消息、转发消息、图片原始数据解析、URL 图片浏览、表情包类消息标记。 +- 优化上下文压缩,显示实时上下文占用,压缩早期 assistant 信息。 +- 私聊支持独立上下文长度和回复频率控制,群聊与私聊链路更统一。 +- 新增聊天特定额外 prompt、多语言 prompt、prompt 独立文件管理、用户自定义 prompt 与覆盖能力。 +- 新增工具索引展开方式和按顺序选择相关能力,压缩工具描述,提高工具调用成功率。 +- 修复无参工具、孤儿工具、Gemini tool、timing gate 非法工具调用等问题。 +- 新增内置 at 动作与原生 @ 能力,可按需关闭引用回复。 +- 新增回复后打分追踪器,用于记录和分析回复效果。 +- 优化回复频率控制、引用回复概率、打字时间、重复思考、wait 行为和 replyer 空回复处理。 -MCP / 工具系统 -新增独立 mcp_module,包含连接、管理、Provider、Host LLM Bridge、Hook 与数据模型。 -引入统一插件与 MCP 工具系统,移除旧工具系统和 tool_use 模型。 -工具支持索引检索、延迟展开、统一控制台展示、失败请求留档与重试分析。 -新增 host LLM bridge,使 MCP 工具和宿主模型调用链路更统一。 +### 记忆系统 / A_Memorix -WebUI / API -WebUI 后端整体重构,拆分为 app、依赖、中间件、routers、schemas、services、utils 等结构。 -新增统一 WebSocket 连接管理与路由。 -新增聊天、配置、表情包、表达方式、黑话、插件、记忆、知识库、统计、系统等路由重构。 -新增规划器和回复器监控 API、日志搜索、日志上线数量配置、prompt log 预览。 -新增本地已安装插件 README 读取 API、插件安装/配置/运行时管理相关 API。 -新增静态资源包提示和错误处理,后续修复为仅使用包内 WebUI 静态资源。 -修复 knowledgebase 反馈详情类型问题、WebUI memory 路由、配置 schema 测试等问题。 -注意:历史中有大量 dashboard 前端提交和 WebUI dist 迁移/删除,但本次没有修改 dashboard。 +- 新增并主线化 A_Memorix 长期记忆系统,包含运行时、检索、存储、管理界面和迁移脚本。 +- 超级优化 A-Mem 记忆算法,提升检索准确率、长期记忆写回质量和整体稳定性。 +- 新增记忆测试、检索工具、记忆服务、记忆自动化钩子与写回链路。 +- 支持将旧 LPMM/旧记忆数据迁移到新长期记忆系统。 +- 优化记忆检索速度、token 消耗、时间信息、上下文检索方式和人物事实提取。 +- 优化长期记忆控制台体验,提升记忆查看、调试与管理效率。 +- 新增记忆反馈修正、知识库反馈详情、图存储持久化、总结导入、embedding 维度控制等回归测试。 +- 移除旧 `memory_system` 中的大量检索工具与聊天总结逻辑,改由新服务层和 A_Memorix 承担。 -配置 / 模型 / 依赖 -配置系统引入 ConfigBase 测试与更严格校验,支持自动检测并升级旧版配置。 -支持 Union / Optional 字段转换,并禁止不安全的多类型 Union。 -新增配置版本到 8.4.0,加入工具筛选、回复器、多模态、Maim Message、日志颜色等配置。 -移除 Planner 问题配置项、无用配置、旧路径显示配置、模板配置文件等冗余项。 -模型配置移除无用模型、utils_small、弃用的 LLM_judge 类型和 tool_use 模型。 -新增模型随机选择策略、模型 visual 参数、OpenAI 兼容性增强。 -修复 Qwen 3.5 空回复、Gemini 请求思考签名、部分模型不支持 gif、OpenAI client 工具请求等问题。 -移除 uv.lock,更新 pyproject.toml / requirements.txt 依赖,最终 HEAD 又移除部分依赖。 +### 插件系统 / Runtime -表情包 / 图片 -新增表情包系统重构,包含注册、识别、缓存、发送、选择、数据库迁移。 -表情包选择改为一次性选择全部,支持配置,并接入 subagent。 -移除旧内置 emoji 插件,改为 Maisaka 内置动作或新系统能力。 -修复表情包发送无记录、识别失败、缓存问题、图片存储问题、图片过大自动重试等。 -新增异步后台图片/表情处理、图片展示模式优化、复杂消息查看。 +- 大规模替换旧 `plugin_system`,新增 `plugin_runtime`。 +- 新增插件能力注册、组件注册、事件分发、Hook 分发、API 注册、Supervisor、Runner、RPC Server/Client。 +- 插件组件支持按群聊类型启用、按 session_id 启用,以及黑名单/白名单控制。 +- 插件支持通过 msg_id 获取消息,提升插件对上下文消息的追溯能力。 +- 支持插件 manifest 校验、包式插件导入、临时 `sys.path` 管理、导入保护和模块访问控制。 +- 新增插件配置版本管理、配置归一化、运行时配置校验、批量插件重载。 +- 新增插件依赖流水线、HTML 渲染服务、插件 SDK 集成增强。 +- 新增旧数据库 peewee 兼容层,初步重构插件 database API。 +- 新增插件侧消息网关能力、出站追踪、会话 ID 计算和适配器回执消息 ID 更新。 +- 支持插件对 LLM provider 进行兼容适配。 +- 默认禁用示例插件。 +- 修复 Windows 平台插件运行时信号处理、DLL 导入隔离、包式导入、重载机制等问题。 +- 限制 maibot-plugin-sdk 版本范围,并升级到 2.3.0 相关适配。 -表达方式 / 黑话 / 学习 -新增自动表达优化、表达方式检查脚本、表达方式最后修改来源字段。 -修复私聊表达风格随机、表达方式学习与使用、表达方式全局共享。 -新增 planner 黑话缓存,恢复表达学习、黑话学习、黑话使用和表达使用。 -修复黑话提取学习缓存和 Jargon 提取问题。 -新增表达方式快速版本,优化表达方式提取与 LLM 判断标记。 +### MCP / 工具系统 -文档 / 国际化 / 工程规范 -更新 README、徽章、快速导航、版本信息和主仓库地址。 -新增/更新 changelog、设计文档、todo、记忆契约文档、Caddy 反向代理与 TLS/SSL 文档。 -新增 AGENTS.md,并更新代码规范、导入顺序、注释规范、语言规范。 -新增 Crowdin 配置和多语言资源,包含中英日韩等 locale。 -新增 CodeRabbit 配置、PR 模板、测试计划和若干调试/迁移脚本。 -新增 agentlite 子项目/模块,包含 agent、tool、provider、skills、MCP、文件/网页/shell 工具和大量测试、示例、文档。 -测试与质量 +- 新增独立 `mcp_module`,包含连接、管理、Provider、Host LLM Bridge、Hook 与数据模型。 +- 引入统一插件与 MCP 工具系统,移除旧工具系统和 tool_use 模型。 +- 工具支持索引检索、延迟展开、统一控制台展示、失败请求留档与重试分析。 +- 新增 host LLM bridge,使 MCP 工具和宿主模型调用链路更统一。 +- 修复 timing gate 场景下意外启用 tool 的问题。 + +### 模型 / 缓存 / 配置 + +- 配置系统引入 ConfigBase 测试与更严格校验,支持自动检测并升级旧版配置。 +- 支持 Union / Optional 字段转换,并禁止不安全的多类型 Union。 +- 更新 config 初始化流程,新增配置版本并加入工具筛选、回复器、多模态、Maim Message、日志颜色等配置。 +- 支持模型请求缓存相关配置,大幅优化缓存命中率,并进一步优化模型缓存命中率。 +- 修改部分模型调用链路,修复部分模型请求问题和 deepseek v4 相关问题。 +- 修复 OpenRouter 和 Groq 的 reasoning 字段支持问题。 +- 修复门控在部分模型出错的问题。 +- 模型配置移除无用模型、utils_small、弃用的 LLM_judge 类型和 tool_use 模型。 +- 新增模型随机选择策略、模型 visual 参数、OpenAI 兼容性增强。 +- visual_style 配置项改为模板内容,人设结构完成调整。 +- 表达方式模型改为 replyer,并可开启简化模式。 +- 移除 Planner 问题配置项、无用配置、旧路径显示配置、模板配置文件和部分内部温度设定。 +- 修复 Qwen 3.5 空回复、Gemini 请求思考签名、部分模型不支持 gif、OpenAI client 工具请求等问题。 +- 移除 uv.lock,更新 pyproject.toml / requirements.txt 依赖,最终 HEAD 又移除部分依赖。 + +### WebUI / API + +- WebUI 后端整体重构,拆分为 app、依赖、中间件、routers、schemas、services、utils 等结构。 +- 新增统一 WebSocket 连接管理与路由。 +- 新增或重构聊天组件、人格与表情配置表单、知识库相关界面。 +- 新增聊天、配置、表情包、表达方式、黑话、插件、记忆、知识库、统计、系统等路由重构。 +- 新增规划器和回复器监控 API、日志搜索、日志上限数量配置、prompt log 预览。 +- API 响应模型与文档完成部分重构。 +- 新增本地已安装插件 README 读取 API、插件安装/配置/运行时管理相关 API。 +- 新增静态资源包提示和错误处理,后续修复为仅使用包内 WebUI 静态资源。 +- 修复 knowledgebase 反馈详情类型问题、WebUI memory 路由、配置 schema 测试、回复格式和部分显示问题。 +- 注意:历史中有大量 dashboard 前端提交和 WebUI dist 迁移/删除,但本次没有修改 dashboard。 + +### 表情包 / 图片 + +- 新增表情包系统重构,包含注册、识别、缓存、发送、选择、数据库迁移。 +- 表情包选择改为一次性选择全部,支持配置,并接入 subagent。 +- 移除旧内置 emoji 插件,改为 Maisaka 内置动作或新系统能力。 +- 修复表情包发送无记录、识别失败、缓存问题、图片存储问题、图片过大自动重试等。 +- 新增异步后台图片/表情处理、图片展示模式优化、复杂消息查看。 + +### 表达方式 / 黑话 / 学习 + +- 新增自动表达优化、表达方式检查脚本、表达方式最后修改来源字段。 +- 表达方式模型改为 replyer,表达选择逻辑进一步精简,优化 replyer 表现。 +- 表达方式支持开启简化模式。 +- 修复私聊表达风格随机、表达方式学习与使用、表达方式全局共享。 +- 新增 planner 黑话缓存,恢复表达学习、黑话学习、黑话使用和表达使用。 +- 修复黑话提取学习缓存和 Jargon 提取问题。 +- 新增表达方式快速版本,优化表达方式提取与 LLM 判断标记。 + +### 日志 / 部署 / 工程 + +- 日志系统新增上限和配置,避免日志膨胀。 +- 溢出部分冗余 log,减少运行时噪音。 +- 修复 Docker 非交互模式无法启动的问题。 +- 优化部分导入,加快启动速度。 +- 更新 README、徽章、快速导航、版本信息和主仓库地址。 +- 新增/更新 changelog、设计文档、todo、记忆契约文档、Caddy 反向代理与 TLS/SSL 文档。 +- 新增 AGENTS.md,并更新代码规范、导入顺序、注释规范、语言规范。 +- 新增 Crowdin 配置和多语言资源,包含中英日韩等 locale。 +- 新增 CodeRabbit 配置、PR 模板、测试计划和若干调试/迁移脚本。 +- 新增 agentlite 子项目/模块,包含 agent、tool、provider、skills、MCP、文件/网页/shell 工具和大量测试、示例、文档。 +- 添加 astrbot 友链之粉毛必定女装。 -## [0.12.2] - 2026-1-11 -### 功能更改 + + + + + +# [0.12.2] - 2026-1-11 + +## 功能更改 + - 优化私聊wait逻辑 - 超时时强制引用回复 - 修复部分适配器断联问题 @@ -123,21 +170,26 @@ WebUI 后端整体重构,拆分为 app、依赖、中间件、routers、schema - 优化记忆检索逻辑 - 更新readme -## [0.12.1] - 2025-12-31 -### 🌟 主要更新 +# [0.12.1] - 2025-12-31 + +## 🌟 主要更新 + - 添加年度总结!可以在webui查看 - 可选让llm判定引用回复 - 表达方式优化!现在可以进行自动和手动评估,使其更精准 - 回复和规划记录!webui可以查看每一条回复和plan的详情 -### 细节功能更改 +## 细节功能更改 + - 优化间隔过长消息的显示 - enable_jargon_detection - global_memory_blacklist。指定部分群聊不参与全局记忆 - 移除utils_small模型,移除弃用的lpmm模型 -## [0.12.0] - 2025-12-21 -### 🌟 重大更新 +# [0.12.0] - 2025-12-21 + +## 🌟 重大更新 + - 添加思考力度机制,动态控制回复时间和长度 - planner和replyer现在开启联动,更好的回复逻辑 - 新的私聊系统,吸收了pfc的优秀机制 @@ -145,13 +197,15 @@ WebUI 后端整体重构,拆分为 app、依赖、中间件、routers、schema - mcp插件作为内置插件加入,默认不启用 - 添加全局记忆配置项,现在可以选择让记忆为全局的 -### 🌟 WebUI 重大更新 +## 🌟 WebUI 重大更新 + - **模型预设市场功能正式完善并发布**:现在可以将模型配置完整分享,分享按钮位于模型配置界面右上角 - **全面安全加固**:为所有 WebUI API 和 WebSocket 端点添加身份认证保护,Cookie 添加 Secure 和 SameSite 属性,支持环境感知动态配置 - **前端认证重构**:从 localStorage 迁移到 HttpOnly Cookie,新增 WebSocket 临时 token 认证机制,解决跨域开发环境下 Cookie 无法携带的问题 - **增强插件配置管理**:支持原始 TOML 配置的加载和保存,前端支持查看和编辑插件配置文件源文件 -### 细节功能更改 +## 细节功能更改 + - 移除频率自动调整 - 移除情绪功能 - 优化记忆差许多呢超时设置 @@ -172,8 +226,10 @@ WebUI 后端整体重构,拆分为 app、依赖、中间件、routers、schema - 修复并优化了聊天室、模型配置、日志查看器、黑话管理、WebUI 端口占用、配置向导、首页图表、聊天室消息重复、移动端日志不可见、模型提供商删除、主程序配置换行符、HTTP 警告横幅、重启界面、LPMM 配置、人物信息、插件端点安全认证、WebSocket token 等问题,提升整体稳定性与体验。 - 完成主程序配置与模型配置界面重构、模型提供商与麦麦适配器配置重构(引入 TOML 校验)、WebSocket 认证逻辑抽取为共享模块统一 WS 端点,升级 React 到 19.2.1 并更新依赖,WebUI 配置与可视化全部迁移到主配置及模型配置中,优化配置更新提示、插件详情页面和路径安全校验,并增强模型与梦境等多项配置的可视化和自动检验。 -## [0.11.6] - 2025-12-2 +# [0.11.6] - 2025-12-2 + ### 🌟 重大更新 + - 大幅提高记忆检索能力,略微提高token消耗 - 重构历史消息概括器,更好的主题记忆 - 日志查看器性能革命性优化 @@ -186,6 +242,7 @@ WebUI 后端整体重构,拆分为 app、依赖、中间件、routers、schema - WebUI新增黑话管理 & 编辑界面 ### 细节功能更改 + - 可选记忆识别中是否启用jargon - 解耦表情包识别和图片识别 - 修复部分破损json的解析问题 @@ -194,6 +251,7 @@ WebUI 后端整体重构,拆分为 app、依赖、中间件、routers、schema - 新增Lpmm可视化 ### webui细节更新 + - 修复侧边栏收起、UI及表格横向滚动等问题,优化Toast动画 - 修复适配器配置、插件克隆、表情包注册等相关BUG - 新增适配器/模型预设模式及模板,自动填写URL和类型 @@ -219,14 +277,17 @@ WebUI 后端整体重构,拆分为 app、依赖、中间件、routers、schema - 新增表达反思设置和WebUI聊天室“思考中”占位组件 - 细节如移除部分字段或UI控件、优化按钮/弹窗/编辑逻辑等 -## [0.11.5] - 2025-11-21 +# [0.11.5] - 2025-11-21 + ### 🌟 重大更新 + - WebUI 现支持手动重启麦麦,曲线救国版“热重载” - 新增麦麦 QQ 适配器可视化编辑 UI(独立进程,需手动上传/下载并覆盖适配器文件) - 麦麦主程序配置支持可视化模式与源代码模式双模式编辑,后端执行 TOML 校验 - 优化 planner 与 replyer 协同机制,调试日志更细 ### 新增 + - 表情包管理、人物信息管理、表达方式管理界面手机端适配 - 配置页“重启麦麦”提示 - 详细的debug prompt显示配置 @@ -234,6 +295,7 @@ WebUI 后端整体重构,拆分为 app、依赖、中间件、routers、schema - 前端集成 CodeMirror(Python/JSON/TOML 语法高亮)并对 JSON 配置提供自动纠错提示 ### 修复 + - 表情包缩略图过小 - 添加模型后无法立即显示 - 插件市场分类错误 @@ -250,11 +312,13 @@ WebUI 后端整体重构,拆分为 app、依赖、中间件、routers、schema - 移除前端 TOML 解析库导致的兼容问题 ### 优化 + - 首页加载用户体验 - 首页加载速度(9s → <1s) - 整个界面的初屏加载速度 ### 更新 + - 适配器配置支持上传模式与指定路径模式;指定路径模式可免去反复上传/下载配置文件 - 适配器配置界面标签页响应式优化,小屏仅显示简短标签 - 麦麦资源管理所有界面的批量删除能力 @@ -262,8 +326,10 @@ WebUI 后端整体重构,拆分为 app、依赖、中间件、routers、schema - 插件市场新增点赞、点踩、评分与下载量统计(基于 Cloudflare,保证国内可访问) - 麦麦表情包查看界面的描述部分支持 Markdown 渲染 -## [0.11.4] - 2025-11-19 +# [0.11.4] - 2025-11-19 + ### 🌟 主要更新内容 + - **首个官方 Web 管理界面上线**:在此版本之前,MaiBot 没有 WebUI,所有配置需手动编辑 TOML 文件 - **认证系统**:Token 安全登录(支持系统生成 64 位随机令牌 / 自定义 Token),首次配置向导 - **配置管理(可视化编辑,无需手动改 TOML)**: @@ -290,6 +356,7 @@ WebUI 后端整体重构,拆分为 app、依赖、中间件、routers、schema - **全局搜索**:Cmd/Ctrl + K 快捷键,快速跳转任意页面 ### 细节 + - **技术栈**: - 前端: React 19 + TypeScript + Vite + TanStack Router + shadcn/ui - 后端: FastAPI + Uvicorn + WebSocket @@ -298,12 +365,14 @@ WebUI 后端整体重构,拆分为 app、依赖、中间件、routers、schema - `WEBUI_ENABLED=true` - `WEBUI_MODE=production` - **WebUI 开源协议**:GPLv3 -- **WebUI 地址**:https://github.com/Mai-with-u/MaiBot-Dashboard +- **WebUI 地址**:[https://github.com/Mai-with-u/MaiBot-Dashboard](https://github.com/Mai-with-u/MaiBot-Dashboard) 告别手动编辑配置文件,享受现代化图形界面! -## [0.11.3] - 2025-11-18 +# [0.11.3] - 2025-11-18 + ### 功能更改和修复 + - 优化记忆提取策略 - 优化黑话提取 - 优化表达方式学习 @@ -313,20 +382,24 @@ WebUI 后端整体重构,拆分为 app、依赖、中间件、routers、schema 提示:清理旧的记忆数据和表达方式,表现更好 方法:删除数据库中 expression jargon 和 thinking_back 的全部内容 -## [0.11.2] - 2025-11-16 +# [0.11.2] - 2025-11-16 + ### 🌟 主要功能更改 + - "海马体Agent"记忆系统上线,最新最好的记忆系统,默认已接入lpmm - 添加黑话jargon学习系统 - 添加群特殊Prompt系统 - 优化直接提及时的回复速度 ### 细节功能更改 + - 添加 WebUI 模块及相关 API 路由和 Token 管理功能 - 可通过海马体Agent记录和查询群昵称 - 添加聊天记录总结模块 - 添加大量新统计指标 ### 功能更改和修复 + - 移除表达方式学习上限限制 - 移除部分未使用代码 - 移除问题追踪和旧版记忆 @@ -353,16 +426,19 @@ WebUI 后端整体重构,拆分为 app、依赖、中间件、routers、schema - 修复(bot): 恢复戳一戳正常响应 - 提供更多细节debug配置 -## [0.11.1] - 2025-11-4 +# [0.11.1] - 2025-11-4 + ### 功能更改和修复 + - 记忆现在能够被遗忘,并且拥有更好的合并 - 修复部分llm请求问题 - 优化记忆提取 - 提供replyer的细节debug配置 +# [0.11.0] - 2025-10-27 -## [0.11.0] - 2025-10-27 ### 🌟 主要功能更改 + - 重构记忆系统,新的记忆系统更可靠,双通道查询,可以查询文本记忆和过去聊天记录 - 主动发言功能,麦麦会自主提出问题(可精细调控频率) - 支持多重人格设定,可以随机切换成不同状态 @@ -372,8 +448,8 @@ WebUI 后端整体重构,拆分为 app、依赖、中间件、routers、schema - 添加deepthink插件(默认关闭),让麦麦可以深度思考一些问题 - 现已内置BetterFrequency插件 - ### 细节功能更改 + - 修复配置文件转义问题 - 情绪系统现在可以由配置文件控制开关 - 修复平行动作控制失效的问题 @@ -389,8 +465,10 @@ WebUI 后端整体重构,拆分为 app、依赖、中间件、routers、schema - 修改默认推荐模型为ds v3.2 - 优化了对gemini和不同模型的支持,优化了对gemini搜索的支持 -## [0.10.3] - 2025-9-22 +# [0.10.3] - 2025-9-22 + ### 🌟 主要功能更改 + - planner支持多动作,移除Sub_planner - 移除激活度系统,现在回复完全由planner控制 - 现可自定义planner行为,更优化的聊天频率控制 @@ -399,6 +477,7 @@ WebUI 后端整体重构,拆分为 app、依赖、中间件、routers、schema - 更好的event系统,正式建立 ### 细节功能更改 + - 支持所有表达方式互通 - 现可使用付费嵌入模型 - 添加多种发送类型 @@ -407,27 +486,30 @@ WebUI 后端整体重构,拆分为 app、依赖、中间件、routers、schema - 加入brainchat模式,为私聊支持做准备 - 修复qq号格式 +# [0.10.2] - 2025-8-31 - -## [0.10.2] - 2025-8-31 ### 🌟 主要功能更改 + - 精简了人格相关配置,提供更清晰,有效的自定义 - 大幅优化了聊天逻辑,更易配置,动态控制 - 现在支持提及100%回复 ### 细节功能更改 + - 更好的event系统 - 记忆系统优化 - 为空回复添加重试机制 - 修复tts插件可能的复读问题 +# [0.10.1] - 2025-8-24 -## [0.10.1] - 2025-8-24 ### 🌟 主要功能更改 + - planner现在改为大小核结构,移除激活阶段,提高回复速度和动作调用精准度 - 优化关系的表现的效率 ### 细节功能更改 + - 优化识图的表现 - 为planner添加单独控制的提示词 - 修复激活值计算异常的BUG @@ -438,9 +520,10 @@ WebUI 后端整体重构,拆分为 app、依赖、中间件、routers、schema - 重构内部代码 - 暂时禁用记忆 +# [0.10.0] - 2025-8-18 -## [0.10.0] - 2025-8-18 ### 🌟 主要功能更改 + - 优化的回复生成,现在的回复对上下文把控更加精准 - 新的回复逻辑控制,现在合并了normal和focus模式,更加统一 - 优化表达方式系统,现在学习和使用更加精准 @@ -451,37 +534,44 @@ WebUI 后端整体重构,拆分为 app、依赖、中间件、routers、schema - **警告所有插件开发者:插件系统即将迎来不稳定时期,随时会发动更改。** #### 🔧 工具系统重构 + - **工具系统整合**: 工具系统现在完全合并到插件系统中,提供统一的扩展能力 - **工具启用控制**: 支持配置是否启用特定工具,提供更人性化的直接调用方式 - **配置文件读取**: 工具现在支持读取配置文件,增强配置灵活性 #### 🚀 LLM系统全面重构 + - **LLM Request重构**: 彻底重构了整个LLM Request系统,现在支持模型轮询和更多灵活的参数 - **模型配置升级**: 同时重构了整个模型配置系统,升级需要重新配置llm配置文件 - **任务类型支持**: 新增任务类型和能力字段至模型配置,增强模型初始化逻辑 - **异常处理增强**: 增强LLMRequest类的异常处理,添加统一的模型异常处理方法 #### 🔌 插件系统稳定化 + - **插件系统重构完成**: 随着LLM Request的重构,插件系统彻底重构完成,进入稳定状态 - **API扩展**: 仅增加新的API,保持向后兼容性 - **插件管理优化**: 让插件管理配置真正有用,提升管理体验 #### 💾 记忆系统优化 + - **及时构建**: 记忆系统再优化,现在及时构建,并且不会重复构建 - **精确提取**: 记忆提取更精确,提升记忆质量 #### 🎭 表达方式系统 + - **表达方式记录**: 记录使用的表达方式,提供更好的学习追踪 - **学习优化**: 优化表达方式提取,修复表达学习出错问题 - **配置优化**: 优化表达方式配置和逻辑,提升系统稳定性 #### 🔄 聊天系统统一 + - **normal和focus合并**: 彻底合并normal和focus,完全基于planner决定target message - **no_reply内置**: 将no_reply功能移动到主循环中,简化系统架构 - **回复优化**: 优化reply,填补缺失值,让麦麦可以回复自己的消息 - **频率控制API**: 加入聊天频率控制相关API,提供更精细的控制 #### 日志系统改进 + - **日志颜色优化**: 修改了log的颜色,更加护眼 - **日志清理优化**: 修复了日志清理先等24h的问题,提升系统性能 - **计时定位**: 通过计时定位LLM异常延时,提升问题排查效率 @@ -489,27 +579,30 @@ WebUI 后端整体重构,拆分为 app、依赖、中间件、routers、schema ### 🐛 问题修复 #### 代码质量提升 + - **lint问题修复**: 修复了lint爆炸的问题,代码更加规范了 - **导入优化**: 修复导入爆炸和文档错误,优化代码结构 #### 系统稳定性 + - **循环导入**: 修复了import时循环导入的问题 - **并行动作**: 修复并行动作炸裂问题,提升并发处理能力 - **空响应处理**: 空响应就raise,避免系统异常 #### 功能修复 + - **API问题**: 修复api问题,提升系统可用性 - **notice问题**: 为组件方法提供新参数,暂时解决notice问题 - **关系构建**: 修复不认识的用户构建关系问题 - **流式解析**: 修复流式解析越界问题,避免空choices的SSE帧错误 #### 配置和兼容性 + - **默认值**: 添加默认值,提升配置灵活性 - **类型问题**: 修复类型问题,提升代码健壮性 - **配置加载**: 优化配置加载逻辑,提升系统启动稳定性 - -## [0.9.1] - 2025-7-26 +# [0.9.1] - 2025-7-26 ### 主要修复和优化 @@ -530,20 +623,22 @@ WebUI 后端整体重构,拆分为 app、依赖、中间件、routers、schema - 修复部分模型由于enable_thinking导致的400问题 - 移除dependency_manager - -## [0.9.0] - 2025-7-24 +# [0.9.0] - 2025-7-24 ### 摘要 + MaiBot 0.9.0 重磅升级!本版本带来两大核心突破:**全面重构的插件系统**提供更强大的扩展能力和管理功能;**normal和focus模式统一化处理**大幅简化架构并提升性能。同时新增s4u prompt模式优化、语音消息支持、全新情绪系统和mais4u直播互动功能,为MaiBot带来更自然、更智能的交互体验! ### 🌟 主要功能概览 #### 🔌 插件系统全面重构 - 重点升级 + - **完整管理API**: 全新的插件管理API,支持插件的启用、禁用、重载和卸载操作 - **权限控制系统**: 为插件管理增加完善的权限控制,确保系统安全性 - **智能依赖管理**: 优化插件依赖管理和自动注册机制,减少配置复杂度 #### ⚡ Normal和Focus模式统一化处理 - 重点升级 + - **架构统一**: 彻底统一normal和focus聊天模式,消除模式间的差异和复杂性 - **智能模式切换**: 优化频率控制和模式切换逻辑,normal可以无缝切换到focus - **统一LLM激活**: normal模式现在支持LLM激活插件,与focus模式功能对等 @@ -551,38 +646,45 @@ MaiBot 0.9.0 重磅升级!本版本带来两大核心突破:**全面重构 - **统一退出机制**: 为focus提供更合理的退出方法,简化状态管理 #### 🎯 s4u prompt模式 + - **s4u prompt模式**: 新增专门的s4u prompt构建方式,提供更好的交互效果 - **配置化启用**: 可在配置文件中选择启用s4u prompt模式,灵活控制 - **兼容性保持**: 与现有系统完全兼容,可随时切换启用或禁用 #### 🎤 语音消息支持 + - **Voice消息处理**: 新增对voice类型消息的支持,麦麦现在可以识别和处理语音消息(需要模型配置) #### 全新情绪系统 -- **持续情绪**: 麦麦现在拥有持续的情绪状态,情绪会影响回复风格和行为 +- **持续情绪**: 麦麦现在拥有持续的情绪状态,情绪会影响回复风格和行为 ### 💻 更新预览 #### 关系系统优化 + - **prompt优化**: 优化关系prompt和person_info信息展示 - **构建间隔**: 让关系构建间隔可配置,提升灵活性 - **关系配置**: 优化关系配置,采用和focus一致的关系构建 #### 表情包系统升级 + - **识别增强**: 加强emoji的识别能力,优化emoji显示 - **匹配精准**: 更精准的表情包匹配算法 #### 完善mais4u系统(需要amaidesu支持) + - **直播互动**: 新增mais4u直播功能,支持实时互动和思考状态展示 - **动作控制**: 支持眨眼、微动作、注视等多种动作适配 #### 日志系统优化 + - **显示优化**: 优化Logger前缀映射、颜色格式和计时信息显示 - **级别优化**: 优化日志级别和信息过滤,提升调试体验 - **日志查看器**: 升级logger_viewer,移除无用脚本 #### 配置系统改进 + - **配置简化**: 简化配置文件,让配置更加精简易懂 - **prompt显示**: 可选打开prompt显示功能 - **配置更新**: 更好的配置文件更新机制和更新内容显示 @@ -598,8 +700,7 @@ MaiBot 0.9.0 重磅升级!本版本带来两大核心突破:**全面重构 - 移除observation和processor等冗余组件,大幅简化focus代码逻辑 - 修复了LPMM的学习问题 - -## [0.8.1] - 2025-7-5 +# [0.8.1] - 2025-7-5 功能更新: @@ -620,7 +721,7 @@ MaiBot 0.9.0 重磅升级!本版本带来两大核心突破:**全面重构 - 修复表达器无法读取原始文本 - 修复normal planner没有超时退出问题 -## [0.8.0] - 2025-6-27 +# [0.8.0] - 2025-6-27 MaiBot 0.8.0 现已推出! @@ -638,6 +739,7 @@ MaiBot 0.8.0 现已推出! MMC启动速度加快 ### 🔌 插系统正式推出 + **全面重构的插件生态系统,支持强大 的扩展能力** - **插件API重构**: 全面重构插件系统,统一加载机制,区分内部插件和外部插件 @@ -649,10 +751,13 @@ MMC启动速度加快 - **文档完善**: 补全插件API文档,提供详细的开发指南 ### 👥 人物印象系统 + **麦麦现在能认得群友,记住每个人的特点** + - **人物侧写功能**: 加入了人物侧写!麦麦现在能认得群友,新增用户侧写功能,将印象拆分为多方面特点 ### ⚡ Focus模式大幅优化 - 降低Token消耗与提升速度 + - **Planner架构更新**: 更新planner架构,大大加快速度和表现效果! - **处理器重构**: - 移除冗余处理器 @@ -660,65 +765,72 @@ MMC启动速度加快 - 后置工具处理器,大大减少token消耗 - **统计系统**: 提供focus统计功能,可查看详细的no_action统计信息 - ### ⏰ 聊天频率精细控制 + **支持时段化的精细频率管理,让麦麦在合适的时间说合适的话** + - **时段化控制**: 添加时段talk_frequency控制,支持不同时间段不同群聊的精细频率管理 - **严格频率控制**: 实现更加严格和可靠的频率控制机制 - **Normal模式优化**: 大幅优化normal模式的频率控制逻辑,提升回复的智能性 ### 🎭 表达方式系统大幅优化 + **智能学习群友聊天风格,让麦麦的表达更加多样化** + - **智能学习机制**: 优化表达方式学习算法,支持衰减机制,太久没学的会被自动抛弃 - **表达方式选择**: 新增表达方式选择器,让表达使用更合理 - **跨群互通配置**: 表达方式现在可以选择在不同群互通或独立 - **可视化工具**: 提供表达方式可视化脚本和检查脚本 ### 💾 记忆系统改进 + **更快的记忆处理和更好的短期记忆管理** + - **海马体优化**: 大大优化海马体同步速度,提升记忆处理效率 - **工作记忆升级**: 精简升级工作记忆模块,提供更好的短期记忆管理 - **聊天记录构建**: 优化聊天记录构建方式,提升记忆提取效率 ### 📊 日志系统重构 + **使用structlog提供更好的结构化日志** + - **structlog替换**: 使用structlog替代loguru,提供更好的结构化日志 - **日志查看器**: 新增日志查看脚本,支持更好的日志浏览 - **可配置日志**: 提供可配置的日志级别和格式,支持不同环境的需求 ### 🎯 其他改进 + - **emoji系统**: 移除emoji默认发送模式,优化表情包审查功能 - **控制台发送**: 添加不完善的控制台发送功能 - **行为准则**: 添加贡献者契约行为准则 - **图像清理**: 自动清理images文件夹,优化存储空间使用 +# [0.7.0] -2025-6-1 - - -## [0.7.0] -2025-6-1 - 你可以选择normal,focus和auto多种不同的聊天方式。normal提供更少的消耗,更快的回复速度。focus提供更好的聊天理解,更多工具使用和插件能力 - 现在,你可以自定义麦麦的表达方式,并且麦麦也可以学习群友的聊天风格(需要在配置文件中打开) - 不再需要繁琐的安装MongoDB!弃用MongoDB,采用轻量sqlite,无需额外安装(提供数据迁移脚本) - focus模式初步支持了插件,我们提供了两个示例插件(需要手动启用),可以让麦麦实现更丰富的操作。禁言插件和豆包绘图插件是示例用插件。 **重构专注聊天(HFC - focus_chat)** + - 模块化设计,可以自定义不同的部件 - - 观察器(获取信息) - - 信息处理器(处理信息) - - 重构:聊天思考(子心流)处理器 - - 重构:聊天处理器 - - 重构:聊天元信息处理器 - - 重构:工具处理器 - - 新增:工作记忆处理器 - - 新增:自我认知处理器 - - 新增:动作处理器 - - 决策器(选择动作) - - 执行器(执行动作) - - 回复动作 - - 不回复动作 - - 退出HFC动作 - - 插件:禁言动作 - - 表达器:装饰语言风格 + - 观察器(获取信息) + - 信息处理器(处理信息) + - 重构:聊天思考(子心流)处理器 + - 重构:聊天处理器 + - 重构:聊天元信息处理器 + - 重构:工具处理器 + - 新增:工作记忆处理器 + - 新增:自我认知处理器 + - 新增:动作处理器 + - 决策器(选择动作) + - 执行器(执行动作) + - 回复动作 + - 不回复动作 + - 退出HFC动作 + - 插件:禁言动作 + - 表达器:装饰语言风格 - 可通过插件添加和自定义HFC部件(目前只支持action定义) - 为专注模式添加关系线索 - 在专注模式下,麦麦可以决定自行发送语音消息(需要搭配tts适配器) @@ -727,37 +839,44 @@ MMC启动速度加快 - 可自定义处理器超时时间 **优化普通聊天(normal_chat)** + - 添加可学习的表达方式 - 增加了talk_frequency参数来有效控制回复频率 - 优化了进入和离开normal_chat的方式 - 添加时间信息 **新增表达方式学习** + - 麦麦配置单独表达方式 - 自主学习群聊中的表达方式,更贴近群友 - 可自定义的学习频率和开关 - 根据人设生成额外的表达方式 **聊天管理** + - 移除不在线状态 - 优化自动模式下normal与focus聊天的切换机制 - 大幅精简聊天状态切换规则,减少复杂度 - 移除聊天限额数量 **插件系统** + - 示例插件:禁言插件 - 示例插件:豆包绘图插件 **人格** + - 简化了人格身份的配置 - 优化了在focus模式下人格的表现和稳定性 **数据库重构** + - 移除了默认使用MongoDB,采用轻量sqlite - 无需额外安装数据库 - 提供迁移脚本 **优化** + - 移除日程系统,减少幻觉(将会在未来版本回归) - 移除主心流思考和LLM进入聊天判定 - 支持qwen3模型,支持自定义是否思考和思考长度 @@ -765,47 +884,54 @@ MMC启动速度加快 - 添加配置项 - 添加临时配置文件读取器 +# [0.6.3-fix-4] - 2025-5-18 -## [0.6.3-fix-4] - 2025-5-18 - 0.6.3 的最后一个修复版 ### fix1-fix4修复日志 + **聊天状态** - - 大幅精简聊天状态切换,提高麦麦说话能力 - - 移除OFFLINE和ABSENT状态 - - 移除聊天数量限制 - - 聊天默认normal_chat - - 默认关闭focus_chat + +- 大幅精简聊天状态切换,提高麦麦说话能力 +- 移除OFFLINE和ABSENT状态 +- 移除聊天数量限制 +- 聊天默认normal_chat +- 默认关闭focus_chat **知识库LPMM** - - 增加嵌入模型一致性校验功能 - - 强化数据导入处理,增加非法文段检测功能 - - 修正知识获取逻辑,调整相关性输出顺序 - - 添加数据导入的用户确认删除功能 + +- 增加嵌入模型一致性校验功能 +- 强化数据导入处理,增加非法文段检测功能 +- 修正知识获取逻辑,调整相关性输出顺序 +- 添加数据导入的用户确认删除功能 **专注模式** - - 默认提取记忆,优化记忆表现 - - 添加心流查重 - - 为复读增加硬限制 - - 支持获取子心流循环信息和状态的API接口 - - 优化工具调用的信息获取与缓存 + +- 默认提取记忆,优化记忆表现 +- 添加心流查重 +- 为复读增加硬限制 +- 支持获取子心流循环信息和状态的API接口 +- 优化工具调用的信息获取与缓存 **表情包系统** - - 优化表情包识别和处理 - - 提升表情匹配逻辑 + +- 优化表情包识别和处理 +- 提升表情匹配逻辑 **日志系统** - - 优化日志样式配置 - - 添加丰富的追踪信息以增强调试能力 + +- 优化日志样式配置 +- 添加丰富的追踪信息以增强调试能力 **API** - - 添加GraphQL路由支持 - - 新增强制停止MAI Bot的API接口 +- 添加GraphQL路由支持 +- 新增强制停止MAI Bot的API接口 -## [0.6.3] - 2025-4-15 +# [0.6.3] - 2025-4-15 ### 摘要 + - MaiBot 0.6.3 版本发布!核心重构回复逻辑,统一为心流系统管理,智能切换交互模式。 - 引入全新的 LPMM 知识库系统,大幅提升信息获取能力。 - 新增昵称系统,改善群聊中的身份识别。 @@ -813,12 +939,14 @@ MMC启动速度加快 - 优化日志输出,修复若干问题。 ### 🌟 核心功能增强 + #### 统一回复逻辑 (Unified Reply Logic) + - **核心重构**: 移除了经典 (Reasoning) 与心流 (Heart Flow) 模式的区分,将回复逻辑完全整合到 `SubHeartflow` 中进行统一管理,由主心流统一调控。保留 Heart FC 模式的特色功能。 - **智能交互模式**: `SubHeartflow` 现在可以根据情境智能选择不同的交互模式: - - **普通聊天 (Normal Chat)**: 类似于之前的 Reasoning 模式,进行常规回复(激活逻辑暂未改变)。 - - **心流聊天 (Heart Flow Chat)**: 基于改进的 PFC 模式,能更好地理解上下文,减少重复和认错人的情况,并支持**工具调用**以获取额外信息。 - - **离线模式 (Offline/Absent)**: 在特定情况下,麦麦可能会选择暂时不查看或回复群聊消息。 + - **普通聊天 (Normal Chat)**: 类似于之前的 Reasoning 模式,进行常规回复(激活逻辑暂未改变)。 + - **心流聊天 (Heart Flow Chat)**: 基于改进的 PFC 模式,能更好地理解上下文,减少重复和认错人的情况,并支持**工具调用**以获取额外信息。 + - **离线模式 (Offline/Absent)**: 在特定情况下,麦麦可能会选择暂时不查看或回复群聊消息。 - **状态管理**: 交互模式的切换由 `SubHeartflow` 内部逻辑和 `SubHeartflowManager` 根据整体状态 (`MaiState`) 和配置进行管理。 - **流程优化**: 拆分了子心流的思考模块,使整体对话流程更加清晰。 - **状态判断改进**: 将 CHAT 状态判断交给 LLM 处理,使对话更自然。 @@ -826,15 +954,18 @@ MMC启动速度加快 - **重复性检查**: 加入心流回复重复性检查机制,防止麦麦陷入固定回复模式。 #### 全新知识库系统 (New Knowledge Base System - LPMM) + - **引入 LPMM**: 新增了 **LPMM (Large Psychology Model Maker)** 知识库系统,具有强大的信息检索能力,能显著提升麦麦获取和利用知识的效率。 - **功能集成**: 集成了 LPMM 知识库查询功能,进一步扩展信息检索能力。 - **推荐使用**: 强烈建议使用新的 LPMM 系统以获得最佳体验。旧的知识库系统仍然可用作为备选。 #### 昵称系统 (Nickname System) + - **自动取名**: 麦麦现在会尝试给群友取昵称,减少对易变的群昵称的依赖,从而降低认错人的概率。 - **持续完善**: 该系统目前仍处于早期阶段,会持续进行优化。 #### 记忆与上下文增强 (Memory and Context Enhancement) + - **聊天记录压缩**: 大幅优化聊天记录压缩系统,使机器人能够处理5倍于之前的上下文记忆量。 - **长消息截断**: 新增了长消息自动截断与模糊化功能,随着时间推移降低超长消息的权重,避免被特定冗余信息干扰。 - **记忆提取**: 优化记忆提取功能,提高对历史对话的理解和引用能力。 @@ -843,10 +974,12 @@ MMC启动速度加快 - **Prompt 优化**: 进一步优化了关系系统和记忆系统相关的提示词(prompt)。 #### 私聊 PFC 功能增强 (Private Chat PFC Enhancement) + - **功能修复与优化**: 修复了私聊 PFC 载入聊天记录缺失的 bug,优化了 prompt 构建,增加了审核机制,调整了重试次数,并将机器人发言存入数据库。 - **实验性质**: 请注意,PFC 仍然是一个实验性功能,可能在未来版本中被修改或移除,目前不接受相关 Bug 反馈。 #### 情感与互动增强 (Emotion and Interaction Enhancement) + - **全新表情包系统**: 新的表情包系统上线,表情含义更丰富,发送更快速。 - **表情包使用优化**: 优化了表情包的选择逻辑,减少重复使用特定表情包的情况,使表达更生动。 - **提示词优化**: 优化提示词(prompt)构建,增强对话质量和情感表达。 @@ -854,44 +987,56 @@ MMC启动速度加快 - **颜文字保护**: 保护颜文字处理机制,确保表情正确显示。 #### 工具与集成 (Tools and Integration) + - **动态更新**: 使用工具调用来更新关系和心情,取代原先的固定更新机制。 - **智能调用**: 工具调用时会考虑上下文,使调用更加智能。 - **知识库依赖**: 添加 LPMM 知识库依赖,扩展知识检索工具。 ### 💻 系统架构优化 + #### 日志优化 (Logging Optimization) + - **输出更清晰**: 优化了日志信息的格式和内容,使其更易于阅读和理解。 #### 模型与消息整合 (Model and Message Integration) + - **模型合并**: 合并工具调用模型和心流模型,提高整体一致性。 - **消息规范**: 全面改用 `maim_message`,移除对 `rest` 的支持。 #### (临时) 简易 GUI (Temporary Simple GUI) + - **运行状态查看**: 提供了一个非常基础的图形用户界面,用于查看麦麦的运行状态。 - **临时方案**: 这是一个临时性的解决方案,功能简陋,**将在 0.6.4 版本中被全新的 Web UI 所取代**。此 GUI 不会包含在主程序包中,而是通过一键包提供,并且不接受 Bug 反馈。 ### 🐛 问题修复 + - **记忆检索优化**: 提高了记忆检索的准确性和效率。 - 修复了一些其他小问题。 ### 🔧 其他改进 + #### 桌宠适配器 (Bug Catcher Adapter) + - **独立适配器**: 提供了一个"桌宠"独立适配器,用于连接麦麦和桌宠。 - **获取方式**: 可在 MaiBot 的 GitHub 组织中找到该适配器,不包含在主程序内。 #### 一键包内容 (One-Click Package Contents) + - **辅助程序**: 一键包中包含了简易 GUI 和 **麦麦帮助配置** 等辅助程序,后者可在配置出现问题时提供帮助。 -## [0.6.2] - 2025-4-14 +# [0.6.2] - 2025-4-14 ### 摘要 + - MaiBot 0.6.2 版本发布! - 优化了心流的观察系统,优化提示词和表现,现在心流表现更好! - 新增工具调用能力,可以更好地获取信息 - 本次更新主要围绕工具系统、心流系统、消息处理和代码优化展开,新增多个工具类,优化了心流系统的逻辑,改进了消息处理流程,并修复了多个问题。 ### 🌟 核心功能增强 + #### 工具系统 + - 新增了知识获取工具系统,支持通过心流调用获取多种知识 - 新增了工具系统使用指南,详细说明工具结构、自动注册机制和添加步骤 - 新增了多个实用工具类,包括心情调整工具`ChangeMoodTool`、关系查询工具`RelationshipTool`、数值比较工具`CompareNumbersTool`、日程获取工具`GetCurrentTaskTool`、上下文压缩工具`CompressContextTool`和知识获取工具`GetKnowledgeTool` @@ -899,6 +1044,7 @@ MMC启动速度加快 - 需要配置支持工具调用的模型才能使用完整功能 #### 心流系统 + - 新增了上下文压缩缓存功能,可以有更持久的记忆 - 新增了心流系统的README.md文件,详细介绍了系统架构、主要功能和工作流程。 - 优化了心流系统的逻辑,包括子心流自动清理和合理配置更新间隔。 @@ -906,6 +1052,7 @@ MMC启动速度加快 - 更新了`Heartflow`类的方法和属性,支持异步生成提示词并提升生成质量。 #### 消息处理 + - 改进了消息处理流程,包括回复检查、消息生成和发送逻辑。 - 新增了`ReplyGenerator`类,用于根据观察信息和对话信息生成回复。 - 优化了消息队列管理系统,支持按时间顺序处理消息。 @@ -915,67 +1062,82 @@ MMC启动速度加快 ### 💻 系统架构优化 #### 部署支持 + - 更新了Docker部署文档,优化了服务配置和挂载路径。 - 完善了Linux和Windows脚本支持。 ### 🐛 问题修复 + - 修复了消息处理器中的正则表达式匹配问题。 - 修复了图像处理中的帧大小和拼接问题。 - 修复了私聊时产生`reply`消息的bug。 - 修复了配置文件加载时的版本兼容性问题。 ### 📚 文档更新 + - 更新了`README.md`文件,包括Python版本要求和协议信息。 - 新增了工具系统和心流系统的详细文档。 - 优化了部署相关文档的完整性。 ### 🔧 其他改进 + - 新增了崩溃日志记录器,记录崩溃信息到日志文件。 - 优化了统计信息输出,在控制台显示详细统计信息。 - 改进了异常处理机制,提升系统稳定性。 - 现可配置部分模型的temp参数 -## [0.6.0] - 2025-4-4 +# [0.6.0] - 2025-4-4 ### 摘要 + - MaiBot 0.6.0 重磅升级! 核心重构为独立智能体MaiCore,新增思维流对话系统,支持拟真思考过程。记忆与关系系统2.0让交互更自然,动态日程引擎实现智能调整。优化部署流程,修复30+稳定性问题,隐私政策同步更新,推荐所有用户升级体验全新AI交互!(V3激烈生成) ### 🌟 核心功能增强 + #### 架构重构 + - 将MaiBot重构为MaiCore独立智能体 - 移除NoneBot相关代码,改为插件方式与NoneBot对接 #### 思维流系统 + - 提供两种聊天逻辑,思维流(心流)聊天(ThinkFlowChat)和推理聊天(ReasoningChat) - 思维流聊天能够在回复前后进行思考 - 思维流自动启停机制,提升资源利用效率 - 思维流与日程系统联动,实现动态日程生成 #### 回复系统 + - 更改了回复引用的逻辑,从基于时间改为基于新消息 - 提供私聊的PFC模式,可以进行有目的,自由多轮对话(实验性) #### 记忆系统优化 + - 优化记忆抽取策略 - 优化记忆prompt结构 - 改进海马体记忆提取机制,提升自然度 #### 关系系统优化 + - 优化关系管理系统,适用于新版本 - 改进关系值计算方式,提供更丰富的关系接口 #### 表情包系统 + - 可以识别gif表情包 - 表情包增加存储上限 - 自动清理缓存图片 ## 日程系统优化 + - 日程现在动态更新 - 日程可以自定义想象力程度 - 日程会与聊天情况交互(思维流模式下) ### 💻 系统架构优化 + #### 配置系统改进 + - 新增更多项目的配置项 - 修复配置文件保存问题 - 优化配置结构: @@ -985,12 +1147,15 @@ MMC启动速度加快 - 移除冗余配置 #### 部署支持扩展 + - 优化Docker构建流程 - 完善Windows脚本支持 - 优化Linux一键安装脚本 ### 🐛 问题修复 + #### 功能稳定性 + - 修复表情包审查器问题 - 修复心跳发送问题 - 修复拍一拍消息处理异常 @@ -1002,12 +1167,14 @@ MMC启动速度加快 - 修复EULA和隐私政策编码问题 ### 📚 文档更新 + - 更新README.md内容 - 优化文档结构 - 更新EULA和隐私政策 - 完善部署文档 ### 🔧 其他改进 + - 新增详细统计系统 - 优化表情包审查功能 - 改进消息转发处理 @@ -1018,10 +1185,12 @@ MMC启动速度加快 - 版本硬编码,新增配置自动更新功能 - 优化了统计信息,会在控制台显示统计信息 +# [0.5.15] - 2025-3-17 -## [0.5.15] - 2025-3-17 ### 🌟 核心功能增强 + #### 关系系统升级 + - 新增关系系统构建与启用功能 - 优化关系管理系统 - 改进prompt构建器结构 @@ -1029,6 +1198,7 @@ MMC启动速度加快 - 增加alter支持功能 #### 启动器优化 + - 新增MaiLauncher.bat 1.0版本 - 优化Python和Git环境检测逻辑 - 添加虚拟环境检查功能 @@ -1041,6 +1211,7 @@ MMC启动速度加快 - 修复.env文件复制路径错误 #### 日志系统改进 + - 新增GUI日志查看器 - 重构日志工厂处理机制 - 优化日志级别配置 @@ -1049,7 +1220,9 @@ MMC启动速度加快 - 优化logger输出格式 ### 💻 系统架构优化 + #### 配置系统升级 + - 更新配置文件到0.0.10版本 - 优化配置文件可视化编辑 - 新增配置文件版本检测功能 @@ -1058,11 +1231,13 @@ MMC启动速度加快 - 修复人格设置和其他项配置保存问题 #### WebUI改进 + - 优化WebUI界面和功能 - 支持安装后管理功能 - 修复部分文字表述错误 #### 部署支持扩展 + - 优化Docker构建流程 - 改进MongoDB服务启动逻辑 - 完善Windows脚本支持 @@ -1070,7 +1245,9 @@ MMC启动速度加快 - 新增Debian 12专用运行脚本 ### 🐛 问题修复 + #### 功能稳定性 + - 修复bot无法识别at对象和reply对象的问题 - 修复每次从数据库读取额外加0.5的问题 - 修复新版本由于版本判断不能启动的问题 @@ -1082,6 +1259,7 @@ MMC启动速度加快 - 修复willing模块cfg变量引用问题 ### 📚 文档更新 + - 更新CLAUDE.md为高信息密度项目文档 - 添加mermaid系统架构图和模块依赖图 - 添加核心文件索引和类功能表格 @@ -1090,24 +1268,30 @@ MMC启动速度加快 - 更新EULA和隐私政策文档 ### 🔧 其他改进 + - 更新全球在线数量展示功能 - 优化statistics输出展示 - 新增手动修改内存脚本(支持添加、删除和查询节点和边) ### 主要改进方向 + 1. 完善关系系统功能 2. 优化启动器和部署流程 3. 改进日志系统 4. 提升配置系统稳定性 5. 加强文档完整性 -## [0.5.14] - 2025-3-14 +# [0.5.14] - 2025-3-14 + ### 🌟 核心功能增强 + #### 记忆系统优化 + - 修复了构建记忆时重复读取同一段消息导致token消耗暴增的问题 - 优化了记忆相关的工具模型代码 #### 消息处理升级 + - 新增了不回答已撤回消息的功能 - 新增每小时自动删除存留超过1小时的撤回消息 - 优化了戳一戳功能的响应机制 @@ -1115,42 +1299,53 @@ MMC启动速度加快 - 改进了图片发送错误时的处理机制 #### 日程系统改进 + - 修复了长时间运行的bot在跨天后无法生成新日程的问题 - 优化了日程文本解析功能 - 修复了解析日程时遇到markdown代码块等额外内容的处理问题 ### 💻 系统架构优化 + #### 日志系统升级 + - 建立了新的日志系统 - 改进了错误处理机制 - 优化了代码格式化规范 #### 部署支持扩展 + - 改进了NAS部署指南,增加HOST设置说明 - 优化了部署文档的完整性 ### 🐛 问题修复 + #### 功能稳定性 + - 修复了utils_model.py中的潜在问题 - 修复了set_reply相关bug - 修复了回应所有戳一戳的问题 - 优化了bot被戳时的判断逻辑 ### 📚 文档更新 + - 更新了README.md的内容 - 完善了NAS部署指南 - 优化了部署相关文档 ### 主要改进方向 + 1. 提升记忆系统的效率和稳定性 2. 完善消息处理机制 3. 优化日程系统功能 4. 改进日志和错误处理 5. 加强部署文档的完整性 -## [0.5.13] - 2025-3-12 +# [0.5.13] - 2025-3-12 + ### 🌟 核心功能增强 + #### 记忆系统升级 + - 新增了记忆系统的时间戳功能,包括创建时间和最后修改时间 - 新增了记忆图节点和边的时间追踪功能 - 新增了自动补充缺失时间字段的功能 @@ -1159,12 +1354,14 @@ MMC启动速度加快 - 优化了记忆系统的数据结构,确保所有数据类型的一致性 #### 私聊功能完善 + - 新增了完整的私聊功能支持,包括消息处理和回复 - 新增了聊天流管理器,支持群聊和私聊的上下文管理 - 新增了私聊过滤开关功能 - 优化了关系管理系统,支持跨平台用户关系 #### 消息处理升级 + - 新增了消息队列管理系统,支持按时间顺序处理消息 - 新增了消息发送控制器,实现人性化的发送速度和间隔 - 新增了JSON格式分享卡片读取支持 @@ -1172,7 +1369,9 @@ MMC启动速度加快 - 改进了消息处理流程,支持多种消息类型 ### 💻 系统架构优化 + #### 配置系统改进 + - 新增了配置文件自动更新和版本检测功能 - 新增了配置文件热重载API接口 - 新增了配置文件版本兼容性检查 @@ -1180,6 +1379,7 @@ MMC启动速度加快 - 优化了配置文件格式和结构 #### 部署支持扩展 + - 新增了Linux系统部署指南 - 新增了Docker部署支持的详细文档 - 新增了NixOS环境支持(使用venv方式) @@ -1187,7 +1387,9 @@ MMC启动速度加快 - 优化了Docker部署文档 ### 🛠️ 开发体验提升 + #### 工具链升级 + - 新增了ruff代码格式化和检查工具 - 新增了知识库一键启动脚本 - 新增了自动保存脚本,定期保存聊天记录和关系数据 @@ -1196,6 +1398,7 @@ MMC启动速度加快 - 精简了日志输出,禁用了Uvicorn/NoneBot默认日志 #### 安全性强化 + - 新增了API密钥安全管理机制 - 新增了数据库完整性检查功能 - 新增了表情包文件完整性自动检查 @@ -1203,7 +1406,9 @@ MMC启动速度加快 - 优化了安全性检查机制 ### 🐛 关键问题修复 + #### 系统稳定性 + - 修复了systemctl强制停止的问题 - 修复了ENVIRONMENT变量在同一终端下不能被覆盖的问题 - 修复了libc++.so依赖问题 @@ -1213,6 +1418,7 @@ MMC启动速度加快 - 修复了配置文件加载时的版本兼容性问题 #### 功能完善性 + - 修复了私聊时产生reply消息的bug - 修复了回复消息无法识别的问题 - 修复了CQ码解析错误 @@ -1223,11 +1429,9 @@ MMC启动速度加快 - 修复了变量拼写错误问题 ### 主要改进方向 + 1. 提升记忆系统的智能性和可靠性 2. 完善私聊功能的完整生态 3. 优化系统架构和部署便利性 4. 提升开发体验和代码质量 5. 加强系统安全性和稳定性 - - - diff --git a/changelogs/mai_next_design.md b/changelogs/mai_next_design.md deleted file mode 100644 index dcee4681..00000000 --- a/changelogs/mai_next_design.md +++ /dev/null @@ -1,732 +0,0 @@ -# Mai NEXT 设计文档 -Version 0.2.2 - 2025-11-05 - -## 配置文件设计 -主体利用`pydantic`的`BaseModel`进行配置类设计`ConfigBase`类 -要求每个属性必须具有类型注解,且类型注解满足以下要求: -- 原子类型仅允许使用: `str`, `int`, `float`, `bool`, 以及基于`ConfigBase`的嵌套配置类 -- 复杂类型允许使用: `list`, `dict`, `set`,但其内部类型必须为原子类型或嵌套配置类,不可使用`list[list[int]]`,`list[dict[str, int]]`等写法 -- 禁止了使用`Union`, `tuple/Tuple`类型 - - 但是`Optional`仍然允许使用 -### 移除template的方案提案 -
-配置项说明的废案 -

方案一

-
-from typing import Annotated
-from dataclasses import dataclass, field
-@dataclass
-class Config:
-    value: Annotated[str, "配置项说明"] = field(default="default_value")
-
-

方案二(不推荐)

-
-from dataclasses import dataclass, field
-@dataclass
-class Config:
-    @property
-    def value(self) -> str:
-        """配置项说明"""
-        return "default_value"
-
-

方案四

-
-from dataclasses import dataclass, field
-@dataclass
-class Config:
-    value: str = field(default="default_value", metadata={"doc": "配置项说明"})
-
-
- -- [x] 方案三(个人推荐) -```python -import ast, inspect -class AttrDocBase: - ... -from dataclasses import dataclass, field -@dataclass -class Config(ConfigBase, AttrDocBase): - value: str = field(default="default_value") - """配置项说明""" -``` - -### 配置文件实现热重载 - -#### 整体架构设计 -- [x] 文件监视器 - - [x] 监视文件变化 - - [x] 使用 `watchfiles` 监视配置文件变化(提案) - - [ ] 备选提案:使用纯轮询监视文件变化 - - [x] 使用Hash检查文件变化(`watchfiles`实现) - - [x] 防抖处理(使用`watchfiles`的防抖) - - [x] 重新分发监视事件,正确监视文件变化 -- [ ] 配置管理器 - - [x] 配置文件读取和加载 - - [ ] 重载配置 - - [ ] 管理全部配置数据 - - [ ] `validate_config` 方法 -- [ ] 回调管理器(合并到文件监视器中) - - [x] `callback` 注册与注销 - - [ ] 按优先级执行回调(提案) - - [x] 错误隔离 - - [ ] 锁机制 - -#### 工作流程 -``` -1. 文件监视器检测变化 -2. 配置管理器加锁重载 -3. 验证新配置 (失败保持旧配置) -4. 更新内存数据 -5. 回调管理器按优先级执行回调 (错误隔离) -6. 释放锁 -``` - -#### 回调执行策略 -1. 优先级顺序(提案): 数字越小优先级越高,同优先级异步回调并行执行 -2. 错误处理: 单个回调失败不影响其他回调 - - -#### 代码框架 -实际代码实现与下类似,但是进行了调整 - -`ConfigManager` - 配置管理器: -```python -import asyncio -import tomlkit -from typing import Any, Dict, Optional -from pathlib import Path - -class ConfigManager: - def __init__(self, config_path: str): - self.config_path: Path = Path(config_path) - self.config_data: Dict[str, Any] = {} - self._lock: asyncio.Lock = asyncio.Lock() - self._file_watcher: Optional["FileWatcher"] = None - self._callback_manager: Optional["CallbackManager"] = None - - async def initialize(self) -> None: - """异步初始化,加载配置并启动监视""" - pass - - async def load_config(self) -> Dict[str, Any]: - """异步加载配置文件""" - pass - - async def reload_config(self) -> bool: - """热重载配置,返回是否成功""" - pass - - def get_item(self, key: str, default: Any = None) -> Any: - """获取配置项,支持嵌套访问 (如 'section.key')""" - pass - - async def set_item(self, key: str, value: Any) -> None: - """设置配置项并触发回调""" - pass - - def validate_config(self, config: Dict[str, Any]) -> bool: - """验证配置合法性""" - pass -``` -
-回调管理器(废案) - -`CallbackManager` - 回调管理器: -```python -import asyncio -from dataclasses import dataclass, field - -class CallbackManager: - def __init__(self): - self._callbacks: Dict[str, List[CallbackEntry]] = {} - self._global_callbacks: List[CallbackEntry] = [] - - def register( - self, - key: str, - callback: Callable[[Any], Union[None, asyncio.Future]], - priority: int = 100, - name: str = "" - ) -> None: - """注册回调函数,priority为正整数,数字越小优先级越高""" - pass - - def unregister(self, key: str, callback: Callable) -> None: - """注销回调函数""" - pass - - async def trigger(self, key: str, value: Any) -> None: - """触发回调,按优先级执行(数字小的先执行),错误隔离""" - pass - - def enable_callback(self, key: str, name: str) -> None: - """启用指定回调""" - pass - - def disable_callback(self, key: str, name: str) -> None: - """禁用指定回调""" - pass -``` - -对于CallbackManager中的优先级功能说明: - -- 数字越小优先级越高 -- 为什么要有优先级系统: - - 理论上来说,在热重载配置之后,应该要通过回调函数管理器触发所有回调函数,模拟启动的过程,类似于“重启” - - 而优先级模块是保证某一些模块的重载顺序一定是晚于某一些地基模块的 - - 例如:内置服务器的启动应该是晚于所有模块,即最后启动 - -
- -`FileWatcher` - 文件监视器: -```python -import asyncio -from watchfiles import awatch, Change -from pathlib import Path - -class FileWatcher: - def __init__(self, debounce_ms: int = 500): - self.debounce_ms: int = debounce_ms - - def start(self, on_change: Callable) -> None: - """启动文件监视""" - pass - - def stop(self) -> None: - """停止文件监视""" - pass - - async def invoke_callback(self) -> None: - """调用变化回调函数""" - pass -``` -#### 配置文件写入 -- [x] 将当前文件写入toml文件 - - -## 消息部分设计 -解决原有的将消息类与数据库类存储不匹配的问题,现在存储所有消息类的所有属性 - -完全合并`stream_id`和`chat_id`为`chat_id`,规范名称 - -`chat_stream`重命名为`chat_session`,表示一个会话 - -### 消息类设计 -- [ ] 支持并使用maim_message新的`SenderInfo`和`ReceiverInfo`构建消息 - - [ ] 具体使用参考附录 -- [ ] 适配器处理跟进该更新 -- [ ] 修复适配器的类型检查问题 -- [ ] 设计更好的平台消息ID回传机制 - - [ ] 考虑使用事件依赖机制 -### 图片处理系统 -- [ ] 规范化Emojis与Images的命名,统一保存 -### 消息到Prompt的构建(提案) -- [ ] 类QQ的时间系统(即不是每条消息加时间戳,而是分大时间段加时间戳)(此功能已实现,但效果不佳) -- [ ] 消息编号系统(已经有的) -- [ ] 思考打断,如何判定是否打断? - - [ ] 如何判定消息是连贯的(MoFox: 一个反比例函数???太神秘了) -### 消息进入处理 -使用轮询机制,每隔一段时间检查缓存中是否有新消息 - ---- - -## 数据库部分设计 -合并Emojis和Images到同一个表中 - -数据库ORM应该使用SQLModel而不是peewee(墨:peewee我这辈子都不会用它了) -### 数据库缓存层设计 -将部分消息缓存到内存中,减少数据库访问,在主程序处理完之后再写入数据库 - -要求:对上层调用保持透明 -- [ ] 数据库内容管理类 `DatabaseManager` - - [ ] 维护数据库连接 - - [ ] 提供增删改查接口 - - [ ] 维护缓存类 `DatabaseMessageCache` 的实例 - -- [ ] 缓存类 `DatabaseMessageCache` - - [ ] **设计缓存失效机制** - - [ ] 设计缓存更新机制 - - [ ] `add_message` - - [ ] `update_message` (提案) - - [ ] `delete_message` - -- [ ] 与数据库交互部分设计 - - [ ] 维持现有的数据库sqlite - - [ ] 继续使用peewee进行操作 -### 消息表设计 -- [ ] 设计内部消息ID和平台消息ID两种形式 -- [ ] 临时消息ID不进入数据库 -- [ ] 消息有关信息设计 - - [ ] 消息ID - - [ ] 发送者信息 - - [ ] 接收者信息 - - [ ] 消息内容 - - [ ] 消息时间戳 - - [ ] 待定 -### Emojis与Images表设计 -- [ ] 设计图片专有ID,并作为文件名 -### Expressions表设计 -- [ ] 待定 -### 表实际设计 -#### ActionRecords 表 -- [ ] 动作唯一ID `action_id` -- [ ] 动作执行时间 `action_time` -- [ ] 动作名称 `action_name` -- [ ] 动作参数 `action_params` (JSON格式存储)(原`action_data`) ---- - -## 数据模型部分设计 -- [ ] Message从数据库反序列化,不再使用额外的Message类(放弃) -- [ ] 设计 `BaseModel` 类,作为所有数据模型的基类 - - [ ] 提供通用的序列化和反序列化方法(提案) - ---- - -## 核心业务逻辑部分设计 -### Prompt 设计 -将Prompt内容彻底模块化设计 -- [ ] 设计 Prompt 类 - - [ ] `__init__(self, template: list[str], *, **kwargs)` 维持现有的template设计,但不进行format,直到最后传入LLM时再进行render - - [ ] `__init__`中允许传入任意的键值对,存储在`self.context`中 - - [ ] `self.prompt_name` 作为Prompt的名称 - - [ ] `self.construct_function: Dict[str, Callable | AsyncCallable]` 构建Prompt内容所需的函数字典 - - [ ] 格式:`{"block_name": function_reference}` - - [ ] `self.content_block: Dict[str, str]`: 实际的Prompt内容块 - - [ ] 格式:`{"block_name": "Unrendered Prompt Block"}` - - [ ] `render(self) -> str` 使用非递归渲染方式渲染Prompt内容 - - [ ] `add_construct_function(self, name: str, func: Callable | AsyncCallable, *, suppress: bool = False)` 添加构造函数 - - [ ] 实现重名警告/错误(偏向错误) - - [ ] `suppress`: 是否覆盖已有的构造函数 - - [ ] `remove_construct_function(self, name: str)` 移除指定名称的构造函数 - - [ ] `add_block(self, prompt_block: "Prompt", block_name: str, *, suppress: bool = False)` 将另一个Prompt的内容更新到当前Prompt中 - - [ ] 实现重名属性警告/错误(偏向错误) - - [ ] 实现重名构造函数警告/错误(偏向错误) - - [ ] `suppress`: 是否覆盖已有的内容块和构造函数 - - [ ] `remove_block(self, block_name: str)` 移除指定名称的Prompt块 -- [ ] 设计 PromptManager 类 - - [ ] `__init__(self)` 初始化一个空的Prompt管理器 - - [ ] `add_prompt(self, name: str, prompt: Prompt)` 添加一个新的Prompt - - [ ] 实现重名警告/错误(偏向错误) - - [ ] `get_prompt(self, name: str) -> Prompt` 根据名称获取Prompt - - [ ] 实现不存在时的错误处理 - - [ ] `remove_prompt(self, name: str)` 移除指定名称的Prompt - - [ ] 系统 Prompt 保护 - - [ ] `list_prompts(self) -> list[str]` 列出所有已添加的Prompt名称 -### 内建好奇插件设计 -- [ ] 设计“麦麦好奇”插件 - - [ ] 解决麦麦乱好奇的问题 - - [ ] 好奇问题无回复清理 - - [ ] 好奇问题超时清理 - - [ ] 根据聊天内容选择个性化好奇问题 - - [ ] 好奇频率控制 - ---- - -## 插件系统部分设计 -### 设计一个插件沙盒系统(放弃) -### 插件管理 -- [ ] 插件管理器类 `PluginManager` 的更新 - - [ ] 重写现有的插件文件加载逻辑,精简代码,方便重载 - - [ ] 学习AstrBot的基于子类加载的插件加载方式,放弃@register_plugin(提案) - - [ ] 直接 breaking change 删除 @register_plugin 函数,不保留过去插件的兼容性(提案) - - [ ] 设计插件重载系统 - - [ ] 插件配置文件重载 - - [ ] 复用`FileWatcher`实现配置文件热重载 - - [ ] 插件代码重载 - - [ ] 从插件缓存中移除此插件对应的模块 - - [ ] 从组件管理器中移除该插件对应的组件 - - [ ] 重新导入该插件模块 - - [ ] 插件可以设计为禁止热重载类型 - - [ ] 通过字段`allow_hot_reload: bool`指定 - - [ ] Napcat Adapter插件设计为禁止热重载类型 - - [ ] 其余细节待定 -- [ ] 组件管理器类 `ComponentManager` 的更新 - - [ ] 配合插件重载系统的更好的组件管理代码 - - [ ] 组件全局控制和局部控制的平级化(提案) - - [ ] 重新设计组件注册和注销逻辑,分离激活和注册 - - [ ] 可以修改组件的属性 - - [ ] 组件系统卸载 - - [ ] 联动插件卸载(方便重载设计) - - [ ] 其余细节待定 -- [ ] 因重载机制设计的更丰富的`plugin_meta`和`component_meta` - - [ ] `component_meta`增加`plugin_file`字段,指向插件文件路径,保证重载时组件能正确更新 - - [ ] `plugin_meta`增加`sub_components`字段,指示该插件包含的组件列表,方便重载时更新 - - [ ] `sub_components`内容为组件类名列表 -### 插件激活方式的动态设计 -- [ ] 设计可变的插件激活方式 - - [ ] 直接读写类属性`activate_types` -### 真正的插件重载 -- [ ] 使用上文中提到的配置文件热重载机制 - - [ ] FileWatcher的复用 -### 传递内容设计 -对于传入的Prompt使用上文提到的Prompt类进行管理,方便内容修改避免正则匹配式查找 -### MCP 接入(大饼) -- [ ] 设计 MCP 适配器类 `MCPAdapter` - - [ ] MCP 调用构建说明Prompt - - [ ] MCP 调用内容传递 - - [ ] MCP 调用结果处理 -### 工具结果的缓存设计 -可能的使用案例参考[附录-工具缓存](#工具缓存可能用例) -- [ ] `put_cache(**kwargs, *, _component_name: str)` 方法 - - [ ] 设计为父类的方法,插件继承后使用 - - [ ] `_component_name` 指定当前组件名称,由MaiNext自动传入 -- [ ] `get_cache` 方法 -- [ ] `need_cache` 变量管理是否调用缓存结果 - - [ ] 仅在设置为True时为插件创立缓存空间 -### Events依赖机制(提案) -- [ ] 通过Events的互相依赖完成链式任务 -- [ ] 设计动态调整events_handler执行顺序的机制 (感谢@OctAutumn老师!伟大,无需多言) - - [ ] 作为API暴露,方便用户使用 -### 正式的插件依赖管理系统 -- [ ] requirements.txt分析 -- [ ] python_dependencies分析 -- [ ] 自动安装 -- [ ] plugin_dependencies分析 - - [ ] 拓扑排序 - -#### 插件依赖管理器设计 -使用 `importlib.metadata` 进行插件依赖管理,实现自动依赖检查和安装功能 - -`PluginDependencyManager` - 插件依赖管理器: -```python -import importlib.metadata -from typing import Dict, List, Optional, Tuple -from dataclasses import dataclass - -@dataclass -class DependencyInfo: - """依赖信息""" - name: str - required_version: str - installed_version: Optional[str] = None - is_satisfied: bool = False - -class PluginDependencyManager: - def __init__(self): - self._installed_packages: Dict[str, str] = {} - self._dependency_cache: Dict[str, List[DependencyInfo]] = {} - - def scan_installed_packages(self) -> Dict[str, str]: - """ - 扫描已安装的所有Python包 - 使用 importlib.metadata.distributions() 获取所有已安装的包 - 返回 {包名: 版本号} 的字典 - """ - pass - - def parse_plugin_dependencies(self, plugin_config: Dict) -> List[DependencyInfo]: - """ - 解析插件配置中的依赖信息 - 从 plugin_config 中提取 python_dependencies 字段 - 支持多种版本指定格式: ==, >=, <=, >, <, ~= - 返回依赖信息列表 - """ - pass - - def check_dependencies( - self, - plugin_name: str, - dependencies: List[DependencyInfo] - ) -> Tuple[List[DependencyInfo], List[DependencyInfo]]: - """ - 检查插件依赖是否满足 - 对比插件要求的依赖版本与已安装的包版本 - 返回 (满足的依赖列表, 不满足的依赖列表) - """ - pass - - def compare_version( - self, - installed_version: str, - required_version: str - ) -> bool: - """ - 比较版本号是否满足要求 - 支持版本操作符: ==, >=, <=, >, <, ~= - 使用 packaging.version 进行版本比较 - 返回是否满足要求 - """ - pass - - async def install_dependencies( - self, - dependencies: List[DependencyInfo], - *, - upgrade: bool = False - ) -> bool: - """ - 安装缺失或版本不匹配的依赖 - 调用 pip install 安装指定版本的包 - upgrade: 是否升级已有包 - 返回安装是否成功 - """ - pass - - def get_dependency_tree(self, plugin_name: str) -> Dict[str, List[str]]: - """ - 获取插件的完整依赖树 - 递归分析插件依赖的包及其子依赖 - 返回依赖关系图 - """ - pass - - def validate_all_plugins(self) -> Dict[str, bool]: - """ - 验证所有已加载插件的依赖完整性 - 返回 {插件名: 依赖是否满足} 的字典 - """ - pass -``` - -#### 依赖管理工作流程 -``` -1. 插件加载时触发依赖检查 -2. PluginDependencyManager.scan_installed_packages() 扫描已安装包 -3. PluginDependencyManager.parse_plugin_dependencies() 解析插件依赖 -4. PluginDependencyManager.check_dependencies() 对比版本 -5. 如果依赖不满足: - a. 记录缺失/版本不匹配的依赖 - b. (可选) 自动调用 install_dependencies() 安装 - c. 重新验证依赖 -6. 依赖满足后加载插件,否则跳过并警告 -``` - - -#### TODO List -- [ ] 实现 `scan_installed_packages()` 方法 - - [ ] 使用 `importlib.metadata.distributions()` 获取所有包 - - [ ] 规范化包名(处理大小写、下划线/横杠问题) - - [ ] 缓存结果以提高性能 -- [ ] 实现 `parse_plugin_dependencies()` 方法 - - [ ] 支持多种依赖格式解析 - - [ ] 验证版本号格式合法性 - - [ ] 处理无版本要求的依赖 -- [ ] 实现 `compare_version()` 方法 - - [ ] 集成 `packaging.version` 库 - - [ ] 支持所有 PEP 440 版本操作符 - - [ ] 处理预发布版本、本地版本标识符 -- [ ] 实现 `check_dependencies()` 方法 - - [ ] 逐个检查依赖是否已安装 - - [ ] 比对版本是否满足要求 - - [ ] 生成详细的依赖检查报告 -- [ ] 实现 `install_dependencies()` 方法 - - [ ] 调用 pip 子进程安装包 - - [ ] 支持指定 PyPI 镜像源 - - [ ] 错误处理和回滚机制 - - [ ] 安装进度反馈 -- [ ] 实现依赖冲突检测 - - [ ] 检测不同插件间的依赖版本冲突 - - [ ] 提供冲突解决建议 -- [ ] 实现依赖缓存机制(可选) - - [ ] 缓存已检查的依赖结果 - - [ ] 定期刷新缓存 -- [ ] 集成到 `PluginManager` - - [ ] 在插件加载前进行依赖检查 - - [ ] 依赖不满足时的处理策略(警告/阻止加载/自动安装) - - [ ] 提供手动触发依赖检查的接口 -- [ ] 日志和报告 - - [ ] 记录依赖安装日志 - - [ ] 生成依赖关系报告 - - [ ] 依赖问题的用户友好提示 -### 插件系统API更改 -#### Events 设计 -- [ ] 设计events.api - - [ ] `emit(type: EventType | str, * , **kwargs)` 广播事件,使用关键字参数保证传入正确 - - [ ] `order_change` 动态调整事件处理器执行顺序 -#### 组件控制API更新 -- [ ] 增加可以更改组件属性的方法 - - [ ] 验证组件属性的存在 - - [ ] 修改组件属性 -#### 全局常量API设计 -- [ ] 设计 `api.constants` 模块 - - [x] 提供全局常量访问 - - [ ] 设计常量注册和注销方法 - - [x] 系统内置常量通过`dataclass`的`frozen=True`实现不可变 - - [x] 方便调用设计 -```python -from dataclasses import dataclass -@dataclass(frozen=True) -class SystemConstants: - VERSION: str = "xxx" - ADA_PLUGIN: bool = True - -SYSTEM_CONSTANTS = SystemConstants() -``` -#### 配置文件API设计 -- [ ] 正确表达配置文件结构 -- [ ] 同时也能表达插件配置文件 -#### 自动API文档生成系统 -通过解析插件代码生成API文档 -- [ ] 设计文档生成器 `APIDocumentationGenerator` - - [ ] 解析插件代码(AST, inspect, 仿照AttrDocBase) - - [ ] 提取类和方法的docstring - - [ ] 生成Markdown格式的文档 ---- - -## 表达方式模块设计 -在0.11.x版本对本地模型预测的性能做评估,考虑使用本地朴素贝叶斯模型来检索 -降低延迟的同时减少token消耗 -需要给表达方式一个负反馈的途径 - ---- -## 加入测试模块,可以通过通用测试集对对话内容进行评估 -## 加入更好的基于单次思考的Log - ---- - -## 记忆系统部分设计 -启用LPMM系统进行记忆构建,将记忆分类为短期记忆,长期记忆,以及知识 -将所有内容放到同一张图上进行运算。 - -### 时间相关设计 -- [ ] 尝试将记忆系统与时间系统结合 - - [ ] 可以根据时间查找记忆 - - [ ] 可以根据时间删除记忆 -- [ ] 记忆分层 - - [ ] 即刻记忆 - - [ ] 短期记忆 - - [ ] 长期记忆 - - [x] 知识 - - [ ] 细节待定,考虑心理学相关方向 ---- - -## 日志系统设计 -将原来的终端颜色改为六位HEX颜色码,方便前端显示。 - -将原来的256色终端改为24真彩色终端,方便准确显示颜色。 - ---- - -## API 设计 -### API 设计细则 -#### 配置文件 -- [x] 使用`tomlkit`作为配置文件解析方式 -- [ ] 解析内容 - - [x] 注释(已经合并到代码中,不再解析注释而是生成注释) - - [x] 保持原有格式 -- [ ] 传递只读日志内容(使用ws) - - [ ] message - - [ ] level - - [ ] module - - [ ] timestamp - - [ ] lineno - - [ ] logger_name 和 name_mapping - - [ ] color -- [ ] 插件安装系统 - - [ ] 通过API安装插件 - - [ ] 通过API卸载插件 - - ---- - -## LLM UTILS设计 -多轮对话设计 -### FUNCTION CALLING设计(提案) -对于tools调用将其真正修正为function calling,即返回的结果不是加入prompt形式而是使用function calling的形式[此功能在tool前处理器已实现,但在planner效果不佳,因此后弃用] -- [ ] 使用 MessageBuilder 构建function call内容 - - [ ] (提案)是否维护使用同一个模型,即选择工具的和调用工具的LLM是否相同 - - [ ] `generate(**kwargs, model: Optional[str] = None)` 允许传入不同的模型 -- [ ] 多轮对话中,Prompt不重复构建减少上下文 -### 网络相关内容提案 -增加自定义证书的导入功能 -- [ ] 允许用户传入自定义CA证书路径 -- [ ] 允许用户选择忽略SSL验证(不推荐) - ---- - -## 内建WebUI设计 -⚠️ **注意**: 本webui设计仅为初步设计,方向为展示内建API的功能,后续应该分离到另外的子项目中完成 -### 配置文件编辑 -根据API内容完成 -### 插件管理 -### log viewer -通过特定方式获取日志内容(只读系统,无法将操作反向传递) -### 状态监控 -1. Prompt 监控系统 -2. 请求监控系统 - - [ ] 请求管理(待讨论) - - [ ] 使用量 -3. 记忆/知识图监控系统(待讨论) -4. 日志系统 - - [ ] 后端内容解析 -5. 插件市场系统 - - [ ] 插件浏览 - - [ ] 插件安装 - -## 自身提供的MCP设计(提案) -- [ ] 提供一个内置的MCP,作为插件系统的一个组件 -- [ ] 该MCP可以对麦麦自身的部分设置进行更改 - - [ ] 例如更改Prompt,添加记忆,修改表达方式等 - ---- - -# 提案讨论 -- MoFox 在我和@拾风的讨论中提出把 Prompt 类中传入构造函数以及构造函数所需要的内容 -- [ ] 适配器插件化: 省下序列化与反序列化,但是失去解耦性质 -- [ ] 可能的内存泄露问题 - - [ ] 垃圾回收 -- [ ] 数据库模型提供通用的转换机制,转为DataModel使用 -- [ ] 插件依赖的自动安装 -- [ ] 热重载系统的权重系统是否需要 - ---- - -# PYTEST设计 -设计一个pytest测试系统,在代码完成后运行pytest进行测试 - -所有的测试代码均在`pytests`目录下 - ---- - -# 依赖管理 -已经完成,要点如下: -- 使用 pyproject.toml 和 requirements.txt 管理依赖 -- 二者应保持同步修改,同时以 pyproject.toml 为主(建议使用git hook) - ---- - -# 迁移说明 -由于`.env`的移除,可能需要用户自己把`.env`里面的host和port复制到`bot_config.toml`中的`maim_message`部分的`host`和`port` -原来使用这两个的用户,请修改`host`到`second_host`,`port`到`second_port` - -# 附录 -## Maim_Message 新版使用计划 -SenderInfo: 将作为消息来源者 -ReceiverInfo: 将作为消息接收者 -尝试更新MessageBaseInfo的sender_info和receiver_info为上述两个类的列表(提案) -给出样例如下 -群聊 -```mermaid -sequenceDiagram - participant GroupNotice - participant A - participant B - participant Bot - A->>B: Message("Hello B", id=1) - A->>B: Message("@B Hello B", id=2) - A->>Bot: Message("@Bot Hello Bot", id=3) - Bot->>A: Message("Hello A", id=4) - Bot->>B: Message("@B Hello B", id=5) - A->>B: Message("@B @Bot Hello Guys", id=6) - A->>Bot: Message("@B @Bot Hello Guys", id=6) - A->>GroupNotice: Message("@ALL Hello Everyone", id=7) -``` -上述消息的Info如下 -| Message ID | SenderInfo | ReceiverInfo | -|-|-----|-----| -| 1 | [A] | NULL | -| 2 | [A] | [B] | -| 3 | [A] | [Bot] | -| 4 | [Bot] | [A] | -| 5 | [Bot] | [B] | -| 6 | [A] | [B, Bot] | -| 7 | [A] | [ALL*] | - -*ALL为一个特殊类型,尝试用`user_id="all"`表示 - -Bot可以通过ReceiverInfo判断自己是否被提及,同时在ReceiverInfo表明自己回复的对象 - -## 工具缓存可能用例 -考虑一个天气插件,将时间按照半小时进行划分,即每半小时查询一次天气,半小时内的查询均使用缓存结果。 -- `need_cache` 设置为 True 表示使用缓存结果 -- `put_cache` 在查询天气后将结果`{