ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Docker Compose 一键打包多技能 AI Agent 的实践与踩坑

Docker Compose 一键打包多技能 AI Agent 的实践与踩坑

最近帮一个内部小团队把散落一地的 AI 脚本收编成一套容器化服务,感觉这件事比写业务代码本身还值。团队里原本有做问答的、做知识库检索的、做文档摘要的,各自跑在个人笔记本上,Python 版本不一样、依赖互相冲突、模型 API 密钥散落在每个人的环境变量里,换个人接手基本等于重写。后来我们用 Docker Compose 把这套多技能 Agent 统一打包成一条命令就能启动的服务,过程里踩了不少坑,也沉淀了一些方法论。这篇就围绕“一键打包多技能 Agent”展开,讲讲为什么这么设计、怎么落地、以及常见问题怎么排查。

适用对象是那些有十到几十人、不想养专职运维、又希望把 AI 能力做成内部基础设施的小团队。只要有一台 Linux 服务器,或者哪怕是开发同学的本地机器,都可以用这套思路快速搭起来。下面直接进入正题。

1. 先聊清楚:小团队搭 AI 服务,为什么非要用 Compose

1.1 团队里那堆“半成品 Agent”是怎么卡住的

小团队做 AI 功能,最常见的路径是:某个同学先在 notebook 或脚本里验证效果,跑通了就扔到业务里用。一开始没问题,但积累到三五个 Agent 之后,问题就来了。

首先是环境隔离问题。A 同学用的 Python 3.9 和某个旧版依赖,B 同学升级成了 3.11,结果 C 同学一跑就报错。更麻烦的是,有的技能依赖本地二进制工具,比如文档解析要调 libmagic,语音识别要装 ffmpeg,这些系统级依赖在个人电脑上装一遍容易,换台机器就是灾难。

其次是配置分散。模型 API 的密钥、接口地址、超时参数、并发限制,写在哪里的都有。有的人放环境变量,有的人写在配置文件里,还有的人干脆硬编码在代码里。一旦模型服务调整了版本或者换了接入方,所有脚本都要手动改一遍,改漏一个就是线上事故。

最后是启动方式混乱。有人用python app.py跑,有人用 systemd 拉起来,还有人依赖 IDE 里的运行按钮。整个服务没有一个统一的入口,想整体重启都找不到一个地方操作。

这些问题的本质是:业务逻辑没问题,但交付形态太原始。我们需要把每个 Agent 都变成一个可独立构建、可独立部署、可统一管理的“服务单元”,而不是一段只能活在别人电脑上的脚本。容器化是最现实的解法,而在容器编排工具里,Docker Compose 又是小团队门槛最低的一个。

1.2 为什么选 Docker Compose 而不是 K8s 或直接跑源码

有人可能会问,既然要编排,为什么不直接用 K8s?我的答案很直接:杀鸡不用牛刀。K8s 能解决大规模调度问题,但小团队往往只有一两台服务器,也没有专门的平台工程师。为了跑四个容器引入一堆 Operator、Ingress、Service Mesh,维护成本比 AI 服务本身还高。

Docker Compose 的价值在于它卡在了一个非常舒服的位置:用 YAML 声明服务、网络、卷、资源限制,一条docker compose up -d就能把整套服务拉起来。它不需要额外安装 Kubernetes 组件,不需要理解 Pod 和 Deployment 的区别,只要会 Docker 就能上手。对于内部工具类服务,这种轻量编排已经覆盖了 90% 的需求。

直接跑源码之所以不行,刚才已经说了——环境、配置、依赖全是坑。而 Compose 把“如何跑起来”这件事固化成了文件,交给机器执行。同样的代码和配置,在谁的机器上跑结果都一样。这套“声明式”的思路,才是真正解决团队协作问题的关键。

1.3 多技能 Agent 的架构思路:拆成“网关 + 技能 + 模型适配”

一开始我们想做一个“超级 Agent”,把所有技能揉在一个服务里。结果代码越来越重,改一个功能要重新构建整个镜像,部署一次等五分钟。后来才意识到,多技能 Agent 的正确做法不是“一个大进程”,而是“一组小服务”。

我的拆分方法可以概括成三层:

