第 35 章

第 35 章:Jev-Mem 缩放实验

第 35 章:Jev-Mem 缩放实验

本文整理自 Datawhale 开源项目 datawhalechina/jev-cookbook(CC BY-NC-SA 4.0),源文件:main/08_前沿研究/jev_mem/jev_mem_scaling.ipynb。

上一本 notebook(jev_mem_walkthrough.ipynb)在 16 轮对话上比了四个臂。这一本把问题换成长程:同一组 10 个目标事实、同一组 10 道题,把干扰对话从 48 轮拉到 96、192 再到 384 轮(4/8/16/32 个会话,跨 14 个多月),看每个臂的质量、延迟、prompt tokens 怎么缩放,算清时间和 token 的损失对比。另附真 LoCoMo 样本(300+ 轮英文对话)的 Jev-Mem 实测。最后回答一个问题:为什么全量上下文准确度这么高,它的高是真本事还是假优势。

实验设计要点(控制变量):

  • 题目不变:10 道题在三个规模下完全相同,分数差异全部来自"干扰对话变多";
  • 事实不变:10 个目标事实按相同比例位置植入(第 i 个事实在对话的 (i+0.5)/10 处),所以短对话里它们挨得近,长对话里散得开;
  • 干扰递增:填充闲聊 + 定向干扰句(别人的过敏、别人的 PMP、别人的燃油车),规模越大干扰越多;
  • 全部真 API 实测:真 Jev 决策、DeepSeek 答题+评审、硅基流动 Qwen3-Embedding。
import os, sys, json, time, re, random
from pathlib import Path

REPO = Path("../Jev-Mem").resolve()
sys.path.insert(0, str(REPO))
os.chdir(REPO)
from dotenv import load_dotenv
load_dotenv(REPO / ".env")
if os.getenv("DEEPSEEK_API_KEY") and not os.getenv("OPENAI_API_KEY"):
    os.environ["OPENAI_API_KEY"] = os.environ["DEEPSEEK_API_KEY"]
os.environ.setdefault("OPENAI_BASE_URL", "https://api.deepseek.com")

ANSWER_MODEL = os.getenv("ANSWER_MODEL", "deepseek-chat")
HAS_DEEPSEEK = bool(os.getenv("OPENAI_API_KEY"))
HAS_TYPESAFE = bool(os.getenv("TYPESAFE_API_KEY"))
print(f"System-Two: {ANSWER_MODEL} ({'可用' if HAS_DEEPSEEK else '无 key'}) | "
      f"System-One: {'真 Jev' if HAS_TYPESAFE else '兜底'}")
System-Two: deepseek-chat (可用) | System-One: 真 Jev
# encoder:硅基流动 Qwen3-Embedding,无 key 回落本地 MiniLM
import numpy as np

class LocalMiniLMEncoder:
    # 本地兜底:多语言 MiniLM
    def __init__(self):
        from sentence_transformers import SentenceTransformer
        self.model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")
        getter = getattr(self.model, "get_sentence_embedding_dimension", None) or self.model.get_embedding_dimension
        self.dimension = getter()
    def encode(self, texts):
        return self.model.encode([texts] if isinstance(texts, str) else texts)

class SiliconFlowEncoder:
    # 硅基流动 Qwen3-Embedding-0.6B:OpenAI 兼容 /embeddings,1024 维
    def __init__(self, model="Qwen/Qwen3-Embedding-0.6B", batch_size=16):
        from openai import OpenAI
        self.client = OpenAI(api_key=os.environ["SILICONFLOW_API_KEY"],
                             base_url="https://api.siliconflow.cn/v1")
        self.model, self.batch_size = model, batch_size
        self.dimension = len(self.encode(["维度探测"])[0])
    def encode(self, texts):
        if isinstance(texts, str):
            texts = [texts]
        vecs = []
        for i in range(0, len(texts), self.batch_size):
            resp = self.client.embeddings.create(model=self.model, input=texts[i:i + self.batch_size])
            vecs.extend(d.embedding for d in sorted(resp.data, key=lambda d: d.index))
        return np.asarray(vecs, dtype=np.float32)

encoder = SiliconFlowEncoder() if os.getenv("SILICONFLOW_API_KEY") else LocalMiniLMEncoder()
if not HAS_DEEPSEEK:
    raise SystemExit("需要 DEEPSEEK_API_KEY 才能跑长程实验")
from utils.memory_layer import LLMController
llm = LLMController(backend="openai", model=ANSWER_MODEL, api_key=os.environ["OPENAI_API_KEY"]).llm
print(f"encoder: {type(encoder).__name__} ({encoder.dimension} 维) | LLM 就绪")
/Volumes/拓展/workspace/jev cookbook/Jev-Mem/.venv/lib/python3.11/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html
  from .autonotebook import tqdm as notebook_tqdm
encoder: SiliconFlowEncoder (1024 维) | LLM 就绪

1. 长程对话生成器:固定事实与题目,缩放干扰

10 个目标事实(过敏、健身、套餐、充电桩、尿酸、PMP、咖啡店、私教、姐姐买房、公积金)按等比位置植入;填充对话来自闲聊池,其中混入定向干扰句——问"用户对海鲜过敏吗"时对话里躺着"同事小李对虾过敏",问"燃油车"时躺着"表哥的燃油车限行"。这些干扰专门考验检索臂会不会抓错证据。

