跳到主要内容

数据截至 (上游 commit daa7624a2755)

AiServices:一个 Java 接口如何变成一次 LLM 调用

30 秒导读: 你写一个 Java 接口 String chat(String msg),LangChain4j 用 JDK 动态代理给它生成实现。调用这个方法时,代理会跑一条固定顺序的流水线:取聊天记忆 → 用注解拼提示词 → RAG 增强 → 输入护栏 → 决定输出格式 → 装配消息 → 调模型 → 工具循环 → 输出护栏 → 把文本解析成你声明的返回类型。这条流水线就是全书主线。

本章讲这条流水线本身——谁在什么时候被调用、参数怎么流转。不讲工具循环内部(见 03-tool-calling)、输出解析与护栏内部(见 04-structured-output-and-guardrails)、RAG 组件内部(见 05-rag)。


1. 这是什么(零基础也能懂)

一句话定义: AI Service 是 LangChain4j 的声明式 LLM 调用层——你只声明一个 Java 接口,框架在运行时生成实现。

它解决什么问题。 直接用底层 ChatModel 调 LLM 时,每次都要手写同一套模板代码:

  • 拼 system/user 消息
  • 把历史消息塞进请求、把新回答塞回历史
  • 检索 RAG 上下文并拼进 prompt
  • 告诉模型"请返回 JSON",再把返回的字符串解析成对象
  • 模型要调工具时,执行工具、把结果回填、再调一次模型

AI Service 把这五件事全部收进框架内部,只留一个接口给你。

最小示例。 这是源码 javadoc 里给的例子(langchain4j/src/main/java/dev/langchain4j/service/AiServices.java:100-108):

interface Assistant {
String chat(String userMessage);
}

Assistant assistant = AiServices.create(Assistant.class, model);
String answer = assistant.chat("hello"); // "Hello, how can I help you today?"

再复杂一点——带模板变量的版本(同文件 AiServices.java:147-152):

interface Translator {
@SystemMessage("You are a professional translator into {{language}}")
@UserMessage("Translate the following text: {{text}}")
String translate(@V("text") String text, @V("language") String language);
}

返回类型就是输出契约。 方法签名写 Sentiment(枚举)、List<String>Person(POJO)、Result<T>,框架就负责让模型产出对应结构并解析。可选类型清单见 AiServices.java:112-119

一个类比(仅用于建立直觉,下文不再当术语用): 和 Spring Data JPA 的 Repository、Retrofit 的 HTTP 接口是同一路数——接口即契约,实现由代理在运行时补齐。区别在于这里的"后端"是一个不确定的语言模型,所以代理里塞了比 SQL 拼装复杂得多的编排。


2. 顶层全景:装配期与调用期

先分清两个时间点,后面所有细节都挂在这两根柱子上。

时间点做什么入口符号
装配期builder 收集模型、记忆、工具、RAG、护栏等开关,校验接口合法性,生成代理对象AiServices.builder(...)DefaultAiServices.build()
调用期每次调用接口方法,代理跑一遍完整编排InvocationHandler.invoke(...) → 内部 invoke(method, args, invocationContext)

怎么读下面这张图: 从左到右是装配期(一次性),从上到下是调用期(每次方法调用跑一遍)。

装配期(一次)
AiServices.builder(Assistant.class)
│ .chatModel / .chatMemoryProvider / .tools / .contentRetriever ...

AiServiceContext ──────── 所有开关的载体(可变字段包)

▼ DefaultAiServices.build()
Proxy.newProxyInstance ──► Assistant 代理实例

─────────────────────────────────────┼──────────────────────────
调用期(每次) ▼
InvocationHandler.invoke
│ 四路分流
┌───────────────┼───────────────┐
▼ ▼ ▼
default 方法 Object 方法 ChatMemoryAccess
│ │ │
└── 直接返回 ───┴───────────────┘

▼(其余走中央循环)
① 建 InvocationContext
② 取/建 ChatMemory
③ 拼 system / user 消息
④ RAG 增强
⑤ 输入护栏
⑥ 输出格式(JSON Schema 或指令)
⑦ 装配 messages + 写记忆
⑧ moderation(异步)
⑨ 建 ToolServiceContext
⑩ ChatExecutor.execute → 模型
⑪ 工具循环
⑫ 输出护栏
⑬ 解析成返回类型

部件一句话职责:

