样章1:第3节 大模型对接:主流大模型接入与统一封装
在Java AI项目里,最基础也最容易做乱的一步,就是大模型对接。很多人上来就直接在业务代码里写HTTP调用,接口地址、鉴权Key、请求参数硬编码在各个方法里,等到要换模型、加降级、做统计的时候,整个代码全是重复逻辑,改一处动全身。
这一节我们就从最基础的大模型接入讲起,用LangChain4j的原生能力,结合设计模式,实现一套可扩展、易维护的大模型统一封装。学完这一节,你再对接新的大模型、加降级策略、做调用统计,都只需要改一处代码,不会侵入业务逻辑。
3.1 为什么一定要做统一封装
很多人觉得不就是调个HTTP接口吗,直接用OkHttp写也能用。但只要项目上生产,你就会遇到这些问题:
- 模型切换成本高:一开始用通义千问,后面想切换豆包、DeepSeek,每个业务接口都要改一遍请求参数和解析逻辑;
- 公共逻辑分散:鉴权、超时、重试、日志、Token统计,每个调用的地方都要写一遍,重复代码满天飞;
- 降级无法统一做:大模型故障的时候,要切换备用模型、降级为同步返回,没有统一入口就只能到处打补丁;
- 成本无法统计:每个模型的Token消耗、调用次数、响应时长,没有统一埋点,根本算不清成本。
而LangChain4j的核心价值之一,就是已经帮我们抽象了ChatLanguageModel这个统一接口,所有主流大模型都实现了这个接口。我们只需要配置不同的模型实现,业务代码面向接口编程,完全不用关心底层是哪家的模型。
3.2 LangChain4j原生接入主流大模型
LangChain4j官方已经适配了国内绝大多数主流大模型,我们不需要自己写HTTP客户端,只需要引入对应的依赖、配置参数,就能直接使用。
以SpringBoot项目为例,首先引入核心依赖:
xml
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-spring-boot-starter</artifactId>
<version>0.32.0</version>
</dependency>
<!-- 通义千问适配 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-qwen</artifactId>
<version>0.32.0</version>
</dependency>
然后在配置类里构建ChatLanguageModel实例:
java
@Configuration
public class LlmConfig {
@Bean
public ChatLanguageModel qwenChatModel() {
return QwenChatModel.builder()
.apiKey("你的API_KEY")
.modelName("qwen-plus")
.temperature(0.7)
.timeout(Duration.ofSeconds(60))
.build();
}
}
这样业务代码里直接注入ChatLanguageModel就可以调用了:
java
@Service
public class ChatService {
@Autowired
private ChatLanguageModel chatLanguageModel;
public String chat(String userMessage) {
return chatLanguageModel.generate(userMessage);
}
}
这个写法的好处是:业务代码完全不感知底层模型。如果后面想换成豆包模型,只需要新增一个ChatLanguageModel的Bean实现,不需要改任何业务代码。
3.3 生产级统一封装:工厂+策略模式
原生的用法已经够用,但上生产还不够。我们还需要支持多模型切换、降级策略、调用埋点、统一异常处理。这里我推荐用「工厂模式+策略模式」做一层封装,这也是我线上项目在用的方案。
第一步,定义模型类型枚举:
java
public enum LlmType {
QWEN_PLUS,
DEEPSEEK_CHAT,
DOUBAO_PRO
}
第二步,定义模型工厂,根据类型返回对应的ChatLanguageModel:
java
@Component
public class LlmModelFactory {
private final Map<LlmType, ChatLanguageModel> modelMap = new ConcurrentHashMap<>();
@PostConstruct
public void init() {
// 通义千问
modelMap.put(LlmType.QWEN_PLUS, QwenChatModel.builder()
.apiKey(llmProperties.getQwen().getApiKey())
.modelName("qwen-plus")
.temperature(0.7)
.timeout(Duration.ofSeconds(60))
.build());
// DeepSeek
modelMap.put(LlmType.DEEPSEEK_CHAT, DeepSeekChatModel.builder()
.apiKey(llmProperties.getDeepseek().getApiKey())
.modelName("deepseek-chat")
.temperature(0.7)
.timeout(Duration.ofSeconds(60))
.build());
}
public ChatLanguageModel getModel(LlmType type) {
ChatLanguageModel model = modelMap.get(type);
if (model == null) {
throw new IllegalArgumentException("不支持的模型类型:" + type);
}
return model;
}
}
第三步,封装统一的调用服务,加入日志、异常处理、埋点:
java
@Service
@Slf4j
public class LlmService {
@Autowired
private LlmModelFactory llmModelFactory;
public LlmResponse generate(LlmRequest request) {
long start = System.currentTimeMillis();
LlmType modelType = request.getModelType() == null
? LlmType.QWEN_PLUS
: request.getModelType();
try {
ChatLanguageModel model = llmModelFactory.getModel(modelType);
String result = model.generate(request.getPrompt());
long cost = System.currentTimeMillis() - start;
log.info("大模型调用成功,模型:{},耗时:{}ms", modelType, cost);
// 这里可以加调用统计、Token消耗埋点
return LlmResponse.success(result);
} catch (Exception e) {
log.error("大模型调用失败,模型:{}", modelType, e);
// 可以在这里加降级逻辑:切换备用模型
return LlmResponse.error("大模型调用失败");
}
}
}
3.4 踩坑经验与最佳实践
- 不要硬编码API Key和模型参数:全部放到配置文件里,不同环境用不同的Key和模型版本;
- 超时时间一定要单独设置:大模型接口不是普通HTTP接口,推理时间可能很长,读超时至少设置60秒以上,流式输出要设置到5分钟;
- 必须加异常捕获和降级:大模型接口随时可能超时、报错,生产环境一定要有备用模型或者降级方案,不能因为大模型挂了导致整个业务不可用;
- 统一日志和埋点:所有调用都要记录模型类型、输入输出长度、耗时、错误码,方便后续排查问题和统计成本;
- 面向接口编程 :业务代码永远只依赖
ChatLanguageModel接口,不要直接依赖具体的模型实现类,否则后面换模型会非常痛苦。
这一套封装做完,你的大模型调用层就具备了生产级的可维护性。后面不管是加新模型、做限流、加缓存、统计成本,都只需要在这一层修改,业务代码完全不用动。这也是Java AI项目最基础的架构意识,很多人上来就写业务,到后面越写越乱,根源就是没有在一开始做好分层和抽象。
样章2:第10节 检索优化:查询改写、路由与召回调优
很多人做RAG,做到最后都会遇到一个瓶颈:召回的内容不准,要么漏了关键信息,要么召回一堆无关内容,导致大模型回答错误。大部分人的第一反应是换嵌入模型、加大TopK数量,但其实80%的召回问题,都可以通过检索侧的优化解决,不用换模型也不用加成本。
这一节我们就讲RAG里最核心的检索优化手段:查询改写、查询路由、索引优化、召回策略。这些都是我线上项目反复调试验证过的方案,落地之后问答准确率能提升15%以上。
10.1 为什么原始检索效果差
先搞清楚一个问题:用户的提问,为什么直接去向量库检索效果不好?
核心原因有三个:
- 查询歧义:用户的提问很简短,比如"怎么对接?",没有上下文,向量相似度计算根本不知道你要对接什么;
- 表述差异:用户的用词和文档里的用词不一样,比如用户说"限流",文档里写的"流量控制",向量距离就会变大,排不到前面;
- 多意图查询:用户一句话包含多个问题,比如"怎么对接大模型,怎么控制成本?",向量检索会偏向其中一个意图,另一个的内容召回不到。
所以检索优化的核心思路,不是直接拿用户的原始提问去查向量库,而是先对查询做一层「预处理」,再去检索。这一步做好了,效果提升非常明显。
10.2 查询改写(Query Rewrite)
查询改写是成本最低、见效最快的优化手段。原理很简单:用大模型把用户的原始提问,改写成更适合检索的查询语句。
常见的改写策略有三种:
- 补全上下文:把对话历史加进去,把指代不清的问题补全。比如用户问"它支持流式输出吗?",改写后变成"LangChain4j支持流式输出吗?";
- 同义词扩展:把用户的用词扩展成行业通用表述,增加匹配概率;
- 多查询生成:把一个原始问题,生成3-5个不同表述的查询,分别去检索,合并结果。
其中性价比最高的是多查询生成。我们用LangChain4j很容易实现:
java
@Service
public class QueryRewriteService {
@Autowired
private ChatLanguageModel chatLanguageModel;
/**
* 多查询改写:生成3个不同表述的检索查询
*/
public List<String> multiQueryRewrite(String userQuery) {
String prompt = """
你是一个专业的查询改写助手。请根据用户的原始问题,生成3个不同表述、
适合用于向量知识库检索的查询语句。要求:
1. 每个查询独立完整,表述不同,但语义相近
2. 扩展可能的同义词和行业术语
3. 直接输出查询内容,每行一个,不要编号,不要多余解释
用户原始问题:%s
""".formatted(userQuery);
String result = chatLanguageModel.generate(prompt);
return Arrays.stream(result.split("\n"))
.map(String::trim)
.filter(s -> !s.isEmpty())
.collect(Collectors.toList());
}
}
然后检索的时候,用这3个查询分别去检索,合并去重:
java
public List<Document> multiRetrieve(List<String> queries, int topK) {
Set<Document> resultSet = new HashSet<>();
for (String query : queries) {
List<Document> docs = embeddingStore.similaritySearch(query, topK);
resultSet.addAll(docs);
}
return new ArrayList<>(resultSet);
}
这个方案的优点是实现简单,效果提升明显;缺点是会增加检索次数和大模型调用成本。建议对准确率要求高的场景开启,成本敏感的场景可以只用单查询改写。
10.3 查询路由(Query Routing)
当你的知识库有多个分类、多个向量集合的时候,不要所有问题都去全库检索,那样既慢又容易召回无关内容。这时候就需要查询路由:先判断用户的问题属于哪个分类,再路由到对应的向量库去检索。
比如你的知识库分「产品文档」「故障排查」「运营规则」三类,就可以先让大模型做分类:
java
public String routeQuery(String userQuery) {
String prompt = """
请判断用户的问题属于以下哪个分类:产品文档、故障排查、运营规则。
只输出分类名称,不要多余内容。
用户问题:%s
""".formatted(userQuery);
String category = chatLanguageModel.generate(prompt).trim();
// 路由到对应的向量集合
return switch (category) {
case "产品文档" -> "collection_product";
case "故障排查" -> "collection_trouble";
case "运营规则" -> "collection_operation";
default -> "collection_all";
};
}
查询路由的价值在于:
- 缩小检索范围,提升检索速度;
- 避免其他分类的无关内容干扰,提升准确率;
- 不同分类可以用不同的嵌入模型和索引参数,针对性优化。
10.4 索引与检索参数调优
除了查询侧的优化,向量库本身的参数也很重要,很多人从来没调过,用默认参数跑,效果当然不好。
以Milvus为例,核心调优点:
- 索引类型选择:百万级数据以内用IVF_FLAT,追求速度用HNSW;不要上来就用HNSW,数据量小的时候IVF_FLAT更快更准;
- nprobe参数:检索的时候,nprobe设置为nlist的5%-10%,比如nlist=1024,nprobe就设64-128;nprobe越大召回率越高,但速度越慢,需要做权衡;
- TopK设置:不是越大越好,TopK太大反而会引入很多噪声。一般初始设20-30,然后根据业务场景调整;
- 相似度阈值:加一个最小相似度阈值,低于阈值的结果直接过滤掉,不要返回给大模型,避免干扰回答。
10.5 检索优化的最佳实践
- 优化顺序:先做查询改写,再做路由,最后调索引参数;不要上来就换嵌入模型,那是成本最高的手段;
- 数据驱动:不要凭感觉调,一定要埋点记录「检索召回的文档有没有被大模型引用」「哪些问题回答错误是因为召回不准」,针对性优化;
- 分级策略:简单问题用单查询+单库检索,复杂问题开启多查询+全库检索,平衡成本和效果;
- 不要过度依赖向量检索:向量检索是召回的核心,但一定要配合关键词检索做混合检索,才能兼顾语义匹配和精确匹配。
检索优化是RAG系统里最有技术含量的环节,也是拉开普通Demo和生产级系统差距的地方。很多人做RAG只做到"能跑",但线上效果差,核心就是检索这一步没做深。把这一节的方案落地,你的RAG系统准确率会有非常直观的提升。
样章3:第17节 SSE流式输出全链路实现与坑点排查
流式输出是大模型应用的标配功能,能极大提升用户体验。但很多人做流式输出,都会遇到一个问题:本地测试好好的,一上生产就频繁断流、卡顿,用户体验极差。然后去网上搜解决方案,90%的文章只会告诉你"把超时调长一点",但你调完发现该断还是断。
本质原因在于:流式输出不是单点问题,而是大模型客户端→后端服务→Nginx网关→浏览器前端整条链路的问题,任何一个节点配置不对,都会导致断流或者卡顿。这一节我们就把整条链路的坑全部讲透,每个节点都给完整的生产级配置,照着做就能实现稳定的流式输出。
17.1 先分清:断流和卡顿不是一回事
排查之前先分清楚现象,原因完全不一样:
- 断流:连接直接断开,输出停在一半,前端需要重新发起请求。原因是某个节点超时断开了TCP连接。
- 卡顿:连接没断,但数据一卡一卡的,半天出一个字。原因是某个节点开启了响应缓冲,数据攒一批才发一次。
很多人排查的时候分不清,乱调一通参数,当然解决不了问题。
17.2 节点一:大模型客户端的两个坑
第一个坑在最底层的大模型HTTP客户端,很多人用默认配置,直接踩两个雷:读超时太短、开启了响应缓冲。
坑1:读超时设置太短
大模型流式输出,第一个Token需要等模型推理,通常要2-5秒,长回答甚至要十几秒。如果HTTP客户端的读超时用默认的10秒,遇到模型慢一点或者输出长一点,直接就超时断连了。
正确配置(OkHttp):
java
@Bean
public OkHttpClient llmStreamOkHttpClient() {
return new OkHttpClient.Builder()
.connectTimeout(Duration.ofSeconds(10))
.readTimeout(Duration.ofMinutes(5)) // 流式输出读超时必须设长,建议5分钟
.writeTimeout(Duration.ofSeconds(30))
.retryOnConnectionFailure(true)
.build();
}
坑2:一次性读取整个响应
很多人图省事,直接用response.body().string()去拿结果,这会把整个响应全部读完才返回,流式直接变成同步,前端必然卡死。流式输出必须逐行读取,收到一点发一点。
正确的流式读取:
java
try (Response response = client.newCall(request).execute()) {
ResponseBody body = response.body();
if (body == null) return;
BufferedReader reader = new BufferedReader(
new InputStreamReader(body.byteStream(), StandardCharsets.UTF_8)
);
String line;
while ((line = reader.readLine()) != null) {
if (line.startsWith("data:")) {
String token = line.substring(5).trim();
if ("[DONE]".equals(token)) {
break;
}
// 实时推送给前端
emitter.send(SseEmitter.event().name("token").data(token));
}
}
emitter.complete();
}
17.3 节点二:后端服务的异步坑
第二个坑在SpringBoot后端,SSE的异步请求非常容易踩坑,很多人不知道Spring MVC的异步请求默认只有30秒超时。
坑1:异步请求超时太短
SseEmitter默认超时时间非常短,长回答肯定会超时断连。必须手动设置超时时间。
java
// 创建时指定超时,单位毫秒,5分钟
SseEmitter emitter = new SseEmitter(300_000L);
或者全局配置:
yaml
spring:
mvc:
async:
request-timeout: 300000
坑2:和普通接口共用线程池
流式请求会长时间占用线程(一个请求可能占几分钟),如果和普通接口共用Tomcat线程池,只要十几个流式请求就能把线程占满,整个服务都会卡死。
解决方案:单独分配流式线程池
java
@Configuration
public class SseThreadPoolConfig {
@Bean("sseExecutor")
public ThreadPoolTaskExecutor sseExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("sse-stream-");
// 拒绝策略:降级为非流式返回,而不是抛异常
executor.setRejectedExecutionHandler((r, e) -> {
// 业务自定义降级逻辑
});
executor.initialize();
return executor;
}
}
然后把流式任务提交到这个线程池执行,不要占用Tomcat的请求线程。
坑3:不注册回调,连接泄漏
如果前端断开连接,后端还在继续往emitter里写数据,会抛异常。如果不捕获异常、不释放资源,就会造成连接泄漏,线程越用越多,最后服务挂掉。
必须注册三个回调:
java
SseEmitter emitter = new SseEmitter(300_000L);
emitter.onCompletion(() -> {
log.info("SSE连接正常结束");
// 清理资源、停止心跳
});
emitter.onTimeout(() -> {
log.warn("SSE连接超时");
emitter.complete();
});
emitter.onError(e -> {
log.error("SSE连接异常", e);
emitter.completeWithError(e);
});
17.4 节点三:Nginx网关的缓冲重灾区
这是90%的人都会踩的坑,也是最容易被忽略的。本地测试流式正常,一上Nginx就卡顿,几乎都是因为Nginx默认开启了代理缓冲。
Nginx默认会把后端的响应攒起来,攒够一个buffer大小才发给前端。所以你会看到数据一卡一卡的,半天出来一大段。
必须关闭代理缓冲:
nginx
location /api/chat/stream {
proxy_pass http://backend_upstream;
# 流式输出核心:关闭代理缓冲
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
# 超时对应调长
proxy_connect_timeout 10s;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
# 告诉浏览器不要缓冲
add_header X-Accel-Buffering no;
add_header Cache-Control no-cache;
}
最关键的就是proxy_buffering off和add_header X-Accel-Buffering no这两行,少了任何一行,都可能出现卡顿。
17.5 节点四:前端的心跳与重连
最后一个节点在前端。如果大模型推理时间很长,中间几十秒没有数据,很多防火墙、CDN、网关会自动断开空闲连接。这时候就需要心跳保活。
心跳保活
后端每隔10秒发一个SSE注释行,浏览器会忽略,但能保持连接活跃。
java
// 每10秒发送心跳注释
ScheduledExecutorService heartbeat = Executors.newSingleThreadScheduledExecutor();
heartbeat.scheduleAtFixedRate(() -> {
try {
emitter.send(SseEmitter.event().comment("heartbeat"));
} catch (Exception e) {
heartbeat.shutdown();
}
}, 10, 10, TimeUnit.SECONDS);
断点续传
前端EventSource自带断线重连,但重连后会从头开始输出,用户已经看到的内容会重复。所以要做断点续传:前端记录已经收到的Token偏移量,重连时带给后端,后端从断点处继续发。
17.6 全链路检查清单
上线前对照这个清单逐项检查,少一项都可能出问题:
- 大模型客户端:读超时≥5分钟,逐行流式读取
- 后端服务:SseEmitter超时≥5分钟,独立线程池,注册三个回调
- Nginx网关:关闭proxy_buffering,设置X-Accel-Buffering: no
- 前端:心跳保活,断线重连+断点续传
流式输出看起来简单,其实整条链路到处都是坑。很多人只调后端一个点,当然解决不了问题。把整条链路的配置都对齐,你的流式输出就能做到和大厂产品一样稳定流畅。