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

第三章 · 通用条款与番邦私货:ChatOptions 与流式契约

下一章
字体

主题

版式

8,705 字 · 约 22 分钟

第三章 · 通⽤条款与番邦私货:ChatOptions 与流式契约

"我只认⼋个字段。你那些花⾥胡哨的番邦条款,我不拦,但也不管——⾃⼰另起⼀张副契去。" —— ChatOptions 的⾃⽩上⼀章我们看完了"国书"如何成形:四种封印的信函、装进⼀只 Prompt 的信筒。可信筒⾥除了信,还塞了⼀张折叠的⼩纸条—— @Nullable ChatOptionschatOptions ( spring-ai-model/.../chat/prompt/Prompt.java:50 )。这张纸条不写"说什么",只写"怎么说":要不要发散⼀点(temperature)、最多写⼏个字(maxTokens)、写到哪⼏个词就收笔(stopSequences)。

这就是本章的主⻆。⼀座要给天下番邦做通译的译馆,最棘⼿的事不是翻译内容,⽽是翻译"规矩"——OpenAI 的温度旋钮、Anthropic 的停⽌词、Ollama 的采样参数,名字相近、语义微妙、各有各的私货。译馆怎么⽤⼀张⽂牒把它们统⼀起来,⼜不把番邦的特⾊阉割掉?

答案是⼀份"双轨契约":正⾯印着所有番邦都认的通⽤条款,背⾯留着各家⾃填的私货槽。我们⼀条⼀条拆。

⼀、⼋条通⽤条款:薄得近乎吝啬先看正契。 ChatOptions 是个接⼝,继承⾃更上层的空标记接⼝ ModelOptions ,Javadoc ⼀句话定了调⼦——"common options that are portable acrossdifferent chat models"( chat/prompt/ChatOptions.java:25-28 )。可移植,是它唯⼀的信仰。

public interface ChatOptions extends ModelOptions {
@Nullable String getModel();
@Nullable Double getFrequencyPenalty();
@Nullable Integer getMaxTokens();
@Nullable Double getPresencePenalty();
@Nullable List<String> getStopSequences();
@Nullable Double getTemperature();
@Nullable Integer getTopK();
@Nullable Double getTopP();

// ...

}

( chat/prompt/ChatOptions.java:29-77 )

数⼀数,⼋个 getter,全部 @Nullable 。这是⼀份吝啬到近乎洁癖的契约:它只收录那些"⼏乎每个主流 LLM 都有、且语义⾜够⼀致"的旋钮——选哪个模型、随机性(temperature/topP/topK)、⻓度上限(maxTokens)、重复惩罚(frequency/presence penalty)、停⽌词(stopSequences)。仅此⽽已。

为什么全 @Nullable ?因为这⼋条不是"默认值表",⽽是"覆盖意向表"。 null 在这⾥的含义不是"零",⽽是"我没意⻅,按番邦⾃⼰的默认来"。客官只填⾃⼰在乎的那⼀两个旋钮,其余留空,译馆和番邦各⾃⼼照不宣。这个约定后⾯合并语义那⼀节会反复⽤到,先记住:这⾥的 null = "弃权",不是"清零"。

✨ 爽点时刻:正因为薄,所以稳。你今天对着 OpenAI 写的 temperature(0.7).maxTokens(500) ,明天原样搬到 Anthropic、Ollama 上,⼀个字都不⽤改——这⼋条

就是译馆承诺的"⼀纸⽂牒⾛天下"的实体。可移植性不是⼝号,它就是这⼋个字段的交集。

⼆、⾃递归泛型 Builder:让番邦⼦类链式调⽤不"掉档"

抽象薄是好事,但番邦的私货怎么办?OpenAI 还想填 logitBias 、 seed 、 responseFormat ……这些通⽤契约⾥没有。译馆的解法是:让你继承我的 Builder,加你⾃⼰的 setter。

可继承 Builder 有个经典陷阱。假设 OpenAiChatOptions.Builder 继承⾃基类 Builder,你写:

new OpenAiChatOptions.Builder()
.temperature(0.7) //基类⽅法,返回的是基类 Builder!
.seed(42) //编译错误:基类 Builder上没有 seed()

temperature() 定义在⽗类,返回类型写死成⽗类 Builder,链⼀调⽤就"掉档"成⽗类型,⼦类的 seed() ⽴刻丢失。Spring AI ⽤ CRTP(Curiously RecurringTemplate Pattern,⾃递归泛型) 根治了这个问题:

interface Builder<B extends Builder<B>> extends Cloneable {
B clone();
B model(@Nullable String model);
B frequencyPenalty(@Nullable Double frequencyPenalty);

// ...⼋个 setter全部返回 B,⽽⾮写死的某个具体类型

ChatOptions build();
B combineWith(ChatOptions.Builder<?> other);
}

( chat/prompt/ChatOptions.java:99-171 )

注意那个 Builder<B extends Builder<B>> ——类型参数 B 把⾃⼰递归地约束成"⾃⼰的⼦类型"。每个 setter 不再返回某个固定类型,⽽是返回 B 。⼦类声明时把 B 钉死成⾃⼰,链式调⽤全程类型就不会塌陷成⽗类。

默认实现把这套机制落地得很直⽩:

public class DefaultChatOptionsBuilder<B extends DefaultChatOptionsBuilder<B>>
implements ChatOptions.Builder<B> {
@SuppressWarnings("unchecked")
protected B self() {
return (B) this;
}

@Override

public B temperature(@Nullable Double temperature) {
this.temperature = temperature;

return self(); //永远返回"最具体的我"

}

// ...

}

( chat/prompt/DefaultChatOptionsBuilder.java:27, 60-63, 100-104 )

那个 self() 是整套魔法的开关:⼀次 (B) this 的强转,把"当前实例"重新打扮成它的最具体类型 B 再返回。这就是为什么 ChatOptions.mutate() 的Javadoc 会专⻔叮嘱实现者——"Concrete ChatOptions classes must implement this and return the most concrete builder implementation"

( chat/prompt/ChatOptions.java:83-84 )。⼦类必须⽼实交出"最具体的那只 Builder",链条才不会断。

🦴 ⻣灰级细节: self() 上挂着 @SuppressWarnings("unchecked") ( DefaultChatOptionsBuilder.java:60 )。这不是程序员偷懒,⽽是 CRTP 的"原罪"——

(B) this 是个⽆法被编译器证明安全的强转。它的正确性靠⼀条不成⽂的纪律守着:每个⼦类声明时,必须把 B 钉成⾃⼰( classOpenAiChatOptions.Builder<B extends ...Builder<B>> )。⼀旦哪个⼦类图省事写错了泛型参数,这个强转就会在运⾏时变成⼀颗 ClassCastException 的定时炸弹,⽽编译期⼀声不吭。这就是 CRTP ⽤类型安全换链式优雅时,悄悄塞进合同的那⾏⼩字。

再看 clone() 这个容易被忽略的细节——它不是 super.clone() 完事就算,⽽是单独把 stopSequences 拎出来做了⼀次深拷⻉

( DefaultChatOptionsBuilder.java:49-58 ):

@Override

public B clone() {
try {
B copy = (B) super.clone();
copy.stopSequences = this.stopSequences == null ? null : new ArrayList<>(this.stopSequences);
return copy;
}
catch (CloneNotSupportedException e) {
throw new RuntimeException(e);
}
}

super.clone() 是浅拷⻉,会让新旧两个 Builder 共享同⼀个 stopSequences 列表引⽤;这⾥特意 new ArrayList<>(...) 切断引⽤,防⽌你 clone 出⼀份"副本"后往⾥ add ⼀个停⽌词,结果把原件也污染了。⼋个字段⾥七个是不可变的 String/Double/Integer ,唯独 List<String> 是可变的——所以唯独它需要这道额外的防护。能精准识别出"只有这⼀个字段可变"并单独处理,是写过线程安全集合库的⼈才有的直觉。

三、combineWith:三套合并语义,藏在⼀个⽅法⾥的暗礁现在到了本章最值得细看、也最值得吐槽的地⽅:运⾏时合并。

场景是这样的:你起馆时给 ChatModel 配了⼀份"默认 options"(⽐如全局 temperature(0.3) );某次具体请求,你⼜在 Prompt ⾥临时塞了⼀份options( maxTokens(2000) )。两份 options 怎么合成最终发给番邦的那⼀份?这就是 combineWith ⼲的活:

@Override

public B combineWith(ChatOptions.Builder<?> other) {
if (other instanceof DefaultChatOptionsBuilder<?> that) {
if (that.model != null) {
this.model = that.model;
}

// ... frequencyPenalty / maxTokens / presencePenalty同款:⾮ null才覆盖

if (that.stopSequences != null) {
if (this.stopSequences == null) {
this.stopSequences = new ArrayList<>(that.stopSequences);
}
else {
List<String> merged = new ArrayList<>(this.stopSequences);
merged.addAll(that.stopSequences); //注意:追加,不是覆盖!
this.stopSequences = merged;
}
}

// ... temperature / topK / topP⼜回到:⾮ null才覆盖

}
return self();
}

( chat/prompt/DefaultChatOptionsBuilder.java:118-154 )

接⼝ Javadoc 把意图写得很美:"take all other 's values that are non-null, retaining this other values"( ChatOptions.java:165-168 )——拿 other 的⾮空

值,留 this 的其余值。这正好兑现了第⼀节那个约定: null = 弃权,谁有意⻅听谁的。七个标量字段都⽼⽼实实照此执⾏。

但 stopSequences 是个例外。它不覆盖,⽽是追加( :138-140 那个 merged.addAll )。设计者的考量不难猜:停⽌词是个集合,默认配的停⽌词("END")和运⾏时加的停⽌词("STOP")理应都⽣效,⽽不是后者把前者顶掉。从语义上讲,这个特例是合理的。

⚠ 暗礁:问题不在"追加合理不合理",⽽在于同⼀个⽅法、同⼀份契约⾥,藏着两套互相⽭盾的合并范式,且没有任何接⼝层⾯的标注来提醒你。七个字段是"覆盖",⼀个字

段是"追加",全靠你逐⾏读实现才能发现。Javadoc 那句"take all non-null values"甚⾄在字⾯上误导了你——它描述的是覆盖语义,却对那个偷偷追加的stopSequences 只字未提。更要命的是,这种"按字段各⾏其是"的合并,调⽤⽅根本⽆从在编译期察觉:你以为运⾏时传⼀组新停⽌词会替换掉默认的,结果它们叠在了⼀起,模型在你意想不到的地⽅收了笔。

这还只是第⼀道暗礁。第⼆道在下⼀节。

四、ToolCallingChatOptions:私货槽的"样板间",和第三套合并规则通⽤契约只管⼋个旋钮,那"差役"(⼯具调⽤)这种能⼒维度的参数往哪放?Spring AI 给出的答案很优雅:不旁路,仍⾛ options 体系——开⼀个⼦接⼝ToolCallingChatOptions extends ChatOptions ,把⼯具相关的两样东⻄挂上去:

public interface ToolCallingChatOptions extends ChatOptions {
@Nullable List<ToolCallback> getToolCallbacks();
@Nullable Map<String, Object> getToolContext();

@Override

ToolCallingChatOptions.Builder<?> mutate(); //协变窄化,⻅下

// ...

}

( model/tool/ToolCallingChatOptions.java:39-60 )

这⾥有个值得点出的⼩⼿艺: mutate() ⽤ @Override 协变窄化了返回类型——⽗接⼝返回 ChatOptions.Builder<?> ,⼦接⼝窄化成

ToolCallingChatOptions.Builder<?> ( :59-60 )。Javadoc 直说了⽬的:"so generic tool calling code can chain methods without casting"。配合上⼀节的

CRTP,处理⼯具的通⽤代码拿到 mutate() 就能直接 .toolCallbacks(...) ,不必先强转。这是"可移植契约之上叠加能⼒维度"的范式样板——能⼒扩展和参数扩展⾛的是同⼀条路。

但真正要看的是这个接⼝上挂的三个静态⼯具⽅法。它们才是各 provider 在合并 options 时实际会调⽤的"官⽅裁判":

static @Nullable List<ToolCallback> mergeToolCallbacks(
@Nullable List<ToolCallback> runtimeToolCallbacks,
@Nullable List<ToolCallback> defaultToolCallbacks) {
if (CollectionUtils.isEmpty(runtimeToolCallbacks)) {
return defaultToolCallbacks != null ? List.copyOf(defaultToolCallbacks) : null;
}
return List.copyOf(runtimeToolCallbacks); // runtime⾮空 ->整体替换 default
}

( model/tool/ToolCallingChatOptions.java:69-75 )

看清楚这第三套语义:runtime ⼀旦⾮空,就整体替换 default,绝不合并。这和上⼀节 stopSequences 的"追加"截然相反,也和七个标量的"⾮ null 才覆盖"貌合神离(单字段覆盖 vs 整个列表替换)。同⼀个项⽬、同⼀层抽象,⼯具回调⽤"整体替换",停⽌词⽤"追加",温度⽤"字段覆盖"——三套合并规则,三种⼼智模型。

⽽ mergeToolContext ⼜是第四种:它做的是 Map 级合并——先铺 default,再⽤ runtime putAll 覆盖同名键( :77-90 ),既不整体替换也不简单追加,⽽是逐键覆盖。四个维度,四种合法但互不相同的合并姿势。

static void validateToolCallbacks(@Nullable List<ToolCallback> toolCallbacks) {
if (CollectionUtils.isEmpty(toolCallbacks)) {
return;
}
List<String> duplicateToolNames = ToolUtils.getDuplicateToolNames(toolCallbacks);
if (!duplicateToolNames.isEmpty()) {
throw new IllegalStateException("Multiple tools with the same name (%s) found ..."
.formatted(String.join(", ", duplicateToolNames)));
}
}

( model/tool/ToolCallingChatOptions.java:92-101 )

validateToolCallbacks 是个加分项:同名⼯具直接抛 IllegalStateException ,把"两个差役重名、模型不知道派谁"这种隐患挡在调⽤之前。

⚠ 第⼆道暗礁(也是本章最该被批评的⼀处):这四个 merge* 都是静态⽅法,不是接⼝契约。抽象层根本不强制任何 provider 去调⽤它们,更不强制它们按统⼀规则合

并。 ChatModel 接⼝本身(我们上⼀章⻅过)只默认 getOptions() 返回⼀份空 options( chat/model/ChatModel.java:52-54 ),对"默认 options 与 Prompt

options 如何合并"⼀字未提。也就是说,合并这件⾼频、易错、语义⼜分裂的事,Spring AI 把它从接⼝契约⾥漏掉了——它只在 combineWith 和这⼏个静态 helper⾥"提供了⼯具",却没有"建⽴法律"。每个 provider 的 createRequest 都得⾃⼰挑⼯具、⾃⼰拼合并逻辑,理论上完全可能两家拼出不⼀致的⾏为。这是可移植抽象的⼀道真实裂缝:横向(跨字段)和纵向(跨 provider)的合并语义,都缺⼀个单⼀权威⼊⼝。 对⼀个把"portable"刻在 DNA ⾥的框架来说,这块短板格外刺眼。

五、流式契约:⼀个函数式接⼝,和⼀句"优雅认怂"

把"怎么说"讲完,本章最后看⼀眼"怎么收"——同步⼀次性拿回 vs 流式逐字吐出。流式契约简单得出奇:

@FunctionalInterface

public interface StreamingChatModel extends StreamingModel<Prompt, ChatResponse> {
default Flux<String> stream(String message) { /*包成 Prompt,逐块取⽂本 */ }
default Flux<String> stream(Message... messages) { /*同上 */ }