部件干什么文件
AiServices装配面 builder,几十个开关langchain4j/src/main/java/dev/langchain4j/service/AiServices.java:159
AiServiceContext装配结果的可变字段包,调用期全程只读它langchain4j/src/main/java/dev/langchain4j/service/AiServiceContext.java:25
DefaultAiServices生成代理 + 中央循环的唯一实现langchain4j/src/main/java/dev/langchain4j/service/DefaultAiServices.java:83
ChatMemoryService按 memoryId 隔离多会话记忆langchain4j/src/main/java/dev/langchain4j/service/memory/ChatMemoryService.java:13
InternalReflectionVariableResolver把方法实参映射成模板变量langchain4j/src/main/java/dev/langchain4j/service/InternalReflectionVariableResolver.java:24
ChatExecutor真正发出 ChatRequest 并触发事件的执行器langchain4j-core/src/main/java/dev/langchain4j/guardrail/ChatExecutor.java:20
AiServiceTokenStream流式返回类型的实现langchain4j/src/main/java/dev/langchain4j/service/AiServiceTokenStream.java:38

3. 动态代理:build() 里到底发生了什么

它要解决的小问题: 接口没有实现类,assistant.chat("hi") 这行字节码得有人接住。

思路: JDK 自带 java.lang.reflect.Proxy——给它一个接口和一个 InvocationHandler,它就生成一个实现该接口的对象,所有方法调用都转发给 handler 的 invoke

原理演示(示意,非源码):

// 演示 JDK 动态代理的最小形态
Object proxy = Proxy.newProxyInstance(
Assistant.class.getClassLoader(), // 用哪个 ClassLoader 定义代理类
new Class<?>[]{Assistant.class}, // 代理实现哪些接口
(self, method, args) -> callLlm(method, args)); // 所有调用都落到这里

真实实现: DefaultAiServices.build() 就是这个形状(langchain4j/src/main/java/dev/langchain4j/service/DefaultAiServices.java:110-113),先 validate(),再 Proxy.newProxyInstance,最后强转返回。

3.1 四路分流

invoke 进来的方法不是每一个都要打 LLM。handler 按下面顺序分流(DefaultAiServices.java:118-143):

顺序判断条件处理方式行号
1method.isDefault()交回接口的 default 实现,不碰 LLM:120-122
2method.getDeclaringClass() == Object.classequals 用身份比较、hashCodeSystem.identityHashCodetoString 拼类名+hash:124-136
3method.getDeclaringClass() == ChatMemoryAccess.classhandleChatMemoryAccess,读写记忆而不调模型:138-140
4其余校验参数后进中央循环:143-162

第 2 路值得单说。 代理对象没有真实身份,所以 equals 被定义成 proxy == args[0](引用相等,:127)。这意味着两个由同一接口生成的 AI Service 代理永远不相等,别拿它当值对象放进 HashSet 做去重语义。

第 3 路的实现只有一个 switch(DefaultAiServices.java:100-108):getChatMemory 直接查 chatMemoryService(:102),evictChatMemory 把服务层的返回值和 null 比一下,当成"是否真的驱逐掉了"返回(:103)。这一句在单例记忆模式下会踩雷,见 §6.3。

3.2 每次调用建一个 InvocationContext

分流到第 4 路后,第一件事是冻结这次调用的身份信息(DefaultAiServices.java:149-160):

InvocationContext invocationContext = InvocationContext.builder()
.invocationId(UUID.randomUUID())
.interfaceName(context.aiServiceClass.getName())
.methodName(method.getName())
.methodArguments(args != null ? Arrays.asList(args) : List.of())
.chatMemoryId(findMemoryId(method, args).orElse(ChatMemoryService.DEFAULT))
...

这个对象随后被塞进事件、护栏参数、工具上下文、ChatExecutor——它是把一次调用的各个阶段串起来的关联键。接口定义在 langchain4j-core/src/main/java/dev/langchain4j/invocation/InvocationContext.java:22

注意 chatMemoryId 的兜底: 找不到 @MemoryId 参数就用常量 "default"(ChatMemoryService.java:15)。

异常包了一层。 整个中央循环被 try/catch 包住,任何异常先 fire 一个 AiServiceErrorEvent 再原样抛出(DefaultAiServices.java:161-169)——这样监听器不会漏掉失败的调用。


4. 中央循环:invoke() 的编排顺序

这是本项目最重要的一段代码(DefaultAiServices.java:188-450)。顺序不是随意的,每一步都依赖前一步的产物。

怎么读下面这张图: 自上而下是执行顺序;右侧分叉是流式与非流式的分界,分开后不再合流。

① memoryId ─► ChatMemory :190-193

② prepareSystemMessage ──► systemMessageTransformer :195-202

③ getUserMessageTemplate ─► findTemplateVariables
└─► prepareUserMessage :203-207

④ fire AiServiceStartedEvent :209-213

⑤ retrievalAugmentor.augment(userMessage) :217-230
│ (RAG 只改 userMessage)
⑥ addContentsToUserMessage(多模态) :232

⑦ 输入护栏 invokeInputGuardrails :243-244

⑧ returnType → streaming? :246-248

⑨ JSON Schema 或 追加格式指令 :251-260

⑩ 装配 messages + 写 ChatMemory :262-275

⑪ @Moderate → 异步提交审核 :281

