Vibe-Trading 开源项目的记忆分层:四个模块与它们的新麻烦
本文基于 Vibe-Trading 仓库 commit 3a752d5(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/HKUDS/Vibe-Trading 最新代码与文档为准。
这里说的 Vibe-Trading 不是「凭感觉做交易」这个泛称,而是 HKUDS 开源的那个具体项目(仓库 HKUDS/Vibe-Trading,MIT 许可证),本文只拆它 agent/src/memory/ 这一个子系统的代码。
这套记忆系统真正的地基不是分层,而是「一个目录 + 一堆带 frontmatter 的 .md 文件」;分层、衰减、语义关联三块全是挂在这个地基上的可选增强,默认全关。 想明白这一点,你读这四个文件的姿势就完全不同了:不是去学一套「记忆架构」,而是去看一个已经能跑的扁平方案,在被逐块加上层级、生命周期、关联之后,各自换来了什么、又赔进去了什么。
站内已经写过通用的记忆分层方法论、Agent 记忆的整体概念和记忆污染问题,分别在 Agent 记忆分层的通用做法、Agent 记忆是什么 和 记忆污染与遗忘 里;这篇不重复那些结论,只逐行读 Vibe-Trading 这一个具体实现的代码,看它把方法论落到磁盘上之后长成什么样。
一、地基与总览:四个模块各站在哪一段
agent/src/ 下有 23 个模块目录,memory/ 是其中之一。这个目录里我数了一下是 7 个 Python 文件:__init__.py、persistent.py、hierarchy.py、lifecycle.py、semantic_links.py、compression.py、search_index.py。选题点名的四块,加上压缩与全文索引两个配套件,就是全部。
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| PersistentMemory | 落盘、扫描、关键词检索、写 MEMORY.md 索引、去重 | agent/src/memory/persistent.py | 只要记忆功能开着,每一次 save/recall 都走它 |
| MemoryHierarchy | 按 memory_type 把条目路由进分类子目录 | agent/src/memory/hierarchy.py | 打开 VT_MEMORY_HIERARCHY 之后 |
| MemoryLifecycle | 质量分强化、访问计数、重要度衰减、归档 GC | agent/src/memory/lifecycle.py | 打开质量分或 GC 开关,以及每次 recall 之后 |
| SemanticLinker | BM25 算相似度,把关联写成 .relations.json 侧车文件 | agent/src/memory/semantic_links.py | 打开 VT_MEMORY_LINKS,在 add 与 recall 时触发 |
| CompressionPipeline | raw → daily → digest 三级正文压缩 | agent/src/memory/compression.py | GC 跑起来、且压缩开关同时打开时 |
| MemorySearchIndex | SQLite FTS5 倒排索引,替掉顺序扫描 | agent/src/memory/search_index.py | 打开 VT_MEMORY_FTS_INDEX 之后 |
先看地基。persistent.py 顶上写着 MEMORY_BASE = Path.home() / ".vibe-trading" / "memory",所有记忆就是这个目录下的 .md 文件,每个文件头部一段 frontmatter,add() 里写死的字段包括 name、description、type、id、created_at、updated_at、keywords、quality_score、access_count、last_accessed、importance、related_memories、category、compression_level。新条目的 quality_score 和 importance 都固定初始化成 0.5。类型只有四种,MEMORY_TYPES = ("user", "feedback", "project", "reference"),传别的值 add() 直接抛 ValueError。
入口是 agent/src/tools/remember_tool.py 里的 RememberTool,工具名就叫 remember,四个 action:save、recall、forget、reinforce。另一头,agent/src/agent/context.py 的 build_system_prompt 会把 PersistentMemory.snapshot 注入系统提示——注意它的注释写得很明确,快照在会话启动时冻结(_load_snapshot 只在 __init__ 里调一次),为的是不破坏提示缓存。也就是说会话中途新写的记忆不会自动出现在系统提示里,要靠模型主动 recall 才捞得回来。这个取舍值得单独记住,它决定了「刚存的东西下一轮为什么没生效」。
二、层级:目录路由,与开启后会撞见的两处不一致
hierarchy.py 的文件头自己写了目的:把记忆按 memory_type 组织进分类子目录,让检索从 O(n) 的全量扫描变成 O(category_size) 的定向扫描。CATEGORIES 与 MEMORY_TYPES 一一对应,核心方法 route_entry(memory_type, filename) 返回 base_dir/{memory_type}/{filename},遇到未知类型打一条 warning 后退回基目录。scan_all() 会同时扫基目录和四个分类子目录,用 _SKIP_NAMES 跳过 MEMORY.md、.hierarchy.yaml、.lock、archive、gc.log——扁平时期的老文件因此不会丢。
问题出在接线处。persistent.py 的 add() 里,两个分支给出的文件名不是同一种东西:
if get_env_config().memory.hierarchy_enabled:
from src.memory.hierarchy import MemoryHierarchy
hierarchy = MemoryHierarchy(self._dir)
path = hierarchy.route_entry(memory_type, slug)
else:
filename = f"{memory_type}_{slug}.md"
path = self._dir / filename
扁平分支拼了 .md 后缀,分层分支传进去的是裸 slug。而 slug 由 _SLUG_DISALLOWED_RE.sub("_", ...) 生成,这个正则把点号也算作非法字符替换掉,所以它不可能自带后缀。于是分层开启后落盘的文件是没有扩展名的,而 scan_all()、scan_category() 的过滤条件都是 item.suffix == ".md"。你可以自己在仓库里对照这两处,判断当前 commit 下写进去的条目还扫不扫得回来。第二处连带影响是 _update_index():它写进 MEMORY.md 的链接只用 path.name,不含分类目录段,条目一旦进了子目录,索引里那条相对链接就指不到实际文件。
还有一层更容易被忽略的落差:hierarchy.py 里写得最细的几个能力,在主链路上其实没有调用点。我在 agent/src/ 全目录 grep 过 prune_search_scope、migrate_flat_entry,以及 rebuild_index(MemoryHierarchy 的那个),除了它们自己的定义处,src/ 下没有第二处引用,只有 agent/tests/memory/ 里的用例在覆盖。也就是说 .hierarchy.yaml 这个关键词摘要索引、按关键词重叠度给分类排序的检索剪枝、以及把老的扁平条目迁进子目录的迁移函数,目前是「写好了但还没接上」的状态。读的时候别把它当成运行时既成事实。
对你意味着什么:分层这一层现在能拿到的收益,主要是目录变整齐、按类别定向扫描成为可能;宣称的检索加速尚未在主流程兑现。真要开它,先自己写用例验证一遍写入与召回的闭环。
三、生命周期:质量分、衰减、归档,以及压缩的隐藏耦合
lifecycle.py 管三件事:强化、访问追踪、垃圾回收。强化的分值表是硬编码的:
_EVENT_DELTAS: MappingProxyType[str, float] = MappingProxyType(
{
"task_success": 0.1,
"task_failure": -0.15,
"user_confirm": 0.2,
"user_reject": -0.3,
"passive_decay": -0.05,
}
)
这是 MemoryLifecycle 的类属性,五个事件名就是 reinforce(name, event, source) 里 event 允许的全部取值,传别的名字会打一条 warning 并返回 False,不会静默改分。
两个细节很有工程味道。一是来源折扣:source == "system" 时 delta 乘 0.7,只有用户显式反馈才按满额算,把「Agent 自己觉得成功」和「人说这条有用」区分开了。二是会话内封顶,_MAX_SESSION_DELTA = 0.5,同一条记忆在一个会话里累计位移超过这个值就拒绝再改,防止一轮循环把某条记忆刷到 1.0 或者砸到 0。这两条约束在很多自建方案里是缺的,值得抄走。
衰减公式在 persistent.py 的 compute_importance() 里,模块注释称之为 Ebbinghaus 风格:HALF_LIFE_DAYS = 14.0,_DECAY_LAMBDA = math.log(2) / HALF_LIFE_DAYS,最终 raw = quality_score * (retention + access_bonus),其中 retention 是按距上次访问天数算的指数衰减,access_bonus 是 min(0.3, access_count * 0.1)。衰减关掉时这个函数直接返回 quality_score 原值。这个重要度不只是用来 GC,衰减开着的时候,find_relevant() 里还会拿它调排序权重:final_score *= 0.5 + 0.5 * entry.importance,也就是最惨的老条目排序分打对折,不会被彻底埋掉;衰减关着时这一行整个跳过,排序纯看关键词命中。
GC 的阈值同样写在类属性上:MIN_AGE_DAYS = 7(不到七天的条目一律跳过)、ARCHIVE_THRESHOLD = 0.15、DELETE_THRESHOLD = 0.05,以及 ENABLE_DELETE = False。最后这个常量的注释写着 Tier 1 只做归档,_execute_gc_action 里也确实有一行「force archive even if classified as delete」的兜底。run_gc(dry_run=True) 是默认值,跑一次只往 gc.log 追加决策记录不动文件。另外 MAX_MEMORY_COUNT = 500 这个常量定义了但在 run_gc() 里没有被读到,容量上限目前不是实际生效的裁决条件。
真正的耦合陷阱在 GC 尾巴上:三级压缩不是独立跑的,它挂在 run_gc() 内部,压缩开关打开、GC 开关关着的时候压缩永远不触发。项目自己也知道,run_gc() 开头那个提前返回分支里专门打了一条 warning 提醒这种组合。压缩本身由 compression.py 执行,should_compress() 用 DAILY_THRESHOLD_DAYS = 7 和 DIGEST_THRESHOLD_DAYS = 30 两级门槛,raw 超过七天没访问压成 daily,daily 超过三十天再压成 digest。daily 走 TF-IDF 抽关键句,digest 直接退化成关键词条目列表——compress_to_digest 输出的是 Key concepts: 加一串词,原文语义基本没了。压缩前会 archive_original() 备份到 archive/,备份失败就中止压缩,这个顺序是对的。
四、语义关联:BM25 侧车文件与它的保鲜期
semantic_links.py 做的事很克制:不引向量库,用纯 Python 实现 BM25(_BM25_K1 = 1.5、_BM25_B = 0.75),把一条记忆的正文当查询、其余记忆当文档集算分,超过 _MIN_SCORE_THRESHOLD = 0.3 的留下,按分降序取前若干条,写成与记忆文件同目录的 {stem}.relations.json。写文件用的是 tempfile.mkstemp + os.fsync + os.replace 的原子替换,崩溃不会留半截文件。上限有两个,_MAX_OUTGOING_LINKS = 10 和 discover_links(top_k=5) 的默认值,add() 调用时没传 top_k,所以实际生效的是 5。
召回侧的用法在 find_relevant() 末尾:关键词排序拿到结果之后,读每条结果的关联文件,把关联到的条目补进结果集,直到凑满 max_results。RememberTool._recall 还会额外把每条结果的前三个关联目标塞进返回 JSON 的 related 字段。这是典型的一跳扩展,成本可控。
它的新麻烦是保鲜期。关联只在 add() 里算一次,而且是拿当时磁盘上的全部条目当语料算 IDF。之后再写入新记忆,老条目的 .relations.json 不会重算;删除记忆时 remove_relations() 只删被删那条自己的侧车文件,别人指向它的链接留在原地成了悬空目标。悬空目标在召回时表现为匹配不上、静默忽略,不会报错——好处是不崩,坏处是你从文件里看到的关联图和实际能召回的关联并不是同一张图。另外 resolve_wikilinks() 那套 [[6位十六进制id]] 的显式互链解析,我在 agent/src/ 下同样只找到定义、没找到调用点,正文里手写 wikilink 目前不会被消费。
五、边界与代价
它明确不管的事。这套记忆没有向量检索,语义相似靠 BM25 词频,同义换词就断(search_index.py 的 CJK 处理是把连续汉字拆成 unigram 加 bigram 塞进 FTS5,不是语义嵌入)。它也不管多机共享:全部状态在本机家目录下,memory_index.db 是本地 SQLite,换台机器就是另一份记忆。快照冻结的设计决定了它不追求「记忆实时进提示」,会话中途的新记忆需要显式召回。
并发保护是有边界的。memory_lock() 第一行就是平台判断:sys.platform == "win32" 时直接 yield True 返回,不做任何加锁,fcntl.flock 那条路径只在非 Windows 上走。也就是说 Windows 下多进程同时写记忆没有互斥保护。即使在 Linux 上,锁超时也只有 _LOCK_TIMEOUT_S = 5.0,超时后 add() 的处理是打 warning 然后「best-effort write」照写不误。想在多 Agent 并行下用它,这块得自己补,思路可以参考 Agent 工作区隔离 里的做法。
存的是明文,且会被复制多份。记忆正文原样写进 .md,_sanitize_body 只剥控制字符,_truncate_body 只在超过 MAX_ENTRY_CHARS = 8000 时截断,没有任何加密或脱敏。同一段内容还可能被复制进 archive/(GC 归档与压缩备份)和 FTS5 数据库。这条对交易类工程尤其要紧:如果你让 Agent 把券商账号、API 凭据、资金授权相关的信息「记下来」,那它就是躺在家目录里的明文,且散落在多个副本里,备份、同步盘、崩溃转储都可能把它带走。凭据应该走独立的密钥管理,不进记忆目录。至于实盘链路本身,下错单在多数市场是不可撤销的,程序化交易的报备与合规义务因司法辖区而异,能不能这么接、要不要报备,以你所在司法辖区的监管要求与券商协议为准。
去重窗口很短。DEDUP_WINDOW_SECONDS = 30.0,只挡三十秒内的重复写入,注释说明白了是为了拦重试循环和并行调用的连发重复,不是长期语义去重。而且 add() 里这一步的判断条件是 _is_quality_enabled(),质量分关着的时候去重也一并关掉了。
每次召回都在写盘。track_access() 会给每条被召回的条目改 access_count 和 last_accessed,走的是完整的读文件、改 frontmatter、写临时文件、os.replace 流程。召回是读操作,代价却是一批文件写入,条目多了之后这笔开销要算进去。这类隐性写放大在做可观测时容易漏,值得单独埋一条计数。
六、上手与避坑清单
别指望改一个开关就全开。VT_MEMORY 是预设入口,env_schema.py 里 _PRESET_FLAGS 写死了三档:off 全关;on 只开 quality、gc、decay 三项;full 才把 hierarchy、links、compression、fts_index 一起打开。为什么会踩:文档里看到某个能力就以为默认可用,实际默认是 off,全部返回空。怎么避:先确认预设档位,再看单项 VT_MEMORY_* 是否覆盖了它——_apply_preset 的逻辑是显式设过的环境变量优先,没设的才用预设基线填。
开 hierarchy 之前先跑闭环验证。为什么会踩:route_entry 收到的文件名不带 .md,而所有扫描函数都按 .md 后缀过滤,写入和读取用的判据不一致。怎么避:在测试目录里 save 一条再 recall 一次,直接看 ~/.vibe-trading/memory/ 下生成的到底是什么文件名、能不能被扫到,不要凭模块注释推断。
开 compression 一定连着开 gc。为什么会踩:压缩逻辑写在 run_gc() 内部,GC 关着时压缩代码根本执行不到,你会看到「开了压缩但什么都没发生」。怎么避:要么 VT_MEMORY=full,要么手动把 VT_MEMORY_GC 一起打开;项目自己在这个分支上留了 warning,看日志就能确认。
第一次跑 GC 用 dry run 读日志。为什么会踩:run_gc() 的 dry_run 默认是 True,很多人反过来以为默认会执行,于是直接传 False 上真实目录。怎么避:先跑默认参数,去 gc.log 里看每条决策的 importance 和 reason,确认阈值和你的使用节奏匹配(七天最小年龄、0.15 归档线)再开执行。
给记忆起名要当心 slug 生成规则。为什么会踩:slug 由标题转小写后替换非法字符生成并截断到 60 字符,白名单只有小写字母、数字、下划线、连字符,外加正则里显式列出的几段非拉丁字符区间(汉字、CJK 扩展 A、泰文、阿拉伯字母、希伯来字母、西里尔字母),其余一律变下划线;带重音符号的拉丁字母、日文假名都在名单外。标题只差标点的两条记忆因此可能撞上同一个 slug,add() 里落盘走的是 path.write_text,后写的直接覆盖先写的,且不会有任何提示。怎么避:标题里带一段有区分度的稳定标识,不要靠标点、大小写或字符区间外的文字来区分。
别把 related_memories 当成关联的真相。为什么会踩:frontmatter 里的 related_memories 在 add() 时写成空列表,解析时还要求每项是 6 位十六进制才收;真正被消费的关联数据在 .relations.json 侧车文件里,两者不是一回事。怎么避:查关联去读侧车文件,别读 frontmatter 字段。
记忆写入的内容自己先过一遍。为什么会踩:remember 是模型自己决定调用的工具,is_readonly = False,模型觉得重要就存,包括它从对话里读到的任何内容。怎么避:在系统提示或工具描述层面框定哪些类别不许进记忆,并定期人工看一遍 MEMORY.md 索引——它最多只保留 MAX_INDEX_LINES = 200 行,超出部分会被截掉,这本身也是个需要盯的边界。上下文层面的取舍可以参考 Agent 上下文管理。
收尾:接下来该读哪个文件
把这四个模块串起来看,Vibe-Trading 的记忆做的是一件很朴素的事:先保证扁平方案能跑且零外部依赖,再把每一项增强都做成默认关闭的独立开关,允许其中一部分暂时只有测试覆盖、还没接进主链路。这种推进方式的好处是主路径始终稳定,坏处是你光读模块注释会高估它当前的实际能力——注释描述的是模块自己的设计意图,不等于运行时会走到那里。
给自己留三个检查动作:第一,任何一个能力,先 grep 一遍 agent/src/ 看有没有主链路调用点,只有测试引用的先当作未启用;第二,任何一个开关,先在临时目录里跑一遍 save/recall 闭环,用磁盘上的真实文件名和内容说话;第三,任何要写进记忆的内容,先问一句它值不值得以明文形式在家目录里存上几个月。
想继续往下读,建议按这个顺序:agent/src/tools/remember_tool.py 看清楚模型能对记忆做哪四件事,agent/src/agent/context.py 的 build_system_prompt 看快照怎么进提示,最后再回到 agent/src/config/env_schema.py 的 MemoryConfig,把七个开关和三档预设的关系对一遍。这三处读完,前面四个模块的行为边界基本就锁死了。
本篇属于一个把开源个人交易 Agent 项目 Vibe-Trading逐层拆开讲的系列,整体地图见 Vibe-Trading 是什么:HKUDS 这个开源交易 Agent 项目的工程全景与边界;沿着这条线往下,还可以看 开源交易 Agent 项目 Vibe-Trading 的上下文管理:组装、压缩工具与记忆压缩 和 Vibe-Trading 开源项目防模型编造数字的两层设计与代价。