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 | 引擎 → worker | messages / 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 数据在层间怎么流
四层之间流动的数据,其实只有几种形态,认清它们,就知道每一层该传什么、不该传什么:
| 方向 | 传的东西 | 形态 |
|---|---|---|
| 服务 → worker | run_id(不是整个请求体) | 一个字符串 |
| worker → 引擎 | messages + 工具 schemas | 卷一的 Message 列表 |
| 引擎 → worker | RunResult(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 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。