⑫ toolService.createContext :283-284

├─ streaming ──► new AiServiceTokenStream ──► 返回(§8) :286-307

▼ 非流式
⑬ ChatExecutor.execute() ──► ChatResponse :327-333
│ fire AiServiceResponseReceivedEvent :335-339
│ verifyModerationIfNeeded :341

⑭ toolService.executeInferenceAndToolsLoop :345-354

⑮ 输出护栏(带 ToolAwareRepromptExecutor) :408-422

⑯ serviceOutputParser.parse → 返回类型 :434-447

⑰ fire AiServiceCompletedEvent 并返回 :449

下面挑几个顺序上不显然、容易踩坑的点展开。

4.1 RAG 只改 user message,不改 system message

retrievalAugmentor.augment(...) 的输入是 AugmentationRequest(userMessage, metadata),返回的 augmentationResult.chatMessage() 被强转回 UserMessage(DefaultAiServices.java:226-229)。system message 只是作为 Metadata 的只读上下文传进去(:222)。

结论:检索到的内容一定拼在用户消息里。 RAG 组件内部见 05-rag

4.2 输入护栏在 RAG 之后

顺序是 RAG → 多模态内容合并 → 输入护栏(:228:232:243)。所以输入护栏看到的是已经注入检索内容的最终用户消息,不是用户原始输入。护栏参数 GuardrailRequestParams 里同时带上了 augmentationResult 和原始的 userMessageTemplatevariables(:234-241),想拿原文的护栏得自己从这里取。

4.3 输出格式:两条互斥的路

条件走哪条行号
模型声明支持 RESPONSE_FORMAT_JSON_SCHEMA 且非流式、非图片返回生成 JsonSchema,后面塞进 ResponseFormat:255-257:309-315
不支持 schema,或 schema 生成为空把自然语言格式指令追加到用户消息最后一段文本上:258-260
流式(TokenStream)或返回图片两条都不走:255:258!streaming && !returnsImage

能力判断只看 chatModelsupportedCapabilities()(DefaultAiServices.java:518-521)。

追加指令的做法有个细节:从后往前找最后一个 TextContent 拼上去,找不到才新增一段(:531-543)。这是为了在多模态消息里别把指令插到图片前面。

4.4 记忆写入策略:一个开关决定存原文还是存增强后的文本

这是本章最值得记住的设计(DefaultAiServices.java:262-275):

if (context.hasChatMemory()) {
systemMessage.ifPresent(chatMemory::add);
messages.addAll(chatMemory.messages());
if (context.storeRetrievedContentInChatMemory) {
chatMemory.add(userMessage); // 存"带 RAG 内容"的版本
} else {
chatMemory.add(originalUserMessage);// 存用户原始输入
}
messages.add(userMessage); // 本次发给模型的永远是增强版
}

三个要点分开看:

  • 发给模型的和写进记忆的可以不是同一条消息。 messages 里永远是增强版,记忆里存哪个由 storeRetrievedContentInChatMemory 决定(默认 true,见 AiServiceContext.java:49)。
  • 关掉它的价值: 检索内容通常很长,存进记忆会在后续轮次里反复占用 token 预算,而且会把上一轮的检索结果误当成这一轮的上下文。
  • 没有记忆时走 else 分支,只把 system + user 两条塞进 messages(:272-275)——即无状态单轮调用。

还有一个易忽略点:systemMessage.ifPresent(chatMemory::add) 让 system message 也进记忆,MessageWindowChatMemory 对此有专门的去重/替换逻辑(见 §6.2)。

4.5 moderation 是异步的,在模型返回后才 join

@Moderate 注解会把审核任务丢进默认线程池,拿到 Future(DefaultAiServices.java:281548-559),然后立刻继续调模型;直到模型返回后才 verifyModerationIfNeeded(moderationFuture) 阻塞取结果(:341)。

  • 好处:审核延迟和模型延迟并行,不叠加。
  • 代价:违规内容仍然被送到了模型,审核只能阻止把结果返回给调用方(AiServices.java:1281-1293ModerationException)。
  • 送审前会剔除工具相关消息(removeToolMessages,AiServices.java:1274-1279)。

4.6 工具立即返回的特殊出口

如果工具被标记为 ReturnBehavior.IMMEDIATE / IMMEDIATE_IF_LAST,循环会短路,toolServiceResult.immediateToolReturn() 为真(:356)。此时框架尝试从工具执行结果里直接凑出返回值:

情形返回什么行号
返回类型是 Result<T>装配 Result,content 为 null、finishReasonTOOL_EXECUTION:357-368
返回类型是 voidnull:370-372
全部工具结果为 nullnull:389-391
恰好一个非 null 且类型匹配那个结果对象:392-399
其余IllegalConfigurationException,提示改用 Result:400-402

工具循环本身见 03-tool-calling

