文章介绍为编码 Agent 构建可查询的语义记忆层,并将检索延迟和提示词 Token 成本作为核心约束。作者结合小型虚拟机上的实际实现,复盘检索链路中出现的问题及保持轻量的方法。
编码 Agent 每次启动新会话时,都是从零开始。它不知道你上周二做了什么决定,不知道你为什么否决那个显而易见的方案,也不知道哪个配置值浪费了你整整一个下午。你可以每次都把上下文重新粘贴进去,也可以给 Agent 一套可供查询的记忆。
第二种方案听起来很简单,但当你把它当作一个工程问题写下来时,就会发现它面临两个彼此牵制的约束:
检索必须足够快。记忆查询位于 Agent 的工具调用循环中。如果一次记忆召回会在 Agent 正在执行的其他操作之外,额外造成明显卡顿,人们很快就会弃用它。
检索必须节省 token。如果取回上下文消耗的 token 比重新解释一遍还多,那么记忆层带来的就是负收益。它必须证明自己值得占用 prompt 的空间。
这篇文章会介绍我们如何为 Kireo 构建这一层,以及更有价值的部分——我们一路上踩过的坑。先坦诚说明:这不是一篇炫耀规模的文章。整个系统运行在一台小型 VM 上,真正有意思的是,只要检索路径足够精简,一台配置普通的机器究竟能做到什么程度。
对于任何 Agent 记忆方案,最常见的质疑都是:这难道不会让每个 prompt 都变得臃肿吗?这是个合理的担忧,而答案完全取决于设计。
记忆是一个由 Agent 按需调用的 MCP 工具,不是一团被注入每轮对话的数据。当 Agent 需要先前的上下文时,它会调用 memory_search,得到一小组经过排序的匹配结果(默认 10 条,硬上限 50 条),并且只为这些结果消耗 token。系统 prompt 前面不会预先附加任何内容;在 Agent 没有主动请求的轮次里,也不会运行任何记忆查询。
这种差异——拉取,而不是推送——正是 token 成本能够成立的根本原因。逐轮注入的方案,无论记忆有没有帮助,每条消息都要为它付费。按需调用的 top-k 工具则只会在模型判断相关上下文值得检索时产生开销,而且预算受 limit 参数约束。
服务器通过 MCP stdio 提供八个工具:memory_save、memory_search、memory_recall、memory_get、memory_update、memory_delete、memory_list_namespaces 和 memory_health。所有 MCP 客户端,包括 Claude Code、Cursor 等,看到的工具集合都完全相同。但 memory_search 既要快,又要节省 token,因此大部分工程工作都集中在这里。
完整的检索路径包括:
LanceDB——一个嵌入式列式向量存储。不需要单独部署数据库服务器;它是一个库,直接读取挂载到 API 和 worker 容器磁盘卷上的 Lance 格式文件。记忆内容就存放在这里。
Neon Postgres——只保存元数据,例如行记录信息和 embedding 状态。它从不保存记忆正文。后面你会看到,这种拆分非常重要。
自托管 embedding——使用 HuggingFace 的 Text Embeddings Inference(TEI),在 CPU 上运行 intfloat/multilingual-e5-small。这是一个输出 384 维向量的模型,通过内部网络提供兼容 OpenAI 的 /v1/embeddings endpoint。
其中有两个选择承担着整个架构的关键作用。
LanceDB 是嵌入式库,而不是独立服务。对于这种规模的工作负载,使用托管向量数据库有些大材小用。LanceDB 在磁盘上采用列式存储,查询路径只是一次库调用,不需要经过网络。向量列的类型是 Arrow FixedSizeList(dim)——请记住这一点,后面它会反过来给我们添麻烦。
在 CPU 上使用 384 维向量已经足够,而且成本很低。记忆片段通常很短——一个决策、一个坑,或者一条配置说明。你不需要 1536 维或 3072 维的前沿模型,来区分“我们选择 Postgres 行级安全,而不是应用层检查”和“CI 缓存键需要包含 lockfile 的 hash”。更小的向量意味着更低的 ANN 成本和更少的存储空间,而小型 e5 模型在 CPU 上也能很好地完成短文本语义匹配。唯一需要注意的是:e5 系列模型要求使用非对称前缀——存储的文档使用 passage:,搜索查询使用 query:。这些前缀由配置提供;使用 OpenAI 风格的模型时则留空。(那个看似无害的尾随空格真的引发过一个 bug,后面会讲。)
TEI 提供兼容 OpenAI 的 endpoint,还带来了一个额外好处:同一套客户端代码只需将 base URL 指向内部服务,就能与自托管模型通信——更换 provider 只需修改配置,无须重写代码。缓存层和限流层也采用了同样的办法:在自托管 Redis 前面放置一个兼容 Upstash REST API 的 shim,这样 REST 客户端就不需要任何托管账户。
纯向量搜索容易漏掉需要精确匹配的情况,例如特定错误码或函数名。纯关键词搜索则无法识别改写和同义表达。因此,memory_search 会同时执行两种搜索,再将结果融合。
对查询生成 embedding。结果会被缓存,因此重复搜索不需要重新生成 embedding。
并行执行两路搜索:对向量列进行 ANN 搜索,取前 50 条;执行原生全文搜索,同样取前 50 条。两种搜索都会限定在调用者所属的 tenant 范围内。
使用 reciprocal rank fusion(倒数排名融合,RRF)合并两个排序列表,再按照请求的 limit 截取结果。
RRF 被刻意设计得很朴素:它使用 1/(k + rank) 合并不同排名,不要求两套评分系统使用相同的尺度——cosine distance 和 BM25 本来就不在同一尺度上。它既稳健又便宜,正适合放在热路径中。
真正有意思的是逐级降级机制:在一套精简的技术栈中,任何依赖都有可能短暂不可用,但搜索仍然必须返回一些结果:
查询 embedding 失败,例如 provider 异常或请求超时?记录一条降级事件并触发告警,停用向量搜索分支,只执行关键词搜索。
没有 FTS 索引——这种情况在刚搭好的开发环境中很常见?退化为有界的、不区分大小写的 substring 扫描,并按照重要性排序。
两路搜索都没有结果?返回空结果,而不是 500。
这些处理一点也不光鲜,但它们决定了系统出现故障时,究竟是“记忆偶尔少返回几条结果”,还是“记忆在 Agent 的工具循环中直接抛出异常”。
每一行都带有 user_id 和 namespace,例如用于已索引代码仓库的 code-my-app。我们通过推入每条 LanceDB 查询中的 user_id predicate 实现隔离,同时在结果返回后再加一道双保险断言:如果任何一行的 user_id 与调用者不一致,代码会直接抛出异常,而不是泄露数据。理论上过滤条件永远不应该出错——但我们仍然会在每条读取路径上进行检查。跨 tenant 数据泄露是你最不愿意发布到生产环境的 bug,而三行断言代码是一份成本极低的保险。
这套技术栈并非一开始就使用 384 维向量。最初,它通过一个托管模型生成 1536 维向量;后来迁移到自托管的 384 维 e5 模型时,所有已存储向量的长度都变得不再匹配。
这时,LanceDB 的 FixedSizeList(dim) schema 就不再只是一个实现细节。向量列的维度被固化在表的 schema 中,而我们使用的 LanceDB 版本既不能原地调整列的大小,也不支持重命名表。一旦向量维度发生变化,所有针对旧表的插入都会失败,而且你无法悄悄扩展或缩减这一列。
迁移流程只能是 dump、drop、recreate:
从 memories 表中读取所有行。
在执行任何破坏性操作之前,先把数据写入磁盘上的持久化 JSON dump。记忆内容只存在于 LanceDB 中,Postgres 仅保存元数据;因此,如果重建过程执行到一半失败,这份 dump 就是唯一的数据副本。重新载入时,旧向量无论如何都会被丢弃,所以 dump 中不包含它们。
删除原表,按照新的维度创建一张空表。
将每一行分批重新插入,并设置 embedding = null、embedding_status = 'queued';同时把 Postgres 中的状态行也重置为 queued,这样 backfill job 就会使用新模型重新为所有内容生成 embedding。
迁移过程是幂等的——如果表已经采用目标维度,就什么都不做。这一点非常重要,尤其是当你在一台线上机器上手动执行迁移,却不确定上一次尝试是否已经完成时。
这次迁移还暴露了一个微妙的二阶 bug。embedding 缓存的 key 由模型名称和内容 hash 组成,却不包含维度。因此,如果同一个模型的 endpoint 发生变化,并导致输出向量长度改变,缓存中旧长度的向量仍然能通过缓存查询,随后被写入刚刚重建的表中,最终导致插入失败。
解决办法是在两层设置维度保护:embed 客户端在返回前断言 vector.length === EMBEDDING_DIM,这样长度错误的 embedding 会失败并降级进入队列,而不是破坏数据表;缓存读取也会把长度不匹配视为 cache miss。规则很简单:绝不能让维度错误的向量抵达数据表,并且要在它可能进入系统的每个入口强制执行这条规则。
索引代码仓库时,会分批上传 symbol——每个请求最多上传 100 个。批处理有时会超时,而最直观的重试方式,也就是重新发送整批数据,如果写入路径不具备幂等性,就会产生重复记录。
解决方案是通过一次集合查询,基于 content hash 去重,而不是执行 N 次点查询。插入一个批次之前,先用一条查询获取所有 content_hash 位于该批次 hash 集合中、仍处于 active 状态且未删除的行——即针对整个批次执行一次 content_hash IN (...)——然后跳过这些行。这样,重复执行同一次上传就是安全的:相同内容会产生相同的 hash,并对应相同的行,重试不会让记录成倍增加。
这里有两点我很喜欢。第一,一次 IN 查询避免了在每个 item 的热路径上执行去重——每个批次只查询一次,而不是每个 symbol 查询一次。第二,这项保证也明确写进了 CLI 文档:如果批量上传超时,重新运行同一条命令是安全的。它不只是一个内部实现细节,而是一项公开的使用契约——遇到超时的人,才是最需要确信重试安全的人。
删除操作最容易让朴素的实现悄无声息地丢失数据,或者在数量统计上撒谎。我们在这里所做的修复,归根结底都是为了明确语义。
默认采用软删除:执行删除时写入 deleted_at,并设置一个 30 天恢复窗口对应的 expires_at。数据行仍然保留在表中,普通读取则通过 deleted_at IS NULL 将其过滤掉。恢复时会检查有效窗口,超过期限便拒绝恢复;TTL sweep 会物理删除 expires_at 已过期的行。
这又带来了三个后续要求,每一项都必须明确处理:
回收站视图需要使用相反的过滤条件。列出已删除项目并不等于“包含已删除项目”,而是“只显示已删除项目”。它们是不同的查询;当两个 flag 同时设置时,“只显示已删除项目”必须拥有更高优先级。这里稍有差错,用户看到的回收站就会是空的,或者包含错误数据。
数量统计必须与当前视图一致。trashed 数量需要单独查询,不能使用“总数减去 active 数量”计算,因为这种差一点点的计数错误,恰恰最容易被用户发现,并让他们失去对系统的信任。
更新必须原地完成,绝不能先删除再添加。LanceDB 允许你删除一行后重新插入,但如果程序在两次调用之间崩溃,该行就会永久丢失,而它在 Postgres 中的元数据却仍然存在——这就是 torn write。所有变更,包括编辑、软删除、恢复和 namespace 重命名,都使用原地更新,因此不会出现数据行暂时不存在的窗口。
还有一个由 LanceDB 特性造成的小问题:普通 scan 不支持 ORDER BY。为了在不实例化一百万行数据的情况下按照最新时间优先列出内容,列表查询路径会流式处理每个符合条件的 batch,在数据到达时持续重新排序,并截断到 limit + 1,从而让内存中只保留当前排名最靠前的一页数据。它比 ORDER BY ... LIMIT 需要更多代码,但这是底层存储引擎实际支持的实现方式。
在任何一篇诚实讲述“单台 VM”实践的文章里,下面三件事也值得写出来:
有一次,单个日志文件增长到了 62 GB。某个 worker 在调用资源耗尽的上游服务时陷入错误循环,产生的 JSON 日志吃光了磁盘。修复方式平淡却永久有效:每个容器都把日志轮转限制为 10 MB × 3 个文件。在小型机器上,无限增长的日志就是一颗定时炸弹。
embedding image 通过 digest 固定版本。TEI 早期的某个 CPU tag 打包了一个存在问题的依赖版本,下载模型时会报出含义晦涩的 "relative URL without a base"。现在我们把它固定到一个已知可用的 digest,并通过注释准确说明原因,这样一次无意间的 :latest 拉取就不会让 bug 死灰复燃。
配置中的空白字符至关重要。Docker 的 dotenv 解析会移除尾随空白,这会悄悄吃掉 e5 的 passage: 前缀后面的尾随空格。直到我们为该值加上引号之前,记忆召回质量都在无声地下降。这种 bug 不会留下任何 stack trace。
这些问题都算不上巧妙。它们只是让真实工作负载在普通硬件上运行所必须付出的代价——而把它们记录下来,可以让下一个接手的人,通常也包括三个月后的我自己,跳过这些调试过程。
这就是 Kireo 背后的记忆层。Kireo 是一个 MCP server,它为 Claude Code、Cursor 以及任何 MCP 客户端提供一套共享的长期记忆:你可以在工作过程中保存决策和踩坑记录,从任何工具中召回它们,也可以通过 web dashboard 浏览、编辑或删除所有内容;如果有一天想带着自己的数据离开,还能完整导出 JSON。目前它正处于免费 beta 阶段,额度充足,也不需要绑定信用卡。
claude mcp add kireo --scope user --env KIREO_API_KEY=ki_sk_xxx -- npx -y --package=@kireo/mcp-server kireo-mcp
你可以在 kireo.app 获取 key 并阅读文档。关于隐私:服务器只会发送你明确传给 memory_save 的内容;它绝不会读取你的代码。代码索引保存的是派生出的 embedding 和文件路径,而不是源代码——具体可参阅数据存储政策。MCP server 已发布到 npm 和 GitHub。
我刻意没有在这里给出延迟方面的数字——比起提供漂亮的整数,我更愿意发布经过测量的数据。后续针对搜索路径进行性能分析的文章已经列入计划。如果你也在精简技术栈上构建 Agent 记忆系统,并撞上了另一批不同的墙,我很想听听你遇到的是哪些问题。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。