第一层是统一接入网关。所有对外的请求都先进网关,由它做路由、鉴权、限流、日志。团队成员只需要知道一个地址,不需要关心背后有几个技能服务。

第二层是技能服务层。每个技能都是一个独立容器,比如skill-qa负责问答,skill-rag负责知识库检索,skill-summary负责摘要。它们只处理一种类型的任务,彼此之间不直接调用,有问题也互不影响。

第三层是模型适配层。这一层是所有技能服务共同依赖的公共组件,统一封装对模型服务 API 的请求。技能服务不直接拼接模型请求体,不管理密钥,也不处理重试和超时,这些脏话全部下沉到适配层。

这么做的好处是:新增技能时只需要写一个独立模块,接上网关暴露路由就行,不需要改动其他技能。而且模型适配层升级一次,所有技能都受益。

如果你现在只有一个 Agent,可能觉得这样拆很“重”。但相信我,当技能数量超过三个时,这种边界清晰的拆法会极大降低维护成本。后面所有的实操内容,都基于这个架构。

2. 动手前,先设计技能和模块边界

2.1 技能清单:先别贪多,挑三个核心技能入手

很多人在设计多技能 Agent 时会犯一个毛病:想一口气把所有能力都塞进去,对话、搜索、绘图、语音、OCR、批量处理……全都要。结果就是项目迟迟推不下去。

我的建议是,MVP 阶段只挑三个核心技能,跑通之后再慢慢加。以我们团队为例,最初只做了三个:

第一个是智能问答。输入一个问题,模型生成回答。这是最基础也最能体现价值的技能,很多内部工具都基于它。

第二个是知识库检索。把团队的内部文档、产品手册、历史工单切块后做向量化,用户提问时先检索引文,再把上下文交给模型生成答案。这个技能解决“搜不到”的问题,价值非常直接。

第三个是文档批处理。给一个目录或一批文档,自动做摘要、提取结构化字段。这个技能不需要实时候答,可以走异步任务队列,所以它的交互方式跟前面两个都不一样。

这三个技能覆盖了同步问答、检索增强、异步批处理三种典型模式。把模式跑通,后面加新技能就只是复制粘贴再加改业务逻辑了。

2.2 每个容器只干一件事:进程边界、数据边界、配置边界

拆分技能服务时,我给自己定了三个边界原则。

第一个是进程边界。一个容器里只跑一个主进程。不要在同一个容器里又跑 Web 服务又跑定时任务。如果既有同步接口又有后台任务,就拆成两个容器,或至少用同一个镜像跑不同的启动命令。

第二个是数据边界。每个服务的数据库、向量索引、文件目录尽量独立。知识库检索用的向量库,不要和问答技能的历史记录放在同一个库。否则数据互相干扰,备份和迁移也不好处理。

第三个是配置边界。每个服务需要哪些环境变量、连接哪些上游,都写在 Compose 文件的对应服务下。全局配置放在.env文件里,敏感信息用环境变量注入,不要写死在镜像里。这样每个服务只看到自己需要的参数,不会因为某个服务增加配置导致其他服务也要重启。

2.3 模型适配层:别把模型 Provider 写死在代码里

模型适配层是全系统的关键,也是最容易被忽视的部分。没有它,每个技能服务都要自己写调用模型 API 的代码。一旦模型提供方换了接口格式,或者团队想从模型 A 切到模型 B,就得把所有技能服务改一遍。

我们在model-adapter里抽象了一个统一接口。以 Python 为例,这个接口至少包含两个方法,一个用于生成对话回复,一个用于生成向量嵌入。技能服务调用时只需要传消息列表,不需要关心背后是哪个模型服务。适配层的伪代码如下:

# model_adapter/client.py class ModelClient: def chat(self, messages: list[dict], temperature: float = 0.7): raise NotImplementedError def embed(self, texts: list[str]) -> list[list[float]]: raise NotImplementedError

然后针对不同的模型服务实现具体子类。配置里用MODEL_PROVIDER环境变量控制当前默认使用哪个实现。这样切换模型时就改一个变量,技能服务完全无感知。