import random
random.seed(42)

FACTS = [
    "对了,我对花生过敏,你们推荐零食的时候要注意",
    "我决定每周去三次健身房练力量",
    "我把手机套餐换成了 199 元的畅游套餐",
    "家里换了新能源车,充电桩装在地下车库 B2",
    "我上个月体检发现尿酸偏高,医生让我少喝啤酒",
    "我打算考 PMP 项目管理认证,明年 3 月考试",
    "我和老周合伙开的咖啡店预计十月开业",
    "我请了私教,每周二和周五晚上上课",
    "我姐姐在杭州买了房子,明年交房",
    "我把公积金从 A 银行转到了 B 银行,图利率优惠",
]

QUESTIONS = [
    {"q": "用户对什么食物过敏?", "gold": "对花生过敏", "cat": "单跳"},
    {"q": "用户的健身计划是什么?", "gold": "每周去三次健身房练力量", "cat": "单跳"},
    {"q": "用户手机套餐月费是多少?", "gold": "199 元", "cat": "单跳"},
    {"q": "用户的充电桩装在哪里?", "gold": "地下车库 B2", "cat": "单跳"},
    {"q": "用户在健康和饮食方面要注意哪两件事?", "gold": "对花生过敏;尿酸偏高要少喝啤酒", "cat": "多跳"},
    {"q": "用户考 PMP 和咖啡店开业分别定在什么时候?", "gold": "PMP 明年 3 月考试;咖啡店预计十月开业", "cat": "多跳"},
    {"q": "发现尿酸偏高和决定考 PMP,哪件事先发生?", "gold": "发现尿酸偏高先发生", "cat": "时间"},
    {"q": "用户的私教课安排在哪两个晚上?", "gold": "每周二和周五晚上", "cat": "时间"},
    {"q": "用户对海鲜过敏吗?", "gold": "记忆中没有;对虾过敏的是同事小李,不是用户", "cat": "对抗"},
    {"q": "用户现在开的是燃油车吗?", "gold": "记忆中没有;用户提到换了新能源车,没提过燃油车", "cat": "对抗"},
]

ACKS = ["好的,记下了", "收到", "没问题", "明白", "好的呢", "了解", "记下来了", "OK"]
FILLER = [
    "这周降温了,早上骑车上班冻得手疼,我把厚外套翻出来了,晚上回家还得给电瓶车充电",
    "今天地铁又故障,在隧道里停了二十分钟,全车人都急死了,到公司已经迟到被记了一笔",
    "中午吃了公司楼下的麻辣烫,老板娘认得我了,多给我加了一勺酸萝卜,吃完还打包了点汤",
    "周末看了场科幻电影,特效确实好,就是剧情后半段有点崩,出来和朋友吃了顿火锅聊了聊",
    "最近在追一部讲空间站建设的纪录片,每周四更新一集,摄影拍得特别震撼,已经追到第六集",
    "今天连着开了三个会,产品需求和运营对不齐,散会又被拉去讨论一个线上问题,头都大了",
    "晚上打算去试试小区门口新开的面馆,看点评说他家牛肉面是现熬的汤,顺便散散步消食",
    "项目评审终于通过了,改了三版方案,客户签了字,请小组同事喝了下午茶,大家都很开心",
    "这几天睡得不太好,躺下就开始想工作的事,凌晨两点才睡着,早上起来脑子昏昏沉沉的",
    "下班顺路去菜市场买了点水果,应季的橘子特别甜,摊主还教我怎么挑,说明后价格还要降",
    "家里空气净化器滤芯该换了,研究了半天型号,原来不同机型滤芯不通用,只能重新买原装",
    "早上晨跑遇到邻居遛狗,那只金毛特别亲人,聊了几句才知道他也在这个行业做后端开发",
    "中午和同事拼了个外卖,平台满减算下来比单点便宜十几块,现在各家平台都在卷补贴",
    "晚上帮我妈调了一下电视,智能电视的操作逻辑对老人太不友好了,遥控器塞了一堆按钮",
]
DISTRACTORS = [
    "我同事小李对虾子严重过敏,上周聚餐点了海鲜大虾,他碰都没敢碰,我们只能帮他吃了",
    "我表哥那台燃油车限行了,天天跟我吐槽油费贵还摇不到号,问我新能源值不值得换",
    "隔壁工位小张也在备考 PMP,他是想跳槽去外企才考的,每天午休都在刷题,挺拼的",
    "我们部门团建去了杭州,西湖边人山人海,断桥上挤得走不动,灵隐寺倒是很清净",
    "楼下那家咖啡店又涨价了,美式从十五涨到十八,老板说豆子进价涨了,我也没办法",
    "同事推荐我办 B 银行的信用卡,说新户有观影券和加油返现,我还在犹豫要不要办",
]

from datetime import datetime, timedelta
BASE = datetime(2026, 6, 1, 9, 0, 0)

