我们在给公司内部的 Agent 能力平台做架构设计时,面对的是一个相当典型的处境:业务是全新的(LLM Agent、知识检索、工具调用、推理编排),团队只有两三个人,公司基础设施现成(MySQL、Redis、Milvus、ClickHouse),但谁也不敢说三个月后这个系统会长成什么样。
这篇文章记录我们最终选定的答案:一个 COLA 四层 + Maven 多模块的模块化单体,以及围绕它做的一系列"反直觉但有效"的决策——接口即未来服务拆分点、编译期强制单向依赖、四个数据存储各司其职、配置即代码。这些决策有的来自经典架构方法论,有的来自被现实打脸后的修正,一并写下来供参考。
1. 为什么是模块化单体,而不是微服务
Agent 平台天然看起来"适合微服务":知识库、工具管理、推理引擎、画像、记忆、反馈……每个都是清晰的能力单元,画在图上就是六七个服务。
但我们的结论是:初期所有模块共处单一代码仓库、单一部署单元,模块间通过 Java 接口通信,而非网络 RPC。 理由很朴素:
- 运维负担:两三个人维护一个 JVM 进程,和维护七个服务 + 注册中心 + 链路追踪 + 分布式事务,是两种人生。
- 迭代速度:Agent 平台的接口契约每天都在变(今天给工具调用加个参数,明天给知识检索换个返回结构)。单体内改一个 interface + 实现 + 调用方,一次编译全过;微服务下这是三个仓库的协调发布。
- 网络开销:一次推理请求内部要调用记忆、知识库、工具、画像等四五个模块,RPC 序列化和网络往返在热路径上是纯浪费。
关键的折中在于:模块的接口定义即为未来拆分时的服务边界。我们不是在"放弃微服务",而是在"推迟微服务的付费时间"——接口先切好痕,哪天真的需要拆,代价是可控的(详见 §7)。
配套的硬约束只有一条,但必须作为铁律执行:
严禁模块间绕过接口直接访问对方的数据表或内部实现。
这条约束一旦破例一次,接口边界就开始腐烂,"未来可拆分"就成了一句空话。
2. COLA 四层与"编译期防火墙"
2.1 分层本身不值钱,强制执行才值钱
我们采用 COLA(Clean Object-oriented Layered Architecture)四层划分:
| 层 | 职责 | 可以依赖 | 不得依赖 |
|---|---|---|---|
| Adapter | REST Controller、SSE 通道、全局异常处理、认证过滤器 | App 层 | Domain 实体直接出入参 |
| Application | AppService,编排用例,事务边界 | Domain Gateway(SPI)、common | Infrastructure |
| Domain | 纯领域模型 + Gateway SPI 接口 | 仅 common | 一切外部世界 |
| Infrastructure | Gateway 实现、Mapper、外部集成(LLM、向量库、对象存储、MCP) | Domain SPI、外部库 | — |
分层图谁都会画。真正的问题是:Java 包结构无法阻止任何人 import 任何东西。 单 Maven 模块时代,我们的 domain 包里 import org.springframework.* 只能靠 Code Review 肉眼发现——而 CR 总会漏。
2.2 用 Maven 模块边界代替口头约定
解决方案是把分层从"包约定"升级为"模块物理边界"。拆分的时机选择也有讲究:单模块约 110 个 Java 文件时是临界点,超过 150 个文件后混乱速度会指数增长,越晚拆代价越大。
最终模块划分:
server/ ← Git 仓库根
├── pom.xml ← parent POM(packaging=pom,聚合子模块)
├── module-common/ ← 零依赖共享工具(JSON 工具等)
├── module-domain/ ← 纯领域模型 + SPI,零 Spring 依赖
├── module-infrastructure/ ← 所有 Gateway 实现 + Mapper + 外部集成
└── module-boot/ ← 启动模块(adapter + app + resources + 主类)
依赖方向单向不可逆,由编译器保证:
module-boot ──→ module-infrastructure ──→ module-domain ──→ (module-common 为共享叶子)
效果是:
| 尝试 | 结果 |
|---|---|
domain 中 import org.springframework.* |
❌ 编译失败——依赖根本不在该模块的 pom 里 |
| infrastructure 中 import boot 的类 | ❌ 编译失败——反向依赖不存在 |
| boot 中使用 domain 的 SPI | ✅ 传递依赖,正常 |
"domain 不能 import Spring"从一条 CR checklist 变成了物理不可能。这是整个改造里最划算的一笔:不需要任何 ArchUnit 之类的额外工具,Maven 自己就是架构守护器。
2.3 Domain 层的"禁带物品清单"
domain 模块的 pom 只允许出现 lombok + jackson(注解处理与序列化注解)这类不入侵运行时的东西。明确禁止:
org.springframework.*- ORM 核心包(仅允许 annotation 包用于
@TableName这类纯标记注解——这是我们对实用主义的唯一让步) - ClickHouse、Redis、Milvus 等一切存储客户端
domain 里只有两类东西:实体和 Gateway SPI。SPI 是纯接口契约,例如:
package com.example.platform.domain.knowledge;
public interface KnowledgeGateway {
List<SearchResult> search(String spaceId, String kbId, String query, int topK);
void upsert(String spaceId, String kbId, List<Document> docs);
void delete(String spaceId, String kbId, List<String> docIds);
}
接口签名里不出现任何存储类型——没有 Milvus 的 SearchResp,没有 Redis 的 ValueOperations。上层 Application 只面向这些接口编程,Infrastructure 提供实现。这是经典的依赖倒置,但在多模块的编译期约束下,它第一次变得无法被破坏。
2.4 为什么不是五个模块
我们也评估过"每层一个模块"的教科书方案,结论是过度工程化:
| 不拆 | 理由 |
|---|---|
app 层独立模块 |
只有个位数的 AppService,独立后它的 pom 依赖几乎等于 infrastructure,白加一个构建单元 |
adapter 层独立模块 |
Controller 依赖 WebFlux 与 JSON 解析,和启动类同模块更自然 |
| 按业务子域拆 domain(agent/knowledge/reasoning 各自成模块) | 每个子域只有 2~4 个文件,拆完就是一个"3 文件的 Maven 模块",徒增 IDE 索引与构建顺序成本 |
模块化的目的是强制边界,不是制造数量。 只为"会被破坏的边界"付模块化的成本。
3. 领域模块:接口即未来的服务拆分点
domain 层内部按业务能力划子域,每个子域 = 实体 + Gateway SPI:
| 子域 | 核心实体 | Gateway SPI |
|---|---|---|
workspace |
Workspace, WorkspaceMember, SpaceApiKey | WorkspaceGateway |
agent |
AgentTemplate, AgentInstance | AgentGateway |
knowledge |
KnowledgeBase, Document, DocumentChunk | KnowledgeGateway |
reasoning |
ReasoningSession, AgentMessage | ReasoningGateway, MessageBus |
tool |
ToolDefinition, ToolResult, McpServerConfig | ToolGateway |
memory |
SessionMemory, ChatMessage | MemoryGateway, ChatMessageGateway |
profile |
Profile, EndUserProfile | ProfileGateway |
skill |
SkillDefinition | SkillGateway |
media |
MediaWork | MediaGenerationGateway |
feedback |
FeedbackRecord | FeedbackGateway |
注意一个细节:外部身份系统也是一个 Gateway。我们不直连企业统一身份系统的数据库,而是在 domain 定义 IdentityGateway.verifyCredentials(user, pass),infrastructure 里用 HTTP 调用对方 API 实现——防腐层内建,身份源的表结构、字段名、加密方式变化都与我们无关。
每个 Gateway 接口都遵循同一套纪律:
- 接口签名必带
spaceId(空间隔离的强制体现,见姊妹篇《Agent 平台的多层隔离模型》); - 不泄漏实现细节(存储类型、HTTP 客户端、序列化格式);
- 粒度按"业务能力"而非"表"切分——一个 Gateway 背后可以是多张表甚至多种存储。
第三条值得展开:比如 KnowledgeGateway.search() 的实现内部是 "Milvus 向量检索 + MySQL 元数据过滤" 的组合,但调用方看到的就是一个语义检索接口。未来拆分服务时,接口背后的多存储实现整体搬走,调用方零感知。
4. 四个数据存储,各司其职
这是整个架构里最容易被质疑的部分:"一个单体应用为什么要用四种数据库?"答案:不是因为我们是单体就要假装所有数据长得一样。 Agent 平台的数据访问模式天然分四类,强行塞进一个存储才是扭曲。
| 存储 | 角色 | 存什么 | 为什么是它 |
|---|---|---|---|
| MySQL | 系统记录(system of record) | 工作空间、成员、Agent 配置、知识库元数据、工具定义、画像、会话元数据、反馈 | 强一致、事务、团队最熟悉;结构化配置数据的标准答案 |
| ClickHouse | 对话消息持久化 + OLAP 分析 | chat_message(append-only)、调用统计 |
消息量大且只追加不修改,写 MySQL 会持续膨胀直至分库分表;列存天然适配聚合分析(详见姊妹篇《用 ClickHouse 做对话消息存储》) |
| Redis | 热缓存 + 多 Agent 消息总线 | 活跃会话记忆(TTL)、Agent 配置变更的 pub/sub、send_message 收件箱 |
亚毫秒读取;可丢失、可从 MySQL 重建,恰好是缓存与信箱的语义 |
| Milvus | 向量检索 | 知识库文档 embedding | 语义搜索的唯一正确工具;用 Partition Key 按 space_id 物理分区,把空间隔离做到存储层 |
几个关键的设计点:
1. 每个存储只通过对应的 Gateway 访问。 Application 层不知道 ChatMessage 在 ClickHouse——它只调用 ChatMessageGateway.insertBatch()。存储选型因此变成一个可以在 infra 层内部推翻重来的局部决策(事实上我们就推翻过一次:消息最初只在 Redis,TTL 24 小时,重启即全丢——这是后来引入 ClickHouse 的直接原因)。
2. 冗余优先于 JOIN。 ClickHouse 的 chat_message 表冗余了 space_id、user_id、end_user_id 等本可通过会话元数据 JOIN 出来的字段。OLAP 的最佳实践是宽表冗余——宁可多存,绝不跨库 JOIN。这条原则在多存储架构里尤其重要:一旦你的查询需要跨 MySQL 和 ClickHouse JOIN,说明字段放错了地方。
3. 可丢失性分级。 Redis 里的东西全部允许丢(缓存可重建、信箱可容忍极端情况丢失);MySQL 跟随公司备份策略(每日全量 + binlog);ClickHouse 是消息的权威副本。灾备方案按这个分级各自制定,不搞一刀切。
5. Config as Code:让 AI 编程助手成为一等公民
平台里 Agent 的行为——用哪个模型、system prompt 是什么、挂载哪些知识库、激活哪些工具、绑定哪些技能、有没有子 Agent——全部以 JSON 文本文件的形式存放在仓库的 config/ 目录下:
resources/config/
├── agents/ # Agent 定义(模型、prompt、挂载能力、kbStrategy、maxSteps…)
├── tools/ # 内置工具定义
├── knowledge/ # 知识库定义
├── skills/ # 技能(命名 prompt 片段)
├── mcp/ # MCP server 接入配置
└── workspaces/ # 基线工作空间
启动时一个 ConfigBootstrap 组件扫描这些文件,幂等入库(insert if not exists,永不覆盖线上修改)。
配套的架构约束:推理引擎代码中禁止出现 "if 场景==某业务 then 调用某工具" 的硬编码。 知识库、工具、画像都是独立的能力单元,Agent 实例通过配置声明挂载哪些能力,运行时动态组装。一个 Agent 的一次执行,本质上是"配置 → PromptBuilder 组装 system prompt + 技能片段 + 知识上下文 → ToolProvider 把激活的工具解析成 Spring AI 的 ToolCallback → 一次带原生 tool-calling 的流式 LLM 调用"。
为什么坚持"文本优先"而不是先做可视化编辑器?因为在 2026 年,配置的第一读者和第一作者都可能是 AI 编程助手。JSON 文件 AI 能直接读、直接改、直接 diff review;一个封闭的图形格式或只存在于数据库 blob 里的配置,AI 进不去。可视化界面可以做,但只能是文本配置的一个可选编辑器,绝不能成为唯一事实来源。
这条原则的延伸收益是:新增一个 Agent 不需要发版,不需要写代码——加一个 JSON 文件即可。配置变更通过 Redis pub/sub 通知推理引擎刷新本地缓存。
6. 可观测性从 Day 1 开始
上线前必须通过的检查只有一条:
能否仅凭日志还原一次完整请求的调用链路?
具体落地:
- 所有日志输出结构化 JSON,每行携带
trace_id(请求进入时在过滤器生成,贯穿后续所有日志行); - 关键路径(推理调用、工具执行、知识检索)必须记录耗时与状态;
- 关键业务事件(文档入库、Agent 创建、推理完成、反馈提交)通过 Spring ApplicationEvent 发布,异步写入工作日志表,主链路零阻塞;
- Prometheus 指标端点暴露推理请求数/耗时/成功率、工具调用次数/耗时、知识检索耗时;
- 告警基线:错误率 >5%、P99 延迟 >10s、LLM 单日成本超预算。
这里有个反常识的经验:Agent 系统的可观测性比传统业务系统更重要,而不是更不重要。 因为 LLM 的行为是概率性的,"为什么这个 Agent 今天答非所问"这种问题,没有全链路 trace 根本无从排查。
7. 演进路线:拆分不是失败,是计划的一部分
模块化单体最常见的质疑是"以后拆不动怎么办"。我们的回答是一张明确的触发条件表,而不是一句"以后再说":
| 信号 | 对应操作 |
|---|---|
| 知识库检索成为性能瓶颈 | 知识库模块独立部署,单独扩节点 |
| 工具调用量暴增,需独立限流或计费 | 工具管理模块拆为独立服务 |
| 多团队/业务线要求独立发布节奏 | 按层拆分:资产层一个服务,推理层一个服务 |
| LLM 接入策略复杂化(路由、成本、熔断规则膨胀) | LLM Gateway 独立为公共服务 |
由于各模块已经通过 Java interface 隔离,且 domain 层物理上不依赖任何基础设施,拆分路径被压缩成四步:
- 选定目标模块——它的接口早已定义为 Java interface;
- 创建独立服务仓库,实现相同接口;
- 单体中该模块的实现替换为 HTTP/gRPC 客户端(实现同一个 SPI);
- 逐步迁移流量,下线单体中的旧实现。
没有大规模重构,接口即边界。这四步里最难的第 1 步——划边界——在写第一行代码的那天就已经做完了。
8. 回头看:哪些决策最值
- Maven 模块边界当架构守护器。成本是一天的重构,收益是"分层不可被破坏"的永久保证。所有"靠自觉"的架构约束都会腐烂,编译器不会。
- SPI 签名强制带 spaceId。隔离约束被编码进类型系统,漏传 spaceId 是个编译错误,而不是一个安全漏洞。
- 四种存储各司其职 + 冗余优先于 JOIN。Agent 平台的数据形态差异极大,承认这一点比对抗它省力得多。
- 配置即代码,文本优先。在 AI 编程助手深度参与开发的时代,"配置必须能被 AI 直接读写"是一条会带来复利的设计约束。
- 推迟微服务的付费时间,但保留付款能力。模块化单体不是微服务的对立面,是它的前置形态——前提是你真的把接口当回事。
这套架构未必适合所有人:如果团队二十人、业务边界稳定、QPS 早已需要独立扩容,直接上微服务。但如果你也是小团队承载一个形态未定的新平台,"编译期强制的模块化单体"是一个被低估的起点。