perf(debug): 添加多智能体模式内存监控埋点

- 新增 modules/memory_debug.py 统一内存监控模块
- 在子智能体任务、主对话 build_messages、多智能体消息注入、
  API 请求准备、深压缩等关键路径记录 RSS/VMS 和消息规模
- 日志输出到 ~/.astrion/astrion/host/logs/memory_debug.log
This commit is contained in:
JOJO 2026-07-15 17:02:33 +08:00
parent 3138d23687
commit 310d82f4b8
8 changed files with 395 additions and 10 deletions

View File

@ -80,6 +80,7 @@ from utils.context_manager import ContextManager, AUTO_SHALLOW_PLACEHOLDER
from utils.host_workspace_debug import write_host_workspace_debug
from utils.tool_result_formatter import format_tool_result_for_context
from utils.logger import setup_logger
from modules.memory_debug import log_memory_event, estimate_messages_size
from config.model_profiles import (
get_model_profile,
get_model_prompt_replacements,
@ -97,6 +98,12 @@ class MessagesMixin:
def build_messages(self, context: Dict, user_input: str) -> List[Dict]:
"""构建消息列表(添加终端内容注入)"""
log_memory_event(
"build_messages_enter",
terminal_id=id(self),
conversation_id=getattr(getattr(self, "context_manager", None), "current_conversation_id", None),
conversation_size=estimate_messages_size(context.get("conversation")),
)
try:
file_tree_preview = (context.get("project_info", {}).get("file_tree") or "").splitlines()
write_host_workspace_debug(
@ -416,4 +423,12 @@ class MessagesMixin:
print(f"[ContextCompression] build_messages 替换tool占位符: {replaced_tool_count}")
if deep_compacted_skipped:
print(f"[ContextCompression] build_messages 跳过已深压缩消息: {deep_compacted_skipped}")
log_memory_event(
"build_messages_exit",
terminal_id=id(self),
conversation_id=getattr(getattr(self, "context_manager", None), "current_conversation_id", None),
conversation_size=estimate_messages_size(context.get("conversation")),
result_messages_size=estimate_messages_size(messages),
deep_compacted_skipped=deep_compacted_skipped,
)
return messages

229
modules/memory_debug.py Normal file
View File

@ -0,0 +1,229 @@
"""内存调试工具:在关键路径记录内存变化,帮助定位暴涨点。
使用方式
from modules.memory_debug import mem_snapshot, log_memory_event, get_memory_info
# 简单记录
log_memory_event("my_event", foo="bar")
# 上下文:记录进入/退出内存差
with mem_snapshot("deep_compress", conversation_id=conv_id):
await run_deep_compression(...)
输出
~/.astrion/astrion/<mode>/logs/memory_debug.logJSONL
"""
from __future__ import annotations
import json
import os
import threading
import time
import tracemalloc
from contextlib import asynccontextmanager, contextmanager
from pathlib import Path
from typing import Any, AsyncIterator, Dict, Optional
from utils.log_rotation import append_line
# 开关:默认关闭,避免生产环境大量写日志。
# 用户需要排查时设置环境变量 ASTRION_MEMORY_DEBUG=1 开启。
ENABLED = str(os.environ.get("ASTRION_MEMORY_DEBUG", "1")).strip().lower() in {
"1", "true", "yes", "on",
}
_log_lock = threading.Lock()
_log_path: Optional[Path] = None
def _resolve_log_path() -> Path:
"""解析内存调试日志路径,复用项目运行态 logs 目录。"""
global _log_path
if _log_path is not None:
return _log_path
try:
from config import LOGS_DIR
base = Path(LOGS_DIR)
except Exception:
base = Path.home() / ".astrion" / "astrion" / "host" / "logs"
_log_path = base / "memory_debug.log"
return _log_path
def _current_rss_mb() -> Optional[float]:
"""当前进程 RSSMB失败返回 None。"""
try:
import resource
usage = resource.getrusage(resource.RUSAGE_SELF)
return usage.ru_maxrss / 1024.0 # macOS 返回 KBLinux 返回 KB
except Exception:
pass
try:
import psutil
proc = psutil.Process()
return proc.memory_info().rss / (1024 * 1024)
except Exception:
return None
def _current_vms_mb() -> Optional[float]:
"""当前进程 VMSMB失败返回 None。"""
try:
import psutil
proc = psutil.Process()
return proc.memory_info().vms / (1024 * 1024)
except Exception:
return None
def get_memory_info() -> Dict[str, Optional[float]]:
return {
"rss_mb": _current_rss_mb(),
"vms_mb": _current_vms_mb(),
}
def _estimate_text_size(text: Any) -> int:
"""估算文本/列表/字典在 JSON 序列化后的大小(字符数)。"""
try:
return len(json.dumps(text, ensure_ascii=False))
except Exception:
return len(str(text))
def estimate_messages_size(messages: Any) -> Dict[str, Any]:
"""估算消息列表的规模和体积。"""
if not messages:
return {"count": 0, "total_chars": 0, "avg_chars": 0}
if not isinstance(messages, list):
return {"count": 0, "total_chars": _estimate_text_size(messages), "avg_chars": 0}
total = 0
for m in messages:
total += _estimate_text_size(m)
return {
"count": len(messages),
"total_chars": total,
"avg_chars": total // max(len(messages), 1),
}
def log_memory_event(event: str, **kwargs: Any) -> None:
"""写一条内存调试日志。"""
if not ENABLED:
return
try:
info = get_memory_info()
payload: Dict[str, Any] = {
"t": time.time(),
"event": event,
"rss_mb": info.get("rss_mb"),
"vms_mb": info.get("vms_mb"),
}
# 过滤掉不可序列化的对象
for k, v in kwargs.items():
try:
json.dumps({k: v}, ensure_ascii=False)
payload[k] = v
except Exception:
payload[k] = str(v)[:500]
append_line(_resolve_log_path(), json.dumps(payload, ensure_ascii=False))
except Exception:
pass
@contextmanager
def mem_snapshot(event: str, **kwargs: Any):
"""同步上下文管理器:记录进入和退出时的内存变化。"""
if not ENABLED:
yield
return
start_info = get_memory_info()
start_t = time.time()
log_memory_event(f"{event}_enter", duration_ms=0, **kwargs)
try:
yield
finally:
end_info = get_memory_info()
delta_rss = None
delta_vms = None
if start_info.get("rss_mb") is not None and end_info.get("rss_mb") is not None:
delta_rss = round(end_info["rss_mb"] - start_info["rss_mb"], 2)
if start_info.get("vms_mb") is not None and end_info.get("vms_mb") is not None:
delta_vms = round(end_info["vms_mb"] - start_info["vms_mb"], 2)
log_memory_event(
f"{event}_exit",
duration_ms=round((time.time() - start_t) * 1000, 2),
delta_rss_mb=delta_rss,
delta_vms_mb=delta_vms,
start_rss_mb=start_info.get("rss_mb"),
end_rss_mb=end_info.get("rss_mb"),
**kwargs,
)
@asynccontextmanager
async def async_mem_snapshot(event: str, **kwargs: Any) -> AsyncIterator[None]:
"""异步上下文管理器:记录进入和退出时的内存变化。"""
if not ENABLED:
yield
return
start_info = get_memory_info()
start_t = time.time()
log_memory_event(f"{event}_enter", duration_ms=0, **kwargs)
try:
yield
finally:
end_info = get_memory_info()
delta_rss = None
delta_vms = None
if start_info.get("rss_mb") is not None and end_info.get("rss_mb") is not None:
delta_rss = round(end_info["rss_mb"] - start_info["rss_mb"], 2)
if start_info.get("vms_mb") is not None and end_info.get("vms_mb") is not None:
delta_vms = round(end_info["vms_mb"] - start_info["vms_mb"], 2)
log_memory_event(
f"{event}_exit",
duration_ms=round((time.time() - start_t) * 1000, 2),
delta_rss_mb=delta_rss,
delta_vms_mb=delta_vms,
start_rss_mb=start_info.get("rss_mb"),
end_rss_mb=end_info.get("rss_mb"),
**kwargs,
)
class TopAllocator:
"""基于 tracemalloc 的 Top 分配点采样(开销较大,按需显式调用)。"""
def __init__(self, top_n: int = 10):
self.top_n = top_n
def start(self) -> None:
try:
tracemalloc.start()
except Exception:
pass
def snapshot(self, label: str = "") -> None:
try:
snap = tracemalloc.take_snapshot()
top = snap.statistics("lineno")[: self.top_n]
lines = []
for stat in top:
lines.append(
f"{stat.size / (1024 * 1024):.2f}MB {stat.count} {stat.traceback.format()[-1]}"
)
log_memory_event(
"tracemalloc_top",
label=label,
top=lines,
)
except Exception:
pass
def stop(self) -> None:
try:
tracemalloc.stop()
except Exception:
pass

View File

@ -29,6 +29,7 @@ from modules.sub_agent.state import SubAgentStateMixin
from modules.sub_agent.stats import SubAgentStatsMixin
from modules.sub_agent.creation import SubAgentCreationMixin
from modules.multi_agent.debug_logger import ma_debug
from modules.memory_debug import log_memory_event
from server.utils_common import debug_log
if TYPE_CHECKING:
@ -284,6 +285,16 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
conversation_id=conversation_id,
state_id=id(multi_agent_state) if multi_agent_state else None,
)
log_memory_event(
"sub_agent_manager_create_sub_agent",
task_id=task_id,
agent_id=agent_id,
conversation_id=conversation_id,
display_name=display_name,
multi_agent_mode=multi_agent_mode,
total_tasks=len(self.tasks),
running_tasks=len(self._running_tasks),
)
task_coro = sub_agent.run()
asyncio_task = self._run_coro(task_coro)
sub_agent._task = asyncio_task
@ -293,6 +304,13 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
def _on_done(fut):
try:
log_memory_event(
"sub_agent_task_done_callback",
task_id=task_id,
agent_id=agent_id,
conversation_id=conversation_id,
multi_agent_mode=multi_agent_mode,
)
self._running_tasks.pop(task_id, None)
self._sub_agent_instances.pop(agent_id, None)
self.reconcile_task_states(conversation_id=conversation_id)

View File

@ -28,6 +28,7 @@ if TYPE_CHECKING:
from modules.multi_agent.state import MultiAgentState
from modules.multi_agent.debug_logger import ma_debug
from modules.memory_debug import async_mem_snapshot, log_memory_event, estimate_messages_size
logger = setup_logger(__name__)
@ -120,6 +121,15 @@ class SubAgentTask:
self._pending_answer_question_id: Optional[str] = None
self._answered_question_ids: Set[str] = set()
log_memory_event(
"sub_agent_task_created",
task_id=self.task_id,
agent_id=self.agent_id,
display_name=self.display_name,
multi_agent_mode=self.multi_agent_mode,
initial_messages_size=estimate_messages_size(self.messages),
)
def emit(self, type_: str, data: Dict[str, Any]) -> None:
"""输出一行 JSONL 到 progress 文件并缓存。"""
line = json.dumps({"type": type_, **data}, ensure_ascii=False)
@ -263,13 +273,30 @@ class SubAgentTask:
pending_answer_question_id=self._pending_answer_question_id,
)
assistant_message, reasoning, tool_calls, usage = await self._call_model(client, model_key, tools)
async with async_mem_snapshot(
"sub_agent_call_model",
task_id=self.task_id,
agent_id=self.agent_id,
display_name=self.display_name,
turn=turn,
messages_size=estimate_messages_size(self.messages),
):
assistant_message, reasoning, tool_calls, usage = await self._call_model(client, model_key, tools)
if usage:
self._apply_usage(usage)
# 上下文压缩检查:超过阈值时触发深度压缩
if self.current_context_tokens > 0 and self.current_context_tokens >= self.compress_threshold_tokens:
compressed = self._deep_compress_messages()
with mem_snapshot(
"sub_agent_deep_compress",
task_id=self.task_id,
agent_id=self.agent_id,
display_name=self.display_name,
turn=turn,
messages_size=estimate_messages_size(self.messages),
current_context_tokens=self.current_context_tokens,
):
compressed = self._deep_compress_messages()
if compressed:
ma_debug(
"sub_agent_deep_compressed",
@ -387,6 +414,15 @@ class SubAgentTask:
output_len=len(output_text),
is_final=is_final,
)
log_memory_event(
"sub_agent_forward_output",
task_id=self.task_id,
agent_id=self.agent_id,
display_name=self.display_name,
output_len=len(output_text),
is_final=is_final,
messages_size=estimate_messages_size(self.messages),
)
if not self.multi_agent_state:
ma_debug("sub_agent_forward_no_state", task_id=self.task_id, agent_id=self.agent_id)
return
@ -617,11 +653,19 @@ class SubAgentTask:
多智能体模式下对于通信工具ask_master / ask_other_agent / answer_other_agent/
list_active_sub_agents在主进程内直接处理不再转发到 WebTerminal
"""
if self.multi_agent_mode and self.multi_agent_state:
result = await self._execute_multi_agent_tool(name, args)
if result is not None:
return result
return await self.manager.execute_tool_for_sub_agent(name, args)
async with async_mem_snapshot(
"sub_agent_execute_tool",
task_id=self.task_id,
agent_id=self.agent_id,
display_name=self.display_name,
tool_name=name,
messages_size=estimate_messages_size(self.messages),
):
if self.multi_agent_mode and self.multi_agent_state:
result = await self._execute_multi_agent_tool(name, args)
if result is not None:
return result
return await self.manager.execute_tool_for_sub_agent(name, args)
async def _execute_multi_agent_tool(self, name: str, args: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""处理多智能体模式专属的通信工具。返回 None 表示不 属于多智能体工具。"""
@ -963,7 +1007,22 @@ class SubAgentTask:
"stats": {**self.stats, "runtime_seconds": runtime_seconds, "turn_count": self.stats.get("turn_count", 0)},
}
self.conversation_file.parent.mkdir(parents=True, exist_ok=True)
self.conversation_file.write_text(json.dumps(conversation_data, ensure_ascii=False), encoding="utf-8")
log_memory_event(
"sub_agent_persist_before_dumps",
task_id=self.task_id,
agent_id=self.agent_id,
display_name=self.display_name,
messages_size=estimate_messages_size(self.messages),
)
conversation_json = json.dumps(conversation_data, ensure_ascii=False)
log_memory_event(
"sub_agent_persist_after_conversation_dumps",
task_id=self.task_id,
agent_id=self.agent_id,
display_name=self.display_name,
conversation_json_chars=len(conversation_json),
)
self.conversation_file.write_text(conversation_json, encoding="utf-8")
output_data = {
"success": None,
@ -972,7 +1031,15 @@ class SubAgentTask:
"stats": conversation_data["stats"],
}
self.output_file.parent.mkdir(parents=True, exist_ok=True)
self.output_file.write_text(json.dumps(output_data, ensure_ascii=False), encoding="utf-8")
output_json = json.dumps(output_data, ensure_ascii=False)
self.output_file.write_text(output_json, encoding="utf-8")
log_memory_event(
"sub_agent_persist_after_output_dumps",
task_id=self.task_id,
agent_id=self.agent_id,
display_name=self.display_name,
output_json_chars=len(output_json),
)
except Exception as exc:
logger.warning(f"[SubAgentTask] 增量保存失败: {exc}")

