保留原文 vs 写入时摘要:Agent 记忆的证据覆盖率

受论文 Naive RAG Is Stronger Than We Thought 启发,用构造的对话历史演示:写入时做摘要的记忆会丢掉「旧值」「原因」等证据,而对原始消息做检索能保住证据,代价是上下文更长。

已验证 Python 3.12.15 耗时 0.1s 运行于 2026-10-24 22:09 UTC
结论

构造数据中,Raw-RAG top-8 证据覆盖率 100.0%,覆盖式摘要 58.7%——摘要省上下文,但写入时没留的信息事后无法找回

检查 5/5 通过
  • 覆盖式摘要能覆盖全部「当前值」问题
  • 覆盖式摘要写入时丢了旧值,「上一次的值」问题全部缺证据
  • 带历史的摘要仍不记录原因,「修改原因」问题全部缺证据
  • Raw-RAG top-8 的总体证据覆盖率高于带历史的摘要
  • 检索条数从 3 增加到 8,覆盖率不下降
实验指标(%)
覆盖式摘要 总体证据覆盖率58.7%
带历史摘要 总体证据覆盖率82.6%
Raw-RAG top-3 总体证据覆盖率89.1%
Raw-RAG top-8 总体证据覆盖率100.0%
覆盖式摘要 previous 类覆盖率0.0%
Raw-RAG top-8 previous 类覆盖率100.0%
覆盖式摘要 reason 类覆盖率0.0%
Raw-RAG top-8 reason 类覆盖率100.0%

数值取自 GitHub Actions 实际运行输出中的 METRIC 行

实验指标(词)
Raw-RAG top-8 平均上下文长度62词
覆盖式摘要平均上下文长度4词

数值取自 GitHub Actions 实际运行输出中的 METRIC 行

以上内容从实际运行输出中提取,完整输出见下方。

python main.py
历史消息 111 条,属性修改 42 次,问题 46 个
METRIC: 覆盖式摘要 总体证据覆盖率 = 58.7 %
METRIC: 带历史摘要 总体证据覆盖率 = 82.6 %
METRIC: Raw-RAG top-3 总体证据覆盖率 = 89.1 %
METRIC: Raw-RAG top-8 总体证据覆盖率 = 100.0 %
METRIC: 覆盖式摘要 previous 类覆盖率 = 0.0 %
METRIC: Raw-RAG top-8 previous 类覆盖率 = 100.0 %
METRIC: 覆盖式摘要 reason 类覆盖率 = 0.0 %
METRIC: Raw-RAG top-8 reason 类覆盖率 = 100.0 %
METRIC: Raw-RAG top-8 平均上下文长度 = 62 词
METRIC: 覆盖式摘要平均上下文长度 = 4 词
[PASS] 覆盖式摘要能覆盖全部「当前值」问题
[PASS] 覆盖式摘要写入时丢了旧值,「上一次的值」问题全部缺证据
[PASS] 带历史的摘要仍不记录原因,「修改原因」问题全部缺证据
[PASS] Raw-RAG top-8 的总体证据覆盖率高于带历史的摘要
[PASS] 检索条数从 3 增加到 8,覆盖率不下降
RESULT: 构造数据中,Raw-RAG top-8 证据覆盖率 100.0%,覆盖式摘要 58.7%——摘要省上下文,但写入时没留的信息事后无法找回

目的

论文 Is Complex Agentic Memory Always Needed? Naive RAG Is Stronger Than We Thought(arXiv:2610.30755)发现:直接对原始对话历史做稠密检索加重排(Raw-RAG),在 7 个基准×模型设置中有 6 个排第一;作者认为保留原始证据比复杂的记忆加工更重要,证据覆盖率高可以抵消利用率低。

本实验用构造的对话历史演示其中的机制:写入时就做摘要的记忆,会在当时决定保留什么;没保留的信息,之后遇到新问题时无法再找回。 原文检索的上下文更长,但证据还在。

注意:数据由脚本按固定随机种子构造,检索用的是简单的 BM25(论文用的是稠密检索 + 重排),只演示机制,不复现论文中的任何数字。三类问题和两种摘要方式都是本实验的设定,摘要方式的设计决定了它们在某些问题上必然缺证据——这正是要演示的点,而不是对真实记忆系统的评测。

