🏗️ 第二篇:架构设计

第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
typesSPI 契约插件与工具的轻量契约,不依赖 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章用事件风暴的方法论带你还原这个划分过程。