第十六章 · 馆心·账房·驿马·公文:三层架构与设计哲学总览
下一章第⼗六章 · 馆⼼·账房·驿⻢·公⽂:三层架构与设计哲学总览"我不依赖 Spring Boot。"——这句话不是我说的,是 design/02-boot-modularity.adoc 第 9 ⾏替整座春芽译馆说的。⼀座号称"开箱即起馆"的译馆,⻣⼦⾥却拒绝跟 Boot 绑死。读懂这个⽭盾,就读懂了整个 Spring AI。
⾛到这⾥,我们已经把译馆的前台掌柜、⼀排驿丞、藏经阁、起居注、差役调度房、通商⼝岸都拆过⼀遍了。现在我想带你后退⼗步,站到译馆⻔外的⾼坡上,看⼀眼它的整体地基——三层⻣架是怎么垒起来的,⼜是靠什么把每⼀层的边界焊死,让它们不会在两年的迭代⾥悄悄渗成⼀锅粥。
这⼀章不抛新功能,只回答⼀个问题:Spring AI 凭什么敢同时承诺"可移植"和"开箱即⽤"这两件⼏乎⽭盾的事?
⼀、三进院⼦:馆⼼、装配房、套装铺先把地图摊开。译馆的物理布局是三进院落,对应仓库根⽬录⾥清清楚楚的三类⽬录: core (各种 mcp/ 、 spring-ai-model 、 spring-ai-vector-store …)、auto-configurations/ 、 starters/ 。这不是随⼿分的⽂件夹,是写进设计⽂档的硬规矩。
第⼀进——馆⼼(core):这是译馆真正⼲活的地⽅。common ⼯具、各路 model 实现(ChatModel/ImageModel/EmbeddingModel…)、VectorStore 抽象与实现、ChatClient、Advisors,还有整套 MCP ⽀持,全都住在这⾥。设计⽂档把话说得不能再直⽩:
The Spring AI core classes should be usable without Spring Boot and assuch should not depend on boot. Core classes include common modules,
various `model` implementations (be it `ChatModel`, `ImageModel`, _etc._ ),`VectorStore` abstraction and implementations, but also the `ChatClient`abstraction and `Advisors`, MCP support. As a matter of fact, allfunctionality of Spring AI is available to a plain Spring Framework app.—— design/02-boot-modularity.adoc:9-14注意最后那句:all functionality of Spring AI is available to a plain Spring Framework app。翻译过来就是——你哪怕不碰 Spring Boot,只⽤裸的 SpringFramework,甚⾄连 Spring 都不太⽤,只要把 jar 拖进 classpath,译馆的全部本事你都能调。馆⼼是⾃给⾃⾜的,它不需要 Boot 替它点灯。这就是"可移植抽象"
(portable abstraction)落到⼯程层⾯的样⼦:⼀纸通⽤⽂牒⾛天下,⽽签发⽂牒的衙⻔本身不挂靠任何⼀个朝廷。
第⼆进——装配房(auto-configuration):这⼀层是给 Boot ⽤户的贴⼼服务。它⽤ @AutoConfiguration 把馆⼼的零件⾃动接好线,你 application.yml ⾥写两⾏属性,⼀座馆就⾃⼰起来了。但这⼀层有条铁律——底层库不在 classpath 上时,必须零影响:
these modules should have zero impact if the underlyinglibraries are not present on the application classpath. As a consequence,all dependencies of those `auto-configuration` modules which are notdedicated to tests or depending on other `auto-configuration` should be marked `optional`
(including the `spring-boot-autoconfigure` module ...)—— design/02-boot-modularity.adoc:19-24划重点:装配房的所有⾮测试、⾮⾃配置间依赖,统统标 optional ——连那个触发 @AutoConfiguration ⽣效的 spring-boot-autoconfigure 本身都不例外。
这意味着你把某个 spring-ai-autoconfigure-model-openai 拖进来,如果你的⼯程⾥根本没有 OpenAI 的 SDK,这个装配房就安安静静地什么都不做,不会强⾏往你的依赖树⾥塞⼀堆你⽤不上的东⻄。
第三进——套装铺(starter):这⼀层最薄,薄到不含⼀⾏代码。它的全部职责就是"打包":把某个功能 X 的装配房,连同 X 需要的传递依赖,⼀起拉进来;再通过 spring-boot-starter 顺⼿带上 spring-boot-autoconfigure ,让装配房的 @AutoConfiguration 真正"显灵"。starter 之间还能互相依赖,层层套娃。
Lastly, `starters` provide end users with an opinionated way toquickly bootstrap an application with everything needed to leverage
some feature X. As a consequence, those modules (which do not contain code)are responsible for dragging the autoconfiguration module for X ...—— design/02-boot-modularity.adoc:28-31研究笔记⾥印证过这个结构: starters/spring-ai-starter-model-openai/pom.xml 拉的是 webclient/restclient 的 starter + 那个 autoconfig 模块 +
spring-ai-openai (core) + chat-client/chat-memory 两个 autoconfig,清⼀⾊ <dependency> ,没有 src/ 。它就是⼀个 pom,⼀张"购物清单"。三进院⼦的分⼯,⽤译馆话说就是:馆⼼负责真本事,装配房负责接线,套装铺负责打包发货。 你想要省⼼,买套装铺的⼀个 starter,⼀键起馆;你想要克制,直接依赖装配房,⾃⼰补 spring-boot-autoconfigure ;你想要极致可移植,绕开后两进,直接⽤馆⼼——三种姿势,各取所需。
⼆、把边界焊死:maven-enforcer 这把焊枪到这⾥你可能会说:分层谁不会?画三个框,谁都能画。难的是两年后这三个框还能不能站住。
任何做过⼤型⼯程的⼈都知道⼀个残酷规律:架构边界靠⼝头约定,必烂。 某个深夜赶⼯的 PR,顺⼿在 core ⾥ import 了⼀个 Boot 的类,评审没看出来,合进去了——边界就破了⼀个洞。下⼀个⼈看⻅ core 已经能⽤ Boot 了,⼼安理得再⽤⼀次。半年后,你那个号称"可移植"的馆⼼,已经离不开 Boot 了,⽽且没⼈记得是从哪⼀⾏代码开始烂的。
Spring AI 2.0 的解法是:不靠⼈,靠机器。 设计⽂档的"Solution"段写得斩钉截铁:
Starting with Spring AI 2.0, the code base has been revisited to enforcethese principles. In particular, the `maven-enforcer-plugin` and its`bannedDependencies` rule have been configured to make sure that
* the core codebase does NOT depend on boot,
* all dependencies of `auto-configurations` have been marked `optional=true` except those to other `auto-configurations`—— design/02-boot-modularity.adoc:38-43maven-enforcer-plugin 的 bannedDependencies 规则,就是架在每⼀次构建上的⼀把焊枪。它在编译期就检查两件事:(1) core 模块的依赖树⾥不准出现 boot;
(2) auto-configurations 的依赖,除了指向别的 auto-configuration 的那些,必须全部 optional=true 。 ⼀旦哪个 PR 越界,构建直接红,根本进不了主⼲。
这是这⼀章我最想让你记住的⼀个设计姿态:架构纪律⼀旦能机器化,就别留给⼈的⾃觉。 ⽂档⾥那句话翻译过来近乎⼀种⼯程哲学——⼝头的"应该"会随时间腐烂,只有写进 pom.xml 、能让 CI 变红的"必须",才真正站得住。译馆把"馆⼼不挂靠朝廷"这条祖训,从⼀句话变成了⼀道每次开⻔都要过的关卡。
⚠ 暗礁:这套优雅是有学费的。 三层切分加上 per-provider 的 starter/autoconfig 对称展开,直接后果是模块数量爆炸。光是研究笔记⾥点到的,MCP 相关
starter 就有 mcp-client 、 mcp-client-webflux 、 mcp-server 、 mcp-server-webflux 、 mcp-server-webmvc 五个;再叠上⼏⼗家模型 provider、⼗⼏种 VectorStore、各路 advisor,每家都是"core + autoconfig + starter"三件套——整个仓库的模块数轻松冲到三位数。对译馆的维护者,这是约定俗成的整⻬;
对第⼀次进来找路的读者,这是⼀⽚让⼈发懵的密林:你想给 OpenAI 接个向量库,得在 starters/ 、 auto-configurations/ 、core 三个⽬录⾥来回跳,才能拼出⼀条完整的依赖链。可移植性和易上⼿,在这⾥是有真实张⼒的——别被"分层很美"的叙事骗了,分层的代价是认知负荷,Spring AI 把这份代价部分转嫁给了新⼈。
馆⼼搭好了,边界焊死了。接下来该看的是,横跨三层、谁都绕不开的三套"基础设施":账房(可观测)、备⽤驿⻢(重试)、公⽂模板(PromptTemplate)。它们最能体现"core 不依赖 Boot"这条祖训是怎么在细节⾥贯彻的。
三、账房:⼀套观测四件套,从模型到藏经阁通管先看账房——可观测性。⼀座正经的译馆,每⼀笔⽣意都要记账:谁来过、要了什么、花了多少墨、办成没办成。在 Spring AI ⾥,这本账由 Micrometer Observation统⼀记,⽽且全框架是同⼀套四件套: *ObservationContext (这笔账的上下⽂)、 *ObservationConvention (怎么记的规矩,带 Default 实现 + 接⼝)、
*ObservationDocumentation (账⽬的字段定义,枚举⾥列 KeyName)、 *ObservationHandler (账记到哪去)。ChatModel 这条线,字段定义在 ChatModelObservationDocumentation ⾥,核⼼是那个枚举常量 CHAT_MODEL_OPERATION ,低基数的账⽬字段有AI_OPERATION_TYPE 、 AI_PROVIDER 、 REQUEST_MODEL 、 RESPONSE_MODEL ——也就是"这是什么操作、找的哪个番邦、请求⽤的什么模型、回函是哪个模型签
的"( ChatModelObservationDocumentation.java:32-90 )。但真正的证据不在字段定义,在埋点的调⽤现场。看驻馆翻译官 OpenAiChatModel 是怎么把⼀次真实的 HTTP 调⽤包进账本的:
ChatModelObservationContext observationContext = ChatModelObservationContext.builder()
.prompt(prompt)
.provider(AiProvider.OPENAI.value())
.build();
ChatResponse response = ChatModelObservationDocumentation.CHAT_MODEL_OPERATION
.observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext,
this.observationRegistry)
.observe(() -> {
ChatCompletion chatCompletion = this.openAiClient.chat().completions().create(request);// ...解析 choices、算 usage、组 ChatResponse ...
observationContext.setResponse(chatResponse);
return chatResponse;
});—— models/spring-ai-openai/.../OpenAiChatModel.java:207-249
看清楚这个结构:真正捅向番邦的那⼀⼑( this.openAiClient.chat().completions().create(request) ),被整个塞进了 .observe(() -> { ... }) 的闭包⾥。 账房不是事后补记的流⽔,它是把⽣意本身裹在账本⾥办的——观测的开始、结束、异常,严丝合缝地框住模型调⽤的真实⽣命周期。流式版更讲究,因为流是异步的、没有⼀个⼲净的 try-finally 边界,它改⽤ observation.start() / 显式 scope / 收尾的写法,⼿动管理观测的⽣与死( OpenAiChatModel.java:271-
289 )。藏经阁那边同样的套路,⽽且做得更彻底。所有 VectorStore 的共同基类 AbstractObservationVectorStore ,把 add / delete / similaritySearch 三个对外动作全部⽤观测包了⼀层:
@Override
public List<Document> similaritySearch(SearchRequest request) {
VectorStoreObservationContext searchObservationContext = this
.createObservationContextBuilder(VectorStoreObservationContext.Operation.QUERY.value())
.queryRequest(request)
.build();return VectorStoreObservationDocumentation.AI_VECTOR_STORE
.observation(this.customObservationConvention, DEFAULT_OBSERVATION_CONVENTION,
() -> searchObservationContext, this.observationRegistry)
.observe(() -> {
var documents = this.doSimilaritySearch(request);
searchObservationContext.setQueryResponse(documents);
return documents;
});
}—— spring-ai-vector-store/.../observation/AbstractObservationVectorStore.java:124-143
这是个漂亮的模板⽅法:基类负责"记账"这件横切的脏活,⼦类只需要实现 doAdd / doDelete / doSimilaritySearch 三个"真正⼲活"的钩⼦( :149-181 ),完全不⽤操⼼观测。每⼀家新接的向量库,⽩捡⼀套完整可观测性。
🦴 ⻣灰级细节:account 这本账,记得在馆⼼,但记账的笔由 Boot 借。 这是"core 不依赖 Boot"这条祖训最精巧的⼀次落地。你回头看上⾯两段代码——
OpenAiChatModel 和 AbstractObservationVectorStore 这两个馆⼼⾥的类,它们埋点时只依赖⼀个东⻄:Micrometer 的 ObservationRegistry 。⽽Micrometer 是独⽴于 Spring Boot 的库。更妙的是,这个 observationRegistry 在构造期可以是 ObservationRegistry.NOOP ——⼀个什么都不记的空账本。也就是说,你裸⽤馆⼼、根本没接监控时,这套埋点代码照样跑,只是悄⽆声息地记到⼀本"假账"⾥,零开销、零报错。等你升级成 Boot ⽤户,装配房
OpenAiChatAutoConfiguration 会⽤ ObjectProvider<ObservationRegistry> 把容器⾥真实的 registry 注进来(缺省仍兜底 NOOP,⻅OpenAiChatAutoConfiguration.java:85 ),账本⼀下⼦就变成真的了。core 写"该记账",Boot 决定"账记到哪、记不记"——⼀条祖训,两层各守其位,严丝合缝。
四、备⽤驿⻢:RetryUtils 的"哪些该追、哪些该认命"
第⼆套基础设施是重试——译馆的备⽤驿⻢。信使在半路上摔了⼀跤,是该换匹⻢再追⼀程,还是该认命回报"这趟⻩了"?这个判断的全部智慧,压缩在 RetryUtils⾥。
先说⼀个容易被忽略的"新旧之变":这⾥⽤的 RetryTemplate ,已经不是当年那个独⽴的 spring-retry 库了,⽽是 Spring Framework ⾃家
org.springframework.core.retry.RetryTemplate (看 import: RetryUtils.java:29-33 )。这是 2.0 时代往 Spring Framework 6.x/7.x 新基建靠拢的⼀个信号——能⽤框架原⽣的,就不再背第三⽅的包。
驿⻢的第⼀桩智慧是分诊:哪些错该重试,哪些纯属⽩费⼒⽓。
if (response.getStatusCode().is4xxClientError()) {
throw new NonTransientAiException(message);
}
throw new TransientAiException(message);—— spring-ai-retry/.../retry/RetryUtils.java:89-92逻辑⼲脆利落:HTTP 4xx 客户端错误,⼀律 NonTransientAiException ——不可重试。 你 API key 填错了(401)、你不在某个组织⾥(401),这种错你重试⼀万遍也是错,追上去毫⽆意义,直接认命抛出去。其余的错(包括服务端 5xx),⼀律 TransientAiException ——可重试。
这⾥藏着⼀个反直觉但正确的判断,源码的注释甚⾄专⻔点出来了:
/*
* Thrown on 4xx client errors, such as 401 - Incorrect API key provided,
* 401 - You must be a member of an organization to use the API, 429 -
* Rate limit reached for requests, 429 - You exceeded your current quota,
* please check your plan and billing details.
*/
—— RetryUtils.java:83-88注意:429(限流 / 配额)虽然是 4xx,却被注释明确归到了"会触发 NonTransientAiException "的家族⾥。 这意味着按这段 is4xxClientError() 的判定,429 被当作不可重试处理。这是个值得停下来想⼀想的取舍:
⚠ 暗礁 / ⼀处真实质疑:429 ⼀⼑切成"不重试",未必总是对的。 业界对 429 的主流处理其实是"带退避地重试"——尤其是"Rate limit reached for
requests"这种瞬时限流,等⼏秒、退避⼀下再发,往往就过去了。Spring AI 这⾥把所有 4xx(含 429)统统划⼊ NonTransientAiException ,等于把"瞬时限流"和"配额耗尽"两种性质完全不同的 429 揉成了⼀锅:配额耗尽确实重试⽆⽤,但瞬时限流被⼀并堵死,反⽽可能让本可⾃愈的调⽤直接失败。要想对 429 单独退避,你得⾃⼰定制 ResponseErrorHandler 或换掉默认重试模板——开箱默认在这⼀点上是偏保守的。隐喻边界也得点破:"备⽤驿⻢"听着是"⻢多就能硬追",但译馆的真实策略其实是"先分诊、再决定追不追",⽽且分诊的⼑法在 429 上略钝。
驿⻢的第⼆桩智慧是追的节奏——默认重试模板的退避曲线:
RetryPolicy retryPolicy = RetryPolicy.builder()
.maxRetries(DEFAULT_MAX_ATTEMPTS) // 10
.includes(TransientAiException.class)
.includes(ResourceAccessException.class)
.delay(Duration.ofMillis(DEFAULT_INITIAL_INTERVAL)) // 2000ms起
.multiplier(DEFAULT_MULTIPLIER) // ×5
.maxDelay(Duration.ofMillis(DEFAULT_MAX_INTERVAL)) //封顶 3min
.build();
—— RetryUtils.java:108-116 (常量⻅ :50-56 )读出来就是:最多追 10 回,⾸次等 2 秒,每失败⼀次等待 ×5,但单次最⻓不超过 3 分钟。 2s → 10s → 50s → 封顶 180s……指数退避把"别把已经喘不过⽓的番邦再压垮"这件事考虑进去了。能重试的异常被⽩名单卡死成两类: TransientAiException (我们⾃⼰分诊出来的可重试错)和 ResourceAccessException (⽹络层⾯的连不上)。还有个 SHORT_RETRY_TEMPLATE ,⾸次只等 100ms,注释写明是测试场景⽤的( :139-145 ),别在⽣产⾥⼿滑⽤了它。
最后是失败语义的统⼀收⼝。 execute 这个⽅法做了件体贴的事:
public static <R extends @Nullable Object> R execute(RetryTemplate retryTemplate, Retryable<R> retryable) {
try {
return retryTemplate.execute(retryable);
}
catch (RetryException e) {
throw (e.getCause() instanceof RuntimeException runtime) ? runtime
: new RuntimeException(e.getMessage(), e.getCause());
}
}—— RetryUtils.java:170-178它把重试框架⾃⼰的 RetryException 拆开:如果底下真正的 cause 是个 RuntimeException ,就原样抛原始异常(你 catch 到的是真凶,不是⼀层框架包装);否则才裹⼀层 RuntimeException 。这样所有驻馆翻译官重试失败时,抛给你的异常语义是⼀致的、⼲净的——你不⽤学会剥洋葱才能看⻅真正出了什么事。
五、公⽂模板:StringTemplate v4 与"宁可炸,不要静默漏"
第三套基础设施是公⽂模板——PromptTemplate。译馆⾥所有要填空的国书(prompt),最终都要过⼀道公⽂房:把 {name} 、 {context} 这样的占位符,换成真实内容。这道⼯序由 StTemplateRenderer 完成,底座是 StringTemplate v4。
它最值得说的有两点。第⼀,线程安全是设计出来的,不是碰巧的:
@Override
public String apply(String template, Map<String, ? extends @Nullable Object> variables) {
Assert.hasText(template, "template cannot be null or empty");// ...
ST st = createST(template); //每次新建 ST实例
for (Map.Entry<...> entry : variables.entrySet()) {
st.add(entry.getKey(), entry.getValue());
}
if (this.validationMode != ValidationMode.NONE) {
validate(st, variables);
}
return st.render();
}—— StTemplateRenderer.java:100-114类注释把话挑明了:每次 apply 都新建⼀个 ST 实例,线程之间不共享任何可变状态( :48-52 )。这是个朴素但关键的决定——StringTemplate 的 ST 对象本身是有状态的(你往⾥ add 变量),如果图省事把它做成共享单例,⾼并发下两个请求互相把对⽅的变量覆盖掉,⽣成的 prompt 就会串味。译馆宁可每次多 new ⼀个对象,也要换来"绝不串味"的确定性。
第⼆,也是更有性格的⼀点:默认校验模式是 THROW ,⽽且要炸就给你炸得明明⽩⽩。
private static final ValidationMode DEFAULT_VALIDATION_MODE = ValidationMode.THROW;—— StTemplateRenderer.java:67ValidationMode 有三档: NONE (不管)、 WARN (漏了变量记条⽇志)、 THROW (漏了直接抛异常)。默认是最严的 THROW 。校验逻辑⻓这样:
private Set<String> validate(ST st, Map<String, ? extends @Nullable Object> templateVariables) {
Set<String> templateTokens = getInputVariables(st); //模板⾥要求的变量
Set<String> modelKeys = templateVariables.keySet(); //你实际给的变量
Set<String> missingVariables = new HashSet<>(templateTokens);
missingVariables.removeAll(modelKeys); //差集 =缺了哪些
if (!missingVariables.isEmpty()) {
if (this.validationMode == ValidationMode.WARN) {
logger.warn(VALIDATION_MESSAGE.formatted(missingVariables));
}