《AI Agent 工程实战》1.2 架构总览:引擎 / 服务 / worker / 存储边界

本节给 ResearchCopilot 画出四层边界:卷一的 minagent 引擎层、FastAPI 服务层、异步 worker 层、Postgres 加 Redis 的存储层。我们用一张 ASCII 图和一张职责表说清每层管什么、不该管什么,用可跑的检查脚本钉死依赖方向,并完整走一遍一个提问从进来到出结果、跨四层的时序。

1.2 架构总览:引擎 / 服务 / worker / 存储边界

1.1 定下了 ResearchCopilot 要做哪三件事。现在的问题是:这些事由谁来做?一个研究助理服务,如果所有逻辑都堆在一个 main.py 里——既处理 HTTP、又跑 Agent 循环、又直接读写数据库——它在单机 demo 阶段能跑,但一旦要加并发、要重试、要横向扩容,就会立刻散架。散架的根源不是代码量,而是边界不清。

本节把 ResearchCopilot 推进到「有骨架」:画出引擎 / 服务 / worker / 存储四层边界,钉死依赖方向,说明每层的职责与禁忌,并完整描述一个请求跨层流转的时序。

1.2.1 边界先于实现

工程上有个反直觉的结论:分层不是为了好看,是为了让每一层能独立变化、独立测试。举个具体例子——如果 API 服务层直接调用模型、直接在请求线程里跑 Agent 循环,那么:

  • 一个慢请求会占住一个 web worker,并发一上来就雪崩;
  • 换模型供应商要改 HTTP 路由代码;
  • 想单独测试「鉴权逻辑」得先起一个假模型。

而如果把「跑 Agent」这件事从服务层剥离出去,交给一个独立的 worker 层,上面三个问题同时消失:web 层只负责「收请求、发任务、回结果」,worker 层负责「跑引擎」,两者可以分别扩容、分别测试。

所以本节的顺序是:先划边界,再谈实现。边界划对了,后面 17 章往里填代码就顺理成章。

1.2.2 四层与一张图

ResearchCopilot 只有四层,自下而上是:

+---------------------------------------------------------------+
|  API 服务层 (FastAPI + uvicorn)                                |
|  收请求 / 鉴权 / 幂等 / 校验 / 投任务 / SSE 回推                |
|  不跑 Agent 循环、不直接拼 SQL                                  |
+---------------------------------------------------------------+
        |  (依赖)                    ^  (投递 run_id)
        v                            |
+---------------------------+  +-------------------------------+
|  worker 层 (异步任务)      |  |  任务队列 (Redis)             |
|  取任务 / 跑引擎 / 组装报告 |->|  run 队列 / 重试 / 死信        |
|  / 写回结果 / 定时 digest  |  +-------------------------------+
+---------------------------+
        |  (依赖)                    |  (依赖)
        v                            v
+---------------------------------------------------------------+
|  引擎层  minagent (卷一产物,本卷不改)                          |
|  Message / ToolCall / ToolRegistry / run() / Event / Session   |
|  只管「循环怎么转」,不知道 HTTP、不知道 Postgres               |
+---------------------------------------------------------------+
        ^  (被 worker 调用)
+---------------------------------------------------------------+
|  存储层  Postgres (17.11) + Redis (7.4.11)                     |
|  Postgres: 会话/消息/run/记忆/报告    Redis: 缓存/队列/限流     |
+---------------------------------------------------------------+

(这张图是纯文本的,不用 Mermaid——站点的 Mermaid 默认关闭,本节刻意用 ASCII 保证任何渲染器下都能读。)

图里有个关键的不对称:服务层和 worker 层都依赖引擎层与存储层,但引擎层谁都不依赖。这条不对称是整套架构的地基,1.2.4 会专门验证它。

1.2.3 每层管什么、不管什么

