跳到主要内容

数据截至 (上游 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 方法,厂商只写 doChatChatModel.java:47-72
两段合并overrideWith / defaultedBy 定义参数谁盖谁,让厂商私有字段随子类一起流过ChatRequestParameters.java:61-89
物理隔离一厂商一 Maven 模块,core 里零厂商依赖;运行时靠 SPI 装载pom.xmlspi/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
ChatRequestParameters11 个跨厂商通用参数的接口(温度、topP、工具规格、responseFormat…)request/ChatRequestParameters.java:13-35
ChatResponseAiMessage + 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,再让方法参数里找到的 ChatRequestParametersdefaultedBy(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 想要 seedlogitBiasreasoningEffortserviceTier。 这些字段既不能塞进 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 的 overrideWithsuper.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


6. 消息模型:五种消息、五种内容

6.1 消息类型

ChatMessage 是个只有一个方法的接口:type()(data/message/ChatMessage.java:16-24)。 类型枚举把"枚举值 ↔ 实现类"绑定在一起(data/message/ChatMessageType.java:7-31), 一共五种,没有第六种:

枚举值实现类承载什么关键字段位置
SYSTEMSystemMessage开发者设定的系统提示SystemMessage.java
USERUserMessage用户输入,多模态在这里UserMessage.java:41-43(name / contents / attributes)
AIAiMessage模型输出,含思维链和工具调用请求AiMessage.java:36-39(text / thinking / toolExecutionRequests / attributes)
TOOL_EXECUTION_RESULTToolExecutionResultMessage工具跑完的回填结果ToolExecutionResultMessage.java:25-29(id / toolName / contents / isError)
CUSTOMCustomMessage只有一个 Map<String,Object> 的逃生舱CustomMessage.java:15-17

两处值得注意:

  • AiMessage 同时有 textthinking(:36-37)——推理模型的思维链被独立成字段, 而不是混进正文。工具调用请求也挂在这里,是 03-tool-calling 那条循环的起点。
  • UserMessage.attributes 不发给模型,只进 ChatMemory(类 javadoc,UserMessage.java:36-37)。 这是给上层挂业务标记用的旁路通道。

6.2 多模态:UserMessage 装的是 Content 列表

UserMessage 的正文不是 String 而是 List<Content>(UserMessage.java:42)。 Content 同样只有一个 type() 方法(data/message/Content.java:12-21), 五个子类型由 ContentType 枚举绑定(data/message/ContentType.java:7-32):

内容类型实现类三种常见构造入口
TEXTTextContent直接给字符串
IMAGEImageContentURL / base64+mimeType / 本地 Path(ImageContent.java:196-278),另有 DetailLevel 枚举(:27)
AUDIOAudioContent同上三入口
VIDEOVideoContent同上三入口
PDFPdfFileContentURL / base64 / Path(PdfFileContent.java:100-152)

厂商侧靠 instanceof 逐个翻译,翻不了就抛。 OpenAI 的分派在 OpenAiUtils.java:203-218,末尾是 throw illegalArgument("Unknown content type: " + content)。 所以"某厂商不支持视频"表现为运行时异常,不是编译期错误——这是本设计的代价。

6.3 Capability:唯一的静态能力探测

Capability 枚举目前只有一个值:RESPONSE_FORMAT_JSON_SCHEMA (model/chat/Capability.java:11-21)。它存在的理由写在类 javadoc 里: 让底层 ChatModel 告诉上层 API 自己支持什么

真实用法在 AiServices 里:决定要不要走 JSON Schema 路线时查一下 context.chatModel.supportedCapabilities().contains(RESPONSE_FORMAT_JSON_SCHEMA) (langchain4j/src/main/java/dev/langchain4j/service/DefaultAiServices.java:520)。 OpenAI 侧则在 responseFormatString 等于 "json_schema" 时动态加上这个能力 (OpenAiChatModel.java:141-148)。细节见 04-structured-output-and-guardrails

注意这套能力探测很薄:一个枚举值,覆盖不了"支不支持图片""支不支持并行工具调用"。 多模态能力目前没有静态探测手段(见 6.2 的运行时抛错)。


7. 向量侧:同一套手法再来一遍

7.1 四个类型分工

类型职责位置
EmbeddingModel文本 → 向量;embedAll 是唯一抽象方法model/embedding/EmbeddingModel.java:33
EmbeddingStore<Embedded>向量库;search 是唯一抽象方法store/embedding/EmbeddingStore.java:24:143
EmbeddingSearchRequest把 query 向量 + maxResults + minScore + filter 打成一个对象EmbeddingSearchRequest.java:17-23
EmbeddingMatch<Embedded>一条命中:score / embeddingId / embedding / embeddedEmbeddingMatch.java:15-20

和 ChatModel 同构的地方:接口很窄,default 方法很多。 EmbeddingModel 只要求实现 embedAll(List<TextSegment>)(:51), embed(String)embed(TextSegment) 都是 default 转调(:25-43); 连 dimension() 都有个"嵌一次 test 字符串量长度"的兜底实现(:58-60)。

7.2 可选能力的表达方式:default 方法抛 UnsupportedFeatureException

EmbeddingStore 里除了 search 和几个 add,其余全是 default 抛异常:

default void removeAll(Filter filter) {
throw new UnsupportedFeatureException("Not supported yet.");
}

同样的写法出现在 addAll(ids, embeddings, embedded)(EmbeddingStore.java:103-105)、 removeAll(Collection)(:110-112)、removeAll(Filter)(:121-123)、removeAll()(:128-130)。

这是"可选能力"的实现方式:接口上全都有,谁支持谁覆盖,不支持的运行时报错。 好处是 21 个向量库模块不必被最弱的那个拖累;代价还是老问题——编译期看不出来。

EmbeddingSearchRequest 的默认值也值得记:maxResults 默认 3,minScore 默认 0.0 且被 ensureBetween(..., 0.0, 1.0, ...) 卡在 EmbeddingSearchRequest.java:51-53

7.3 Filter DSL:一棵与厂商无关的表达式树

元数据过滤各家语法完全不同(Pinecone 用 JSON、pgvector 用 SQL、Elasticsearch 用 DSL)。 LangChain4j 的做法是先建一棵中立的树,再由各 store 翻译成自己的方言—— 这句设计意图写在 store/embedding/filter/Filter.java:18-28 的 javadoc 里。

Filter 接口只有一个 test(Object) 加三个组合器(Filter.java:44-72)。实现分两类:

类别实现
比较(9 个)IsEqualTo IsNotEqualTo IsGreaterThan IsGreaterThanOrEqualTo IsLessThan IsLessThanOrEqualTo IsIn IsNotIn ContainsStringfilter/comparison/
逻辑(3 个)And Or Notfilter/logical/

构造靠静态入口 MetadataFilterBuilder.metadataKey(String) (filter/MetadataFilterBuilder.java:36-38),它为每种 Java 数值类型都重载了一遍 (isEqualTo 有 String/UUID/int/long/float/double 六个重载,:48-68)。

metadataKey("type").isEqualTo("doc")
.and(metadataKey("year").isGreaterThan(2020))

And
/ \
IsEqualTo IsGreaterThan
key=type key=year
="doc" =2020

test() 不是摆设。 每个 Filter 都能在内存里直接判定一条 Metadata—— IsEqualTo.test 会先检查 key 存在、再做类型兼容校验、数字统一转 BigDecimal 比较、 UUIDString 特判(filter/comparison/IsEqualTo.java:33-53)。 这让内存实现的 store 零成本支持过滤,外部 store 则只用翻译不用求值。

同一棵树还能从 SQL 文本解析出来——FilterParser 接口在 core,SQL 实现单独一个模块 (pom.xml:123embedding-store-filter-parsers/langchain4j-embedding-store-filter-parser-sql)。


8. 装配机制:SPI 与 HttpClient 抽象

8.1 SPI:core 不认识实现,但能在运行时找到

ServiceHelper 是对 JDK ServiceLoader 的一层薄包装 (langchain4j-core/src/main/java/dev/langchain4j/spi/ServiceHelper.java:14)。 两个入口:loadFactory(取第一个,:29-32)和 loadFactories(取全部,:41-43)。

它比裸 ServiceLoader 多做的一件事:先用线程上下文 ClassLoader 找, 找不到再退回加载 ServiceHelper 自己的那个 ClassLoader(:62-77)。 javadoc 明说这是为 OSGi 之类"ClassLoader 不按常理出牌"的环境准备的。

典型用法是给 builder 留个替换口子——OpenAiChatModel.builder() 会先看有没有人注册 OpenAiChatModelBuilderFactory,有就用注册的,没有才 new 默认的 (OpenAiChatModel.java:203-208)。Spring Boot / Quarkus 集成就是从这里插进来的。

8.2 HttpClient:第二层"统一接口 + 多实现"

模型层解决了"厂商差异",HTTP 层还得再解决一次"传输库差异"(JDK 自带 / Apache / OkHttp)。 手法完全一样,只是接口更小:

接口方法位置
HttpClientexecute(request) 同步;execute(request, parser, listener) 流式langchain4j-http-client/src/main/java/dev/langchain4j/http/client/HttpClient.java:12-68
HttpClientBuilder只有 connectTimeout / readTimeout / buildHttpClientBuilder.java:5-16
ServerSentEventParserparse(InputStream, ServerSentEventListener)sse/ServerSentEventParser.java:10-23
HttpClientBuilderFactorycreate(),SPI 注册点HttpClientBuilderFactory.java:3-6

流式的默认实现也在接口里:execute(request, listener) 直接补上 new DefaultServerSentEventParser()(HttpClient.java:44-46)—— 只有 OpenAI 这种 SSE 格式有特殊约定的才需要自带 parser。

8.3 装载流程与一个刻意的严格设计

classpath 里有 langchain4j-http-client-jdk
│ META-INF/services/dev.langchain4j.http.client.HttpClientBuilderFactory

ServiceLoader 扫出 JdkHttpClientBuilderFactory

HttpClientBuilderLoader.loadHttpClientBuilder()

├─ 扫到 0 个 → IllegalStateException "No HTTP client has been found"
├─ 扫到 ≥2 个且没指定 → IllegalStateException,要求设系统属性

JdkHttpClientBuilder → HttpClient

依据:HttpClientBuilderLoader.java:11-45。三个实现模块各自带一份 services 文件 (http-clients/langchain4j-http-client-{jdk,apache,okhttp}/src/main/resources/META-INF/services/)。

值得学的一点:多实现共存时它宁可炸也不猜。 循环里一旦发现第二个 factory 就直接抛异常,并把所有候选类名打进错误信息里,让你用系统属性 langchain4j.http.clientBuilderFactory 显式选一个(:16-32)。 这比"随便挑第一个"debug 起来省事得多。

厂商模块怎么接进来:DefaultOpenAiClient 的构造器一行 getOrDefault(builder.httpClientBuilder, HttpClientBuilderLoader::loadHttpClientBuilder) (langchain4j-open-ai/src/main/java/dev/langchain4j/model/openai/internal/DefaultOpenAiClient.java:47-48)—— 用户显式给的优先,没给才走 SPI。这个"显式 > 装载"的模式和第 4 节的参数合并是同一个思路。


9. Maven 分层:让"100+ 集成"在依赖上互不打扰

9.1 模块清单

pom.xml<module> 共出现 105 次:主 <modules> 段(:11-148)103 个, 另有 2 个只在 profile 里激活(langchain4j-jlama :248langchain4j-gpu-llama3 :258)。 主段按注释分组如下,数量相加正好 103:

分组数量代表模块pom.xml 行
骨架6langchain4j-parent langchain4j-bom langchain4j-core langchain4j-test langchain4j langchain4j-kotlin:13-19
开箱即用与 MCP3langchain4j-easy-rag langchain4j-mcp langchain4j-mcp-docker:21-23
HTTP 客户端4接口 1 + jdk/apache/okhttp:26-29
模型厂商23langchain4j-open-ai langchain4j-anthropic langchain4j-ollama:32-54
进程内嵌入模型11embeddings/langchain4j-embeddings-*:57-67
向量 / 记忆存储21langchain4j-pgvector langchain4j-qdrant langchain4j-milvus:70-90
文档装载/解析/转换14document-loaders/* document-parsers/* document-transformers/*:93-110
其它21代码执行、Web 搜索、filter parser、护栏、experimental、agentic、skills、可观测、集成测试、内部工具:113-147

实际实现数与模块数不完全对齐:全仓 implements ChatModel 的非测试类 25 个、 implements EmbeddingStore 的 27 个(同一模块可能有多个实现,比如 Chat 和 Responses 两套 API)。

9.2 依赖方向:上层模块对厂商零依赖

langchain4j-core ← 只依赖 jackson / slf4j / jspecify(langchain4j-core/pom.xml:19-43)

│ compile
langchain4j ← AiServices / ChatMemory 所在,compile 依赖里【没有任何厂商模块】
┊ (langchain4j/pom.xml:23-53)
┊ test only
langchain4j-open-ai ← 仅出现在 test scope(langchain4j/pom.xml:57-62)

这是整个设计能成立的物理证据。 高层模块 langchain4j 在编译期完全不认识任何厂商; langchain4j-open-ai 只在跑测试时才被拉进来。

措辞要准确:langchain4j 的 compile 依赖不是"只有 core",除 langchain4j-core 外 还有 jackson 三件套、opennlp-tools(句子切分用)、slf4j-api(langchain4j/pom.xml:29-53)。 真正成立的论断是它一个厂商模块都不依赖——core 之外全是通用第三方库。

反过来,厂商模块的依赖也很克制。langchain4j-open-ai/pom.xml:18-36 只有三条 LangChain4j 依赖: langchain4j-corelangchain4j-http-clientlangchain4j-http-client-jdk—— 连 OpenAI 官方 SDK 都不用(HTTP 调用自己手写,所以才需要那套 HttpClient 抽象)。

9.3 BOM 与双版本线

langchain4j-bom 是给用户的版本对齐清单,<dependencyManagement> 里管理 114 条依赖。 它的特殊之处是两条版本线(langchain4j-bom/pom.xml:19-22):

属性值(本 commit)给谁用
langchain4j.stable.version1.18.0-SNAPSHOTcore / langchain4j / open-ai / http-client-jdk 等已稳定的
langchain4j.beta.version1.18.0-beta28-SNAPSHOTtest / http-client-apache / 多数新集成

读法:同一次发布里,稳定模块和 beta 模块版本号不同,但由 BOM 保证互相兼容。 用户只要 import 一次 BOM,就不用记哪个集成还在 beta。


10. 巧妙之处(可以搬走的技术)

① 用 default 方法做模板方法,而不是抽象基类。 Java 8 之后接口能带实现,ChatModel 就把整条流程焊在接口上(ChatModel.java:47-68), 厂商实现类不必继承任何基类——它们可以自由继承别的东西。抽象基类做不到这点。

② 合并语义只认非 null,并且写进 javadoc。 getOrDefault 逐字段合并(DefaultChatRequestParameters.java:205-218)带来一个天然限制: 不能用 null 清空。作者没有藏着掖着,而是在 ChatRequest.Builder 的类 javadoc 里 明确写下"setting a field back to null will not clear an existing value"(ChatRequest.java:160-162)。 把设计的代价写在 API 门口,比修掉它更有价值。

③ 协变返回把子类型一路带到底。 OpenAiChatRequestParameters.overrideWith 返回自己的类型(:95-101), OpenAiChatModel.defaultRequestParameters() 也窄化返回类型(:136-139), 两者配合让通用管道 chat() 处理完之后,doChat 里那句强制转型不会炸。 这是"用类型系统而不是文档"来保证私有参数不丢。

④ 多实现共存时抛错而不是猜。 HttpClientBuilderLoader 扫到两个 HTTP 客户端就直接抛异常,并把候选名和解决办法 (设哪个系统属性)一起写进错误信息(:16-32)。

⑤ 可选能力用 default 抛异常,而不是拆成十个接口。 EmbeddingStore 的五个 removeAll/addAll 变体全是 default 抛 UnsupportedFeatureException(:91-130)。21 个向量库能力参差不齐, 拆接口会组合爆炸,这个折中很务实。

⑥ 中立表达式树 + 各家自己翻译。 Filter 既能被翻译成 Pinecone/pgvector 的原生语法,又能靠 test() 在内存里直接求值 (IsEqualTo.java:33-53)——一棵树,两种消费方式


11. 边界与局限(诚实版)

局限表现依据
能力探测太薄Capability 枚举只有 1 个值,只覆盖 JSON SchemaCapability.java:11-21
多模态不可静态检查厂商不支持某种 Content 只能运行时抛 illegalArgumentOpenAiUtils.java:216
跨厂商参数会静默丢失传错厂商的 ChatRequestParameters,私有字段被 instanceof 挡掉,不报错OpenAiChatRequestParameters.java:198
null 不能清空合并全走 getOrDefault,没有"显式置空"的表达方式DefaultChatRequestParameters.java:205-218
doChat 里要强制转型类型安全靠"构造器保证 + 协变返回"的约定维持,不是编译器强制OpenAiChatModel.java:153
ModelProvider 是封闭枚举13 个已知厂商,自建实现只能是 OTHER,监听器难区分ModelProvider.java:3-22
通用字段厂商不支持时才发现topK 传给 OpenAI 要到 doChat 才抛 UnsupportedFeatureExceptionOpenAiUtils.java:547-551

一句话总结这些代价:LangChain4j 选择了"接口尽量宽、运行时报错"而不是 "接口切得很细、编译期报错"。 换来的是加一个新厂商几乎不用碰 core。


12. 代码地图(导航索引)

按符号名 grep 比按行号更抗上游漂移。

主题文件路径(相对克隆根)符号名
模型模板方法langchain4j-core/src/main/java/dev/langchain4j/model/chat/ChatModel.javachat / doChat / defaultRequestParameters / listeners / provider / supportedCapabilities
监听器三连langchain4j-core/src/main/java/dev/langchain4j/model/chat/ChatModelListenerUtils.javaonRequest / onResponse / onError
每调用元数据langchain4j-core/src/main/java/dev/langchain4j/model/chat/ChatRequestOptions.javaChatRequestOptions / EMPTY / listenerAttributes
参数接口langchain4j-core/src/main/java/dev/langchain4j/model/chat/request/ChatRequestParameters.javaoverrideWith / defaultedBy
参数默认实现langchain4j-core/src/main/java/dev/langchain4j/model/chat/request/DefaultChatRequestParameters.javaBuilder.overrideWith / EMPTY
请求/响应载体.../model/chat/request/ChatRequest.java.../model/chat/response/ChatResponse.javaChatRequest.Builder / ChatResponse.metadata
能力探测langchain4j-core/src/main/java/dev/langchain4j/model/chat/Capability.javaRESPONSE_FORMAT_JSON_SCHEMA
厂商身份langchain4j-core/src/main/java/dev/langchain4j/model/ModelProvider.javaModelProvider
消息模型langchain4j-core/src/main/java/dev/langchain4j/data/message/ChatMessage / ChatMessageType / UserMessage / AiMessage / ToolExecutionResultMessage / CustomMessage
多模态内容同上目录Content / ContentType / TextContent / ImageContent / PdfFileContent / AudioContent / VideoContent
厂商参数扩展样本langchain4j-open-ai/src/main/java/dev/langchain4j/model/openai/OpenAiChatRequestParameters.javaoverrideWith / Builder.overrideWith
厂商模型样本langchain4j-open-ai/src/main/java/dev/langchain4j/model/openai/OpenAiChatModel.javadoChat / provider / defaultRequestParameters / supportedCapabilities
厂商翻译层langchain4j-open-ai/src/main/java/dev/langchain4j/model/openai/internal/OpenAiUtils.javatoOpenAiChatRequest / toOpenAiContent / validate
向量模型/库langchain4j-core/src/main/java/dev/langchain4j/model/embedding/EmbeddingModel.java.../store/embedding/EmbeddingStore.javaembedAll / search / removeAll
检索请求/结果langchain4j-core/src/main/java/dev/langchain4j/store/embedding/EmbeddingSearchRequest / EmbeddingSearchResult / EmbeddingMatch
Filter DSLlangchain4j-core/src/main/java/dev/langchain4j/store/embedding/filter/Filter / MetadataFilterBuilder.metadataKey / IsEqualTo / And / FilterParser
SPI 装载langchain4j-core/src/main/java/dev/langchain4j/spi/ServiceHelper.javaloadFactory / loadFactories
HTTP 抽象langchain4j-http-client/src/main/java/dev/langchain4j/http/client/HttpClient / HttpClientBuilder / HttpClientBuilderFactory / HttpClientBuilderLoader.loadHttpClientBuilder
SSE 解析langchain4j-http-client/src/main/java/dev/langchain4j/http/client/sse/ServerSentEventParser / DefaultServerSentEventParser
模块与版本pom.xmllangchain4j-bom/pom.xml<modules> / langchain4j.stable.version / langchain4j.beta.version

下一步该读哪章:

  • 想知道 ChatModel 上面那层怎么把 Java 接口变成一次调用 → 02-ai-services
  • 想知道 AiMessage.toolExecutionRequests 怎么变成一轮 round-trip → 03-tool-calling
  • 想知道 CapabilityresponseFormat 怎么撑起类型化输出 → 04-structured-output-and-guardrails
  • 想知道 EmbeddingStoreFilter 在检索流水线里的位置 → 05-rag
  • 想知道多智能体怎么复用这一层 → 06-agentic