1. 背景
在这里,我主要分享的是在应用层面大模型相关的技术,假如你已有一个现成的大模型接口,无论是符合OpenAI规范的,还是各家公司一些自己的接口,例如Gemini,Deepseek,通义千问,问心一言等,让用这些大模型来构建一些应用,可以选取下面的方案:
使用低代码大模型应用搭建平台,例如Coze,Dify, FastGPT等,这些平台带了流程编排,知识库,也很方便的和各种大模型对接,向量模型、向量库,有些还有了监控界面或者插件市场等,能满足我们的大部分需求
使用编程的方式来构建应用,这种可以使用公司现有的技术栈,提供更为灵活的使用,接入现有的系统等,或者从更高层面来说,定制自己的大模型应用规范,定制大模型应用构建平台,接入平台等;也可以把上面所说的低代码平台看作为自建大模型应用体系的一部分,即可以通过代码的方式灵活去构建应用,也通过平台更高效的去构建应用
我们后面讲的主要是使用第二种方式,我们选取一些现有的框架来实现,Python主要Langchain相关技术,Java也有一个对应的框架Langchain4j,也有SpringAI
这些框架帮我们做了很多事情:
封装一个通用的模型调用接口,屏蔽了底层不同公司大模型接口的差异
管理了会话和上下文,会话就是将用户之前问的问题和现在问的问题关联起来
结构化输出,将大模型的文本输出转为程序可以使用的结构化对象,例如JSON对象
工具/函数调用
可观测性
模型效果评估
一个框架LiteLLM专门把不同大模型接口适配为OpenAI格式的,这也是一种屏蔽差异的方式
2. 架构
.png)
整体架构如上,下面主要介绍部分实现
模型框架层,SpringAI,Java大模型应用开发框架
模型推理能力,Ollama,利用本地CPU或GPU,和现有训练模型,实现模型推理能力,便于开发,生产环境需要替换
监控追踪,LangFuse,追踪大模型应用请求,例如输入输出,耗时、Token消耗等
3. 实现
3.1 安装
Ollama和LangFuse参照文档安装和启动
SpringAI通过pom导入,除此之外,还导入了springboot ollama、opentelemetry等相关包,后者是为了接入LangFuse
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.jd.jt</groupId>
<artifactId>ai-base</artifactId>
<version>1.0-SNAPSHOT</version>
<name>Archetype - ai-base</name>
<url>http://maven.apache.org</url>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.3</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.opentelemetry.instrumentation</groupId>
<artifactId>opentelemetry-instrumentation-bom</artifactId>
<version>2.17.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.0.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-ollama</artifactId>
</dependency>
<!-- Spring AI needs a reactive web server to run for some reason-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry.instrumentation</groupId>
<artifactId>opentelemetry-spring-boot-starter</artifactId>
</dependency>
<!-- Spring Boot Actuator for observability support -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Micrometer Observation -> OpenTelemetry bridge -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<!-- OpenTelemetry OTLP exporter for traces -->
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.38</version>
</dependency>
</dependencies>
</project>
3.2 起步示例
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
@RestController
@Slf4j
class MyController {
private final ChatClient chatClient;
public MyController(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder.build();
}
@GetMapping(value = "/ai-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
Flux<String> generationStream(@RequestParam("userInput") String userInput) {
return this.chatClient.prompt()
.user(userInput)
.stream()
.content();
}
@GetMapping("/ai")
String generation(String userInput) {
return this.chatClient.prompt()
.user(userInput)
.call()
.content();
}
application.properties
spring.ai.ollama.chat.enabled=true
spring.ai.model.chat=ollama
spring.ai.ollama.chat.options.model=qwen3:8b
spring.ai.chat.observations.include-prompt=true
spring.ai.chat.observations.include-completion=true
management.tracing.sampling.probability=1.0
执行命令
curl --location 'http://localhost:8080/ai?userInput=%E4%BD%A0%E5%A5%BD'
<think>
嗯,用户发来“你好”,我需要以自然的方式回应。首先,应该用中文回复,保持友好和亲切的语气。可以简单问候,比如“你好!有什么我可以帮助你的吗?”这样既回应了对方的问候,又主动询问是否需要帮助,符合我的设计原则。同时,要避免使用复杂或生硬的表达,让对话显得轻松。另外,考虑到用户可能有各种需求,保持开放式的提问可以引导他们进一步说明具体问题,这样我才能更好地提供帮助。确保回复简洁明了,符合日常交流的习惯。
</think>
你好!有什么我可以帮助你的吗?😊
3.3 提示词
3.3.1 提示词模板
将变量嵌入提示词模板中,动态生成提示词
public String generateChildName() {
PromptTemplate promptTemplate = new PromptTemplate("""
男方姓:{maleName}
女方姓:{femaleName}
帮孩子起名
""");
Prompt prompt = promptTemplate.create(Map.of("maleName", "宋", "femaleName", "刘"));
return chatClient.prompt(prompt).call().content();
}
执行命令
curl --location 'http://localhost:8080/get-name
<think>
嗯,用户让我帮忙给孩子起名,男方姓宋,女方姓刘。首先,我需要了解用户的需求。他们可能想要一个结合双方姓氏的名字,或者更倾向于其中一个姓氏。不过通常在中国,孩子跟父姓,所以可能主要用宋姓,但有时候也会考虑双姓或者结合双方姓氏的元素。
...
提示词模板也可以通过Resource的方式使用,这样提示词可以存在任何地方了,工程文件、数据库、配置中心等,把提示词模板放在外部,更容易管理和实时变更,我们也可以通过第三方库来实现这个功能
3.3.2 提示词管理
提示词管理是将提示词单独存储在工程外部,提供一些易用的功能,例如版本控制,搜索提示词等。
我们这用了LangFuse,所以只要把它接入我们的项目即可
在界面上面创建提示词模板,并发布
在Java项目中接入,因为LangFuse没有Java的SDK,所以我们需要使用Api的方式
<dependency>
<groupId>com.github.lianjiatech</groupId>
<artifactId>retrofit-spring-boot-starter</artifactId>
<version>3.2.0</version>
</dependency>
创建LangFuseClient Http请求Client,改造之前写generateChildName方法,从LangFuse获取提示词
@RetrofitClient(baseUrl = "http://localhost:3000")
public interface LangFuseClient {
@GET("api/public/v2/prompts/{promptName}")
@Headers("Authorization: Basic cGstbGYtNDFmZjZkYjItZWZjNC00YTg4LTkyNmItZmMxZDE1ZGUwNGNiOnNrLWxmLTYzN2RmMDE0LWVkZDItNDdhNi1iNmUwLTE0N2U2MjMyOWYyMQ==")
LFPrompt getPrompts(@Path("promptName") String promptName);
}
public String generateChildName() {
LFPrompt lfPrompt = langFuseClient.getPrompts("起名");
PromptTemplate promptTemplate = new PromptTemplate(lfPrompt.getPrompt());
Prompt prompt = promptTemplate.create(Map.of("maleName", "宋", "femaleName", "刘"));
return chatClient.prompt(prompt).call().content();
}
执行命令,查看结果
curl --location 'http://localhost:8080/get-name'
<think>
嗯,用户让我帮忙给孩子起名,男方姓宋,女方姓刘。首先,我需要确认用户的需求。他们可能希望名字中包含双方的姓氏,或者结合两者的元素。不过,用户没有明确说明是否要双姓,所以可能需要考虑不同的可能性。
接下来,我得考虑名字的寓意和音韵。中文名字通常讲究平仄搭配,读起来顺口,同时要有好的寓意。比如,宋和刘的组合,可能需要找一个字来连接,或者用两个字分别代表双方的姓氏。
然后,用户要求输出在30个字以内,所以每个名字要简洁。可能需要列出多个选项,让用户有选择的余地。同时,要注意避免生僻字,确保名字的易读性和美观性。
3.3.3 提示词工程
提示词工程是通过关注提示词开发和优化,提升大语言模型处理复杂任务场景的能力
具体可以参考提示工程指南
主要涉及了:
提示词的基本结构,例如包含指令、上下文,用户输入,输出指示
通用技巧
提示词技术,零样本提示,少样本提示,链式思考
如果有现成的提示词,可以直接拿过来用,开源的提示词awesome-chatgpt-prompts,商业的提示词promptbase,这样的网站很多,甚至可以使用大模型来生成提示词,通常会比自己从头开始写效率会高
3.4 结构化输出
将大模型的文本输出转化为固定结构
public List<String> generateChildName() {
LFPrompt lfPrompt = langFuseClient.getPrompts("起名");
PromptTemplate promptTemplate = new PromptTemplate(lfPrompt.getPrompt());
Prompt prompt = promptTemplate.create(Map.of("maleName", "宋", "femaleName", "刘"));
return chatClient.prompt(prompt).call().entity(new ListOutputConverter(new DefaultConversionService()));
}
执行命令,可以看到返回结果变为了数组格式,但并不完美,通过提示词关闭think内容后,格式上面还是有一些问题,后续SpringAI会支持通过参数的方式关闭think过程
curl --location 'http://localhost:8080/get-name'
[
"<think>\n\n</think>\n\n宋明轩",
"宋子涵",
"宋浩然",
"宋梓睿",
"宋俊熙",
"宋天宇",
"宋浩宇",
"宋梓豪",
...
]
可以从提示词中,看到Spring AI为我们加了约束输出的提示词,输出结果也是按照这个格式,这个截图是LangFuse的监控的一部分,可以看到请求大模型的记录
另外可以将结果转为Map或者Java的某个具体Class
3.5 会话和上下文
在使用大模型应用的过程中,通常会有两种模式
单轮对话,也就是一问一答,之前问过的问题不会再考虑
多轮对话,在整个对话周期呢,大模型会关联之前的问题,综合之后给出回复
要实现多轮对话的功能,就需要我们记录是否是一次会话和会话期间的上下文,因为大模型本身是无状态的,它自己不记录这些内容
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.ai.converter.ListOutputConverter;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
@Service
public class ChatService {
private final ChatClient chatClient;
public ChatService(ChatClient.Builder chatClientBuilder) {
ChatMemory chatMemory = MessageWindowChatMemory.builder().maxMessages(10).build();
this.chatClient = chatClientBuilder
.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
.build();
}
public List<String> generateChildName(String conversationId, String surname, String gender) {
// 系统提示词
String systemMessage = "你是一个专业的起名专家,擅长根据中国传统文化和现代审美为孩子起名。请确保名字寓意美好、朗朗上口。";
// 用户提示词模板
String userMessageTemplate = """
请为姓{surname}的{gender}孩子起名,要求:
1. 输出不超过10个名字
2. 每个名字都要有寓意说明
3. 名字要符合现代审美""";
PromptTemplate userPromptTemplate = new PromptTemplate(userMessageTemplate);
Prompt userPrompt = userPromptTemplate.create(Map.of(
"surname", surname,
"gender", gender
));
return chatClient
.prompt()
.system(systemMessage) // 系统提示词
.user(userPrompt.getContents()) // 用户提示词
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
.call()
.entity(new ListOutputConverter(new DefaultConversionService()));
}
}
当发了两次请求之后,从回复的内容可以看看,大模型知道问了两次
["<think>\n好的,用户之前让我为姓宋的男孩起名,现在又发了一个类似的请求,但这次要求输出格式是逗号分隔的列表,没有其他文本。我需要仔细分析用户的需求。\n\n首先,用户可能是在测试我是否能按照新的格式要求输出,或者他们需要将名字直接用于某个系统,比如注册表单或应用程序,所以需要简洁的格式。之前的回复是中文名字和寓意,现在需要转换为只列出名字,用逗号分隔。\n\n接下来,我需要确保每个名字都符合现代审美,同时保持寓意美好。用户之前提供的例子中有“宋子墨”、“宋知远”等,这些名字都比较文雅,符合传统文化,同时又不失现代感。我需要保持这种风格,避免生僻字,确保名字朗朗上口。\n\n另外,用户可能希望名字有独特的寓意,同时避免重复。比如“宋明轩”中的“明”象征光明,“轩”有气度的意思,这样的组合既传统又现代。我需要检查每个名字的寓意是否明确,并且没有重复的字。\n\n还要注意名字的结构,姓氏“宋”是单姓,名字通常为两个字,所以每个名字都是两个字的组合。需要确保每个名字都符合这个结构,并且整体看起来协调。\n\n最后,用户要求不超过10个名字,所以我要控制数量,确保每个名字都经过筛选,符合所有要求。同时,输出格式必须严格遵循逗号分隔,没有其他文字,这可能需要在生成时特别注意格式的正确性,避免任何多余的字符或空格。\n</think>\n\n宋知远","宋云舟","宋景行","宋清晏","宋修远","宋墨言","宋怀瑾","宋承泽","宋明轩","宋致远"]
3.6 工具/函数调用
利用外部的工具,函数现有的能力,同时利用大模型的能力,结合起来完成复杂的任务,例如:
某些数学计算函数,天气查询服务,下单服务,订票服务,发邮件等,利用这些服务可以扩展大模型的能力
又例如通过数据库查询信息,文档库查询文档
工具类
import org.springframework.ai.tool.annotation.Tool;
public class TicketUtil {
@Tool(description = "输入出发地和目的地,购买火车票")
public static void buyTicket(String source, String target) {
System.out.println("已购买" + source + "到" + target + "的票");
}
}
大模型请求时,使用tool方法
import com.jd.ai.util.TicketUtil;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;
@Service
public class TicketService {
private final ChatClient chatClient;
public TicketService(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder.build();
}
public String buyTicket() {
return chatClient
.prompt("帮我买北京到晋城的车票")
.tools(new TicketUtil())
.call()
.content();
}
}
大模型回复:
好的,用户让我帮忙买北京到晋城的车票。首先,我需要确认出发地和目的地是否正确。用户已经明确说明是北京到晋城,所以直接调用buyTicket函数,参数是source:北京,target:晋城。没有其他参数需要处理,比如日期或座位类型,所以直接生成工具调用。然后,系统返回“Done”,说明操作成功。接下来,我应该告诉用户购票成功,并询问是否需要进一步帮助,比如确认车次或办理乘车证。这样既完成了购票,又提供了后续支持,确保用户满意。 您的北京至晋城车票已成功购买!请问是否需要帮您查询具体车次信息或办理乘车证相关手续呢?
控制台输出:
已购买北京到晋城的票
3.7 知识库和RAG
RAG(Retrieval Augmented Generation)是检索增强生成,简单来讲通过提前检索一些知识,将这些知识和用户的问题一起给大模型,会提高大模型回复的质量
3.7.1 知识库
对于通过分词的检索系统来讲,知识库一般是通过语义来检索的,要实现语义检索,需要对数据做向量化,所以需要向量模型和向量库
在这里我们用SimpleVectorStore来做向量库,这是一个内存库
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.SimpleVectorStore;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class VectorStoreConfiguration {
@Bean
public VectorStore vectorStore(EmbeddingModel embeddingModel) {
return SimpleVectorStore.builder(embeddingModel).build();
}
}
使用mxbai-embed-large作为向量模型,这个模型也使用了ollama的功能,且在SpringAI中不需要额外的配置就可以使用,当然也可以参照文档做一些定制配置,这里我们存了一些豆瓣的电影信息到里面
import jakarta.annotation.PostConstruct;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.JsonReader;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.FileSystemResource;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class MovieService {
private final ChatClient chatClient;
@Autowired
VectorStore vectorStore;
@PostConstruct
void load() {
String sourceFile = "/Users/songjiyang.3/Downloads/less_douban_movie.json";
JsonReader jsonReader = new JsonReader(new FileSystemResource(sourceFile),
"title", "year", "id", "role_desc", "original_title");
List<Document> documents = jsonReader.get();
this.vectorStore.add(d