在树莓派集群上为 AI Agent 搭建记忆系统,使用 Qdrant 向量数据库存储对话历史,文章详细记录了四个文档未覆盖的工程坑点。
我的家庭集群运行着一小队 Agent。它们会告诉我今晚哪个垃圾桶该倒了、超市有什么特价,以及我的博客仓库里有多少篇文章。它们确实很有用,但记忆力和金鱼差不多。
每条消息都是从零开始的。问一句"我昨天让你做了什么?",路由器会满怀信心地回答:它无法访问任何历史记录。它没有说错。确实没有历史——只有一堆追踪数据堆积在某个可观测性工具里,而没有哪个 Agent 能读取它们。
解决方案是向量数据库——这里用的是 Qdrant。有趣的并不是这个解决方案本身,而是从"Qdrant 运行起来了"到"Qdrant 真正能回答问题"之间的四个问题,这些问题在任何文档里都找不到。
集群由三台树莓派组成。它们对被要求运行向量数据库、备份它、快照它这件事颇有微词。Qdrant Cloud 有一个免费层,提供 1GB 存储空间,对于会话记忆来说基本上是永久的,而且把存储和备份从本就捉襟见肘的硬件上挪了出去。
你会得到一个集群和一个端点。端点看起来是这样的:
https://11111111-2222-3333-4444-555555555555.eu-west-1-0.aws.cloud.qdrant.io:6333
末尾的 :6333 是第一个会让你花掉一个下午的东西。控制台显示的是裸主机名,而裸主机名会解析到 443 端口,REST API 并没有在那个端口上监听。客户端不会告诉你连错了端口——它只是连接失败,然后你去查网络策略和 DNS,而不是看你粘贴的那个字符串。
主机名不是秘密。API 密钥才是,它存在于一个通过 band 方式创建的 Secret 里,和这个集群里其他所有凭证一模一样:
kubectl create secret generic qdrant-cloud -n kmcp-system \
--from-literal=QDRANT_API_KEY='<key>'
不在仓库里。不在 values 文件里。仓库只知道 secret 的名字,仅此而已。
A["kagent agents — Telegram front door"] -->|"emit OTLP spans"| B["Collector — drops framework noise"]
B -->|"HTTPS"| C[("Langfuse Cloud — trace store")]
D["CronJob — every 30 minutes"] -->|"public read API"| C
D -->|"embed with FastEmbed — 384 dims"| E[("Qdrant Cloud — conversations")]
F["MCP server — read only"] -->|"qdrant-find"| E
G["Recall agent"] -->|"MCP over HTTP"| F
H["Router agent"] -->|"A2A delegation"| G
A -.->|"same fleet"| H
两条路径,故意分开。写入来自一个定时任务,它读取 Agent 已经发出的追踪数据。读取通过 MCP 服务器,任何集群中的 Agent 都可以调用它。
另一种方案是让每个 Agent 自己保存会话。听起来更干净,但实际上更糟:它依赖模型记住调用工具,当一个 Agent 委托给另一个时会重复写入同一会话,而且给用户等待的每个回复都增加了一次嵌入往返。读取已经存在的追踪数据对 Agent 来说零成本,而且不可能被遗忘。
如果你喜欢看堆栈跟踪,请跳到下一节。
在任何人批准类似方案之前,有三件事值得了解。
数据本来就存在。没有收集任何新东西。Agent 们已经在为可观测性发出追踪数据了;这不过是读取同样的追踪数据并使其可搜索。没有新的同意面,没有新的真实来源,只增加了一个存储位置。
运行成本为零,且保持如此。两端都是免费层——1GB 向量和家庭不会威胁到的追踪配额。嵌入在已经付费的硬件上计算。真正重要的成本行不是账单,而是每月有个人在版本 pin 需要移动时花掉的半小时。
诚实的范围。这使得"我们关于 X 决定了什么"可以从搜索框里回答,而不是从某个人的记忆里回答。在家庭规模上这是一个小技巧。在团队规模上——runbook、架构决策、事件报告、入职文档——同样的模式就不再是小技巧了,因为没有它的代价是每个季度五个人问同一个问题得到四个不同的答案。
第一个版本的摄取任务从每个追踪中读取输入和输出,将它们嵌入,然后存储零个点。两个字段在全部 97 条追踪上都是 null。
它们是 null 的,因为 kagent 没有在追踪级别设置它们。对话存在于 observations 中——一个问题有 57 条 observation,其中大部分是框架内部的管道操作。用户面对的交换内容在少数几个 input 是聊天消息列表的 GENERATION observation 中:
def conversation_turn(observation):
raw_input = observation.get("input")
if not isinstance(raw_input, list) or not raw_input:
return None
# Internal model calls carry a system_instruction and return a
# function_call. Embedding those fills the index with prompt
# boilerplate instead of anything a human said.
还有第二层需要剥开。用户的轮次是双重编码的——一个持有内容块列表的 JSON 字符串——所以 raw 字段是 '[{"type": "text", "text": "which bin goes out tonight?"}]'。原封不动地嵌入这个,你就把标点和问题本身一起嵌进去了。
这个教训可以推广到整个技术栈:在写解析器之前先看一条真实的记录。我根据一个看似合理的 schema 写了一个看似合理的解析器,而那个 schema 是虚构的。
每次运行都会回顾一个比自身调度窗口更宽的范围,所以一次失败的运行会在下一次运行中自愈。这之所以可行,是因为重新读取一条追踪是免费的:
POINT_NAMESPACE = uuid.UUID("6f9619ff-8b86-d011-b42d-00c04fc964ff")
point_id = str(uuid.uuid5(POINT_NAMESPACE, trace_id))
一个点的 ID 是 trace ID 的 UUIDv5,所以重新读取一段对话会覆盖它自己的点,而不是添加一个重复的副本。没有游标,没有检查点,没有要损坏的状态。从设计上重叠就是免费的,而不是靠记账实现的——这是唯一一种在任务被中途杀死时还能保持免费的方式。
这是值得一读的部分。
命名向量,而不是匿名向量。官方的 MCP 服务器存储和查询命名向量——内部是 vector={name: embedding} 并用 using=name 搜索。用普通的 VectorParams 创建的集合接受所有写入但拒绝所有读取:
400 Bad Request: Wrong input: Not existing vector name error: fast-all-minilm-l6-v2
名称是从模型派生出来的,所以用同样的方式派生它,而不是粘贴字符串:
def vector_name(model_name):
return f"fast-{model_name.split('/')[-1]}".lower()
过滤器寻址一个嵌套路径。有一次我想要日期过滤,工具参数出现了但什么也没返回。过滤器针对 metadata.<field> 解析,而不是 payload 根节点。扁平的最高层键仍然会返回给调用者,这就是为什么这很令人困惑——数据明明可见却不可过滤。除了文档文本之外的所有东西都移到了 metadata 键下面。
每个字段一个条件。可过滤的字段携带单个比较,且集合以名称为键,所以范围需要两个不同名称的字段。因此 day_from 用 >= 比较,day_to 用 <= 比较,两者用相同的值写入:
filterableFields:
- name: day_from
field_type: integer
condition: ">="
- name: day_to
field_type: integer
condition: "<="
可用的类型有 keyword、integer、float、boolean——没有一个能对 ISO 时间戳排序。日期存储了一份副本作为纯整数 20260818,纯粹为了能够比较。
范围查询需要索引,而读者无法创建索引。Qdrant 拒绝对未建索引的键进行范围过滤:
400 Bad request: Index required but not found for "metadata.day_from"
MCP 服务器以 QDRANT_READ_ONLY=true 运行,所以它没有理由创建索引,也不会创建。摄取任务拥有索引并在每次运行时协调它们,因为在过滤器存在之前构建的集合没有任何索引。
过滤后的计数等于搜索限制是截断,不是泄露。过滤到某一天返回了 25 个结果,我一度以为过滤器坏了。那天有 46 段对话,而限制是 25。在调试过滤器之前先检查底层分布。
在构建缺失的功能之前先读一下已安装的包。我准备写第二个带日期过滤的 MCP 服务器。在运行中的 pod 上花十分钟用 inspect.getsource() 研究了一下,发现这个功能已经存在了,由一个在任何示例中都找不到的环境变量配置。我没写的那个工具是那天我交付的最好的代码。
shell 超时不是部署失败。两次 helmfile apply 运行都在 120 秒时死掉了,留下一个卡在 pending-install 状态的 release。chart 没问题,是我的命令太急了。Helm 在等待 PVC 绑定。检查资源,而不是退出码——如果你把一个部署从膝盖处砍断了,在重试之前先清理掉半安装状态的 release。
免费层的速率限制比你计划的更严格。Langfuse 每分钟允许 15 个请求,而这个任务每条追踪需要一个请求。不控速的话,它在大约一秒内就耗尽了全部配额然后进入退避。以每请求 4.5 秒的节奏并尊重 Retry-After 虽然每个请求更慢,但总体更快。
arm64 上 glibc 或 bust。FastEmbed 拉取 ONNX Runtime,它发布了 manylinux_2_28_aarch64 的 wheel 但没有 musl 的。在 Alpine 基础镜像上,pip 悄悄地开始从源码构建,然后在树莓派上永远构建不完。python:3.12-slim 下 wheel 直接落下。
日期缺失时,问,不要猜。解析"今天"需要今天的日期,而没有什么能保证模型被告知了这一点。错误的日期静默返回错误的窗口——查询成功但答案自信地不完整。Agent 被指示去问,或者退回到无过滤搜索并说明这一点,而不是假设。
61 段对话已被索引、按日期过滤、通过一个 Agent 们本来就知道怎么调用的工具回答。问你在某一天请求了什么,你会得到那段交换的引用回复,带时间戳。
向量数据库是最简单的部分——一个端点、一个密钥、一个下午。从一个运行中的 Qdrant 到一个可用的记忆系统之间的一切,都是一个没有文档的 schema、一个不明显的 payload 路径、一个没人说你需要的索引,以及一个我差点因为没有先读源码而重建的功能。