边界的本质是「该做」与「不该做」成对出现。只写「该做」,边界就守不住——因为总会有人用「顺手」的名义越界。下表把禁忌也写清楚:

层该做不该做依赖
引擎层 minagent循环、工具注册、事件流、会话、检查点碰 HTTP、碰数据库、读环境变量、打日志到 stdout无(纯库)
服务层 service路由、鉴权、幂等键、参数校验、投任务、SSE跑 Agent 循环、写业务 SQL、持有长连接状态引擎、存储
worker 层 worker取任务、跑 run()、组装报告、重试、定时 digest处理 HTTP、直接对外暴露端口引擎、存储
存储层 storage持久化、缓存、队列、限流计数反向调用上层、写业务逻辑无(叶子)

三条最容易被违反的禁忌,值得单独强调:

  • 引擎层不许碰环境变量。 卷一的 minagent 是纯库,连 os.environ 都不该读——所有配置由调用方注入。这样它在 CI 里能零依赖跑(第 12 章的离线回放就靠这条)。
  • 服务层不许跑 Agent 循环。 这是本卷最重要的一条纪律。循环动辄几十秒、几百次 token,绝不能占住 web worker。服务层只投任务、拿 run_id、通过 SSE 把结果推回去。
  • 存储层不许有业务逻辑。 SQL 里不写「什么算一条有效报告」这类规则,那是 worker 的事。存储只管存取。

1.2.4 依赖方向:只准自上而下

边界划好后,最大的风险是反向依赖:某天有人为了让「引擎知道租户 ID」,让 minagent 去 import 服务层的 Settings;或者让引擎直接连 Postgres 查语料。这类改动短期让代码「更短」,长期让引擎失去「可离线测试」这个核心优势。

把依赖规则写成可跑的检查,是防止它悄悄腐化的唯一办法:

from __future__ import annotations

# 层序:数字越大越靠上。引擎与存储是底层,服务与 worker 是上层。
RANK: dict[str, int] = {"engine": 0, "storage": 0, "service": 1, "worker": 1}

# 本层允许依赖的下游层
ALLOWED: dict[str, set[str]] = {
    "engine": set(),                    # 卷一产物,不依赖任何上层,也不依赖存储
    "service": {"engine", "storage"},   # API 服务依赖引擎与存储
    "worker": {"engine", "storage"},    # worker 依赖引擎与存储
    "storage": set(),                   # 存储是叶子,不反向依赖
}


def check_direction() -> list[str]:
    """只允许「上层 -> 下层」:被依赖层的 RANK 必须严格更小。"""
    problems: list[str] = []
    for up, deps in ALLOWED.items():
        for d in deps:
            if RANK[d] >= RANK[up]:
                problems.append(f"{up} 反向依赖 {d}")
    if "storage" in ALLOWED["engine"]:
        problems.append("engine 依赖了 storage(引擎必须可离线测试)")
    return problems


print("依赖方向检查:", check_direction() or "通过")

用 venv 里的 Python 3.14.6 跑出来:

依赖方向检查: 通过

这个检查器小到可以放进 CI,但它守住的东西很关键:只要 ALLOWED["engine"] 是空集,引擎就永远能在没有数据库、没有网络的机器上跑。将来真要给引擎加「语料查询」能力,正确的做法是在 worker 层把查询封装成一个 Tool 注入进去,而不是让引擎自己连库——这正是第 10.2 节「工具设计模式」要讲的东西。

1.2.5 层间契约:三组接口

边界要落地,得靠接口。四层之间真正需要约定的接口只有三组,把它们钉死,各层就能并行开发:

接口从 → 到形状
enqueue(run_id)服务层 → 队列一个字符串
run(model, registry, input)worker → 引擎卷一的函数签名,原样复用
RunResult引擎 → workermessages / steps / final

三组接口里,只有中间那组是卷一已经定死的——run(model, registry, user_input, system="", max_steps=8) -> RunResult。本卷一个字都不改它,这正是「引擎不依赖上层」的直接结果:接口不因上层需求而变形。

