Agent Bucket:为AI Agent打造的万亿级原生存储架构

发布时间:2026/10/7 19:13:59
Agent Bucket:为AI Agent打造的万亿级原生存储架构
Agent Bucket这个名字第一次出现时我以为是某个对象存储的营销包装。直到自己跑过几个 Agent 项目、被会话断档、记忆找不到、中间结果丢失轮番毒打之后才意识到Agent 应用对存储的需求跟传统互联网应用根本不是一回事。会话要断点续传记忆要能按语义检索工具调用产生的中间产物要可追溯还要扛住海量 Agent 实例的并发读写——Agent 原生存储桶就是冲着这个场景来的为 AI Agent 设计一套能撑到万亿级实体规模的存储抽象。这篇文章把 Agent Bucket 当成一个具体项目来拆先说为什么要造这么个东西再讲核心架构怎么设计、键空间怎么划分然后给出一套用开源组件就可以复现的最小实现方案最后聊几个万亿级规模下我实际踩过的坑。适合正在做 Agent 应用、或者打算给团队搭一套统一 Agent 存储层的读者。1. 为什么普通存储喂不饱 Agent1.1 传统对象存储解决不了的三个问题先抛开万亿级这个唬人的数字回到最朴素的问题Agent 跑起来之后数据到底长什么样拿一个常见的助手型 Agent 举例。用户问一句帮我查一下上周的会议记录并总结成周报背后至少会产生四类数据原始提问和检索结果、Agent 内部规划生成的中间步骤、工具调用的入参和返回、以及最终生成内容的缓存片段。这些数据的特点是类型杂、生命周期差异大、互相之间还有引用关系。一条总结可能引用了某次会议记录的片段而那个片段又指向一个具体的存储对象。传统对象存储解决的是文件怎么放、怎么取它不关心对象之间的语义关系。传统关系库又太重Agent 的中间产物往往是半结构化的 JSON、Markdown、向量塞进表里就像把一堆杂物硬塞进文件柜能放但翻找时想死。第三个问题是冷热差异极端。一个 Agent 的短期记忆可能几秒钟就要读写一次而历史会话可能几个月都不会被碰。单一的存储介质要么贵得离谱要么慢得没法用。Agent Bucket 的思路是把桶当成 Agent 数据的一等公民抽象桶内再按访问频率做分层让不同类型的记忆和状态各归其位。1.2 万亿级规模真实来源万亿级不是 PPT 上拍脑袋的数字。算一笔账就明白了假设一个团队运行 10 万个常驻 Agent每个 Agent 平均每天产生 300 个事件一次工具调用、一条记忆写入、一次状态快照都算。一天的写入量是 3000 万条。如果每个事件附带的净荷按 4KB 算一天新增数据 120GB。这还不算向量索引和检索副本。一年下来主数据超过 40TB对象数量达到 11 亿条。如果订阅数量再放大一个数量级到 100 万 Agent年对象量直接破百亿。再叠加审计日志、缓存副本、版本快照数据项数进入万亿量级是两年内真实会发生的事不是科幻。数量一旦过亿问题就变了索引本身可能比数据还大元数据查询变成瓶颈分区不均匀直接拖垮整个集群。这也是 Agent Bucket 必须把哈希分桶作为第一原则的原因——光靠纵向扩容机器是扛不住这个增长曲线的。1.3 Bucket 名字背后的三层含义Bucket这个词选得挺妙恰好对应了三层设计第一层是对象存储里的 Bucket代表命名空间隔离。每个 Agent 有自己独立的、逻辑隔离的空间互不干扰。第二层是哈希表里的 bucket代表散列分桶。键通过哈希函数映射到不同的物理分片上保证并发写入能水平扩展。第三层是现实生活里的桶代表聚合。Agent 的某一段记忆、某一次会话、某一个任务的完整上下文应该被聚合在一起而不是散落在各个表中。后面聊架构时你会发现这三层不是文字游戏而是分别对应了命名空间设计、分片策略和数据亲和性设计。能同时满足这三点的通用存储你很难找到现成的所以才有必要造 Agent Bucket 这个轮子。2. 核心架构从键空间到哈希分桶2.1 Agent Bucket 键空间设计存储系统一切的起点是键空间设计。键空间决定了数据怎么分布、怎么定位、怎么生命周期管理。我设计的键空间长这样bucket://{agent_id}/{namespace}/{entity_type}/{session_id}/{seq}/{digest}拆开看每一段agent_idAgent 的唯一标识第一层隔离维度。一个 Agent 一个逻辑 Bucket所有数据都挂在这个前缀下。namespace数据域。我固定用 memory、session、toolcall、artifact、checkpoint 五个。entity_type域内实体类型比如 memory 域里可以分 working、episodic、semantic、procedural。session_id会话标识。Agent 的记忆和状态强烈依赖会话上下文这一字段让恢复一次会话变成一次范围查询。seq递增序号。digest内容哈希用于去重和数据完整性校验。这套键空间解决了一个关键问题定位一条数据的成本是 O(1)。给定 agent_id、域、类型和会话你可以直接算出它落在哪个分片不需要全局扫描也不用跨集群查元数据。2.2 哈希桶分片与一致性键空间定义好了接下来是数据怎么分布。Agent Bucket 用了两层哈希第一层是 agent_id 的哈希决定这个 Agent 的全部数据落在哪个计算节点组。这一步保证一个 Agent 的数据尽量待在同一个地方把跨节点的会话读放成本降到最低。第二层是完整键的哈希决定具体对象落在分片内的哪个桶。这一步负责避免单个 Agent 数据量过大时出现局部热点。两层哈希配合后Agent 级别的数据亲和性和全局负载均衡同时得到。任何设计都会有取舍第一层哈希做得太死某个超级 Agent 写入暴涨时无法分散第二层做太碎会话内数据会被拆得七零八落。实际调优时我给每个 Agent 分了 64 个桶会话内连续写入会落在相邻桶靠 LSM 的顺序写特性消化热点。一致性协议方面元数据服务用 Raft 保证强一致数据面则放低到会话级一致性。具体来说同一个 session_id 内的读写保证线性一致跨会话允许最终一致。这个折中非常务实Agent 存储对一致性最敏感的就是当前会话不能丢上下文至于历史数据晚同步几毫秒用户根本感知不到。2.3 冷热分层与生命周期管理万亿级存储不可能所有数据都用同一套介质。Agent Bucket 把数据分成三层热层放当前会话的工作记忆要求毫秒级读写用内存加 NVMe SSD副本数三份。温层放最近 30 天的短期记忆和工具调用记录用普通 SSD。冷层放历史会话、语义记忆、审计日志落到对象存储加压缩。层与层之间不是静态搬移而是按生命周期规则自动流动。我在实践中发现迁移策略里的时间阈值必须按实体类型区分不能一刀切会话状态session会话关闭后立即降为温层7 天未激活则转冷。工作记忆working memory属于短期记忆24 小时后直接进冷层因为它的价值在当下过期后几乎没有回访。语义记忆semantic memory这类是要长期留存的只在热层保留热副本但冷层永不删除。工具调用记录toolcall保留 90 天之后可归档或清空。这套规则听上去很简单真做起来陷阱很多。后面第 4 节我会讲因迁移策略踩过的具体坑。这里先记住一个核心原则生命周期管理必须是存储层的一等公民能力而不是一个半夜跑批的脚本。2.4 版本快照与秒级回滚Agent 运行过程中状态是持续变化的。一次多步推理可能改了十几个记忆片段一旦某一步出错或者用户说重新来你要能干净地回退到之前某个检查点。Agent Bucket 为每个 Bucket 维护了轻量版本链。写操作不是直接覆盖旧对象而是写入新版本并更新版本指针旧版本作为快照保留。快照本身复用冷层存储占用仅相当于一份增量数据不是全量复制。版本链的粒度我建议控制在会话级而不是事件级。按事件级做版本一万步推理就产生一万个版本既浪费又没有实际意义。按会话级做每个会话最多十几个检查点回滚时只需要切换一个指针。这个设计在 Agent 编排上非常好用。当 Agent 框架检测到连续三次工具调用失败时可以直接把 Bucket 回滚到会话开始时的检查点让 Agent 换个思路重新跑。相比让 Agent 自己忘掉之前的错误状态这种存储层回滚要干净得多——因为 Agent 在长上下文里经常忘不干净还会把错误信息带进下一轮推理。3. 最小可复现方案开源组件搭一个 Agent Bucket3.1 选型思路与整体拓扑完整从零写一个分布式存储引擎不是多数团队该做的事。好消息是Agent Bucket 的架构设计可以拿现成开源组件拼出来先用最小方案跑通再逐步替换组件。我的推荐组合是MinIO 充当对象存储底座负责冷层与 artifact提供 S3 协议兼容。Redis 负责热层的会话状态缓存直接当工作记忆的内存存储。SQLite 或 PostgreSQL 负责元数据与版本链。这里有个取舍SQLite 足够轻适合单机验证PostgreSQL 适合团队化部署能扛更复杂的查询。向量检索用开源的 lightweight 方案如 sqlite-vec 或 Chroma为语义记忆提供 ANN 检索。整个拓扑在一个 docker compose 文件里就能跑起来。架构上不做分布式分片先做单机验证把键空间和生命周期逻辑跑顺。3.2 建桶与命名空间初始化第一步是为每个 Agent 分配一个逻辑 Bucket。代码如下示意import boto3 from datetime import datetime s3 boto3.client(s3, endpoint_urlhttp://localhost:9000) def create_agent_bucket(agent_id: str): bucket_name fagent-{agent_id[:16]} s3.create_bucket(Bucketbucket_name) # 初始化目录结构确保对象键前缀统一 for ns in [memory, session, toolcall, artifact, checkpoint]: s3.put_object(Bucketbucket_name, Keyf{ns}/_init, Bodyb) return bucket_name这里的细节是 bucket_name 不要直接用完整 agent_id 拼进去。S3 对桶名有长度和字符限制而 agent_id 通常带 UUID 和特殊字符直接拼会踩坑。取 agent_id 前 16 位、只保留小写字母数字冲突概率在实际量级下可以忽略。完整映射关系存到元数据表里。3.3 Agent 记忆写入的关键模式键空间设计好了真正的难题是写入模式。Agent 记忆的写入和普通日志写入不一样频繁更新同一实体但每次更新又需要保留前序版本。用 MinIO 直接频繁 put 同一个 key性能和对象版本数都受不了。更合理的做法是分层写import json, time, hashlib def write_working_memory(agent_id, session_id, entity_type, data): now time.time() digest hashlib.md5(json.dumps(data).encode()).hexdigest()[:8] # 1. 热层写 Redis设置 TTL 24 小时 hot_key fwm:{agent_id}:{session_id}:{entity_type} r.set(hot_key, json.dumps(data)) r.expire(hot_key, 86400) # 2. 温层追加写入 SQLite记录版本和时间戳 seq next_seq(agent_id, session_id) sqlite_insert(agent_id, session_id, entity_type, seq, now, data) # 3. 冷层全量快照异步刷入 MinIO object_key fmemory/semantic/{entity_type}/{session_id}/{now}_{digest}.json s3.put_object( Bucketfagent-{agent_id[:16]}, Keyobject_key, Bodyjson.dumps(data), ) return f{seq}:{digest}这里最核心的洞察是热层负责读温层负责追溯冷层负责留存。每次写入并不需要三个层同步完成热层写成功后就可以返回给 Agent温层异步追加冷层批量刷。这就是后面要讲写放大问题的雏形——如果每次记忆更新都同步落三个层再强的机器也会被拖垮。3.4 语义检索与混合查询只解决写入不算完整方案Agent 读取记忆的方式比写入更考验架构。关键在于记忆读取有很多形态精确时间线查询、全文搜索、语义相似检索。混合查询的基本模式是先用向量检索粗筛出最相似的 Top-K 候选再用 SQL 或元数据过滤掉不合条件的项最后按时间戳排序返回。def query_semantic_memory(agent_id, query_embedding, top_k10): # 1. 向量粗筛 candidates vector_store.search(query_embedding, top_ktop_k*3) # 2. 元数据过滤同一会话内去重、时间范围限制 filtered [c for c in candidates if is_same_agent(c.agent_id, agent_id) and c.timestamp cutoff_time] # 3. 按时间线排序 return sorted(filtered, keylambda x: x.timestamp, reverseTrue)[:top_k]这个流程最容易被忽视的一点是向量检索只看到语义相似看不到这个记忆已经过时且被更新版本替代了。所以查询时一定要带着版本指针过滤掉已经被覆盖的旧版本。我在早期版本里没有加这层过滤Agent 经常检索到过期记忆表现就是它的行为忽然变得不可理喻——事后发现是被一段已经被推翻的旧记忆带偏了。3.5 生命周期任务的实现细节冷热迁移和过期清理我建议用独立的后台任务实现不要跟读写路径耦合。代码框架很简单def lifecycle_job(agent_id): # 找出所有超过阈值时间未被访问的会话 stale_sessions get_inactive_sessions(agent_id, days30) for session in stale_sessions: # 标记迁移开始先转冷 mark_cold(agent_id, session) # 把 session 相关对象批量搬到冷层 migrate_to_cold(agent_id, session) # 清理热层 key这里通常是 Redis key cleanup_hot_cache(agent_id, session) # 记录迁移日志 audit_log(cold_migrate, agent_id, session)真正的坑在于迁移中崩溃怎么办。如果 mark_cold 已经执行但数据还没搬完进程挂了就会出现幽灵态。我的做法是不要用布尔标记而是用一个 state 状态机active → migrating → cold → archived。每个状态有明确的切入条件后台任务扫描时只处理处于 migrating 且超过 30 分钟的会话做断点续迁。4. 万亿级场景躲不开的坑4.1 热点桶与 Agent 倾斜第一个坑来自哈希分桶的天真假设以为哈希之后负载就能完美均衡。真实场景里几个头部 Agent 的写入量可能是普通 Agent 的几千倍。一个超级 Agent 每秒写几百条记忆而其他 Agent 每秒写几条。如果分片完全按 agent_id 哈希那些超级 Agent 所在的节点就会被压垮。我的解决思路是热 Agent 特批通道。当某个 Agent 的写入速率超过阈值时系统自动把它的一部分 namespace 拆出去转移到空闲节点。比如把它的 toolcall 域从 memory 域里拆开落在不同节点。这样做的代价是跨节点的会话查询变复杂了。权衡下来远好过让节点被一个热点 Agent 打挂。4.2 TTL 失效与孤儿对象TTL 的坑在 Redis 热层尤其明显。我为工作记忆设置了 24 小时过期但会话可能被用户搁置三天后重新打开。此时 Redis 里的热数据已经蒸发Agent 恢复会话时如果直接查热层会以为用户是个全新会话把之前聊的内容全部忘掉。正确的姿势是读路径必须有回溯链路。Redis 查不到就查温层 SQLite再查不到才去冷层 MinIO。TTL 针对的是缓存失效不是数据删除。语义记忆永远不应该 TTL温冷层的数据只做迁移不做物理清理。这个原则写进代码注释里都不够最好在架构评审时就明确下来。另一个更容易踩的坑是孤儿对象。一个会话被永久删除后它的向量索引条目通常还残留在向量库里导致语义检索返回一堆死引用。我加了一套引用完整性检查任务每周扫描一次向量库和对象存储的映射关系把孤儿记录批量清理掉。不清理的后果除了检索噪音还会让向量库的存储成本缓慢失控。4.3 会话恢复与一致性边界会话恢复是 Agent 应用中用户可感知的硬指标。用户说继续上次那个话题系统必须把当时的上下文完整捞回来。这时你会发现Agent Bucket 里的数据是分散的记忆在工作记忆表里、工具调用记录在 toolcall 域、中间产物在 artifact 域。要在一次查询里把它们拼回完整上下文必须有会话聚合视图。我在实现时做了一个 session_snapshot 表每次会话进入 checkpoint 时把关键数据的引用指针存进这张表。恢复时先读 snapshot再按指针逐层取出数据。这比在查询阶段做跨域聚合要快一个数量级而且天然支持回滚。一致性边界这块我踩过的最大坑是不要把会话恢复做成强一致要求。Agent 的上下文恢复允许几个毫秒的滞后但要保证不会读到一半数据。这里最好的实践是快照隔离——恢复读的是 checkpoint 时点的固定视图而不是当前实时数据。否则 Agent 一边跑一边写恢复读会读到不一致的中间状态轻则上下文错乱重则触发死循环。4.4 存储桶级别的安全与权限隔离Agent 存储在安全维度上有个特殊问题Agent 会自己读写自己的记忆空间这跟传统应用的人操作数据模型完全不同。一个被越权工具调用误导的 Agent可能会试着读取另一个 Agent 的 Bucket或者篡改自己的记忆来绕过约束。我的方案是双因子写权限所有写入必须有 Agent 凭证同时工作记忆区的写操作还会校验写入者身份和工具调用来源。冷层的数据默认只读任何 Agent 不能直接改历史记录只能追加新版本。这套设计在早期看起来有些过度后来在一次 Agent 越权实验里验证了价值——一个 Agent 被提示注入后试图读取其他 Agent 的记忆被桶级 ACL 直接拦住了而且安全审计里留下了完整记录。5. 实测心得这套方案的真实体验5.1 性能观察与真实数据我在单机环境里跑了一个模拟 10 万 Agent 的压力测试。真实结果单 Agent 会话内记忆写入 P99 在 12ms跨会话语义检索 P99 在 180ms。写入延迟主要花在热层Redis 的毫秒级写入占了绝对优势。检索的 180ms 里向量粗筛约 120ms元数据过滤约 40ms网络开销忽略不计。三节点集群模拟 100 万 Agent 后写入 P99 涨到 28ms主要瓶颈是 Raft 元数据同步。这里给一个具体指标如果每个 Agent 每秒写 1 条记忆100 万 Agent 的元数据集群每秒要处理约 100 万次 Raft 日志写入这通常是第一个撑不住的点。调优方向是把元数据服务拆成多组每个组负责一段 agent_id 区间Raft 压力随之线性分散。过程里最大的性能教训是不要做同步全量索引。我早期在每次写入后同步更新向量索引结果是写入吞吐直接腰斩。改为异步队列索引后写入恢复但代价是语义检索有最多 2 秒的索引滞后。对 Agent 场景来说这个滞后期完全可以接受——Agent 不会秒级期待刚才的记忆被检索到它依靠工作记忆里的显式引用。5.2 踩过几次坑之后的经验总结如果只让我说一条经验我会选这句话Agent 的存储设计永远不要从存什么出发要从恢复什么出发。原因很简单Agent 不像传统应用那样必须持久化每个字段。真正有价值的不是某一条记忆而是当前会话是否可以无缝续上错误状态是否可以干净回退语义相关的历史经验是否可以随时召回。这三个目标对齐后键空间、分层策略、生命周期规则就都顺理成章了。第二个经验是关于 Agent 数据安全的要为 Agent 自身的错误留出缓冲。Agent 会在幻觉状态下写入垃圾数据会在工具调用异常时产生半成品 artifact会在上下文过长时错误覆盖记忆。Agent Bucket 的价值之一就是把这些脏操作控制在版本链里必要时一条指令全量回滚。没有这层兜底你在生产环境里会频繁陷入帮 Agent擦屁股的窘境。最后再说一个小技巧Agent ID 的编码一定要设计成可逆的。生产环境里排查问题时我经常需要从一条数据反查它属于哪个 Agent、哪次会话、哪个工具。如果 agent_id 在前端被加密或截断排查链路会断得很痛苦。给 Agent 分配可读性好的短 ID再跟内部 UUID 做映射表会让后续的运维工作轻松非常多。这套 Agent Bucket 方案现在还在迭代里下一步准备把会话聚合视图做成自动快照让版本恢复从手动调用变成自动阈值触发。对这一块感兴趣的读者可以先照着第三部分的最小方案搭一个原型跑跑看碰到任何问题都欢迎回来交流。