掘金小册样章

样章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 踩坑经验与最佳实践
  1. 不要硬编码API Key和模型参数:全部放到配置文件里,不同环境用不同的Key和模型版本;
  2. 超时时间一定要单独设置:大模型接口不是普通HTTP接口,推理时间可能很长,读超时至少设置60秒以上,流式输出要设置到5分钟;
  3. 必须加异常捕获和降级:大模型接口随时可能超时、报错,生产环境一定要有备用模型或者降级方案,不能因为大模型挂了导致整个业务不可用;
  4. 统一日志和埋点:所有调用都要记录模型类型、输入输出长度、耗时、错误码,方便后续排查问题和统计成本;
  5. 面向接口编程 :业务代码永远只依赖ChatLanguageModel接口,不要直接依赖具体的模型实现类,否则后面换模型会非常痛苦。

这一套封装做完,你的大模型调用层就具备了生产级的可维护性。后面不管是加新模型、做限流、加缓存、统计成本,都只需要在这一层修改,业务代码完全不用动。这也是Java AI项目最基础的架构意识,很多人上来就写业务,到后面越写越乱,根源就是没有在一开始做好分层和抽象。


样章2:第10节 检索优化:查询改写、路由与召回调优

很多人做RAG,做到最后都会遇到一个瓶颈:召回的内容不准,要么漏了关键信息,要么召回一堆无关内容,导致大模型回答错误。大部分人的第一反应是换嵌入模型、加大TopK数量,但其实80%的召回问题,都可以通过检索侧的优化解决,不用换模型也不用加成本。

这一节我们就讲RAG里最核心的检索优化手段:查询改写、查询路由、索引优化、召回策略。这些都是我线上项目反复调试验证过的方案,落地之后问答准确率能提升15%以上。

10.1 为什么原始检索效果差

先搞清楚一个问题:用户的提问,为什么直接去向量库检索效果不好?

核心原因有三个:

  1. 查询歧义:用户的提问很简短,比如"怎么对接?",没有上下文,向量相似度计算根本不知道你要对接什么;
  2. 表述差异:用户的用词和文档里的用词不一样,比如用户说"限流",文档里写的"流量控制",向量距离就会变大,排不到前面;
  3. 多意图查询:用户一句话包含多个问题,比如"怎么对接大模型,怎么控制成本?",向量检索会偏向其中一个意图,另一个的内容召回不到。

所以检索优化的核心思路,不是直接拿用户的原始提问去查向量库,而是先对查询做一层「预处理」,再去检索。这一步做好了,效果提升非常明显。

10.2 查询改写(Query Rewrite)

查询改写是成本最低、见效最快的优化手段。原理很简单:用大模型把用户的原始提问,改写成更适合检索的查询语句。

常见的改写策略有三种:

  1. 补全上下文:把对话历史加进去,把指代不清的问题补全。比如用户问"它支持流式输出吗?",改写后变成"LangChain4j支持流式输出吗?";
  2. 同义词扩展:把用户的用词扩展成行业通用表述,增加匹配概率;
  3. 多查询生成:把一个原始问题,生成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为例,核心调优点:

  1. 索引类型选择:百万级数据以内用IVF_FLAT,追求速度用HNSW;不要上来就用HNSW,数据量小的时候IVF_FLAT更快更准;
  2. nprobe参数:检索的时候,nprobe设置为nlist的5%-10%,比如nlist=1024,nprobe就设64-128;nprobe越大召回率越高,但速度越慢,需要做权衡;
  3. TopK设置:不是越大越好,TopK太大反而会引入很多噪声。一般初始设20-30,然后根据业务场景调整;
  4. 相似度阈值:加一个最小相似度阈值,低于阈值的结果直接过滤掉,不要返回给大模型,避免干扰回答。
10.5 检索优化的最佳实践
  1. 优化顺序:先做查询改写,再做路由,最后调索引参数;不要上来就换嵌入模型,那是成本最高的手段;
  2. 数据驱动:不要凭感觉调,一定要埋点记录「检索召回的文档有没有被大模型引用」「哪些问题回答错误是因为召回不准」,针对性优化;
  3. 分级策略:简单问题用单查询+单库检索,复杂问题开启多查询+全库检索,平衡成本和效果;
  4. 不要过度依赖向量检索:向量检索是召回的核心,但一定要配合关键词检索做混合检索,才能兼顾语义匹配和精确匹配。

检索优化是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 offadd_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 全链路检查清单

上线前对照这个清单逐项检查,少一项都可能出问题:

  1. 大模型客户端:读超时≥5分钟,逐行流式读取
  2. 后端服务:SseEmitter超时≥5分钟,独立线程池,注册三个回调
  3. Nginx网关:关闭proxy_buffering,设置X-Accel-Buffering: no
  4. 前端:心跳保活,断线重连+断点续传

流式输出看起来简单,其实整条链路到处都是坑。很多人只调后端一个点,当然解决不了问题。把整条链路的配置都对齐,你的流式输出就能做到和大厂产品一样稳定流畅。

相关推荐
凌杰2 小时前
AI 学习笔记:LLM 的微调实验
人工智能
zlwool2 小时前
图纸管理选型:从变更频率到齐套率
java·前端·python·erp·设备erp·非标机械
wangruofeng2 小时前
Claude Fable 5.1 发布,一个模型两种安全档,账单最多省 45%
aigc·ai编程·claude
晶捷软件2 小时前
离散型制造数字化转型:晶捷智能全栈方案打通从研发到交付全链路
大数据·人工智能·制造
一晌小贪欢2 小时前
python-第15天:Python 列表推导式
开发语言·python·excel·数据可视化·python办公
Java后端的Ai之路2 小时前
17、Python - 观察者模式
开发语言·人工智能·python·观察者模式·外观模式
萌动的小火苗3 小时前
3、深度学习与全连接面试题
人工智能·深度学习
2301_780789663 小时前
CDN提供商常用的DDoS缓解技术与策略
linux·运维·服务器·人工智能·架构
薛定e的猫咪3 小时前
(IEEE Transactions 2025)自适应元强化学习动态柔性作业车间调度框架
网络·人工智能·算法