源码小说CodeStory
登录注册
上一章

第一章 · 一纸文牒走天下:Model 泛型三件套与可移植抽象

下一章
字体

主题

版式

8,336 字 · 约 21 分钟

卷⼀ · ⽴契(核⼼抽象层)

第⼀章 · ⼀纸⽂牒⾛天下:Model 泛型三件套与可移植抽象"我只有⼀个⽅法。可天底下所有番邦的话,都得从我这⼉过。" —— Model.call() 的⾃⽩推开春芽译馆的第⼀道⻔,你⼤概以为会先看到掌柜、看到驿丞、看到⼀排忙着翻译的差役。

可没有。第⼀进院⼦空荡荡,只⽴着⼀块⽯碑,碑上刻着⼀⾏字,短到不像话:

TRes call(TReq request);

⼀⾏。整座译馆——往后你要⻅的 ChatClient、Advisor、ToolCallback、VectorStore、MCP ⼝岸——千头万绪,全部压在这⼀⾏上。我在 /tmp/spring-ai-src ⾥翻了很久才信:Spring AI 真正的地基,不是哪个花哨的 fluent API,⽽是这块刻着泛型的⽯碑。它叫 Model。

这⼀章我们只做⼀件事:把这块碑、以及它⽣出来的「三件套」彻底看穿。看穿了它,后⾯九⼗九章你都会轻松。

⼀、那块只有⼀⾏字的碑: Model<TReq, TRes>先把整块碑请出来。它住在抽象层的最顶上,⼲净得像没写完:

public interface Model<TReq extends ModelRequest<?>, TRes extends ModelResponse<?>> {

/**

* Executes a method call to the AI model.

* @param request the request object to be sent to the AI model

* @return the response from the AI model

*/

TRes call(TReq request);
}
— spring-ai-model/.../model/Model.java:31 (签名) / :38 (唯⼀⽅法)

⼀个接⼝,⼀个⽅法。但请把⽬光钉在那对尖括号上: <TReq extends ModelRequest<?>, TRes extends ModelResponse<?>> 。

这就是整座译馆的「通⽤⽂牒」。它没有规定你要跟哪个番邦做⽣意,也没规定你递的是聊天、是向量、还是画图——它只规定⼀件事:凡是来谈的,必须递上⼀份"请求"( TReq ,且必须是⼀种 ModelRequest ),我必还你⼀份"响应"( TRes ,且必须是⼀种 ModelResponse )。 ⾄于请求⾥写的是中原话还是番邦⽅⾔,那是后院翻译官的事,跟这块碑⽆关。

它的 Javadoc 把话说得很直⽩:⽤ Java 泛型来"accommodate different types of requests and responses",⽬的是"enhancing flexibility and adaptabilityacross different AI model implementations"( model/Model.java:19-31 )。翻译成⼈话:我⽤两个类型参数,把"模态差异"全收编进了类型系统。OpenAI、Anthropic、Ollama……这些番邦的脾⽓再不同,到了 Model 这⼀层,都被抹平成"⼀进⼀出"的同⼀种动作。

✨ 这就是本章第⼀个、也是全书最⼤的爽点:⼀条泛型链,统管所有模态。 你学会读 Model<TReq,TRes> ,就等于⼀次性学会了读 ChatModel、

EmbeddingModel、ImageModel、乃⾄以后的语⾳、转写、审核——它们全是这块碑的"特化拓本"。下⽂你会亲眼看到这⼀点。

这⾥先点破⼀处隐喻边界,免得你被"⽂牒"带偏:现实⾥你并不会直接拿着 Model 这个裸接⼝去调模型。 Model 是类型层⾯的总契约,真正出场的永远是它的某个模态⼦接⼝( ChatModel 等)。把 Model 理解成"所有⽂牒共⽤的那张空⽩模板",⽽不是"你⼿⾥那张盖了章的⽂牒",才对得上代码。

⼆、碑的另⼀⾯: StreamingModel ,⼀次还你⼀叠⽯碑还有背⾯。同步那⼀⾏管"⼀锤⼦买卖"——你递请求,我⼀次性把整份回函拍给你。可⼤模型最迷⼈的体验是"边想边说",字⼀个⼀个往外蹦。于是 Spring AI在 Model 旁边并排⽴了第⼆块碑:

