自托管社区看到一个 quickstart 要 docker compose up 起十几个容器,通常只会给两种解释:架构没收敛,或者为了显得像企业级而堆料。这两种解释都不算冤枉——确实有项目是这样的。
上一篇我做的是减法(从 12 个服务到 4 个常驻容器:一个开源 Agent 运行时的最小演示拓扑实测),把这个栈砍到 4 个常驻容器并跑通了端到端。发出来之后被追问最多的反而不是「你是怎么砍的」,而是把问题掉过头来的那一版:
没被砍掉的那些,到底各自在替我做什么?
这篇就干一件很笨的事:沿着一次 agent run 从进来到结束的时间线走一遍,每碰到一个服务就交代它在这一刻做了什么、以及把它拿掉之后具体是哪一个保证会消失。不讲理念,只对着 compose 文件和源码说话。先把最反直觉的一条放在前面:这十几个容器里,真正在跑模型的只有一个进程。 其余的重量几乎全部买的是同一件事——把「跑了一次 agent」变成「跑完之后能查、能对账、能重放,而且中途进程崩了不会丢一半状态」。
顺带纠正一个数字:compose 文件里定义的是 14 个服务,quickstart 实际起来的是 12 个。差在哪儿,第一节就讲。
服务 | 在一次 run 里的位置 | 它承担的那件事 | 拿掉之后消失的保证 |
|---|---|---|---|
| 全程 | 台账:run / step / tool_call / artifact / cost 五张表 | 可观测与可重放的物理基础,readiness 硬门槛 |
| 鉴权时、跨实例广播时 | 权限缓存(5 分钟 TTL)、限流器、跨实例事件总线 | 多副本之间事件不互通;每次鉴权都落库 |
| 产出大对象时 | 存 run 产物,库里只留 | readiness 硬门槛;产物无处可放 |
| 检索那一步 | 向量库 | 知识检索报错(但不拉低 readiness) |
| 不直接参与 | Milvus 自己的元数据存储,不是平台的依赖 | 跟 Milvus 一起进退 |
| 取密钥时 | KV v2 密钥库,凭据不落进程也不落 | 退回进程内实现,重启即失 |
| run 开始之前 | 一次性:建表、建管理员与租户 | —(跑完就退出) |
| 同上 | 一次性:建桶并把匿名访问关掉 | —(跑完就退出) |
| 全程 | 唯一真正跑模型的进程 | 就是被演示的东西 |
| 全程 | 前端 | 同上 |
| run 返回之后 | 把事务里落库的事件真正投递出去,带 checkpoint 幂等 | 事件永远停在 |
| 与 run 无关 | 文档解析入库 | 传文档进不去知识库 |
| 与 run 无关 | 到点触发定时任务 | ⚠ quickstart 根本没起它,见第十二节 |
quickstart 那条命令写了 12 个服务名:
docker compose --env-file .env -f docker/docker-compose.yml up -d \
postgres redis minio etcd milvus vault migrate bootstrap api web \
knowledge-ingest-worker outbox-dispatcher但 docker/docker-compose.yml 里定义了 14 个 services。差额是两个:
minio-init 没写在命令里,但 api 的 depends_on 带 minio-init: service_completed_successfully,所以它会被拉起来跑一次(建桶 + mc anonymous set none)然后退出。实际启动的是 13 个。scheduler 不在命令里,也没有任何服务 depends_on 它——它在 quickstart 拓扑里根本不会启动。这一条后面单开一节讲。这个数字差本身不重要,重要的是它说明了一件事:「compose 里有几个服务」和「你实际跑起来几个」是两个数,讨论「这个栈重不重」时先把这两个分开,不然争的不是同一件事。
migrate 和 bootstrap 是一次性任务,restart: "no",跑完就是 Exited (0)。
migrate 跑 sh scripts/migrate.sh,等 postgres healthy 之后建表。bootstrap 跑 python scripts/bootstrap_admin.py,等 migrate 成功退出之后建第一个管理员和租户(默认 admin@example.com / changeme123 / 租户 default)。值得一提的是这两个的 depends_on 用的是 service_completed_successfully 而不是 service_healthy:「跑完并且成功」和「起来了」是不同的条件,用错了会得到一个「容器都在、表没建完」的栈。api 同时等这两个成功退出,才开始启动。
看 docker compose ps 的时候这两个显示 Exited,是对的,不是失败。
请求进来第一件事是鉴权。这里 Redis 出场,但它的身份是缓存,不是权威——权威永远是 Postgres。
server/app/kernel/identity/permissions.py 里的 PermissionCache:
class PermissionCache:
"""Permission cache using Redis."""
def __init__(self, redis_client: redis_async.Redis | None = None):
self._redis: redis_async.Redis | None = redis_client
self._redis_pool: redis_async.ConnectionPool | None = None
self._cache_ttl = 300 # 5 minutes三件事值得注意:
invalidate(代码里有,走 scan_iter + delete 按模式清)。_get_redis() 里如果 settings.redis_url 为空或者含 "None",直接返回 None,上层 get_cached_permission 拿到 None 就当没缓存,回落到数据库查。没有 Redis 不会 500,只会慢。server/app/kernel/ports/common/rate_limiter.py),实现是一段 Lua:ZREMRANGEBYSCORE 清窗口外的、ZCARD 数当前的、没超就 ZADD 并 EXPIRE。滑动窗口计数,一次 eval 里做完,不存在读-改-写竞态。所以「Redis 能不能砍」这个问题的准确答案是:演示能,生产不能——但理由不是缓存,是第九节的事件总线。
鉴权过了,run 开始。这是 Postgres 承担的主要工作,也是整个栈里最「重」的那部分设计。
一次执行会往五张表里写:
表 | 一行是什么 | 关键列 |
|---|---|---|
| 一次执行 |
|
| 执行里的一步 |
|
| 一次工具调用 |
|
| 一件产物 |
|
| 一次计量 |
|
有几个列值得单独点出来,因为它们解释了「为什么不是往日志里打两行就完了」:
parent_run_id / source_run_id / attempt_no(server/app/kernel/runtime/db/models/runs.py)。前者是父子关系,后两个是重试与重放谱系:这次 run 是从哪一次 run 派生出来的、是第几次尝试。"replayable" 这个词能落地,靠的就是这三列——重放不是把日志再读一遍,是新建一次 run 并把它指回源头。sandbox。标记这次 run 是彩排还是真活。模型注释里写得很直白:发布前回归会真的跑 agent,不标记的话这些成本和证据会混进真实活动里把数据撑大。input_summary / output_summary 限 8KB,metrics_json 走 JSON 列。台账存的是摘要,不是全量——全量去 run_artifacts 指向的对象存储里拿。这是一条刻意的边界:关系库存可查询的结构,对象存储存大块内容。run_step_tool_calls 是这五张表里设计最重的一张,它有三个唯一约束:
UniqueConstraint("tenant_id", "workspace_id", "run_step_id", ...)
UniqueConstraint("tenant_id", "workspace_id", "run_id", "tool_call_id", ...)
UniqueConstraint("tenant_id", "workspace_id", "idempotency_key", ...)外加一个 ("status", "lease_expires_at") 的索引。这个组合在说一件事:工具调用是有副作用的,所以它必须是"至多一次"的。
tool_call_id 不能重复落地(第二条);tool:{run_id}:{tool_call_id}。lease_owner + lease_expires_at 那一对是给崩溃恢复用的:拿了租约的 worker 死了就不再续约,租约过期后这行才重新可被认领。这套语义在仓库里被抽成了一个共享模块(server/app/kernel/runtime/common/lease.py),注释写得很明确——每个在请求之外执行工作的运行时域都必须用同一套 claim / renew / 孤儿回收语义。里面有两个常量和一处实现细节值得记:MIN_LEASE_SECONDS = 30(配再小也会被抬到 30 秒)、LEASE_RENEWALS_PER_LEASE = 3(心跳间隔按租约的三分之一算)、以及 claim 用 SKIP LOCKED 避免多 worker 抢同一行(注释里坦白 SQLite 会忽略这个子句,测试单 worker 场景可接受)。
这一节是全篇的缩影:这些容器之所以存在,不是因为 AI 复杂,是因为"有副作用的操作要恰好执行一次"这件事在分布式下本来就贵。
如果这次 run 里有检索步骤,api 会去问 Milvus。适配器在 server/app/adapters/vector/milvus.py,建集合时的索引参数是写死的:
index_params={"index_type": "IVF_FLAT", "metric_type": metric_type, "params": {"nlist": 1024}}metric_type 支持 cosine → COSINE 的映射,集合名会被规范化成 Milvus 能接受的格式。
etcd 这一格值得单独澄清一次,因为它是最容易被误读成「堆料」的那个:
milvus:
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
depends_on:
etcd: { condition: service_healthy }
minio: { condition: service_healthy }etcd 不是平台的依赖,是 Milvus 自己的依赖——Milvus standalone 用 etcd 存元数据、用 MinIO 存数据文件。整个代码库里没有任何一行业务代码连 etcd。所以正确的读法是:「向量检索」这一个能力,在拓扑上表现为两个半容器(etcd + Milvus,加上与 MinIO 共用)。要不要为演示付这个代价,是一个明确的取舍,不是一笔糊涂账。
顺带一个上一篇已经验证过的事实,这里再点一次:向量库不是 readiness 的门槛。server/app/api/v1/health/router.py 里三个后端被区别对待——数据库和对象存储探测失败直接 503,向量库只是「探测并上报」:
try:
await vector.check_ready()
vector_status = "connected"
except Exception:
vector_status = "unavailable"函数 docstring 把理由写清楚了:向量库挂了,非向量端点照常服务,所以把实例从轮转里摘掉是过度反应。
run_artifacts 那张表里只有 storage_key、sha256、size_bytes、mime ——内容本身不在库里,在 MinIO。
minio-init 那个一次性容器做两件事:mc mb -p local/soit-artifacts 建桶,然后 mc anonymous set none 把匿名访问关掉。第二条是个小而正确的默认值:产物桶默认不可匿名读。
对象存储是 readiness 的硬门槛(探测失败 503)。上一篇实跑时踩到的坑正是这里:纸面上可以换成本地文件系统适配器,实测在官方镜像里跑不通(路径被 strip("/") 变成相对路径,已提 issue #43)。所以现实结论是:MinIO 这一格砍不掉。
server/app/adapters/secrets/vault.py 走的是 KV v2:读用 secrets.kv.v2.read_secret_version,写用 create_or_update_secret,删用 delete_metadata_and_all_versions。
它解决的问题是上一篇 Governed MCP 那篇的主线:凭据不进 agent 进程,也不进 .env,工具调用时按 secret_id 引用、由运行时注入。
坦白一句:quickstart 里的 Vault 是 dev 模式:
vault:
command: ["server", "-dev"]
environment:
VAULT_DEV_ROOT_TOKEN_ID: ${VAULT_DEV_ROOT_TOKEN_ID:-soit-vault-root}dev 模式内存存储、重启即失、root token 是个固定字符串。这是演示配置,不是部署建议——它在这儿是为了让「密钥走外部密钥库」这条路径在演示里是通的,而不是为了假装生产就绪。
到这里 run 已经结束、响应已经返回。但栈里还有一个容器刚开始干活。
api 在事务里把领域事件写进 event_outbox 表(server/app/kernel/runtime/db/models/events.py),和业务数据同一个事务。这就是 transactional outbox:业务提交了,事件一定在;业务回滚了,事件一定不在。不存在「库写了但消息没发出去」。
然后 outbox-dispatcher 这个独立进程轮询这张表。行上带的列几乎就是它的状态机:status / available_at / locked_at / lock_owner / lock_expires_at / attempt_count / last_error / processed_at。
server/app/kernel/events/dispatcher.py 的模块 docstring 一句话说完了流程:
claim rows, run registered handlers with checkpoint idempotency
「checkpoint idempotency」指的是另一张表 event_consumer_checkpoint,唯一约束是 (consumer_name, event_id)。派发前先问 checkpoints.is_processed(consumer_name, event_id),处理成功再 try_record_success。所以幂等的粒度是「每个消费者 × 每个事件」,不是「每个事件」——三个消费者里第二个失败了重投,第一个不会被重复执行。
这个容器的存在理由是 exactly-once 语义要落到实处。它有自己的 Prometheus 端点(expose: 9201,start_http_server),健康检查就是去拉 /metrics。
api 是同一份代码这是最容易被误解成「微服务堆料」的地方,但事实相反:migrate / bootstrap / api / outbox-dispatcher / scheduler 用的是同一个构建上下文(build: context: ../server),官方镜像路径下更直白——docker/docker-compose.images.yml 给 migrate / bootstrap / api / outbox-dispatcher 四个服务指的是同一个镜像 ghcr.io/soit-ai/soit/server,只有 knowledge-worker 和 web 是另外两个镜像。三个镜像,撑起 12 个容器。
区别只在于跑哪个入口、以及哪些后台循环被打开。server/app/main.py 的 lifespan 里有六个开关,每个都决定一个后台循环要不要折进 API 进程:
开关 | 代码默认值 | 折进 API 后跑的东西 |
|---|---|---|
|
| 回收孤儿工作流 |
|
| 定时任务 |
|
| 账号删除清扫 |
|
| 知识入库 |
|
| outbox 派发 |
|
| 会话交互 |
也就是说,「几个容器」在很大程度上是个部署决定,不是架构决定。同一份代码,你可以把它跑成 1 个进程,也可以拆成 5 个。compose 选择拆开,理由写在 scripts/schedule_worker.py 的 docstring 里,是我见过最直白的一句:
Separate from the API for the same reason the outbox dispatcher is: a scheduler that shares a process with request handling competes with it, and an API restart should not be a gap in when jobs fire.
(与 API 分开的理由和 outbox dispatcher 一样:调度器和请求处理共用进程会互相抢资源,而且 API 重启不应该变成定时任务的触发空档。)
上面那些开关看起来像「随便你」,但有一半在生产模式下不是可选项。server/app/settings/settings.py 里的 validate_runtime_requirements() 只在 ENVIRONMENT=production 时生效,然后逐条 fail closed:
redis(代码默认值其实是 memory,compose 里给的是 redis);outbox_dispatcher_enabled 为真就抛 —— 生产环境明令禁止把派发器折进 API 进程,必须是独立进程;这一段是我认为最值得拿出来讲的部分:「哪些服务是可选的」在这个仓库里不是一个态度问题,是一段会让进程起不来的代码。 你可以在演示里把 Redis 砍掉、把派发器折进 API,但你没法带着这套配置声称自己在跑生产。
写这篇的时候核出来一件事,它不是设计取舍,是个漏洞:
docker-compose.yml 里定义了 scheduler 服务,它设 SCHEDULE_WORKER_ENABLED: "true" 并跑 scripts/schedule_worker.py;depends_on 它;api 容器没有设 SCHEDULE_WORKER_ENABLED,而代码默认是 False;.env.example 里也没有这个变量;docs/ 目录下一次都没提过 scheduler(全仓库只有两个 compose 文件提到它)。合起来的后果是:照 quickstart 起的栈里,定时任务永远不会自己触发。 API 上 POST /schedules 能建、能预览下次触发时间、POST /schedules/{id}/run 能手动跑一次,但到点没有任何进程会去认领它。
docker-compose.production.yml 里是有 scheduler 的,所以这不是功能缺失,是 quickstart 拓扑的覆盖缺口。另外还有一个连带问题:docker-compose.images.yml 那个官方镜像覆盖层只覆盖了 6 个服务,里面没有 scheduler —— 也就是说照着「用官方镜像」的路径自己补一个 scheduler 上去,它会退回本地构建。
这两条我打算提一个 issue:quickstart 拓扑把 scheduler 补进启动命令,以及官方镜像覆盖层把它一并覆盖掉。写这篇的时候还没提,所以正文里不挂链接——想自己确认的,把上面那四条按顺序核一遍就够了。
几件本篇没做、或者做得不彻底的事,写在这里免得读者误判:
OTEL_ENABLED(默认 false)和 OTLP 端点配置,production compose 里带 otel-collector,但这值得单独一篇。回到开头那个问题。把这 12 个服务按「它保证了什么」重新分组,是这样的:
api、web。migrate、bootstrap、minio-init,跑完就退出。postgres、minio,也是仅有的两个 readiness 硬门槛。milvus + etcd(etcd 是 Milvus 的依赖,不是平台的)。vault。redis。outbox-dispatcher、knowledge-ingest-worker。真正在跑模型的是其中一个。剩下的重量买的是同一样东西:跑完之后这次执行还留得下证据,而且执行过程中进程崩了不会丢一半状态。
这个代价值不值得,取决于你要拿它干什么。如果只是想试试一个 agent 能不能跑通,那这个栈对你就是过重的——上一篇教你怎么砍到 4 个。如果你要把 agent 放进一个需要事后对账的流程里,那么上面这些容器就是你迟早要自己写一遍的东西。
docker/docker-compose.yml利益相关:我是 SOIT 的维护者。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。