4.7 ChatExecutor 是唯一发请求的地方

非流式路径不直接调 chatModel.chat(...),而是构造一个 ChatExecutor(DefaultAiServices.java:327-333)。原因是输出护栏需要"重试"能力——它拿到的 ToolAwareRepromptExecutor 能在护栏判定失败时重新发起一次带工具上下文的请求(:408-422)。

ChatExecutor 的抽象基类在发请求前统一触发 AiServiceRequestIssuedEvent(langchain4j-core/src/main/java/dev/langchain4j/guardrail/AbstractChatExecutor.java:55-67),同步实现只是一行 chatModel.chat(chatRequest)(SynchronousChatExecutor.java:30-33)。


5. 提示词是怎么拼出来的

这节讲: 注解、模板变量、以及"没写注解时框架怎么猜"。

5.1 五个注解

注解加在哪作用定义文件
@SystemMessage方法系统消息模板,支持多行数组 + delimiter,或 fromResource 读 classpath 文件langchain4j/src/main/java/dev/langchain4j/service/SystemMessage.java:44-58
@UserMessage方法或参数用户消息模板;加在参数上则该参数的字符串值就是模板langchain4j/src/main/java/dev/langchain4j/service/UserMessage.java:44-56
@V("name")参数显式指定该参数对应的模板变量名.../service/V.java
@MemoryId参数该参数值作为会话隔离键.../service/MemoryId.java
@UserName参数该参数值作为 UserMessage 的发送者名字.../service/UserName.java

@SystemMessage / @UserMessage 的取值逻辑共用一个 getTemplate(DefaultAiServices.java:857-871):有 fromResource 就读资源文件(找不到抛异常),否则用 String.join(delimiter, value);模板为空一律抛 IllegalConfigurationException

5.2 系统消息:三级来源

findSystemMessageTemplate 的优先级(DefaultAiServices.java:615-638):

① 方法上的 @SystemMessage 注解
↓ 没有
② context.systemMessageProviderWithContext(InvocationContext) ← 能看到方法名、参数
↓ 没配
③ context.systemMessageProvider(memoryId) ← 只看到 memoryId

拿到模板后套 PromptTemplate 渲染(:606-613)。渲染完还有一道后处理:systemMessageTransformer 可以整体改写甚至删掉 system message(返回 null 就变成 Optional.empty(),:196-202)。

5.3 用户消息:五级 fallback

怎么读这张图: 从上往下依次尝试,命中即停。

① 方法上的 @UserMessage :674-676
│ 未命中
② 参数上的 @UserMessage(取该实参的字符串) :677-679
│ 未命中 ※ ①②同时存在 → 抛异常 :668-672
③ 唯一参数且没有任何有效注解 → 该参数即模板 :681-685
│ 未命中
④ 参数里带 Content(多模态)→ 模板为空串 :687-689
│ 未命中
⑤ context.userMessageProvider(memoryId) :691-694
│ 仍无
抛 IllegalConfigurationException

模板为空串时(第 ④ 路),prepareUserMessage 走另一条分支:把所有 Content 类型实参收集起来直接构造消息(DefaultAiServices.java:655-674)。

@UserName 在两条分支里都生效——有名字就用 UserMessage.from(userName, ...)(:646-648:657-659)。

5.4 模板变量从哪来

findTemplateVariables 做三件事(InternalReflectionVariableResolver.java:28-55):

  1. 遍历所有实参,以 ParameterNameResolver.name(parameter) 为键放进 map;InvocationParameters 类型的参数跳过。
  2. 如果某个实参本身是 Map,把它的 String 键摊平成顶层变量。
  3. 模板里出现 {{it}} 且变量表里没有 it 时,再单独求值。

{{it}} 的求值规则(:57-77):唯一参数且没有 @MemoryId/@UserMessage/@UserName(或虽有 @V 但值就是 "it")→ 用它;否则找显式标了 @V("it") 的参数;都没有就抛异常。

变量名从哪来是个坑。 默认解析器优先读 @V 的值,没有 @V 才退回 parameter.getName()(ParameterNameResolver.java:39-46)。而 Java 编译时不带 -parameters 就拿不到真实参数名,只会得到 arg0arg1。解析器本身可通过 ServiceLoader 替换(:23-29)。

PromptTemplate.apply 渲染前还会自动注入三个变量:{{current_date}}{{current_time}}{{current_date_time}}(langchain4j-core/src/main/java/dev/langchain4j/model/input/PromptTemplate.java:126-143)。

5.5 参数校验:每个方法只校验一次

validateParameters每次调用时被调,但内部用一个静态 Set<Method> 做了幂等——VALID_METHODS.add(method) 返回 false 就直接 return(AiServiceValidation.java:75-78)。源码里还留着 // TODO do it once, when creating AI Service?(DefaultAiServices.java:142)。