步骤

  1. 生成约 100 条对话消息:12 个项目的上线日期、负责人、预算被多次修改,修改原因在另一条消息中单独说明,中间穿插无关闲聊
  2. 生成三类问题:当前值(latest)、上一次的值(previous)、最近一次修改的原因(reason),并记下每题所需的证据消息
  3. 构建三种记忆: - 覆盖式摘要:每个(项目, 属性)只存最新值 - 带历史的摘要:存全部取值,但不存原因 - Raw-RAG:对原始消息做 BM25 检索,取 top-3 或 top-8 条
  4. 统计每种记忆交给阅读模型的上下文是否包含全部所需证据(证据覆盖率),以及上下文长度

运行:

python main.py

程序打印 METRIC: 指标、[PASS]/[FAIL] 检查和最后一行 RESULT:,任一检查失败时以非零状态退出。

预期结果

  • 覆盖式摘要能回答全部「当前值」问题,但「上一次的值」和「原因」问题全部缺证据
  • 带历史的摘要补上了旧值,但仍缺「原因」
  • Raw-RAG 的覆盖率随检索条数增加而上升,代价是上下文长了一个数量级

启示与可以继续尝试

  • 摘要式记忆不是错,而是「提前做了取舍」:如果你的 Agent 只会被问当前状态,它又省又准;如果用户会追问历史和原因,就要保留原文或至少保留指向原文的链接
  • 把 Raw-RAG top-k 的 k 改成 1、2,观察覆盖率如何下降——检索条数太少时原文检索也会漏
  • 增加更多同名项目的干扰消息,观察 BM25 的覆盖率变化;论文用重排模型来缓解这类问题
  • 本实验只看「证据在不在上下文里」,不看阅读模型能否用好它;论文也指出覆盖率和利用率是两件事

内网复现

  • 无需任何第三方依赖,只用 Python 3.8+ 标准库,无需联网,可直接在内网运行
  • 把 main.py 拷贝到内网机器执行 python main.py 即可
  • 如要换成论文中的稠密检索 + 重排,需要嵌入模型和重排模型;论文附录给出了 Qwen3-Embedding-4B 和 Qwen3-Reranker-4B 的开源替代组合,权重可通过内网的 Hugging Face 镜像下载,本实验不依赖这些

代码

main.py183 行
"""保留原文 vs 写入时摘要:两种 Agent 记忆对「必要证据覆盖率」的影响

受论文 "Is Complex Agentic Memory Always Needed? Naive RAG Is Stronger Than We Thought"
(arXiv:2610.30755)启发。论文的观点:直接对原始对话历史做检索(Raw-RAG)常常比先把历史
加工成结构化记忆更好,因为加工会丢掉回答问题所需的证据;证据覆盖率高,可以抵消利用率低。

本脚本全部为构造数据(固定随机种子),只演示机制,不复现论文任何数字:
- 生成一段多轮对话历史:若干项目的属性(上线日期、负责人、预算)会被多次修改,
  修改原因在另一条消息里单独说明,中间穿插大量无关闲聊
- 三类问题:当前值(latest)、上一次的值(previous)、修改原因(reason)
- 三种记忆:
  A. 覆盖式摘要:每个 (项目, 属性) 只保留最新值
  B. 带历史的摘要:保留每个 (项目, 属性) 的全部取值,但不记录原因
  C. Raw-RAG:对原始消息做 BM25 检索,取 top-k 条原文
- 指标:回答所需的全部证据是否出现在交给「阅读模型」的上下文里(证据覆盖率),
  以及上下文的大致长度(词数)
"""
from __future__ import annotations

import math
import random
import re
import sys
from collections import Counter

SEED = 25
PROJECTS = ["Atlas", "Borealis", "Cobalt", "Dune", "Ember", "Fjord", "Granite", "Harbor",
            "Iris", "Juniper", "Kestrel", "Lumen"]
