Natural Memory NM2.1: 记忆路由器分叉、数据集缺陷修复与全轴评测证据

- 引入 MemoryRouterXL 与 v5/v6 流式多线程训练/编码管线
- 修复 prepare_memory_router_dataset 候选池重建缺陷(mega 家族 3568x 加速,输出逐字节相同)
- 修复 v5 被破坏的拒答与多跳标签(train 未知样本 319 -> 16319,multi_hop 平均正例 1.00 -> 2.00)
- 同存储预算下 V2-128 v6 逐轴 22/22 通过:Top-1 41.12% -> 94.62%,未知拒答 0.00% -> 100.00%
- 记录三条被实测推翻的显然优化(logits_to_keep=1 反而慢 55%、XL 容量未带来收益)
- 记忆手术跨架构可移植性 14/14,读写关闭时与原生模型逐位相同
This commit is contained in:
WpyQwq
2026-09-19 11:11:31 +08:00
commit 643e22ecb9
484 changed files with 306821 additions and 0 deletions
+203
View File
@@ -0,0 +1,203 @@
"""Low-pressure end-to-end test for the embedded Natural Memory v2 path.
This benchmark intentionally uses only the model package's third safetensors
memory shard. It does not create SQLite files or exercise disk paging. The
long-context cases lower the temporary KV budget so the test measures the
model-owned archive/read path without asking a 12 GiB GPU to hold a huge KV.
"""
from __future__ import annotations
import argparse
import gc
import json
import random
import time
from pathlib import Path
from typing import Any
import torch
from .qwen_integration import load_qwen_dynamic, load_tokenizer
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--model-path",
default=r"H:\Memory\V2_dpskw\qwen3_5_4b_natural_memory_v2",
)
parser.add_argument("--lengths", default="4096,8192,16384,32768")
parser.add_argument("--kv-budget", type=int, default=2048)
parser.add_argument("--chunk-tokens", type=int, default=1024)
parser.add_argument("--max-new-tokens", type=int, default=8)
parser.add_argument("--no-4bit", action="store_true")
parser.add_argument(
"--output",
default=r"H:\Memory\V2_dpskw\embedded_memory_v2_long_benchmark.json",
)
return parser.parse_args()
def build_prompt(tokenizer: Any, target_tokens: int, seed: int) -> tuple[str, str, int]:
rng = random.Random(seed + target_tokens)
answer = f"EMBEDDED-LONG-{target_tokens}-{rng.randrange(100000, 999999)}"
needle = f"长期记忆锚点:唯一编号是 {answer}。"
filler = (
"这是长文本记忆压力测试中的普通背景段落,包含项目说明、日期、日志和无关备注。"
"这些内容不是问题答案,读取时应保留原文但忽略干扰。"
)
chunks: list[str] = []
while len(tokenizer(" ".join(chunks + [filler, needle]), add_special_tokens=False)["input_ids"]) < target_tokens:
chunks.append(filler)
# Keep the needle safely inside the archived prefix even in the smallest
# case, so a pass must come from memory rather than the retained window.
pivot = max(1, len(chunks) // 3)
material = " ".join(chunks[:pivot] + [needle] + chunks[pivot:])
prompt = (
"请阅读下面的长材料,回答末尾问题,只输出编号,不要解释。\n"
"---开始材料---\n"
f"{material}\n"
"---结束材料---\n"
"问题:长期记忆锚点的唯一编号是什么?"
)
prompt_tokens = len(tokenizer(prompt, add_special_tokens=False)["input_ids"])
return prompt, answer, prompt_tokens
def chat_inputs(tokenizer: Any, text: str, device: torch.device) -> dict[str, torch.Tensor]:
encoded = tokenizer.apply_chat_template(
[{"role": "user", "content": text}],
tokenize=True,
add_generation_prompt=True,
return_tensors="pt",
return_dict=True,
enable_thinking=False,
)
return {
key: value.to(device)
for key, value in encoded.items()
if isinstance(value, torch.Tensor)
}
def run_case(model: Any, tokenizer: Any, target_tokens: int, args: argparse.Namespace) -> dict[str, Any]:
device = model._find_layer_device()
model.reset_memory(batch_size=1, device=device)
assert model.memory_os_v2 is not None
model.memory_os_v2.kv_budget.max_tokens = int(args.kv_budget)
prompt, answer, prompt_tokens = build_prompt(tokenizer, target_tokens, 20260904)
encoded = chat_inputs(tokenizer, prompt, device)
query_text = "长期记忆锚点的唯一编号"
query = tokenizer(query_text, add_special_tokens=False, return_tensors="pt")
query_ids = query["input_ids"].to(device)
query_mask = query.get("attention_mask")
if query_mask is None:
query_mask = torch.ones_like(query_ids)
query_mask = query_mask.to(device)
started = time.perf_counter()
with torch.inference_mode():
output = model.generate(
**encoded,
max_new_tokens=args.max_new_tokens,
do_sample=False,
use_cache=True,
update_memory=False,
pad_token_id=tokenizer.pad_token_id,
memory_query_input_ids=query_ids,
memory_query_attention_mask=query_mask,
memory_query_text=query_text,
)
if device.type == "cuda":
torch.cuda.synchronize(device)
elapsed = time.perf_counter() - started
response_ids = output[0, encoded["input_ids"].shape[1] :]
response = tokenizer.decode(response_ids.detach().cpu().tolist(), skip_special_tokens=True).strip()
stats = model.memory_v2_stats()
records = model.memory_os_v2.bank.records
archived_text_hit = any(
answer in tokenizer.decode(record.token_ids.tolist(), skip_special_tokens=True)
for record in records.values()
if record.memory_type == "context_chunk" and isinstance(record.token_ids, torch.Tensor)
)
query_key = model._encode_model_key(query_ids, query_mask)[0]
retrieved, decision = model.read_hierarchical_memory(
query_key,
query_text=query_text,
query_token_ids=query_ids[0],
top_k_pages=model.memory_config.memory_top_k_pages,
top_k_records=model.memory_config.memory_top_k_records,
max_hops=model.memory_config.memory_max_hops,
)
retrieved_hit = any(
answer in tokenizer.decode(record.token_ids.tolist(), skip_special_tokens=True)
for record in retrieved
if isinstance(record.token_ids, torch.Tensor)
)
return {
"target_tokens": target_tokens,
"prompt_tokens": prompt_tokens,
"kv_budget_tokens": args.kv_budget,
"chunk_tokens": args.chunk_tokens,
"response": response,
"expected": answer,
"generation_hit": answer in response,
"archived_text_hit": archived_text_hit,
"retrieved_text_hit": retrieved_hit,
"retrieved_records": len(retrieved),
"router_stop_reason": decision.stop_reason,
"router_hop_count": decision.hop_count,
"seconds": elapsed,
"records": stats.get("records", 0),
"pages": stats.get("pages", 0),
"gpu_cache_records": stats.get("gpu_cache_records", 0),
"gpu_cache_tokens": stats.get("gpu_cache_tokens", 0),
"gpu_cache_device": stats.get("gpu_cache_device", "none"),
"passed": bool(archived_text_hit and retrieved_hit),
}
def main() -> None:
args = parse_args()
lengths = [int(item.strip()) for item in args.lengths.split(",") if item.strip()]
tokenizer = load_tokenizer(args.model_path)
model = load_qwen_dynamic(args.model_path, load_in_4bit=not args.no_4bit)
model.eval()
model.memory_config.context_chunk_tokens = int(args.chunk_tokens)
report: dict[str, Any] = {
"benchmark": "Natural Memory v2 embedded third-shard long-memory test",
"storage_mode": model.memory_config.memory_storage_mode,
"tier_store_enabled": bool(model.memory_os_v2 and model.memory_os_v2.bank.tier_store is not None),
"model_path": str(Path(args.model_path).resolve()),
"lengths": lengths,
"quantization": "4bit_nf4" if not args.no_4bit else "none",
"kv_budget_tokens": args.kv_budget,
"chunk_tokens": args.chunk_tokens,
"rows": [],
}
try:
if report["storage_mode"] != "embedded" or report["tier_store_enabled"]:
raise RuntimeError("embedded benchmark requires memory_storage_mode=embedded and no tier store")
for target_tokens in lengths:
row = run_case(model, tokenizer, target_tokens, args)
report["rows"].append(row)
print(json.dumps(row, ensure_ascii=False))
finally:
model.reset_memory(batch_size=1, device=model._find_layer_device())
model.close_memory_storage()
del model
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
report["passed_cases"] = sum(bool(row["passed"]) for row in report["rows"])
report["total_cases"] = len(report["rows"])
report["all_passed"] = report["passed_cases"] == report["total_cases"]
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(report, ensure_ascii=False, indent=2))
print(f"saved={output}")
if __name__ == "__main__":
main()