我们在给公司内部的 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 接口都遵循同一套纪律:

  1. 接口签名必带 spaceId(空间隔离的强制体现,见姊妹篇《Agent 平台的多层隔离模型》);
  2. 不泄漏实现细节(存储类型、HTTP 客户端、序列化格式);
  3. 粒度按"业务能力"而非"表"切分——一个 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_iduser_idend_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 层物理上不依赖任何基础设施,拆分路径被压缩成四步:

  1. 选定目标模块——它的接口早已定义为 Java interface;
  2. 创建独立服务仓库,实现相同接口;
  3. 单体中该模块的实现替换为 HTTP/gRPC 客户端(实现同一个 SPI);
  4. 逐步迁移流量,下线单体中的旧实现。

没有大规模重构,接口即边界。这四步里最难的第 1 步——划边界——在写第一行代码的那天就已经做完了。


8. 回头看:哪些决策最值

  1. Maven 模块边界当架构守护器。成本是一天的重构,收益是"分层不可被破坏"的永久保证。所有"靠自觉"的架构约束都会腐烂,编译器不会。
  2. SPI 签名强制带 spaceId。隔离约束被编码进类型系统,漏传 spaceId 是个编译错误,而不是一个安全漏洞。
  3. 四种存储各司其职 + 冗余优先于 JOIN。Agent 平台的数据形态差异极大,承认这一点比对抗它省力得多。
  4. 配置即代码,文本优先。在 AI 编程助手深度参与开发的时代,"配置必须能被 AI 直接读写"是一条会带来复利的设计约束。
  5. 推迟微服务的付费时间,但保留付款能力。模块化单体不是微服务的对立面,是它的前置形态——前提是你真的把接口当回事。

这套架构未必适合所有人:如果团队二十人、业务边界稳定、QPS 早已需要独立扩容,直接上微服务。但如果你也是小团队承载一个形态未定的新平台,"编译期强制的模块化单体"是一个被低估的起点。