public interface StreamingModel<TReq extends ModelRequest<?>, TResChunk extends ModelResponse<?>> {
Flux<TResChunk> stream(TReq request);
}
— model/StreamingModel.java:34 (签名) / :41 (唯⼀⽅法), Flux 来⾃ reactor.core.publisher ( :19 )

形状和正⾯⼏乎⼀模⼀样,但有两个改动值得你慢下来品。

第⼀,返回值从 TRes 变成了 Flux<TResChunk> ——Reactor 的响应式流。你不再拿到"⼀份回函",⽽是拿到"⼀条会陆续吐出回函碎⽚的⽔管"。

第⼆,也是更要紧的⼀处:第⼆个泛型参数改了名字,叫 TResChunk 。注意这个 Chunk(块)。它在提醒你⼀件容易看⾛眼的事:流式吐出来的每⼀个碎⽚,本身也仍然是⼀个完整的 ModelResponse (它的约束就是 extends ModelResponse<?> )。换到聊天场景,⽔管⾥流过的每⼀滴,都是⼀个货真价实的ChatResponse ,只不过⾥头通常只装了半个字、⼏个 token。

🦴 ⻣灰级细节:很多⼈第⼀次读会下意识以为"流式块是某种轻量级的 DTO"。不是。Spring AI 在抽象层就咬死了——流式块和同步响应共⽤同⼀套类型⻣架。这

带来⼀个极漂亮的下游红利:你在第五卷会⻅到的 MessageAggregator ,能把 Flux<ChatResponse> ⼀⽚⽚重新缝合成⼀条完整的 AssistantMessage ,靠的正是"每个碎⽚都是合法 ChatResponse"这条铁律。抽象层这⼀⼑切对了,⼏⼗⾥地之外的聚合器才能优雅。这种"在最上游就把约束钉死、让下游⽩捡好处"的设计,是 Spring AI 最值得偷师的地⽅。

三、⽂牒⾥到底写了什么:三件套的契约两块碑都反复念叨 ModelRequest 和 ModelResponse 。它们是什么?拆开看,你会发现整座译馆的"公⽂格式",其实就三件套:请求、响应、结果。

请求 ModelRequest<T> :两栏,⼀必填⼀可选

public interface ModelRequest<T> {
T getInstructions(); // required input
@Nullable ModelOptions getOptions();
}
— model/ModelRequest.java:32 / :38 (必填) / :44 (可选)

⼀份⽂牒就两栏。 getInstructions() 是正⽂——你到底想让模型⼲嘛(聊天场景⾥它是⼀串 Message ,嵌⼊场景⾥是⼀串待向量化的⽂本)。 getOptions()

是附加条款——温度、最⼤ token 之类的旋钮,可以不填( @Nullable 标得明明⽩⽩)。

源码作者甚⾄懒得删那句⾏内注释 // required input ( :38 ),直接把"这栏必填、那栏可空"的契约写在了脸上。

响应 ModelResponse<T extends ModelResult<?>> :⾸个、全部、外加⼀张总账
public interface ModelResponse<T extends ModelResult<?>> {
@Nullable T getResult(); //⾸个结果,可能为 null
List<T> getResults(); //全部结果
ResponseMetadata getMetadata();
}

— model/ModelResponse.java:34 / :40 / :46 / :52回函有三栏。 getResult() 给你头⼀个结果(注意它 @Nullable ——空着⼿回来是合法的,下游必须防 null); getResults() 给你全部结果(你可以请模型⼀次⽣成多条候选); getMetadata() 给你⼀张响应级总账——耗了多少墨(usage)、限了多少流(rate limit)等等。

这⾥的泛型约束有点意思: T extends ModelResult<?> 。也就是说,响应⾥装的不是裸数据,⽽是⼀摞 ModelResult 。这就引出第三件套。

