数据截至 (上游 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):
| 顺序 | 判断条件 | 处理方式 | 行号 |
|---|---|---|---|
| 1 | method.isDefault() | 交回接口的 default 实现,不碰 LLM | :120-122 |
| 2 | method.getDeclaringClass() == Object.class | equals 用身份比较、hashCode 用 System.identityHashCode、toString 拼类名+hash | :124-136 |
| 3 | method.getDeclaringClass() == ChatMemoryAccess.class | 转 handleChatMemoryAccess,读写记忆而不调模型 | :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 和原始的 userMessageTemplate、variables(:234-241),想拿原文的护栏得自己从这里取。
4.3 输出格式:两条互斥的路
| 条件 | 走哪条 | 行号 |
|---|---|---|
模型声明支持 RESPONSE_FORMAT_JSON_SCHEMA 且非流式、非图片返回 | 生成 JsonSchema,后面塞进 ResponseFormat | :255-257、:309-315 |
| 不支持 schema,或 schema 生成为空 | 把自然语言格式指令追加到用户消息最后一段文本上 | :258-260 |
流式(TokenStream)或返回图片 | 两条都不走 | :255、:258 的 !streaming && !returnsImage |
能力判断只看 chatModel 的 supportedCapabilities()(DefaultAiServices.java:518-521)。
追加指令的做法有个细节:从后往前找最后一个 TextContent 拼上去,找不到才新增一段(:531-543)。这是为了在多模态消息里别把指令插到图片前面。