上一篇文章我把这个栈砍到了 4 个常驻容器,评论区里最常见的追问不是「怎么砍」,而是反过来的那个问题:
那剩下的那些,到底是干什么用的?
这个问题值得认真回答。自托管社区对「一个 quickstart 要起十几个容器」的默认判断通常是两条:要么是架构没收敛,要么是为了显得像企业级而堆料。这两条判断都不算冤枉------确实有项目是这样的。所以下面不讲理念,只做一件事:沿着一次 agent run 从进来到结束的时间线走一遍,每碰到一个服务就交代它在这一刻做了什么、以及去掉它之后具体是哪个保证会消失。
先说结论里最反直觉的一条:这 12 个容器里,真正在跑模型的只有一个进程。剩下的重量几乎全部来自同一件事------把「跑一次 agent」变成「跑完之后能查、能对账、能重放,而且进程崩了不会丢一半状态」。
先看结论
| 服务 | 在一次 run 里的位置 | 它承担的那件事 | 拿掉之后消失的保证 |
|---|---|---|---|
postgres |
全程 | 台账:run / step / tool_call / artifact / cost 五张表 | 可观测与可重放的物理基础,readiness 硬门槛 |
redis |
鉴权时、跨实例广播时 | 权限缓存(5 分钟 TTL)、限流器、跨实例事件总线 | 多副本之间事件不互通;每次鉴权都落库 |
minio |
产出大对象时 | 存 run 产物,库里只留 storage_key + sha256 |
readiness 硬门槛;产物无处可放 |
milvus |
检索那一步 | 向量库 | 知识检索报错(但不拉低 readiness) |
etcd |
不直接参与 | Milvus 自己的元数据存储,不是平台的依赖 | 跟 Milvus 一起进退 |
vault |
取密钥时 | KV v2 密钥库,凭据不落进程也不落 .env |
退回进程内实现,重启即失 |
migrate / bootstrap |
run 开始之前 | 一次性:建表、建管理员与租户 | ---(跑完就退出) |
minio-init |
同上 | 一次性:建桶并把匿名访问关掉 | ---(跑完就退出) |
api |
全程 | 唯一真正跑模型的进程 | 就是被演示的东西 |
web |
全程 | 前端 | 同上 |
outbox-dispatcher |
run 返回之后 | 把事务里落库的事件真正投递出去,带 checkpoint 幂等 | 事件永远停在 pending,下游什么都收不到 |
knowledge-ingest-worker |
与 run 无关 | 文档解析入库 | 传文档进不去知识库 |
scheduler |
与 run 无关 | 到点触发定时任务 | ⚠ quickstart 根本没起它,见第十二节 |
一、先纠正一个数字:不是 12 个,是 14 个
quickstart 那条命令写了 12 个服务名:
bash
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 里有几个服务」和「你实际跑起来几个」是两个数,讨论「这个栈重不重」时先把这两个分开,不然争的不是同一件事。
二、run 开始之前:两个跑完就退出的容器
migrate 和 bootstrap 是一次性任务,restart: "no",跑完就是 Exited (0)。
migrate跑sh scripts/migrate.sh,等postgreshealthy 之后建表。bootstrap跑python scripts/bootstrap_admin.py,等migrate成功退出 之后建第一个管理员和租户(默认admin@example.com/changeme123/ 租户default)。
值得一提的是这两个的 depends_on 用的是 service_completed_successfully 而不是 service_healthy:「跑完并且成功」和「起来了」是不同的条件 ,用错了会得到一个「容器都在、表没建完」的栈。api 同时等这两个成功退出,才开始启动。
看 docker compose ps 的时候这两个显示 Exited,是对的,不是失败。
三、鉴权这一下:Postgres 是权威,Redis 是缓存
请求进来第一件事是鉴权。这里 Redis 出场,但它的身份是缓存,不是权威------权威永远是 Postgres。
server/app/kernel/identity/permissions.py 里的 PermissionCache:
python
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
三件事值得注意:
- TTL 是硬编码的 300 秒 ,不是配置项。也就是说改权限之后最坏情况 5 分钟才全局生效------除非显式调
invalidate(代码里有,走scan_iter+delete按模式清)。 - Redis 拿不到就直接降级 :
_get_redis()里如果settings.redis_url为空或者含"None",直接返回None,上层get_cached_permission拿到None就当没缓存,回落到数据库查。没有 Redis 不会 500,只会慢。 - 同一个 Redis 还兼着限流器(
server/app/kernel/ports/common/rate_limiter.py),实现是一段 Lua:ZREMRANGEBYSCORE清窗口外的、ZCARD数当前的、没超就ZADD并EXPIRE。滑动窗口计数,一次 eval 里做完,不存在读-改-写竞态。
所以「Redis 能不能砍」这个问题的准确答案是:演示能,生产不能------但理由不是缓存,是第九节的事件总线。
四、run 被记下来:五张台账表
鉴权过了,run 开始。这是 Postgres 承担的主要工作,也是整个栈里最「重」的那部分设计。
一次执行会往五张表里写:
| 表 | 一行是什么 | 关键列 |
|---|---|---|
runs |
一次执行 | status / trace_id / request_id / parent_run_id / source_run_id / attempt_no / sandbox |
run_steps |
执行里的一步 | step_type(llm / retrieval / rerank / tool / workflow_node / agent_plan / memory_write / io)/ metrics_json |
run_step_tool_calls |
一次工具调用 | idempotency_key / request_hash / lease_owner / attempt_count |
run_artifacts |
一件产物 | storage_key / sha256 / size_bytes / mime |
run_cost_entries |
一次计量 | billed_quantity / amount / currency |
有几个列值得单独点出来,因为它们解释了「为什么不是往日志里打两行就完了」:
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 是这五张表里设计最重的一张,它有三个唯一约束:
python
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") 的索引。这个组合在说一件事:工具调用是有副作用的,所以它必须是"至多一次"的。
- 一个 step 只能对应一次工具调用(第一条);
- 同一次 run 里同一个
tool_call_id不能重复落地(第二条); - 幂等键全局唯一(第三条)------这就是上一篇 Governed MCP 文章里讲的
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 复杂,是因为"有副作用的操作要恰好执行一次"这件事在分布式下本来就贵。
六、检索那一步:Milvus 在干什么,etcd 为什么跟着来
如果这次 run 里有检索步骤,api 会去问 Milvus。适配器在 server/app/adapters/vector/milvus.py,建集合时的索引参数是写死的:
python
index_params={"index_type": "IVF_FLAT", "metric_type": metric_type, "params": {"nlist": 1024}}
metric_type 支持 cosine → COSINE 的映射,集合名会被规范化成 Milvus 能接受的格式。
etcd 这一格值得单独澄清一次,因为它是最容易被误读成「堆料」的那个:
yaml
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,向量库只是「探测并上报」:
python
try:
await vector.check_ready()
vector_status = "connected"
except Exception:
vector_status = "unavailable"
函数 docstring 把理由写清楚了:向量库挂了,非向量端点照常服务,所以把实例从轮转里摘掉是过度反应。
七、大对象往哪写:MinIO
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 这一格砍不掉。
八、密钥从哪来:Vault
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 模式:
yaml
vault:
command: ["server", "-dev"]
environment:
VAULT_DEV_ROOT_TOKEN_ID: ${VAULT_DEV_ROOT_TOKEN_ID:-soit-vault-root}
dev 模式内存存储、重启即失、root token 是个固定字符串。这是演示配置,不是部署建议------它在这儿是为了让「密钥走外部密钥库」这条路径在演示里是通的,而不是为了假装生产就绪。
九、请求返回之后才开始的那部分:outbox
到这里 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。
十、四个 worker 容器和 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 后跑的东西 |
|---|---|---|
workflow_orphan_reaper_enabled |
False(compose 给 api 设成 true) |
回收孤儿工作流 |
schedule_worker_enabled |
False |
定时任务 |
account_deletion_sweeper_enabled |
False |
账号删除清扫 |
knowledge_ingest_worker_enabled |
False |
知识入库 |
outbox_dispatcher_enabled |
False(compose 显式写死 "false") |
outbox 派发 |
response_interaction_worker_enabled |
False |
会话交互 |
也就是说,「几个容器」在很大程度上是个部署决定,不是架构决定 。同一份代码,你可以把它跑成 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:
- 数据库 URL 必须带 host / 库名 / 用户名 / 密码,缺一个就抛;
- 事件总线必须 是
redis(代码默认值其实是memory,compose 里给的是redis); outbox_dispatcher_enabled为真就抛 ------ 生产环境明令禁止把派发器折进 API 进程,必须是独立进程;- 禁止 inline 执行会话交互,且必须开启持久化的交互 worker;
- 必须开启插件签名校验,并且至少配一个公钥------注释解释了为什么要单独查这一条:要求签名却没有可信公钥,等于拒绝一切插件,看起来像门禁,实际是彻底封死。
这一段是我认为最值得拿出来讲的部分:「哪些服务是可选的」在这个仓库里不是一个态度问题,是一段会让进程起不来的代码。 你可以在演示里把 Redis 砍掉、把派发器折进 API,但你没法带着这套配置声称自己在跑生产。
十二、顺带发现的一个问题:quickstart 里没有 scheduler
写这篇的时候核出来一件事,它不是设计取舍,是个漏洞:
docker-compose.yml里定义了scheduler服务,它设SCHEDULE_WORKER_ENABLED: "true"并跑scripts/schedule_worker.py;- 但 quickstart 的启动命令里没有它 ,也没有任何服务
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 补进启动命令,以及官方镜像覆盖层把它一并覆盖掉。写这篇的时候还没提,所以正文里不挂链接------想自己确认的,把上面那四条按顺序核一遍就够了。
坦白局
几件本篇没做、或者做得不彻底的事,写在这里免得读者误判:
- 本篇没有新的实跑 。上一篇的最小拓扑是端到端跑过的,这一篇是读代码 + 读 compose 得到的静态结论。所以「scheduler 不触发」这一条我给的是代码与配置层面的证据链(服务未启动 + 开关默认 False + 无文档),没有实跑一个定时任务等它不触发来确认。要证伪很简单:起 quickstart 拓扑,建一个一分钟后触发的 schedule,看它有没有跑。
- 性能与资源占用一个数都没给。这篇讲的是职责,不是开销。Milvus 镜像 2.6GB 那类数字在上一篇里。
- 没有讲怎么把它接进已有的可观测栈 。compose 里有
OTEL_ENABLED(默认false)和 OTLP 端点配置,production compose 里带 otel-collector,但这值得单独一篇。 - Redis 那一格的结论是有条件的。「演示可以砍」成立,是因为演示只有一个 api 副本;一旦多副本,进程内事件总线不跨进程,SSE 推送会只送到接到请求的那个副本上。生产强制 redis 就是为了这个。
所以「重」在哪
回到开头那个问题。把这 12 个服务按「它保证了什么」重新分组,是这样的:
- 2 个是被演示的东西 :
api、web。 - 3 个是一次性任务 :
migrate、bootstrap、minio-init,跑完就退出。 - 2 个是台账与产物的物理载体 :
postgres、minio,也是仅有的两个 readiness 硬门槛。 - 2 个半是"向量检索"这一个能力 :
milvus+etcd(etcd 是 Milvus 的依赖,不是平台的)。 - 1 个是密钥隔离 :
vault。 - 1 个是跨副本广播与缓存 :
redis。 - 2 个是同一份代码拆出来的后台进程 :
outbox-dispatcher、knowledge-ingest-worker。
真正在跑模型的是其中一个 。剩下的重量买的是同一样东西:跑完之后这次执行还留得下证据,而且执行过程中进程崩了不会丢一半状态。
这个代价值不值得,取决于你要拿它干什么。如果只是想试试一个 agent 能不能跑通,那这个栈对你就是过重的------上一篇教你怎么砍到 4 个。如果你要把 agent 放进一个需要事后对账的流程里,那么上面这些容器就是你迟早要自己写一遍的东西。
来试试
- 仓库:github.com/soit-ai/soi...
- 完整拓扑:
docker/docker-compose.yml - 最小拓扑(4 个常驻容器):见上一篇《一个 Agent 平台要起 12 个容器,做演示能砍到几个?》
- 觉得哪一格的取舍不对,欢迎直接开 issue 拍砖。
利益相关:我是 SOIT 的维护者。