结果 ModelResult<T> :每条结果 = 产出 + 它⾃⼰的⼩账ModelResult 才是最⼩颗粒:它持有⼀个 getOutput() (真正的产物,⽐如⼀条 AssistantMessage 、⼀个 float[] 向量)和⼀个 getMetadata() (这⼀条结果⾃⼰的元数据,⽐如它的 finishReason )。

于是整条链清清爽爽:⼀个 ModelResponse ⾥装着⼀摞 ModelResult ,每个 ModelResult 包着⼀份 Output 和它的⼩账,整摞之上再压⼀张响应级总账。

(请求/响应/结果分别落在 model/ModelRequest.java 、 ModelResponse.java 、 ModelResult.java:29 )

两枚"空印章": ModelOptions 与 ResultMetadata三件套⾥还藏着两个看着像 bug、其实是设计的东⻄:

public interface ModelOptions {
}

— model/ModelOptions.java:29 (空标记接⼝)

对,⾥⾯⼀个⽅法都没有。 ResultMetadata ( model/ResultMetadata.java:29 )同样是个空壳。

这不是没写完,是标记接⼝(marker interface)——它们只做⼀件事:在类型系统⾥钉⼀根"锚桩"。 ModelOptions 说"凡是模型参数,都得是我这⼀类",但它不规定任何具体参数;具体有哪些旋钮,全下放给⼦接⼝(下⼀章你会看到 ChatOptions 在它之上⻓出⼋个跨⼚商通⽤参数)。

🦴 ⻣灰级细节:为什么不把通⽤参数直接写进 ModelOptions ?因为聊天有温度、嵌⼊没有;画图有尺⼨、聊天没有。没有任何⼀个参数是所有模态都通⽤的,所

以最顶层只能是空壳。把"空"留在顶层、把"实"下沉到模态,是这套抽象能横跨六种模态还不打架的关键。空标记接⼝在这⾥不是偷懒,是克制。

四、三件套怎么"特化"成你天天⽤的 API讲了半天抽象,该看它落地了。还记得第⼀节那句爽话吗——"⼀条泛型链统管所有模态"。现在兑现。

各模态要做的,⽆⾮是把 Model<TReq, TRes> 这块空⽩⽂牒⾥的两个尖括号填上⾃⼰的类型。⼀张表说尽:

模态 请求( ModelRequest ) 响应( ModelResponse ) 结果( ModelResult ) 产物Chat Prompt ChatResponse Generation AssistantMessageEmbedding EmbeddingRequest EmbeddingResponse Embedding float[]Image ImagePrompt ImageResponse ImageGeneration Image每⼀⾏,都是把⽯碑上的 <TReq, TRes> 换成⾃⼰那⼀对⽽已。下⾯挑三个最典型的看实物。

ChatModel:把"同步"和"流式"焊在了⼀起

public interface ChatModel extends Model<Prompt, ChatResponse>, StreamingChatModel {

// ...

}

— chat/model/ChatModel.java:30看清这⾏继承:它同时继承了同步的 Model<Prompt, ChatResponse> 和流式的 StreamingChatModel 。也就是说, ChatModel 天⽣"⼆合⼀"——⼀个实现类同时具备 call() 和 stream() 两副本事。这正是第⼆节那两块碑(正⾯/背⾯)在聊天模态的合体。

⽽ StreamingChatModel ⾃⼰⼜是什么?

@FunctionalInterface

public interface StreamingChatModel extends StreamingModel<Prompt, ChatResponse> {
// ... default stream(String) / stream(Message...) ...

@Override

Flux<ChatResponse> stream(Prompt prompt);
}

— chat/model/StreamingChatModel.java:30 / :48-49

它就是把 StreamingModel 的两个尖括号填成了 <Prompt, ChatResponse> ,再标上 @FunctionalInterface ——它只有⼀个抽象⽅法 stream(Prompt) ,

所以⼀个 lambda 就能实现⼀个流式聊天模型。

EmbeddingModel:只同步,不流式

public interface EmbeddingModel extends Model<EmbeddingRequest, EmbeddingResponse> {

@Override

EmbeddingResponse call(EmbeddingRequest request);

// ...

}