注意:适配层一定要集中处理超时和重试。我们曾经因为没加超时控制,某个技能服务在模型服务响应变慢时,把所有工作线程全部占满,整个服务都假死。后来在适配层统一配置了超时和重试策略,这个问题才彻底解决。

3. Docker Compose 实操:从零到一键启动

3.1 准备目录与文件

先给你看一下我们最终的项目结构。它并不复杂,但每个目录都有明确职责。

ai-agent-platform/ ├── docker-compose.yml ├── .env.example ├── gateways/ │ └── api-gateway/ │ ├── Dockerfile │ └── app.py ├── skills/ │ ├── skill-qa/ │ │ ├── Dockerfile │ │ └── app.py │ ├── skill-rag/ │ │ ├── Dockerfile │ │ └── app.py │ └── skill-summary/ │ ├── Dockerfile │ └── worker.py ├── adapters/ │ └── model-adapter/ │ ├── Dockerfile │ └── client.py └── ui/ └── web-ui/ ├── Dockerfile └── static/

gateways/api-gateway是统一入口,skills下面放各个技能服务,adapters/model-adapter是模型适配服务,ui/web-ui是一个简单的前端页面,方便团队成员通过浏览器访问。目录设计原则很简单:按团队分工分目录,每个人都只修改自己负责的部分。

3.2 一份可以直接改的 docker-compose.yml 示例

下面是一份能用的docker-compose.yml,在正式项目里它够“落地”了,不是概念演示。我已经把注释写进关键位置,方便你照着调整。

version: "3.8" services: api-gateway: build: ./gateways/api-gateway ports: - "8080:8080" environment: GATEWAY_ROUTES: "/qa, /rag, /summary" networks: - ai-net depends_on: - skill-qa - skill-rag - skill-summary restart: unless-stopped logging: driver: json-file options: max-size: "10m" max-file: "3" skill-qa: build: ./skills/skill-qa environment: MODEL_ADAPTER_URL: "http://model-adapter:8000" QA_MODEL: ${QA_MODEL} networks: - ai-net restart: unless-stopped deploy: resources: limits: memory: 512M skill-rag: build: ./skills/skill-rag environment: MODEL_ADAPTER_URL: "http://model-adapter:8000" VECTOR_DB_URL: "http://vector-db:6333" ENABLE_RAG: "true" networks: - ai-net restart: unless-stopped depends_on: vector-db: condition: service_healthy volumes: - rag_data:/app/data deploy: resources: limits: memory: 768M skill-summary: build: ./skills/skill-summary environment: MODEL_ADAPTER_URL: "http://model-adapter:8000" TASK_QUEUE_URL: "redis://redis:6379/0" networks: - ai-net restart: unless-stopped model-adapter: build: ./adapters/model-adapter environment: MODEL_PROVIDER: ${MODEL_PROVIDER} MODEL_API_KEY: ${MODEL_API_KEY} MODEL_API_BASE: ${MODEL_API_BASE} DEFAULT_TIMEOUT: "30" MAX_RETRIES: "2" networks: - ai-net restart: unless-stopped vector-db: image: qdrant/qdrant:v1.9.0 ports: - "6333:6333" volumes: - vector_db_data:/qdrant/storage networks: - ai-net healthcheck: test: ["CMD", "curl", "-f", "http://localhost:6333/"] interval: 10s timeout: 5s retries: 5 redis: image: redis:7-alpine networks: - ai-net volumes: - redis_data:/data web-ui: build: ./ui/web-ui ports: - "3000:80" networks: - ai-net environment: API_GATEWAY_URL: "http://api-gateway:8080" depends_on: - api-gateway volumes: rag_data: vector_db_data: redis_data: networks: ai-net: driver: bridge

这里有几个值得注意的设计点:

  • 所有服务都放在同一个自定义网络ai-net里,它们之间可以用服务名互相访问,不需要走宿主机端口。这比直接连宿主机 IP 更稳定,也天然隔离了外部访问。
  • 只有需要对外暴露的服务才映射端口:api-gateway暴露 8080,vector-db的 6333 端口只是为了方便你用客户端调试,实际使用可以让它只暴露在内网。
  • depends_on配合condition: service_healthy,确保skill-rag在向量数据库健康之后才启动,避免启动时报连接错误。这里我用的是 Compose 支持的数组写法,旧版可能需要用depends_on: condition的写法,注意版本兼容。
  • 日志通过 json-file driver 限制了单文件大小,避免容器在长时间运行后把磁盘打满。这是很多人容易忽略的点。

