ReMe/benchmark/locomo/eval_reme_light.py
Diego C 2f37a159b7
Some checks failed
Pre-commit / run (ubuntu-latest) (push) Has been cancelled
为文件型记忆(ReMeLight)新增 LoCoMo 评测支持。在此之前仅向量型记忆系统(ReMe)有评测脚本 (#283)
* feat: 新增 ReMeLight 文件版记忆的 LoCoMo 评测脚本

* feat: add LoCoMo eval for ReMeLight file-based memory

* fix: resolve pre-commit lint issues

* fix: align answer model with eval_reme.py (qwen3-30b-a3b-instruct-2507)

* fix: add --disable=E0611
2026-06-24 19:29:17 +08:00

1195 lines
42 KiB
Python
Raw Permalink 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.

"""
ReMeLight (file-based) evaluation script for LoCoMo benchmark.
This script evaluates the file-based memory system (ReMeLight) on the
LoCoMo benchmark, using the same evaluation protocol as the vector-based
eval_reme.py but adapted for ReMeLight's file-based API.
Pipeline:
1. Load LoCoMo data
2. For each user conversation:
a. Initialize ReMeLight with per-user working_dir
b. Process all sessions via summary_memory() -> writes memory/*.md
c. Answer questions via memory_search() -> LLM generates answer
d. Judge answers via LLM-as-Judge (GPT-4o-mini)
3. Aggregate and report metrics
Usage:
python benchmark/locomo/eval_reme_light.py \
--data_path locomo10.json \
--top_k 20 --user_num 5 --max_concurrency 2
"""
import asyncio
import json
import os
import re
import shutil
import time
from pathlib import Path
from datetime import datetime, timedelta
from dataclasses import dataclass
from typing import Any
from agentscope.message import Msg
from loguru import logger
from reme.reme_light import ReMeLight
# ==================== Configuration ====================
@dataclass
class EvalConfig:
"""Evaluation configuration parameters."""
data_path: str = ""
top_k: int = 20
user_num: int = 1
max_concurrency: int = 2
batch_size: int = 40
output_dir: str = "bench_results/reme_light"
reme_model_name: str = "qwen-flash"
eval_model_name: str = "qwen3-max"
# Time to wait for FileWatcher to re-index after file writes (seconds)
index_wait_seconds: int = 5
# Skip summarization, go straight to QA (reuse existing working_dir)
resume: bool = False
# ==================== Utilities ====================
class DataLoader:
"""Handles loading and parsing of LoCoMo data."""
@staticmethod
def load_json(file_path: str) -> dict:
"""Load and parse a JSON file."""
with open(file_path, "r", encoding="utf-8") as f:
return json.load(f)
@staticmethod
def format_dialogue_messages(
dialogue: list[dict],
speaker_a: str,
base_timestamp: datetime,
time_interval: int,
) -> list[Msg]:
"""Format LoCoMo dialogue into agentscope Msg objects for ReMeLight."""
messages: list[Msg] = []
for idx, turn in enumerate(dialogue):
role = "user" if turn["speaker"] == speaker_a else "assistant"
ts = (base_timestamp + timedelta(seconds=idx * time_interval)).strftime(
"%Y-%m-%d %H:%M:%S",
)
msg = Msg(
name=turn["speaker"],
content=turn["text"],
role=role,
metadata={"time_created": ts},
)
messages.append(msg)
return messages
class FileManager:
"""Manages file I/O operations for eval results."""
def __init__(self, base_dir: str):
self.base_dir = Path(base_dir)
self.base_dir.mkdir(parents=True, exist_ok=True)
def get_user_dir(self, user_name: str) -> Path:
"""Get or create the output directory for a user."""
user_dir = self.base_dir / user_name
user_dir.mkdir(parents=True, exist_ok=True)
return user_dir
def get_session_file(self, user_name: str, session_id: int) -> Path:
"""Get the file path for a session result."""
return self.get_user_dir(user_name) / f"session_{session_id}.json"
def save_session(self, user_name: str, session_id: int, data: dict):
"""Save session evaluation data to a JSON file."""
file_path = self.get_session_file(user_name, session_id)
with open(file_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def user_has_cache(self, user_name: str) -> bool:
"""Check if cached evaluation data exists for a user."""
user_dir = self.get_user_dir(user_name)
has_sessions = any(f.name.startswith("session_") and f.suffix == ".json" for f in user_dir.iterdir())
has_questions = (user_dir / "questions.json").exists()
return has_sessions and has_questions
def combine_results(self, output_file: str):
"""Combine all user session files into a single JSONL results file."""
with open(output_file, "w", encoding="utf-8") as f_out:
for user_dir in sorted(self.base_dir.iterdir()):
if not user_dir.is_dir():
continue
session_files = sorted(
f for f in user_dir.iterdir() if f.name.startswith("session_") and f.suffix == ".json"
)
if not session_files:
continue
with open(session_files[0], "r", encoding="utf-8") as f_in:
first_session = json.load(f_in)
user_data = {
"uuid": first_session["uuid"],
"user_name": first_session["user_name"],
"sessions": [],
}
for sf in session_files:
with open(sf, "r", encoding="utf-8") as f_in:
session_data = json.load(f_in)
session_data.pop("uuid", None)
session_data.pop("user_name", None)
user_data["sessions"].append(session_data)
question_file = user_dir / "questions.json"
if question_file.exists():
with open(question_file, "r", encoding="utf-8") as f_in:
user_data["evaluation_results"] = json.load(f_in)
f_out.write(json.dumps(user_data, ensure_ascii=False) + "\n")
# ==================== Memory Operations ====================
class ReMeLightMemoryProcessor:
"""Handles ReMeLight memory operations for eval."""
def __init__(
self,
eval_model_name: str = "qwen3-max",
index_wait_seconds: int = 5,
):
self.eval_model_name = eval_model_name
self.index_wait_seconds = index_wait_seconds
# 每 0.5 秒检查 file_store 有没有索引文件,有了立刻返回
async def _wait_for_index(self, reme: ReMeLight, timeout: int = 10):
"""Poll file_store until indexed files appear, instead of blind sleep."""
from reme.core.enumeration import MemorySource
fs = reme.service_context.file_stores["default"]
for _ in range(timeout * 2): # 每 0.5 秒检查一次
files = await fs.list_files(MemorySource.MEMORY)
if files:
return
await asyncio.sleep(0.5)
logger.warning("FileWatcher indexing timed out, proceeding anyway")
async def add_memories(
self,
reme: ReMeLight,
messages: list[Msg],
batch_size: int = 10000,
) -> tuple[str, float]:
"""Process session messages and persist to memory files.
Returns:
tuple: (summary_text, duration_ms)
"""
start = time.time()
summary_text = ""
for i in range(0, len(messages), batch_size):
batch = messages[i : i + batch_size]
try:
result = await reme.summary_memory(
messages=batch,
language="en",
)
summary_text += result
except Exception as e:
logger.error(f"summary_memory failed for batch: {e}")
# 轮询等 FileWatcher 重建索引,替代 sleep(5)
await self._wait_for_index(reme)
duration_ms = (time.time() - start) * 1000
return summary_text, duration_ms
# LLM 生成 4 个变体问题,逐个搜,按 (path, line) 去重合并
async def _multi_query_search(
self,
reme: ReMeLight,
question: str,
top_k: int,
) -> list[dict]:
"""Generate multiple query variations and merge search results."""
llm = reme.service_context.as_llms.get("default")
if llm is None:
return []
# 让 LLM 生成查询变体
prompt = _QUERY_VARIATIONS_PROMPT.format(question=question, n=4)
try:
resp = await asyncio.wait_for(
llm(messages=[{"role": "user", "content": prompt}]),
timeout=30,
)
text = "".join(b["text"] if isinstance(b, dict) else getattr(b, "text", "") for b in (resp.content or []))
# 按行解析变体
variations = [q.strip("- ").strip() for q in text.split("\n") if q.strip("- ").strip()]
except Exception:
variations = []
# 原始问题 + 变体,去重
all_queries = list(dict.fromkeys([question] + variations[:4]))
logger.info(f" Multi-query: {len(all_queries)} queries")
# 逐个搜索,按 merge_key 去重合并
seen = set()
merged: dict[str, dict] = {}
for q in all_queries:
try:
sr = await reme.memory_search(query=q, max_results=top_k, min_score=0.1)
for r in _parse_search_results(sr):
key = f"{r.get('path', '')}:{r.get('start_line', '')}"
if key not in seen:
seen.add(key)
merged[key] = r
except Exception:
continue
results = sorted(merged.values(), key=lambda r: r.get("score", 0), reverse=True)
return results[:top_k]
# 多轮检索 最多 3 轮, LLM 判断信息够不够,不够生成新查询再搜
async def _multi_round_search(
self,
reme: ReMeLight,
question: str,
top_k: int,
max_rounds: int = 3,
) -> list[dict]:
"""Multi-round retrieval: search, check sufficiency, refine query if needed."""
all_results = await self._multi_query_search(reme, question, top_k)
if not all_results:
return []
llm = reme.service_context.as_llms.get("default")
if llm is None or max_rounds <= 1:
return all_results
# 后续轮次
seen_keys = {f"{r.get('path', '')}:{r.get('start_line', '')}" for r in all_results}
for round_idx in range(1, max_rounds):
# LLM 判断是否足够,不够则给新查询
context = _format_search_results_for_prompt(all_results[:10])
check_prompt = _SUFFICIENCY_CHECK_PROMPT.format(
question=question,
context=context,
)
try:
resp = await asyncio.wait_for(
llm(messages=[{"role": "user", "content": check_prompt}]),
timeout=60,
)
text = "".join(
b["text"] if isinstance(b, dict) else getattr(b, "text", "") for b in (resp.content or [])
)
except Exception:
break
# 解析 LLM 决策
if "SUFFICIENT" in text.upper() and "INSUFFICIENT" not in text.upper():
break # 够了就停
# 提取新查询
new_query = ""
for line in text.split("\n"):
if "NEW_QUERY:" in line.upper():
new_query = line.split(":", 1)[-1].strip()
break
if not new_query:
break
logger.info(f" Round {round_idx + 1}: refined query -> {new_query[:60]}...")
new_results = await self._multi_query_search(reme, new_query, top_k // 2)
for r in new_results:
key = f"{r.get('path', '')}:{r.get('start_line', '')}"
if key not in seen_keys:
seen_keys.add(key)
all_results.append(r)
return sorted(all_results, key=lambda r: r.get("score", 0), reverse=True)[:top_k]
async def search_memory(
self,
reme: ReMeLight,
query: str,
top_k: int = 20,
) -> tuple[dict, list, float]:
"""Multi-round, multi-query memory search with LLM answer generation.
Returns:
tuple: (answer_dict, raw_search_results, duration_ms)
"""
start = time.time()
# 多轮 + 多查询检索
raw_results = await self._multi_round_search(reme, query, top_k)
# LLM 基于搜到的记忆生成结构化回答
answer_dict = await _answer_question_with_memories(
reme=reme,
question=query,
search_results=raw_results,
_model_name=self.eval_model_name,
)
duration_ms = (time.time() - start) * 1000
return answer_dict, raw_results, duration_ms
def _parse_search_results(search_result) -> list[dict]:
"""Parse ReMeLight memory_search ToolResponse into list of result dicts."""
try:
if not search_result.content:
return []
# ToolResponse.content is a list of dicts, each with 'type' and 'text' keys
block = search_result.content[0]
if isinstance(block, dict):
text = block.get("text", "[]")
elif hasattr(block, "text"):
text = block.text
else:
return []
return json.loads(text)
except (json.JSONDecodeError, AttributeError, IndexError, TypeError):
logger.warning("Failed to parse memory_search results")
return []
def _format_search_results_for_prompt(results: list[dict]) -> str:
"""Format raw search results into a prompt-friendly string."""
if not results:
return "No relevant memories found."
lines = []
for i, r in enumerate(results, 1):
path = r.get("path", "unknown")
snippet = r.get("snippet", r.get("content", ""))
score = r.get("score", 0)
lines.append(f"[{i}] {path} (score={score:.2f}):\n{snippet}")
return "\n\n".join(lines)
async def _answer_question_with_memories(
reme: ReMeLight,
question: str,
search_results: list[dict],
_model_name: str = "qwen3-30b-a3b-instruct-2507",
) -> dict:
# 把搜索结果格式化成 prompt 上下文
memories_text = _format_search_results_for_prompt(search_results)
context = f"Memories from file-based memory system:\n{memories_text}"
# 填入 prompt 模板
prompt = _PROMPT_MEMZERO_JSON.format(context=context, question=question)
llm = reme.service_context.as_llms.get("default")
if llm is None:
logger.error("No default LLM available")
return {"reasoning": "LLM not available", "answer": ""}
try:
response = await asyncio.wait_for(
llm(messages=[{"role": "user", "content": prompt}]),
timeout=120, # 2 minutes per LLM call
)
text = ""
for b in response.content or []:
t = b["text"] if isinstance(b, dict) else getattr(b, "text", "")
if t:
text += t
# Try parsing JSON from the response
json_match = re.search(r"\{[^}]+\}", text, re.DOTALL)
if json_match:
return json.loads(json_match.group(0))
return {"reasoning": text, "answer": text}
except asyncio.TimeoutError:
logger.error("LLM answer generation timed out")
return {"reasoning": "LLM timeout", "answer": ""}
except Exception as e:
logger.error(f"LLM answer generation failed: {e}")
return {"reasoning": str(e), "answer": ""}
# 每个问题被裁判两次:
# 1. LLM 整理后的回答 vs 标准答案
# 2. 原始搜出来的记忆片段 vs 标准答案(衡量检索本身的质量)
async def _evaluation_for_question(
reme: ReMeLight,
question: str,
golden_answer: str, # 数据集标准答案
generated_answer: str, # LLM 生成的回答
_model_name: str = "qwen3-max",
) -> dict:
"""LLM-as-Judge: compare generated answer with golden answer."""
await asyncio.sleep(2) # Rate limiting
user_prompt = _USER_PROMPT_TEMPLATE.format(
question=question,
golden_answer=golden_answer,
generated_answer=generated_answer,
)
llm = reme.service_context.as_llms.get("default")
if llm is None:
return {"reasoning": "LLM not available", "evaluation_result": False}
try:
# 调 LLM 当裁判,2 分钟超时
response = await asyncio.wait_for(
llm(
messages=[
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
),
timeout=120,
)
content = ""
for b in response.content or []:
t = b["text"] if isinstance(b, dict) else getattr(b, "text", "")
if t:
content += t
match = re.search(r'"label"\s*:\s*"([^"]*?)"', content)
if match:
label = match.group(1)
else:
# Fallback: look for CORRECT or WRONG anywhere in response
if "CORRECT" in content.upper():
label = "CORRECT"
else:
label = "WRONG"
return {
"reasoning": content,
"evaluation_result": label.strip().upper() == "CORRECT",
}
except asyncio.TimeoutError:
logger.error("Evaluation LLM call timed out")
return {"reasoning": "LLM timeout", "evaluation_result": False}
except Exception as e:
logger.error(f"Evaluation LLM call failed: {e}")
return {"reasoning": str(e), "evaluation_result": False}
# ==================== Evaluation Prompt Templates ====================
_SYSTEM_PROMPT = "You are an expert grader that determines if answers to questions match a gold standard answer"
_USER_PROMPT_TEMPLATE = (
"Your task is to label an answer to a question as 'CORRECT' or 'WRONG'."
" You will be given the following data:\n"
" (1) a question (posed by one user to another user),\n"
" (2) a 'gold' (ground truth) answer,\n"
" (3) a generated answer\n"
"which you will score as CORRECT/WRONG.\n"
"\n"
"The point of the question is to ask about something one user should know "
"about the other user based on their prior conversations.\n"
"The gold answer will usually be a concise and short answer that includes "
"the referenced topic.\n"
"\n"
"For time related questions, the gold answer will be a specific date, "
"month, year, etc. The generated answer might be much longer or use "
"relative time references, but you should be generous with your grading "
"- as long as it refers to the same date or time period as the gold "
"answer, it should be counted as CORRECT.\n"
"\n"
"Now it's time for the real question:\n"
"Question: {question}\n"
"Gold answer: {golden_answer}\n"
"Generated answer: {generated_answer}\n"
"\n"
"First, provide a short (one sentence) explanation of your reasoning, "
"then finish with CORRECT or WRONG.\n"
"Do NOT include both CORRECT and WRONG in your response.\n"
"\n"
'Just return the label CORRECT or WRONG in a json format with the key as "label".'
)
_PROMPT_MEMZERO_JSON = """# CONTEXT:
{context}
# CONTEXT PRIORITY:
When the context contains information from multiple sources, follow this strict priority order:
1. **Historical Dialogue** (highest priority) - Direct conversation content
2. **Extracted Memories** (medium priority) - Summarized memory points
3. **User Profile** (lowest priority) - General user information
# Question:
{question}
# INSTRUCTIONS:
1. Carefully analyze all provided memories (facts and entities)
2. Pay special attention to the timestamps to determine when events occurred
3. If the question asks about a specific event or fact, look for direct evidence
4. If the memories contain contradictory information, prioritize the most recent memory
5. Always convert relative time references to specific dates, months, or years
6. Be as specific as possible when talking about people, places, and events
# OUTPUT FORMAT:
Please provide your response in the following JSON format:
```json
{{
"reasoning": "reasoning content",
"answer": "Provide a detailed answer"
}}
```"""
_QUERY_VARIATIONS_PROMPT = (
"Generate {n} search query variations for the question below. Each variation "
"should use different wording, focus on different entities, or approach from "
'a different angle. Output one query per line, starting with "- ".\n'
"\n"
"Question: {question}\n"
"\n"
"Queries:"
)
_SUFFICIENCY_CHECK_PROMPT = (
"You are evaluating whether retrieved memories contain enough information "
"to answer a question.\n"
"\n"
"Question: {question}\n"
"\n"
"Retrieved memories:\n"
"{context}\n"
"\n"
"If the memories contain sufficient information to answer the question, reply:\n"
"SUFFICIENT\n"
"\n"
"If more information is needed, reply:\n"
"INSUFFICIENT\n"
"NEW_QUERY: <a refined search query to find the missing information>\n"
"\n"
"Reply:"
)
# ==================== Evaluation Classes ====================
class QuestionAnsweringEvaluator:
"""Evaluates question answering performance using ReMeLight."""
def __init__(
self,
memory_processor: ReMeLightMemoryProcessor,
eval_model_name: str = "qwen3-max",
):
self.memory_processor = memory_processor
self.eval_model_name = eval_model_name
async def evaluate_questions(
self,
reme: ReMeLight,
questions: list[dict],
user_name: str, # pylint: disable=unused-argument
uuid: str,
top_k: int = 20,
) -> list[dict]:
"""Evaluate all questions for one user."""
results = []
total = len(questions)
for qi, qa in enumerate(questions):
if qa.get("category") == 5:
continue
logger.info(f" QA {qi+1}/{total}: {qa['question'][:80]}...")
print(f" QA {qi+1}/{total}: {qa['question'][:60]}...", flush=True)
answer_dict, raw_results, duration_ms = await self.memory_processor.search_memory(
reme=reme,
query=qa["question"],
top_k=top_k,
)
system_answer = answer_dict.get("answer", "")
system_reasoning = answer_dict.get("reasoning", "")
# Evaluate LLM-generated answer
eval_result = await _evaluation_for_question(
reme=reme,
question=qa["question"],
golden_answer=qa["answer"],
generated_answer=system_answer,
_model_name=self.eval_model_name,
)
# Also evaluate raw search results
raw_memories_text = _format_search_results_for_prompt(raw_results)
eval_raw = await _evaluation_for_question(
reme=reme,
question=qa["question"],
golden_answer=qa["answer"],
generated_answer=raw_memories_text,
_model_name=self.eval_model_name,
)
qa_result = {
**qa,
"uuid": uuid,
"system_response": system_answer,
"system_reasoning": system_reasoning,
"retrieved_memories": raw_memories_text,
"raw_search_results": raw_results,
"search_duration_ms": duration_ms,
"result_type": eval_result.get("evaluation_result"),
"question_answering_reasoning": eval_result.get("reasoning", ""),
"original_result_type": eval_raw.get("evaluation_result"),
"original_question_answering_reasoning": eval_raw.get("reasoning", ""),
}
results.append(qa_result)
return results
class MetricsAggregator:
"""Aggregates evaluation metrics (same as vector-based eval)."""
@staticmethod
def _compute_single_metric(
qa_records: list[dict],
result_key: str,
) -> dict[str, Any]:
total = len(qa_records)
if total == 0:
return {
"correct_qa_ratio(all)": 0,
"correct_qa_ratio(valid)": 0,
"qa_valid_num": 0,
"qa_num": 0,
"category_1_accuracy": 0.0,
"category_2_accuracy": 0.0,
"category_3_accuracy": 0.0,
"category_4_accuracy": 0.0,
}
correct = 0
valid = 0
cat_correct = {1: 0, 2: 0, 3: 0, 4: 0}
cat_total = {1: 0, 2: 0, 3: 0, 4: 0}
for qa in qa_records:
cat = qa.get("category", 0)
if cat in cat_total:
cat_total[cat] += 1
result_type = qa.get(result_key, "")
if result_type is not None and cat in (1, 2, 3, 4):
valid += 1
if result_type is True:
correct += 1
if cat in cat_correct:
cat_correct[cat] += 1
metrics = {
"correct_qa_ratio(all)": correct / total,
"correct_qa_ratio(valid)": correct / valid if valid > 0 else 0,
"qa_valid_num": valid,
"qa_num": total,
}
for cat in (1, 2, 3, 4):
metrics[f"category_{cat}_accuracy"] = cat_correct[cat] / cat_total[cat] if cat_total[cat] > 0 else 0.0
return metrics
@staticmethod
def compute_qa_metrics(qa_records: list[dict]) -> dict[str, Any]:
"""Compute QA accuracy metrics grouped by evaluation type."""
return {
"with_llm_answer": MetricsAggregator._compute_single_metric(
qa_records,
"result_type",
),
"with_original_memories": MetricsAggregator._compute_single_metric(
qa_records,
"original_result_type",
),
}
@staticmethod
def compute_time_metrics(eval_results_file: str) -> dict[str, float]:
"""Compute time-based metrics from evaluation results."""
add_duration = 0
search_duration = 0
with open(eval_results_file, "r", encoding="utf-8") as f:
for line in f:
if not line.strip():
continue
user_data = json.loads(line)
for session in user_data.get("sessions", []):
add_duration += session.get("add_dialogue_duration_ms", 0)
for qa in user_data.get("evaluation_results", {}).get(
"question_answering_records",
[],
):
search_duration += qa.get("search_duration_ms", 0)
return {
"add_dialogue_duration_time": add_duration / 1000 / 60,
"search_memory_duration_time": search_duration / 1000 / 60,
"total_duration_time": (add_duration + search_duration) / 1000 / 60,
}
def parse_locomo_timestamp(timestamp_str: str) -> datetime | None:
"""Parse LoCoMo timestamp format: '6:07 pm on 13 January, 2023'."""
timestamp_str = timestamp_str.replace("\\s+", " ").strip()
if timestamp_str.lower() == "unknown" or not timestamp_str:
return None
try:
return datetime.strptime(timestamp_str, "%I:%M %p on %d %B, %Y")
except ValueError:
logger.warning(f"Failed to parse timestamp: {timestamp_str}")
return None
# ==================== Main Evaluator ====================
class LocomoReMeLightEvaluator:
"""Main evaluation orchestrator for ReMeLight on LoCoMo benchmark."""
def __init__(self, config: EvalConfig):
self.config = config
self.file_manager = FileManager(config.output_dir)
self.memory_processor = ReMeLightMemoryProcessor(
eval_model_name=config.eval_model_name,
index_wait_seconds=config.index_wait_seconds,
)
self.qa_evaluator = QuestionAnsweringEvaluator(
memory_processor=self.memory_processor,
eval_model_name=config.eval_model_name,
)
self.data_loader = DataLoader()
self._update_lock: asyncio.Lock | None = None
self._output_file: str | None = None
self._reme_instances: list[ReMeLight] = []
async def create_reme(self, working_dir: str) -> ReMeLight:
"""Create a ReMeLight instance with eval configuration."""
reme = ReMeLight(
working_dir=working_dir, # 每个用户独立目录
default_as_llm_config={ # 摘要用的 LLM
"model_name": self.config.reme_model_name,
"backend": "openai",
"stream": False,
},
default_embedding_model_config={ # embedding
"model_name": "text-embedding-v4",
"backend": "openai",
},
default_file_store_config={ # 开启混合搜索
"fts_enabled": True,
"vector_enabled": True,
},
enable_load_env=True,
)
await reme.start()
self._reme_instances.append(reme)
return reme
# 单个用户的完整评测流水线
async def process_user(self, user_data: dict) -> dict:
"""Process all sessions for one user conversation."""
conv = user_data["conversation"]
speaker_a = conv["speaker_a"]
speaker_b = conv["speaker_b"]
uuid = f"{speaker_a}_{speaker_b}"
user_name = [speaker_a, speaker_b]
user_file_name = f"{speaker_a}_{speaker_b}"
working_dir = str(
Path(self.config.output_dir) / "working_dirs" / user_file_name,
)
if self.config.resume:
if not Path(working_dir).exists():
logger.error(f"Resume mode: working_dir not found: {working_dir}")
return {"uuid": uuid, "user_name": user_file_name, "status": "no_working_dir"}
logger.info(f"Resume mode: reusing {working_dir}")
else:
if Path(working_dir).exists():
shutil.rmtree(working_dir)
# 初始化 ReMeLight,配 LLM + Embedding + FileWatcher
reme = await self.create_reme(working_dir)
session_num = 19 if uuid == "Caroline_Melanie" else int(len(conv) / 2 - 1)
time_interval = 60
logger.info(
f"Processing user {user_name}: {session_num} sessions, " f"working_dir={working_dir}",
)
if not self.config.resume:
# 循环 19 个 session: 调 summary_memory() 写 memory/*.md
for idx in range(session_num):
logger.info(
f" Session {idx + 1}/{session_num} for {user_file_name}",
)
session_data = {
"uuid": uuid,
"user_name": user_file_name,
"timestamp": conv[f"session_{idx + 1}_date_time"],
"session": conv[f"session_{idx + 1}"],
}
dialogue = conv[f"session_{idx + 1}"]
base_timestamp = parse_locomo_timestamp(session_data["timestamp"])
if base_timestamp is None:
base_timestamp = datetime(2023, 1, 1)
formatted_messages = self.data_loader.format_dialogue_messages(
dialogue,
speaker_a,
base_timestamp,
time_interval,
)
# 调 summary_memory() 写 memory/*.md
summary_text, duration_ms = await self.memory_processor.add_memories(
reme=reme,
messages=formatted_messages,
batch_size=self.config.batch_size,
)
session_data.update(
{
"dialogue": dialogue,
"summary_text": summary_text,
"add_dialogue_duration_ms": duration_ms,
},
)
self.file_manager.save_session(user_file_name, idx, session_data)
qas = user_data.get("qa", [])
# 逐条 QA:搜记忆 → LLM 回答 → LLM 裁判
qa_results = await self.qa_evaluator.evaluate_questions(
reme=reme,
questions=qas,
user_name=user_file_name,
uuid=uuid,
top_k=self.config.top_k,
)
question_file = self.file_manager.get_user_dir(user_file_name) / "questions.json"
with open(question_file, "w", encoding="utf-8") as f:
json.dump({"question_answering_records": qa_results}, f, ensure_ascii=False, indent=2)
await reme.close()
return {"uuid": uuid, "user_name": user_file_name, "status": "ok"}
# 整个评测的调度中心
async def run_evaluation(self):
"""Run the complete evaluation pipeline."""
start_time = time.time()
all_users = self.data_loader.load_json(self.config.data_path)
users_to_process = all_users[: self.config.user_num]
print("\n" + "=" * 80)
print("LOCOMO EVALUATION - ReMeLight (FILE-BASED)")
print(f"Users: {len(users_to_process)} | Concurrency: {self.config.max_concurrency}")
print(f"Output: {self.config.output_dir}")
print("=" * 80 + "\n")
self._output_file = os.path.join(self.config.output_dir, "eval_results.jsonl")
self._update_lock = asyncio.Lock()
semaphore = asyncio.Semaphore(self.config.max_concurrency)
async def process_with_cache(idx: int, user_data: dict):
async with semaphore:
user_name = f"{user_data['conversation']['speaker_a']}_" f"{user_data['conversation']['speaker_b']}"
if self.file_manager.user_has_cache(user_name):
logger.info(f"[{idx}/{len(users_to_process)}] Skipping {user_name} (cached)")
return {"user_name": user_name, "status": "cached"}
logger.info(f"[{idx}/{len(users_to_process)}] Processing {user_name}...")
result = await self.process_user(user_data)
logger.info(f"[{idx}/{len(users_to_process)}] Completed {user_name}")
await self._trigger_update()
return result
tasks = [process_with_cache(idx, user) for idx, user in enumerate(users_to_process, 1)]
await asyncio.gather(*tasks, return_exceptions=True)
elapsed = time.time() - start_time
print(f"\nProcessing completed in {elapsed:.2f}s")
print(f"Results: {self._output_file}\n")
await self._aggregate_and_report(self._output_file)
async def _trigger_update(self):
if self._update_lock is None or self._output_file is None:
return
async with self._update_lock:
self.file_manager.combine_results(self._output_file)
self._update_statistics(self._output_file)
def _update_statistics(self, results_file: str):
if not os.path.exists(results_file):
return
qa_records = []
try:
with open(results_file, "r", encoding="utf-8") as f:
for line in f:
if not line.strip():
continue
user_data = json.loads(line)
eval_results = user_data.get("evaluation_results", {})
qa_records.extend(
eval_results.get("question_answering_records", []),
)
except (json.JSONDecodeError, KeyError):
return
if not qa_records:
return
qa_metrics = MetricsAggregator.compute_qa_metrics(qa_records)
time_metrics = MetricsAggregator.compute_time_metrics(results_file)
final_results = {
"overall_score": {
"question_answering": qa_metrics,
"time_consuming": time_metrics,
},
}
report_file = os.path.join(self.config.output_dir, "eval_statistics.json")
with open(report_file, "w", encoding="utf-8") as f:
json.dump(final_results, f, ensure_ascii=False, indent=4)
async def _aggregate_and_report(self, results_file: str):
print("=" * 80)
print("AGGREGATING METRICS")
print("=" * 80 + "\n")
qa_records = []
with open(results_file, "r", encoding="utf-8") as f:
for line in f:
if not line.strip():
continue
user_data = json.loads(line)
eval_results = user_data.get("evaluation_results", {})
qa_records.extend(
eval_results.get("question_answering_records", []),
)
qa_metrics = MetricsAggregator.compute_qa_metrics(qa_records)
time_metrics = MetricsAggregator.compute_time_metrics(results_file)
final_results = {
"overall_score": {
"question_answering": qa_metrics,
"time_consuming": time_metrics,
},
"question_answering_records": qa_records,
}
report_file = os.path.join(self.config.output_dir, "eval_statistics.json")
with open(report_file, "w", encoding="utf-8") as f:
json.dump(final_results, f, ensure_ascii=False, indent=4)
self._print_summary(qa_metrics, time_metrics)
def _print_summary(self, qa_metrics: dict, time_metrics: dict):
print("=" * 80)
print("EVALUATION SUMMARY - ReMeLight (FILE-BASED)")
print("=" * 80 + "\n")
llm_metrics = qa_metrics["with_llm_answer"]
print("Question Answering (with LLM answer):")
print(f" Correct (all): {llm_metrics['correct_qa_ratio(all)']:.4f}")
print(f" Correct (valid): {llm_metrics['correct_qa_ratio(valid)']:.4f}")
print(f" Valid/Total: {llm_metrics['qa_valid_num']}/{llm_metrics['qa_num']}")
print(f" Category 1 Accuracy: {llm_metrics['category_1_accuracy']:.4f}")
print(f" Category 2 Accuracy: {llm_metrics['category_2_accuracy']:.4f}")
print(f" Category 3 Accuracy: {llm_metrics['category_3_accuracy']:.4f}")
print(f" Category 4 Accuracy: {llm_metrics['category_4_accuracy']:.4f}")
orig_metrics = qa_metrics["with_original_memories"]
print("\nQuestion Answering (with original memories):")
print(f" Correct (all): {orig_metrics['correct_qa_ratio(all)']:.4f}")
print(f" Correct (valid): {orig_metrics['correct_qa_ratio(valid)']:.4f}")
print(f" Valid/Total: {orig_metrics['qa_valid_num']}/{orig_metrics['qa_num']}")
print("\nTime Metrics:")
print(f" Memory Addition: {time_metrics['add_dialogue_duration_time']:.2f} min")
print(f" Memory Search: {time_metrics['search_memory_duration_time']:.2f} min")
print(f" Total: {time_metrics['total_duration_time']:.2f} min")
print("\n" + "=" * 80)
# ==================== Main ====================
async def main_async(
data_path: str,
top_k: int = 20,
user_num: int = 1,
max_concurrency: int = 2,
reme_model_name: str = "qwen-flash",
eval_model_name: str = "qwen3-max",
output_dir: str = "bench_results/reme_light",
index_wait_seconds: int = 5,
resume: bool = False,
):
"""Async entry point for the LoCoMo ReMeLight evaluation."""
config = EvalConfig(
data_path=data_path,
top_k=top_k,
user_num=user_num,
max_concurrency=max_concurrency,
reme_model_name=reme_model_name,
eval_model_name=eval_model_name,
output_dir=output_dir,
index_wait_seconds=index_wait_seconds,
resume=resume,
)
evaluator = LocomoReMeLightEvaluator(config)
await evaluator.run_evaluation()
def main(
data_path: str,
top_k: int = 20,
user_num: int = 1,
max_concurrency: int = 2,
reme_model_name: str = "qwen-flash",
eval_model_name: str = "qwen3-max",
output_dir: str = "bench_results/reme_light",
index_wait_seconds: int = 5,
resume: bool = False,
):
"""Entry point for the LoCoMo ReMeLight evaluation."""
asyncio.run(
main_async(
data_path=data_path,
top_k=top_k,
user_num=user_num,
max_concurrency=max_concurrency,
reme_model_name=reme_model_name,
eval_model_name=eval_model_name,
output_dir=output_dir,
index_wait_seconds=index_wait_seconds,
resume=resume,
),
)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(
description="ReMeLight (file-based) evaluation on LoCoMo benchmark",
)
parser.add_argument(
"--data_path",
type=str,
default="benchmark/locomo/data/locomo10.json",
help="Path to LoCoMo data file (default: benchmark/locomo/data/locomo10.json)",
)
parser.add_argument(
"--top_k",
type=int,
default=20,
help="Max memory search results (default: 20)",
)
parser.add_argument(
"--user_num",
type=int,
default=1,
help="Number of users to evaluate",
)
parser.add_argument(
"--max_concurrency",
type=int,
default=2,
help="Max concurrent users",
)
parser.add_argument(
"--reme_model_name",
type=str,
default="qwen-flash",
help="Model for ReMeLight summarization",
)
parser.add_argument(
"--eval_model_name",
type=str,
default="qwen3-max",
help="Model for LLM-as-Judge evaluation",
)
parser.add_argument(
"--output_dir",
type=str,
default="bench_results/reme_light",
help="Output directory for results",
)
parser.add_argument(
"--index_wait_seconds",
type=int,
default=5,
help="Seconds to wait for FileWatcher re-indexing after summarization",
)
parser.add_argument(
"--resume",
action="store_true",
default=False,
help="Skip summarization, go straight to QA (reuse existing working_dir)",
)
args = parser.parse_args()
print(f"Args: {args}")
# Check if data file exists, print helpful instructions if not
if not os.path.exists(args.data_path):
print(f"\n Data file not found: {args.data_path}\n")
print("To download the LoCoMo dataset:")
print(" mkdir -p benchmark/locomo/data")
print(" git clone https://github.com/luyanhexay/locomo-dynamemory.git /tmp/locomo-dynamemory")
print(" cp /tmp/locomo-dynamemory/data/locomo10.json benchmark/locomo/data/\n")
print("Or specify a custom path:")
print(" python benchmark/locomo/eval_reme_light.py --data_path /path/to/locomo10.json\n")
import sys
sys.exit(1)
main(
data_path=args.data_path,
top_k=args.top_k,
user_num=args.user_num,
max_concurrency=args.max_concurrency,
reme_model_name=args.reme_model_name,
eval_model_name=args.eval_model_name,
output_dir=args.output_dir,
index_wait_seconds=args.index_wait_seconds,
resume=args.resume,
)