1. 为什么 Java 后端值得花时间搞明白 LangChain4j
做 Java 后端的这几年,我最大的感受是:AI 功能已经从“要不要接”变成了“什么时候接、怎么接得不难看”。以前团队里想做个智能问答或者文档摘要,第一反应是让 Python 同学搭个服务,Java 这边通过 HTTP 去调。结果就是多了一个服务要部署、多了一套鉴权要维护、多了一份超时和重试逻辑要写,联调的时候两边互相甩锅。LangChain4j 出现之后,这件事的逻辑变了——它把大模型调用、提示词模板、对话记忆、向量检索这些能力,用 Java 开发者熟悉的方式封装了起来,直接塞进 Spring Boot 项目里就能跑。
这篇内容我打算按一个真实项目的推进节奏来写:从依赖引入、AiService 声明式接口、TokenStream 流式输出,到 RAG 检索增强、和 Spring Boot 的整合细节,再到实际踩过的坑。适合已经有 Java 基础、用过 Spring Boot、想在自己项目里落地 AI 能力的后端同学。如果你连 Maven 依赖都没配过,建议先把 Spring Boot 跑起来再回来看,不然中间有些地方会卡住。
先说清楚 LangChain4j 到底解决什么问题。你可以把它理解成一层“翻译官”:左边是你熟悉的 Java 对象、接口、注解,右边是大模型厂商各自的 HTTP API、参数格式、返回结构。没有它的时候,你得自己拼 JSON、自己解析响应、自己管理对话历史;有了它,你定义一个接口,加个注解,调用起来就像调本地 Service 一样。这个体验上的差别,用过一次就回不去了。
2. 核心概念拆解:AiService、TokenStream 与 RAG 到底在干嘛
2.1 AiService:把大模型调用伪装成普通接口
AiService 是 LangChain4j 里我觉得最舒服的设计。传统写法是你要拿到一个模型客户端,然后手动构造消息列表,再调chat()方法,最后从响应里抠出文本。AiService 把这套流程反过来:你先定义一个 Java 接口,方法签名写清楚输入输出,然后用AiServices.create()生成实现。框架在运行时用动态代理帮你把方法调用翻译成模型请求。
举个最直观的例子。假设我要做一个“代码解释器”,输入一段 Java 代码,输出中文解释。接口可以这么写:
interface CodeExplainer { @SystemMessage("你是一个资深 Java 工程师,用简洁的中文解释代码逻辑") String explain(@UserMessage String code); }然后创建实例:
CodeExplainer explainer = AiServices.create(CodeExplainer.class, chatModel); String result = explainer.explain("public int add(int a, int b) { return a + b; }");这里@SystemMessage定义的是系统角色设定,@UserMessage标记的是用户输入。框架会自动把参数填进消息模板,发给模型,再把返回文本映射成 String。整个过程你不需要碰任何 JSON。
为什么这个设计重要?因为它让 AI 调用变成了“面向接口编程”的一部分。你可以像注入普通 Bean 一样注入 AiService,可以在接口上做 AOP,可以写单元测试时 mock 掉。对于习惯了 Spring 生态的 Java 后端来说,这种心智负担几乎为零。
2.2 TokenStream:流式输出不是炫技,是体验刚需
大模型生成一段 500 字的回答,如果等全部生成完再返回,用户盯着转圈可能要等五六秒。TokenStream 的作用是把生成过程拆成一个个 token,边生成边推给前端。用户看到文字一个个蹦出来,主观等待感会大幅下降。
LangChain4j 里流式调用有两种常见形态。一种是直接用StreamingChatLanguageModel,通过回调处理每个 token:
StreamingChatLanguageModel model = OpenAiStreamingChatModel.builder() .apiKey(System.getenv("OPENAI_API_KEY")) .modelName("gpt-4o-mini") .build(); model.generate("用一句话介绍 Java 的垃圾回收", new StreamingResponseHandler<AiMessage>() { @Override public void onNext(String token) { System.out.print(token); } @Override public void onComplete(Response<AiMessage> response) { System.out.println("\n--- 完成 ---"); } @Override public void onError(Throwable error) { error.printStackTrace(); } });另一种是在 AiService 接口里把返回类型声明成TokenStream,框架会自动帮你处理流式逻辑:
interface StreamingAssistant { TokenStream chat(@UserMessage String message); }调用后拿到 TokenStream,可以注册onNext、onComplete、onError回调,也可以配合 Spring 的 SSE 或 WebSocket 推给前端。
注意:流式接口和普通接口不要混用在同一个方法上。返回
TokenStream的方法,框架不会等结果生成完,所以你不能在方法内部再做后处理。需要后处理就老老实实用同步返回。
2.3 RAG:让模型回答“它本来不知道”的事
RAG 是 Retrieval-Augmented Generation 的缩写,中文一般叫检索增强生成。核心思路很朴素:模型训练数据里没有你公司的内部文档,那就先把相关文档片段检索出来,拼进提示词里,让模型基于这些片段回答。
LangChain4j 的 RAG 流程分两大阶段。索引阶段:把文档切块、向量化、存进向量库。检索阶段:把用户问题向量化,去向量库找最相似的片段,拼进上下文。
索引阶段的代码大致长这样:
EmbeddingModel embeddingModel = OpenAiEmbeddingModel.builder() .apiKey(System.getenv("OPENAI_API_KEY")) .build(); EmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>(); DocumentSplitter splitter = DocumentSplitters.recursive(500, 50); List<Document> documents = FileSystemDocumentLoader.loadDocuments("/path/to/docs"); List<TextSegment> segments = splitter.splitAll(documents); EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder() .documentSplitter(splitter) .embeddingModel(embeddingModel) .embeddingStore(embeddingStore) .build(); ingestor.ingest(documents);检索阶段可以做成一个 ContentRetriever,挂到 AiService 上:
ContentRetriever retriever = EmbeddingStoreContentRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(5) .minScore(0.7) .build(); Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(chatModel) .contentRetriever(retriever) .build();这样每次调用assistant.chat(),框架会先检索、再拼提示词、再调模型。你不需要手动写“请根据以下资料回答”这种模板,框架帮你做了。
3. 从零搭一个 Spring Boot + LangChain4j 的最小可运行项目
3.1 依赖引入与版本选择
先说版本。LangChain4j 迭代很快,不同版本 API 有差异。我写这篇时用的是 0.35.0 附近的版本,核心依赖分几个模块:
<properties> <langchain4j.version>0.35.0</langchain4j.version> </properties> <dependencies> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>${langchain4j.version}</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-open-ai</artifactId> <version>${langchain4j.version}</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-spring-boot-starter</artifactId> <version>${langchain4j.version}</version> </dependency> </dependencies>如果你用的是其他模型厂商,把langchain4j-open-ai换成对应模块即可。langchain4j-spring-boot-starter提供自动配置,能省掉不少手动创建 Bean 的代码。
提示:不要盲目追最新版本。LangChain4j 有些版本之间包名和类名会调整,升级前先看 release notes,否则编译报错会浪费很多时间。
3.2 配置文件与模型 Bean 的创建
在application.yml里放模型配置:
langchain4j: open-ai: chat-model: api-key: ${OPENAI_API_KEY} model-name: gpt-4o-mini temperature: 0.7 timeout: PT60S streaming-chat-model: api-key: ${OPENAI_API_KEY} model-name: gpt-4o-mini embedding-model: api-key: ${OPENAI_API_KEY} model-name: text-embedding-3-small如果不想用 starter 的自动配置,也可以手动建 Bean:
@Configuration public class AiConfig { @Bean public ChatLanguageModel chatLanguageModel() { return OpenAiChatModel.builder() .apiKey(System.getenv("OPENAI_API_KEY")) .modelName("gpt-4o-mini") .temperature(0.7) .timeout(Duration.ofSeconds(60)) .build(); } @Bean public StreamingChatLanguageModel streamingChatLanguageModel() { return OpenAiStreamingChatModel.builder() .apiKey(System.getenv("OPENAI_API_KEY")) .modelName("gpt-4o-mini") .build(); } }手动建 Bean 的好处是参数一目了然,出问题好排查。自动配置的好处是代码少。我一般项目初期用手动,稳定后再考虑切自动。
3.3 定义第一个 AiService 并注入使用
定义一个客服助手接口:
public interface CustomerServiceAssistant { @SystemMessage("你是电商平台的客服助手,回答要礼貌、简洁,不确定的信息不要编造") String answer(@UserMessage String question); @SystemMessage("你是电商平台的客服助手") TokenStream answerStream(@UserMessage String question); }在配置类里注册成 Bean:
@Bean public CustomerServiceAssistant customerServiceAssistant( ChatLanguageModel chatModel, StreamingChatLanguageModel streamingModel) { return AiServices.builder(CustomerServiceAssistant.class) .chatLanguageModel(chatModel) .streamingChatLanguageModel(streamingModel) .build(); }然后在 Controller 里注入:
@RestController @RequestMapping("/api/assistant") public class AssistantController { private final CustomerServiceAssistant assistant; public AssistantController(CustomerServiceAssistant assistant) { this.assistant = assistant; } @PostMapping("/ask") public String ask(@RequestBody String question) { return assistant.answer(question); } }到这里,一个最小可运行的 AI 接口就完成了。启动项目,用 Postman 发个请求,能看到模型返回就说明链路通了。
4. 流式输出与前端联调:TokenStream 落地细节
4.1 SSE 推送的完整实现
流式输出最常见的落地方式是 SSE。Spring Boot 里用SseEmitter配合 TokenStream 回调:
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter stream(@RequestParam String question) { SseEmitter emitter = new SseEmitter(120_000L); TokenStream tokenStream = assistant.answerStream(question); tokenStream.onNext(token -> { try { emitter.send(SseEmitter.event().data(token)); } catch (IOException e) { emitter.completeWithError(e); } }).onComplete(response -> emitter.complete()) .onError(emitter::completeWithError) .start(); return emitter; }前端用EventSource接收:
const source = new EventSource('/api/assistant/stream?question=' + encodeURIComponent(q)); source.onmessage = (event) => { document.getElementById('output').textContent += event.data; }; source.onerror = () => source.close();这里有几个细节值得说。第一,SseEmitter的超时时间要设得比模型生成时间长,否则长回答会被截断。第二,onNext里发数据可能抛 IOException,必须捕获,不然线程会挂。第三,.start()不能忘,忘了回调不会触发。
4.2 流式场景下的异常处理
流式接口的异常处理和同步接口不一样。同步接口抛异常,全局异常处理器能兜住。流式接口一旦开始推送,HTTP 状态码已经发出去了,再抛异常前端只能通过连接断开感知。
我的做法是在onError里推一个特殊标记,比如[ERROR]前缀,前端识别到就展示错误提示:
.onError(error -> { try { emitter.send(SseEmitter.event().data("[ERROR] 生成失败,请重试")); } catch (IOException ignored) { } emitter.completeWithError(error); })另外,模型调用超时、限流、余额不足这些情况,最好在进入流式之前先做一次轻量校验,能提前失败的不要拖到流中间。
注意:SSE 连接在 Nginx 后面容易被缓冲,导致前端看不到逐字效果。需要在 Nginx 配置里对 SSE 路径关闭
proxy_buffering,否则你本地测试正常,上线就变成一次性返回。
5. RAG 实战:把内部文档变成可检索知识库
5.1 文档切块策略与参数选择
切块是 RAG 里最容易被忽视但影响最大的环节。切太大,检索出来的片段包含太多无关信息,模型容易被干扰;切太小,语义不完整,检索命中率下降。
LangChain4j 提供DocumentSplitters.recursive(maxSegmentSize, maxOverlapSize)。我的经验值:中文文档maxSegmentSize设 300 到 500 字符,maxOverlapSize设 50 到 80 字符。重叠是为了防止一句话被切断后两边都读不懂。
DocumentSplitter splitter = DocumentSplitters.recursive(400, 60);如果你的文档是 Markdown,建议按标题层级切,保留结构信息。LangChain4j 有DocumentByParagraphSplitter、DocumentByLineSplitter等,按文档类型选。
5.2 向量库选型:内存、Redis 还是专用库
开发阶段用InMemoryEmbeddingStore最省事,重启数据就没了,但调试方便。生产环境要持久化,常见选择:
| 向量库 | 适用场景 | 特点 |
|---|---|---|
| InMemoryEmbeddingStore | 开发调试、小数据量 | 零依赖,重启丢失 |
| Redis | 已有 Redis 基础设施 | 部署简单,性能够用 |
| PgVector | 已用 PostgreSQL | 和业务数据同库,事务方便 |
| Milvus | 大规模向量检索 | 专业向量库,运维成本高 |
| Chroma | 快速原型 | 轻量,Python 生态常用 |
我一般中小项目直接上 PgVector,因为业务库本来就是 PostgreSQL,少维护一个组件。LangChain4j 有对应的langchain4j-pgvector模块。
5.3 检索质量调优的几个抓手
RAG 效果不好,八成出在检索环节。我常用的调优手段:
第一,调整maxResults和minScore。maxResults是返回片段数,太多会撑爆上下文,太少可能漏掉关键信息,一般 3 到 5 个。minScore是相似度阈值,低于这个分数的片段直接丢弃,避免无关内容干扰。
第二,加元数据过滤。比如文档有部门、时间、类型字段,检索时可以限定范围:
ContentRetriever retriever = EmbeddingStoreContentRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(5) .minScore(0.7) .dynamicFilter(query -> metadataKey("department").isEqualTo("tech")) .build();第三,查询改写。用户问“报销怎么弄”,直接检索可能命中率低,可以先让模型把问题改写成“报销流程 报销标准 报销材料”,再检索。LangChain4j 有QueryTransformer接口,CompressingQueryTransformer和ExpandingQueryTransformer都能用。
6. 踩坑记录与常见问题速查
6.1 依赖冲突与类找不到
LangChain4j 依赖了一些 HTTP 客户端和 JSON 库,和 Spring Boot 自带的有时候会打架。典型症状是启动报NoSuchMethodError或ClassNotFoundException。排查方法:mvn dependency:tree看冲突,用<exclusions>排掉旧版本。
另一个常见问题是模型模块和核心模块版本不一致。比如核心用 0.35.0,open-ai 模块用 0.34.0,编译能过但运行时报错。统一用${langchain4j.version}管理。
6.2 超时与重试配置
大模型调用慢是常态,默认超时往往不够。同步调用建议 60 秒,流式调用建议 120 秒以上。重试要谨慎,因为模型调用可能已经产生费用,盲目重试会翻倍扣费。我的做法是只对网络类异常重试,对业务类异常(如内容审核不通过)不重试。
OpenAiChatModel.builder() .apiKey(apiKey) .timeout(Duration.ofSeconds(60)) .maxRetries(2) .build();6.3 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 启动报 Bean 找不到 | AiService 没注册成 Bean | 检查配置类是否扫描到 |
| 调用返回空字符串 | 提示词模板参数没填 | 检查 @UserMessage 参数绑定 |
| 流式无输出 | 忘了调 start() | 检查 TokenStream 调用链 |
| RAG 答非所问 | 检索片段不相关 | 调 minScore、换切块策略 |
| 中文乱码 | 编码未指定 | 统一 UTF-8 |
| 响应特别慢 | 上下文太长 | 减少 maxResults、精简提示词 |
6.4 几个我踩过的具体坑
第一个坑:@SystemMessage里写了变量占位符但没传参,启动不报错,调用时抛异常。解决办法是变量用{{var}}语法,方法参数用@V("var")标注。
第二个坑:AiService 接口方法返回TokenStream,但配置里只给了ChatLanguageModel没给StreamingChatLanguageModel,运行时报错说找不到流式模型。两个都要配。
第三个坑:RAG 索引时文档编码不是 UTF-8,向量化出来全是乱码,检索自然不准。加载文档时显式指定编码。
第四个坑:在@SystemMessage里写了很长的角色设定,结果每次调用都消耗大量 token。角色设定建议控制在 200 字以内,细节放到检索片段里。
7. 和 Spring Boot 生态整合的进阶玩法
7.1 用 Bean 注入控制不同场景用不同模型
一个项目里往往需要多个模型:便宜的模型做分类,贵的模型做生成。可以定义多个 Bean,用@Qualifier区分:
@Bean("cheapModel") public ChatLanguageModel cheapModel() { ... } @Bean("smartModel") public ChatLanguageModel smartModel() { ... } @Bean public ClassifierAssistant classifierAssistant(@Qualifier("cheapModel") ChatLanguageModel model) { return AiServices.create(ClassifierAssistant.class, model); }这样成本可控,该省的地方省,该花的地方花。
7.2 对话记忆的持久化
AiService 默认的对话记忆是内存的,重启就丢。生产环境要持久化,LangChain4j 提供ChatMemoryStore接口,可以自己实现存到 Redis 或数据库:
public class RedisChatMemoryStore implements ChatMemoryStore { @Override public List<ChatMessage> getMessages(Object memoryId) { ... } @Override public void updateMessages(Object memoryId, List<ChatMessage> messages) { ... } @Override public void deleteMessages(Object memoryId) { ... } }挂到 AiService 上:
AiServices.builder(Assistant.class) .chatLanguageModel(chatModel) .chatMemoryProvider(memoryId -> MessageWindowChatMemory.builder() .id(memoryId) .maxMessages(20) .chatMemoryStore(redisStore) .build()) .build();maxMessages控制保留多少轮对话,太多会撑爆上下文,太少会“失忆”。我一般设 10 到 20 轮。
7.3 可观测性:日志与指标
AI 调用是黑盒,出问题不好查。建议在 AiService 外面包一层切面,记录请求参数、响应内容、耗时、token 消耗。LangChain4j 本身有ChatModelListener接口,可以挂监听器:
OpenAiChatModel.builder() .apiKey(apiKey) .listeners(List.of(new LoggingChatModelListener())) .build();监听器里能拿到请求消息、响应消息、token 用量。把这些打到日志或者推到时序库,后面做成本分析和性能优化就有依据了。
8. 一些关于落地节奏的个人建议
我见过不少团队一上来就想做“全能 AI 助手”,结果三个月没上线。我的建议是分三步走。第一步,先做一个单轮问答接口,把链路跑通,验证模型效果和成本。第二步,加上流式输出和对话记忆,让体验接近可用。第三步,再引入 RAG,把内部知识接进来。每一步都能独立上线,每一步都有可衡量的产出。
成本这块要有预期。以 gpt-4o-mini 为例,输入 token 比输出便宜不少,但 RAG 场景下输入会膨胀好几倍,因为每次都要带上检索片段。控制成本的关键是精简检索片段数量和长度,别把整篇文档塞进去。
最后分享一个我常用的调试技巧:把发给模型的完整提示词打到日志里。很多时候效果不好不是模型的问题,是提示词拼错了、参数没填对、检索片段不相关。看到完整提示词,问题基本一目了然。LangChain4j 的监听器或者自己包一层都能做到,这个习惯帮我省了大量排查时间。