数据截至 (上游 commit daa7624a2755)
核心抽象层:模型接口、消息模型与 100+ 集成如何共存
30 秒导读: LangChain4j 用同一套 Java 类型接住了 23 个模型厂商模块和 21 个向量库模块。 本章只讲
langchain4j-core里的类型骨架——它凭什么能做到"换厂商只换一行 builder"。
1. 这一章要回答的问题
先把问题摆清楚,再看答案才有味道。
表面需求: 用户想写一次代码,底下随便换 OpenAI、Anthropic、Ollama、Bedrock。
真正的难点不在"换",在"厂商各有各的私货":
| 冲突 | 具体表现 |
|---|---|
| 参数不通用 | OpenAI 有 seed/logitBias/reasoningEffort,Anthropic 没有;Anthropic 有 topK,OpenAI 直接不支持 |
| 能力不对齐 | 有的模型支持 JSON Schema 严格输出,有的只支持"尽量 JSON" |
| 输入形态不同 | 有的能吃图片和 PDF,有的只能吃纯文本 |
| 依赖打架 | 每个厂商 SDK 都拖一堆传递依赖,全塞一个 jar 里必炸 |
一个常见的错误解法: 把所有厂商参数并进一个巨大的 ChatRequest,字段越加越多,谁都用不干净。
LangChain4j 没走这条路。它的答案分三层,下一节先给全景。
本章不讲:AiServices 怎么把 Java 接口变成一次调用(见 02-ai-services)、
工具调用循环(见 03-tool-calling)、结构化输出与护栏(见
04-structured-output-and-guardrails)、
RAG 流水线(见 05-rag)、多智能体编排(见 06-agentic)。
2. 顶层全景:三根支柱
先看物理结构。怎么读这张图:自上而下是依赖方向,箭头指向"我依赖谁";横向的兄弟模块互不相识。
上层玩法 AiServices / RAG / agentic ← 都在别的章
(langchain4j 等) │
│ 只依赖 core,不依赖任何厂商
▼
统一抽象 ┌───────────────────────────────────┐
(core) │ ChatModel ChatMessage Capability │
│ EmbeddingModel EmbeddingStore │
└───────────────┬───────────────────┘
│ 被实现
┌──────────────┬────────┴───────┬──────────────┐
▼ ▼ ▼ ▼
langchain4j- langchain4j- langchain4j- …共 23 个
open-ai anthropic ollama 厂商模块
└──────────────┴────────┬───────┴──────────────┘
│ 共用
▼
langchain4j-http-client(接口)
jdk / apache / okhttp(SPI 三选一)
依据:根 pom.xml:31-54 的 <!-- model providers --> 段共 23 个 module;
pom.xml:69-90 的 <!-- embedding / chat memory stores --> 段共 21 个;
pom.xml:26-29 是 http-client 的接口模块 + 三个实现模块。
支柱一句话概括:
| 支柱 | 干什么 | 在哪 |
|---|---|---|
| 模板方法 | 把"参数合并 + 监听器回调"收进接口 default 方法,厂商只写 doChat | ChatModel.java:47-72 |
| 两段合并 | overrideWith / defaultedBy 定义参数谁盖谁,让厂商私有字段随子类一起流过 | ChatRequestParameters.java:61-89 |
| 物理隔离 | 一厂商一 Maven 模块,core 里零厂商依赖;运行时靠 SPI 装载 | 根 pom.xml、spi/ServiceHelper.java |
用起来什么样。 先感受一下最朴素的调用:
// 示意,非源码:一次最朴素的调用
ChatModel model = OpenAiChatModel.builder()
.apiKey(System.getenv("OPENAI_API_KEY"))
.modelName("gpt-4o-mini")
.temperature(0.7) // 存成这个模型的"默认参数"
.build();
String answer = model.chat("用一句话解释什么是向量数据库");
重点看:chat(String) 不是 OpenAI 模块写的,是接口上的 default 方法(ChatModel.java:86-94)。
换成 AnthropicChatModel.builder(),这行代码一个字不用改。
3. 机制一:ChatModel 的模板方法
3.1 它要解决的小问题
"参数合并"和"监听器回调"这两件事,23 个厂商模块每家都要做一遍。
做重复了是浪费,做得不一致才是灾难——同样的 temperature,A 厂商覆盖了默认值,B 厂商忽略了。
3.2 思路:把公共动作焊死在接口里
Java 接口的 default 方法天然适合做模板方法(Template Method,父类定好流程骨架、
子类只填某一步)。LangChain4j 把骨架放在 chat(),把唯一的变化点留给 doChat()。
怎么读这张图:从上到下是一次调用的时间顺序,只有 ③ 需要厂商写代码。
调用方 chat(chatRequest)
│
├─ ① 合并参数:模型默认 .overrideWith(本次请求) → finalChatRequest
│
├─ ② onRequest(...) 通知所有监听器
│
├─ ③ doChat(finalChatRequest) ◄── 唯一交给厂商的一步
│
├─ ④ 成功 → onResponse(...)
│ 抛错 → onError(...) 之后原样再抛
▼
ChatResponse
3.3 真实实现
流程骨架就是 ChatModel.chat(ChatRequest, ChatRequestOptions) 这一个方法
(langchain4j-core/src/main/java/dev/langchain4j/model/chat/ChatModel.java:47-68)。
最关键的是合并那一行:
ChatRequest finalChatRequest = ChatRequest.builder()
.messages(chatRequest.messages())
.parameters(defaultRequestParameters().overrideWith(chatRequest.parameters()))
.build();
这行在 ChatModel.java:51-54。它说的是:以模型 builder 里存的默认参数打底,本次请求给的值往上盖。
盖完之后才进 doChat,所以厂商实现拿到的永远是"已经合并好的最终参数",不用自己再兜一遍默认值。
错误路径也被框住了:catch (Exception error) 里先 onError(...) 再 throw error
(ChatModel.java:64-67)——异常原样往外抛,监听器只是旁路观察,不吞不改。
3.4 接口一共只有这几个钩子
| 方法 | 默认行为 | 厂商要不要覆盖 |
|---|---|---|
doChat(ChatRequest) | 抛 RuntimeException("Not implemented") | 必须(ChatModel.java:70-72) |
defaultRequestParameters() | 返回 DefaultChatRequestParameters.EMPTY | 强烈建议(:74-76) |
listeners() | 返回 List.of() | 想支持监听器就覆盖(:78-80) |
provider() | 返回 ModelProvider.OTHER | 建议,用于监听器区分来源(:82-84) |
supportedCapabilities() | 返回 Set.of() | 支持 JSON Schema 时覆盖(:110-112) |
chat(String) / chat(ChatMessage...) / chat(List) | 包装成 ChatRequest 后转调 | 不用管(:86-108) |
这个表就是"接一个新厂商的最小成本清单"。 只实现第一行,模型就能跑了。
// 示意,非源码:接一个自家网关,只需要 doChat
class MyGatewayChatModel implements ChatModel {
@Override
public ChatResponse doChat(ChatRequest request) {
String text = callMyGateway(request.messages(), request.temperature());
return ChatResponse.builder().aiMessage(AiMessage.from(text)).build();
}
}
重点看:参数合并、监听器三连、chat("...") 这些便利方法全是白拿的。
4. 机制二:参数的两段式合并
4.1 三个类各管一段
| 类型 | 装什么 | 关键位置 |
|---|---|---|
ChatRequest | 消息列表 + 参数对象,是 chat() 的唯一入参 | request/ChatRequest.java:15-55 |
ChatRequestParameters | 11 个跨厂商通用参数的接口(温度、topP、工具规格、responseFormat…) | request/ChatRequestParameters.java:13-35 |
ChatResponse | AiMessage + ChatResponseMetadata(id / modelName / tokenUsage / finishReason) | response/ChatResponse.java:11-41 |
注意 ChatRequestParameters 是接口不是类——这是第 5 节厂商扩展的前提。
默认实现 DefaultChatRequestParameters 是不可变对象,所有字段 final
(request/DefaultChatRequestParameters.java:19-29)。
4.2 overrideWith 和 defaultedBy 是同一个动作的两个方向
这两个方法名字容易混,一张表说清:
| 方法 | 谁赢 | 白话 | 典型调用者 |
|---|---|---|---|
a.overrideWith(b) | b 赢 | "拿 b 去盖 a" | ChatModel.chat(),用本次请求盖模型默认 |
a.defaultedBy(b) | a 赢 | "b 只是 a 的兜底" | AiServices,用推导出的工具列表兜底用户显式参数 |
实现上它们是同一个 builder 换个顺序,几乎对称
(DefaultChatRequestParameters.java:100-120):
public ChatRequestParameters overrideWith(ChatRequestParameters that) {
return DefaultChatRequestParameters.builder()
.overrideWith(this).overrideWith(that).build(); // that 后写,that 赢
}
public ChatRequestParameters defaultedBy(ChatRequestParameters that) {
return DefaultChatRequestParameters.builder()
.overrideWith(that).overrideWith(this).build(); // this 后写,this 赢
}
真正干活的是 builder 上的 overrideWith,它逐字段做 getOrDefault
(DefaultChatRequestParameters.java:205-218):
temperature(getOrDefault(parameters.temperature(), temperature));
这带来一个必须记住的语义:合并只认非 null(集合还要非空),null 不代表"清空"。
所以你没法用 temperature(null) 把模型默认的 0.7 抹掉——这一点在
ChatRequest.Builder 的 javadoc 里被明确写下来了(ChatRequest.java:154-166)。
4.3 三层优先级
把 AiServices 那一层也算进来,实际优先级是这样的:
优先级低 ──────────────────────────────────────────► 优先级高
[模型 builder 默认] → [AiServices 推导的兜底] → [调用方显式传的]
defaultRequestParameters() defaultedBy() overrideWith()
温度/模型名/超时 工具列表 + responseFormat 这一次的临时覆盖
中间那层的依据在 langchain4j/src/main/java/dev/langchain4j/service/AiServiceParamsUtil.java:23-29:
先把工具列表和 responseFormat 建成 defaultParams,再让方法参数里找到的
ChatRequestParameters 去 defaultedBy(defaultParams)——用户显式给的赢,框架推导的只兜底。
// 示意,非源码:同一个模型,本次调用临时把温度压到 0
ChatResponse response = model.chat(ChatRequest.builder()
.messages(UserMessage.from("给我一个 JSON"))
.temperature(0.0) // 只覆盖这一项
.build());
// modelName 仍然是 builder 里设的 gpt-4o-mini
顺带一提,ChatRequest 的构造器里还有一段小合并:如果你既传了 parameters(...)
又用了 temperature(...) 这类散装 setter,散装的会被打包成 overrides 再盖上去
(ChatRequest.java:23-54)。目的是让 chatRequest.toBuilder().temperature(0.0).build()
不会把其它字段(包括厂商私有字段)洗掉。
5. 机制三:厂商怎么加参数而不撑破统一接口
5.1 它要解决的小问题
OpenAI 想要 seed、logitBias、reasoningEffort、serviceTier。
这些字段既不能塞进 core(core 不该知道 OpenAI 存在),又必须能穿过
ChatModel.chat() 那条通用管道活着到达 doChat。
5.2 思路:子类型 + 协变返回 + 自递归泛型 Builder
样本是 OpenAiChatRequestParameters。手法一共四步:
| 步骤 | 做法 | 位置 |
|---|---|---|
| ① 继承 | extends DefaultChatRequestParameters,加 12 个 OpenAI 私有字段 | OpenAiChatRequestParameters.java:12-28 |
| ② 覆盖合并 | 覆盖 overrideWith/defaultedBy,返回自己的类型(协变返回) | :95-107 |
| ③ Builder 继承 | Builder extends DefaultChatRequestParameters.Builder<Builder>(自递归泛型) | :180 |
| ④ 私货合并 | Builder 的 overrideWith 先 super.overrideWith,再 instanceof 判断后合私有字段 | :196-213 |
第 ③ 步那个 Builder<T extends Builder<T>>(定义在
DefaultChatRequestParameters.java:191)是让父类 setter 返回子类类型的标准 Java 技巧,
否则 .temperature(0.7).seed(42) 链式调用会在第二步断掉。
5.3 私有字段是怎么活着穿过管道的
这是全章最值得看的一条链路。怎么读:跟着"对象的真实类型"看,不要跟着声明类型看。
OpenAiChatModel 构造时
└─ defaultRequestParameters 字段 = OpenAiChatRequestParameters (OpenAiChatModel.java:99-124)
│
ChatModel.chat() 调用 defaultRequestParameters().overrideWith(...)
│ ← 动态分派命中 OpenAI 的覆盖版本
▼
返回值仍然是 OpenAiChatRequestParameters (:95-101)
│
进入 doChat 后强制转型回来
▼
(OpenAiChatRequestParameters) chatRequest.parameters() (:153)
OpenAiChatModel.defaultRequestParameters() 的返回类型被窄 化成
OpenAiChatRequestParameters(langchain4j-open-ai/src/main/java/dev/langchain4j/model/openai/OpenAiChatModel.java:136-139),
构造器又保证这个字段一定是 OpenAI 子类型(:99-124)。
两条加起来,doChat 里 :151-153 那句强制转型才是安全的。
5.4 三个必须知道的边界
外来厂商的私有参数会被静默丢弃。 Builder 的合并有 instanceof 守卫
(OpenAiChatRequestParameters.java:198):不是 OpenAiChatRequestParameters 就只合公共字段。
你把 AnthropicChatRequestParameters 传给 OpenAI 模型,通用字段生效,Anthropic 私货悄悄没了——不报错。
通用字段里厂商不支持的,会显式报错。 topK 是 core 接口的通用字段,但 OpenAI 不支持,
于是 doChat 一开头就 validate(parameters),直接抛 UnsupportedFeatureException
(langchain4j-open-ai/src/main/java/dev/langchain4j/model/openai/internal/OpenAiUtils.java:547-551)。
换厂商时 provider() 是唯一的身份标识。 它返回 ModelProvider 枚举
(OpenAiChatModel.java:198-201 返回 OPEN_AI),枚举里只有 13 个已知厂商 + OTHER
(langchain4j-core/src/main/java/dev/langchain4j/model/ModelProvider.java:3-22)——
23 个厂商模块并非人人有专属枚举值,自建实现落到 OTHER。