3.3 各服务的 Dockerfile 与启动脚本怎么写

每个服务都应该是独立的镜像,但 Dockerfile 的写法很相似。以skill-qa为例,它的 Dockerfile 长这样:

FROM python:3.11-slim # 先安装系统依赖,再装 Python 依赖,充分利用 Docker 层缓存 RUN apt-get update && \ apt-get install -y --no-install-recommends curl && \ rm -rf /var/lib/apt/lists/* WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app.py . # 使用非 root 用户运行,降低安全风险 RUN useradd --create-home --shell /usr/sbin/nologin appuser USER appuser EXPOSE 8000 CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]

这里有个小技巧:先复制requirements.txt再安装依赖,最后才复制应用代码。因为 Python 依赖相对固定,代码会频繁变动,这样写可以充分利用 Docker 层缓存——代码改动时不会重新安装依赖,构建速度快很多。

skill-summary这种异步任务服务的启动命令不太一样,它不启动 Web 服务,而是启动一个 worker 进程。在 Compose 里可以通过覆盖 command 来实现,但更清晰的做法是在 Dockerfile 里默认 CMD 写成 worker 启动命令,然后 Compose 里什么也不用改。

# skill-summary worker 示例启动命令 python worker.py --queue redis://redis:6379/0 --concurrency 4

多个技能服务的镜像最好都打上版本标签。比如skill-qa:1.2.0。版本号跟着技能代码变动走,不跟着 AI 模型变动走。这样在回滚场景下,你可以单独回滚某个技能,而不是把整个平台都回滚。

3.4 一键打包与分享给队友

所有东西准备好之后,“一键打包”其实包含两步。

本地先构建:

docker compose build

然后可以直接在开发机上启动:

docker compose up -d

如果要分享给其他团队成员,最方便的方式是把整包代码连同.env.example发给对方,对方拷贝后执行:

cp .env.example .env # 编辑 .env,填入自己团队的模型 API 密钥 docker compose up -d --build

如果成员不在同一个网络,或者服务器需要通过镜像分发,可以执行docker compose build后用docker save打包,也可以把构建好的镜像推送到私有仓库。小团队规模不大时,我建议用私有仓库,简单直接:

docker tag ai-agent-platform-skill-qa:latest registry.example.internal/skill-qa:1.2.0 docker push registry.example.internal/skill-qa:1.2.0

这里要强调一下,.env文件绝不能提交到代码仓库。每次分享都给一个.env.example,让每个人自己填自己的密钥。我们一开始有人图省事直接提交了.env,结果半天之内密钥就被扫走了,这属于付出过真金白银的教训。

4. 踩坑记录:常见问题与排查方法

4.1 容器网络通不了怎么办

第一个常见问题是服务之间互相访问不了。症状是在skill-rag容器里访问http://vector-db:6333提示连接失败。

排查步骤别乱,按顺序来。先确认两个服务是不是在同一个 Compose 网络里,可以执行:

docker network inspect ai-agent-platform_ai-net

如果当前服务没出现在网络里,多半是因为docker-compose.yml里某个服务忘了写networks字段。另一个常见原因是直接把宿主机地址写进了配置,比如用localhost或127.0.0.1访问数据库,这在容器里指向的是容器自身,而不是宿主机。正确做法是用host.docker.internal或者在 Compose 里使用服务名。

4.2 模型 API 请求超时与限流

模型服务不是自家机房里的东西,外部接口的延迟和限流是最不可控的因素。我们第一次上线时,技能服务直接同步调用模型 API,结果外部服务一次进了很多任务,接口响应从几百毫秒变成几十秒,所有 Web 工作线程都被堵住,用户点一下要等一分钟。

后来我们做了两件事。一是在模型适配层统一设置超时和重试,超时设为 30 秒,最多重试 2 次,且重试使用指数退避。二是把异步批处理技能的任务丢进 Redis 队列,worker 按固定并发数消费,从源头限制对模型 API 的并发请求。