规则本身:方法参数 ≥ 2 时,每个参数必须满足以下之一(:80-117):

允许形式说明
@V / @UserMessage / @MemoryId / @UserName常规注解
类型是 InvocationParameters每个方法至多一个
类型是 ChatRequestParameters每个方法至多一个,会覆盖默认请求参数
实现 LangChain4jManaged框架托管参数

装配期还有一批校验(AiServiceValidation.validate,:23-73):服务类型必须是接口;用 @MemoryId 或实现 ChatMemoryAccess 却没配记忆 → 抛异常;用 @Moderate 却没配 moderation 模型 → 抛异常;Result/List/Set 返回类型必须带泛型参数。


6. 记忆:多会话如何隔离

6.1 ChatMemory 接口只有四个方法

定义在 langchain4j-core/src/main/java/dev/langchain4j/memory/ChatMemory.java:16:id()add(ChatMessage)messages()clear()。状态实际落在 ChatMemoryStore 里(默认 SingleSlotChatMemoryStore,见 MessageWindowChatMemory.java:201-202)。

6.2 两种内置滑动窗口

实现窗口按什么算驱逐时机文件
MessageWindowChatMemory消息条数 maxMessagesaddsetmessages() 都会 ensureCapacitylangchain4j/src/main/java/dev/langchain4j/memory/chat/MessageWindowChatMemory.java:47
TokenWindowChatMemory估算 token 数 maxTokens,靠 TokenCountEstimator同上langchain4j/src/main/java/dev/langchain4j/memory/chat/TokenWindowChatMemory.java:49

两者共享两条非平凡规则:

规则一:system message 去重且不被驱逐。 add 时若已有 system message:内容相同直接丢弃,内容不同则替换旧的(MessageWindowChatMemory.java:71-80);可选 alwaysKeepSystemMessageFirst 把它插到 0 位(:82-86)。驱逐时若 0 位是 system message,驱逐指针从 1 开始(:125-128)。

规则二:驱逐 AiMessage 要连带驱逐孤儿工具结果。 因为部分厂商(如 OpenAI)拒绝没有对应 AiMessageToolExecutionResultMessage:

ChatMessage evictedMessage = messages.remove(messageToEvictIndex);
if (evictedMessage instanceof AiMessage aiMessage && aiMessage.hasToolExecutionRequests()) {
while (messages.size() > messageToEvictIndex
&& messages.get(messageToEvictIndex) instanceof ToolExecutionResultMessage) {
messages.remove(messageToEvictIndex);
}
}

这段在 MessageWindowChatMemory.java:131-139,TokenWindowChatMemory.java:145-154 有等价逻辑。

窗口大小还支持动态:maxMessagesProvider / maxTokensProviderFunction<Object, Integer>,每次 add/messages() 都重新求值(MessageWindowChatMemory.java:88115)。

6.3 ChatMemoryService:按 memoryId 隔离

整个类只有 57 行(langchain4j/src/main/java/dev/langchain4j/service/memory/ChatMemoryService.java),但它是多会话的全部:

public ChatMemory getOrCreateChatMemory(Object memoryId) {
if (chatMemoryProvider != null) {
return chatMemories.computeIfAbsent(memoryId, chatMemoryProvider::get);
}
return defaultChatMemory;
}

:30-35。两个构造器对应两种模式:

装配方式行为源码
.chatMemory(chatMemory)单例共享记忆,所有 memoryId 拿到同一个ChatMemoryService.java:26-28AiServices.java:404
.chatMemoryProvider(provider)ConcurrentHashMap 按 memoryId 懒创建ChatMemoryService.java:21-24AiServices.java:431

getChatMemory 是纯查询,不创建;单例模式下只有传入 "default" 才返回(:37-39)。

evictChatMemory 在单例模式下会抛 NPE,不是"返回 null"。 这条要说准,否则会写出踩坑的代码。三步链路:

步骤事实位置
① 字段没被赋值单例构造器 ChatMemoryService(ChatMemory) 只给 defaultChatMemory 赋值,chatMemories 字段声明处也没有初始值:26-28:18
② 方法体直接解引用evictChatMemory 的全部实现就是 return chatMemories.remove(memoryId);:41-43
③ 代理层不吞异常handleChatMemoryAccess 只把返回值和 null 比一下(... != null),NPE 原样抛给调用方DefaultAiServices.java:103

顺带一个佐证:ChatMemoryAccess.evictChatMemory 声明的返回类型是 boolean(langchain4j/src/main/java/dev/langchain4j/service/memory/ChatMemoryAccess.java:24),从签名上就不可能"返回 null"。

同一个未初始化字段还牵连另外三个方法——clearAll(:45-48)、getChatMemoryIDs(:50-52)、getChatMemories(:54-56),在单例模式下同样是 NPE。

结论:想用驱逐/枚举记忆这类能力,就得配 .chatMemoryProvider(...) 而不是 .chatMemory(...)

6.4 ChatMemoryAccess:让接口自己暴露记忆操作

让你的接口 extends ChatMemoryAccess(langchain4j/src/main/java/dev/langchain4j/service/memory/ChatMemoryAccess.java:8),就能调 getChatMemory(id) / evictChatMemory(id)。这两个方法走 §3.1 的第 3 路分流,不打模型。装配期会校验必须配了记忆(AiServiceValidation.java:31-37)——但它只校验"配了记忆",不区分是单例还是 provider,所以 §6.3 那个 NPE 在装配期拦不住。

6.5 并发是明确的雷区

AiServices 的 javadoc 直说了:同一个 @MemoryId 不能并发调用,会损坏 ChatMemory,框架目前没有任何防护(AiServices.java:76-78)。这不是推断,是源码注释里的原话。


7. 装配面:builder 上的开关

AiServices 是抽象类,builder() 通过 ServiceLoaderAiServicesFactory,找不到就 new DefaultAiServices(AiServices.java:203-217)——Quarkus/Spring 扩展就是从这里接管的

开关按关切分组(行号均在 AiServices.java):

关切主要方法行号
模型chatModel / streamingChatModel227 / 242
系统消息systemMessage / systemMessageProvider / systemMessageProviderWithContext / systemMessageTransformer258 / 278 / 301 / 323346
用户消息userMessage / userMessageProvider363 / 383
记忆chatMemory / chatMemoryProvider403 / 430
请求改写chatRequestTransformer(可拿到 memoryId)445 / 461
工具tools 多个重载 / toolProvider / maxToolCallingRoundTrips / executeToolsConcurrently / toolSearchStrategy489830
RAGcontentRetriever / retrievalAugmentor / storeRetrievedContentInChatMemory848 / 865 / 1241
护栏inputGuardrails / outputGuardrails 及其 *Classes / *Config 变体9501230
可观测registerListener(s) / unregisterListener(s)880939
审核moderationModel475

装配期只有一条硬校验: chatModelstreamingChatModel 至少配一个(performBasicValidation,AiServices.java:1268-1272)。

AiServiceContext 的形态值得注意(AiServiceContext.java:25-57):它是一包 public 可变字段,builder 直接赋值。只有两个东西是懒初始化的——guardrailServiceAtomicReference.updateAndGet 首次访问时构建(:90-93),eventListenerRegistrar 在字段声明处直接 new(:31)。


8. 流式路径:另一半世界

分叉点在哪: boolean streaming = returnType == TokenStream.class || canAdaptTokenStreamTo(returnType)(DefaultAiServices.java:248)。判定为 true 就在 :286-307 直接返回,后面的非流式代码一行都不执行

关键性质:返回 TokenStream 时模型还没被调用。 invoke 只是构造好参数对象就返回了;真正发请求发生在你调 start() 的时候。

8.1 三个类的分工

AiServiceTokenStream ← 用户订阅回调、最后 start()
│ start()
├─ validateConfiguration() 检查回调注册次数
├─ 建 ChatRequest + ChatExecutor
├─ new AiServiceStreamingResponseHandler(...)
├─ 回放 onRetrieved(RAG 结果)
├─ fire AiServiceRequestIssuedEvent
└─ streamingChatModel.chat(request, handler)


AiServiceStreamingResponseHandler ← 实现 StreamingChatResponseHandler
onPartialResponse / onPartialThinking / onPartialToolCall
onCompleteResponse ──► 有工具调用则再发一轮,否则收尾
  • AiServiceTokenStream.start():langchain4j/src/main/java/dev/langchain4j/service/AiServiceTokenStream.java:201-258
  • AiServiceStreamingResponseHandler:langchain4j/src/main/java/dev/langchain4j/service/AiServiceStreamingResponseHandler.java:65

8.2 validateConfiguration 的严格规则

start() 第一件事就是校验回调注册次数(AiServiceTokenStream.java:260-296),违规直接抛 IllegalConfigurationException:

规则说明
onPartialResponseonPartialResponseWithContext 合计 ≤ 1thinking、toolCall 各有同款规则
onIntermediateResponse / onCompleteResponse / onRetrieved / beforeToolExecution / onUnmappedRawEvent / onToolExecuted 各 ≤ 1不允许重复订阅
onError + ignoreErrors 恰好等于 1必须显式表态怎么处理错误

最后一条最容易踩:忘了写 onError 会在 start() 时报错,而不是静默吞异常。

8.3 没配记忆时用"临时记忆"

流式路径需要在多轮工具调用之间累积消息,所以即使没配 ChatMemory 也要有地方放。做法是造一个 MessageWindowChatMemory.withMaxMessages(Integer.MAX_VALUE) 作为临时容器(AiServiceTokenStream.java:298-299),handler 里按有没有真记忆二选一(AiServiceStreamingResponseHandler.java:513-517)。