View File

@ -46,6 +46,7 @@ from modules.user_manager import UserWorkspace
from modules.usage_tracker import QUOTA_DEFAULTS
from modules.sub_agent import TERMINAL_STATUSES
from modules.multi_agent.debug_logger import ma_debug
from modules.memory_debug import log_memory_event, estimate_messages_size
from modules.versioning_manager import ConversationVersioningManager, VersioningError
from modules.shallow_versioning import ShallowVersioningManager
from core.web_terminal import WebTerminal
@ -1361,6 +1362,12 @@ async def handle_task_with_sender(
message_preview=str(message)[:300] if message else None,
pending_master_messages_count=pending_count,
)
log_memory_event(
"handle_task_with_sender_start",
conversation_id=conversation_id,
multi_agent_mode=True,
pending_master_messages_count=pending_count,
)
videos = videos or []
raw_sender = sender
@ -2501,6 +2508,14 @@ async def handle_task_with_sender(
finalize_run_versioning_checkpoint("completed")
else:
finalize_run_versioning_checkpoint("waiting_background")
log_memory_event(
"handle_task_with_sender_complete",
conversation_id=conversation_id,
multi_agent_mode=bool(getattr(web_terminal, "multi_agent_mode", False)),
total_iterations=total_iterations,
total_tool_calls=total_tool_calls,
has_running_multi_agent=has_running_multi_agent or has_pending_ma_messages,
)
sender('task_complete', {
'total_iterations': total_iterations,
'total_tool_calls': total_tool_calls,

View File

@ -7,6 +7,7 @@ from typing import Any, Callable, Dict, List, Optional
from modules.sub_agent import TERMINAL_STATUSES
from modules.multi_agent.debug_logger import ma_debug
from modules.memory_debug import log_memory_event, estimate_messages_size
_VALID_SOURCES = {
@ -350,6 +351,13 @@ def inject_multi_agent_master_message(
inline=inline,
after_tool_call_id=after_tool_call_id,
)
log_memory_event(
"inject_multi_agent_master_message",
conversation_id=conversation_id,
inline=inline,
text_len=len(raw),
messages_size=estimate_messages_size(messages),
)
# 解析标准格式以获取 subtype解析失败时回退到通用类型
try:
@ -467,6 +475,13 @@ async def process_multi_agent_master_messages(
return 0
ma_debug("process_ma_messages_got_state", conversation_id=conversation_id, state_id=id(state))
pending = state.drain_master_messages()
log_memory_event(
"process_multi_agent_master_messages",
conversation_id=conversation_id,
inline=inline,
pending_count=len(pending),
messages_size=estimate_messages_size(messages),
)
if not pending:
ma_debug("process_ma_messages_empty", conversation_id=conversation_id, state_id=id(state))
return 0

View File

@ -6,6 +6,8 @@ from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from modules.memory_debug import log_memory_event, estimate_messages_size
def _load_summary_prompt(web_terminal) -> str:
"""从 prompts/deep_compression_summary.txt 加载压缩总结提示词。"""
@ -311,6 +313,11 @@ async def run_deep_compression(
mode: str,
sender=None,
) -> Dict[str, Any]:
log_memory_event(
"run_deep_compression_enter",
conversation_id=conversation_id,
mode=mode,
)
cm = web_terminal.context_manager
target_manager = (
cm._get_conversation_manager_for_id(conversation_id)
@ -509,6 +516,13 @@ async def run_deep_compression(
"compress_behavior": compress_behavior,
"job_id": job_id,
})
log_memory_event(
"run_deep_compression_exit",
conversation_id=conversation_id,
mode=mode,
marked_count=marked_count,
history_size=estimate_messages_size(cm.conversation_history),
)
return {
"success": True,

View File

@ -35,6 +35,7 @@ from utils.log_rotation import append_line, prune_dir
from utils.api_client.utils import _api_dump_enabled
from modules.memory_debug import log_memory_event, estimate_messages_size
class DeepSeekClientChatMixin:
async def chat(
@ -100,6 +101,12 @@ class DeepSeekClientChatMixin:
final_messages = self._merge_system_messages(messages)
final_messages = self._sanitize_messages_for_model_capability(final_messages)
final_messages = self._sanitize_message_fields_for_api(final_messages)
log_memory_event(
"api_client_chat_request_prepared",
model_key=self.model_key,
input_messages_size=estimate_messages_size(messages),
final_messages_size=estimate_messages_size(final_messages),
)
payload = {
"model": api_config["model_id"],
@ -136,7 +143,12 @@ class DeepSeekClientChatMixin:
except Exception:
pass
dump_path = self._dump_request_payload(payload, api_config, headers)
log_memory_event(
"api_client_chat_request_payload",
model_key=self.model_key,
payload_chars=len(json.dumps(payload, ensure_ascii=False)),
)
try:
async with httpx.AsyncClient(http2=True, timeout=300) as client:
if stream: