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

第十三章 · 驻馆翻译官:OpenAI Provider 解剖

下一章
字体

主题

版式

18,690 字 · 约 47 分钟

卷六 · 通译(Provider 实现)

第⼗三章 · 驻馆翻译官:OpenAI Provider 解剖"我曾以为我的⼯作是从头学会⼀⻔番邦⽅⾔。后来译馆给我配了⼀名⼟⽣⼟⻓的⾆⼈——我只需把客官的国书递给他,再把他带回的回函翻成译馆通⽤的格式。我从语⾔学家,降级成了⼀名校对。" ——某位驻馆翻译官的⾃⽩前⾯整整五卷,我们⼀直在译馆的"馆⼼"⾥打转:通⽤⽂牒( ChatModel )、前台掌柜( ChatClient )、⼀排驿丞(Advisor)、藏经阁(VectorStore)、起居注(ChatMemory)。它们都很优雅,优雅得近乎抽象——你递⼀纸 Prompt ,拿回⼀纸 ChatResponse ,中间发⽣了什么,馆⼼⼀概不告诉你。

但天下没有抽象能凭空跟番邦做⽣意。 call(Prompt) 这道契约,终归要有⼈在某个房间⾥,把客官的国书⼀字⼀句翻成 OpenAI 听得懂的⽅⾔,把请求真的发出去,再把回来的密信翻回译馆通⽤格式。这个房间,就是 驻馆翻译官—— OpenAiChatModel 。

这⼀章,我们推开这扇⻔。我警告你:这是全书最"脏"的房间之⼀。馆⼼⾥那些⼲净的接⼝,在这⾥要落成四百⾏的巨型⽅法、⼀台拼接流式密信的脆弱状态机、⼀个⼀千⼀百多⾏的选项类。但也正是在这⾥,你才第⼀次看清"可移植抽象"四个字背后,翻译官替你咽下了多少苦。

⽽开⻔第⼀眼,就有个惊⼈的发现。

⼀、翻译官不再"⾃学⽅⾔"了如果你读过 Spring AI 1.x 的源码,你脑⼦⾥的 OpenAI provider ⼤概⻓这样:⼀个⼿写的 OpenAiApi ,⾥头堆着 RestClient / WebClient ,⾃⼰拼 JSON、⾃⼰处理SSE 流、⾃⼰包 RetryTemplate 。翻译官是个语⾔学家,从⾳标到语法,亲⼿把 OpenAI 这⻔⽅⾔学了个遍。

这⼀版,翻译官辞掉了语⾔学家的活。

我们先去 models/spring-ai-openai/ ⽬录⾥找那个传说中的 api/OpenAiApi.java ——它不在了。 find 确认整个模块没有 api/ ⽬录,HTTP 层只剩孤零零⼀个 http/okhttp/SpringAiOpenAiHttpClient.java 。那⼿写的 API 哪去了?看 import 就懂了:

import com.openai.client.OpenAIClient;
import com.openai.client.OpenAIClientAsync;
import com.openai.models.chat.completions.ChatCompletion;
import com.openai.models.chat.completions.ChatCompletionCreateParams;

// ...⼀⻓串 com.openai.*models/spring-ai-openai/.../OpenAiChatModel.java:35-69com.openai.* ——这是 OpenAI 官⽅的 Java SDK。翻译官不再⾃学⽅⾔了,他直接雇了个 OpenAI 总部派来的、⼟⽣⼟⻓的⾆⼈( OpenAIClient ),⾃⼰只做"把客官的话递给⾆⼈、把⾆⼈的回话翻回译馆通⽤格式"的校对⼯作。研究笔记⾥点明:这套思路明说借鉴⾃ LangChain4j 的 InternalOpenAiOfficialHelper ,两边作者同为 Julien Dubois。

这⼀改,意味着 OpenAI 每出⼀个新参数( verbosity 、 reasoningEffort 、 serviceTier 、 promptCacheKey ……),翻译官不必再⼿写序列化——⾆⼈天⽣就会说。代价我们后⾯会算,但先记住这个基调:本章的 OpenAiChatModel ,本质是⼀层"翻译 + 校对"的胶⽔,不是⼀个 HTTP 客户端。

类声明朴素得很:

public final class OpenAiChatModel implements ChatModel {

OpenAiChatModel.java:125final ,实现 ChatModel ——就是卷⼀⾥那纸通⽤⽂牒。构造时注⼊五样东⻄( :161-167 ):⼀个同步⾆⼈ OpenAIClient 、⼀个异步⾆⼈

OpenAIClientAsync (专给流式⽤)、⼀份默认选项 OpenAiChatOptions 、⼀个账房 ObservationRegistry ,还有⼀个…… ToolCallingManager 。最后这个差

役调度房,在本版⾥的⻆⾊已经⼤变,我们留到第四节再揭。

⼆、call() 全流程:三步⾛,中间那步包了⼀层账房进⻔先看正路。 call() 短得让⼈怀疑:

@Override

public ChatResponse call(Prompt prompt) {
Prompt requestPrompt = buildRequestPrompt(prompt);
verifyPromptChatOptions(requestPrompt);
return this.internalCall(requestPrompt, null);
}

OpenAiChatModel.java:190-195三步:兜底默认选项 → 校验 → 真正⼲活。

第⼀步 buildRequestPrompt ,你以为它会做精巧的选项合并?它不会。看实现:

private Prompt buildRequestPrompt(Prompt prompt) {
if (prompt.getOptions() == null) {
return prompt.mutate().chatOptions(this.getOptions()).build();
}
else {
return prompt;
}
}

OpenAiChatModel.java:1025-1032逻辑粗暴到家:客官递来的国书 有 ⾃带条款,就原样⽤;没有,才塞⼀份馆⾥的默认条款进去。⼆选⼀,没有深度 merge。 这是⼀个容易看⾛眼的设计——很多⼈以为"运⾏时选项覆盖默认选项"是在这⾥发⽣的,其实不是。真正的合并发⽣在更上游,我们第三节专⻔算这笔账。

第⼆步 verifyPromptChatOptions ( :476-482 )是个体⾯的⼩动作:OpenAI 不⽀持 topK ,客官要是傻乎乎填了,这⾥ warn ⼀句然后忽略,不报错也不假装⽀持。

这种"我做不到就明说"的诚实,⽐偷偷吞掉强。

第三步 internalCall ,真正的活在这⾥。我把它的⻣架拆给你看:

private ChatResponse internalCall(Prompt prompt, @Nullable ChatResponse previousChatResponse) {
ChatCompletionCreateParams request = createRequest(prompt, false);
// ...建 observationContext,塞 provider(AiProvider.OPENAI.value())...
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空则 return new ChatResponse(List.of())...
List<Generation> generations = choices.stream().map(choice -> {
Map<String, Object> metadata = Map.of("id", chatCompletion.id(), /* role/index/
finishReason/refusal/annotations/reasoningContent */ );
return buildGeneration(choice, metadata, request);
}).toList();

// ...usage累计...

ChatResponse chatResponse = new ChatResponse(generations, from(chatCompletion, accumulatedUsage));
observationContext.setResponse(chatResponse);
return chatResponse;
});
return response;
}
OpenAiChatModel.java:203-252 (为篇幅删去注释与空 choices 分⽀)

读懂这⼀段,你就懂了 90% 的 provider 套路:

1. createRequest(prompt, false) 把译馆国书翻成 SDK 的 ChatCompletionCreateParams ——这是翻译官最累的活,四百⾏,下⼀节细说。

2. 整个调⽤被 .observe(() -> {...}) 闭包包住。账房(observation)在外圈站岗,闭包内才是真正的业务。context ⾥早早塞好provider(AiProvider.OPENAI.value()) ( :209 ),好让账本上记清这笔⽣意是跟哪家番邦做的。

3. 闭包内,⼀⾏直调⾆⼈: this.openAiClient.chat().completions().create(request) ( :217 )。没有 RestClient ,没有⼿拼 JSON,没有

RetryTemplate ——⼀个同步⽅法调⽤,⾆⼈就把请求发出去、把回函( ChatCompletion )拿回来了。

4. 回函翻译回译馆格式:每个 choice 经 buildGeneration 变成⼀份 Generation ,同时把

id/role/index/finishReason/refusal/annotations/reasoningContent 塞进 metadata( :228-234 )。

这⾥有个值得停⼀秒的细节——耗墨账(usage)是 跨多轮累加 的:

CompletionUsage usage = chatCompletion.usage().orElse(null);
Usage currentChatResponseUsage = usage != null ? getDefaultUsage(usage) : new EmptyUsage();
Usage accumulatedUsage = UsageCalculator.getCumulativeUsage(currentChatResponseUsage, previousChatResponse);

OpenAiChatModel.java:239-242注意 internalCall 的第⼆个参数 previousChatResponse 。它在 call() ⾥被传成 null ,但⽅法签名留着它,就是为了把"上⼀轮的耗墨"加进"这⼀轮的累计"。这是个伏笔——你会本能地猜:既然有 previousChatResponse ,是不是说明这⽅法会被⼯具循环 递归调⽤?

记住这个疑问。第四节,我会告诉你这个伏笔最后变成了⼀个"过时的传说"。

🦴 ⻣灰级细节:Jackson 2 和 Jackson 3,在同⼀个类⾥打架

翻看 import,你会撞⻅⼀桩很别扭的事:

import com.fasterxml.jackson.databind.ObjectMapper; //第 34⾏
import tools.jackson.databind.JsonNode; //第 78⾏

OpenAiChatModel.java:34, 78

com.fasterxml.jackson 是 Jackson 2, tools.jackson 是 Jackson 3(包名换了)。⼀个类同时依赖两个⼤版本的 Jackson。类⾥那个 objectMapper 字段还

特意留了⾏注释:

// Jackson 2 required due to OpenAI deserializers

private static final ObjectMapper objectMapper = new ObjectMapper();

OpenAiChatModel.java:136-137翻译过来:OpenAI 官⽅ SDK 的反序列化器钉死在 Jackson 2 上,⽽ Spring AI ⾃⼰的⼯具(⽐如⼯具 schema 那套)已经迁到了 Jackson 3。两个⽣态在同⼀个⽂件⾥被迫共存。这就是"雇官⽅⾆⼈"的隐性账单之⼀——你的依赖树⾥从此挂着别⼈的版本包袱,⽽且是 两套。这不是 bug,但它是⼀道你升级时迟早会被绊⼀下的暗线。

三、createRequest:四百⾏的巨型翻译房,与⼀处该被批评的"胖"

现在进到翻译官最累的那间屋: createRequest(prompt, stream) , :490-899 ,单⽅法四百⾏。它⼲两件⼤事。

第⼀件:把译馆的国书(Message)翻成 SDK 的消息参数( :492-685 ),按 MessageType 分⽀:

USER / SYSTEM(客⾔/训谕)→ ChatCompletionUserMessageParam ;如果 UserMessage 带了 media(图、⾳频、PDF),还要拆成⼀串
ChatCompletionContentPart (image URL / base64 / audio / pdf-file, :511-584 )。
ASSISTANT(馆答)→ ChatCompletionAssistantMessageParam ,把上⼀轮模型要求的 toolCalls 回填进去( :620-648 ),并透传
reasoning_content ( :653-658 )。
TOOL(差役回执)→ ChatCompletionToolMessageParam ,把每个 ToolResponse 映成 toolCallId + content ( :662-678 )。

其它类型?直接 throw IllegalArgumentException ( :681 )。不认识就翻脸,绝不静默。

第⼆件:把 OpenAiChatOptions 逐字段翻成请求参数( :687-825 )。这⼀段是清⼀⾊的 if (xxx != null) builder.xxx(...) :model、frequencyPenalty、
maxTokens/maxCompletionTokens、n、modalities/audio、responseFormat(text/json_object/json_schema, :741-773 )、temperature、topP、

reasoningEffort、verbosity、serviceTier、promptCacheKey、customHeaders……⼏⼗个字段,⼀个⼀个⼿翻。

⚠ 暗礁:四百⾏的巨型⽅法 + ⼀千⼀百多⾏的 Options 类

这⼀节我必须摘下"导览员"的帽⼦,说句不中听的。

createRequest 把"消息映射"和"选项映射"两件本可拆开的事,焊死在同⼀个四百⾏⽅法⾥( :490-899 )。任何想给某个分⽀加测试、或者改 USER 消息⾥ media拆分逻辑的⼈,都得先在四百⾏⾥找路。它不是写错了,它只是 胖得不健康。

更胖的是它的搭档 OpenAiChatOptions ——⼀千⼀百⼋⼗七⾏。罪魁在于它把三种性质完全不同的东⻄塞进了同⼀个类:

可移植层:model/temperature/topP/maxTokens……这些是任何番邦都该有的通⽤条款。

OpenAI 专属层:logitBias/logprobs/reasoningEffort/serviceTier/extraBody……这些是 OpenAI 私货。

连接/传输层:baseUrl/apiKey/timeout/maxRetries/proxy,外加 Azure Foundry / GitHub Models 的开关( OpenAiChatOptions.java:65-128 )。

把"我要 0.7 的温度"和"我的 API key 是什么、超时⼏秒、连哪个代理"放进同⼀个对象,在概念上是混淆的——前者是 每次请求 都可能变的业务参数,后者是 建连⼀次 就固定的基础设施配置。它们的⽣命周期根本不同。这个胖类还得⼿写⼀个同样冗⻓的 combineWith ( :1033-1170 ,满屏 if (that.x != null) this.x =that.x ),每加⼀个新字段,你都得记得在 combineWith 和 mutate 两处各补⼀⾏,漏⼀个就是⼀个静默 bug。这是典型的"上帝对象"味道。

那么,既然 buildRequestPrompt 不做 merge,真正的"运⾏时选项覆盖默认选项"在哪发⽣?答案就是 OpenAiChatOptions 的 combineWith ( :1033-1170 ): super.combineWith(other) 先处理通⽤字段,然后对每个专属字段做覆盖;其中

logitBias / outputModalities / metadata / extraBody / customHeaders 是 map/list 合并(putAll/addAll),其余是 直接覆盖。⽽这套 combineWith 是 由

上游的 ChatClient /Advisor 链调⽤的,不是 OpenAiChatModel ⾃⼰调的。

🦴 ⻣灰级细节:Chat 把合并"上交"了,Embedding/Image 却没有——同⼀个模块⾥两套范式

这是本章我最想让你记住的⼀处"范式裂缝"。

OpenAiChatModel ⾃⼰不 merge 选项,把这活上交给了 ChatClient。但你转头去看同模块的兄弟:

// OpenAiEmbeddingModel.call⾥:

OpenAiEmbeddingOptions requestOptions = OpenAiEmbeddingOptions.builder()
.from(this.options)
.merge(request.getOptions())
.build();
OpenAiEmbeddingModel.java:220-223 ( OpenAiImageModel.java:167-170 同理)
Embedding 和 Image 仍然在 model 内部 from().merge() 。也就是说,在 同⼀个 OpenAI 模块 ⾥:

Chat 模型:"合并我不管,交给 ChatClient。"

Embedding/Image 模型:"合并我⾃⼰来。"

这不是⼤ bug,但它是⼀道⼼智裂缝。⼀个维护者读完 Chat 以为"哦,provider 都不 merge 了",转⼿改 Embedding 就会懵。范式不统⼀,⽐范式落后更折磨⼈——因为你以为你懂了。这道裂缝⼤概率是历史演进的残留:Chat 因为⼯具循环上移、被顺⼿重构得更"薄",⽽ Embedding/Image 还没轮到。但残留就是残留,值得点名。

四、stream():⼀台叫 ChunkMerger 的脆弱状态机同步路看完,流式路才是翻译官真正的"私货"所在。 stream() 的⼊⼝和 call() 对称( :254-259 ),活全在 internalStream ( :267-353 )⾥。我挑三处硬核的讲。

第⼀处:延迟到订阅 + ⼿⼯接线 observation ⽗⼦关系。

