1. 从手写大模型接口说起:Java 开发为什么总在这件事上吃亏
先说说我自己吧。之前接手一个知识库问答系统,技术栈是 Java 17 + Spring Boot,需要对接大模型能力。团队里大多数人跟我一样,第一个想法就是:直接调 HTTP 接口呗。当时我花了两三天时间,封装了通义千问的 HTTP 调用——拼接 URL、构建 JSON 请求体、解析 JSON 响应,还要专门处理鉴权签名;做流式响应的时候更麻烦,得手动处理 SSE(Server-Sent Events)流的读和解析,一边维护连接状态一边逐行处理返回内容。项目上线之后,团队里任何一个人想加一条新重调整逻辑,都要对着这些手写代码愁半天。
这不是个例。我在身边和社区里看过太多 Java 工程师做 AI 应用时的套路:拿到大模型 SDK 或者 HTTP API,然后开始写耦合了大量业务逻辑的调用代码,Prompt 拼接、上下文管理、文档拆分、向量检索全靠手撸,代码越来越重,维护越来越吃力。
这里我想先点明一个核心问题:Java 生态并不是没有对应这个大模型场景的成熟工具,而是很多开发习惯了直接用最原始的 HTTP 方式,没有意识到其实有个专门为 Java 打造的框架——LangChain4j 已经非常成熟了。
简单介绍一下 LangChain4j。熟悉 Python 生态的人都知道 LangChain,它把“调用大模型”和“构建 AI 应用”这件事抽象得很舒服。Java 这边长期以来都比较缺一个同等水平的抽象层,LangChain4j 就是来补这个缺口的。它支持对接非常多的模型提供商,通义千问就是其中之一;它内置了一整套 RAG(检索增强生成)组件,比如文档加载、文本切分、嵌入向量、向量存储、检索器、提示词模板、流式调用、AI 服务接口。换句话说,我们之前手动干的事情,这个框架基本一网打尽了。
所以这篇文章,我完整地记录一次基于 LangChain4j + 通义千问实现 RAG 流式问答的全过程。会给出可以直接抄走的代码,也会解释每一步为什么要这么做。适合已经会用 Java 做普通项目、想尝试或者正在尝试接入大模型的读者,也适合做后端应用、Web 应用的开发朋友。不管你是第一次接触大模型应用开发,还是已经有了一些手写 API 的老经验,这篇文章都能让你省掉不少弯路。
2. 方案选型:为什么是 LangChain4j、通义千问和流式问答
我其实接触过各种组合,有一段时间用 Python 写 AI 服务,然后在 Java 项目里通过 HTTP 调用那个 AI 服务,但最终放弃了,原因很现实:两边维护成本高,类型系统断裂,调试困难,生产环境排错费劲。后来我下决心在 Java 里直接实现整条链路,才认真评估了当时的几个方案:用原生 HTTP 封装、用 Spring 的 RestTemplate/WebClient 直接对接、或者用 LangChain4j。
原生 HTTP 封装看起来最透明,但实际开发中痛点很多。比如通义千问的响应结构里有好几种 role、内容和工具的字段结构,需要自己写一堆实体类;流式和非流式响应结构还不一样,一套解析代码要写两遍;上下文管理、Token 统计这些都得自己维护。写一次可以忍,长期迭代确实很耗精力。
用 RestTemplate/WebClient 直接对接,稍微好一点,至少 HTTP 层的代码可以托管,但本质上还是手工造轮子。尤其是 Prompt 拼接和上下文维护,如果没有任何框架约束,每个人都会写出自己的一套,项目一多就乱。
LangChain4j 的定位就是把这些通用逻辑抽象成标准组件。它不需要你理解 LangChain 是什么,你完全可以把它当作一个“Java 大模型开发工具包”来用。它有清晰的接口抽象,也有默认实现;不绑定某一家模型厂商,切换后端只需要改配置和依赖。
再说通义千问,选择它有很实际的原因。首先是国内网络环境下调用非常稳定,日活、并发、限流政策都相对友好。其次是它提供兼容 OpenAI 协议的接口,也提供 DashScope 原生 SDK,这意味着在 LangChain4j 体系里可以通过标准配置直接接入。还有一个很重要的点是它有 embedding 模型可用,这个是 RAG 的核心依赖之一,不需要再额外找一套专门做向量化的服务。
最后说流式问答。我觉得在现在的 AI 应用里,流式输出不是加分项,是默认配置。想象一下用户在一个知识库里查询信息,系统要思考、要检索、要生成答案,每一个环节花费的时间加起来可能要好几秒。如果做非流式接口,用户就要面对一个一直转圈的加载图标,体验很差;而流式输出可以在大模型生成第一个字的时候就把内容推给前端,后面的内容逐字展示,用户感知到的是“正在回答”,参与感和可用性都完全不一样。除此之外,流式还有个隐藏优势:对于长回答,可以大大降低首响应耗时,这一点在超时配置比较严格的生产环境里价值很大。
综合来看,这个方案选的不是“最强”,而是“最适合 Java 工程团队长期维护”:组件化落地、国内链路稳、流式体验好、有现成的 RAG 组件可以扩展。
3. 环境准备与项目初始化:Maven 依赖、配置文件和基础封装
这里我会给出一个完整的可运行配置,大家可以直接基于这个开始。先说明一个事情,不同版本的依赖库更新很快,下面用的是我当时实际验证过的版本,建议你以 Maven 仓库中对应时间为准,但整体用法是一致的。
3.1 引入 LangChain4j 核心依赖
我用的是 Maven 管理项目。在 pom.xml 里加入下面这些依赖:
<properties> <langchain4j.version>0.36.2</langchain4j.version> </properties> <dependencies> <!-- LangChain4j 核心模块 --> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>${langchain4j.version}</version> </dependency> <!-- 通义千问集成模块 --> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-dashscope</artifactId> <version>${langchain4j.version}</version> </dependency> <!-- 简单的 Embedding 存储(本地内存实现) --> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-easy-rag</artifactId> <version>${langchain4j.version}</version> </dependency> <!-- 文档解析辅助 --> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-document-parser-apache-pdfbox</artifactId> <version>${langchain4j.version}</version> </dependency> <!-- 内置 Embedding 模型库 --> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-embedding-bge-small-zh</artifactId> <version>${langchain4j.version}</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>这里解释一下几个选择。
为什么没有直接用 spring-boot-starter 形式?因为很多朋友的项目不一定是 Spring Boot,可能只是普通的 Java 工程或者 Vert.x、Micronaut 等项目。langchain4j 的核心模块本来就不依赖 Spring,保留通用性更稳妥。当然,如果你的项目是 Spring Boot,可以直接加 langchain4j-spring-boot-starter,它能让你用属性前缀自动装配很多 Bean,更方便,但只要理解 LangChain4j 本身的 API,手动构建也非常简单。
为什么选择 BGE Small 中文 embedding 模型作为默认方案?因为它体积相对小,对中文支持不错,可以直接在本地 CPU 运行,适合开发和中小规模应用。通义千问也提供了 text-embedding-v 系列在线模型,不过本地模型在离线环境、开发调试、低成本场景下优势明显,所以这里先放本地。
3.2 配置文件里的关键参数
我习惯把配置集中在 application.properties 或 application.yml 中。用 Spring Boot 的话,配置大概是这样:
langchain4j.dashscope.api-key=${DASHSCOPE_API_KEY} # 通义千问对话模型 langchain4j.dashscope.chat-model.model-name=qwen-plus langchain4j.dashscope.chat-model.temperature=0.1 langchain4j.dashscope.chat-model.max-tokens=2048 # 流式问答 langchain4j.dashscope.streaming-chat-model.model-name=qwen-plus langchain4j.dashscope.streaming-chat-model.temperature=0.2 # 本地 Embedding langchain4j.embedding-model.bge-small-zh.enabled=true如果不是 Spring Boot 自动装配,而是手动构建,则可以在代码里写一个配置类:
ChatLanguageModel chatModel = DashScopeChatModel.builder() .apiKey(System.getenv("DASHSCOPE_API_KEY")) .modelName("qwen-plus") .temperature(0.1) .build(); StreamingChatLanguageModel streamingChatModel = DashScopeStreamingChatModel.builder() .apiKey(System.getenv("DASHSCOPE_API_KEY")) .modelName("qwen-plus") .build(); EmbeddingModel embeddingModel = BgeSmallZhEmbeddingModel.builder() .build();关于 API Key 的管理,有一个重要的习惯:绝对不要把 Key 硬编码在代码里。生产上建议放到环境变量、KMS 或者配置中心里,然后代码里只读引用。很多实战项目里出现密钥泄露,往往就是因为本地调试随手写死,结果提交到了代码仓库。这个小坑真的很容易踩。
3.3 手动封装一个 RAG 问答服务类
为了后面演示方便,我们先定义一个 RAG 问答服务类,它负责两件事:加载外部文档到向量库;接收用户问题,返回对话结果。
import dev.langchain4j.data.document.Document; import dev.langchain4j.data.document.DocumentSplitter; import dev.langchain4j.data.document.splitter.DocumentSplitters; import dev.langchain4j.data.segment.TextSegment; import dev.langchain4j.memory.chat.MessageWindowChatMemory; import dev.langchain4j.model.embedding.EmbeddingModel; import dev.langchain4j.model.output.Response; import dev.langchain4j.rag.content.retriever.EmbeddingStoreContentRetriever; import dev.langchain4j.service.AiServices; import dev.langchain4j.store.embedding.EmbeddingStore; import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore; public class RagAssistant { private final EmbeddingStore<TextSegment> embeddingStore; private final EmbeddingModel embeddingModel; private final StreamingChatLanguageModel streamingModel; public RagAssistant() { this.embeddingStore = new InMemoryEmbeddingStore<>(); this.embeddingModel = new BgeSmallZhEmbeddingModel(); this.streamingModel = DashScopeStreamingChatModel.builder() .apiKey(EnvReader.getApiKey()) .modelName("qwen-plus") .build(); } public void ingest(Document document) { // 切分文档为若干小片段 DocumentSplitter splitter = DocumentSplitters.recursive(300, 50); List<TextSegment> segments = splitter.split(document); // 为每个片段生成向量并存入向量库 List<Embedding> embeddings = embeddingModel.embedAll(segments).content(); embeddingStore.addAll(embeddings, segments); } public void chatWithStream(String message, StreamResponseHandler handler) { // 配置检索器 EmbeddingStoreContentRetriever retriever = EmbeddingStoreContentRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(5) .minScore(0.6) .build(); // 使用 AI 服务接口 Assistant assistant = AiServices.builder(Assistant.class) .streamingChatLanguageModel(streamingModel) .contentRetriever(retriever) .build(); assistant.chat(message); } }这里有必要解释一个 LangChain4j 里非常好用的特性:AiServices。它允许你通过接口定义 + 注解的方式来描述 Agent 的能力。比如:
interface Assistant { String chat(String userMessage); }如果改成流式,可以直接用TokenStream chat(String userMessage)。当你在 AiServices 上绑定 contentRetriever 时,LangChain4j 会自动把检索到的内容拼进 Prompt,然后调用大模型生成答案。你不需要手工去拼接文档字符串,也不需要自己维护检索逻辑,框架帮你把这些细节隔离掉了。
4. RAG 核心链路实现:文档加载、文本切分、向量存储与检索器配置
RAG 在工程上其实就是一条管道:加载文档——切分文本——向量化——存储——检索——拼装 Prompt——生成答案。每一环都有不少需要细节处理的地方,这里我拆开来讲。
4.1 文档加载:不要只想着 PDF,还有 Word、Markdown、HTML
很多人一想到知识库就是 PDF,其实业务系统里 Word、Markdown、PPT、HTML 都有可能出现。LangChain4j 提供了一组 DocumentLoader,比如 FileSystemDocumentLoader 可以加载文件系统中的文档,UrlDocumentLoader 可以加载线上文档。我实际测试下来,PDF 用 pdfbox parser 解析效果不错,Word 文档还需要额外加 poi 相关依赖来做解析,这里建议按实际文件类型补充 parser 模块。
有一个当时我自己没有立刻意识到的问题:PDF 本身有两种形态,电子版(有文字层)和扫描版(纯图片)。text PDF 解析器只能处理前者,扫描版必须接入 OCR。如果你做企业知识库,扫描件占比通常不低,但 LangChain4j 目前内置的 PDF parser 是不带 OCR 的,别踩这个坑。先确认你的文档里的 PDF 到底是不是有文字层。
文档加载示例:
Document pdfDocument = FileSystemDocumentLoader.loadDocument(new File("rag-test.pdf"));一个小细节:有的 PDF 转换后会出现大量排版空格、换行错乱,文本质量直接影响切分和检索效果。如果发现解析效果不好,建议先做文本清洗,比如把多余空白字符替换掉,把行尾“非断行空格”转成普通空格,再做后续处理。就因为这个细节,我前几次跑出来的检索命中率一直不稳定。
4.2 文本切分:长度和重叠度的选择
文本切分的目的是把长文档拆成许多小的语义单元,每一个单元是一个 TextSegment。切分粒度太粗,后续向量化不精确,检索容易覆盖太多无关内容;切分太细,又会破坏上下文连贯性,单个片段可能连一句完整的话都撑不起来。
LangChain4j 提供了多种切分器,我推荐用 DocumentSplitters.recursive(300, 50)。这里两个参数分别是 maxSegmentSize 和 maxOverlapSize。maxSegmentSize 是每个分片大概包含多少个字符(注意是字符不是 token);maxOverlapSize 是相邻分片之间重叠的字符数。
为什么要有重叠?因为某句话如果恰好被拦腰截断,他的语义主体可能在上一段,也可能在下一段。重叠相当于给切分边界加了一圈“缓冲带”,能让上下文信息在相邻片段里都保留一部分,从而提升检索召回效果。
假设你的文本是这样的:
这是一段关于智能客服系统的介绍。它支持多轮对话、意图识别和知识库检索。切分器在 maxSegmentSize=300 时大概率不会去切这种短文本,因为长度没到阈值。但对于超长文本,它会在某些位置截断。重叠的 50 个字符会尽量让上一段末尾与下一段开头保持语义连续。
我测试过几组参数,对于大多数业务文档,300/50 的组合效果比较均衡;技术文档偏向更小的 200/30,问答类短文本还可以 200/50。具体得根据自己文档的句子长度去调整,不建议一个参数打天下。
4.3 向量化与向量存储:Embedding 模型决定检索效果的上限
很多人容易忽略一个现实:切分、存储、检索都只是工程上的处理,RAG 效果的上限其实在 Embedding 模型本身。
Embedding 模型的本质是把一段文本映射成一组高维向量。向量之间的余弦相似度代表语义相似度。如果 Embedding 模型对中文支持不好,那么“苹果手机”和“iPhone”这种同义表达可能就检索不到。BGE Small 中文模型在不少公开评测里表现都可以,但如果你要处理的是专业领域术语、代码文本或垂直业务,建议用领域微调的 embedding 模型或者在线大模型提供的 embedding 接口。
InMemoryEmbeddingStore 是 LangChain4j 内置的本地向量库,它把所有向量装在内存里,适合 Demo、小规模知识库和验收测试。缺点是内存占用高、没有持久化。一旦服务重启,向量数据就丢失了。生产环境建议接 Redis 向量库、Milvus、pgvector 等,LangChain4j 对这些都有对应的集成模块。
我这里先讲本地实现,后面有一小节专门说生产环境怎么演进。
4.4 检索器的核心参数:maxResults 和 minScore
检索器里最关键的两个参数就是 maxResults 和 minScore。maxResults 是最终返回给大模型命中的文本片段数量,minScore 是相似度阈值。
我遇到一个比较常见的误区:有人为了“尽可能多给模型素材”,把 maxResults 设成 50。实践下来这并非好事。一方面,提供的文本越多,消耗的 Token 就越多,成本上升;另一方面检索结果会有大量噪音,模型反而容易被无关内容干扰,回答质量下降。我平时知识库取 3-5 条,大多数场景都足够。
minScore 也一样,设太低,无关内容混进来;设太高,遇到相似度普遍不高的模糊查询回答不了问题。这个没有统一标准,要先跑一批测试问题,统计一下检索结果的 score 分布,然后再去定阈值。如果自己没法统计,先给个 0.5-0.7 之间的保守值,后续再调。
5. 流式问答的工程实现:从 TokenStream 到前端友好输出
前面的链路是纯文本知识库问答的基础;但既然标题里强调了流式问答,我就重点展开这一节。流式问答的难点并不在于 LangChain4j 怎么提供流式接口,而在于如何把流式结果优雅地接进你的业务系统,让前端能收到“打字机”效果。
5.1 LangChain4j 的 TokenStream 处理流程
LangChain4j 中,流式模型返回的是 TokenStream,你可以对它注册三个阶段的事件回调:onPartialResponse(每次拿到部分响应时)、onCompleteResponse(完成时)、onError(异常时)。这是非常典型的事件驱动模式。伪代码如下:
TokenStream tokenStream = streamingChatModel.generate(userMessage, contextDocuments); tokenStream.onPartialResponse(partial -> { // 把 partial 发送到前端 }).onCompleteResponse(completion -> { // 收尾处理 }).onError(error -> { // 异常处理 }).start();千万不要小看这几个回调的设计,它把流式输出这件事拆得很清晰。每个 onPartialResponse 可能会被触发很多次,每次拿到的 partial 只是当前这次累积响应的增量,至少我在实际测试时是递增的。但同时要注意一个问题:这个回调返回的是整串当前生成的文本,还是本次新生成的增量?我在不同版本文档里见到过差异。建议在收到 onPartialResponse 后往前端发送时,不要直接覆盖,而是把增量做 diff,或者干脆让前端做纯增量追加,以免在一些模型上出现重复文本。
实践下来更好的做法是:后端把每次 partial 文本原样推给前端/客户端,由前端做字符串拼接。如果你用的是 WebSocket 或 SSE,直接把 partial 作为一帧消息推送即可。
5.2 结合消息记忆的多轮对话
企业知识库问答基本是多次对话,一问一答之间需要维持上下文。LangChain4j 的 MessageWindowChatMemory 可以指定记忆窗口大小,例如记住最近 20 条消息。示例配置:
MessageWindowChatMemory chatMemory = MessageWindowChatMemory.withMaxMessages(20); Assistant assistant = AiServices.builder(Assistant.class) .streamingChatLanguageModel(streamingModel) .chatMemory(chatMemory) .contentRetriever(retriever) .build();动手写过上下文管理后,你会很清楚这其实不容易:不同用户、不同对话 session 的记忆要隔离,Java 服务端多用户同时在线时很容易串线。LangChain4j 这里我用了 AiServices,它默认会为每个服务实例保留一个共享 memory,单用户没问题;多用户场景,需要给每个用户建一个独立的 Assistant 实例。
当然也可以直接从 ChatMemory 接口实现,把消息存储放到 Redis 里,持久化 + 过期策略一起解决了。LangChain4j 提供了持久化 ChatMemoryStore 的接口,我建议多用户系统一开始就设计成按用户维度分 Store。
5.3 阿里云通义千问的流式细节
我这里重点说一下通义千问的流式模式。在 DashScope API 里,流式输出是通过 SSE 格式返回的,每个事件里面包含一个增量 token。LangChain4j 的 DashScope 模块已经把这层协议封装好了,所以你在业务代码里无感知,不需要自己读 SSE 流。
但有一个地方值得留意:enableSearch这个参数,也就是通义千问的联网搜索能力。它和 RAG 结合使用时,需要注意优先级。我自己实际测试时发现,开启联网搜索后,模型有时会基于搜索结果回答,而不是完全遵循你提供的私有知识库文档。具体业务里需要根据场景来权衡,知识库问答一般建议关闭 enableSearch,或者至少配置成不在 Prompt 体系里主动触发。
另一个点:通义千问模型对maxTokens的理解和 OpenAI 不太一样,有的平台默认返回长度有上限,如果你要生成很长的回答,需要把 maxTokens 调大。但在 RAG 场景下,我通常不建议把 maxTokens 设得过大,而是尽量依靠检索质量来压缩生成内容。响应越长,整体时延越高,流式体验也会打折扣。
6. 生产环境需要升级的四个点:向量库、异步线程、安全与成本
如果一个 RAG 项目只是本地跑通验证,那上面讲的基本够用了。但只要你准备发布到生产环境,有四件事一定要提前考虑。
6.1 向量存储的持久化与选型
InMemoryEmbeddingStore 最大的问题是不持久化。生产上必须换成能持久化的存储。我给大家列举几种常用选项:
| 存储方案 | 部署方式 | 特点 | 适合场景 |
|---|---|---|---|
| Redis 向量检索 | 已有 Redis 集群 | 接入简单,实时性好 | 中小规模知识库 |
| pgvector | 已有 PostgreSQL | 事务与向量共存 | 与业务数据关系密切 |
| Milvus | 独立集群 | 大规模高并发 | 百万级向量知识库 |
| OpenSearch | 独立集群 | 全文+向量混合检索 | 需要结合关键词检索 |
每个方案没什么高下之分,主要看现有基础设施。我们当时选的是 Redis 方案,因为团队已有比较成熟的 Redis 集群,接入成本最低。如果你的知识库只有几万篇文档,Redis 方案完全可以撑住,但要注意内存占用和过期策略是否适合你的业务。
6.2 异步化与连接管理
大模型接口的响应时延明显高于普通 HTTP API,这是大家能感觉得到的。如果业务请求量稍微上来一点,直接用同步 Spring MVC 请求线程去等大模型返回,Tomcat 默认的 200 个线程可能很快就耗尽。这里有两个优化建议。
第一,用 Spring 的 WebFlux 或者 Servlet 异步化处理,把大模型调用放到独立线程池,结合 CompletableFuture 创建异步任务。对于流式场景,前端一般走 WebSocket 或 SSE,服务端应保持“一连接一长任务”的模型,而不是占用业务请求线程等待。
第二,为 HTTP 连接池设置合理的超时配置。通义千问这类平台有各自的网络要求,超时太短很容易把正常的长请求误判为失败;超时太长又会让故障恢复得很慢。我平时一般把连接超时设为 10 秒,但读取超时要看流式周期,一般给到 120 秒以上。
注意:Java 的 HttpClient 或 OkHttp 默认读取超时设置并不一定适合流式场景,需要显式配置。LangChain4j 底层用 OkHttp 或 Java HttpClient,具体的超时设置可以在模块配置里实现。这个是我自己踩过的坑,之前用默认配置,回答长一点就频繁触发 ReadTimeout。
6.3 敏感信息过滤与合规
知识库里的文档很可能包含机密数据、隐私、内部资料。检索之后,大模型生成回答时,你必须要确保两点:一是不会被随意检索出来泄漏给无权限用户;二是在 Prompt 里明确约束模型不得输出与问题无关的敏感数据。
技术上可以做用户级权限过滤,比如按用户角色过滤检索片段集合,或者给不同的知识库分区设置独立向量库。这个 LangChain4j 不直接帮你做,但你可以通过自定义 ContentRetriever 实现。建议在生产设计前期就把这部分加进去,不要等出事了再补窟窿。
6.4 成本控制与 Token 统计
在线大模型的费用是持续发生的,RAG 里每个问题都会调用一次嵌入模型(把用户问题向量化)和大模型生成模型。如果还开了多次重试,成本会增加。
我们可以做一些务实优化:一是缓存用户问题,完全相同的问句不走完整 RAG 链路;二是按文档更新时间做增量向量化,避免每次服务重启就全量重新 embedding;三是把对话记忆截断,保留最后 N 轮,超长记忆会消耗大量 Token;四是合理控制检索条数,之前说的 maxResults 不要盲目调大。
我用过的方案里,最立竿见影的是“问题规范化 + 缓存”:用户在短时间窗口内的相似问题可以直接命中历史答案,成本几乎为零。实现也不复杂,用一个 Redis Set 或 SimpleCache 记录最近的问题哈希即可。
7. 常见问题速查表:流式中断、解析失败、检索不到、依赖冲突
这一节直接给结论,都是我实际踩过的坑。我把它们整理成表格,方便遇到问题时快速对照。
| 问题现象 | 可能原因 | 解决思路 |
|---|---|---|
| 流式输出中途停止 | 读取超时、网络抖动、模型异常 | 调大读取超时,增加重试策略,观察服务端日志 |
| PDF 文本解析失败或乱码 | 扫描版 PDF,没有文字层 | 换 OCR 方案,或改用带文字层的 PDF |
| 检索结果始终不相关 | Embedding 模型能力不足或文本切分不合理 | 换专业领域 Embedding 模型,调小切分长度,增加重叠 |
| 回答不来自知识库 | Prompt 约束不够、enableSearch 被开启 | 调整 Prompt 指令,关闭搜索,检查检索器是否真正注入 |
| 调用抛 401/403 | API Key 错误或失效 | 检查密钥权限、环境变量、是否被泄露拦截 |
| 依赖 jar 冲突 | 多个 HTTP 客户端版本不一致 | 比对 mvn dependency:tree,统一版本 |
| 内存占用过高 | InMemoryEmbeddingStore 保存向量过多 | 换持久化向量库,设置过期清理 |
| 多用户上下文串号 | 共享了同一个 ChatMemory | 按用户/会话维度创建 Assistant 实例 |
再说几个开发时的独门技巧。
第一个,加一个“检索调试”接口。生产环境你去排查回答错误时,最需要确认的是“模型为什么这么回答”。所以我在开发时期会加一个隐藏接口,输入用户问题,返回当前检索到的所有片段和相似度分数。这样出了问题,一眼就能看到底是检索链路问题还是大模型生成问题。这个接口上线前可以加权限保护或直接去掉。
第二个,把 Prompt 模板单独管理,不要散落在代码里。LangChain4j 支持使用资源文件作为 Prompt 模板。我会把系统提示词、用户问题模板、答案格式模板全部放到 resources 目录下,用 Message 和 ChatMessage 统一加载。这样做的好处是后期调整提示词不用重新编译,也可以让非研发同学协助调优。
第三个,建议引入测试集对 RAG 效果做回归。比如准备 20-50 条常见问题,每次改完检索参数或换模型,跑一遍测试集,计算命中率、答案内容是否包含关键词。没有这套测试,你很难知道自己改动的方向是好是坏。我自己最初没有做,结果每次调参都靠感觉,后来加上简单测试之后,效率高非常多。
8. 关于 Java RAG 效果调优的一些私人建议
最后再分享一些我在生产环境里积累下来的调优体会,可能不系统,但每一条都是真金白银的经验。
第一个体会是,RAG 的效果七成在检索,三成在生成。很多朋友遇到回答不准确,第一反应就是换更贵的大模型,或者疯狂改提示词。但实测下来,很多时候问题出在知识库文档质量、切分粒度、Embedding 模型选择上。先把检索这一环调通了,再去看生成,否则就是本末倒置。
第二个体会是,不要让 Prompt 头发长、格式复杂、挤占 Model 上下文。我在调优时发现,RAG 检索出来的片段本身已经包含了很多信息,Prompt 里需要约束模型“只依据给定内容回答,不要编造”,但不要塞太多无关的约束指令。指令过长反而影响模型的注意力分配。
第三个体会是,在 Java 里做 AI 应用,慢慢要习惯“面向接口编程”式的思维方式。不要把大模型当成一个独行侠,而要把它当作你系统里的一个组件,像操作数据库、缓存一样去接入。LangChain4j 这种框架的意义,就是让你把这种组件化的思维方式固化到代码里。
第四个体会是,如果团队里没有专门做 AI 的工程师,用 LangChain4j 这类框架反而比从零手写更安全。因为框架本身沉淀了很多通用问题的处理方案,比如流式、上下文、错误处理,底层踩过的坑已经帮你处理了一部分。手写的方案虽然灵活,但也很可能把同样的问题踩一遍。
写到这,我可以肯定地说:Java 开发直接对接大模型接口的时代已经卷到没什么必要了。与其重复造轮子,不如把时间花在业务梳理、知识库内容质量、检索调优这些真正能拉开差距的事情上。这套 LangChain4j + 通义千问 + 流式问答的方案,我从开发到上线跑了不少问题,整体稳定,值得你直接拿去试。