# 在 model-adapter 的环境变量中统一配置 MODEL_ADAPTER_TIMEOUT: "30" MODEL_ADAPTER_MAX_RETRIES: "2"

如果你用的是更现代的模型服务,有的还提供流式接口,建议在网关层就把流式响应能力暴露出来。否则用户那边体验会很差——一直转圈,直到整个响应完成,像在用“老式传真”。

4.3 向量数据库持久化与备份

知识库检索服务依赖向量数据库,如果向量数据库的数据丢了,整个 RAG 服务基本等于废了。容器重启没问题,但容器一旦被删除,默认的存储空间也没了。

我们在 Compose 文件里挂了持久化卷,这个前面已经写了。但卷不是备份。卷只是把数据放在宿主机某个目录里,宿主机磁盘坏了照样丢。我们团队目前的做法是用一个定时任务把向量库的集合导出到对象存储,每周一份。另外,每次往知识库里灌新文档时,也先导出一份增量快照,避免污染整体索引。

4.4 资源限制与 GPU 支持

小团队的机器资源往往很紧张,最怕某个技能因为内存泄漏把整台服务器拖死。所以每个服务都尽量配上deploy.resources.limits。如果机器内存是 16G,就给每个服务设置合理上限。这样即使某个服务出问题,也只是它自己 OOM 被重启,不会殃及池鱼。

如果你的模型需要 GPU 推理,Compose 从较新版本开始支持deploy.resources.reservations.devices的写法。例如:

deploy: resources: reservations: devices: - driver: nvidia count: all capabilities: [gpu]

不过要注意,这句话在 Docker Swarm 和标准 Compose 下的行为不完全一样。本地跑的时候可以直接加--gpus all参数,但 Compose 文件里还是优先用上面这种声明式写法,并且只能在安装了 NVIDIA 容器工具包的机器上生效。

4.5 配置热更新与版本管理

Compose 本身不提供热更新。你改了某个技能的环境变量或代码,必须重新构建和启动。但可以把“重新启动”的影响降到最小:只重启变更的服务,而不是全部重启。

docker compose up -d --no-deps skill-qa

--no-deps这个参数很关键,它告诉 Compose 不要重建依赖链,比如不要因为model-adapter没变就重启它。这样单独技能发布时,其他服务全程无感知。

版本管理上,除了镜像 tag 要每次递增外,我强烈建议在.env里维护一个VERSION变量,然后在 Compose 中用它来指定镜像 tag。比如:

image: registry.example.internal/skill-qa:${VERSION}

发版时只改.env里的版本号,然后docker compose pull和up -d,非常清晰。

4.6 日志收集技巧

小团队不一定有专门的日志系统,但至少不能让日志把磁盘占满。前面在 Compose 里已经写了日志驱动,这里再补充两个实用命令。

查看当前所有服务的日志:

docker compose logs -f --tail=200

按服务查看:

docker compose logs -f skill-rag

如果发现某个容器日志特别多,可以用docker inspect查看它的日志路径和大小。我们曾遇到一个 bug 导致某个技能疯狂打印无效响应,一天产生了 20G 日志,直接把服务器磁盘占满。加上了日志驱动限制之后,这个问题就再没出现过。

还有一个经验:尽可能在代码里给每个请求加一个request_id,日志里输出这个 ID。排查问题时能沿着 ID 串起网关、技能、模型适配层的完整链路,不然在多个容器之间调日志会让你怀疑人生。

最后再分享一个小细节。我们在整个 Compose 服务里特意用了一个很弱的内部网关,不让它承载太多业务逻辑,只做路由和鉴权。很多小团队一开始会想着在网关上堆一堆东西,结果网关变成新的单体。我自己踩过这个坑,后来把网关里的逻辑尽量往技能服务里推,网关保持单纯,整个系统的可维护性立刻上来了。多技能 Agent 的核心不是“多”,而是每个技能都小而独立,最后拼成一个整体。这套 Compose 方案我们已经稳定跑了三个多月,团队里现在没人再说“在我电脑上能跑”,因为大家共享的是同一套容器环境,这句话已经彻底失去意义了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进