return Flux.deferContextual(contextView -> {
ChatCompletionCreateParams request = createRequest(prompt, true);

// ...

Observation parentObservation = contextView.getOrDefault(ObservationThreadLocalAccessor.KEY, null);
observation.parentObservation(parentObservation);
try (Observation.Scope ignored = parentObservation != null ? parentObservation.openScope()
: Observation.Scope.NOOP) {
observation.start();
}

OpenAiChatModel.java:268-290Flux.deferContextual 把 createRequest 推迟到真正订阅时才执⾏。更讲究的是那段注释解释的事:它从 reactor 的 contextView ⾥捞出 parentObservation,短暂地把⽗ observation 设为 current,只为了在这⼀瞬间 observation.start() ,这样 Micrometer tracing 推导出的 span ⽗级才是对的,⽽不是当前线程上恰好开着的那个 scope(⽐如外层的 HTTP servlet span)。⼀句话:在 reactor 异步世界⾥,trace 的⽗⼦关系不会⾃动续上,得⼿⼯接线。 这是流式可观测性⾥最容易翻⻋、也最少⼈讲透的⼀个坑,Spring AI 这⾥处理得相当克制专业。

第⼆处:把 SDK 的异步流桥成 Flux。

Flux<ChatCompletionChunk> chunks = Flux.<ChatCompletionChunk>create(sink -> this.openAiClientAsync.chat()
.completions()
.createStreaming(request)
.subscribe(sink::next)
.onCompleteFuture()
.whenComplete((unused, throwable) -> {
if (throwable != null) { sink.error(throwable); }
else { sink.complete(); }
}));

OpenAiChatModel.java:293-305⾆⼈(异步客户端)吐的是它⾃家的 AsyncStreamResponse ,Spring AI ⽤ Flux.create 把它⼀⽚⽚喂进 reactor 的 sink 。这⼀步是纯粹的"胶⽔",但必须有——因为译馆对外承诺的是 Flux<ChatResponse> ,你不能让客官去学 SDK 的异步流 API。

第三处,也是翻译官全身最硬核、也最脆弱的私货—— ChunkMerger 。

OpenAI 流式返回的是⼀⽚⽚ ChatCompletionChunk 。普通⽂本好办,⼀⽚⽚拼起来就是。但 ⼯具调⽤ 麻烦:模型决定调⼀个⼯具时,它的 arguments (JSON 字符串)是 跨多个 chunk 分⽚送达 的。你必须有⼀台状态机,把散落在多个 chunk ⾥的 arguments 碎⽚重新拼成⼀个完整的 JSON。这台状态机,SDK 不提供,得

provider ⾃⼰写。它就藏在⽂件底部的内部静态类 ChunkMerger ( :1034-1172 )⾥。

聚合的触发逻辑在主流⾥:

AtomicBoolean isInsideTool = new AtomicBoolean(false);
Flux<ChatCompletion> aggregatedChatCompletions = chunks.doOnNext(chunk -> {
if (ChunkMerger.hasToolCall(chunk)) { isInsideTool.set(true); }
}).bufferUntil(chunk -> {
if (isInsideTool.get() && ChunkMerger.toolCallsDone(chunk)) {
isInsideTool.set(false);
return true;
}
return !isInsideTool.get();
}).map(ChunkMerger::mergeChunks).map(ChunkMerger::chunkToChatCompletion);

OpenAiChatModel.java:308-319

读法是这样: doOnNext ⼀旦嗅到某 chunk 带 tool_call,就把 isInsideTool 拉起; bufferUntil 进⼊"攒着不发"模式,直到嗅到 toolCallsDone (finishReason

是 TOOL_CALLS)才收⼝,把这⼀串攒着的 chunk mergeChunks 合并、再 chunkToChatCompletion 转回完整回函。普通⽂本则不进缓冲,逐⽚放⾏。

真正拼 arguments 的活在 mergeDeltas :

private static Delta mergeDeltas(Delta left, Delta right) {
var tcs = Stream.of(left.toolCalls(), right.toolCalls()).flatMap(Optional::stream).reduce((tcs1, tcs2) -> {
Assert.isTrue(tcs2.size() <= 1, "no more than one tool call per message currently supported");
ToolCall toolCall = tcs2.get(0);
if (toolCall.id().isPresent()) {

//新⼯具调⽤:id出现了,开⼀条新记录

List<ToolCall> result = new ArrayList<>(tcs1);
result.add(toolCall);
return result;
}
else {

//没 id:是上⼀条⼯具调⽤的 arguments续⽚,拼接字符串

ToolCall lastFromTc1 = tcs1.get(tcs1.size() - 1);
Function lastFromTc1F = lastFromTc1.function().get();
var concatenatedArgs = Stream
.of(lastFromTc1F.arguments(), toolCall.function().flatMap(Function::arguments))
.flatMap(Optional::stream)
.reduce((args1, args2) -> args1 + args2)
.orElse("");

// ...⽤拼好的 args重建这条 toolCall...

}
}).orElse(List.of());
return left.toBuilder().toolCalls(tcs).build();
}
OpenAiChatModel.java:1070-1100 (为可读性加了⾏内中⽂注释)

机制本身很聪明:有 id 就是新⼯具,没 id 就是上⼀条的 arguments 续⽚,字符串⾸尾相接。 但请你死死盯住第 1072 ⾏那个断⾔:

Assert.isTrue(tcs2.size() <= 1, "no more than one tool call per message currently supported");

⚠ 暗礁:ChunkMerger 硬性假设"⼀条 message 最多⼀个 tool call"

这是⼀个 硬编码的能⼒上限。流式场景下,这台状态机假设每条 message ⾄多携带⼀个⼯具调⽤——⼀旦 OpenAI 在⼀⽚流⾥同时吐回多个并⾏⼯具调⽤,这个Assert 会直接抛异常,把整条流打断。

更要命的是 chunkToChatCompletion ( :1105-1157 )⾥那⼏个 .get() : tc.id().get() 、
tc.function().get().name().get() 、 ...arguments().get() ——⼀旦某个字段在某⽚ chunk ⾥恰好缺席, Optional.get() 就是⼀个

NoSuchElementException 。整台状态机建⽴在"OpenAI 的 chunk ⼀定按我设想的顺序、形状到达"这个乐观假设上。

我把话说⽩:这是"雇官⽅⾆⼈"这笔买卖⾥,翻译官替你咽下的最苦的⼀⼝胶⽔税。 SDK 只给你最原始的 chunk 流,把"拼⼯具调⽤"这台脆弱状态机的全部责任,甩回给了 provider。 ChunkMerger 写得不能算差,但它是整个 OpenAI 模块⾥我最不敢在⽣产⾥压重负载的⼀块——并发流式 + 多⼯具调⽤,是它的盲区。如果你的业务严重依赖流式⼯具调⽤,这⾥值得你亲⾃压测⼀遍,⽽不是相信"官⽅ SDK 总该靠谱"。

流的收尾很⼲净: doOnError(observation::error) + doFinally(observation::stop) ,最后⽤ new MessageAggregator().aggregate(...) ( :350 )把流

式碎⽚聚合成⼀份完整 response,只为喂给账房记账——客官那边照样是逐⽚到达的。

五、差役调度房,已经搬⾛了——⼀个过时传说的破灭现在回收第⼆节那个伏笔: internalCall 那个 previousChatResponse 参数,是不是意味着⼯具循环会递归?

我们先看翻译官⼿⾥那个 ToolCallingManager (差役调度房)。它接⼝上有两件法宝( ToolCallingManager.java:36, 41 ): resolveToolDefinitions (把差役

解析成定义,塞进请求)和 executeToolCalls (真正派差办差、把回执回灌对话)。

然后我们在整个 OpenAiChatModel ⾥ grep 这两个⽅法:

resolveToolDefinitions ——⽤了,在 createRequest ⾥( :855 ),只为把⼯具声明塞进请求的 tools 参数。

executeToolCalls ——零命中。 internalToolExecutionEnabled 、⼯具回路、⾃我递归——全部零命中。

那个 previousChatResponse ?它从头到尾 只⽤于 usage 累计( :241-242 ), call() 传的就是 null ,根本没有谁递归地把上⼀轮 response 喂回来。第⼆节那个"会不会递归"的伏笔,答案是:不会。

铁证在 builder 上:

/**

* @deprecated since 2.0.0 for removal in 3.0.0 — internal tool execution in

* {@link OpenAiChatModel} is superseded by {@code ToolCallingAdvisor} used via
* {@code ChatClient}.

*/

@Deprecated(since = "2.0.0", forRemoval = true)
public Builder toolCallingManager(ToolCallingManager toolCallingManager) {
this.toolCallingManager = toolCallingManager;
return this;
}

OpenAiChatModel.java:1317-1329注释把话说死了:OpenAiChatModel ⾥的内部⼯具执⾏,已被 ChatClient ⾥的 ToolCallingAdvisor 取代。 这正是卷四讲过的那场⼤搬迁——⼯具执⾏循环(调模型 → 模型申请派差 → 执⾏⼯具 → 把回执回灌 → 再调模型),整套从 model 层上移到了 ChatClient 的⼀名驿丞 ⼿⾥。翻译官如今只剩两件事:声明⼯具(resolveToolDefinitions),以及 把回函⾥的 toolCall 解析成 AssistantMessage.ToolCall 。派差、办差、回灌、再请——他⼀概不管。

提醒⼀句隐喻边界:"差役调度房"这个名字在本版 OpenAI provider ⾥已经名不副实了。这⾥持有的 ToolCallingManager 只剩"差役名录登记处"的功能(解析定义),真正的"调度"(派差办差)早搬去了 ChatClient。读到这个字段时别被名字骗了。

🦴 ⻣灰级细节:Anthropic 翻译官的注释,撒了个"善意的谎"

如果你以为只有 OpenAI 这么⼲,去看隔壁的 Anthropic 翻译官,会撞⻅⼀桩更有意思的事。 AnthropicChatModel ⾄今仍持有 toolCallingManager 字段

( AnthropicChatModel.java:174 ),它的 internalCall ⽅法 javadoc 还⽩纸⿊字写着 "called recursively to support multi-turn tool calling"( :522-523 )

——"为⽀持多轮⼯具调⽤⽽被递归调⽤"。

可你翻开⽅法体( :531-579 ),它跟 OpenAI ⼀样,根本没有真正调 executeToolCalls , previousChatResponse 同样只⽤于 usage 累计。也就是说:两家翻译官的⼯具循环都已经上移,但 Anthropic 那条"recursively"的注释,是⼀句没⼈删的过时⽂档。 它会精准地误导每⼀个相信注释的读者,让⼈以为 model 层还在跑⼯具循环。

这件事的教训很硬核:读源码时,代码是真相,注释只是某个时刻的传说。 当注释和⽅法体打架,信⽅法体。Spring AI 这么成熟的项⽬尚且有这种残留,何况别处。这也是我在本章⼀路坚持"回源核实每个 file:line"的原因——光读注释,你会被这句"recursively"带进沟⾥。

六、retry 与 observation:⼀个往下沉,⼀个往两头铺最后看两件横切关注点,它们的⾛向恰好相反,各⾃漂亮。

retry,整个往下沉了。 ChatModel 内 没有 RetryTemplate 、没有 @Retryable (1.x 那套全废了)。重试责任沉到两层:⼀层是 SDK 客户端⾃⼰的

ClientOptions.builder().maxRetries(maxRetries) ( OpenAiSetup.java:171 ), maxRetries 从 OpenAiChatOptions.getMaxRetries() ⼀路透传下去;
另⼀层在更底的 OkHttp, retryOnConnectionFailure(true) ,专治 stale 连接。这层的注释特意澄清( SpringAiOpenAiHttpClient.java:534-535 ):它 不和

SDK 的状态码重试重复(⻅ gh-6318)。两层重试,职责切得很清,不打架。timeout 也是同样的下沉路数。

observation,往两头铺成了双层。

业务级(上层):就是第⼆、四节⾥那个包住 internalCall 的 CHAT_MODEL_OPERATION ,记的是"这是⼀次 chat 调⽤、什么 provider、什么 prompt、什么response"——业务语义。

HTTP 传输级(下层): SpringAiOpenAiHttpClient 给 OkHttp 装了 OkHttpObservationInterceptor ,observation 名叫
okhttp.requests ( SpringAiOpenAiHttpClient.java:404, 543 ),每次 HTTP 尝试都出⼀条 span/metric;有 MeterRegistry 时还顺⼿绑上
OkHttpConnectionPoolMetrics ( :597 ),连连接池都给你监起来。

这个 SpringAiOpenAiHttpClient 本身就是⼀处⽤⼼:类注释( :75-80 )明说,Spring AI 不得不 ⾃⼰写这个 HttpClient 实现,因为 OpenAI SDK ⾃带的

OpenAiOkHttpClient.Builder 不开放 interceptor 接⼝(没有 "interceptor seam"),装不进 Micrometer。所以这个类的绝⼤部分是官⽅ SDK okhttp client 的
Java 移植,唯⼀原创的就是那段 Micrometer 接线。连跨线程的 context 传播都照顾到了——dispatcher 的 executor ⽤ ContextExecutorService.wrap(...)

( :566 )包了⼀层,保证 observation 上下⽂不会在线程切换时丢失。

✨ 这是"雇官⽅⾆⼈"这笔买卖⾥,翻译官替你挣回的那⼝⽓:为了把可观测性塞进⼀个不让你塞 interceptor 的 SDK,Spring AI 宁可把 SDK 的 OkHttp 客户端整个移

植⼀遍,只为留出⼀个钩⼦。胶⽔税收得苦,但该补的可观测性,⼀分没省。

七、加⼀个新番邦翻译官:照着 OpenAI 抄的配⽅把这⼀章的解剖反过来,就是⼀张"加新 provider"的配⽅。如果哪天你要给译馆接⼀个新番邦,照这个抄:

1. 写 XxxChatModel implements ChatModel ,构造注⼊ SDK client + Options + ObservationRegistry(⼯具循环既然上移了, ToolCallingManager 能不要

就别要)。

2. call() = buildRequestPrompt (兜底默认)→ createRequest (Prompt→SDK 请求)→ .observe(...) 包裹 → ⼀⾏调 SDK → 回函转
ChatResponse ( buildGeneration + UsageCalculator + ChatResponseMetadata )。
3. stream() = deferContextual + SDK 异步流桥成 Flux + tool-chunk 聚合 + observation ⼿⼯接线 + MessageAggregator 收尾。
4. XxxChatOptions implements ToolCallingChatOptions ,继承 DefaultToolCallingChatOptions.Builder ,实现 combineWith (合并)/ mutate (拷

⻉)/ build 。别学 OpenAI 把连接参数也塞进来——这是它的胖,不是它的优点。

5. ⼯具:只 resolveToolDefinitions 塞请求,执⾏循环交给 ChatClient 的 ToolCallingAdvisor ,不要再在 model 层⼿写循环。

6. retry / timeout / 连接池 / HTTP observation 尽量下沉到 HTTP/SDK 层。

✨ ⽽这套配⽅最爽的回报,是 OpenAI 模块附送的⼀个"买⼀送三":由 OpenAiSetup.detectModelProvider ( OpenAiSetup.java:266-290 )按 baseUrl/env ⾃动
判别后端,同⼀套类,同时能服务 OpenAI 官⽅、Azure Microsoft Foundry、GitHub Models;再配上 extraBody / customHeaders 透传

( OpenAiChatModel.java:889-896 ),连 vLLM、Ollama、Groq 这些"说 OpenAI ⽅⾔"的兼容⼚商也⼀并能喂。⼀名翻译官,通吃半个江湖——这是"OpenAI 兼容协议"事实标准化之后,Spring AI 顺势薅到的最⼤⼀笔红利。

🎯 三句带⾛

1. 本版 OpenAiChatModel 是封装官⽅ OpenAI Java SDK( com.openai.* )的"翻译胶⽔",已⽆⼿写 OpenAiApi ; call() = buildRequestPrompt (只兜底
不深 merge)→ createRequest → .observe() 包裹直调 openAiClient.chat().completions().create(request) → 回函转 ChatResponse 。

2. 流式⼯具调⽤要 provider ⾃写 ChunkMerger 状态机拼跨 chunk 的 arguments ,硬性假设"⼀条 message 最多⼀个 tool call"

( OpenAiChatModel.java:1072 ),并发多⼯具场景脆弱;⼯具执⾏循环已上移到 ChatClient 的 ToolCallingAdvisor , builder.toolCallingManager 已

@Deprecated 。

3. retry/timeout 下沉到 SDK( maxRetries )+ OkHttp;observation 分业务级 + HTTP 级双层;须留意三处糙边: OpenAiChatOptions 1187 ⾏混了连接参数、

createRequest 400 ⾏巨型⽅法、Chat 与 Embedding/Image 的 merge 范式不统⼀。

承上启下这⼀章,我们看清了⼀名翻译官如何独⾃把客官的国书翻给⼀个番邦。但译馆的野⼼不⽌于"⼀对⼀通译"——它还想让 不同译馆之间互认对⽅的差役:你这座馆招募的跑腿,我那座馆也能直接差遣。这就需要⼀套跨馆的邦交协议,⼀个真正的 通商⼝岸。下⼀章,我们⾛进「第⼗四章 · 通商⼝岸:MCP 集成」,看 Spring AI 如何⽤Model Context Protocol,把孤岛般的各家⼯具,接成⼀张可以互通有⽆的⽹。