AI 服务
何为AI服务,与LangChain4j的两个层级又有什么关系
还记得前面提到过的,LangChain4j 把整个框架分成两层,一层底层、一层高级,分工完全不同:
基础组件层:就是前面提到的,ChatModel,ChatMessage,ChatMemory等等内容,我们很容易发现它们虽然使用起来非常的灵活,几乎是想怎么拼就怎么拼,但是重复代码和样板代码是真没少写。在这个层面工作非常灵活,赋予你完全的自由,但也意味着需要编写大量的样板代码。
但是由于基于 LLM 的应用程序通常需要多个组件协同工作 (例如,提示模板、聊天记忆、LLM、输出解析器、RAG 组件:嵌入模型和存储),并且通常涉及多轮交互,因此对它们的编排变得愈加繁琐。LangChain4j 希望你能专注于业务逻辑,而不是底层实现的细节。为此,LangChain4j 目前提供了更高级的一层
编排抽象层:框架帮你把底层组件自动组装、自动管理,你不用写重复代码,只关注业务。两个核心高层工具分别是
- AI Service 也就是 AI 服务
- Chains 用于多步骤复杂工作流的链
而 AI 服务 就是一个 Java 接口 + 注解,这样开发获得的全自动的 AI 能力,AI Service 是一个代理的设计模式,你定义接口,框架自动生成实现类,自动把所有底层组件组装好之后,你的调用非常简单,就像 Spring Service 一样使用了,而且能自动支持聊天记忆,工具,RAG等功能。
这种方法与 Spring Data JPA 或 Retrofit 非常相似,你声明性地定义一个具有所需 API 的接口,然后 LangChain4j 提供一个实现此接口的对象(代理)。实际上,你可以将 AI 服务看作应用程序中服务层的一个组件。
1 | // 只需要定义一个接口 |
AI 服务可用于构建有状态的聊天机器人,以促进来回交互,也可用于自动化流程,其中每次对 LLM 的调用都是独立的。
而链下面再说具体的内容,它是一个 多步骤、固定流程 的 AI 任务,就类似于编排一个以 LLM 为核心的工作流
项目示例
首先,我们 AI 服务在本章主要以 AI Services + Prompt + Structured Output 的形式来进行,因为结构化输出也很重要,但是目前本身先展示 AI Services 更强相关的内容
在引入了某个具体集成的 Starter 后,再额外引入:
1 | <dependency> |
流式对话 AI Service 接口
1 | /** |
其中,可以看到,我们只是使用接口,在接口中声明的方法添加提示词相关的注解,就可以进行 AI Service 层的开发,而且完全屏蔽了底层大模型调用、会话记忆管理、消息封装的细节
其中的接口无需实现,由 Spring 配置(AiServiceConfig 中的
AiServices.builder())生成动态代理 Bean,注入到容器中。
其中涉及到了四个注解
@SystemMessage:定义系统提示词,对整个方法有效,支持{{变量名}}模板语法(模板变量必须用@V绑定)1
2
3
4
5@UserMessage:定义用户消息模板,绑定方法参数作为用户输入1
2
3
4
5
6
7
8
9
10
11
12// 三种写法
// 无模板
String chat(String message);
// 模板 + 单个参数
// {{it}} = 唯一参数的简写
String explain(String topic);
// 多参数模板
String answer(String question, String lang);@MemoryId:标注会话 ID 参数,框架根据此 ID 隔离不同会话的记忆(ChatMemory),如果不加,所有对话共享一个记忆,加了的话,对于不同 ID 就拥有不同对话上下文1
TokenStream chat( String userId, String msg);
@V("变量名"):为@SystemMessage中的模板变量绑定方法参数值,只用于系统提示词模板,用户消息模板不需要它,会自动匹配参数名1
2
3
4
5
6
7
TokenStream chat(
String id,
String role, // 绑定 {{role}}
String domain, // 绑定 {{domain}}
String msg
);
其中,对于服务接口中的基础流式对话 chat()方法
1 |
|
返回类型
TokenStream是 LangChain4j 流式响应的核心类型,调用方可以通过链式 API 消费实时返回的 token1
2
3
4
5// 调用示例
assistantService.chat("session-001", "什么是LangChain4j?")
.onPartialResponse(token -> System.out.print(token)) // 实时接收每个token
.onCompleteResponse(full -> System.out.println("\n完成"))
.start(); // 启动流式输出
而对于角色扮演流式对话
chatWithRole()方法,这部分演示了动态系统提示词的内容
1 |
|
@SystemMessage中使用{{role}}模板占位符,不再是固定角色- 而
@V("role") String role将方法参数role绑定到模板中的{{role}},实现动态角色设定
实际上,这部分对于记忆的管理依旧是本地,通常情况下,我们会自定义
ChatMemoryProvider 控制记忆的存储,而且为
@MemoryId 配置默认值,或通过 AOP 自动注入会话
ID,但是我们的这部分并没有演示相关内容
为什么需要一个编排层来实现流式请求
1 | /** |
首先,门面模式是一种结构型设计模式,核心目标是为复杂的子系统提供一个统一的入口,隐藏内部复杂逻辑,简化外部调用。
因为,在我们需要一个流式对话的情况,虽然在流式响应场景下,可以将 AI
Service 的返回类型声明为
Flux<String>来实现流式的情况,但是我更想用原生的
SSE,就需要这么一个TokenStream → SSE 的桥接逻辑
1 |
|
这是因为 LangChain4j AI Services 对 流式响应
有专属的返回类型约定,框架原生支持 TokenStream
作为流式返回类型,TokenStream 内置了
onPartialResponse()/onComplete() 等链式
API,但是若强行用 Flux<String>,需要手动将
TokenStream 转换为 Reactor 类型,这会带来成本
代码库中所有流式接口最终对外暴露的是
SseEmitter,因为我感觉,前端消费 AI 流式响应时,SSE 是比
raw Flux 更通用的选择,而 SseEmitter 是 Spring 对 SSE
的原生支持,不用特意去搞 Reactor
响应式和langchain4j-reactor
测试一下效果
先测试一下流式对话,嗯嗯,没什么问题
然后使用提示词模板注入的流式对话,可以发现,注入的效果还是很明显的
上述的示例项目中的关键内容
AI Service 如何编排的
在最简单的情况下,我们可以剥开我们的例子让它在更加简单的时候更加原始
首先,我们定义一个只有一个方法 chat
的接口,没实现类,它接受一个 String 作为输入并返回一个
String。什么意思,差不多就是,我只想要一个
聊天方法,输入一句话,返回一句话,我不在乎细节,不想写实现逻辑,不想调大模型,不想处理消息格式。
1 | interface Assistant { |
然后,我们创建对应的低级组件。这些组件将在我们的 AI 服务底层使用。
1 | ChatModel model = OpenAiChatModel.builder() |
它的用法很原始:
1 | model.chat(chatMessage); // 只能发 ChatMessage 类型,不能直接发 String |
最后,我们可以使用 AiServices 类来创建 AI
服务的一个实例,动态生成一个代理类:
1 | Assistant assistant = AiServices.create(Assistant.class, model); |
现在我们可以使用 Assistant 这个 AI Service 接口了
1 | String answer = assistant.chat("Hello"); |
那么,上面发生了什么
- 你将接口的
Class以及低级组件提供给AiServices,然后通过反射AiServices会创建一个实现此接口的代理对象。这个代理对象处理所有输入和输出的转换。 - 在本例中,输入是一个
String,但我们使用的是ChatModel,它接受ChatMessage作为输入。因此,AiService会自动将其转换为UserMessage并调用ChatModel。 - 由于
chat方法的输出类型是String,在ChatModel返回AiMessage后,它将在从chat方法返回之前被转换成String。
什么意思,也就是说,整个 AiServices.create
创建的代理类在你调用了String answer = assistant.chat("Hello");的时候做了这些事情
1 | 你传入 String "Hello" |
这么一个官方文档的例子你看完了就会发现,我草,我怎么看不懂。。。。因为实际上我们并非这样进行的开发,Spring Boot Starter 把上面所有代码全部自动帮你写了,声明了注解,就会有对应的东西自己产生
上面官方提到的示例中,interface Assistant { ... },实际上就是
1 |
|
ChatModel model = ...可以对应application.yml
中的配置然后自动创建
Bean,assistant.chat(...)相当于@Autowired
注入后直接调用,而AiServices.create(...)这步及其生成代理都是
Starter 自动执行了
1 |
|
1 |
|
对于流式响应的场景下,可以将 AI Service 的返回类型声明为
Flux<String>,需要引入
langchain4j-reactor 模块。
1 |
|
这下看懂了,实际上,AI Service (带@AiService的接口)
就是一个普通 Spring Bean,你可以注入到Controller,Service,Component
等任何 Spring 管理的类
包装返回类型
AI 服务方法可以返回以下类型之一:
String:在这种情况下,返回 LLM 生成的输出,不进行任何处理/解析- 结构化输出:支持的任何类型,在这种情况下,AI 服务将在返回前将 LLM 生成的输出解析为所需类型,下面再说
而且,任何类型都可以额外包装在 Result<T>
中,以获取有关 AI 服务调用的额外元数据:
TokenUsage:AI 服务调用期间使用的令牌总数。如果 AI 服务对 LLM 进行了多次调用(例如,因为执行了工具),它将对所有调用的令牌使用量求和。- RAG
检索期间检索到的
Content - AI 服务调用期间执行的所有工具(包括请求和结果)
- 最终聊天响应的
FinishReason - 所有中间
ChatResponse - 最终
ChatResponse
一个例子:
1 | interface Assistant { |
Spring Boot 集成的组件注入
如果上下文中存在以下组件,它们会被自动注入到 AI Service:
ChatModelStreamingChatModelChatMemoryChatMemoryProviderContentRetrieverRetrievalAugmentor- 所有
@Component或@Service类中带@Tool注解的方法
什么意思,差不多就是你只要把这些 Bean 放到 Spring 容器里, Starter
会自动绑定给 AI
Service。不需要写这种.chatModel(),.chatMemoryProvider()这种原始API的链式调用方法,全部自动注入
如果你有多个 AI Service,且需要为它们分别指定不同的组件,可以通过
@AiService(wiringMode = EXPLICIT) 来启用显式注入。
例如,假设配置了两个 ChatModel:
1 | # OpenAI |
可以这样指定:
1 |
|
而且,在完成声明式 AI Service
开发后,可以通过实现ApplicationListener<AiServiceRegisteredEvent>
来监听 AI Service 注册事件。该事件会在 AI Service 注册到 Spring
容器时触发,允许你在运行时获取已注册的 AI Service 及其工具信息
当 Spring 启动时,扫描到
@AiService然后生成代理实现类,把它放进 Spring
容器,我们就认为它注册好了一个 @AiService 接口,每注册好一个 @AiService
接口,就会触发一次 AiServiceRegisteredEvent
这个事件。你可以在这个事件里,拿到这个 AI
服务、知道它绑定了哪些工具。
1 |
|
其实这部分涉及到的自动注入和依赖装配的内容,跟 Spring Boot 一绑定起来讲还是很复杂的,但是我只说了官方文档中的内容,剩下的代理到底是怎么生成的,记忆RAG什么的如何自动注入的等,说实话我懒得讲了而且貌似 AI 开发不像微服务,业务开发什么的,这种东西没那么关注的必要
AI Service 涉及到的注解
其中
@AiService它类似于一个标准的 Spring Boot
@Service,但自带 AI 能力。应用启动时,LangChain4j Starter 会扫描所有带@AiService的接口,为每个接口生成实现类,并将其注册为 Spring Bean@SystemMessage定义系统提示词,对整个方法有效,支持
{{变量名}}模板语法(模板变量必须用@V绑定)1
2
3
4
5而且,
@SystemMessage还可以从资源中加载提示模板,例如@SystemMessage(fromResource = "my-prompt-template.txt")@UserMessage定义用户消息模板,绑定方法参数作为用户输入,对于绑定方法参数
注意,如果不是与 Quarkus 或 Spring Boot 一起使用 LangChain4j 时,使用
@V是必需的。只有在 Java 编译时未启用-parameters选项时,才需要此注解。1
2
3
4
5
6
7
8
9
10
11
12// 三种写法
// 无模板
String chat(String message);
// 模板 + 单个参数
// {{it}} = 唯一参数的简写
String explain(String topic);
// 多参数模板
String answer(String question, String lang);同样的,
@UserMessage也可以从资源中加载提示模板,@UserMessage(fromResource = "my-prompt-template.txt")@MemoryId标注会话 ID 参数,框架根据此 ID 隔离不同会话的记忆(ChatMemory),如果不加,所有对话共享一个记忆,加了的话,对于不同 ID 就拥有不同对话上下文
1
TokenStream chat( String userId, String msg);
@V("变量名")为
@SystemMessage中的模板变量绑定方法参数值,只用于系统提示词模板,用户消息模板不需要它,会自动匹配参数名1
2
3
4
5
6
7
TokenStream chat(
String id,
String role, // 绑定 {{role}}
String domain, // 绑定 {{domain}}
String msg
);@Moderate内容安全审核,自动过滤违规内容。如果内容违规,直接抛出异常,不发给大模型,也不返回违规内容。这个需要自己去配置一个
ModerationModel1
2
String chat( String msg);@Timeout设置 AI 调用超时时间,单位是秒,对外 API 接口一般加
1
2// 30秒
String ask( String msg);@Temperature设置模型温度(随机性),值越小,回答越准确、固定、严谨,值越大,回答越创意、发散、随机
1
2
String answer( String q);@MaxTokens最大输出 token 限制
1
2// 最多输出 50000 token
String shortAnswer( String question);
其中,没有被提到的结构化输出和工具调用的相关注解,等到用到的时候再细说




