一次 agent 请求要经过 12 个服务,它们分别在替你做什么

上一篇文章我把这个栈砍到了 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 没写在命令里,但 apidepends_onminio-init: service_completed_successfully,所以它会被拉起来跑一次(建桶 + mc anonymous set none)然后退出。实际启动的是 13 个
  • scheduler 不在命令里,也没有任何服务 depends_on 它------它在 quickstart 拓扑里根本不会启动。这一条后面单开一节讲。

这个数字差本身不重要,重要的是它说明了一件事:「compose 里有几个服务」和「你实际跑起来几个」是两个数,讨论「这个栈重不重」时先把这两个分开,不然争的不是同一件事。

二、run 开始之前:两个跑完就退出的容器

migratebootstrap 是一次性任务,restart: "no",跑完就是 Exited (0)

  • migratesh scripts/migrate.sh,等 postgres healthy 之后建表。
  • bootstrappython 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

三件事值得注意:

  1. TTL 是硬编码的 300 秒 ,不是配置项。也就是说改权限之后最坏情况 5 分钟才全局生效------除非显式调 invalidate(代码里有,走 scan_iter + delete 按模式清)。
  2. Redis 拿不到就直接降级_get_redis() 里如果 settings.redis_url 为空或者含 "None",直接返回 None,上层 get_cached_permission 拿到 None 就当没缓存,回落到数据库查。没有 Redis 不会 500,只会慢。
  3. 同一个 Redis 还兼着限流器(server/app/kernel/ports/common/rate_limiter.py),实现是一段 Lua:ZREMRANGEBYSCORE 清窗口外的、ZCARD 数当前的、没超就 ZADDEXPIRE。滑动窗口计数,一次 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_noserver/app/kernel/runtime/db/models/runs.py)。前者是父子关系,后两个是重试与重放谱系:这次 run 是从哪一次 run 派生出来的、是第几次尝试。"replayable" 这个词能落地,靠的就是这三列------重放不是把日志再读一遍,是新建一次 run 并把它指回源头。
  • sandbox。标记这次 run 是彩排还是真活。模型注释里写得很直白:发布前回归会真的跑 agent,不标记的话这些成本和证据会混进真实活动里把数据撑大。
  • input_summary / output_summary 限 8KBmetrics_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_keysha256size_bytesmime ------内容本身不在库里,在 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: 9201start_http_server),健康检查就是去拉 /metrics

十、四个 worker 容器和 api 是同一份代码

这是最容易被误解成「微服务堆料」的地方,但事实相反:migrate / bootstrap / api / outbox-dispatcher / scheduler 用的是同一个构建上下文build: context: ../server),官方镜像路径下更直白------docker/docker-compose.images.ymlmigrate / bootstrap / api / outbox-dispatcher 四个服务指的是同一个镜像 ghcr.io/soit-ai/soit/server,只有 knowledge-workerweb 是另外两个镜像。三个镜像,撑起 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 个是被演示的东西apiweb
  • 3 个是一次性任务migratebootstrapminio-init,跑完就退出。
  • 2 个是台账与产物的物理载体postgresminio,也是仅有的两个 readiness 硬门槛。
  • 2 个半是"向量检索"这一个能力milvus + etcd(etcd 是 Milvus 的依赖,不是平台的)。
  • 1 个是密钥隔离vault
  • 1 个是跨副本广播与缓存redis
  • 2 个是同一份代码拆出来的后台进程outbox-dispatcherknowledge-ingest-worker

真正在跑模型的是其中一个 。剩下的重量买的是同一样东西:跑完之后这次执行还留得下证据,而且执行过程中进程崩了不会丢一半状态。

这个代价值不值得,取决于你要拿它干什么。如果只是想试试一个 agent 能不能跑通,那这个栈对你就是过重的------上一篇教你怎么砍到 4 个。如果你要把 agent 放进一个需要事后对账的流程里,那么上面这些容器就是你迟早要自己写一遍的东西。

来试试

利益相关:我是 SOIT 的维护者。

相关推荐
QUOR18 分钟前
Zorv AI 内置浏览器技术架构与开发指南(新版)
架构·github
SL_staff19 分钟前
风控规则不该写代码:一个开发者视角的规则引擎实践拆解
java·架构·全栈
OpsEye26 分钟前
公有+私有化混合大模型架构,流量治理底层原理科普
网络·架构
Dawson Zhu1 小时前
Agent 评估方法:评估环境、验证器与统计显著性
人工智能·语言模型·架构·aigc·agi
严同学正在努力1 小时前
Oracle数据技术运维大全(下篇)
运维·数据库·ai·oracle·架构
随遇而安zx1 小时前
Spring Cloud Gateway 微服务网关 设计思想与深度解析
java·spring cloud·微服务·架构
国科安芯1 小时前
小卫星综合电子系统中功能安全与抗辐射加固的协同设计研究
嵌入式硬件·安全·架构·risc-v·抗辐射·小卫星·综合电子系统
某林2122 小时前
机器人收不住、转不动?执行器死区的原理与三层补偿设计
前端·网络·c++·架构·机器人
深念Y2 小时前
视频平台架构决策:从存储到转码的选型逻辑
架构·音视频