
1. 项目概述一台小机器上的全家桶是怎么撑出来的先说结论我这套自托管 Agent 平台原来 docker compose 里躺着 11 个容器内存常年 13GB 起步冷启动一次要三分钟升级任何一个组件都像拆炸弹。折腾了两周砍到 5 个容器内存稳定在 5GB 左右冷启动 40 秒。砍掉的重型组件有三个MinIO对象存储、Milvus向量数据库、Vault密钥管理。这篇文章把替换方案、迁移步骤和真实代价一条条讲清楚给同样在搞自托管 Agent 应用、或者用 RAG 做个人知识库的朋友一个参考。先说明这套平台不是拿现成开源项目改的是我自己攒的一套服务FastAPI 写的 Agent API处理会话、工具调用、知识库检索Celery 跑异步任务比如调用外部工具、抓网页、生成 embeddingPostgreSQL 存业务数据Redis 做缓存和队列前端是 Vue 打包的静态文件用 Caddy 做反向代理和 HTTPS。后来为了支持上传文档做 RAG会话记忆检索密钥安全管理陆陆续续把 MinIO、Milvus、Vault 都加进来了容器数一路涨到 11 个。1.1 最初 11 个容器的构成我把初始架构列一张表方便对照后面每一步的取舍。这 11 个容器在 16GB 内存的迷你主机上实测资源占用如下容器职责实测常驻内存处置方式caddy反向代理、TLS、静态资源约 100MB保留agent-apiAgent 主服务FastAPI约 800MB保留agent-workerCelery 异步任务约 600MB合并进 agent-apiagent-scheduler定时任务celery beat约 300MB合并进 agent-apiagent-web前端静态资源镜像约 50MB保留postgres业务数据库约 1.5GB保留加 pgvector 插件redis缓存、队列约 600MB保留限制最大内存minioS3 对象存储知识库附件约 500MB替换为本地磁盘milvus向量数据库RAG 检索约 4GB替换为 pgvectoretcdMilvus 依赖的元数据存储约 500MB随 Milvus 一起移除vault密钥管理API Key、数据库密码约 300MB替换为 age 加密 env 文件加起来接近 9GB实际跑起来加上 page cache、日志缓冲内存压力非常大。这台机器只有 16GB 内存docker compose 起来之后真机只剩 2GB 出头稍微跑几个并发会话就直接 OOM然后某一两个容器被杀掉整个平台跟着雪崩。1.2 为什么要砍内存只是表面运维复杂度才是大头内存压力是导火索但不是唯一原因。Milvus 这套东西很典型它是一个分布式系统哪怕用 standalone 模式部署也会把 etcd 拉进来做元数据存储而 etcd 又要占用独立的端口和存储目录。所以你以为装了一个向量库实际上装了一套小型分布式系统。而且 Milvus 更新很快从 2.3 到 2.4 到 2.5接口、配置都有变化每次升级我都要重新读一遍 release note否则镜像拉起来直接起不来。Vault 更让人头疼。Vault 本身设计是给多节点、多团队用的功能很强但代价是你得管理 unseal key、根 token、策略文件还要处理存储后端。我一个人维护这台机器Vault 每次重启都要手动 unseal不做的话所有密钥都读不出来。后来我甚至写了个脚本去自动 unseal但脚本本身又成了新的安全脆弱点。MinIO 的问题是大象装进冰箱我只需要一个简单的文件存储给知识库归档、给用户头像和附件用结果我跑了一个带 Web 控制台、带 bucket policy、带审计日志的 S3 对象存储服务。它单节点其实不重但和 Milvus、Vault 叠加在一起整台机器的资源就失控了。所以这次瘦身的核心思路很简单按单机自托管这个真实场景重新做技术选型把三个重量级组件全部换成跟着主服务走的轻量方案而不是继续堆中间件。2. 三个重量级组件分别换成了什么2.1 MinIO换成本地磁盘挂载前提是应用层有存储抽象MinIO 的核心价值是 S3 兼容 API这套 API 的价值在于让应用不关心存储后端的物理位置。但在单机场景下这个价值反而是负担应用通过 HTTP 调 S3 API数据经过网关写入磁盘白白多走一层网络栈和权限校验。我换成了直接挂载本地目录。具体做法是在宿主机建一个目录/data/agent-files把这个目录以 bind mount 方式挂进 agent-api 容器应用里把存储后端从s3切到local。这里有个前提你的应用代码必须做了存储抽象不能到处直接调用 S3 SDK。我当初写代码时统一封装了一个StorageBackend接口有put_object、get_object、delete_object三个方法底层实现是 S3 还是本地文件系统只看配置项。如果你的应用没有这层抽象就得花半小时补一个本地实现核心逻辑其实就是shutil.copy、open、os.remove的封装并不复杂。配置对比非常简单# 之前 STORAGE_BACKENDs3 S3_ENDPOINThttp://minio:9000 S3_ACCESS_KEYxxx S3_SECRET_KEYxxx S3_BUCKETagent-files # 之后 STORAGE_BACKENDlocal LOCAL_STORAGE_PATH/data/files这样 MinIO 就被拿掉了。如果哪天应用要横向扩展成多节点再把配置改回 S3 兼容后端就可以本地文件方案本质上是降级存储不是替代存储。2.2 Milvus换成 PostgreSQL pgvector少掉三个容器Milvus standalone 模式看起来是一个服务实际拉起来会发现它还依赖 etcd有些部署方案还会再带一个 MinIO 做内部对象存储。我看了下我当时的 compose 文件里Milvus 相关容器一共有三个milvus、etcd、attuMilvus 的 Web 管理界面。替换方案是 PostgreSQL 的 pgvector 插件。为什么选 pgvector 而不是 sqlite-vec 或 Qdrant因为这台机器本来就有 Postgres业务数据全在里面让向量数据直接住在同一个数据库里就少了一个独立服务。pgvector 用起来非常顺它提供vector类型、余弦距离操作符、HNSW 索引功能上对单机小规模场景完全够用。替换完成后三件事同时解决向量库没了、etcd 没了、attu 没了容器数直接少了 3 个。代价在后续章节细说这里先给结论如果你的向量规模在 100 万条以内pgvector 的检索性能和 Milvus 的体感差异很小一旦超过这个量级或者你要做非常复杂的标量过滤加向量检索混合查询才需要考虑回到 Milvus 这类独立向量库。2.3 Vault换成 SOPS Age 加密的 .env 文件Vault 是个好东西但它的设计思路是总数据中心给几十个应用发动态密钥、做自动轮转、出审计日志。单机自托管用 Vault等于为了锁一扇门雇了一个保安队。我的替代方案是 SOPS 加 Age 加密。具体来说把所有的密钥写进一个.env文件然后用 age 的公钥加密成.env.age只有持有 age 私钥的人才能解密。容器启动的时候entrypoint 脚本调用 sops 解密把变量导入环境变量然后启动应用进程。整个过程没有引入任何常驻容器密钥文件放在宿主机上权限设为 600私钥我备份在离线移动硬盘里。这个方案能做到静态加密 环境变量注入但放弃了 Vault 的动态密码和审计能力。对单人维护的家庭服务器来说这个权衡是值得的后面第 4 章会展开说它损失的边界。3. 实操过程从 11 到 5 的完整迁移记录3.1 迁移前强制做的三件事备份、快照、跑全链路验证任何架构瘦身都忌讳边改边删。我的迁移第一步不是改代码而是把整个系统的当前状态完整备份下来确保任何一步出错都能回到原点。文件存储备份用 MinIO 客户端# 用 mc 备份 minio 里的所有 bucket mc alias set local http://localhost:9000 admin PASSWORD mc mirror --overwrite local/agent-files /backup/minio/agent-filesMilvus 数据备份用官方 Python SDK把所有 collection 导出成本地文件我写了个一次性脚本把每条数据的 id、标量字段、向量字段全部拉出来存成 parquetfrom pymilvus import Collection import pandas as pd col Collection(agent_memories) col.load() records [] it col.query_iterator(batch_size256, output_fields[id, user_id, source_doc_id, content, embedding]) while True: batch it.next() if not batch: break records.extend(batch) df pd.DataFrame(records) df.to_parquet(/backup/milvus/agent_memories.parquet)Vault 密钥导出更直接用命令行遍历 KV 路径把值拼成环境变量格式vault kv list secret/agent vault kv get -formatjson secret/agent | jq -r .data.data | to_entries[] | \(.key)\(.value)PostgreSQL 业务库直接pg_dumpRedis 用redis-cli SAVE生成 RDB 快照即可。备份做完之后我还写了一个全链路验收脚本注册用户、创建 Agent、上传一份 PDF、让它基于知识库回答一个问题、触发一个定时任务。这个脚本在后面每一步迁移完成之后都要完整跑一遍任何一步失败都能立刻定位是存储、检索还是密钥的问题。3.2 文件存储切换从 S3 bucket 到本地目录文件存储的切换是所有里面最简单的但有一个坑很容易踩容器内运行用户 UID 和宿主机目录属主不匹配。我先把目录建好并授权mkdir -p /data/agent-files # 我的 agent-api 镜像里运行用户是 UID 1000 chown -R 1000:1000 /data/agent-files然后在 docker-compose 里给 agent-api 加上 volumeagent-api: image: registry.example.com/agent-api:2.3.1 volumes: - ./data/agent-files:/data/files environment: STORAGE_BACKEND: local LOCAL_STORAGE_PATH: /data/files改完配置重启容器上传一个测试文件确认文件落到/data/agent-files里。注意应用里已经保存的对象 URL 需要做一次前缀替换。之前是http://minio:9000/agent-files/xxx.pdf现在是/data/files/xxx.pdf我直接在数据库里执行了一条 UPDATE 把旧前缀替换成新路径UPDATE attachments SET url replace(url, http://minio:9000/agent-files, /data/files) WHERE url LIKE http://minio:9000/agent-files%;这个步骤容易漏漏了的话历史附件的下载链接全部失效。3.3 向量库迁移Milvus 导出、pgvector 导入、校验召回向量库迁移是整个过程中最核心的一步因为检索效果直接关系到 Agent 的回答质量。我分了三步走。第一步在 Postgres 里启用 pgvector 并建表。我用的是pgvector/pgvector:pg15镜像它自带插件CREATE EXTENSION IF NOT EXISTS vector; CREATE TABLE agent_memories ( id BIGSERIAL PRIMARY KEY, user_id TEXT NOT NULL, source_doc_id TEXT, content TEXT NOT NULL, embedding VECTOR(1536) NOT NULL );我的 embedding 模型是 OpenAI 的 text-embedding-3-small维度 1536。维度字段必须和导入数据一致否则会报错。第二步写迁移脚本从 parquet 文件批量导入 pgvectorimport psycopg2 from psycopg2.extras import execute_values import pandas as pd df pd.read_parquet(/backup/milvus/agent_memories.parquet) conn psycopg2.connect(postgresql://agent:密码postgres:5432/agent) cur conn.cursor() rows [ (r.user_id, r.source_doc_id, r.content, r.embedding) for r in df.itertuples() ] execute_values( cur, INSERT INTO agent_memories (user_id, source_doc_id, content, embedding) VALUES %v, rows, page_size1000, ) conn.commit()导入之后创建 HNSW 索引。这一步很关键不建索引的话查询是全表扫描几万条数据就可能要等好几秒CREATE INDEX agent_memories_embedding_idx ON agent_memories USING hnsw (embedding vector_cosine_ops) WITH (m 16, ef_construction 64);第三步验证召回效果。我在 Milvus 里随便挑了几条已知 query记下 Top 10 的 id然后在 pgvector 里执行同样的检索看返回集合的重合度SELECT id, content, 1 - (embedding :query_vec) AS similarity FROM agent_memories WHERE user_id :user_id ORDER BY embedding :query_vec LIMIT 10;这里要注意是余弦距离相似度是1 - 距离。Milvus 的 COSINE metric 算出来也是 1 减去余弦相似度理论上是同一个值但实际因为浮点精度和索引参数不同分数会有细微差异不要拿旧分数阈值硬套新库。我自己跑下来Top 10 重合度通常在 9/10 以上偶发 8/10 是因为 HNSW 索引的 ef_search 参数默认值太低调高后基本稳定在 9/10 以上。3.4 密钥迁移Vault KV 到加密 env 文件第一步把所有密钥从 Vault 导出成标准.env格式。第二步生成 age 密钥对age-keygen -o age.key # 输出公钥 age-keygen -y -i age.key第三步用 sops 加密.envsops --age age_PUBLIC_KEY --encrypt .env .env.age加密之后.env.age可以放心地提交到私有仓库里因为任何没有私钥的人都解不开。私钥文件 age.key 放在宿主机的/etc/agent-secrets/目录权限 600并且通过 docker secrets 挂载进容器agent-api: secrets: - age_key entrypoint: [/entrypoint.sh] secrets: age_key: file: /etc/agent-secrets/age.key容器里的 entrypoint 脚本负责解密并注入环境变量#!/bin/bash set -euo pipefail sops --age /run/secrets/age_key --decrypt /app/.env.age /tmp/.env set -a . /tmp/.env set a rm -f /tmp/.env exec /usr/local/bin/agent-api --host 0.0.0.0 --port 8000这套方案的好处是重启容器不需要任何人工介入私钥已经挂在容器里自动解密自动注入。和 Vault 相比最大的区别是轮转密钥时要改.env文件然后重启容器做不到动态切换。但对单机场景来说完全够用。3.5 合并 worker 和 scheduler最终只剩 5 个容器这步是额外收益。之前把 Celery worker 和 beat 拆成两个容器是为了职责单一但单机场景下这三个进程API、worker、beat都是同一份代码拆开纯粹增加资源消耗和编排复杂度。我的做法是在 agent-api 容器内部用 supervisord 同时拉起三个进程对外还是一个容器。supervisord 配置大概是这样[supervisord] nodaemontrue [program:api] commanduvicorn app.main:app --host 0.0.0.0 --port 8000 directory/app autorestarttrue [program:worker] commandcelery -A app.tasks worker --concurrency2 --loglevelINFO directory/app autorestarttrue [program:beat] commandcelery -A app.tasks beat --loglevelINFO directory/app autorestarttrue这样最终容器列表变成了容器职责caddy反向代理、静态资源、TLSagent-api主服务 worker beatsupervisord 拉起agent-web前端静态资源postgres业务库 pgvectorredis缓存、队列正好 5 个。最终 docker-compose 关键片段如下postgres: image: pgvector/pgvector:pg15 environment: POSTGRES_DB: agent POSTGRES_USER: agent POSTGRES_PASSWORD: ${DB_PASSWORD} volumes: - pg_data:/var/lib/postgresql/data restart: unless-stopped redis: image: redis:7-alpine command: [redis-server, --maxmemory, 512mb, --appendonly, yes] volumes: - redis_data:/data restart: unless-stopped agent-api: image: registry.example.com/agent-api:2.4.0 volumes: - ./data/agent-files:/data/files secrets: - age_key depends_on: - postgres - redis restart: unless-stopped启动后跑一遍验收脚本全链路通过。docker stats --no-stream看一眼内存从 13GB 降到 4.8GB效果非常直观。4. 代价清单省下的和付出去的要分开算4.1 省下的资源、升级风险、故障域先说收益不然显得这个折腾没意义。内存从 13GB 降到 4.8GB 是最明显的。进一步看CPU 空载占用率从 30% 降到 8% 左右因为 Milvus 和 etcd 即使空闲也在做各种协调任务它们被移除后整机清爽很多。磁盘方面Milvus 的 segment 文件、etcd 的 WAL 日志、MinIO 的 bucket 元数据加起来占了 100GB 以上这些冗余全部消失数据量本身没变但磁盘占用少了几十个 GB。第二个收益是镜像管理。原来 11 个服务要跟踪 11 条更新线MinIO 一年发几十个版本Milvus 大版本升级要重新初始化数据Vault 的安全补丁不及时就有心理负担。现在只剩三类需要关注的镜像应用镜像、Postgres 镜像、Redis 镜像升级路径简单很多。第三个收益是故障域。之前 Milvus 依赖 etcdetcd 一抖动向量检索就中断MinIO 挂了附件上传下载全挂Vault 没 unseal所有密钥读不出来。三个中间件任何一个出问题都会拖垮整个平台。现在这些都变成应用的一部分要么跟着应用一起正常要么一起挂没有中间的暧昧状态。说实话对单机部署来说这种全有或全无反而更好排查。4.2 付出的扩展性、高可用、动态密钥、管理工具代价必须摊开说。第一个代价是横向扩展能力直接归零。原来的 MinIO 方案多个应用节点可以共享同一个 S3 桶数据天然集中。现在用本地目录一旦 agent-api 扩展到第二个副本两个进程写同一个本地目录就是灾难。这个问题不大但必须知道边界这套方案只适合单节点跑。第二个代价是向量检索规模的硬上限。pgvector 在百万级以下表现良好超过这个量级HNSW 索引的内存占用和召回率会明显劣化。Milvus 可以靠分布式分片扛千万级向量pgvector 扛不了。另外 pgvector 的 HNSW 索引和 SQL 标量过滤结合时有个坑如果 WHERE 条件过滤性太强Postgres 可能放弃走索引做全表扫描这一点在数据量大时很容易踩中。第三个代价是动态密钥能力消失。Vault 可以做到数据库密码每 30 分钟自动轮转Agent 调外部 API 的 token 也能动态签发和撤销。换成静态 env 之后这一切都退化成改文件 重启。如果哪天密钥泄露你得人工改所有相关配置而不是让 Vault 自动 revoke。这个损失对合规要求高的场景是致命的对个人自托管还好。第四个代价是管理工具缺失。原来有 attu 这样的可视化界面看向量数据现在只能 SQL 查询。Milvus 的 partition 能力、按 collection 隔离数据的能力也没有了多租户场景下全得靠 SQL 的WHERE user_id ?来做好在单机自托管本来也没有多租户压力。我用一张表总结得失维度原方案现方案内存占用9GB4.8GB冷启动时间约 190 秒约 40 秒横向扩展支持S3 共享不支持向量规模上限千万级百万级密钥动态轮转支持不支持审计日志完整无可视化管理attu / minio consoleSQL升级复杂度高低单机故障域多个中间件独立故障随应用统一故障4.3 什么时候这套替换方案会反噬如果只是个人自托管、小团队内部使用这套方案非常舒服。但下面这几个信号出现时就该考虑反瘦身把 MinIO、Milvus、Vault 请回来或者直接上托管服务。向量数据增长速度很快比如三个月就超过 50 万条而且还在加速那 pgvector 的生产力会明显下降。应用需要多实例部署比如为了升级零停机必须同时跑两个 agent-api 副本本地文件存储立刻变成瓶颈。团队里有多个角色需要访问平台而且有权限隔离需求静态 env 文件没法做到细粒度授权Vault 的 policy 才能解决。另外如果将来要做等保、审计相关的合规Vault 的审计日志几乎是硬性要求。我个人的判断是单机自托管 Agent 平台的黄金规模就是 5 个容器这个体量。小于这个规模你会觉得连 Postgres 和 Redis 都多余大于这个规模你应该已经在考虑上正式的 Kubernetes 和云数据库而不是继续折腾 docker compose。5. 常见问题与踩坑实录5.1 本地目录权限导致的诡异 403我在切换文件存储后遇到一个很怪的问题上传文件成功但通过 API 下载时 403。查了半天发现是宿主机目录属主是 root容器内应用用户是 UID 1000容器进程能写目录因为目录权限是 777但文件创建出来的属主是 UID 1000下载时应用尝试读取文件权限没问题实际问题是容器内挂载点的父目录权限不对。解决方式就是前面提到的chown -R 1000:1000。这里提醒一句如果你用的是群晖、威联通这类 NAS或者网络挂载的 SMB/CIFS 卷权限模型更复杂bind mount 之前务必确认目录支持 POSIX 权限位否则你会陷入能写不能读、能读不能删的连环坑。5.2 pgvector 的相似度分数和 Milvus 对不上迁移完向量库后我发现同一个 query 在 Milvus 和 pgvector 里返回的相似度分数差了不少。认真排查后原因是索引参数和浮点实现的差异Milvus 的 COSINE metric 和 pgvector 的操作符理论上都算余弦距离但实际计算结果会有小偏差。这里要明确一点不要把不同向量库的相似度阈值混用。你在 Milvus 里定的0.75 以上才返回这个阈值到了 pgvector 可能实际分数差 0.03 左右导致召回结果变多或变少。最好的做法是迁移完成后用一批标注好的 query 重新标定阈值。5.3 合并 worker 后的进程守护和日志问题把 worker 和 beat 塞进 agent-api 容器后第一个问题是进程守护。用sh -c uvicorn ... celery worker ...这种写法主进程退出时子进程可能变孤儿。必须用 supervisord、s6 这类进程管理器并且设置autorestarttrue。第二个问题是日志。三个进程的 stdout 混在一起排查问题时看不清谁打了什么。我的做法是给每个 program 单独指定日志文件[program:worker] commandcelery -A app.tasks worker --concurrency2 --loglevelINFO stdout_logfile/var/log/worker.log stderr_logfile/var/log/worker_error.log autorestarttrue然后让这三个日志文件通过 volume 挂到宿主机方便统一用tail -f查看。第三个问题是健康检查。之前 worker 挂了agent-api 还活着用户访问不受影响只是任务堆积。合并之后agent-api 容器里任何进程崩溃restart: unless-stopped都会把整个容器重启反而更容易恢复。我加了supervisorctl status的健康检查healthcheck: test: [CMD, supervisorctl, status] interval: 10s timeout: 5s retries: 5这样如果某个进程反复重启导致 unhealthycaddy 会自动把它从负载均衡里摘掉虽然单节点场景只有它自己。5.4 没了 attu向量数据怎么排查以前看向量数据直接在 attu 界面里翻现在只能靠 SQL。我建了一个简单的排查视图把相似度检索包装成 SQL 语句-- 找某用户的最近 20 条记忆按时间倒序 SELECT id, content, created_at FROM agent_memories WHERE user_id u_1001 ORDER BY created_at DESC LIMIT 20; -- 看当前向量总数和维度 SELECT count(*), vector_dims(embedding) FROM agent_memories;实际用下来SQL 反而更顺手因为能直接 JOIN 业务表比如把某条记忆对应的文档名、上传时间一起查出来。这算是因祸得福。5.5 回滚预案保留旧 compose 和 volume 快照架构瘦身不是单向门万一新方案出问题你要能快速回滚。我在动手前把旧的docker-compose.yml重命名为docker-compose.pre-slim.yml原样保留镜像 tag 全部锁定在当时的版本数据卷没有删除重建。具体一点我在迁移前执行了 volume 快照docker run --rm \ -v milvus_data:/volume:ro \ -v /backup/docker-volumes:/backup \ alpine tar czf /backup/milvus_data.tar.gz -C /volume . docker run --rm \ -v minio_data:/volume:ro \ -v /backup/docker-volumes:/backup \ alpine tar czf /backup/minio_data.tar.gz -C /volume .Postgres 和 Redis 的卷也做了同样处理。这意味着任何时候想回到 11 容器老架构只需要用旧 compose 文件重新拉起再解压对应 volume 即可。这个动作花了我十分钟但保险系数提升了一个量级。6. 最后说几句掏心窝子的这次瘦身最大的收获不是省了多少内存而是让我重新理解了自托管这个词。之前我总是不自觉地按照生产环境的思路堆中间件总觉得 MinIO、Milvus、Vault 一个都不能少结果一台 16GB 的小主机被拖得动弹不得。实际上中间件的价值在于解决规模化问题而我根本没有规模化的问题。技术选型最忌讳的是看着别人都在用所以我也用而是要看自己的真实负载和运维能力。如果你也打算对自己的自托管平台做一次瘦身我的建议是先做资源盘点拿着docker stats的数据说话不要凭感觉决定砍谁然后保证每一步都可回滚给旧配置留一条完整的后路最后也是最重要的砍掉一个组件之后一定要跑一遍全链路验收而不是只看容器数量变少了就沾沾自喜。另外替换方案本身也有进化的空间。比如现在的本地文件存储方案将来如果遇到多实例需求我可能会把它换成 Garage 这类轻量 S3 实现或者直接把存储桶托管到云服务。pgvector 方案在数据量突破百万后我也可能重新评估 Qdrant。方案不是死的关键是自己心里要有一杆秤每个组件的存在都要回答它解决了什么问题我是否真的有这个问题这两个问题。最后分享一个小技巧瘦身完成后我在 Caddy 的访问日志里加了一个只统计 5xx 的筛选规则配合一个简单的健康检查脚本。任何一次迁移后只要这个 5xx 数量在接下来一周里没有异常上涨我就认为这次改动是成功的。希望你也能找到属于自己的那个观察指标让架构改动不再是靠猜。