资讯动态

Java开发者别再手写大模型接口了:LangChain4j+通义千问落地RAG流式问答

发布时间:2026/10/19 5:24:12 来源:尧图企业网站定制
1. 为什么手写大模型接口是在浪费生命1.1 一个让我彻底放弃手写接口的下午去年做企业知识库项目的时候我干了一件现在想起来都觉得自己蠢的事。当时需求很简单把公司内部几百份产品文档喂给大模型做一个能回答产品问题的问答机器人。我第一反应是——这有什么难的HTTP 请求发一发不就完了于是我花了整整三天时间手写了一套调用大模型 API 的 Java 代码。封装 HTTP 客户端、处理 JSON 序列化、拼接 Prompt 模板、解析返回结果、处理各种异常……代码写完大概八百多行跑起来确实能用。但接下来噩梦开始了产品经理说“能不能让回答流式输出一个字一个字蹦出来那种”我改了两天又说“能不能记住上下文别每次都是金鱼记忆”我又改了一天再后来“能不能让它先查一下我们的文档再回答”我直接崩溃了——这意味着我要自己实现向量检索、文档切分、相似度匹配、结果注入 Prompt 这一整套 RAG 流程。那三天手写的八百行代码最后全部删掉重来。因为 LangChain4j 用不到五十行就搞定了我上面说的所有需求而且流式输出、上下文记忆、RAG 检索全是开箱即用。这就是我想说的核心问题90% 的 Java 开发者还在手写大模型接口纯属浪费时间。不是说手写学不到东西而是在业务交付场景下你花三天手写的东西别人用成熟框架三小时就能落地而且比你稳定、比你功能全、比你更好维护。1.2 这篇文章能帮你解决什么如果你是一个 Java 开发者想在自己的项目里接入大模型能力但被各种 SDK、API、框架搞得头晕那这篇内容就是写给你的。我会用LangChain4j 通义千问这套组合带你从零落地一个完整的 RAG 流式问答系统。具体来说读完你能拿到这些东西一套可直接复用的 Maven 依赖配置不用再纠结版本冲突通义千问在 LangChain4j 里的正确接入姿势包括流式和非流式两种模式RAG 完整链路文档加载、切分、向量化、存储、检索、注入每一步都有可运行的代码流式问答的 SSE 接口实现前端能直接对接我踩过的坑和对应的解决方案这些是官方文档里不会写的适合的读者有 Java 基础、用过 Spring Boot、听说过大模型但没实际接过 API 的开发者。不需要你懂机器学习不需要你会 Python只要你会写 Java 就能跟上。1.3 先搞清楚 RAG 到底在干什么在动手之前我用一个生活化的类比把 RAG 讲清楚不然后面代码你只能抄不能改。想象你是一个客服新人客户问“你们产品支持批量导出吗”。你有两种回答方式第一种纯大模型凭记忆回答。但你的记忆是训练时灌进去的可能过时了可能记错了也可能根本不知道你们公司产品的细节。这就是大模型的“幻觉”问题。第二种RAG你先去翻公司的产品手册找到“批量导出”那一章看完之后再回答客户。这样回答就有据可依准确率高得多。RAG 的全称是 Retrieval-Augmented Generation翻译过来就是“检索增强生成”。核心流程就三步检索Retrieval把用户的问题拿去和知识库里的文档片段做相似度匹配找出最相关的几段增强Augmented把找到的文档片段拼接到 Prompt 里作为“参考资料”一起发给大模型生成Generation大模型基于参考资料生成回答听起来简单但工程落地时每一步都有坑。文档怎么切切多大向量怎么存检索出来不相关怎么办这些我后面会一个个讲。2. 技术选型为什么是 LangChain4j 加通义千问2.1 LangChain4j 到底解决了什么问题LangChain4j 是 LangChain 的 Java 版本专门为 Java 生态设计。它的核心价值在于把大模型应用开发中的通用模式抽象成了 API。我列一下手写接口和用 LangChain4j 的对比你感受一下差距功能点手写实现LangChain4j调用大模型自己封装 HTTP 客户端一行代码流式输出自己处理 SSE 解析内置 StreamingChatLanguageModel上下文记忆自己维护消息列表ChatMemory 开箱即用文档切分自己写递归切分逻辑DocumentSplitters 内置多种策略向量化自己调 Embedding APIEmbeddingModel 统一接口向量存储自己写相似度计算EmbeddingStore 支持多种存储RAG 检索自己拼 PromptRetrievalAugmentor 自动完成多模型切换改代码换一个依赖和配置这个对比不是夸张是我实际项目里的真实感受。手写的时候光是一个“上下文记忆”就要考虑消息怎么存、存多少条、超长了怎么截断、多轮对话怎么区分用户这些细节能吃掉你大量时间。LangChain4j 的另一个好处是模型无关。今天用通义千问明天想换成别的模型只需要换依赖和配置业务代码基本不动。这对企业项目来说太重要了谁也不想被某个模型绑死。2.2 通义千问在 Java 生态里的优势选通义千问有几个实际考虑第一国内访问稳定。做企业项目网络稳定性是硬指标。通义千问的 API 在国内访问延迟低不需要额外处理网络问题。第二有免费的 Embedding 模型。RAG 需要把文档向量化Embedding 调用量很大。通义千问的 text-embedding-v2 有免费额度对中小项目来说能省不少成本。第三LangChain4j 官方支持。LangChain4j 有专门的langchain4j-community-dashscope模块接入通义千问只需要加一个依赖不用自己写适配层。第四流式输出体验好。通义千问的流式接口响应快首字延迟低做打字机效果很流畅。2.3 整体架构设计在动手写代码之前先把架构理清楚。整个系统分四层第一层接入层。Spring Boot 提供 REST 接口前端通过 SSE 接收流式回答。第二层AI 服务层。LangChain4j 的 AiServices 接口定义问答方法框架自动生成实现。第三层RAG 层。包含文档加载器、切分器、Embedding 模型、向量存储、检索器负责把知识库内容变成可检索的向量。第四层模型层。通义千问的 Chat 模型和 Embedding 模型通过 LangChain4j 的适配器接入。数据流向是这样的用户提问 → 接入层接收 → AI 服务层调用 → RAG 层检索相关文档 → 拼接 Prompt → 模型层生成回答 → 流式返回给前端。这个架构的好处是每层职责清晰想换模型只动第四层想换向量库只动第三层业务代码不受影响。3. 环境搭建与依赖配置3.1 Maven 依赖的正确姿势先上依赖配置这是最容易踩坑的地方。LangChain4j 的模块很多版本要对齐不然会出现各种 NoSuchMethodError。properties langchain4j.version0.35.0/langchain4j.version spring-boot.version3.2.5/spring-boot.version /properties dependencies !-- LangChain4j 核心 -- dependency groupIddev.langchain4j/groupId artifactIdlangchain4j/artifactId version${langchain4j.version}/version /dependency !-- 通义千问适配 -- dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-community-dashscope/artifactId version${langchain4j.version}/version /dependency !-- 文档解析支持 PDF、Word、Excel -- dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-document-parser-apache-tika/artifactId version${langchain4j.version}/version /dependency !-- 内存向量存储生产环境可换 Redis 或 PgVector -- dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-embeddings/artifactId version${langchain4j.version}/version /dependency !-- Spring Boot Web用于提供 SSE 接口 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency /dependencies这里有几个关键点要说明版本对齐LangChain4j 的所有模块必须用同一个版本号。我见过有人核心模块用 0.35.0适配模块用 0.34.0结果启动就报错。统一用${langchain4j.version}管理最省心。dashscope 模块这是通义千问的适配模块注意它属于langchain4j-community系列不是核心模块。社区模块的更新频率和核心模块不完全同步选版本时要注意兼容性。Tika 解析器如果你的知识库有 PDF、Word 文档这个依赖必加。它基于 Apache Tika能解析几乎所有常见文档格式。如果只有纯文本可以不加。向量存储开发阶段用内存存储就够了重启数据丢失但调试方便。生产环境建议换 Redis 或 PostgreSQL 的 pgvector 扩展这个后面会讲怎么切换。3.2 通义千问 API Key 配置去阿里云百炼平台开通通义千问服务拿到 API Key。然后在application.yml里配置langchain4j: dashscope: api-key: ${DASHSCOPE_API_KEY} chat-model: qwen-plus embedding-model: text-embedding-v2API Key 千万别硬编码在代码里用环境变量注入。我见过有人把 Key 提交到 Git 仓库结果被人刷了几千块钱的调用量。模型选择上qwen-plus是性价比最高的日常问答够用。如果对回答质量要求极高可以换qwen-max但价格贵不少。qwen-turbo最便宜适合对延迟敏感、问题简单的场景。Embedding 模型用text-embedding-v2它支持中文维度是 1536。这个维度很重要后面配置向量存储时要用到。3.3 项目结构规划我习惯按职责分包这样后期维护清晰src/main/java/com/example/rag/ ├── config/ 配置类 │ ├── LangChainConfig.java │ └── RagConfig.java ├── service/ 业务服务 │ ├── AiService.java │ └── DocumentService.java ├── controller/ 接口层 │ └── ChatController.java ├── model/ 数据模型 │ ├── ChatRequest.java │ └── ChatResponse.java └── RagApplication.java这个结构不是必须的但建议至少把配置、服务、接口分开。我见过把所有代码塞一个类里的后期改起来想死。4. 核心实现从模型接入到 RAG 链路4.1 通义千问模型接入先写配置类把 Chat 模型和 Embedding 模型注册成 Spring BeanConfiguration public class LangChainConfig { Value(${langchain4j.dashscope.api-key}) private String apiKey; Bean public ChatLanguageModel chatLanguageModel() { return DashScopeChatModel.builder() .apiKey(apiKey) .modelName(qwen-plus) .temperature(0.7) .build(); } Bean public StreamingChatLanguageModel streamingChatModel() { return DashScopeStreamingChatModel.builder() .apiKey(apiKey) .modelName(qwen-plus) .temperature(0.7) .build(); } Bean public EmbeddingModel embeddingModel() { return DashScopeEmbeddingModel.builder() .apiKey(apiKey) .modelName(text-embedding-v2) .build(); } }这里解释几个参数temperature控制回答的随机性。0 最确定1 最随机。做知识问答建议 0.3 到 0.7太低回答死板太高容易胡说。我一般设 0.7兼顾准确性和自然度。两个 Chat 模型ChatLanguageModel用于普通调用StreamingChatLanguageModel用于流式输出。两个都要注册因为 RAG 检索时需要普通调用最终回答时需要流式。Embedding 模型负责把文本转成向量。注意它的输入是文本输出是 float 数组维度 1536。4.2 文档加载与切分RAG 的第一步是把知识库文档加载进来并切分成小块。切分策略直接影响检索效果这是最容易被忽视的环节。Service public class DocumentService { Autowired private EmbeddingModel embeddingModel; Autowired private EmbeddingStoreTextSegment embeddingStore; public void loadDocuments(String path) { // 1. 加载文档 DocumentParser parser new ApacheTikaDocumentParser(); ListDocument documents new ArrayList(); File dir new File(path); for (File file : dir.listFiles()) { Document document parser.parse(file); documents.add(document); } // 2. 切分文档 DocumentSplitter splitter DocumentSplitters.recursive( 500, // 每块最大字符数 50 // 块之间重叠字符数 ); ListTextSegment segments splitter.splitAll(documents); // 3. 向量化并存储 for (TextSegment segment : segments) { Embedding embedding embeddingModel.embed(segment).content(); embeddingStore.add(embedding, segment); } } }这段代码有三个关键决策切分大小 500 字符这是经验值。太小了语义不完整检索出来断章取义太大了包含太多无关信息影响检索精度。500 字符大约是一到两段话能保证语义相对完整。如果你的文档句子特别长可以调到 800如果是 FAQ 这种短问答可以调到 300。重叠 50 字符块之间保留重叠是为了避免关键信息被切断。比如一句话正好跨在两个块的分界线上没有重叠的话两个块都读不到完整意思。50 字符大约是 10% 的重叠率是个平衡点。递归切分DocumentSplitters.recursive会优先按段落切段落太长再按句子切句子还长再按字符切。这样能最大程度保持语义完整性。别用固定长度切分会把句子拦腰截断。注意文档切分是 RAG 效果的地基。我见过有人检索效果差排查半天发现是切分策略有问题。如果你的知识库文档结构清晰有明确的章节标题建议按标题切分效果比递归切分更好。4.3 向量存储与检索开发阶段用内存存储配置很简单Bean public EmbeddingStoreTextSegment embeddingStore() { return new InMemoryEmbeddingStore(); }但内存存储有两个问题重启数据丢失数据量大时内存吃紧。生产环境建议换 RedisBean public EmbeddingStoreTextSegment embeddingStore(RedisClient redisClient) { return RedisEmbeddingStore.builder() .host(localhost) .port(6379) .dimension(1536) // 必须和 Embedding 模型维度一致 .build(); }维度 1536 是text-embedding-v2的输出维度必须匹配否则存进去检索不出来。换 Embedding 模型时这个值要跟着改。检索器的配置Bean public ContentRetriever contentRetriever( EmbeddingStoreTextSegment embeddingStore, EmbeddingModel embeddingModel) { return EmbeddingStoreContentRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(5) // 返回最相关的 5 条 .minScore(0.7) // 相似度低于 0.7 的不要 .build(); }maxResults 设 5返回太多会稀释 Prompt让模型抓不住重点太少可能漏掉关键信息。5 条是个平衡点每条 500 字符总共 2500 字符加上问题和系统提示控制在模型上下文窗口内绰绰有余。minScore 设 0.7这是相似度阈值。低于这个值说明文档和问题不相关硬塞给模型反而干扰回答。0.7 是余弦相似度的经验值实际项目可以根据效果调整。如果发现经常检索不到内容可以降到 0.6如果检索结果经常跑偏可以升到 0.8。4.4 AI Service 接口定义LangChain4j 最优雅的地方就是 AiServices用接口声明式地定义 AI 能力public interface Assistant { SystemMessage( 你是一个专业的产品知识助手。请根据提供的参考资料回答用户问题。 要求 1. 只基于参考资料回答不要编造信息 2. 如果参考资料中没有相关内容直接说抱歉我没有找到相关信息 3. 回答要简洁明了分点说明 4. 引用参考资料时注明来源 ) String chat(UserMessage String question); SystemMessage(你是一个专业的产品知识助手请根据参考资料回答问题。) TokenStream streamChat(UserMessage String question); }然后配置 RAG 增强Bean public Assistant assistant( StreamingChatLanguageModel streamingModel, ChatLanguageModel chatModel, ContentRetriever contentRetriever) { return AiServices.builder(Assistant.class) .chatLanguageModel(chatModel) .streamingChatLanguageModel(streamingModel) .contentRetriever(contentRetriever) .chatMemory(MessageWindowChatMemory.withMaxMessages(10)) .build(); }SystemMessage 的设计这是整个 RAG 效果的关键。我加了四条约束每条都有用意。“只基于参考资料回答”是防止幻觉“没有相关内容直接说不知道”是避免硬编“分点说明”是提升可读性“注明来源”是方便用户核实。你可以根据自己的业务调整但“不编造”这条建议保留。ChatMemoryMessageWindowChatMemory.withMaxMessages(10)表示保留最近 10 条消息作为上下文。这样多轮对话时模型能记住前面聊了什么。10 条是个经验值太多会占用上下文窗口太少记不住。如果你的对话轮次多可以调到 20。contentRetriever把检索器注入后每次调用chat或streamChat时框架会自动执行“检索 → 拼接 Prompt → 调用模型”这一整套流程。你不需要手动拼 Prompt这就是框架的价值。4.5 流式问答接口实现最后是 Controller 层提供 SSE 接口给前端RestController RequestMapping(/api/chat) public class ChatController { Autowired private Assistant assistant; GetMapping(value /stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter streamChat(RequestParam String question) { SseEmitter emitter new SseEmitter(180_000L); // 3 分钟超时 TokenStream tokenStream assistant.streamChat(question); tokenStream.onNext(token - { try { emitter.send(SseEmitter.event().data(token)); } catch (IOException e) { emitter.completeWithError(e); } }).onComplete(response - { emitter.complete(); }).onError(error - { emitter.completeWithError(error); }).start(); return emitter; } }SseEmitter 超时设 3 分钟大模型生成长回答可能需要几十秒默认超时太短会中断。3 分钟是个安全值一般回答不会超过这个时间。onNext 逐 token 发送每生成一个 token 就推送给前端前端收到后追加到页面上就实现了打字机效果。onComplete 和 onError必须处理否则连接不会关闭会泄漏资源。前端对接很简单const eventSource new EventSource(/api/chat/stream?question encodeURIComponent(question)); eventSource.onmessage (event) { document.getElementById(answer).innerHTML event.data; }; eventSource.onerror () { eventSource.close(); };5. 踩坑实录与性能优化5.1 我踩过的五个坑坑一Embedding 维度不匹配。第一次换 Redis 存储时忘了改 dimension 配置存进去的数据检索出来全是乱码。排查了半天才发现是维度对不上。记住Embedding 模型换了dimension 必须跟着换。坑二文档切分把表格切碎了。产品文档里有大量参数表格递归切分按字符切把表格切得七零八落检索出来根本没法看。解决方案是自定义切分器遇到表格标记时整块保留。如果你的文档表格多建议用DocumentSplitters.recursive之前先做预处理把表格转成文本描述。坑三流式输出中文乱码。SSE 默认编码可能不是 UTF-8中文会乱码。解决方案是在produces里指定MediaType.TEXT_EVENT_STREAM_VALUESpring Boot 会自动用 UTF-8。如果还乱码检查一下server.servlet.encoding.charset配置。坑四并发调用时 ChatMemory 串了。ChatMemory 默认是全局的多个用户同时对话时A 用户的问题会出现在 B 用户的上下文里。解决方案是用ChatMemoryProvider按用户 ID 隔离.chatMemoryProvider(memoryId - MessageWindowChatMemory.withMaxMessages(10))调用时传入 memoryIdString chat(MemoryId String userId, UserMessage String question);坑五API 调用超时没重试。通义千问偶尔会超时没有重试机制的话用户直接看到报错。LangChain4j 支持配置重试DashScopeChatModel.builder() .apiKey(apiKey) .modelName(qwen-plus) .maxRetries(3) .timeout(Duration.ofSeconds(60)) .build();5.2 性能优化实战优化一文档向量化批处理。一篇文档切分成几百个块逐个调用 Embedding API 很慢。LangChain4j 支持批量ListEmbedding embeddings embeddingModel.embedAll(segments).content(); embeddingStore.addAll(embeddings, segments);实测批量比逐个快 5 到 10 倍。优化二检索结果缓存。相同的问题反复问每次都走一遍检索很浪费。可以用 Caffeine 做本地缓存CacheString, ListContent cache Caffeine.newBuilder() .maximumSize(1000) .expireAfterWrite(Duration.ofMinutes(30)) .build();优化三Prompt 精简。检索出来的 5 条内容不一定都用得上可以在拼接前做一次过滤只保留相似度最高的 3 条。这样 Prompt 更短模型响应更快。优化四流式输出首字优化。用户最在意的是首字延迟。可以把 SystemMessage 精简减少模型的前置处理时间。实测精简 SystemMessage 能让首字延迟降低 20% 左右。5.3 常见问题速查表问题现象可能原因解决方案启动报 NoSuchMethodError依赖版本不一致统一 langchain4j 版本号检索不到相关内容切分太大或 minScore 太高调小切分粒度降低 minScore回答胡编乱造SystemMessage 约束不够强化“只基于参考资料”约束流式输出中断SseEmitter 超时太短调大超时时间到 3 分钟中文乱码编码未指定produces 指定 UTF-8多用户上下文串了ChatMemory 全局共享用 ChatMemoryProvider 隔离API 调用超时网络波动配置 maxRetries 和 timeout内存溢出向量数据全在内存换 Redis 或 PgVector 存储6. 从 Demo 到生产的最后一公里6.1 向量存储的选型建议内存存储只适合开发和演示生产环境必须换持久化方案。我对比过三种Redis部署简单性能好支持向量检索。适合中小规模知识库百万级向量以内。缺点是 Redis 的向量检索功能需要 Redis Stack普通 Redis 不支持。PostgreSQL pgvector如果你已经在用 PostgreSQL加个扩展就行不用额外部署。适合数据量不大、想减少组件的场景。缺点是性能不如专用向量库。Milvus专业向量数据库支持亿级向量检索性能最强。适合大规模知识库。缺点是部署复杂运维成本高。我的建议是中小项目用 Redis已有 PostgreSQL 的用 pgvector大规模项目上 Milvus。别一上来就上 Milvus运维成本会让你怀疑人生。6.2 知识库更新的处理生产环境的知识库是动态的文档会增删改。全量重建向量库太慢需要增量更新public void updateDocument(String docId, Document newDoc) { // 1. 删除旧向量 embeddingStore.removeAll(docId); // 2. 切分新文档 ListTextSegment segments splitter.split(newDoc); // 3. 重新向量化存储 ListEmbedding embeddings embeddingModel.embedAll(segments).content(); embeddingStore.addAll(embeddings, segments); }关键是给每个文档打上 docId 标记更新时先删后加。LangChain4j 的TextSegment支持 metadata可以把 docId 存在 metadata 里。6.3 效果评估与调优RAG 效果好不好不能靠感觉要有量化指标。我一般看三个检索命中率准备一批测试问题人工标注每个问题的正确答案在哪个文档块里然后看检索器能不能把那个块排在前 5。命中率低于 80% 就要调切分策略或检索参数。回答准确率让模型回答测试问题人工判断回答是否正确。准确率低于 90% 就要调 SystemMessage 或增加检索结果数量。首字延迟从用户提问到第一个字出现的时间。超过 2 秒用户就会觉得卡。优化方向是精简 Prompt、换更快的模型、加缓存。这三个指标我建议做成监控每次调整参数后跑一遍用数据说话别凭感觉调。6.4 成本控制大模型调用是花钱的尤其是 Embedding 调用。几个省钱技巧Embedding 结果缓存相同文档不要重复向量化。用文档内容的 MD5 做 key缓存 Embedding 结果。检索结果缓存相同问题不要重复检索。用问题文本做 key缓存检索结果。模型分级简单问题用 qwen-turbo复杂问题用 qwen-plus。可以在 SystemMessage 里让模型先判断问题复杂度但这样会增加一次调用。更简单的做法是按业务场景分流比如 FAQ 用 turbo深度咨询用 plus。控制检索数量maxResults 从 5 降到 3Prompt 长度减少 40%成本也跟着降。前提是检索精度够高3 条就能覆盖答案。我个人在实际项目里的体会是RAG 落地最难的不是技术是效果调优。技术方案网上到处都是但每个业务场景的文档特点、问题类型、用户预期都不一样需要反复测试和调整。我建议先用小批量文档跑通链路再逐步扩大知识库规模边用边调别想着一次做到完美。最后分享一个小技巧如果你的知识库文档质量参差不齐可以在加载时做一次预处理把格式混乱、内容重复的文档过滤掉。垃圾进垃圾出文档质量决定了 RAG 效果的上限这个道理在 RAG 里体现得特别明显。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