ATTRS = {
    "launch date": ["March 3", "March 10", "April 2", "April 18", "May 6", "May 21", "June 9"],
    "owner": ["Priya", "Marco", "Lena", "Tomas", "Aiko", "Omar", "Sofia"],
    "budget": ["40k", "55k", "62k", "75k", "80k", "95k", "120k"],
}
REASONS = ["the vendor shipped late", "legal asked for another review", "the team was reorganized",
           "finance cut the quarterly plan", "a key customer requested changes",
           "the pilot showed performance issues"]
CHATTER = ["Lunch is at noon today.", "Remember to submit expense reports by Friday.",
           "The office wifi was flaky this morning.", "Great demo yesterday, everyone.",
           "Who has the spare HDMI adapter?", "Coffee machine on floor 3 is fixed.",
           "Please update your calendar for the holiday.", "The quarterly survey is open."]

checks: list[tuple[bool, str]] = []


def check(ok: bool, msg: str) -> None:
    checks.append((ok, msg))
    print(f"[{'PASS' if ok else 'FAIL'}] {msg}")


def tokenize(text: str) -> list[str]:
    return re.findall(r"[a-z0-9]+", text.lower())


def build_history(rng: random.Random):
    """返回消息列表与问题列表。每个问题记录所需证据的消息编号。"""
    msgs: list[str] = []
    events = []  # (project, attr, value, msg_id, reason_msg_id or None)
    for _ in range(90):
        if rng.random() < 0.45:
            msgs.append(rng.choice(CHATTER))
            continue
        p = rng.choice(PROJECTS)
        a = rng.choice(list(ATTRS))
        v = rng.choice(ATTRS[a])
        msgs.append(f"Update: the {a} for {p} is now {v}.")
        mid = len(msgs) - 1
        rid = None
        prev = [e for e in events if e[0] == p and e[1] == a]
        if prev and rng.random() < 0.8:
            # 原因单独一条,夹在若干闲聊之后
            for _ in range(rng.randint(0, 2)):
                msgs.append(rng.choice(CHATTER))
            msgs.append(f"Context on {p}: we changed the {a} because {rng.choice(REASONS)}.")
            rid = len(msgs) - 1
        events.append((p, a, v, mid, rid))

    questions = []
    keys = sorted({(e[0], e[1]) for e in events})
    for p, a in keys:
        hist = [e for e in events if e[0] == p and e[1] == a]
        last = hist[-1]
        questions.append({"type": "latest", "q": f"What is the current {a} for {p}?",
                          "need": {last[3]}, "key": (p, a), "answer": last[2]})
        if len(hist) >= 2:
            questions.append({"type": "previous", "q": f"What was the {a} for {p} before the last change?",
                              "need": {hist[-2][3]}, "key": (p, a), "answer": hist[-2][2]})
            if last[4] is not None:
                questions.append({"type": "reason", "q": f"Why did the {a} for {p} change most recently?",
                                  "need": {last[4]}, "key": (p, a), "answer": None})
    return msgs, events, questions


class BM25:
    def __init__(self, docs: list[str], k1: float = 1.5, b: float = 0.75):
        self.toks = [tokenize(d) for d in docs]
        self.avg = sum(len(t) for t in self.toks) / len(self.toks)
        self.df = Counter(w for t in self.toks for w in set(t))
        self.n = len(docs)
        self.k1, self.b = k1, b

    def search(self, query: str, k: int) -> list[int]:
        q = tokenize(query)
        scores = []
        for i, t in enumerate(self.toks):
            tf = Counter(t)
            s = 0.0
            for w in q:
                if w not in tf:
                    continue
                idf = math.log(1 + (self.n - self.df[w] + 0.5) / (self.df[w] + 0.5))
                s += idf * tf[w] * (self.k1 + 1) / (tf[w] + self.k1 * (1 - self.b + self.b * len(t) / self.avg))
            scores.append((s, -i))  # 同分时偏向较新的消息
        scores.sort(reverse=True)
        return [-i for _, i in scores[:k]]


