1. 手写大模型接口这件事,到底浪费在哪里
先说一个我观察到的现象:身边不少 Java 后端同学,接到"给系统加个 AI 问答"的需求后,第一反应是打开 HTTP 客户端,手动拼 JSON,调一个对话补全接口,然后自己写重试、自己拼上下文、自己处理流式返回。代码写完三四百行,跑起来还经常出问题——上下文超长被截断、流式响应乱码、多轮对话记不住前文。这套流程走下来,快则两三天,慢则一周,最后交付的还只是一个"能聊天"的壳子。
问题不在于这些同学能力不行,而在于他们把一个已经被工程化解决的问题,当成了从零开始的问题。大模型接口调用这件事,本质上和十年前我们手写 JDBC 是一样的处境:早期大家确实手写Connection、Statement、ResultSet,后来有了 MyBatis、JPA,谁还手写?现在的大模型接入,正处在"该上框架了"的阶段。
LangChain4j 就是 Java 生态里干这件事的框架。它把模型调用、提示词模板、对话记忆、文档检索、流式输出这些重复劳动全部封装掉,让你专注在业务逻辑上。再配上通义千问这类国内可直接调用的模型服务,以及 RAG(检索增强生成)这套让模型"基于你的私有资料回答"的模式,一个完整的智能问答系统,核心代码可以压缩到百来行。
这篇文章要讲清楚三件事:为什么 Java 开发者不该再手写大模型接口、LangChain4j 到底帮你省掉了哪些活、怎么用 LangChain4j + 通义千问搭一套带流式输出的 RAG 问答。适合有 Java 基础、想快速把 AI 能力接进现有系统的后端同学,也适合被"AI 集成"需求追着跑、想找一条靠谱落地路径的技术负责人。全文的代码和配置都可以直接抄,我会把每个选择背后的理由讲透,而不是甩一堆 API 让你自己猜。
2. 手写接口 vs 框架封装:一笔算得清的账
2.1 手写方案里那些看不见的成本
很多人觉得手写接口"简单可控",但真正做过一轮的人会发现,可控是假象。我拿一个最朴素的"多轮对话 + 流式输出"需求来拆,手写方案至少要处理这些事:
- HTTP 层:选 HttpClient 还是 OkHttp?连接池怎么配?超时设多少?流式响应的
InputStream怎么按行读、怎么处理 SSE 的data:前缀和[DONE]结束标记? - 协议层:请求体里
messages数组怎么组织?system、user、assistant三种角色怎么区分?多轮对话时历史消息怎么拼、拼多少条、超了 token 上限怎么截? - 异常层:限流返回 429 怎么办?网络抖动重试几次?重试时流式已经吐了一半的内容怎么处理?
- 业务层:提示词模板怎么管理?不同场景用不同模板怎么切换?模型返回的 Markdown 怎么渲染?
这些事单拎出来都不难,但叠在一起就是几百行胶水代码,而且每一行都是你自己维护的负债。模型服务商改个字段名、加个新参数,你就得跟着改。更别提换模型——从通义千问换到另一个服务,请求格式、鉴权方式、流式协议可能全不一样,手写方案基本等于重写。
2.2 LangChain4j 把哪些活接走了
LangChain4j 的设计思路很清晰:用统一的抽象屏蔽模型差异,用声明式接口替代命令式调用。它主要帮你接管了四块:
| 能力 | 手写方案 | LangChain4j |
|---|---|---|
| 模型调用 | 自己拼 JSON、发 HTTP | 一行model.generate() |
| 对话记忆 | 自己维护消息列表 | MessageWindowChatMemory自动管理 |
| 提示词模板 | 字符串拼接 | @SystemMessage/@UserMessage注解 |
| 流式输出 | 自己解析 SSE | StreamingChatLanguageModel回调 |
| 文档检索 | 自己接向量库 | EmbeddingStore+ContentRetriever |
| 接口定义 | 手写 Service | 声明式interface+ 动态代理 |
最舒服的是最后一条。你可以像写 MyBatis 的 Mapper 一样,定义一个接口,加几个注解,LangChain4j 用动态代理帮你生成实现。比如:
interface Assistant { @SystemMessage("你是一个严谨的技术助手,只基于提供的资料回答") String chat(@UserMessage String question); }就这么几行,背后已经包含了模型调用、提示词注入、消息封装。这种"声明式"的体验,是手写方案给不了的。
2.3 什么时候手写反而更合适
我不是说手写一无是处。有两种情况手写更划算:一是你只需要调一次接口、做一次性验证,引入框架反而重;二是你要对接的模型服务极其特殊,框架还没适配,那只能自己来。但只要你的需求涉及多轮对话、流式输出、RAG 检索、多模型切换中的任意两项,框架的收益就远超学习成本。判断标准很简单:如果你写的胶水代码超过 100 行,就该考虑框架了。
3. 通义千问接入前的环境与依赖准备
3.1 为什么选通义千问作为模型底座
选模型这件事,国内开发者绕不开几个现实约束:网络可达性、计费方式、中文能力、API 稳定性。通义千问在这几点上比较均衡——国内直连、按 token 计费、中文语料充足、有兼容 OpenAI 格式的接口。LangChain4j 对它的支持也比较成熟,有专门的langchain4j-community-dashscope模块。
这里要提醒一句:模型选型不要一上来就追求最强。RAG 场景下,模型主要负责"基于检索到的资料组织语言",对模型本身的推理能力要求没那么高。用中等规格的模型先把链路跑通,等效果不达标再升级,比一上来就用最贵的模型试错要省钱得多。
3.2 Maven 依赖怎么配
核心依赖分三块:LangChain4j 核心、通义千问适配、向量存储。我用的版本组合如下,你可以直接抄:
<properties> <langchain4j.version>0.35.0</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-community-dashscope</artifactId> <version>${langchain4j.version}</version> </dependency> <!-- 内存向量库,用于 RAG 检索 --> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-embeddings-all-minilm-l6-v2</artifactId> <version>${langchain4j.version}</version> </dependency> <!-- 文档解析:支持 PDF、Word 等 --> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-document-parser-apache-tika</artifactId> <version>${langchain4j.version}</version> </dependency> </dependencies>注意:LangChain4j 版本迭代较快,社区模块的 artifactId 偶有调整。如果拉不到依赖,去 Maven 中央仓库搜
langchain4j-community-dashscope确认最新坐标,别硬套我这里的版本号。
3.3 密钥与配置的安全处理
API Key 绝对不能硬编码进代码,也不建议直接写死在application.yml里提交到仓库。我的做法是走环境变量 + 配置类读取:
@Configuration public class ModelConfig { @Value("${dashscope.api-key}") private String apiKey; @Bean public ChatLanguageModel chatModel() { return DashScopeChatModel.builder() .apiKey(apiKey) .modelName("qwen-plus") .temperature(0.3) .build(); } }application.yml里这样写:
dashscope: api-key: ${DASHSCOPE_API_KEY}启动时通过环境变量注入。temperature设 0.3 是有讲究的——RAG 场景要的是"忠实于资料",不是"发挥创意",温度调低能让模型更保守地复述检索内容,减少胡编。这个参数我踩过坑:一开始用默认值 0.7,模型经常在资料没覆盖的地方自由发挥,调低之后幻觉明显减少。
4. 从零搭一套 RAG:文档入库到检索的完整链路
4.1 RAG 到底解决了什么问题
先讲清楚 RAG 的价值,不然容易做成"为了 RAG 而 RAG"。大模型有两个硬伤:知识有截止日期、不知道你的私有资料。你问它"我们公司上季度的报销政策是什么",它要么说不知道,要么一本正经地编。RAG 的思路是:在提问之前,先去你的资料库里检索相关内容,把检索结果塞进提示词,让模型基于这些内容回答。
打个比方,模型是个博学但没看过你公司文件的顾问,RAG 就是每次提问前,先让助理把相关文件找出来递给顾问,顾问看着文件回答。这样既用上了模型的表达能力,又保证了答案有据可依。
4.2 文档加载与切分:切得好不好直接决定检索质量
RAG 效果差,八成问题出在切分上。文档切得太粗,检索出来的段落包含大量无关信息,模型被干扰;切得太细,一个完整意思被拆散,检索到的片段缺头少尾。我的经验值是:中文技术文档按 500 到 800 字符切,重叠 100 字符左右。
DocumentSplitter splitter = DocumentSplitters.recursive(600, 100); DocumentParser parser = new ApacheTikaDocumentParser(); Document document = parser.parse(new File("docs/manual.pdf")); List<TextSegment> segments = splitter.split(document);recursive切分器会优先按段落、句子这些自然边界切,而不是硬按字符数截断,这样切出来的片段语义更完整。重叠部分是为了防止关键信息正好卡在切口上——比如一个定义跨了两个片段,重叠能保证至少有一个片段包含完整定义。
提示:如果你的资料里有大量表格,Tika 解析出来的表格结构会乱。这种情况建议先把表格转成 Markdown 再入库,或者针对表格单独做处理,别指望通用解析器能完美还原。
4.3 向量化与存储:让"语义相似"可计算
切分完的文本要转成向量才能做语义检索。向量化的本质是把一段文字映射成一串数字,语义相近的文字,向量距离也近。这样用户问"怎么报销",即使资料里写的是"费用申请流程",也能被检索到——因为它们语义相近,向量距离近。
EmbeddingModel embeddingModel = new AllMiniLmL6V2EmbeddingModel(); EmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>(); EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder() .documentSplitter(splitter) .embeddingModel(embeddingModel) .embeddingStore(embeddingStore) .build(); ingestor.ingest(document);这里用的是内存向量库,适合验证和小规模场景。生产环境数据量大、要持久化,得换成专门的向量库。选型上,数据量在百万级以内、团队不想额外运维,可以考虑基于本地文件的方案;数据量再大或者要分布式,就得上独立的向量数据库。别一上来就上重型向量库,先用内存版把链路跑通,确认效果后再迁移,迁移成本其实不高,因为 LangChain4j 的EmbeddingStore接口是统一的。
4.4 检索器配置:召回数量不是越多越好
检索器负责在提问时找出最相关的片段。核心参数是召回数量maxResults:
ContentRetriever retriever = EmbeddingStoreContentRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(5) .minScore(0.6) .build();maxResults设 5 是我反复调出来的经验值。设 3,有时候关键信息没被召回;设 10,塞进提示词的无关内容太多,模型反而抓不住重点,还浪费 token。minScore是相似度阈值,低于这个分数的片段直接丢弃,避免"矮子里拔将军"把不相关的内容硬塞进去。这两个参数没有万能值,得拿你的真实问题去测,看召回的内容是否切题。
5. 声明式接口 + 流式输出:把问答体验做顺
5.1 用接口定义替代手写调用
LangChain4j 最优雅的地方是声明式接口。定义一个Assistant接口,加注解,框架自动生成实现:
interface Assistant { @SystemMessage(""" 你是一个企业知识助手。请严格基于检索到的资料回答问题。 如果资料中没有相关信息,直接说"资料中未找到相关内容",不要编造。 回答要简洁,必要时分点说明。 """) String chat(@UserMessage String question); }然后组装:
Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(chatModel) .contentRetriever(retriever) .chatMemory(MessageWindowChatMemory.withMaxMessages(10)) .build();注意contentRetriever一挂上,RAG 就自动生效了——框架会在每次提问前自动检索、自动把结果注入提示词。chatMemory负责多轮对话记忆,withMaxMessages(10)表示保留最近 10 条消息。这个数字也要权衡:留太少,多轮对话会"失忆";留太多,token 消耗大且早期无关内容会干扰当前回答。
5.2 流式输出为什么必须做
同步返回和流式返回,用户体验差一个量级。同步模式下,用户点发送后要盯着转圈等三五秒,才看到整段答案蹦出来;流式模式下,第一个字几乎立刻出现,然后逐字往外吐,用户感觉"它在思考、在回答",等待焦虑大幅降低。这不是锦上添花,是问答类产品的体验底线。
LangChain4j 的流式接口用回调实现:
interface StreamingAssistant { @SystemMessage("你是一个企业知识助手,基于资料简洁回答") TokenStream chat(@UserMessage String question); }TokenStream提供了几种消费方式,最常用的是绑定到回调:
TokenStream tokenStream = streamingAssistant.chat("报销流程是什么"); tokenStream .onNext(token -> { // 每收到一个 token 就推给前端 sseEmitter.send(token); }) .onComplete(response -> { sseEmitter.complete(); }) .onError(error -> { sseEmitter.completeWithError(error); }) .start();5.3 流式接口和 Spring MVC 怎么对接
后端要把流式内容推给前端,标准做法是 SSE(Server-Sent Events)。Spring MVC 提供了SseEmitter:
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter streamChat(@RequestParam String question) { SseEmitter emitter = new SseEmitter(60_000L); streamingAssistant.chat(question) .onNext(emitter::send) .onComplete(r -> emitter.complete()) .onError(emitter::completeWithError) .start(); return emitter; }前端用EventSource接收即可。这里有个坑我踩过:SseEmitter的超时时间一定要设,默认值在某些容器下很短,长回答还没吐完连接就断了。我设的 60 秒,你可以根据最长回答时长调整。另外,onNext里如果直接send原始 token,遇到换行符可能破坏 SSE 格式,稳妥做法是把 token 里的换行转义,或者用 JSON 包一层再发。
5.4 多轮对话的记忆管理细节
MessageWindowChatMemory是滑动窗口,只保留最近 N 条消息。但有个细节要注意:它按"消息条数"算,不按 token 算。如果某条消息特别长,10 条消息可能就撑爆上下文了。更精细的做法是用TokenWindowChatMemory,按 token 数控制窗口:
ChatMemory memory = TokenWindowChatMemory.withMaxTokens(2000, new OpenAiTokenizer());这样无论消息长短,总 token 数都可控。代价是需要一个 tokenizer 来估算 token 数,稍微重一点。我的建议是:对话轮次少、消息短的场景用MessageWindowChatMemory就够;如果用户可能粘贴大段文本提问,果断上TokenWindowChatMemory。
6. 实测中那些文档不会写的坑
6.1 检索到了但模型不用:提示词的锅
链路跑通后,我遇到一个诡异现象:检索器明明召回了正确片段,模型却答非所问。排查半天发现是提示词的问题。原来的 system message 写得太客气:"你可以参考以下资料",模型就真的只是"参考",该编还是编。改成强约束后就好了:
请严格基于以下检索到的资料回答问题。 资料中没有的内容,一律回答"资料中未找到相关内容"。 禁止使用资料之外的知识作答。提示词的约束力,直接决定 RAG 的可靠性。别指望模型自觉,要把规则写死、写明确。这是我从多次调试里总结出的最值钱的一条经验。
6.2 中文分词的隐性影响
用英文 embedding 模型处理中文,效果会打折扣。我一开始用默认的英文模型,发现中文语义检索经常不准。换成对中文更友好的 embedding 模型后,召回质量明显提升。选 embedding 模型时,一定要确认它对中文的支持程度,别默认所有模型都中英文通吃。这个点很容易被忽略,但影响很大。
6.3 流式输出的乱码与断连
流式场景下我遇到过两类问题。一是中文乱码:token 按字节流返回时,一个中文字符可能被拆到两个 token 里,直接拼接会出乱码。解决办法是在服务端按字符边界处理,或者确保编码统一为 UTF-8。二是连接中断:用户网络抖动或主动关闭页面时,后端还在往一个已断开的连接推数据,会抛异常。要在onError里做好兜底,别让异常冒到顶层。
6.4 首次请求慢:模型冷启动
第一次调用模型接口时,响应明显比后续慢,这是模型服务的冷启动。对用户体验有影响的话,可以在应用启动后发一个预热请求,把连接和模型都"叫醒"。这个技巧在延迟敏感的场景里很实用,成本几乎为零。
7. 几个能直接抄的优化方向
链路跑通只是起点,真正拉开差距的是细节优化。分享几个我实践下来收益明显的方向。
检索结果重排序。初步召回 5 条后,用一个更精细的模型对这 5 条重新打分排序,把最相关的放前面。LangChain4j 支持接入重排序模型,对提升答案准确率帮助很大,尤其是资料库内容庞杂的时候。
查询改写。用户的问题往往口语化、有指代,直接拿去检索效果差。可以在检索前先用模型把问题改写成更适合检索的形式。比如用户问"那个报销的事咋弄",改写成"费用报销流程和所需材料",检索命中率立刻不一样。
分场景配置。不同业务用不同的提示词、不同的召回策略。技术文档问答和客服问答,对"忠实度"和"语气"的要求完全不同,别用一套配置打天下。LangChain4j 支持定义多个Assistant接口,按场景注入不同配置,切换很灵活。
加一层缓存。高频重复问题(比如"怎么重置密码")没必要每次都走完整链路。在检索层或答案层加缓存,能显著降低响应时间和调用成本。缓存 key 用问题的向量表示,语义相同的问题也能命中。
监控与反馈闭环。记录每次问答的检索片段、模型回答、用户反馈。这些数据是后续优化的金矿——哪些问题召回不准、哪些回答被用户点了"没用",一目了然。没有这个闭环,优化就是盲人摸象。
最后说个心态上的体会:RAG 不是配好就一劳永逸的东西,它更像一个需要持续调优的系统。检索质量、提示词、模型参数,每一环都有优化空间。但好消息是,用 LangChain4j 把骨架搭起来之后,后续的优化都是在这个骨架上做增量,而不是推倒重来。这比手写一堆胶水代码、每次改动都牵一发动全身,要舒服太多了。先把最小可用链路跑通,再拿真实问题去喂、去调,这条路我走过,是通的。