oGMemory 介绍
当 CLI Agent 被持续使用时,上下文很快会从“当前这一轮对话”变成一个更复杂的问题:用户背景、偏好、历史事件、工具经验和项目状态都会不断积累,但模型窗口本身并不会真正记住这些信息。
oGMemory 要解决的正是这层上下文管理问题。它不是把对话简单存成日志,而是把有价值的事实、偏好、事件和经验整理成结构化记忆,并在写入、索引、检索、压缩和归档之间形成完整生命周期,让 Agent 在后续对话中能够重新使用这些长期上下文。
问题:Token 预算是瓶颈
每个 CLI Agent 运行在固定的 token 预算上。上下文窗口既是最昂贵的,也是最稀缺的资源。
| 症状 | 根因 | 缺失的生命周期阶段 |
|---|---|---|
| Agent 会遗忘对话早期的内容 | 窗口占满后旧内容被淘汰,没有持久化 | ④ afterTurn — 缺少抽取 + 持久化 |
| 跨会话重复犯同样的错误 | 没有跨会话传递经验的机制 | ⑥ session_end 归档 + ② bootstrap 冷启动 |
| 一次本该花 $0.05 的查询花了 $0.50 | 扁平检索加载整篇文档,摘要就够了 | ② assemble — 缺少分层检索 |
| 多 Agent 协作失效 | Agent 之间看不到彼此的工作上下文 | ④ afterTurn 跨会话共享 + 多租户隔离 |
| 长会话中上下文不断膨胀 | 没有系统化压缩 | ⑤ compact — 缺少信号评分 + 摘要链 |
这不是模型的问题。这是基础设施问题。ContextEngine 提供的正是缺失的那层基础设施。
设计哲学
核心洞察:上下文有生命周期
当前 RAG 系统把检索当作单一操作 — 向量化查询、搜索向量库、返回结果。这忽略了一个基本事实:Agent 系统中的上下文有完整的生命周期,就像数据库中的数据一样。
诞生 结构化 存储 索引 召回 压缩 归档
(从对话中 (按类型分类, (原子写入, (向量化 + (向量搜索, (摘要化, (会话结束,
抽取) 按策略路由) 有顺序保证) 写入 L0/L1/L2 分层展开, 去重, 归档,
IndexRecords) 按预算加载) 压缩) 状态快照)每个阶段有不同的约束。抽取必须增量。存储必须原子。索引必须异步。检索必须感知预算。压缩必须保留信号。只处理其中一两个阶段的系统把其余阶段留给了偶然。ContextEngine 覆盖完整生命周期。
六个拦截点
Agent 循环不是黑盒。它有明确的执行阶段。关键设计选择:在循环边界拦截,而不是在模型推理内部拦截。 所有上下文操作都发生在推理前后,绝不在推理过程中 — 零延迟影响。
┌──────────────────────────────────────────────────────────────────────┐
│ Agent Loop(无限循环) │
│ │
│ ┌─────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ │
│ │ ① │ │ ② │ │ ③ │ │ ④ │ │
│ │ 消息 │────▶│ 推理准备 │────▶│ 工具调用 │────▶│ 轮次结束 │ │
│ │ 到达 │ │ │ │ │ │ │ │
│ └──┬──┘ └──────────┘ └──────────┘ └────┬────┘ │
│ ▲ │ │
│ │ ┌──────────┐ ┌─────────┐ │ │
│ │ │ ⑤ │ │ ⑥ │ │ │
│ └─────────│ 压缩管理 │◀────────│ 会话关闭 │◀─────┘ │
│ └──────────┘ └─────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────┘| 阶段 | 时机 | 做什么 |
|---|---|---|
| ① 消息到达 | Agent 推理前 | 解析意图,预取候选上下文 |
| ② 推理准备 | 组装上下文窗口 | 冷启动注入 / 主题跟踪 / 预算规划 / 分层加载 / 去重 |
| ③ 工具调用 | 工具执行前后 | 注入工具技能 / 参数推导 / 结果压缩 / 事实抽取 |
| ④ 轮次结束 | Agent 完成一轮后 | 增量抽取 / 关系构建 / 冲突解决 / 异步索引 |
| ⑤ 压缩管理 | 上下文窗口接近满时 | 信号打分 / 淘汰保护 / 冗余合并 / 摘要链 |
| ⑥ 会话关闭 | 会话结束 | 任务归档 / 状态快照 / 完整性审计 |
上下文类型不平等
七类上下文,各有根本不同的生命周期行为。这不是随意分类 — 由信息本身的语义决定。单一的"用同一种方式存储一切"方案,要么丢失可变状态(如果仅追加),要么破坏不可变历史(如果覆盖写入)。
| 类型 | 为什么是这个生命周期? | 写入策略 | URI 模式 |
|---|---|---|---|
| Profile | 用户状态会变化 — "我住在北京"可能变成"我搬到了东京" | Merge — 冲突时新覆盖旧 | .../memories/profile |
| Preference | 偏好按主题累积,每个主题只有一个当前视图 | 按 slug 归并 | .../memories/preferences/\{slug} |
| Entity | 实体累积事实,但"项目 Alpha"仍然是"项目 Alpha" | 按 slug 归并 | .../memories/entities/\{slug} |
| Event | 历史不可变 — "3月15日完成迁移"永远不会改变 | 仅追加 | .../memories/events/\{event_id} |
| Case | 问题解决轨迹是历史记录 | 仅追加 | .../memories/cases/\{case_id} |
| Pattern | 模式从反复观察中涌现并随时间演化 | 按 slug 归并 | .../memories/patterns/\{slug} |
| Skill | 工具专长累积增长 — 经验越多知识越好 | 累积追加 | .../skills/\{skill_name} |
关键架构决策
为什么用 YAML 驱动 Schema?
问题:在 Python 中硬编码抽取 Schema,每新增一个上下文类型都要改代码。每次改动都要动抽取管线、策略路由、URI 解析三处。
方案:YAML Schema 声明式定义上下文类型。SchemaRegistry 在启动时加载所有 YAML 文件,PolicyRouter 和 URIResolver 根据注册信息自动适配。
不用这个方案会怎样:每次新增类型(比如"Decision"或"Handoff")都是一次跨三个模块的代码变更,引入回归风险。YAML 驱动让新类型变成"加一个配置文件"而不是"改三处代码"。
为什么用 Outbox 模式做异步索引?
问题:向量化 + 写入向量索引需要 100-500ms。如果同步执行,每次 afterTurn 都会阻塞,增加 Agent 响应延迟。
方案:写入完成后投递一个 OutboxEvent,后台 Worker 消费事件、执行 embed + upsert。提供至少一次投递保证,超过最大重试次数移入死信队列。Worker 支持 FOR UPDATE SKIP LOCKED 多进程并发消费。
不用这个方案会怎样:同步索引意味着 Agent 每轮等待额外 100-500ms。在交互式场景中这是不可接受的。或者干脆不做索引,那就没有后续的语义检索能力。
为什么 L0/L1/L2 三级检索?
问题:内容越长,向量相似度反而可能越低 — 而非越高。一个 5000 token 的文档 embedding 必须表示其中每一个概念,导致任何单一主题的信号被稀释。查询"Alice 是做什么的?"时,一篇塞满 Go、Kubernetes、PostgreSQL、迁移策略细节的 L2 内容产生的向量是弥散的,"后端工程师"只是几十个信号中的一个。而一句聚焦的 L0 摘要("Alice 是后端工程师")产生的向量集中精准,直接匹配查询。
方案:每个上下文节点按三种粒度索引。L0 摘要(~100 token)产生聚焦向量 — 精准的主题路标。L1 概述(~500 token)信号居中。L2 完整内容(~5000 token)信息最全但向量弥散。检索引擎对三个层级做一次统一的向量搜索,然后把 L0/L1 命中作为目录入口点,递归展开树结构:当 L0 摘要匹配时,搜索器展开其子节点,发现扁平搜索会遗漏的 L2 内容。分数传播(final = α·child + (1-α)·parent)让强匹配父节点下的边缘 L2 结果获得加成。
┌─────────────┐ ┌─────────────┐ ┌──────────────┐
│ L0 摘要 │ │ L1 概述 │ │ L2 完整内容 │
│ ~100 token │ │ ~500 token │ │ ~5000 token │
│ 聚焦向量 │ │ 平衡信号 │ │ 信息全面 │
│ → 路标定位 │ │ → 决策参考 │ │ 但向量弥散 │
└──────┬───────┘ └──────┬───────┘ └───────┬───────┘
│ │ │
▼ ▼ ▼
.abstract.md .overview.md content.md召回优势:仅对 L2 做扁平向量搜索,会遗漏那些因细节稀释导致向量分数低于阈值的相关内容。L0/L1 路标引导搜索器定位到正确的目录,然后树展开发现其下的完整内容 — 包括原始向量分数不高但父主题强匹配的 chunk。
为什么用文件系统抽象?
问题:上下文操作需要原子性、并发控制、权限隔离、垃圾回收。每一样都是独立的工程挑战。
方案:复用文件系统数十年的成熟方案。open/read/write/link/unlink/gc 直接映射到上下文操作。多租户隔离在文件系统层面通过路径中的 account_id + owner_space 强制执行 — 即使调用方有 bug 也无法绕过。
不用这个方案会怎样:要么为每项能力自研解决方案(原子写入、并发锁、权限检查),要么放弃这些能力。文件系统隐喻让我们站在巨人的肩膀上。
为什么用乐观锁做并发写入?
问题:Profile 节点会被用户的所有会话同时写入。需要并发控制。
方案:乐观锁 — 读取当前 .meta.json 版本,仅在版本未变时写入。无竞争时零开销;竞争时优雅失败并重试。无需额外基础设施。
不用这个方案会怎样:分布式锁需要协调服务(etcd/ZooKeeper),增加运维复杂度。不加锁则可能出现"后写覆盖"导致数据丢失。乐观锁在常见场景(无竞争)下性能最优,在罕见场景下安全失败。
为什么用 ReAct 循环做抽取?
问题:如果系统已经知道"Alice 是后端工程师",再次抽取就是浪费。但不知道已有什么就无法判断。
方案:LLM 被赋予一组工具 — read(uri)、list(uri)、get_relations(uri),以及 extract_* 抽取动作 — 然后在循环中执行。每一轮:先读取已有记忆节点(Reason),判断哪些信息是新的,再调用对应的 extract_* 工具(Act)。如果不确定,就读更多节点继续循环。这是真正的 ReAct:推理和工具调用交替进行,不是单次盲目抽取。
迭代 1: read(profile_uri) → "Alice, 后端工程师, 伦敦"
→ 无新信息,跳过
迭代 2: read(entities/go) → "Go 专家,偏好错误处理模式 X"
→ 新增: "Alice 现在也在用 Rust 做副项目"
→ extract_entity(slug="rust", ...)不用这个方案会怎样:盲目的单次抽取要么重复存储已知信息(浪费 token 和存储),要么遗漏隐含的新信息(丢失信号)。ReAct 循环用少量额外成本换取显著更高的抽取质量。