自己托管 Agent 平台这件事,很多人一开始的思路都是“别人怎么搭,我就怎么搭”。结果就是照着文档把中间件一个个补上,容器数量从 3 个涨到 7 个再到 11 个,等到想升级或者想备份的时候,才发现自己已经不太清楚这台机器上到底跑着多少东西了。我最近就把一套自托管 Agent 平台从 11 个容器砍到了 5 个,其中 MinIO、Milvus、Vault 这三个重量级组件全部换掉了,Postgres 和 Redis 留了下来,整体内存占用少了差不多三分之一,日常备份也从一个需要协调多组件的流程退化成了“拷目录”这么简单。这里把每个替换的来龙去脉、具体落地方案和付出的代价一次说清楚,给正在搭或者准备搭类似平台的同行一个参考。
1. 为何要动刀:11 个容器的真实运营体验
先说我原来的部署结构。这套平台功能上并不复杂,核心是:一个大模型驱动的 Agent 服务、一个前端界面、一个任务队列、一套工具调用执行环境,外加把对话记录、知识库向量、文件附件和敏感配置持久化下来的中间件层。可是真按照“官方推荐 + 高可用预备”的姿势部署,容器数量很快就膨胀到了 11 个。
1.1 这套平台的原始容器组成
把清单摊开大概是这样的:应用层分成 Nginx 反代、前端静态站点、Agent API 服务、任务调度器、异步执行 Worker,这就是 5 个容器。数据层有 Postgres、Redis,这是基础没得跑。再往下就进入了“中间件全家桶”:对象存储用 MinIO,向量库用 Milvus,而 Milvus 官方 Compose 方案里又自带一个 etcd 和它专用的 MinIO 实例,这就 4 个容器了。最后还有一套 Vault 用来管理密钥和动态令牌,又占 1 个。加起来正好 11 个。
这套结构不能说错,很多生产环境就是这么跑的。但放在自托管场景下,问题很直接:Milvus 的那个 etcd 并不是真正的集群 etcd,只是单机元数据存储;Milvus 自带的 MinIO 里往往只放几个索引文件;Vault 的 RAFT 存储和自动解封能力,在只有一台服务器的情况下根本没发挥出价值。多项重基础设施都处于“功能齐全但使用率极低”的状态。
1.2 痛点不止“整数不好看”
容器多最让人头疼的还不是内存,而是版本兼容。Milvus 升级时,etcd 版本是否兼容、MinIO 版本是否匹配、Milvus 自身版本和 SDK 是否对齐,这三者必须同步升级。我踩过一次:单独升级 MinIO 后,Milvus 的存储后端连接直接报错,排了大半天才发现是 MinIO 的访问协议配置变化导致的。
另一个痛点是备份。你不可能只备份 Postgres 就完事,向量库里的知识库索引、MinIO 里的对话附件、Vault 里的密钥,都得逐一处理。Vault 的备份还特别谨慎,直接 copy 存储目录容易导致 unseal 状态失效。我的机器是 8G 内存,这套平台平时空闲就占掉 4G 多,实际留给 Agent 推理进程的内存并不多。当你把容器数量和实际业务量摆到桌面上一对比,就会清醒地意识到:这里八成组件,是我为了“万一要用”而提前透支的运维成本。
2. 逐项替换:MinIO 到底换成了什么
先说结论:MinIO 换成了“本地磁盘目录”。不是换成了另一个对象存储网关,也不是接了一个外部 S3 服务,而是直接把应用配置从 S3 协议切换到本地文件系统协议。
2.1 MinIO 在 Agent 平台里承担了什么
Agent 平台里的文件主要分三类:对话场景里的上传附件、Agent 执行任务时生成的临时文件、还有知识库构建过程中缓存的图片和 PDF。这类数据的共性是:访问量不大、总量不大、几乎没有并发访问。MinIO 在这个架构里的角色,本质上就是一个带 HTTP 接口的文件目录。
我给当时的应用配置做了一次追踪,发现它支持STORAGE_PROVIDER=local的存储方案,只是默认文档和示例配置里都不太强调。如果你的 Agent 应用不支持本地存储,那替换思路除了改配置,还可以在应用层加一个简单的存储适配器,把文件写入和读取的接口从 S3 客户端换成标准文件 I/O。关键是接口不变,内部实现替换。
2.2 我的替换做法与取舍
具体操作分为两步。第一步是在 Docker Compose 中删除 MinIO 服务,并在 Agent API 服务里挂一个数据卷:
agent-api: image: my-agent:latest volumes: - ./data/storage:/data/storage environment: STORAGE_PROVIDER: local STORAGE_LOCAL_ROOT: /data/storage STORAGE_URL_PREFIX: /files第二步是设置目录结构。MinIO 的 bucket 概念对应到本地就是一级子目录,我用YYYY/MM/DD做目录层级来存放文件。这样既保留了按时间归档的习惯,后面做定期清理时也只需删除过期目录,不需要依赖对象存储的生命周期策略。文件命名规则改成uuid.ext,避免重名覆盖,也能防止中文文件名经 URL 编码后出现兼容问题。
做这个替换之前我犹豫过 S3 的版本控制能力。在 MinIO 里,如果你开启版本控制,可以防止误删和覆盖。本地文件系统没有这个能力,我的补偿方案是:对所有要被删除的文件先移动到一个trash/目录,保留 7 天,再让一个定时任务清理。这个策略写成一个很短的 Shell 脚本就行,实测足够应付“手滑删错”的场景。
2.3 本地磁盘方案的适用边界
必须承认,MinIO 换本地磁盘是有明确适用边界的。如果这套 Agent 平台要暴露到公网,给多个端提供文件下载服务,或者对方有频繁的大文件并发读需求,本地磁盘会很快变成瓶颈。S3 协议的对象存储还能解决一个本地目录不容易处理的问题:断点续传和分片上传。但我在自己的平台上统计过,单个附件最大也就是十几 MB,正常通过网关转发完全够用。
另一个要考虑的是系统盘压力。不要把data/storage挂到系统盘根目录,我在迁移时专门给/data单独划了一个逻辑卷,避免日志、容器镜像、程序缓存和业务文件互相抢磁盘 IO。这个动作比换掉 MinIO 本身更重要,因为磁盘满了之后容器并不会自动暂停,反而会给整个系统带来更隐蔽的连锁故障。
3. 向量检索瘦身:Milvus 换成了什么
这是整个替换过程中最费劲的一项。Milvus 给我的感觉是一个“性能很好、但为了性能把架构搞得很重”的组件。好消息是,在一个自托管 Agent 平台里,知识库检索根本不会遇到百万级向量的场景,所以完全可以用 Postgres 的 pgvector 插件来顶替。
3.1 Milvus 看起来很“重”的理由
Milvus 的架构设计是为分布式和横向扩展准备的,于是它的标准部署形态天然包含三部分:Milvus 引擎实例、etcd 元数据存储、MinIO 对象存储。即使你只在单机上跑一个最小编译,这三个容器也得齐活。而且 Milvus 的内存占用从来都不是小数目,向量索引加载到内存里后,300MB 只是起步,数据量上去后轻松吃满 1G 以上。
对于只有几千到几万条知识片段的自托管平台,Milvus 的能力其实被严重浪费。向量检索这一层真正需要的只有三件事:把文本转成向量存进数据库、用余弦相似度做 Top-K 召回、能按知识库 ID 做过滤。这些 pgvector 都能做,而且因为向量数据和业务数据在同一套 Postgres 里,事务一致性还更好处理。
3.2 改造步骤与配置示例
迁移的第一步是在数据库里启用扩展并创建向量表。我的平台用的是 Postgres 16,镜像直接切换成了pgvector/pgvector:pg16:
CREATE EXTENSION IF NOT EXISTS vector; CREATE TABLE IF NOT EXISTS agent_knowledge ( id bigserial PRIMARY KEY, tenant_id varchar(64) NOT NULL, content text NOT NULL, embedding vector(1536), created_at timestamptz DEFAULT now() ); CREATE INDEX ON agent_knowledge USING hnsw (embedding vector_cosine_ops);第二步是改写原本调用 Milvus 的代码。原来的代码里需要配置 Milvus 主机和集合名,现在改成给 Postgres 塞一条带向量字段的记录。这一步不是简单的替换 API,要处理一个细节:召回时不能只做向量排序,还得把 tenant_id 的过滤条件和向量检索组合在一起。pgvector 的 HNSW 索引在这种情况下表现不错,因为过滤后的候选集会缩小,排序压力能控制住。
第三步是离线重建知识库索引。我把原有知识库文档按 ID 顺序导出,每 500 条重新走一次 embedding 流程,写回新表。整个过程花费十几分钟,期间 Agent 服务可以继续对外服务,只是知识库召回暂时空转,影响不大。
3.3 换到 pgvector 后要接受的性能落差
说实话,pgvector 和 Milvus 在性能上确实有差距,尤其在大量数据加上复杂过滤条件时。但我在自己的数据集上做了压测:5000 条向量,查询耗时约 20 毫秒;5 万条向量,查询耗时约 80 毫秒,完全无法感知差距。最后的瓶颈反而落在 embedding 接口的延迟上,而不在向量检索本身。
如果未来知识库条目真正涨到几百万级别,再装回 Milvus 也不迟。而且因为业务表已经按 tenant_id 和知识库 ID 做了逻辑隔离,届时回迁 Milvus,代码改动也只是把向量存储层再抽象一次。所以这次降级不是封死了扩展路径,只是把扩展到未来某个时刻再次决定而已。
4. 密钥管理:Vault 换成了什么
密钥管理这层最开始是最神圣不可侵犯的,毕竟涉及数据库密码、外部工具 Token、模型 API Key。Vault 能提供动态凭证、TTL 过期、细粒度审计。但实际用了半年后我发现:这套平台里根本没有需要“动态生成临时凭证”的场景,所有的密钥都是静态配置,写死在部署环境里。Vault 的价值就变成了一个“稍微安全一点的存放处”。
4.1 Vault 的哪些功能其实用不上
三个典型场景是自托管 Agent 平台最常用的:外部 SaaS 工具 OAuth Token、大模型供应商 Key、数据库密码。它们的特点是一致的:有效期长、调用频率固定、不涉及自动轮换。Vault 的腰封、动态 SQL 凭证、加密即服务能力,在这里完全处于闲置状态。反倒是它自身引入的问题很麻烦:Vault 初始化会产生 unseal 密钥,需要可靠保管;自动解封如果没配好,机器重启后服务会直接不可用。
我在自己环境的重启经历里,至少遇到过两次 Vault 没有自动解封的故障。一开始以为是配置错误,后来发现单机运行环境下 RAFT 存储和自动解封的依赖链太长。事后想想,用这么重的密钥管理服务来服务一个单机平台,本质上是把安全成本“前置付费”了。
4.2 替代方案:环境变量加密钥文件
我的替代策略是分层处理。容器编排工具和 Compose 文件能直接消费的静态变量,放到.env文件里,启动时注入。而 OAuth Token 这类包含特殊字符、容易在转义时出错的内容,则以文件形式挂载到容器里,通过/run/secrets路径读取。
secrets: oauth_token: file: ./secrets/oauth_token services: agent-api: secrets: - oauth_token environment: OAUTH_TOKEN_FILE: /run/secrets/oauth_token关键点是文件权限。宿主机上chmod 600,容器内部以只读方式挂载,不要用:ro以外的模式。生成密钥时用随机的长字符串,尽量不用人脑容易记住的“安全密码”,因为“容易记住的”通常都不安全。
4.3 失去“动态密钥”究竟影响多大
Vault 换掉后损失最明显的是“集中轮换能力”。以前可以让 Vault 按期换数据库密码,现在需要手动改配置并重启服务。我的补偿方法是写了一个密钥轮换脚本:生成新密码、更新 Postgres 角色、更新.env文件、然后滚动重启应用容器。整个过程大概 5 分钟能完成,对于个人自托管场景,这个频率已经足够。
另一个损失是审计日志。Vault 里能看到谁在什么时间读取了哪个密钥,换成文件和环境变量之后,这份审计能力消失了。我的看法是,这类平台本来就是一个人维护,审计需求并不真实。真要是多人团队,大小环境,审计的重要性才会压过部署复杂度。
5. 11 个到 5 个:最终架构全映射
所有替换完成后的架构,比一开始的“全家桶”清晰得多。原来的 11 个容器去掉这、合并那,最后形成 5 个容器的稳定结构:Nginx 反代、Agent API、Agent Worker、Postgres、Redis。应用层的 web 前端不再单独跑一个容器,而是构建后的静态资源直接放在 Nginx 容器里托管;调度器任务和 Worker 合并进同一个容器里,用不同参数区分启动模式。
5.1 新旧容器对照表
| 原组件 | 新方案 | 容器数变化 | 代价 |
|---|---|---|---|
| MinIO 对象存储 | 本地磁盘目录 | 减少 1 | 失去版本控制和生命周期策略 |
| Milvus 向量数据库 | Postgres 的 pgvector 插件 | 减少 1 | 大规模检索能力下降 |
| Milvus 自带 etcd | 移除 | 减少 1 | 无 |
| Milvus 自带 MinIO 实例 | 移除 | 减少 1 | 无 |
| Vault 密钥管理 | 文件挂载密钥 | 减少 1 | 失去动态凭证和集中审计 |
| 前端静态站点容器 | Nginx 直接托管构建产物 | 减少 1 | 失去前端容器独立部署粒度 |
| 调度器容器 | 并入 Worker 容器 | 减少 1 | 定时任务配置需随 Worker 重新加载 |
| Postgres | 保留并启用向量能力 | 不变 | 无 |
| Redis | 保留作为任务队列 | 不变 | 无 |
| Agent API 服务 | 保留 | 不变 | 无 |
| Nginx 反代 | 保留且承载静态资源 | 不变 | 需要额外处理前端缓存规则 |
可以看到,真正被“砍”的实际上是三层中间件壳子:对象存储壳、向量库壳、密钥管理壳。业务本身没有任何削弱。
5.2 Docker Compose 里的落地写法
最终 Compose 的服务定义精简到核心五个部分。下面这个片段去掉了一些不重要的业务环境变量,保留了关键结构:
services: nginx: image: nginx:1.27-alpine ports: - "8080:80" volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro - ./web/dist:/usr/share/nginx/html:ro depends_on: - agent-api db: image: pgvector/pgvector:pg16 environment: POSTGRES_DB: agent POSTGRES_USER: agent POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} volumes: - ./data/postgres:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U agent"] redis: image: redis:7-alpine command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}"] volumes: - ./data/redis:/data agent-api: image: my-agent:latest command: ["web"] environment: DATABASE_URL: postgresql://agent:${POSTGRES_PASSWORD}@db/agent REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0 STORAGE_PROVIDER: local STORAGE_LOCAL_ROOT: /data/storage volumes: - ./data/storage:/data/storage - ./secrets:/run/secrets:ro depends_on: db: condition: service_healthy redis: condition: service_healthy agent-worker: image: my-agent:latest command: ["worker", "--scheduler"] volumes: - ./data/storage:/data/storage - ./secrets:/run/secrets:ro depends_on: - agent-api5.3 迁移流水线与回滚预案
整个迁移过程我严格按照“先保数据、再拆容器”的顺序执行。先备份 Postgres、Redis、存储目录和密钥文件四个关键数据源,再停掉所有业务容器,只留 Postgres 和 Redis。然后启动新架构里的db和redis,做一次版本校验。确认数据无误后,再启动 API 和 Worker,最后启动 Nginx 切换流量。
回滚预案也很明确:旧 Compose 文件没有删掉,保留在deploy/compose.old.yml,配合四个备份目录可以在半小时内恢复。实际操作里回滚只触发了一次,发生在迁移后的首次知识库索引重建时,原因是 embedding 写入失败。退回旧架构后,导出失败记录,修正了字段映射,才再次迁移。这个经验值得强调:向量数据迁移时,不要把“写库失败”简单当成偶发问题,而是要检查向量维度是否和表结构定义一致。
6. 代价清单:哪些能力被静默回收了
任何架构精简都不是免费的。我只在文章开头说了换来的是更轻的部署和更少的运维成本,但另一边的账本也得摊开看,主要有三个方面。
6.1 可扩展性代价
MinIO、Milvus、Vault 分别对应对象存储、向量检索和密钥管理的可扩展性。MinIO 换成本地磁盘,意味着存储容量受限于单机磁盘;Milvus 换成 pgvector,意味着向量检索的并发和规模天花板明显降低;Vault 换成静态密钥,意味着如果你将来要接入多实例、多环境的部署,密钥同步会变成一个手工流程。
我不是单纯说这些不行,而是说“什么时候需要扩展”应该主动设定一个阈值。比如我的设计里,如果知识库向量超过 50 万条,或单次下载文件超过 2GB,那就到了需要重新评估架构的时候。在这之前,这些扩展能力只是为不会到的未来付费。
6.2 安全与审计代价
Vault 能提供的密钥集中管理和动态轮换被我降级成了文件权限加环境变量。这在单机环境下问题不大,但有两件事必须记住:任何进入secrets/目录的文件都不应该被纳入 Git 仓库;备份时密钥文件和业务数据最好分开存放,当前最简单的方式是备份压缩包在生成后立即用 GPG 加密。这样即使备份文件泄露,密文也不会直接变成能用的 Token 或密码。
另外,Postgres 的密码现在写在了.env文件里,这就相当于把安全信任迁移到了宿主机的用户权限和 Docker Compose 配置管理上。如果是多人共享服务器,建议额外建一个独立用户运行整个容器栈,别用 root 直接维护。
6.3 备份与恢复代价
原来的备份流程要处理 Postgres 逻辑备份、MinIO 目录内容、Milvus 向量库快照、Vault 存储文件四个维度。能简化成两个动作:pg_dump导出数据库,tar压缩存储目录。这让日常备份完全可以做成一个 cron job,每天早上自动执行并保留最近 7 份。恢复时也更快了:装好 Compose、恢复 Postgres dump、把存储目录解压回去、启动容器,整个过程不需要考虑多个中间件之间的依赖关系。
代价是丢失了对象存储的版本回滚,即 MinIO 里能找回“十分钟前被覆盖版本”的能力。我的替代方案已经说了:物理删除前先移到 trash 目录并延迟 7 天再清理。这套逻辑虽然实现起来多几个步骤,但比对象存储的版本策略更符合自托管平台的实际操作习惯。
7. 避坑记录与这类优化到底值不值
最后记录几条我在操作过程中真实踩过的坑,希望能帮你少走弯路。
7.1 I 踩过的坑与解决思路
第一个坑是删掉 MinIO 后,部分旧数据无法访问。原因不是文件丢了,而是 MinIO 的 bucket 名称本身形成了一层路径前缀,切换到本地存储后,这些文件在目录中的排列方式变了。解决方法是提前写一个迁移脚本,把 MinIO 导出目录里的 bucket 层级展开,重新挂到本地根目录下。如果你想避免这种迁移,先检查应用层是否已经对存储路径做了抽象。
第二个坑是 pgvector 的 HNSW 索引在数据量比较小时反而可能增加查询延迟。这是因为索引构建和维护都需要开销。对于只有几千条向量的场景,忽略索引反而执行更快。我是先用普通表规模试跑了几次,再根据真实查询耗时决定给哪几张表建索引。给别人建议时,我也一直强调“不要照搬索引策略,自己数据量说了算”。
第三个坑是 Vault 替换后,某些依赖动态密钥的工具没法工作了。比如我之前给某个外部 API 配置过 Vault 的动态令牌,移除 Vault 后,应用启动时报权限错误。这类问题需要提前检查工具是否支持静态 Token。如果它要求很强的流兼容,那就必须保留最小化的密钥分发服务,不能为了精简而强行绕开。
7.2 这类优化到底值得做吗
我做这个精简并不是因为容器数量本身有什么洁癖,而是每个中间件都需要内存、磁盘 IO 和升级时间。一台 8G 内存的单机服务器,跑一个 Agent 推理任务,可用内存每少 1G,体验差别就很明显。砍掉这三个组件后,可用内存从 4G 左右涨到 6G 左右,这对实际任务处理的流畅度是实打实的提升。
更值得关注的是“单一责任”原则。把数据都集中到 Postgres 和 Redis,其实让整个平台的数据关系变得更加清楚。备份、恢复、迁移这三个运维环节,都从“协调多个系统”变成了“操作一个数据库和一堆静态文件”。这反而促成了更规律的备份习惯。
如果你这套平台还在开发或者单机使用阶段,我建议你不用一上来就部署 Milvus、Vault 甚至独立的 MinIO。先用 Postgres + 本地磁盘 + 静态密钥跑起来,把业务流程打通,等确认平台真的被高频使用,再把规模压力逐层补回来。到那时你会更明确地知道:哪些组件是必需的投资,哪些只是橱窗里的摆设。