def make_conversation(total_turns):
    # 12 轮一个会话,日期每会话隔 14 天;事实按等比位置植入
    turns = []
    fact_slots = [int((i + 0.5) / len(FACTS) * total_turns) // 2 * 2 for i in range(len(FACTS))]
    facts_at = set(fact_slots)
    dist_at = set()
    filler_pool = []
    for t in range(total_turns // 2):  # 每个 slot = 用户一句 + 助手一句
        slot = t * 2
        session_idx = slot // 12
        ts = (BASE + timedelta(days=14 * session_idx, hours=t % 8)).isoformat()
        if slot in facts_at:
            user_line = FACTS[fact_slots.index(slot)]
        elif slot % 24 == 10 and session_idx not in dist_at and DISTRACTORS:
            user_line = DISTRACTORS[session_idx % len(DISTRACTORS)]
        else:
            if not filler_pool:
                filler_pool = list(FILLER)
                random.Random(1000 + session_idx).shuffle(filler_pool)
            user_line = filler_pool.pop()
        turns.append({"role": "user", "content": user_line, "ts": ts})
        turns.append({"role": "assistant", "content": ACKS[t % len(ACKS)], "ts": ts})
    return turns

SCALES = [48, 96, 192, 384]
CONVS = {n: make_conversation(n) for n in SCALES}
print("规模    会话数  用户轮数  字符数   干扰句")
for n, conv in CONVS.items():
    n_dist = sum(1 for t in conv if t["role"] == "user" and t["content"] in DISTRACTORS)
    print(f"{n:>4}   {n // 12:>4}   {sum(1 for t in conv if t['role']=='user'):>6}   "
          f"{sum(len(t['content']) for t in conv):>6}   {n_dist:>4}")
missing = {n: [f for f in FACTS if f not in [t['content'] for t in conv]]
           for n, conv in CONVS.items()}
print("事实植入完整性:", {n: len(m) for n, m in missing.items()}, "(0 = 全部植入)")
规模    会话数  用户轮数  字符数   干扰句
  48      4       24      819      2
  96      8       48     1835      4
 192     16       96     3871      8
 384     32      192     7943     16
事实植入完整性: {48: 0, 96: 0, 192: 0, 384: 0} (0 = 全部植入)

2. 四个臂(与走读实验同款)

A 滑动窗口(最近 6 条)· B 全上下文 · C 朴素 RAG(向量 Top-3)· D Jev-Mem。每个规模独立建一份 D 记忆,量出建图时间随规模的增长。

from memory.trg_memory import TemporalResonanceGraphMemory
from memory.vector_db import NumpyVectorDB
from memory.memory_builder import MemoryBuilder
from memory.jev_mem_config import JevMemConfig
from memory.jev_client import JevClient
from memory.query_engine import QueryEngine
from jev_mem import JevMemSystem

def build_jev_mem_system(turns, cache_dir):
    # 与走读实验相同的装配:真 Jev 决策 + 多语言 encoder
    trg = TemporalResonanceGraphMemory(vector_db=NumpyVectorDB(encoder.dimension),
                                       encoder=encoder, llm_backend=None)
    config = JevMemConfig(write_enabled=True, read_enabled=True,
                          audit_path=str(Path(cache_dir) / "decisions.jsonl"))
    builder = MemoryBuilder(str(cache_dir), llm_model=ANSWER_MODEL,
                            trg_memory=trg, jev_config=config)
    sysd = JevMemSystem.__new__(JevMemSystem)
    sysd.cache_dir = Path(cache_dir)
    sysd.memory_builder = builder
    sysd.trg_memory = builder.trg
    sysd.graph_db = builder.trg.graph_db
    sysd.vector_db = builder.trg.vector_db
    sysd.llm_controller = builder.llm_controller
    sysd.answer_formatter = builder.answer_formatter
    sysd.query_engine = QueryEngine(builder.trg, builder.node_index,
                                    jev_config=builder.jev_config, jev_client=builder.jev)
    return sysd

def llm_answer(prompt):
    return llm.get_completion(prompt, response_format={"type": "text"}, temperature=0.0)

def timed(fn, *a):
    t0 = time.perf_counter()
    out = fn(*a)
    return out, time.perf_counter() - t0

def make_arms(conv):
    # 返回 {臂名: 问题 -> 答案};A/B/C 与规模耦合,D 由外部建好传入
    full = chr(10).join(f"[{t['ts'][:10]}] {t['role']}: {t['content']}" for t in conv)
    window = chr(10).join(f"{t['role']}: {t['content']}" for t in conv[-6:])
    texts = [f"[{t['ts'][:10]}] {t['content']}" for t in conv if t["role"] == "user"]
    vecs = encoder.encode(texts)
    def arm_sliding(q):
        return llm_answer(f"根据以下最近对话回答问题。对话:" + chr(10) + window + chr(10) +
                          "问题:" + q + chr(10) + "只依据对话回答;对话里没有的信息就回答\"记忆中没有\"。")
    def arm_full(q):
        return llm_answer(f"根据以下完整对话历史回答问题。对话:" + chr(10) + full + chr(10) +
                          "问题:" + q + chr(10) + "只依据对话回答;对话里没有的信息就回答\"记忆中没有\"。")
    def arm_naive_rag(q):
        qv = encoder.encode(q).reshape(-1)
        sims = vecs @ qv / (np.linalg.norm(vecs, axis=1) * np.linalg.norm(qv) + 1e-9)
        top = sorted(np.argsort(-sims)[:3])
        ctx = chr(10).join(texts[i] for i in top)
        return llm_answer(f"根据以下检索到的记忆片段回答问题。" + chr(10) + ctx + chr(10) +
                          "问题:" + q + chr(10) + "只依据片段回答;片段不足以回答就回答\"记忆中没有\"。")
    return {"A 滑动窗口": arm_sliding, "B 全上下文": arm_full, "C 朴素RAG": arm_naive_rag}

print("臂工厂就绪")
臂工厂就绪
# ============ 逐规模:建 D 记忆(计时)→ 四臂 × 10 题回答 ============
SCALE_STATS, RAW = {}, {}
for n in SCALES:
    conv = CONVS[n]
    cache = f"/tmp/jev_mem_scale/{n}"
    os.makedirs(cache, exist_ok=True)
    sysd = build_jev_mem_system(conv, cache)
    t0 = time.perf_counter()
    res = sysd.build_memory_from_conversation(
        [{"content": t["content"], "timestamp": t["ts"], "metadata": {}} for t in conv])
    build_s = time.perf_counter() - t0
    jev_calls = sum(1 for ln in open(Path(cache) / "decisions.jsonl")
                    if '"event": "jev_decision"' in ln)
    SCALE_STATS[n] = {"build_s": round(build_s, 1), "admitted": res["admitted"],
                      "nodes": len(sysd.graph_db.nodes), "links": len(sysd.graph_db.links),
                      "jev_calls": jev_calls}
    print(f"[{n} 轮] 建图 {build_s:.1f}s,{res['admitted']} 条入图,"
          f"{SCALE_STATS[n]['nodes']} 节点 / {SCALE_STATS[n]['links']} 边,Jev 决策 {jev_calls} 次")

    d_answer = lambda q, s=sysd: s.query(q)
    arms = make_arms(conv)
    arms["D Jev-Mem"] = d_answer
    RAW[n] = {name: [] for name in arms}
    for name, fn in arms.items():
        for item in QUESTIONS:
            ans, lat = timed(fn, item["q"])
            usage = llm.token_usage["total_tokens"]
            RAW[n][name].append({"q": item["q"], "cat": item["cat"], "gold": item["gold"],
                                 "answer": ans, "latency_s": round(lat, 3),
                                 "prompt_tokens": usage[-1] if usage else None})
        print(f"  [{n} 轮] {name} 完成")
json.dump({str(n): RAW[n] for n in SCALES}, open("/tmp/jev_mem_scale/raw.json", "w"),
          ensure_ascii=False, indent=1)
json.dump({str(k): v for k, v in SCALE_STATS.items()}, open("/tmp/jev_mem_scale/stats.json", "w"),
          ensure_ascii=False, indent=1)
print("全部回答完成,原始数据已存 /tmp/jev_mem_scale/")
[48 轮] 建图 28.3s,48 条入图,48 节点 / 351 边,Jev 决策 95 次
  [48 轮] A 滑动窗口 完成
  [48 轮] B 全上下文 完成
  [48 轮] C 朴素RAG 完成
  [48 轮] D Jev-Mem 完成
[96 轮] 建图 50.1s,96 条入图,96 节点 / 732 边,Jev 决策 191 次
  [96 轮] A 滑动窗口 完成
  [96 轮] B 全上下文 完成
  [96 轮] C 朴素RAG 完成
  [96 轮] D Jev-Mem 完成
[192 轮] 建图 89.6s,192 条入图,192 节点 / 1600 边,Jev 决策 383 次
  [192 轮] A 滑动窗口 完成
  [192 轮] B 全上下文 完成
  [192 轮] C 朴素RAG 完成
  [192 轮] D Jev-Mem 完成
[384 轮] 建图 173.6s,384 条入图,384 节点 / 3692 边,Jev 决策 767 次
  [384 轮] A 滑动窗口 完成
  [384 轮] B 全上下文 完成
  [384 轮] C 朴素RAG 完成
  [384 轮] D Jev-Mem 完成
全部回答完成,原始数据已存 /tmp/jev_mem_scale/
# ============ 评审(DeepSeek judge,与走读实验同量表) ============
JUDGE_PROMPT = """请根据参考答案对生成回答打 0.0~1.0 的语义正确分。
评分标准:1.0 完全正确;0.8 基本正确(细节措辞可不同);0.6 部分正确但缺关键信息;
0.4 沾边但漏了重要内容;0.2 大部分不对;0.0 完全错误或编造。
若参考答案本身是"记忆中没有",回答明确表示不知道/记忆里没有,应得 1.0;编造得 0.0。

问题:{q}
参考答案:{gold}
生成的回答:{pred}

只返回 JSON:{{"score": <0.0-1.0>, "reasoning": "<一句话>"}}"""

def judge_score(q, gold, pred):
    raw = llm_answer(JUDGE_PROMPT.format(q=q, gold=gold, pred=pred))
    m = re.search(r"\{.*\}", raw, re.S)
    try:
        return float(json.loads(m.group(0))["score"])
    except Exception:
        return 0.0

for n in SCALES:
    for name, recs in RAW[n].items():
        for rec in recs:
            rec["score"] = judge_score(rec["q"], rec["gold"], rec["answer"])
    print(f"[{n} 轮] 评审完成")
json.dump({str(n): RAW[n] for n in SCALES}, open("/tmp/jev_mem_scale/raw.json", "w"),
          ensure_ascii=False, indent=1)
print("评审分数已写回 raw.json")
[48 轮] 评审完成
[96 轮] 评审完成
[192 轮] 评审完成
[384 轮] 评审完成
评审分数已写回 raw.json
# ============ 主表:规模 × 臂 ============
import matplotlib.pyplot as plt
plt.rcParams["font.family"] = ["Songti SC", "STSong", "PingFang SC", "Arial Unicode MS"]
plt.rcParams["axes.unicode_minus"] = False

ARMS = ["A 滑动窗口", "B 全上下文", "C 朴素RAG", "D Jev-Mem"]
agg = lambda n, name, key: np.mean([r[key] for r in RAW[n][name]])

print("=== 平均评审分(越高越好) ===")
print("轮数   " + "".join(f"{a:>12}" for a in ARMS))
for n in SCALES:
    print(f"{n:>4}  " + "".join(f"{agg(n, a, 'score'):>12.2f}" for a in ARMS))

print()
print("=== 每题平均 prompt tokens(B 臂这是随规模线性涨的成本) ===")
print("轮数   " + "".join(f"{a:>12}" for a in ARMS))
for n in SCALES:
    print(f"{n:>4}  " + "".join(f"{agg(n, a, 'prompt_tokens'):>12.0f}" for a in ARMS))

print()
print("=== 每题平均端到端延迟 s ===")
print("轮数   " + "".join(f"{a:>12}" for a in ARMS))
for n in SCALES:
    print(f"{n:>4}  " + "".join(f"{agg(n, a, 'latency_s'):>12.2f}" for a in ARMS))

print()
print("=== D 臂建图成本 ===")
print("轮数   建图s   节点   边    Jev决策次数   每条观察摊薄s")
for n in SCALES:
    st = SCALE_STATS[n]
    print(f"{n:>4}  {st['build_s']:>5.1f}  {st['nodes']:>5}  {st['links']:>5}   "
          f"{st['jev_calls']:>8}      {st['build_s'] / st['admitted']:>6.2f}")
=== 平均评审分(越高越好) ===
轮数         A 滑动窗口      B 全上下文     C 朴素RAG   D Jev-Mem
  48          0.20        0.98        0.98        0.98
  96          0.20        0.98        0.98        0.92
 192          0.20        0.98        0.94        0.92
 384          0.20        0.88        0.78        0.90

=== 每题平均 prompt tokens(B 臂这是随规模线性涨的成本) ===
轮数         A 滑动窗口      B 全上下文     C 朴素RAG   D Jev-Mem
  48           144        1144         126         121
  96           135        2373         128         119
 192           152        4849         133         137
 384           147        9778         136         120

=== 每题平均端到端延迟 s ===
轮数         A 滑动窗口      B 全上下文     C 朴素RAG   D Jev-Mem
  48          0.55        0.57        0.66        1.44
  96          0.54        0.65        0.62        1.41
 192          0.46        0.58        0.54        1.50
 384          0.64        0.68        0.67        1.66

=== D 臂建图成本 ===
轮数   建图s   节点   边    Jev决策次数   每条观察摊薄s
  48   28.3     48    351         95        0.59
  96   50.1     96    732        191        0.52
 192   89.6    192   1600        383        0.47
 384  173.6    384   3692        767        0.45
# ============ 缩放曲线 + 结论速览 ============
fig, axes = plt.subplots(1, 3, figsize=(16, 4.2))
colors = {"A 滑动窗口": "#9aa5b1", "B 全上下文": "#ed8936", "C 朴素RAG": "#63b3ed", "D Jev-Mem": "#48bb78"}
for ax, key, title in ((axes[0], "score", "平均评审分"), (axes[1], "prompt_tokens", "每题 prompt tokens"),
                       (axes[2], "latency_s", "每题延迟 s")):
    for a in ARMS:
        vals = [agg(n, a, key) for n in SCALES]
        ax.plot(SCALES, vals, "o-", label=a, color=colors[a])
    ax.set_xlabel("对话轮数"); ax.set_title(title); ax.grid(alpha=0.3)
axes[0].legend(fontsize=9)
fig.suptitle("长程缩放:同一组事实与题目,干扰对话 48 -> 192 轮", fontsize=13)
fig.tight_layout()
plt.savefig("/tmp/jev_mem_scale/scaling.png", dpi=130, bbox_inches="tight")
from IPython.display import Image, display
display(Image(filename="/tmp/jev_mem_scale/scaling.png"))

if RAW:
    print("结论速览(本轮实测)")
    for n in SCALES:
        b_tok = agg(n, "B 全上下文", "prompt_tokens")
        d_tok = agg(n, "D Jev-Mem", "prompt_tokens")
        b_s = agg(n, "B 全上下文", "score")
        d_s = agg(n, "D Jev-Mem", "score")
        print(f"  {n:>3} 轮: B 每题 {b_tok:>5.0f} tok / {agg(n,'B 全上下文','latency_s'):.2f}s / {b_s:.2f} 分  |  "
              f"D 每题 {d_tok:>4.0f} tok / {agg(n,'D Jev-Mem','latency_s'):.2f}s / {d_s:.2f} 分  |  "
              f"B/D token 比 {b_tok / d_tok:.1f}x, 分差 {d_s - b_s:+.2f}")
    # 10 题总账:回答阶段的 DeepSeek prompt token 消耗
    for n in SCALES:
        tot = {a: sum(r["prompt_tokens"] for r in RAW[n][a]) for a in ARMS}
        print(f"  {n:>3} 轮 10 题总 prompt tokens: " + "  ".join(f"{a.split()[0]}={tot[a]}" for a in ARMS))
findfont: Failed to find font weight normal for STSong, now using 300.
findfont: Failed to find font weight normal for STSong, now using 300.
findfont: Failed to find font weight normal for STSong, now using 300.
findfont: Failed to find font weight normal for STSong, now using 300.
结论速览(本轮实测)
   48 轮: B 每题  1144 tok / 0.57s / 0.98 分  |  D 每题  121 tok / 1.44s / 0.98 分  |  B/D token 比 9.5x, 分差 +0.00
   96 轮: B 每题  2373 tok / 0.65s / 0.98 分  |  D 每题  119 tok / 1.41s / 0.92 分  |  B/D token 比 19.9x, 分差 -0.06
  192 轮: B 每题  4849 tok / 0.58s / 0.98 分  |  D 每题  137 tok / 1.50s / 0.92 分  |  B/D token 比 35.4x, 分差 -0.06
  384 轮: B 每题  9778 tok / 0.68s / 0.88 分  |  D 每题  120 tok / 1.66s / 0.90 分  |  B/D token 比 81.5x, 分差 +0.02
   48 轮 10 题总 prompt tokens: A=1435  B=11443  C=1256  D=1210
   96 轮 10 题总 prompt tokens: A=1351  B=23731  C=1276  D=1190
  192 轮 10 题总 prompt tokens: A=1521  B=48489  C=1330  D=1370
  384 轮 10 题总 prompt tokens: A=1474  B=97777  C=1362  D=1200

运行输出图

运行输出图

3. 为什么全量准确度这么高?——它高,是因为还没到它的失效区

先看实测衰减曲线(评审分):

轮数 A 滑窗 B 全上下文 C 朴素RAG D Jev-Mem
48 0.20 0.98 0.98 0.98
96 0.20 0.98 0.98 0.92
192 0.20 0.98 0.94 0.92
384 0.20 0.88 0.78 0.90

B 在 48~192 轮几乎满分,384 轮开始掉(0.88),在真 LoCoMo(419 轮、约 2 万 token 上下文)掉到 0.60。它短中期为什么高,四层原因按重要性排:

1. 没有检索环节,就没有"没找到"这种失败。 RAG 类的失分模式是"该召回的没召回"或"召回了干扰"——干扰句每会话一条,越长的对话对检索臂越难(C 从 0.98 掉到 0.78 就是这么来的)。B 把全部证据摊在模型面前,只要装得下,信息可见性 100%,检索型问题对它没有难度曲线,只有成本曲线。

2. 短中期远没到注意力稀释区。 deepseek-chat 窗口 128k token,192 轮才 ~5k token(占窗口 4%),"大海捞针"在这个密度下不难。384 轮(~10k token、16 条定向干扰)后 B 开始丢分——主要是时间题日期算错和细节张冠李戴,这正是长上下文注意力稀释的典型症状。

3. 对抗压力小、judge 宽容。 我们只有 2 道对抗题且参考答案就是"记忆中没有",说"没有"就满分;论文 LoCoMo 的 Full Context 在 adversarial 类目只有 0.205,因为那些题专门诱导模型从相似记忆里编造。B 的高分有一部分是"这次没考到它的真实弱点"。

4. judge 与答案模型同源。 DeepSeek 出答案 DeepSeek 评,风格匹配,对证据最全的 B 最有利。

所以完整的答案是:B 的高分 = 装得下 + 没考弱点。它的失效区由上下文长度和干扰密度决定——真 LoCoMo 上(下一节)B 0.60 对 D 0.98,质量优势在长程下不仅消失,还反转成劣势。

3.5 真 LoCoMo 长程实测:419 轮英文对话

以上是合成对话。再用数据集 LoCoMo 的真实样本 0(419 轮、74k 字符 ≈ 19.8k token,Dyan/Caroline/Melanie 三人多会话)跑了一轮真刀真枪的对照(2026-09-25,真 Jev + DeepSeek,仓库官方基准 CLI + 同题全上下文脚本):

指标 B 全上下文 D Jev-Mem
每题 prompt tokens 19,775 ~784(证据 15 节点实测)
LLM-as-a-Judge(同题 10 题) 0.60 0.98
按题型 时间题 0.2~0.6 成片丢分 时间题全部满分
每题延迟(本机) 0.60s 3.83s(含 Jev 决策链 + judge)
建图成本 0 494s(419 条观察,一次性)

D 的记忆图:419 节点、9,462 条边(ENTITY 4090 / TEMPORAL 3726 / SEMANTIC 1502 / CAUSAL 144),真 Jev 决策,仅 3 次兜底。

三个要点:

  1. 质量反转在真实数据上成立:约 2 万 token 的上下文里,全上下文的时间题("Caroline 什么时候去支持小组")大面积丢分——模型面对 74k 字符算不准日期;Jev-Mem 沿 temporal 图直接召回正确证据,10 题里 9 题 1.0 分。这与论文 Full Context 0.481 的方向完全一致。
  2. token 剪刀差 25 倍:19,775 对 784。问得越多省得越多,且 D 的证据 prompt 大小不随对话长度变化。
  3. 诚实声明:10 题是小样本,judge 是仓库自带的宽松量表(同期 F1 只有 57.3%,说明逐词匹配并不完美);这组数字用于量级判断,不构成对论文 0.777 的复现(那是 1,986 题全量 + 更严协议)。
# ==== 成本剪刀差:B 与 D 的每题成本随规模的变化(动态计算) ===
if RAW:
    print("规模    B每题tok  D每题tok  B/D比   B每题延迟  D每题延迟   B分     D分     D分-B分")
    for n in SCALES:
        bt, dt = agg(n, "B 全上下文", "prompt_tokens"), agg(n, "D Jev-Mem", "prompt_tokens")
        bl, dl = agg(n, "B 全上下文", "latency_s"), agg(n, "D Jev-Mem", "latency_s")
        bs, ds = agg(n, "B 全上下文", "score"), agg(n, "D Jev-Mem", "score")
        print(f"{n:>4}  {bt:>8.0f}  {dt:>8.0f}  {bt / dt:>5.1f}x  {bl:>8.2f}s  {dl:>8.2f}s  "
              f"{bs:>5.2f}  {ds:>5.2f}  {ds - bs:>+6.2f}")
    print()
    print("按 DeepSeek 输入价折算(占位价 1 元/百万 token,请按实际牌价换算):")
    for n in SCALES:
        per_q_b = agg(n, "B 全上下文", "prompt_tokens") / 1e6
        per_q_d = agg(n, "D Jev-Mem", "prompt_tokens") / 1e6
        per_1000 = (per_q_b - per_q_d) * 1000
        print(f"  {n:>4} 轮: 每问一题 B 比 D 多花 {per_q_b * 1e6:>5.0f} tok;"
              f"问 1000 题 ≈ 多花 {per_1000 * 1:>6.1f} 元(占位价)")
规模    B每题tok  D每题tok  B/D比   B每题延迟  D每题延迟   B分     D分     D分-B分
  48      1144       121    9.5x      0.57s      1.44s   0.98   0.98   +0.00
  96      2373       119   19.9x      0.65s      1.41s   0.98   0.92   -0.06
 192      4849       137   35.4x      0.58s      1.50s   0.98   0.92   -0.06
 384      9778       120   81.5x      0.68s      1.66s   0.88   0.90   +0.02

按 DeepSeek 输入价折算(占位价 1 元/百万 token,请按实际牌价换算):
    48 轮: 每问一题 B 比 D 多花  1144 tok;问 1000 题 ≈ 多花    1.0 元(占位价)
    96 轮: 每问一题 B 比 D 多花  2373 tok;问 1000 题 ≈ 多花    2.3 元(占位价)
   192 轮: 每问一题 B 比 D 多花  4849 tok;问 1000 题 ≈ 多花    4.7 元(占位价)
   384 轮: 每问一题 B 比 D 多花  9778 tok;问 1000 题 ≈ 多花    9.7 元(占位价)

4. 时间与 token 损失对比:怎么读这笔账

把四个臂放进"建图成本 + 每题成本 × 题数"的账本里(数字以上面实测为准):

成本项 A 滑窗 B 全上下文 C 朴素 RAG D Jev-Mem
建图(一次性) 0 0 0(现场算向量,毫秒级) 随轮数线性涨(真 Jev 决策,每条观察约 0.5-0.7s)
每题 token 恒定但证据残缺 随轮数线性涨(这是 B 的核心代价) 恒定(Top-3 固定) 恒定(只带筛选后的证据)
每题延迟 最低档 随规模涨(prefill 变长) 低 最高档(Jev 决策链 + embedding 往返)
质量 最早崩(窗口外的全丢) 短中期最高,超长才稀释 中,时间题必挂 中高,时间/多跳稳

三条实测结论:

  1. token 剪刀差是 D 的主场:B 每题 token 随轮数线性涨,D 几乎水平——384 轮时 B/D 比值(上面速览实测)就是"每问一题省下的输入成本倍数"。对话越长、问得越多,这笔省得越多。
  2. 时间账在本地 API 场景下 D 不占优:D 的每题延迟比 B 高(Jev 决策是真金白银的时间),建图还要一次性投入。论文里 Jev-Mem 延迟最低(0.93s)比较对象是其他记忆系统(1.47~32.68s)和超长上下文(1.74s 且还在涨)——当 B 的 prefill 长到几万 token,B 的延迟线才会反超。D 买的是 token 经济性和可审计性,不是本地的低延迟。
  3. 全上下文的高分是"还没到考验":质量上 B 会在对话长到注意力稀释时掉分(论文 LoCoMo 0.481),token 上它从第一轮就开始付线性税。选型的实际判据:对话短(几百轮内)、问得少 → 直接 B;对话长、要反复问、要省钱省审计 → 上记忆系统,且值得为时间题上 Jev 决策面。

5. 结论总览:三组实验连起来看

结合走读实验(jev_mem_walkthrough.ipynb,16 轮)、本篇缩放实验(48~384 轮)与真 LoCoMo(419 轮),所有实测结论收敛成一张矩阵:

实验 规模 一句话结论
走读四臂 16 轮 短对话 B 天花板(0.96);D 0.84 > C 0.72,增益全在时间题
本篇缩放 48→384 轮 B 384 轮掉到 0.88;D 最稳 0.90 反超第一;token 剪刀差 81×
真 LoCoMo 419 轮 / 19.8k tok B 0.60 对 D 0.98,质量反转实锤;token 剪刀差 25×

七条有趣结论

  1. 决策面的增益与 embedding 质量正交。 换更强的 embedding(MiniLM→Qwen3)把 C 拉高 0.08,但时间题 C 始终 0 分——"按时间线找证据"不靠语义相似度,这是 Jev 路由 + temporal 图视图的专属领地。
  2. token 是 D 的主场。 D 的证据 prompt 恒定(中文 ~120 tok、英文 ~784 tok),不随对话长度变化;B 线性上涨,384 轮 81 倍、真 LoCoMo 25 倍。问得越频繁、对话越长,省得越多。
  3. 时间是 D 的成本项。 建图线性(合成对话 ~0.45s/条,LoCoMo 长文本 1.2s/条),每题查询比 B 慢 13s(Jev 决策链)。本地 API 场景 D 不赢时间——它买的是 token 经济性、质量稳定性与可审计性。
  4. 全量上下文的高分 = 装得下 + 没考弱点。 失效区由上下文长度和干扰密度决定:≤192 轮近乎满分,384 轮开始稀释,2 万 token(LoCoMo)掉到 0.60。
  5. 定向干扰是纯向量检索的毒药,对决策面伤害有限。 "同事对虾过敏"这类陷阱把 C 从 0.98 拖到 0.78,D 只从 0.98 掉到 0.90——图视图 + 路由比裸相似度抗干扰。
  6. 建图经济学成立。 384 条观察 = 767 次 Jev 决策调用(LoCoMo 419 条 ≈ 830 次)。按 TypeSafe 牌价(2026-09:输入 $0.042/百万 token、输出免费)粗估每次调用 ~1.5k 输入 token,整份 384 轮记忆的决策成本约 5 美分,一次性投入;对照论文里大模型记忆系统动辄上千秒、按生成计费的建图,这是量级差。
  7. 答案长度会吃掉 judge 分数。 仓库 System-Two 提示词要求"5-6 词简答",中文多跳题漏细节被扣分("改到下周三,推迟了"没提重新订票)。属于提示词工程问题,放宽答题提示即可回收,不是架构缺陷。

选型判据

  • 对话几百轮以内、偶尔问答 → 直接全上下文,别过度工程;
  • 长对话、反复问答、在意 token 账单 → 上记忆系统;
  • 问题里时间线/因果/跨会话占比高 → 值得为 Jev 决策面付费(D 对 C 的全部增益都在这类题上);
  • 本地低延迟场景 → embedding 本地化(省 API 往返),并接受建图的一次性时间投入。

复现备忘(踩过的坑)

  • 仓库 memory/llm_judge.py 硬编码 judge 模型 gpt-4o-mini;配 DeepSeek 需 monkeypatch 模型名为 deepseek-chat(本 notebook 实验用的是自研 judge cell,无此问题);
  • 官方基准 CLI 必须 --no-parallel:macOS spawn 模式下并行 worker 不会继承主进程的 monkeypatch,静默失败;
  • 记忆图缓存可复用(换配置才需 --rebuild),重建 LoCoMo 样本 0 约需 8 分钟;
  • judge 分数轮间漂移 ±0.02~0.03,结论以同轮表格为准;本文所有分数为 LLM 评审的相对值,不构成对论文 LoCoMo 全量协议(1,986 题)的复现。

📑 Jev Cookbook:System One 判断模型实战教程

1 第 1 章:认识 Jev:模型、上手与场景 2 第 2 章:核心概念总览 3 第 3 章:System One:判断的核心心智模型 4 第 4 章:状态:让判断连续可追溯 5 第 5 章:原语:Choice、Score 与 Noul 6 第 6 章:置信度:让概率可信 7 第 7 章:应用构建:从原语到完整系统 8 第 8 章:架构模式 9 第 9 章:实战指南总览 10 第 10 章:自一致性 · Noul 11 第 11 章:自一致性 · Choice 12 第 12 章:并行提问 13 第 13 章:重排序 14 第 14 章:逐行语义搜索 15 第 15 章:结构恢复 16 第 16 章:函数调用 17 第 17 章:技能推荐 18 第 18 章:实体对齐 19 第 19 章:RAG 段落分类 20 第 20 章:引用核查 21 第 21 章:LLM 防护栏 22 第 22 章:SDE 级联 23 第 23 章:日期抽取 24 第 24 章:预解析值抽取 25 第 25 章:层级分类 26 第 26 章:自动研究特征发现 27 第 27 章:基于置信度的分类 28 第 28 章:智能家居实验 29 第 29 章:模型评测总览 30 第 30 章:模型评测实验 31 第 31 章:Laya vs Jev 对比基准 32 第 32 章:JevBench:LLM 评测体系 33 第 33 章:智能家居应用实战 34 第 34 章:Jev-Mem 研究总览 35 第 35 章:Jev-Mem 缩放实验 36 第 36 章:Jev-Mem 完整走查 37 第 37 章:Agent 集成总览 38 第 38 章:Pi 集成实验 39 第 39 章:DSH 决策协作 40 第 40 章:本地模型总览 41 第 41 章:本地模型介绍与对比 42 第 42 章:中文数据集构建方案 43 第 43 章:微调指南 44 第 44 章:RLCD 原理与实验优化 45 第 45 章:中文数据集构建实验 46 第 46 章:中文 Head 微调实验 47 第 47 章:全量 v2 微调实验 48 第 48 章:知识库
← 返回本书大纲