storeRetrievedContentInChatMemory 在流式路径也生效,但实现方式不同——不是"写的时候选",而是发送前把最后一条 user message 换回原始版(AiServiceStreamingResponseHandler.java:523-528):

private List<ChatMessage> messagesToSend(Object memoryId) {
List<ChatMessage> messages = getMemory(memoryId).messages();
return context.storeRetrievedContentInChatMemory
? messages
: UserMessage.replaceLast(messages, invocationContext.userMessage());
}

8.4 TokenStreamAdapter:让方法返回别的流类型

接口只有两个方法(langchain4j/src/main/java/dev/langchain4j/spi/services/TokenStreamAdapter.java:9-14):

boolean canAdaptTokenStreamTo(Type type);
Object adapt(TokenStream tokenStream);

实现通过 ServiceLoaderDefaultAiServices 构造时一次性加载(DefaultAiServices.java:86),canAdaptTokenStreamTo 参与 streaming 判定(:500-507),adapt 在返回前转换(:509-516)。

现成的实现在 Kotlin 模块:langchain4j-kotlin/src/main/kotlin/dev/langchain4j/kotlin/service/TokenStreamToStringFlowAdapter.kt:11TokenStreamToReplyFlowAdapter.kt:12——这就是为什么 Kotlin 里方法可以直接返回 Flow<String>


9. 可观测:七类事件与它们的触发点

事件接口都在 langchain4j-core/src/main/java/dev/langchain4j/observability/api/event/,统一由 context.eventListenerRegistrar.fireEvent(...) 发出(注册器接口 langchain4j-core/src/main/java/dev/langchain4j/observability/api/AiServiceListenerRegistrar.java:12,在 AiServiceContext.java:31 直接 new)。

事件携带什么谁在哪触发
AiServiceStartedEventOptional<SystemMessage> + UserMessageDefaultAiServices.java:209-213(RAG 之前)
AiServiceRequestIssuedEventChatRequest非流式:AbstractChatExecutor.java:55-67;流式:AiServiceTokenStream.java:252-255;工具轮次:ToolService.java:850
AiServiceResponseReceivedEventChatRequest + ChatResponseDefaultAiServices.java:335-339;流式 AiServiceStreamingResponseHandler.java:266-272
ToolExecutedEvent工具请求与结果ToolService.java:839;流式 AiServiceStreamingResponseHandler.java:258-264
AiServiceCompletedEventOptional<Object> result(最终返回值)DefaultAiServices.java:452-458fireEventAndReturn,七处返回口全走它
AiServiceErrorEventThrowableDefaultAiServices.java:164-167(包住整个循环);流式 AiServiceStreamingResponseHandler.java:281-287
Input/OutputGuardrailExecutedEvent护栏执行结果护栏服务内部,见 04-structured-output-and-guardrails

两个注意点:

  • AiServiceStartedEvent 在 RAG 之前触发(:209 早于 :218),所以监听器拿到的 userMessage未增强的。
  • AiServiceCompletedEventfireEventAndReturn 这个唯一出口,非流式路径的七个 return 全经过它(:368:371:391:398:426:430:449),不会漏。

10. 边界与局限

诚实列一下这套机制会在哪里咬人:

  • 同 memoryId 并发不安全。 源码注释明说无防护(AiServices.java:76-78)。
  • 代理对象的 equals 是引用相等(DefaultAiServices.java:127),别当值对象用。
  • 参数名依赖编译选项。 不加 -parameters 且不写 @V,变量名会变成 arg0(ParameterNameResolver.java:44)。
  • @Moderate 拦不住"内容被发出去"。 只能拦住"结果被返回"(§4.5)。
  • JSON Schema 能力只看 chatModel supportsJsonSchema()streamingChatModel 分支不存在(DefaultAiServices.java:518-521),流式路径本来也跳过 schema。
  • validateParameters 只在参数 ≥ 2 时生效(AiServiceValidation.java:81-83),单参数方法拼错变量名不会在此处报错。
  • evictChatMemory 在单例记忆模式下会抛 NPE。 它无条件操作 chatMemories(ChatMemoryService.java:41-43),而这个字段在单例构造器里根本没被初始化(:26-28:18);异常经 DefaultAiServices.java:103 原样抛给调用方。要驱逐记忆就得用 chatMemoryProvider(§6.3)。
  • 中央循环是一个约 260 行的匿名内部类方法(DefaultAiServices.java:188-450),分支密集;这也是它值得单独一章讲的原因。

11. 代码地图

