这两年我们这种三五个人的小团队也开始频繁被业务方要求"上个 Agent"。所谓多技能 Agent,就是把对话理解、知识库检索、内部接口调用、异步批量任务这些能力打包进同一个服务,让用户用一句话完成过去要打开两三个系统才能做的事。但"我本机跑通了"和"团队能一起用"之间隔着一条很宽的河:依赖版本、密钥、模型权重路径、端口占用,任何一环不对,换个同事的电脑就崩。这篇文章记录的是我们用 Docker Compose 把多技能 Agent 打包成一条命令即可拉起的小团队 AI 服务完整落地过程,包括架构拆分、compose 配置逐行解读、一键脚本设计、日常运维和一份踩坑记录。适合没有专职运维、但想把 AI 服务从 demo 推向日常使用的团队参考。
1. 为什么小团队 AI 服务的第一站就选 Docker Compose
1.1 从"我本机跑通了"到"同事能一键用上"的差距
多技能 Agent 比传统 Web 服务更容易翻车,原因是它身上的"活"种类太多:要连大模型接口、要读向量库、要调内部业务接口、还要跑可能持续几分钟的异步任务。这些依赖在本机跑通,靠的是你电脑上凑巧存在的 Python 版本、某个全局装过的包、甚至某个环境变量。一旦换个环境,问题就从"功能对不对"退化成"环境配不配",排查成本反而更高。
我们踩过最典型的一次:某位同事在 Mac 上把 agent 核心服务跑起来了,第二天换到一台 Linux 服务器演示,直接报 CUDA 相关的导入错误——虽然我们根本没用 GPU 跑模型,但某个依赖在安装时检测到了显卡就顺手拉了相关包。这种问题说大不大,但足够让一次演示泡汤。
Docker Compose 解决的核心问题是环境可复现:所有人的运行环境,从操作系统层、依赖层到启动参数,全部由一份 compose 文件和若干 Dockerfile 声明出来。新同事加入时不需要看"安装文档 v13 修订版",只需要 clone 代码、复制一份 .env.example 为 .env、跑一条 make up。这就是"可落地"的第一步——不是你能跑,而是谁都能跑。
1.2 Compose 和 K8s 的边界:我们为什么不做大而全
有同事提过:"是不是该上 Kubernetes?"我的回答很直接:以我们的规模,Compose 覆盖了至少八成需求,剩下两成用不上,或者一年才用上一次。
Kubernetes 擅长的事情是弹性伸缩、跨节点调度、服务发现和故障自愈,但这些能力都有成本:需要至少几个节点组成的集群,需要有人维护 ingress、证书、存储类、命名空间这些概念。小团队通常只有一台或两三台服务器,也没有专职运维,把精力花在"如何让集群跑起来"上,而不是花在"Agent 业务本身"上,是本末倒置。
Compose 的好处恰恰在于它克制:一条docker compose up -d就能拉起整个服务栈,容器间网络自动创建,命名卷自动管理,重启策略写在配置里。它和学习曲线陡峭的集群方案不一样,Compose 是在你已有 Docker 基础之上顺理成章的下一步,适合"团队人数一只手数得过来、服务器一台顶得住、需求变化快"的典型小团队场景。
顺便说一句,"可落地"在我这里的定义是三个词:可复现(换机器能起来)、可回滚(出问题能倒回去)、可迁移(换服务器不用重写)。这三个词 Compose 都能用很低的成本兑现,这也是我坚持选它的根本理由。
2. 多技能 Agent 的容器拓扑与拆服决策
2.1 一个"多技能"Agent 到底需要哪几类角色
先别急着写 compose 文件,先把 Agent 服务拆成角色。我们最终跑起来的服务栈包含七个角色,我按职责整理成下面这张表,你对照自己的项目画一遍,基本就有谱了:
| 角色 | 职责 | 落地的容器方案 | 是否建议单独拆 |
|---|---|---|---|
| 对话编排核心 | 接收用户请求,拆解任务,调度技能,维护会话上下文 | Python 服务,对外提供 REST/WebSocket 接口 | 是,这是主服务 |
| 模型网关 | 统一封装大模型调用,做路由、限流、密钥管理 | 一个轻量 API 代理容器 | 强烈建议 |
| 异步任务执行器 | 跑长耗时技能,如解析上传文件、批量抓取、生成报告 | 独立的 worker 进程容器 | 是,必须拆 |
| 向量数据库 | 存知识库切片和会话记忆,支撑检索增强生成(RAG) | 向量库容器,挂命名卷 | 是 |
| 结构化存储 | 存用户、会话、任务状态、技能执行记录 | PostgreSQL 容器 | 是 |
| 缓存与队列 | 缓存热点数据,做任务消息队列和发布订阅 | Redis 容器 | 是 |
| 前端界面 | 给业务方一个聊天气泡入口 | 静态站点容器,或由 Nginx 托管 | 看团队情况 |
这个表不是拍脑袋定的。多技能 Agent 的"技能"本质上是两种:一种是编排核心进程内部的代码函数,比如调用一个内部查询接口、拼一段 prompt;另一种是需要独立资源和生命周期的东西,比如大段文档的解析、批量对话任务。前者塞在核心服务里没问题,后者如果也塞在同一个进程里,一个跑十分钟的任务就能占满所有工作线程,把对话请求全堵在后面。
2.2 哪些技能该拆、哪些技能该塞进同一个容器
我在项目里坚持的原则是:先按业务耦合度拆,不按概念拆。刚起步时,我们把"知识库检索"和"内部数据查询"都做成了编排核心里的普通函数,当时跑得挺好。后来知识库数据量上来,检索一次要几百毫秒,并且我们想单独给检索加缓存和限流,这才把它后面挂着的向量库拆成独立容器,检索逻辑本身依然留在核心服务里。
真正让我下决心把 worker 拆出来的是那个批量周报技能:用户上传几十个文件,Agent 要逐个解析、生成摘要、再汇总成一张表。这条任务跑起来要几分钟,期间如果用同一个服务进程处理,所有用户的对话请求都会排队,体验立刻崩掉。拆成独立的 worker 容器后,主服务只负责"接单"和"回复任务已开始",worker 慢慢在后台干,干完再通过 Webhook 通知用户,两边互不阻塞。
这里有一个值得养成的设计习惯:不要把多个技能容器直连在一起,所有技能的调度统一走编排核心。也就是说 worker 不要自己直接去连 PostgreSQL,而是让核心服务给它派活、收结果。这样每个容器的职责边界才清晰,以后替换任何一个技能,都不需要动其它容器的联调关系。
2.3 网络拓扑:哪些服务能互相访问,哪些必须隔离
容器之间默认可以通过服务名访问,但我不建议图省事把所有服务放进同一个扁平网络。Compose 支持给服务划分不同的网络,我们会话起来是三层:
- 公网可达层:前端界面、编排核心的对外接口、模型网关的对外端口(如果需要外部调用)。
- 内部服务层:Redis、PostgreSQL、向量库,这些只允许被编排核心和 worker 访问。
- 任务执行层:worker 容器,只需要访问内部服务层和编排核心,不直接暴露任何端口。
这样做最大的收益是安全边界清晰:就算某个技能容器被打穿,攻击者也够不着数据库的管理端口。另一个收益是排障更快——当你用docker compose ps看到某个容器挂在哪张网络下,出问题时的访问链路立刻就清楚了。
3. 落地 compose 文件逐行拆解:从服务定义到健康检查
3.1 主应用服务的 Dockerfile 与多阶段构建
先看编排核心服务的 Dockerfile。我们的核心服务是 Python 写的,技术上没用什么冷门东西,但 Dockerfile 的写法有几个细节直接影响镜像大小和构建速度:
FROM python:3.11-slim AS base WORKDIR /app FROM base AS deps COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt FROM base AS runtime COPY --from=deps /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages COPY app ./app RUN useradd --create-home appuser USER appuser EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]这个 Dockerfile 值得拎出来讲两点。
第一,把requirements.txt单独 COPY 再安装依赖,是为了吃透 Docker 的构建缓存:只要依赖文件没变,这一层永远不会重新执行,改业务代码后重新构建镜像只需要跑 COPY app 那一步,开发期迭代速度差异巨大。第二,最后用USER appuser切到非 root 用户运行,这既是安全习惯,也是后面省掉一堆权限报错的关键——很多中间件容器拒绝以 root 挂载卷时留下的怪问题,在应用层先规避掉会舒服很多。
镜像的 tag 也值得立个规矩:不要每次都用latest,用日期或 commit 简写打 tag,比如agent-core:20250601。回滚的时候,你只需要在 .env 里把IMAGE_TAG指回上一个 tag 再docker compose up -d,就是这个动作救了我不止一次。
3.2 compose 核心配置:服务、网络、卷、环境变量
下面是我们 compose 文件里最核心的一段,为了讲清楚我做了适当删减:
services: agent-core: image: registry.internal/agent-core:${IMAGE_TAG:-latest} restart: unless-stopped env_file: - .env environment: - TZ=Asia/Shanghai depends_on: redis: condition: service_healthy postgres: condition: service_healthy vector-db: condition: service_healthy networks: - public - internal healthcheck: test: ["CMD", "curl", "-fs", "http://localhost:8000/healthz"] interval: 30s timeout: 5s retries: 3 deploy: resources: limits: memory: 1g cpus: "1.0" redis: image: redis:7-alpine restart: unless-stopped command: ["redis-server", "--appendonly", "yes"] healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 3s retries: 5 volumes: - redis-data:/data networks: - internal networks: public: internal: internal: true volumes: redis-data:几个关键点逐一说。
restart: unless-stopped是小团队最容易忽略但最重要的字段:服务器重启、容器崩溃、OOM 被杀,Docker 都会自动把服务拉回来。没有这一行,半夜三点 Redis 挂了,第二天早上业务方已经来问"机器人怎么不回了"。
网络这里用了一个小技巧:internal: true的网络内部容器默认无法访问外网,适合放数据库和队列这类不需要出站的容器。Redis、Postgres、向量库挂在 internal 网络上,编排核心同时挂 public 和 internal 两张网,这是 compose 里很实用的隔离做法。
deploy.resources.limits限制内存和 CPU。很多人觉得小团队不用管资源,实测下来恰恰相反——一个失控的检索任务就能吃光整台服务器的内存,把旁边跑的其它项目全拖下水。限制是给服务戴上的安全帽,宁可偶尔性能低一点,也不能让它把宿主机搞垮。
提示:
env_file里的 .env 文件不要提交到 git,仓库里只放一份 .env.example,里面填占位符。密钥只存在于服务器本机,这是底线。
3.3 依赖启动顺序与健康检查的正确姿势
刚开始用 Compose 的人几乎都会栽在同一个问题上:depends_on只保证"容器启动了",不保证"容器里的服务能用了"。Postgres 容器起来不代表它已经完成初始化、可以接受连接。如果你在 agent-core 里一启动就连数据库,大概率会撞上连接被拒。
解决方法是给每个依赖容器配上 healthcheck,然后用condition: service_healthy让上游等待就绪。Redis 的就绪判断最简单:redis-cli ping返回 PONG 就算合格。Postgres 可以用pg_isready,向量库一般也有类似的原生探活命令。
另一个思路是应用代码里做启动重试:连接失败后休眠两秒再试,最多重试十次。两条腿走路最稳,编排层等就绪,应用层忍短暂抖动。我见过只靠 depends_on、中途把 Postgres 重启一下就雪崩的案例,有了健康检查之后,这类问题基本绝迹。
4. "一键"背后的脚本设计:构建、启动、更新、回滚
4.1 用 Makefile 把散落的命令收口
"一键打包"这件事,真正的主角不是 compose 本身,而是你给团队提供的操作入口。我们团队约定:日常绝不手敲docker compose长命令,所有操作走 Makefile。原因很简单,命令越少越好记,出错的概率就低。
.PHONY: build up down logs ps update rollback backup build: docker compose build up: docker compose up -d down: docker compose down logs: docker compose logs -f --tail=200 ps: docker compose ps update: build up rollback: cd /opt/agent-stack && IMAGE_TAG=$$PREV_TAG docker compose up -d backup: ./scripts/backup_db.sh这套入口文件我们用了很久,最大的价值是让同事形成肌肉记忆:新环境部署只需要make build && make up,看日志make logs,停服务make down。这比让人去记住"先 export 环境变量、再 cd 到目录、再 compose build --pull"之类一串操作靠谱得多。
4.2 更新与回滚:把风险控在一条命令内
小团队迭代 Agent 的频率很高,几乎每周都要更新技能逻辑。我们的更新流程是:make update重新构建镜像并滚动拉起,老镜像的 tag 保留在服务器上。一旦新版本出问题,回滚就是把环境变量IMAGE_TAG指回上一个 tag,再执行docker compose up -d --force-recreate agent-core。
这里要特别提醒一点:凡是 Agent 核心的服务更新,先看一眼这次变更有没有动数据库结构。如果动了表结构,回滚代码容易,回滚数据难。我们的土办法是先手动备份数据库,再执行迁移脚本,迁移脚本跑完保存一份迁移日志。虽然丑,但足够可靠。
4.3 密钥与配置的安全基线
多技能 Agent 的敏感信息比一般 Web 服务更多:上游大模型的 API 密钥、内部业务接口的访问凭证、向量库的连接口令、数据库密码。我们的做法很朴素但有效:所有密钥集中在 .env 里,由服务器文件权限保护;模型网关是唯一持有大模型密钥的容器,其它服务就算被攻破,也拿不到上游凭证。
有人问过为什么不用 Docker 官方的 secrets 机制。我的回答是:机制再好,团队没人养过它,出了问题反而难排查。小团队的安全要按最坏情况设计,但实现必须按最简路径来。文件权限 + 集中管理 + 不提交到 git,这三点做到位,已经有九十分。
5. 小团队最关心的运维问题:资源、日志、备份
5.1 资源限制与 Docker Stats 的真实用法
服务器上跑着多少个容器,每个容器吃了多少内存,不要等出了问题才去看。日常观察用一条docker stats --no-stream就够,看两遍:一次在业务高峰期,一次在空闲期,记下数值,你才能给每个容器设置合理的 limits。
我们的分配经验是这样:编排核心平时吃 300M 左右内存,高峰期能到 700M,所以给它 1G 限制;worker 处理大文件时峰值明显,给 2G;向量库要看索引大小,我们给 1.5G。限制不是越小越好,设置到实测峰值的 1.3 到 1.5 倍比较合理,既留缓冲,又不至于让单个容器独占机器。
如果你们打算在服务器上跑本地开源模型,别忘了给对应容器加上 GPU 配置,比如--gpus all或使用 nvidia 相关 runtime。我们的理解是:本地模型省的是 API 费用,但费的是运维心力,模型显存和容器资源限制之间的平衡,值得专门写一篇文章展开,这里先提醒一句:先把 CPU 推理跑通,再考虑 GPU 加速。
5.2 日志轮转与问题排查路径
容器日志默认是 json-file 驱动,如果不管它,日志文件会一路膨胀,直到把磁盘塞满。我们会在 compose 里对每个服务显式配置日志滚动:
logging: driver: json-file options: max-size: "20m" max-file: "5"每个服务最多保留 100M 日志,足够回溯问题,又不会撑爆磁盘。排查问题时我的习惯路径是:make ps看容器状态,make logs看最近日志,如果日志太多就先docker compose logs --tail=500 agent-core缩小范围。真正有经验的排查者会先看健康检查有没有挂,再看依赖服务能不能通,最后才翻业务代码。顺序反了,往往会被海量日志淹没。
日志里还有一个容易被忽略的坑是时区。容器默认使用 UTC,你凌晨三点排查问题,日志时间戳却显示前一天晚上,人很难受。所以我们在 compose 里给所有服务统一设置了TZ=Asia/Shanghai(按你团队所在时区调整),这个环境变量很便宜,但让日志可读性提升一个量级。
5.3 数据备份与恢复演练
Agent 服务最有价值的数据是什么?不是代码,是知识库切片、会话记录、技能执行历史。这些数据都存在 PostgreSQL 和向量库里,必须定期备份。
备份脚本我们写得非常简单,挂在服务器 crontab 里每天凌晨执行:
#!/bin/bash STAMP=$(date +%F) docker compose exec -T postgres pg_dump -U appuser appdb | gzip > /backup/appdb-$STAMP.sql.gz find /backup -name "*.sql.gz" -mtime +14 -delete向量库的数据要么导出文件,要么直接备份整个命名卷。卷备份的土办法是用一个临时容器把卷内容打包:
docker run --rm -v vector-db-data:/data -v /backup:/backup alpine tar czf /backup/vector-$STAMP.tar.gz -C /data .但备份文件躺在同一台服务器上并不安全——服务器磁盘坏了,备份和业务数据一起没。所以每个月一次,我们把备份目录 rsync 到另一台低配机器上。最后提醒一句:备份脚本写完一定要做一次恢复演练。没演练过的备份,等于没有备份。这个道理我们是用一次真实事故换来的,浪费了不少时间才把旧数据找回来。
6. 这套方案里我踩过的五个坑
6.1 depends_on 不等于服务就绪
第一次上线时,agent-core 启动后立刻连 Redis,报错Connection refused。当时我以为 Redis 没起来,看了半天docker compose ps,发现它明明在运行。后来才明白,depends_on只保证容器创建并启动了,Redis 进程可能还在初始化。加了 healthcheck 和condition: service_healthy之后,这个问题再没出现过。类似的坑在 Postgres 初始化数据目录时更容易触发,因为初始化耗时更长。
6.2 容器里的 localhost 陷阱
写技能代码时,如果某个技能要访问向量库,最自然的写法可能是http://localhost:6333。但在容器世界,localhost 指的是当前容器自己,不是宿主机,更不是向量库容器。正确写法是用 compose 里的服务名,比如http://vector-db:6333。这个坑在多人协作时特别隐蔽,因为写代码的人本机跑得好好的,一进容器就全连不上。排查手段很直接:docker compose exec agent-core curl http://vector-db:6333/healthz,通不通一下就知道了。
6.3 卷目录权限与 UID 错位
有次向量库容器启动后直接退出,日志里写着 permission denied。原因是宿主机上的挂载目录由 root 创建,而容器内进程以普通用户运行,对目录没有写权限。这类问题没有统一答案,每个镜像默认的用户都不一样。我的建议是:先看镜像官方文档确认它以什么用户运行,再把挂载目录的权限调成对应 UID,或者直接用命名卷而不是宿主机目录。命名卷由 Docker 管理权限,能绕开大部分此类问题。
6.4 时区导致日志时间线对不上
排查一次任务超时问题时,我发现 worker 日志里的执行时间比实际晚了八个小时,和编排核心的日志完全对不上,花了很长时间才把前后因果理清。根因就是容器默认 UTC 时区。从那以后,我把TZ环境变量写进了所有服务的 environment 里,作为镜像模板的一部分,避免每次新建服务都重新踩一遍。
6.5 密钥写进镜像层的教训
有段时间我们图省事,在 Dockerfile 里用ARG传数据库密码,后来镜像被推到内部仓库后,有人跑docker history直接看到了密码明文。这是最典型的低级错误:构建参数会残留在镜像历史层里,哪怕后续层删掉了它,历史里依然有。正确做法是运行时通过.env注入环境变量,绝不把任何密钥写进构建参数。如果你想更严格,还可以用 Docker 的 secret mount 功能,构建期临时挂载密钥,用完即焚。
这套方案从落地到现在,帮我们把一个原先只在个人电脑里跑得动的多技能 Agent 变成了整个团队都能维护、业务方能放心使用的正式服务。如果说有什么最值得分享的经验,那就是:小团队做 AI 基础设施,永远别追"大而全"。Docker Compose 给了我们恰到好处的复杂度——比裸跑进程多了隔离和可复现,又不像集群方案那样吞掉所有开发精力。配置一次,剩下的时间都用来迭代 Agent 的实际能力,这才是对的方向。