— embedding/EmbeddingModel.java:39注意它只继承 Model ,没碰 StreamingModel 。这很合理——把⼀段⽂本变成向量是⼀锤⼦的事,没有"边算边吐"的语义。抽象层⽤"继承谁、不继承谁",⼲净地表达了"这个模态⽀不⽀持流式"。这种⽤类型组合来编码能⼒的⼿法,⽐加⼀个 boolean supportsStreaming() 标志位⾼明得多。

ImageModel:⼀个 lambda 就是⼀座画馆@FunctionalInterface

public interface ImageModel extends Model<ImagePrompt, ImageResponse> {
ImageResponse call(ImagePrompt request);
}

— image/ImageModel.java:22整个⽂件去掉版权头只剩五⾏。 @FunctionalInterface ⼀标,它就成了最纯粹的"填空"——把 <TReq, TRes> 填成 <ImagePrompt, ImageResponse> ,再把call() 的签名照抄⼀遍收窄返回类型,完事。

到这⼉,第⼀节那句"⼀条泛型链统管所有模态"算是落到了实处:三个模态,三种活⼉,可它们的接⼝⻓得像三胞胎。 你读懂⼀个,剩下两个不⽤教。

五、给新⼿的台阶:那些 default 便捷⽅法抽象再优雅,要是上⼿得先 new ⼀个 Prompt 、再 new UserMessage 、再 .getResult() 、再 .getOutput() 、再 .getText() ……新⼈早跑了。SpringAI 在接⼝⾥塞了⼀排 default ⽅法当台阶。

聊天最短的⼀句话:

default @Nullable String call(String message) {
Prompt prompt = new Prompt(new UserMessage(message));
Generation generation = call(prompt).getResult();
return (generation != null) ? generation.getOutput().getText() : "";
}

— chat/model/ChatModel.java:32-36你只管 chatModel.call("你好") ,它在背后默默替你把字符串包成 UserMessage 、塞进 Prompt 、调真正的 call(Prompt) 、剥出⾸个 Generation 、取出⽂本。⼀⾏⼊⻔,全套抽象在幕后照常运转。流式那边对称地给了 stream(String) ,把每个 ChatResponse 映射成纯⽂本 Flux<String>( StreamingChatModel.java:32-38 )。

嵌⼊也⼀样体贴:

default float[] embed(String text) {
Assert.notNull(text, "Text must not be null");
List<float[]> response = this.embed(List.of(text));
return response.iterator().next();
}

— embedding/EmbeddingModel.java:49-53

embed("⼀段话") 直接还你⼀个 float[] , EmbeddingRequest / EmbeddingResponse 全程不露脸。

这套"简单场景⼀⾏调⽤、复杂场景⾛完整三件套"的双层设计很妥帖。但——便捷⽅法不全是糖。下⾯这⼀颗,咬下去硌⽛。

六、⚠ 暗礁: dimensions() 这颗"会偷偷打⻓途"的便捷⽅法你想知道某个嵌⼊模型输出向量是多少维。直觉上,这该是个零成本的查询——读个常量嘛。接⼝也确实给了你⼀个 default :

/**

* Get the number of dimensions of the embedded vectors. Note that by default, this

* method will call the remote Embedding endpoint to get the dimensions of the

* embedded vectors. If the dimensions are known ahead of time, it is recommended to

* override this method.

*/

default int dimensions() {
return embed("Test String").length;
}

— embedding/EmbeddingModel.java:137-139看清楚最后那⼀⾏⼲了什么:它 embed("Test String") ——对着远端嵌⼊服务真发了⼀次⽹络请求,把⼀句假数据"Test String"打过去,就为了量⼀量回来的数组有多⻓。

⚠ 这是⼀处实打实的暗礁。 dimensions() ⻓着⼀副"读属性"的⼈畜⽆害脸,⻣⼦⾥却是个带远程副作⽤、要花钱、会延迟、还可能抛⽹络异常的重操作。要是有

⼈天真地把它放进某个频繁调⽤的热路径(⽐如每次构建向量库前都查⼀遍维度),账单和 P99 延迟都会替他上⼀课。