agent-Specialization/modules/memory_debug.py
JOJO 2b131de702 perf(debug): 内存监控增强为 100ms 采样 + 多维度指标
- 采样间隔从 1s 降到 100ms,spike 阈值降到 50MB
- 增加 uss/shared/private/data/dirty/compressed 等指标
- 更贴近 macOS 活动监视器'内存'列的统计口径
2026-07-17 12:16:16 +08:00

337 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""内存调试工具:在关键路径记录内存变化,帮助定位暴涨点。
使用方式:
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 sys
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
_peak_rss_mb: Optional[float] = None
_last_sample_rss_mb: Optional[float] = None
_sample_thread: Optional[threading.Thread] = None
def _current_rss_mb() -> Optional[float]:
"""当前进程 RSSMB优先使用 psutil实时失败回退到 resource。"""
global _peak_rss_mb
try:
import psutil
proc = psutil.Process()
rss = proc.memory_info().rss / (1024 * 1024)
if _peak_rss_mb is None or rss > _peak_rss_mb:
_peak_rss_mb = rss
return rss
except Exception:
pass
try:
import resource
usage = resource.getrusage(resource.RUSAGE_SELF)
# macOS: ru_maxrss 单位是 bytesLinux: 单位是 KB
if sys.platform == "darwin":
rss = usage.ru_maxrss / (1024 * 1024)
else:
rss = usage.ru_maxrss / 1024.0
if _peak_rss_mb is None or rss > _peak_rss_mb:
_peak_rss_mb = rss
return rss
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_detailed_memory_info() -> Dict[str, Optional[float]]:
"""获取更详细的内存指标,更接近 macOS 活动监视器。"""
result: Dict[str, Optional[float]] = {
"rss_mb": _current_rss_mb(),
"vms_mb": _current_vms_mb(),
"uss_mb": None,
"shared_mb": None,
"private_mb": None,
"data_mb": None,
"dirty_mb": None,
"compressed_mb": None,
}
try:
import psutil
proc = psutil.Process()
info = proc.memory_info()
# uss 只在 memory_full_info() 中提供
try:
full = proc.memory_full_info()
result["uss_mb"] = getattr(full, "uss", None) / (1024 * 1024) if getattr(full, "uss", None) else None
result["compressed_mb"] = getattr(full, "compressed", None) / (1024 * 1024) if getattr(full, "compressed", None) else None
except Exception:
pass
result["shared_mb"] = getattr(info, "shared", None) / (1024 * 1024) if getattr(info, "shared", None) else None
result["private_mb"] = getattr(info, "private", None) / (1024 * 1024) if getattr(info, "private", None) else None
result["data_mb"] = getattr(info, "data", None) / (1024 * 1024) if getattr(info, "data", None) else None
result["dirty_mb"] = getattr(info, "dirty", None) / (1024 * 1024) if getattr(info, "dirty", None) else None
except Exception:
pass
return result
def get_memory_info() -> Dict[str, Optional[float]]:
return _get_detailed_memory_info()
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
def _periodic_sampling(
interval_seconds: float = 0.1,
spike_threshold_mb: float = 50.0,
max_records: int = 10,
) -> None:
"""后台线程:每 100ms 采样一次内存,发现快速增长或峰值时记录。"""
global _last_sample_rss_mb, _peak_rss_mb
records_since_spike = 0
last_regular_log = 0.0
while ENABLED:
try:
info = get_memory_info()
rss = info.get("rss_mb")
if rss is not None:
spike = False
delta = None
if _last_sample_rss_mb is not None:
delta = rss - _last_sample_rss_mb
if delta >= spike_threshold_mb:
spike = True
records_since_spike = max_records
_last_sample_rss_mb = rss
if records_since_spike > 0:
records_since_spike -= 1
log_memory_event(
"memory_periodic_spike",
**info,
delta_from_last_mb=delta,
)
else:
now = time.time()
if now - last_regular_log >= 2.0:
last_regular_log = now
log_memory_event(
"memory_periodic_sample",
**info,
)
except Exception:
pass
time.sleep(interval_seconds)
def start_periodic_sampling(
interval_seconds: float = 0.1,
spike_threshold_mb: float = 50.0,
) -> None:
"""启动后台内存采样线程。幂等:重复调用不会启动多个线程。"""
global _sample_thread
if not ENABLED:
return
if _sample_thread is not None and _sample_thread.is_alive():
return
_sample_thread = threading.Thread(
target=_periodic_sampling,
args=(interval_seconds, spike_threshold_mb),
name="memory-debug-sampler",
daemon=True,
)
_sample_thread.start()