三层记忆 LLM Agent(批量评测版)
面向 批量数据集评测与基线对照实验 的 LLM Agent 三层记忆系统。不是 demo: 所有实验产物(逐样本预测、逐 episode 日志、汇总指标、断点信息)都会落盘,可复现、可断点续跑。
硬性约束(已贯彻到代码):
- 禁止 向量 / embedding / 相似度检索 / RAG。记忆只能通过 ID 精确查询。
MemoryManager业务逻辑与存储完全解耦:InMemoryStore↔RedisSQLiteHybridStore切换不触碰任何压缩 / OVERRIDES 逻辑。- 每个 episode 独立:
reset()清空该 episode 热点数据,绝不跨样本污染;评测关键。 - Redis 只做热点缓存,不持久化实验数据;归档摘要与全部原始对话永久落 SQLite。
0. 审查后的重要更正(读实验结果前必读)
一次独立的代码审查发现,若干已归档的真实实验数字是在有缺陷的代码上跑出来的。
修复清单见 REVIEW_FINDINGS.md;下面是最影响结论的几条:
| 问题 | 影响 |
|---|---|
Redis 序列化丢字段:_load_summary 丢 fact_keys/index_id,loads_index 丢 previews/child_index_ids;SQLite 的 index_entries 表根本没有后两列 |
所有真实运行都走 redis_sqlite_hybrid。索引标题的 属性=当前值 digest 恒为空;索引行回退渲染整条成员摘要(实测索引层 474→317 tok/轮,约 1/3 成本虚高);"顶层不可达"的主因是这个 bug,不是架构 |
_fact_digest 顺序反了 |
标题把最旧的值标成"当前值",历史也倒着写(工位=1楼[3楼→2楼→1楼])。索引层一直在顶层显示错误的当前值 |
| 索引条目在覆盖后不变 | size 虚高、expand_index 返回条数对不上、预览与 digest 仍显示已被取代的旧值 |
| 基线被灌输了它们没有的工具 | memgpt_style/naive_chain 的 executor 为 None,却收到"必须先调用工具"的提示与工具手册 → 模型输出工具调用,无法执行,该字符串直接成为它的答案并被判错。三项基线被系统性压低 |
--system all 直接崩溃 |
memgpt_style 的 chain token 是 None(故意表示 n/a),进度行却用 :.0f 格式化 → 崩溃,且不产出任何结果文件 |
多系统 predictions.csv 重复 |
累积行被反复 append(4 系统 × n 题 → 10n 行,权重 4:3:2:1),analyze_results 读的就是这个文件 |
| resume 丢失指标 | 已完成的 episode 被跳过却不回读,metrics.csv 只覆盖本次进程跑的部分;首次 flush 还会把 predictions.csv 截断 |
| 跨 run 归档污染 | 归档命名空间是 system/episode,不含 run id,且 reset 保留归档 → 换 run-id 重跑会继承上一次的死数据(实测某 40 轮 cap=0 的 episode 报告 archived_final=101) |
| 成本无法归因 | 摘要/裁判与回答共用同一个 client,summarizer_stats 是同一个计数器的第二次读取(490/490、token 完全相同) |
| 自行写记忆的块会泄漏进答案 | "回复只有该块"时返回的是原始带标签文本;多块时只剥第一个 |
| 合成数据集 History 标准答案错误 | 一个属性变两次会生成两条措辞相同、gold 不同的探针,其中一条必然判错 |
| LongMemEval 两处 | --lme-types 配默认 --limit 50 返回 0 条(先截断扫描再过滤);轮级截断是纯尾部截断,会把证据会话切掉,而 meta 仍声称保留了 |
结论:results/final_ab、results/final_merge 等修复前产物的绝对数字不可直接引用;
结构性结论("摘要+归档+精确回查"成立、索引优于容量合并、成本需要按 session 粒度优化)方向仍然成立,
但需要重跑才能得到可引用的数字。修复后 index 与 merge 的对比需要在同一份新代码上重做。
1. 快速开始
pip install -r requirements.txt # 必需:requests;可选:redis, tiktoken, openai, datasets
# ⓪ 用 DeepSeek 跑真实实验(.env 里放 DEEPSEEK_API_KEY 即可,无需其它配置)
python evaluation.py --synthetic --limit 30 --llm-backend deepseek \
--model deepseek-flash --system all --store redis_sqlite_hybrid \
--run-id exp_deepseek
python analyze_results.py --results results/main # 生成对照表 + 图 + 错误拆解
# ① 零依赖冒烟跑(内置合成数据 + 规则离线 LLM + 内存存储,不联网、不需要 Redis)
python evaluation.py --synthetic --dataset-format synthetic \
--llm-backend heuristic --system all --limit 4 --out-dir results/smoke
# ② 真实实验(Ollama,四套系统,SQLite 断点续跑)
python evaluation.py --dataset data/sample_episodes.json --llm-backend ollama \
--model qwen2.5:7b --store redis_sqlite_hybrid --system all --run-id exp1
# ③ 崩溃后续跑(同一个 --run-id,已完成的 episode 自动跳过)
python evaluation.py --dataset data/sample_episodes.json --llm-backend ollama \
--system three_layer --run-id exp1
# ④ 查看某个陌生数据集的 schema(尤其是 MEME 的不同版本)
python evaluation.py --dataset meme_export.json --inspect
# ⑤ 人工调试单条 episode(不需要 Redis)
python chat_debug.py --store memory --llm-backend heuristic
2. 架构
┌─────────────────────────── Prompt 拼接顺序 ───────────────────────────┐
│ ① 近期原始对话滑动窗口(K 轮逐字) → ② 活跃事件摘要链 → ③ 系统提示词+工具 │
└───────────────────────────────────────────────────────────────────────┘
▲ ▲
│ │
┌─────────────────────────────────────┴──────┐ ┌──────────────┴───────────────────────┐
│ ④ 近期滑动窗口 RecentSlidingWindow │ │ ① 活跃事件链 Active Chain │
│ 最近 K 轮完整原始对话(默认 4) │ │ 每条摘要带 [OVERRIDES: id_list] │
│ 超长淘汰最老一条(原文仍在层③) │ │ Redis 热数据 + SQLite 镜像 │
└────────────────────────────────────────────┘ └──────────────┬───────────────────────┘
│ 事件驱动覆盖 / 容量压缩
┌────────────────────────────────────────────┐ ┌──────────────▼───────────────────────┐
│ ③ 原始详情库 RawDialogRecord │◀───────│ ② 归档摘要库 ArchivedSummary │
│ 永久保存完整原始对话(SQLite) │ raw_ref│ 被覆盖 / 被容量淘汰的摘要,永不删除 │
│ 只被 reference_id 精确读取 │ │ 只被 summary_id 精确读取(SQLite) │
└────────────────────────────────────────────┘ └──────────────────────────────────────┘
- 层① 活跃事件链:进 LLM 上下文。每条摘要强制携带
[OVERRIDES: id1,id2]/[OVERRIDES: none], 并可携带机器可读的[FACTS: 属性=取值; ...]行(用于"每个属性的最新事实不被容量压缩吃掉",见 §7.2-1)。 - 层② 归档摘要库:
is_overridden=True永久保存,按summary_id精确读取(SQLite)。 - 层③ 原始详情库:完整原始对话永久保存;摘要只存
raw_ref_id引用(SQLite)。 - 层④ 近期滑动窗口:最近
recent_window_turns(默认 4)轮逐字对话,拼在 prompt 最前端,减少不必要的工具调用;被淘汰的记录仍在层③。
2.1 两套压缩逻辑(互不混淆,可分别归因)
| 触发 | 动作 | OVERRIDES | 归档标记 | 统计字段 | |
|---|---|---|---|---|---|
| 事件驱动更新 | 本轮新事实与活跃链旧事实冲突/更新(同对象同属性取值变化) | 旧摘要移出活跃链进归档,新摘要留在活跃链并列出被覆盖 id | [OVERRIDES: id_list] |
is_overridden=True, superseded_by=<新id>, archive_reason="overridden" |
overrides_events, overridden_summaries |
| 容量驱动压缩 | 活跃链 token > ACTIVE_CHAIN_TOKEN_LIMIT |
选取早期无冲突多条摘要合并为高阶摘要放入活跃链,原摘要进归档 | 不生成 OVERRIDES 标签 | is_overridden=False, superseded_by=None, archive_reason="capacity" |
capacity_compressions |
实现约束(保证指标可归因):
- 两种压缩机制在 CSV 里用独立字段统计(
overrides_events/capacity_compressions),且容量合并永不产生 OVERRIDES 标签。但两者并非互斥:一轮里覆盖了旧摘要、同时又把别的摘要归档以压回预算,是完全正常的,该轮的event_triggered与capacity_triggered会同时为真。(早期文档声称有"覆盖当轮不再压缩"的护栏,代码里从来没有;补上它只会让指标更难解释。) - 容量压缩 不会合并本轮刚产生的新摘要 → 最新事实始终可单独读取。
- 摘要器幻觉出的 id(不在活跃链中)被 丢弃并计数(
invalid_override_ids),绝不污染归档图谱。
2.2 两个 Agent 工具(纯文本 function call)
get_archived_summary(summary_id="<id>") # 按 id 精确读取归档摘要(SQLite)
get_raw_record(reference_id="<ref>") # 按 id 精确懒加载完整原始对话(SQLite)
get_predecessor_summary(fact_key="<属性>", exclude_value="<值>") # 按属性精确取"最近的旧值"
get_predecessor_summary 是为"被覆盖的历史值"专门加的精确属性查询:它在归档里按时间倒序找该属性的最近一条,
也就是当前值的直接前驱。没有它,模型只能在几十条归档 id 里猜哪条是前驱——长 episode 实测历史准确率因此从 0.043 提到 0.565。
它仍然是 ID/属性精确匹配,不涉及向量或相似度排序。
解析器(memory3l/tools.py)支持 key="v" / key=v / key: 'v' / JSON / DeepSeek DSML 标记 形式与多种参数别名;
解析失败、id 不存在、工具异常都会被结构化记录,直接进入 Tool_Call_Success_Rate。
get_archived_summary 还会沿 superseded_by 链前向回溯,把"现在被谁取代、当前值是什么"一并返回——
否则模型只能用旧值回答"原值"问题,History_Fact_Acc 无法测量。
2.3 数据模型
ActiveSummary(summary_id, text, override_ids: list[str], timestamp, raw_ref_id)
ArchivedSummary(summary_id, text, override_ids, timestamp, raw_ref_id, is_overridden)
RawDialogRecord(reference_id, user_msg, agent_msg, timestamp)
在保持题设字段的前提下补充了少量可审计字段(均有默认值,不改变语义):
superseded_by(覆盖链前向指针)、raw_ref_ids(容量合并需要多引用)、
episode_id / seq / origin / merged_from / archive_reason(评测与调试所需的溯源信息)。
3. 存储层
| 实现 | 用途 | 说明 |
|---|---|---|
InMemoryStore |
快速调试单条 episode | 零依赖,chat_debug.py 默认 |
RedisSQLiteHybridStore |
正式实验 | Redis 热 + SQLite 冷 |
RedisHotStore |
Redis 热点封装 | 命名空间、SCAN 清理、可降级 |
SQLiteColdStore |
SQLite 冷持久化 | 自动建表、WAL、断点信息 |
本机启动 Redis(任选其一):
docker run -d --rm -p 6379:6379 --name mem-redis redis:7-alpine # 最省事
sudo apt install redis-server && redis-server --daemonize yes # 系统包
若当前 Python 环境的 site-packages 只读,可把可选依赖装到项目内目录(config.py 会自动加入
sys.path,无需改环境变量):
pip install --target ./.pylibs redis # 之后 hybrid 存储会走真实 Redis 热点层
Redis 只存当前 episode 的热数据(key 前缀 episode:{episode_id}:*):
episode:{episode_id}:active_ids LIST summary_id 顺序(新→尾)
episode:{episode_id}:active HASH summary_id -> JSON(ActiveSummary)
episode:{episode_id}:window LIST JSON(RawDialogRecord)
episode:{episode_id}:meta HASH 轮次等簿记
SQLite 七张表(自动建表;evaluation.py 另建一张 episode_events):
| 表 | 内容 |
|---|---|
raw_records |
全部原始对话(永久) |
archived_summaries |
全部归档摘要(永久,含 override_ids / superseded_by / archive_reason) |
active_summaries |
活跃链镜像,仅用于崩溃恢复 |
sliding_window |
滑动窗口镜像,仅用于崩溃恢复 |
episode_progress |
批实验断点:(run_id, episode_id, status) |
experiment_runs |
每个 (run, system, episode) 的结果行 |
关键语义:
reset(episode_id)是不对称的:删除该 episode 的 Redis 全部 key + 清空活跃链/窗口镜像, 但 保留 该 episode 的归档摘要与原始记录。否则 agent 查不到自己被覆盖的历史,History_Fact_Acc 无从测量。- Redis 掉线不丢数据:所有写先落 SQLite;Redis 不可用时自动降级(
redis-py未安装/服务未启动也照跑), 并可从 SQLite 镜像rebuild_hot_state()恢复热点。诊断计数写入结果文件(store_redis_available/store_fallbacks/store_resyncs)。 - 清理用 SCAN + UNLINK,绝不使用
KEYS。
4. 评测:四套系统对照
| system | 说明 |
|---|---|
three_layer |
本方案:三层 + OVERRIDES + 滑动窗口 + Redis/SQLite,具备两个归档工具 |
full_context |
Baseline 1:Plain Full Context,全部原始对话直接进 prompt(超预算从最老开始截断) |
memgpt_style |
Baseline 2:MemGPT 风格破坏性压缩,滚动摘要替换并删除旧原文,无归档、无工具 |
naive_chain |
Baseline 3:朴素时序摘要链,只追加、超预算丢弃最老摘要,无归档、无 OVERRIDES、无工具 |
公平性处理(写进报告前请先读):
- 四套系统共用同一个 LLM、同一个回答契约、同一个工具循环框架(
agents/base_agent.py),差异只来自记忆机制。 - 四套系统都保留同样的近期窗口;只有
three_layer拥有归档工具——这正是对照点:基线设计上就没有可查的地方。 Tool_Call_Success_Rate对无工具系统记为n/a(而不是 0 成功),避免把"设计上没有工具"算成失败。- baseline 的回答质量在 离线 heuristic 后端下不代表真实能力(它是规则匹配,不擅长跨语言/语义对齐)。 heuristic 的用途是验证管道:覆盖是否发生、归档是否可查、工具是否被正确调用、指标是否正确统计。
4.1 指标定义
| 指标 | 定义 |
|---|---|
Current_Fact_Acc |
current_fact 探针正确率(问当前最新值) |
History_Fact_Acc |
history_fact 探针正确率(问被覆盖的历史值) |
Avg_Active_Chain_Tokens |
每 episode "记忆链" token 均值。口径:three_layer 只计入活跃摘要正文 token(id/标签/raw_ref 不计),即题设指标的严格定义;full_context 记全部原始上下文,memgpt_style 记滚动摘要,naive_chain 记摘要链文本——三者的"记忆"本身就是这些内容。同时输出 Avg_Active_Chain_Tokens_rendered(three_layer = 活跃链渲染行 + 滑动窗口的实际上下文开销),便于做同口径上下文成本对照。解析器用 tiktoken:cl100k_base,无 tiktoken 时回退内置字符类估算器 heuristic:v1,口径记录在 run_metadata_*.json |
Tool_Call_Success_Rate |
成功执行且解析到目标 id 的工具调用 / 工具调用尝试次数(另有 Tool_Call_Parse_Rate 区分"格式对"与"id 对") |
4.2 数据集
- 自制 JSON(主用,便于受控实验)——
data/sample_episodes.json即为示例:{"episodes": [{ "episode_id": "demo_zh_1", "dialogues": [{"user": "我的工位在3楼。", "agent": "好的"}, {"user": "我的工位改到7楼了。", "agent": "已更新"}], "facts": {"office floor": [["0", "3楼"], ["3", "7楼"]]}, "probes": [ {"type": "current_fact", "fact_key": "office floor", "question": "我现在工位在几楼?", "answer": "7楼"}, {"type": "history_fact", "fact_key": "office floor", "question": "在改成7楼之前呢?", "answer": "3楼"} ] }]}
逐轮对话也接受["user","agent"]二元组形式,probes 可省略(只跑记忆行为,不做 QA 计分)。 - MEME(
meme-benchmark/MEME):字段别名表见memory3l/dataset.py::MEME_ALIASES,加载器对字段命名宽容; 本地导出直接--dataset path.json,或用--dataset-name meme-benchmark/MEME(需datasets包与网络)。 务必先--inspect确认 schema 与探针类型识别结果。 - 内置合成集(
--synthetic):受控事实更新,自带"改前值"标注与答案, 是唯一能让 Current / History 两类指标可分离的数据构造,也是单元测试的基础。
4.3 动态 memory-point 探针(让工具指标非空)
数据集往往不写具体 summary_id,于是归档工具永远不会被调用、Tool_Call_Success_Rate 恒为空。
--memory-point-probes N(默认 1)在对话灌入之后动态生成探针:
- 支持工具的系统:指出某条已离开活跃链的
summary_id,问它写了什么,gold 即其正文 —— 模型无法猜,必须调工具; - 不支持工具的系统(如破坏性基线):指出某条原始
reference_id,问那轮原话 —— 记录已被删除就无法回答,这是真实测量而非惩罚。
4.4 输出产物
一次实测(合成集 20 episode,离线 heuristic 后端,SQLite 混合存储;这是管道验证,不是模型能力结论):
system CurAcc HisAcc ChainTok CtxTok Arch Ovrd ToolOK
three_layer 0.85 0.8219 74.1 373.6 3.65 73 1.0
full_context 0.0 0.0 306.4 306.4 0 0 n/a
memgpt_style 0.3667 0.0 0.0 89.1 0 0 n/a
naive_chain 0.3667 0.0 216.0 204.5 0 0 n/a
results/
├── predictions.csv # 逐 (system, episode) 指标行
├── probe_predictions.csv # 逐样本预测(问题/gold/预测/判定方式/工具数/耗时)
├── metrics.csv # 汇总指标表(每系统一行)
├── episode_logs.jsonl # 每个 episode 的完整日志:逐轮 stats + 逐探针结果 + 记忆终态
├── run_metadata_<run_id>.json # 本次运行的完整配置快照 + LLM/裁判统计
└── logs/evaluation.log
断点信息写入 SQLite(episode_progress),--run-id 相同即续跑;--force 忽略断点重跑。
存储层用 <system>/<episode_id> 命名空间隔离,四套系统共用同一个 SQLite 也不会互相读串归档
(摘要 id 是确定性的,不加前缀会撞车——这一点对评测正确性很关键)。
5. 配置
全部配置在 config.py,且都可用环境变量或 .env 覆盖(--flag 优先级最高)。常用项:
| 变量 | 默认 | 说明 |
|---|---|---|
STORE_BACKEND |
memory |
memory / redis_sqlite_hybrid |
REDIS_HOST / REDIS_PORT / REDIS_DB |
127.0.0.1 / 6379 / 0 |
Redis 热点层 |
REDIS_KEY_PREFIX |
episode:{episode_id} |
必须含 {episode_id} 占位符 |
SQLITE_PATH |
./exp_memory.db |
冷持久化 + 断点 |
RECENT_WINDOW_TURNS |
4 |
滑动窗口轮数 |
ACTIVE_CHAIN_TOKEN_LIMIT |
800 |
活跃链容量压缩阈值 |
CAPACITY_MIN_MERGE / CAPACITY_MAX_MERGE |
2 / 4 |
容量合并批量 |
RAW_CONTEXT_TOKEN_LIMIT |
4000 |
原始上下文预算(baseline1 截断点 / baseline2 压缩触发) |
MAX_TOOL_ITERATIONS |
4 |
单题工具调用上限 |
LLM_BACKEND / MODEL_NAME / TEMPERATURE |
自动探测 / 见下 / 0.0 |
回答模型。设了 DEEPSEEK_API_KEY 就默认 deepseek,否则有 OPENAI_API_KEY 用 openai,都没有才回退 ollama |
DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL / DEEPSEEK_MODEL |
.env / https://api.deepseek.com / deepseek-flash |
DeepSeek(OpenAI 兼容,仅用 requests,无需 SDK)。deepseek-v4-pro 更强但更慢更贵 |
LLM_MAX_RETRIES / LLM_RETRY_BACKOFF |
3 / 2.0 |
429/5xx/断连的指数退避重试,避免限流丢样本 |
SUMMARIZER_FORMAT_RETRY |
true |
摘要器漏写 [OVERRIDES] 标签时追加一次格式提醒(是否触发计入 format_retries) |
CAPACITY_MIN_GAIN |
0.15 |
容量合并必须让活跃链至少缩小 15%,否则拒绝合并(计入 capacity_merges_rejected)。真实模型确实会产生"越合并越大"的输出 |
STRICT_OVERRIDE_IDS |
true |
丢弃不在活跃链中的(幻觉)override id |
SELF_WRITE_MEMORY |
true |
让回答的那次调用顺便写本轮摘要(<MEMORY_UPDATE> 块),一轮只花 1 次 LLM 调用;块缺失时自动回退到专用摘要器。见 §5.5 |
JUDGE_MODEL_NAME |
空(复用回答模型) | 裁判模型,可用更强模型单独判分 |
SUMMARIZER_BACKEND / SUMMARIZER_MODEL_NAME |
空 | 摘要可用更小的模型 |
DATASET_PATH / OUTPUT_CSV_PATH |
见文件 | 数据与输出 |
JUDGE_BACKEND |
auto |
auto / llm / string |
LOG_LEVEL |
INFO |
批量跑保持 INFO,DEBUG 逐轮日志 IO 很重 |
5.1 可视化页面:看见每一次提问走了哪条路径
python web_ui.py --llm-backend deepseek --model deepseek-flash --num-episodes 3 --synthetic-turns 30
# 打开 http://127.0.0.1:8010
页面围绕一个 JSON 契约(GET /api/state、POST /api/load、POST /api/ask)把检索路径摊开:
- 四层记忆面板:活跃链(含
[OVERRIDES]、[FACTS]、raw_ref)、滑动窗口、归档库(标注 "被覆盖 / 容量淘汰 / 被谁取代 / 合并自几条")、原文库,逐条可展开。 - 检索路径追踪:每一步显示模型原始输出、解析出的工具调用、每个工具的
path(例如anchor=7楼 -> recorder s017 -> it overrode s002),以及候选表—— 工具考虑过哪些摘要、各自取值、哪个被选中/落选、理由是什么。这一步是回答"为什么找错"的关键。 - 标准答案核对(本地、不需模型):点数据集探针会自动带上 gold 答案,页面直接算出 gold 值在四层里各出现在哪里,并给出结论:"值存在于记忆中——答错就是检索路径问题,不是信息丢失"。
- 本轮日志:灌 episode 时逐轮的覆盖/压缩事件;提问后显示 LLM 调用数、工具命中率、token、耗时。
写这个页面的过程中它就抓到了两个真 bug:get_predecessor_summary 的锚点方向写反
(永远返回最新值)、以及 _slot_value 在自然语言摘要上把整句当取值。修复后同一个问题
从"2 次工具调用、第 1 次选错"变成"1 次调用直接命中"。
5.2 层级设计(索引层):实现与实测
这是"目录式记忆"的落地:摘要不再被改写合并,而是在其上加一层标题/索引。
⓪ 索引层 INDEX_LAYER - <index_id> [INDEX turn 5-8, 3 entries] 会议时间9点→下午2点;团队人数6人
└ 预览 1 条下级目录项
① 活跃摘要链(未收纳) - <summary_id> [OVERRIDES: ...] (raw_ref: ...) 正文
①b 已收进索引的下级摘要 仍然按 summary_id 可读(工具 expand_index / get_archived_summary)
② 归档摘要库 被覆盖的旧摘要,永久
③ 原始对话库 全部原文,按 reference_id 精确取
- 检索路径 = 标题 →
expand_index(index_id)拿到目录项 →get_archived_summary/get_raw_record钻到原文; 索引只是"指过去",从不替换或删除下级摘要。 - 建索引时机:链条渲染 token 超预算且未收纳摘要 >
INDEX_KEEP_RECENT时,取最老的INDEX_GROUP_SIZE条建一个目录项。 - 开关:
CHAIN_STRATEGY=index(默认)/merge(旧的"多条合并成一条",保留用于对照);--chain-strategy index|merge、--index-keep-recent、--index-group-size。 - 页面已在"记忆四层"里显示 ⓪ 索引层与 ①b 已收纳摘要。
实测对照(40 轮中文长 episode ×5,DeepSeek-flash,活跃链预算 400 token)
| 指标 | index(你的层级) |
merge(旧合并) |
|---|---|---|
| Current_Fact_Acc | 0.925 (37/40) | 0.800 (32/40) |
| History_Fact_Acc | 0.970 (32/33) | 0.909 (30/33) |
| 渲染链 token / 轮 | 2619 | 476 |
| 若不分层、全平铺 / 轮 | 8824 | 1072 |
| 归档摘要 / 轮 | 24.2 | 74.4 |
| 容量合并 / 轮 | 0 | 24.6 |
诚实的解读(我之前的预测是"层级能提升历史召回且更省 token",只对了一半):
- ✅ 精度两个指标都更好:当前事实 +12.5 个点,历史事实 +6.1 个点。理由是合并会把一个属性的多段 历史揉成一条混合摘要,而索引让每段历史都保持独立可取。
- ❌ 并没有更省 token,反而贵 5.5 倍。原因是:被收进索引的下级摘要仍然算作活跃摘要,
继续占用预算(
active_chain_tokens()目前把"已收纳但未展开"的条目也算在内),于是系统被推着 不断建新索引。当前实现的价值在精度,不在成本;"标题化省 token"还没兑现。
要让"省 token"也兑现,需要三件事(尚未做):
- 按实际渲染物计预算:被收纳的下级摘要不计入
active_chain_tokens(),只在expand_index时才付费。 - 懒展开渲染:链条涨大后,prompt 只给索引层 + 最近
KEEP_RECENT条,其余全部靠工具按需展开。 - 递归索引:索引条目数超阈值时再往上加一层,形成真正的多级目录。
5.3 懒展开(v2:已修正;它本身不省 token,省 token 的是"挂到标题下")
前两版的问题是"收纳了但没省":被标题收录的摘要仍然逐条渲染,于是链条超预算 → 索引继续建 → 标题和预览反而变成新的开销(最坏一次跑出 800 个索引条目、1 万多 token 的 prompt)。
⚠️ 更正(代码审查后)。 本节原先称懒展开是"真正把成本压下去的那一步"。审查发现:
chain_summaries()从来没有排除LAZY_INDEX_ID,所以"懒存"的摘要一直照常渲染——契约没实现;- 判定"两跳成员"的逻辑读的是已经被折叠删除的子目录 id,恒为空集;
- 即使修好,它对渲染也没有增量贡献:
chain_summaries()按 live 目录项的members过滤, 而超级索引的members是传递闭包,两跳成员本来就不渲染。也就是说:已归档实验结果里
lazy_summary_count全部为 0,懒展开从未生效过, 那段 A/B 里index比merge省下的 token,全部来自"挂到标题下 + 超级索引折叠"。 现在chain_summaries()已排除LAZY_INDEX_ID,covered 判定改为读 live 超级索引自身, 并把"把未归档摘要转成懒存"这条分支删掉了——那种摘要没有任何指针,藏起来就是真的不可达。 懒展开现在的定位是两跳成员的可观测簿记,不是成本机制。
现在的渲染契约(三分法,每一层都可按 id 取回):
| 状态 | 是否进 prompt | 怎么取回 |
|---|---|---|
被标题引用(index_id 指向某个目录项) |
不渲染(只在目录里占一行标题) | expand_index(index_id) 展开该目录,或直接 get_archived_summary(summary_id) |
| 未进任何目录 | 渲染(这就是"活跃链"的本义) | 直接可见 |
懒存(index_id = __LAZY_SUMMARIES__,即所属标题已被上层折叠) |
不渲染(v2 起才真正成立) | 按 summary_id 精确取回 |
配套的稳定性护栏(上一轮跑飞的直接教训):
INDEX_MAX_PER_TURN(默认 2):每轮最多新建 2 个目录项,硬上限,杜绝失控循环;- 标题硬上限(
IndexEntry.MAX_TITLE_CHARS,默认 60 字):标题是"指路牌"不是内容; - 成本 gate:索引条目成本 ≥ 被替换摘要成本就拒绝(
INDEX_MAX_PER_TURN+index_rejections可观测); - token 单调性检查:收纳后渲染成本没下降就停手并告警(
no token progress),而不是继续堆; - 超级索引折叠后重指成员归属(否则成员会因"旧 index_id 已不存在"而重新变成未收纳,成本反弹)。
离线实测(40 轮中文 episode ×4,heuristic 后端,预算 400 token):
| 指标 | 数值 |
|---|---|
| Current_Fact_Acc | 0.188(离线规则后端对中文问句的抽槽能力有限,不代表架构) |
| History_Fact_Acc | 0.962(26 题 25 对) |
| 渲染链成本 | 167 tok/轮(不做分层要 2378 tok) |
| 上下文成本 | 502 tok/轮 |
| 目录项 / 懒存 | 2.0 / 0.5 条每轮 |
| 契约:全部摘要按 id 可取回 | 28/28 ✅ |
注:
Current_Fact_Acc在离线后端下偏低是评测器的限制(它靠正则抽属性名,中文自然语言问句 "我现在的工位楼层是什么?" 抽出的槽位与摘要里的规范槽位对不上)。真实模型下该指标此前测得 0.958。 两个后端的用途不同:离线跑得快、用来验证机制与成本;真实模型用来出结论。
5.4 真实验证时发现的两个膨胀问题(一个已修,一个待修)
懒展开在离线环境已验证(见 5.3),但真实模型下暴露了两个失控问题,必须记录清楚:
问题 A(已修):摘要自我复述 真实模型会把"看到的整条活跃链"复述进新摘要,逐轮自我叠加。跑到第 21 轮时, 摘要器的请求达到 184 万 token,超过模型上限(1M)持续报 400,而流程没有停。
两道防线(互相独立):
MAX_SUMMARY_OUTPUT_TOKENS(默认 220):单条摘要硬截断,超长计数summary_truncations;SUMMARIZER_MAX_INPUT_TOKENS(默认 3000):喂给摘要器的链条容量上限,超了只给最新几条 (覆盖判定仍用全量链条,所以裁剪只影响"展示"不影响"正确性")。
修复后 maximum context length 报错降为 0。
问题 B(已修):Redis 写放大导致"内存链条"虚高
现象:同一轮内预算循环反复迭代,日志出现 chain is 4,354,084 tokens, showing the newest 37/56868 entries,而 SQLite 里只有 21 行摘要——内存看到的规模与库里数据不一致。
定位过程(这次靠新加的完整性断言一次抓准):
ERROR integrity at fill_counts: store returned 610129 summaries
but only 39 distinct ids (610090 duplicates)
根因:RedisHotStore.add_active_summary 用的是无条件 RPUSH。而管理器会合法地重写
同一条摘要(懒存扫描要改它的 index_id),于是 Redis 的 id 列表里同一条被反复追加;
读取时 HMGET 按重复 id 逐条取回,读放大成 61 万条。
一个关键教训:之前一直以为"Redis 不可用、已降级到 SQLite"。实际上
config.py会把.pylibs加进sys.path,Redis 从那一刻起就是通的——所以--store redis_sqlite_hybrid一直在走 真实 Redis 路径,这个写放大 bug 也因此一直被真实地触发,只是被误判成"循环失控"。
修复(两处,写入治本 + 读取兜底):
RedisHotStore.add_active_summary改为幂等 upsert:先LREM再RPUSH, 保证"一个 summary_id 占一个槽位",同时保持顺序(新→尾);RedisHotStore.list_active_summaries读取时按 id 去重并告警,历史遗留的重复列表 不会再被放大;- 新增
STRICT_INTEGRITY开关 +MemoryManager._integrity_check(): O(n) 校验"管理器视图 == store 视图",发现重复或单条超限时打印调用栈。 这类静默不一致以后不会再靠猜。
修复后验证(真实 DeepSeek,40 轮中文 episode ×1):
| 项 | 结果 |
|---|---|
| 处理轮数 | 40/40(此前在同一场景失控) |
maximum context length 报错 |
0 |
| 完整性告警 / 循环停止 / 摘要截断 | 0 / 0 / 0 |
| Current_Fact_Acc / History_Fact_Acc | 8/8 · 8/8 |
| 渲染链 token | 141.6(预算 400 内) |
| 工具调用 | 22/24 命中 |
| 耗时 | 111.7 秒/ episode |
结论:问题 A(摘要自膨胀)+ 问题 B(Redis 写放大)修复后,真实模型下长 episode 已经稳定, 可以重新用于批量评测。
5.5 一次调用同时"回答 + 写摘要"(self-write)
问题:原来一轮对话要花 两次 LLM 调用——一次回答用户,一次专门让摘要器总结这一轮。 摘要器调用占了这个设计的主要成本(494 轮的 LongMemEval haystack = 494 次额外生成)。
做法:让回答的那一次调用顺便把摘要写了。回答调用本来就已经同时拥有"这一轮新对话" 和"当前活跃链",它完全有条件输出同样的摘要语法;只要在 system 里加一段自写指令,要求它在 本轮最后一次回复的末尾附上一个块:
<MEMORY_UPDATE>
一句话摘要
[FACTS: 属性=值; 属性2=值2]
[OVERRIDES: s003]
</MEMORY_UPDATE>
解析用同一个 parse_summary_response,落库走同一条 _apply_generated 链路
(override 解析 → 归档 → 索引维护 → token 记账)。唯一的差别是 _finalise_generated() 这个
共享尾巴:两个来源(专用摘要器 / 自写块)在这里被归一成同一种"生成结果",所以 override 图
不关心摘要从哪来。
三个关键设计点:
- 块在 system role 里声明:工具循环会追加消息,指令必须活到最后一轮,不能放在 user 里。
- 块与答案分离:
split_selfwrite_reply()把块从可见答案里剥掉——用户、judge、CSV 看到的 只有答案正文,机器可读载荷不会污染答案(这是最容易被忽略、但会直接毁掉评测的一点)。 - 机会式收割 + 回退:
_apply_generated之前先校验块(harvest_selfwritten()), 校验通过才写 raw record。块缺失/不可解析 → 自动退回原来的专用摘要器调用; 所以"模型不听话"只会多花一次调用,不会丢一次记忆更新,也不会写重复的 raw record。
真实 DeepSeek 验证(deepseek-chat,内存存储,2 轮):
| 项 | 结果 |
|---|---|
| 两轮对话 LLM 调用数 | 2(自写路径)vs 4(原路径) |
| 第 1 轮 | self_written=True,[FACTS: 居住城市=上海; 宠物猫名字=毛毛] |
| 第 2 轮(改城市) | 模型自写 [OVERRIDES: s001] → 短 id 正确映射为规范 id,s001 归档、s002 上链 |
答案泄漏 MEMORY_UPDATE |
无 |
| 回退路径单测 | 块缺失 → 仍调用摘要器,活跃链/归档完全一致,raw record 恰好 1 条 |
适用边界要说清楚:批量评测集(LongMemEval / MEME)里每一轮的 assistant 回复本来就是数据集
给的,没有"回答调用"可以搭载,所以这条路省不了 ingestion 的钱——它省的是真实交互
(chat_debug.py / 可视化页面的 /api/turn)的那一半调用。批量评测要降本得从别处下手
(按 session 粒度摘要、只对含事实的轮次调模型,见 §8)。
开关:config.SELF_WRITE_MEMORY(环境变量同名,默认开);chat_debug.py --self-write/--no-self-write。
指标里新增 self_written_turns / self_write_fallbacks,回退率是可观测的。
5.6 当前值登记表(<CURRENT_VALUES>,新增)
索引标题本来是"当前值"的唯一顶层出口,但它会截断、按组重算、还会在成员被覆盖后过期—— 真实数据里的决定性失效模式(值只存在于某个索引成员里)正是这么来的。
登记表把"当前值"从标题的职责里摘出来:从活跃摘要已有的 fact_keys 派生
"每个槽位的最新值",固定渲染在滑动窗口之后、索引层之前:
<CURRENT_VALUES>
工位楼层=12楼; 会议时间=下午2点
</CURRENT_VALUES>
- 零额外 LLM 调用:
fact_keys本来就存在摘要上; - 不会过期:被覆盖/被合并的摘要自然不再贡献(它是派生的,不是增量维护的第二份真相);
- 成本:约 10~15 tok/槽位,实测 2 个槽位 10 tok;上限
CURRENT_VALUES_MAX_SLOTS(默认 12), 超出时保留最近更新的槽位; - 开关:
CURRENT_VALUES_ENABLED(默认开);成本单独记在current_values_tokens/avg_current_values_tokens,也计入渲染口径(Avg_Active_Chain_Tokens_rendered)与预算触发。
它同时也是"容量合并/索引归档不得吃掉最新事实"这条规则的结构性保障: 即使某条摘要被归档或折叠,它的当前值仍然在顶层可见。
5.7 指标口径(v2 修正)
Avg_Active_Chain_Tokens:四个系统现在都上报每轮均值,且都是实际渲染的量。 旧版把three_layer的每轮均值、naive_chain的终态总量、full_context的未截断存储量 混在同一列里比较。Avg_Active_Chain_Tokens_rendered:渲染口径(three_layer = 链 + 索引 + 登记表 + 滑动窗口)。Memory_Point_Acc/_n(新增):MEMORY_POINT探针的答题正确率。此前只累计到每 episode 的 CSV,从未聚合,"工具调用成功但归档内容答错"在总表上没有任何体现。Tool_Call_Unparsable的 per-probe 列此前恒为 0(日志条目没有parsed键)。
6. 项目结构
config.py # 全部可配置项(环境变量/.env 可覆盖)
evaluation.py # ★ 主实验入口:批量跑数据集、多基线、指标、CSV、断点续跑
chat_debug.py # 辅助:命令行交互式调试(默认内存存储,不需要 Redis)
web_ui.py + web_ui.html # 可视化:索引层/四层记忆 + 每次提问的检索路径追踪 + gold 值核对
memory3l/
├── models.py # 数据模型 + ToolCall/ToolResult/MemoryTurnStats
├── memory_manager.py # ★ MemoryManager:三层维护、事件覆盖、容量压缩
├── audit.py # ★ 可审计面:append-only 台账、I1–I5、AuditReport
├── service.py # ★ AuditService:审计面唯一实现(sidecar 与 MCP 共用)
├── temporal.py # SCD-2 时态投影、契约查询、异常检测、DDL
├── gate.py # 摘要器内容门控(4 档)
├── prompts.py # 摘要/合并/Agent/裁判 prompt,上下文拼接顺序
├── llm.py # LLM 后端:ollama / openai / heuristic(离线) / scripted(测试)
├── tools.py # 文本 function call 解析 + 两个归档工具执行器
├── token_utils.py # token 估算(tiktoken 优先,字符类估算回退)
├── dataset.py # 数据集加载:自制 JSON / MEME / 合成 + schema 诊断
├── agents/
│ ├── base_agent.py # 统一工具循环、指标采集、Agent 工厂
│ ├── three_layer.py # 本方案(MemoryManager 适配器,逻辑全在 manager)
│ └── baselines.py # 三个基线系统
└── store/
├── base.py # BaseMemoryStore 抽象 + InMemoryStore
├── redis_store.py # Redis 热点层(命名空间/SCAN 清理/可降级)
├── sqlite_store.py # SQLite 冷层(含 fact_ledger;可单独驱动 manager,无需 Redis)
└── hybrid_store.py # 混合存储(SQLite 为真源,Redis 可丢)
audit_server.py # 只读审计 sidecar(HTTP)+ 幂等事实写入
audit_check.py # 审计运行器 + 四个必须失败的对照
gate_sweep.py # 门控前沿扫描 + 真实数据证据轮诊断
memory3l-mcp/ # ★ MCP 服务(PyPI 包)+ 跨 agent 技能,见 AUDIT.md 第十三节
tests/
├── test_core.py # 解析、覆盖语义、容量压缩、窗口、工具循环
└── test_store.py # episode 隔离、Redis 命名空间(假客户端)、SQLite 持久化、冷库单独可用
data/sample_episodes.json # 手写示例数据集(中英、含更新与无更新对照)
运行测试(无需网络 / Redis / 模型):
python -m unittest discover -s tests -v # 109 tests
7. 设计评审:原方案的 6 个坑 & 本实现的处理
这些是真跑起来才会暴露的问题,都会直接影响评测结论,建议在技术报告里显式记录:
-
get_archived_summary(A)只返回旧值,会把模型带到沟里。 多跳覆盖 A→B→C 时,问"原来是什么"得到 A 的值没问题,但问"现在是什么"若误查 A 就答错。 → 归档摘要在覆盖时写入superseded_by,工具返回时沿链前向回溯并附注"当前值来自哪条"。 -
ArchivedSummary.raw_ref_id单字段撑不住容量合并。 合并 N 条摘要必须引用 N 条原文。→ 增加raw_ref_ids,raw_ref_id保留为首个引用(兼容题设字段)。 -
容量压缩的输入选择没定义,且会误伤最新事实。 → 规则:优先选"早期、未携带覆盖关系"的摘要,从不吞并本轮新摘要;
CAPACITY_MIN/MAX_MERGE控制批量; 若模型仍输出[OVERRIDES],强制剥离(容量合并按定义不产生覆盖标签)。 -
reset()语义冲突:清热点 vs 工具查历史。 若reset连归档一起删,History_Fact_Acc 直接不可测。 →reset(episode_id)只清该 episode 的热点(Redis + 活跃链/窗口镜像),归档与原文在 SQLite 永久保留。 -
多系统共用一个数据库会互相读串归档。 摘要 id 是确定性的(episode+序号),四套系统会生成同名 id。 → 存储命名空间
<system>/<episode_id>,reset与get_archived_summary均按该前缀隔离(写入 CSV 的episode_id仍是原始 id)。 -
激活链的 token 监控口径不明,且 tokenizer 不稳定。 → 双指标:
active_chain_tokens(渲染行,含 id/标签/raw_ref,用于触发压缩)与active_chain_text_tokens(仅正文,用于上报Avg_Active_Chain_Tokens);tokenizer 名称写入元数据; 字符类估算器保证单调性,压缩触发稳定可复现。
7.1 DeepSeek 实测:两个数据集、两种结论
用 deepseek-flash 跑了两组数据(Redis 热点 + SQLite 冷存 + 断点续跑):
A. 短 episode(12 轮 × 10 个,无损区间) — 没有任何系统需要压缩,因此:
| system | Current_Fact_Acc | History_Fact_Acc | 记忆 token | 说明 |
|---|---|---|---|---|
| three_layer | 1.00 | 0.857 | 161 | 与全上下文同水平精度,1/1.9 的 token |
| full_context | 1.00 | 0.914 | 302 | 原始上下文本来就装得下 |
| memgpt_style | 0.50 | 0.114 | n/a(滚动摘要 199) | 破坏性压缩把旧事实真的删掉了 |
| naive_chain | 1.00 | 0.914 | 334 | 链没超预算,等于没有压缩 |
结论:episode 太短时该架构无差别——这是评测设计的坑,不是系统的优点。
B. 长 episode(40 轮 × 6 个,真实多会话量级,中文) — 历史必须经过归档检索。
下表 run-id=final_v1(修复后的最终代码,四套系统同一次运行):
| system | Current_Fact_Acc | History_Fact_Acc | 活跃链 token | 上下文 token | Tool_Call_Success | 事件覆盖 | 容量合并 |
|---|---|---|---|---|---|---|---|
| three_layer | 0.958 | 0.457 | 348 | 863 | 0.988 | 108 | 340 |
| full_context | 1.000 | 0.848 | n/a(无摘要链) | 2065 | n/a | 0 | 0 |
| memgpt_style | 0.229 | 0.000 | n/a(滚动摘要) | 200 | n/a | 0 | 0 |
| naive_chain | 0.646 | 0.000 | 770 | 807 | n/a | 0 | 141 |
three_layer 逐 episode:当前事实 8/8、8/8、8/8、7/8、8/8、7/8;历史事实 4/9、4/9、3/6、2/6、3/9、5/7。
修复前后对(同一数据集、同一 seed=777、同一模型,仅代码不同):
CurAcc 0.479 → 0.958,HisAcc 0.043 → 0.457,ToolOK 0.764 → 0.988。
历史准确率在不同随机性下会波动(独立复跑一次得到 0.565),报告时请连同置信区间/方差一起写,
6 个 episode 的样本量不足以给出小数点后三位的结论。
结论(这是本项目最该写进报告的一段):
- 本方案用 约 1/6 的上下文 token(863 vs 2065,活跃链本体 348)换到 0.958 的当前事实准确率 (全上下文 1.000),并把两个摘要型基线(0.229 / 0.646)远远甩开—— "摘要 + 归档 + 精确回查"这套组合是成立的。
- 被覆盖历史的准确率只有 0.457 < 全上下文 0.848:压缩本身是有损的,归档里存的也是 摘要而不是原文。也就是说:全上下文在你的上下文窗口装得下时,仍然是最强的记忆; 本方案的价值在上下文成本和不可装下时,不是在绝对精度。
- 这是当前实现的真实差距,不是评测 bug(工具成功率 0.994、覆盖链
superseded_by完整可查)。 想进一步缩小它只有两条路:① 归档存原文而不是摘要(token 成本上升,等于走向全上下文); ② 继续提高摘要与合并的事实保真度(本轮已加[FACTS: 属性=取值]、"最新事实不被合并" 与"合并必须缩小 15%"三道约束,历史准确率已从 0.043 提到 0.565)。
7.2 长 episode 首轮暴露的 5 个真实缺陷(均已修复)
第一次跑长 episode 时本方案只有 cur=0.479 / hist=0.043——比全上下文差得离谱。
逐条定位后修掉,最终 cur=0.958 / hist=0.565(同一数据集、同一 seed、同一模型):
| # | 缺陷 | 现象 | 修复 |
|---|---|---|---|
| 1 | 容量合并丢事实 | 活跃链只剩 2 条"杂项"摘要,最新属性值整批消失 → 当前事实准确率崩到 0.48 | 摘要器输出 [FACTS: 属性=取值];压缩时每个属性的最新摘要受保护,永不进入合并批次 |
| 2 | 合并反而变大 | 日志出现 550 tokens before, 1887 after,token 预算越压越大 |
CAPACITY_MIN_GAIN:合并至少缩小 15%,否则拒绝并保留原摘要(计入 capacity_merges_rejected) |
| 3 | 历史值检索手段不可靠 | 只能靠 get_archived_summary(id),模型在几十条归档里挑错前驱 |
新增精确属性查询 get_predecessor_summary(fact_key, exclude_value):按时间倒序返回该属性的直接前驱(仍是精确匹配,不是相似度检索) |
| 4 | 合并后的归档是混合体 | 查到一条合并摘要,里面混着多个属性,模型给出错误取值 | 合并摘要在归档时保留 merged_from 血缘,工具提示回查原始条目;_slot_value 修掉跨行取值 |
| 5 | DSML / 引用结果被误判 | 模型输出 `< | DSML |
另有三处工程性加固:幻觉 override id 丢弃并计数;每轮写入 SQLite 镜像以支持崩溃续跑时重建热点;
工具回显([TOOL RESULT ...])在解析前剥离,避免"模型引用工具结果"被误判成新的工具调用而死循环。
8. 已知限制
- 离线 heuristic 后端不是实验结论。 它无法做跨语言/语义判断(例如中文归档摘要回答英文问题)。
跑真实结论请用
--llm-backend ollama|openai;结果文件会记录llm_backend,便于甄别。 Avg_Active_Chain_Tokens在无tiktoken时是估算值(±10% 量级),跨系统比较不受影响(同一口径), 与论文数值对齐时建议pip install tiktoken。- MEME 各版本字段命名有差异,加载器用别名表兜底;若
--inspect显示num_probes_total为 0, 需在MEME_ALIASES/_coerce_episode里补一个字段名(改动很小)。 - 数据集中若没有
history_fact标注,History_Fact_Acc会是n/a;用--synthetic或 按 §4.2 的facts格式补标注,才能度量历史回溯能力。 - 容量压缩只做"摘要合并",不做语义去重(禁止 embedding),因此压缩率取决于摘要器质量。
- self-write 的槽位命名漂移。 自写块由回答模型生成,实测同一属性可能被写成不同名字
(第 1 轮
居住城市=上海,第 2 轮居住地=北京)。事件式 override 不受影响(模型直接引用了s001),但依赖槽位名相等的两处会受影响:容量压缩的"按槽位保最新值"和索引标题的_fact_digest。缓解方向是在提示里固定一小张属性名表(或用SELF_WRITE_MEMORY=0走专用 摘要器,它对槽位命名更稳定)。这一项尚未实现。
Release files for memory3l 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| memory3l-1.0.0.tar.gz | 360.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| memory3l-1.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 544.5 kB
Release files / memory3l-1.0.0.tar.gz
| Download URL | memory3l-1.0.0.tar.gz |
|---|---|
| Size | 360.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
3ac65b9413501337d1eca3096d45b8633732ba7bd2ffc58670607b28e4688b5d
|
|
BLAKE2b-256 checksum How to use checksums |
da6b17f90f88daf41a4fe2790b7ad7b356d2dcac81e7cae27db6efc65eeffa8a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.9.7
|
Release files / memory3l-1.0.0-py3-none-any.whl
| Download URL | memory3l-1.0.0-py3-none-any.whl |
|---|---|
| Size | 183.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
cfb4e570ac556ccb409808ef074968a41738c571a2f48c4b42f8c0339e4e9b07
|
|
BLAKE2b-256 checksum How to use checksums |
f9b9db24140f4b3e1a80e9895366f87b7132f675e144c273b29be022b2279bac
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.9.7
|