def main() -> int:
    rng = random.Random(SEED)
    msgs, events, questions = build_history(rng)
    print(f"历史消息 {len(msgs)} 条,属性修改 {len(events)} 次,问题 {len(questions)} 个")

    # A. 覆盖式摘要:只保留最新值(证据视为「最新那条更新」)
    latest_store = {}
    for p, a, v, mid, _ in events:
        latest_store[(p, a)] = (v, {mid})
    # B. 带历史的摘要:保留全部取值,不记录原因
    hist_store: dict[tuple[str, str], list[tuple[str, int]]] = {}
    for p, a, v, mid, _ in events:
        hist_store.setdefault((p, a), []).append((v, mid))

    def covered_A(q):
        return q["need"] <= latest_store[q["key"]][1]

    def covered_B(q):
        return q["need"] <= {mid for _, mid in hist_store[q["key"]]}

    bm = BM25(msgs)

    def covered_C(q, k):
        return q["need"] <= set(bm.search(q["q"], k))

    types = ["latest", "previous", "reason"]
    by_type = {t: [q for q in questions if q["type"] == t] for t in types}

    def rate(fn, qs):
        return sum(1 for q in qs if fn(q)) / len(qs) * 100 if qs else 0.0

    overall = {}
    overall["覆盖式摘要"] = rate(covered_A, questions)
    overall["带历史摘要"] = rate(covered_B, questions)
    for k in (3, 8):
        overall[f"Raw-RAG top-{k}"] = rate(lambda q, k=k: covered_C(q, k), questions)

    for name, val in overall.items():
        print(f"METRIC: {name} 总体证据覆盖率 = {val:.1f} %")
    for t in ("previous", "reason"):
        print(f"METRIC: 覆盖式摘要 {t} 类覆盖率 = {rate(covered_A, by_type[t]):.1f} %")
        print(f"METRIC: Raw-RAG top-8 {t} 类覆盖率 = {rate(lambda q: covered_C(q, 8), by_type[t]):.1f} %")

    # 上下文长度:摘要给出一条键值;Raw-RAG 给出 k 条原文
    avg_msg_words = sum(len(tokenize(m)) for m in msgs) / len(msgs)
    print(f"METRIC: Raw-RAG top-8 平均上下文长度 = {8 * avg_msg_words:.0f} 词")
    entries = [f"{p} {a}: {v}" for (p, a), (v, _) in latest_store.items()]
    avg_entry = sum(len(tokenize(e)) for e in entries) / len(entries)
    print(f"METRIC: 覆盖式摘要平均上下文长度 = {avg_entry:.0f} 词")

    check(rate(covered_A, by_type["latest"]) == 100.0, "覆盖式摘要能覆盖全部「当前值」问题")
    check(rate(covered_A, by_type["previous"]) == 0.0, "覆盖式摘要写入时丢了旧值,「上一次的值」问题全部缺证据")
    check(rate(covered_B, by_type["reason"]) == 0.0, "带历史的摘要仍不记录原因,「修改原因」问题全部缺证据")
    check(overall["Raw-RAG top-8"] > overall["带历史摘要"], "Raw-RAG top-8 的总体证据覆盖率高于带历史的摘要")
    check(overall["Raw-RAG top-8"] >= overall["Raw-RAG top-3"], "检索条数从 3 增加到 8,覆盖率不下降")

    failed = [m for ok, m in checks if not ok]
    if failed:
        print(f"RESULT: 有 {len(failed)} 项检查未通过")
        return 1
    print(f"RESULT: 构造数据中,Raw-RAG top-8 证据覆盖率 {overall['Raw-RAG top-8']:.1f}%,"
          f"覆盖式摘要 {overall['覆盖式摘要']:.1f}%——摘要省上下文,但写入时没留的信息事后无法找回")
    return 0


if __name__ == "__main__":
    sys.exit(main())
requirements.txt1 行
# 无第三方依赖,只使用 Python 标准库
Esc
↑ ↓ 选择Enter 打开

键盘快捷键

j / k
下一条 / 上一条
o
展开或收起当前条目
Enter
打开当前条目详情
c
复制当前条目链接
s
收藏 / 取消收藏当前条目
/ 或 ⌘K
搜索
1 2 3
切换密度:标题 / 摘要 / 完整
t
切换主题
?
显示本帮助