另外两组是本卷新增的,也刻意做得极简。enqueue 只收 run_id,不收整个请求体,理由在 1.2.6 会展开;RunResult 直接用卷一的 dataclass,不包一层「DTO」,因为包一层只会增加转换成本,没有任何收益——worker 本来就跑在 Python 进程里,不存在跨语言序列化问题。

这三组接口合起来,定义了 ResearchCopilot 的最小耦合面:服务层只认识 run_id 与 RunResult,引擎只认识 model / registry / input,谁都不用知道对方的内部结构。第 9 章 TS 前端接入时,会在这三组之外再加一组「事件协议」接口,那是给浏览器看的,同样极简。

1.2.6 数据在层间怎么流

四层之间流动的数据,其实只有几种形态,认清它们,就知道每一层该传什么、不该传什么:

方向传的东西形态
服务 → workerrun_id(不是整个请求体)一个字符串
worker → 引擎messages + 工具 schemas卷一的 Message 列表
引擎 → workerRunResult(messages/steps/final)dataclass
worker → 存储报告、状态、记忆行 / JSON
服务 → 前端Event 流SSE 帧

最值得注意的一行是「服务 → worker 传 run_id」。服务层不把用户的提问直接塞进队列,而是先落一条 run 记录(拿到 run_id),再把 id 投进队列。这样做的好处是:任务在队列里可重试、可去重、可查询;用户即使刷新页面,也能用 run_id 查状态。第 7.1 节会把这条链路完整实现。

1.2.7 一个请求的完整时序

把上面所有规则串起来,一个 UC2(多跳研究)请求从进来到出结果,是这样走的:

 0 [service] POST /runs 收到提问,鉴权 + 幂等键,落一条 run 记录
 1 [service] 把 run_id 投进队列,返回 202 + run_id
 2 [worker ] 从队列取 run_id,加载会话历史与租户配额
 3 [worker ] 调 engine.run():模型 -> 检索工具 -> Postgres 工具 -> 再检索
 4 [engine ] 循环收敛,产出 RunResult(messages/steps/final)
 5 [worker ] 把带引用的报告写回 Postgres,更新 run 状态为 succeeded
 6 [storage] Postgres 存报告,Redis 缓存本轮检索向量
 7 [service] SSE 通道把 final 推给前端,前端渲染引用

逐步读一遍,能看清每层的分工:

  • 第 0–1 步是服务层的全部工作。它做的只有「鉴权、幂等、落记录、投队列、立刻返回 202」。注意它没有做任何 Agent 相关的事——这正是 1.2.3 那条「服务层不许跑循环」的落地。用户拿到 run_id 后,前端另开一条 SSE 连接(第 3.2 节)来接收后续事件。
  • 第 2–5 步是 worker 的主场。它取任务、装上下文、调 engine.run()、把结果写回。第 3 步那句「检索 → Postgres → 再检索」就是 UC2 的「多跳」:引擎每一轮自己决定调哪个工具,worker 只负责把工具注入进去。
  • 第 4 步是引擎唯一的出场,而且它对外界一无所知——它只知道「给我消息和工具,我给你 RunResult」。
  • 第 6 步是存储层,纯粹存取。
  • 第 7 步回到服务层,但走的是 SSE 通道,和最初那个 POST 是两条连接。

这条时序里藏着一个重要的设计后果:POST 请求和最终结果不是同一条连接。这对前端是负担(要处理两条连接),但换来了「服务层不被长任务占住」的伸缩性。第 9 章讲前端渲染、第 3.2 节讲 SSE 时,都会回到这条时序。

1.2.8 边界反例:三种越界写法

规则写在文档里,越界却往往发生在「顺手」的瞬间。下面三种写法(伪代码,只示形态,不追求可执行)都「能跑」,但都会侵蚀边界,值得当作反例记住:

# 反例 1:服务层直接跑循环——web worker 被占住
@app.post("/runs")
def create(req: RunRequest):
    result = run(build_model(), build_registry(), req.input)  # 错:同步跑循环
    return {"final": result.final}


# 反例 2:引擎直接连库——引擎失去离线可测性
# minagent/tools.py 里出现:
import psycopg
def search(q):
    with psycopg.connect(os.environ["DATABASE_URL"]) as conn:  # 错:引擎读环境变量
        ...


# 反例 3:存储层塞业务规则——SQL 里判断「什么算有效报告」
# SELECT * FROM reports WHERE length(body) > 200 AND citations > 0  -- 错:业务规则进 SQL

三条反例的共同病根是为了少写几行,把职责搬到了错误的层。反例 1 让服务层承担了本该属于 worker 的长任务;反例 2 让引擎依赖了具体存储;反例 3 让「有效报告」的定义散进了 SQL,将来改规则要改多处。

正确的写法是:反例 1 改成「投 run_id 到队列」;反例 2 改成「worker 把查询封装成 Tool 注入引擎」;反例 3 改成「worker 里用 Python 判断,SQL 只负责取数据」。判断一个改动是否越界,只要问一句:它让某一层知道了本不该知道的事吗?

1.2.9 真实环境下的边界会怎样

单机跑通后,这套四层边界会遇到三个真实压力:

  • worker 要独立扩容。白天提问多、夜里 digest 多,两类负载峰谷不同。因为 worker 是独立一层,可以按队列深度单独加副本,而不用动服务层。第 7 章的任务队列 + 第 8 章的并发控制就是为这个准备。
  • 引擎要能被并发调用。多个 worker 线程同时调 engine.run(),而卷一的引擎是无状态的纯函数(状态全在 messages 里),天然可并发——这正是「引擎不持有全局状态」这条约束的回报。第 8.1 节会真跑并发。
  • 边界要能被观测。四层各自埋点,才能在出问题时定位「是服务层慢、worker 堵、引擎空转、还是数据库拖后腿」。第 13 章的 OTel 埋点会沿着这条边界铺开。

一句话:四层边界不是画着好看的,它是「独立扩容、独立测试、独立观测」三件事的前提。

还有一层真实压力来自多租户。单机 demo 里四层服务一个用户;真实环境里同一套服务要服务多个 workspace,于是每一层的读写都必须带上租户边界——服务层做鉴权、worker 层查配额、存储层做行级隔离(第 4.2 节)。边界没划清时,租户隔离会退化成「哪里都在过滤、哪里都可能漏」;而边界清晰时,它变成「每层加一个统一的过滤条件」这么简单。这就是先划层、再逐层加隔离的价值。

最后提醒一个常见误区:「分层」不等于「分目录」。把代码放进 service/、worker/ 文件夹,不等于边界就守住了——真正的边界体现在依赖方向与数据形态上。所以本节花了大量篇幅在依赖检查(1.2.4)与接口约定(1.2.5)上,而不是只画一张目录图。

本节的自检清单:

  • 四层各自的「该做 / 不该做」都写明了
  • 依赖方向检查脚本通过,引擎层 ALLOWED 为空集
  • 服务层不跑循环、只传 run_id 这条纪律明确
  • 一个请求的八步时序能对应到具体层
  • 三组层间接口(enqueue / run / RunResult)都已约定
  • 三种越界反例都已识别,且知道正确写法
  • 多租户隔离能在每层找到统一的接入点

骨架立住了,下一步是把它变成真的仓库:建 monorepo、写最小包、让 CI 跑通 lint 与 test。想回看需求本身,翻 1.1 需求与用例 。

阅读导航:上一节:1.1 需求与用例:3 个核心用例与验收标准 · 下一节:1.3 仓库与工程骨架:monorepo + TS 包,CI 跑通 lint/test 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距