主题文件路径符号名
装配 builder 与全部开关langchain4j/src/main/java/dev/langchain4j/service/AiServices.javaAiServicesbuilderperformBasicValidationverifyModerationIfNeeded
代理生成与四路分流langchain4j/src/main/java/dev/langchain4j/service/DefaultAiServices.javabuildhandleChatMemoryAccess
中央循环langchain4j/src/main/java/dev/langchain4j/service/DefaultAiServices.javainvokefireEventAndReturn
提示词组装langchain4j/src/main/java/dev/langchain4j/service/DefaultAiServices.javaprepareSystemMessagefindSystemMessageTemplateprepareUserMessagegetUserMessageTemplategetTemplateaddContentsToUserMessage
输出格式决策langchain4j/src/main/java/dev/langchain4j/service/DefaultAiServices.javasupportsJsonSchemaappendOutputFormatInstructions
审核langchain4j/src/main/java/dev/langchain4j/service/DefaultAiServices.javatriggerModerationIfNeededModerate
memoryId 提取langchain4j/src/main/java/dev/langchain4j/service/DefaultAiServices.javafindMemoryIdfindUserName
装配结果载体langchain4j/src/main/java/dev/langchain4j/service/AiServiceContext.javaAiServiceContexthasChatMemoryinitChatMemoriesguardrailService
模板变量解析langchain4j/src/main/java/dev/langchain4j/service/InternalReflectionVariableResolver.javafindTemplateVariablesgetValueOfVariableItasString
参数名解析 SPIlangchain4j/src/main/java/dev/langchain4j/service/ParameterNameResolver.javaParameterNameResolverDefaultParameterNameResolver
接口与参数校验langchain4j/src/main/java/dev/langchain4j/service/AiServiceValidation.javavalidatevalidateMethodvalidateParameters
请求参数合并langchain4j/src/main/java/dev/langchain4j/service/AiServiceParamsUtil.javachatRequestParametersfindArgumentOfType
多会话记忆隔离langchain4j/src/main/java/dev/langchain4j/service/memory/ChatMemoryService.javaChatMemoryServicegetOrCreateChatMemoryevictChatMemoryDEFAULT
记忆访问接口langchain4j/src/main/java/dev/langchain4j/service/memory/ChatMemoryAccess.javaChatMemoryAccess
记忆抽象langchain4j-core/src/main/java/dev/langchain4j/memory/ChatMemory.javaChatMemory
条数窗口langchain4j/src/main/java/dev/langchain4j/memory/chat/MessageWindowChatMemory.javaMessageWindowChatMemoryensureCapacity
token 窗口langchain4j/src/main/java/dev/langchain4j/memory/chat/TokenWindowChatMemory.javaTokenWindowChatMemoryensureCapacity
记忆工厂langchain4j/src/main/java/dev/langchain4j/memory/chat/ChatMemoryProvider.javaChatMemoryProvider
流式返回类型langchain4j/src/main/java/dev/langchain4j/service/TokenStream.javaTokenStream
流式实现与启动langchain4j/src/main/java/dev/langchain4j/service/AiServiceTokenStream.javaAiServiceTokenStreamstartvalidateConfigurationinitTemporaryMemory
流式回调处理langchain4j/src/main/java/dev/langchain4j/service/AiServiceStreamingResponseHandler.javaAiServiceStreamingResponseHandleronCompleteResponsemessagesToSendgetMemory
流类型适配 SPIlangchain4j/src/main/java/dev/langchain4j/spi/services/TokenStreamAdapter.javaTokenStreamAdapter
Kotlin Flow 适配实现langchain4j-kotlin/src/main/kotlin/dev/langchain4j/kotlin/service/TokenStreamToStringFlowAdapter.ktTokenStreamToStringFlowAdapter
请求执行与事件langchain4j-core/src/main/java/dev/langchain4j/guardrail/AbstractChatExecutor.javaAbstractChatExecutorexecuteInternalfireRequestIssuedEvent
同步执行器langchain4j-core/src/main/java/dev/langchain4j/guardrail/SynchronousChatExecutor.javaSynchronousChatExecutor
调用上下文langchain4j-core/src/main/java/dev/langchain4j/invocation/InvocationContext.javaInvocationContext
事件与监听注册langchain4j-core/src/main/java/dev/langchain4j/observability/api/AiServiceListenerRegistrar.javaAiServiceListenerRegistrarfireEvent
提示词模板渲染langchain4j-core/src/main/java/dev/langchain4j/model/input/PromptTemplate.javaPromptTemplateapplyinjectDateTimeVariables

相关章节

  • 01-core-abstractions —— ChatModel/StreamingChatModel、消息模型、100+ 集成如何共存
  • 03-tool-calling —— toolService.createContextexecuteInferenceAndToolsLoop 内部
  • 04-structured-output-and-guardrails —— ServiceOutputParserGuardrailServiceToolAwareRepromptExecutor 内部
  • 05-rag —— RetrievalAugmentor.augment 背后的摄取与检索流水线
  • 06-agentic —— 多智能体编排如何复用同一套 AI Service 机制