第2章 六边形架构:模块划分与依赖方向
为什么 Agent 系统更需要架构约束——以及这个项目如何落地
2.1 本章导读
第1章你已经把系统跑起来了。从本章开始,我们进入源码世界。读一个 652 个文件的工程,最忌讳从某个类点进去一路跳转——很快就会迷失。正确的姿势是先建立"地图":系统分几层?每层边界在哪?谁可以依赖谁?本章给你这张地图。
2.2 为什么 Agent 系统更需要架构约束
传统的 CRUD 系统,架构松一点可能只是"代码难看";但 Agent 系统架构松了会直接出事故:
| 风险 | 没有架构约束时 | 本项目的对策 |
|---|---|---|
| 模型供应商锁定 | 业务代码里散落 OpenAI SDK 调用,换模型要改遍全系统 | Domain 只认 ILlmRuntimePort 端口,适配器可替换 |
| 工具失控 | Shell 执行逻辑混在 Controller 里,审批形同虚设 | 所有工具调用必须穿过统一 ToolCallExecutor |
| 状态无法回放 | 对话过程只有最终文本,出错无法复盘 | 会话事件日志(Session Event Log)全程落库 |
| 插件污染宿主 | 插件类直接进宿主 ClassLoader,冲突、泄漏难查 | Java 插件用隔离 URLClassLoader,Node 插件走 sidecar 进程 |
Agent 系统的本质是"让不确定的模型输出,驱动确定性的系统行为"。架构的价值就在于:模型可以是黑盒,但模型触达系统的每一条路径都必须是白盒。
2.3 六边形架构回顾
六边形架构(Hexagonal Architecture,又称端口-适配器模式)的核心思想一句话:领域模型不依赖任何外部世界,外部世界通过"端口"与领域对话。
左侧是驱动适配器(Driving Adapter):外部请求如何进入系统;右侧是被驱动适配器(Driven Adapter):系统如何访问外部资源。Domain 居中,两侧都依赖它,它不依赖任何人。
2.4 本项目的七模块落地
项目把六边形架构拆成了 7 个 Maven 模块,依赖方向在 pom.xml 层面被强制约束:
| 模块 | 六边形中的角色 | 职责 | 可以依赖 |
|---|---|---|---|
app | 组装层 | Spring Boot 启动、Bean 装配、静态 UI、Profile | 所有模块 |
trigger | 驱动适配器 | HTTP 协议、认证、参数绑定、SSE 推送 | api, case |
api | 应用门面 | Facade、DTO,隔离外部协议与内部用例 | domain |
case | 用例编排 | 跨领域流程编排(策略树责任链) | api, domain |
domain | 六边形核心 | 实体、值对象、领域服务、端口定义 | types |
infrastructure | 被驱动适配器 | MySQL/H2、LLM、Shell、Node、MCP 的端口实现 | domain |
types | SPI 契约 | 插件与工具的轻量契约,不依赖 Spring | 无 |
注意 infrastructure 指向 domain 的箭头——这是依赖倒置的体现:不是领域调用基础设施,而是基础设施实现领域定义的端口。这个项目曾真实修复过一次违规:Trigger 直接依赖了 infrastructure 的 RuntimeApprovalBroker,修复方式是在 domain 新建端口 IRuntimeApprovalBroker 让适配器实现它,Controller 只注入端口。
2.5 每层的代码长什么样
2.5.1 Trigger 层:只做协议转换
// trigger 模块 · AgentController(简化示意)
@PostMapping("/stream")
public SseEmitter stream(@RequestBody AgentMessageRequest req) {
// 只做三件事:参数绑定 → 调用用例 → 协议推送
return agentUseCase.sendMessageStreaming(req, deltaSink, toolCallSink);
}Trigger 层不写业务规则。CQRS 风格下 Controller 按 command/query 拆分(如 GoalCommandController / GoalQueryController),19 个 Controller 覆盖 Agent、任务、审批、插件、终端、工作流等全部入口。
2.5.2 Domain 层:定义端口
// domain 模块 · llm/adapter/port/ILlmRuntimePort.java
public interface ILlmRuntimePort {
// 流式生成:返回 JDK 9 的 Flow.Publisher,与具体 HTTP 客户端解耦
Flow.Publisher<StreamChunk> stream(LlmCallConfig config, List<Message> messages);
}注意这个接口里没有任何 Spring 注解、没有 HTTP 概念、没有 JSON——它就是领域语言:"给我一个运行时端口,我要流式生成"。谁来实现?Domain 不关心。
2.5.3 Infrastructure 层:实现端口
// infrastructure 模块 · DeepSeekAdapter(简化示意)
public class DeepSeekAdapter implements LlmAdapter {
// 手工解析 SSE 流,HTTP 总超时 120s
// 把 SSE 行解析成 domain 定义的 sealed 接口 StreamChunk
}所有"脏活"都在这里:手解 SSE、管理 HikariCP 连接池、启动 Node sidecar 进程、执行 /bin/sh -c。Domain 层对此一无所知。
2.5.4 Types 层:插件的契约
// types 模块 · 插件 SPI(不依赖 Spring!)
public interface JavaHarnessPlugin {
String pluginId();
List<AbstractTool> tools();
}types 是整个依赖链的最底端,刻意做成纯 POJO——这样第三方插件作者引入这个 JAR 时不会被拖进 Spring 生态。这是插件体系能独立演进的前提(第9章详解)。
2.6 一次请求的完整穿越
把各层串起来,看一次对话请求如何从上到下、再从下往上穿越整个系统:
注意回程的路径:LLM 的响应不是沿路"返回"的,而是通过事件回调(deltaSink)从 domain 一路推回 trigger 的 SSE 通道。这种"命令下行、事件上行"的结构在第5章(事件溯源)和第7章(SSE 协议)会详细展开。
2.7 小结与下一章预告
本章要点:六边形架构 = Domain 居中定义端口,Trigger(驱动)与 Infrastructure(被驱动)分两侧实现;7 个 Maven 模块用 pom 依赖强制单向;types 不依赖 Spring 是插件独立演进的关键;请求"命令下行、事件上行"。
下一章:模块地图有了,但 domain 模块内部还有 27 个限界上下文——它们是怎么划分出来的?为什么是 27 个而不是 5 个?第3章用事件风暴的方法论带你还原这个划分过程。