☰
Java工程师的大模型应用开发实战:基于LangChain4j框架
2026/10/26 5:36:00 网站建设 项目流程

做 Java 后端也有七八年了,大模型刚火那阵我一度挺焦虑——朋友圈里铺天盖地全是 Python 的 LangChain 教程,好像不会 Python 就没法做 AI 应用。直到我发现 LangChain4j,心里那块石头才算落地。LangChain4j 是专门给 Java 开发者用的大模型编排框架,把 Python 生态里做 RAG、Agent、Tool Calling 这些能力,用纯正的 Java 方式重新实现了一遍。我花了两周时间把它用进手头的 Spring Boot 项目,做了智能客服和文档问答,整个过程比想象中顺利。这篇教程就是给跟我一样的 Java 工程师准备的,从环境搭建到实战案例,全程无 Python,放心食用。

1. Java 生态里的 LLM 开发,为什么是 LangChain4j

1.1 Java 做 AI 应用层的难处和出路

很多 Java 工程师面对 AI 应用开发的第一反应是:要不学一下 Python?这种想法可以理解,因为最早的 LangChain、LlamaIndex 都是 Python 生态,文档和社区也都在 Python 那边。但冷静想一下,大部分公司的核心业务系统是 Java 写的,Spring Boot 服务、数据库连接池、消息队列、监控体系都是 Java 的。为了接一个大模型接口,把整个业务链路推倒用 Python 重写,这明显不现实。更现实的路线是:让 Java 服务作为 AI 能力的大脑,通过 HTTP 调用大模型 API,然后把结果落回业务系统。

但这种"HTTP 调用大模型 API"的裸写法,在需求变复杂以后会非常痛苦。举个真实例子,我当时接智能客服,最初就是 HttpClient 拼 Prompt、解析 JSON,接口一多,Prompt 散落在代码各个角落,加一个工具调用要改一大堆字符串拼接逻辑。LangChain4j 解决的正是这个问题:它把与大模型交互的公共逻辑——消息组装、上下文管理、函数调用、记忆、向量检索——都抽象成了 Java 接口和类,让开发者像写普通业务代码一样写 AI 逻辑。而且它完全开源,背后有 Red Hat 的技术人员参与,不是那种跑路风险极高的小项目。

1.2 LangChain4j 到底解决什么问题

LangChain4j 的核心定位是 LLM 编排框架,跟你用 String.format 拼 Prompt 最本质的区别在于:它把"对话轮次""多轮记忆""工具调用""RAG 检索"这些 AI 应用的高频组件,全做成了标准化的 Java API。

举个例子,很多初学者以为给大模型传一个 System Prompt 就能完成多轮对话,实际请求时你会发现要自己维护历史消息数组,每次请求把全部聊天记录塞进去,还得控制长度,不然随便聊几句就到 Token 上限了。LangChain4j 里一个 ChatMemory 组件就把这件事解决了,滑动窗口、Token 超限自动裁剪都是现成的。

再比如函数调用(Function Calling)。这可以说是今年大模型应用最核心的能力:让模型在回答前先决定调用你的业务方法,获取实时数据。在 LangChain4j 里,你只需要在方法上标一个 @Tool 注解,框架会自动把方法名、参数名、参数含义发送给模型,模型返回的调用指令会由框架解析并反射执行。这一点特别香,因为 Java 的类型系统比 Python 更适合做这种结构化约束——方法的入参、返回值都是强类型的,模型返回的 JSON 能被严格校验,不会像 Python 那样经常因为少个字段在运行时报错。

1.3 这套教程适合谁看

我把话说直白一点:如果你是Java 后端工程师,Spring Boot 用得熟,但对大模型 API 接触不多,这篇教程是为你写的。你不需要懂机器学习、不需要知道 Transformer 原理,只需要会 Maven、懂基本的 Spring 依赖注入,就能上手。

如果你已经会用 Python 的 LangChain,想迁移到 Java 体系,这篇文章也能帮你快速建立对应关系:LangChain 里的 LLM、Memory、Tool、Retriever 分别对应 LangChain4j 的 ChatLanguageModel、ChatMemory、@Tool、EmbeddingStore。很多概念是共通的,但也有 Java 独有的设计,比如 AiServices 这种基于接口动态代理的使用方式,比 Python 好玩多了。

  1. 现在不少 Java 面试题里也开始出现"大模型应用开发"相关的问题,LangChain4j 可以算是 Java 技术栈对这个问题的标准答案。
  2. 如果你正处于选型阶段,纠结要不要在 Spring Cloud 微服务体系里引入 AI 能力,这篇教程里的实战部分可以直接拿去做技术预研。

2. 环境准备:半小时跑通第一句对话

2.1 JDK 与 Maven 依赖,一步都不能少

先把环境说清楚。我用的 JDK 21,但 LangChain4j 目前要求最低 JDK 17,这是因为框架源码里大量使用了 record、sealed class 这些现代 Java 特性,在 JDK 8 上完全跑不起来。

Maven 项目里引入核心依赖:

<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>0.35.0</version> </dependency>

注意,LangChain4j 的版本迭代非常快,我写这篇文章时稳定版是 0.35.0,你看到时可能已经到 0.36 或者 1.x 了。API 大体保持稳定,但个别模块名和包名会有调整,建议以 maven 仓库的最新版本为主。这个框架是模块化设计的,核心包只提供抽象接口,具体的模型接入要靠对应的模块包。比如接入 OpenAI 接口:

<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-open-ai</artifactId> <version>0.35.0</version> </dependency>

接入阿里云的 DashScope 通义千问:

<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-dashscope</artifactId> <version>0.35.0</version> </dependency>

用到本地开源模型,比如 Ollama:

<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-ollama</artifactId> <version>0.35.0</version> </dependency>

这里有个选型技巧:很多大模型服务商都提供 OpenAI 兼容接口,也就是 URL 路径和请求格式跟 OpenAI 基本一致。所以即便你想用的不是 OpenAI 官方模型,也可以只引入 langchain4j-open-ai 模块,然后把 baseUrl 指到对应服务商的地址就行。这样能减少不同厂商 SDK 带来的依赖冲突。

2.2 三种模型接入方式对比,按场景选就行

我刚开始比较懵的地方是:LangChain4j 的模块包里为什么名字这么杂?OpenAi、DashScope、Ollama、Bedrock、VertexAI……其实背后的逻辑很简单:每个模块就是做一件事,把特定模型服务商的 HTTP 接口适配成 LangChain4j 统一的 ChatLanguageModel 接口。

我用三个典型方案做个对比:

接入方式典型场景建议模块注意事项
OpenAI 兼容接口使用支持 OpenAI 协议的服务商,包括国内厂商langchain4j-open-ai注意配置 baseUrl,不同服务商的地址和模型名不同
DashScope / 智谱等国内云厂商国内服务器部署、合规要求明确langchain4j-dashscope / langchain4j-zhipu需要申请 API Key,部分模型支持免费的限时额度
Ollama 本地模型内网环境、数据不出域、离线开发调试langchain4j-ollama需要先安装 Ollama 并拉取模型,性能依赖本机配置
企业私有化模型平台公司内部统一的大模型网关一般用 OpenAI 兼容适配先确认内部平台是否兼容 OpenAI 协议

从我个人经验看,开发调试阶段用 Ollama 最省心,因为不花钱、没网络延迟,模型返回质量虽然比商业模型差一些,但用来验证代码逻辑足够了。等代码测试通过以后,再切到正式环境的商业模型,只需要改配置,业务代码不用动。

如果你在国内服务器上做生产部署,我的建议是优先考虑国内的模型服务,或者本地私有化部署。这样网络链路短、稳定、延迟低,也更符合数据合规要求。这一条我踩过坑:早期图省事直连境外模型接口,结果网络时好时坏,用户反馈忽快忽慢,后来切到国内服务,体感立刻稳了。

2.3 最小可运行示例,验证整套链路

依赖配好之后,写一个最简单的调用。我用 DashScope 举例,因为国内环境下比较方便:

import dev.langchain4j.model.chat.ChatLanguageModel; import dev.langchain4j.model.dashscope.DashScopeChatModel; public class QuickStart { public static void main(String[] args) { ChatLanguageModel model = DashScopeChatModel.builder() .apiKey(System.getenv("DASHSCOPE_API_KEY")) .modelName("qwen-plus") .temperature(0.7) .build(); String answer = model.generate("用一句话介绍你自己"); System.out.println(answer); } }

如果你用的是本地 Ollama,代码几乎一模一样:

import dev.langchain4j.model.chat.ChatLanguageModel; import dev.langchain4j.model.ollama.OllamaChatModel; public class QuickStart { public static void main(String[] args) { ChatLanguageModel model = OllamaChatModel.builder() .baseUrl("http://localhost:11434") .modelName("qwen2.5:7b") .build(); String answer = model.generate("你好,请介绍一下你自己"); System.out.println(answer); } }

看到没?接口完全统一。这就是 LangChain4j 最基础的抽象能力:ChatLanguageModel。不管底层是哪个模型厂商,你写的业务代码面对的都是这一个接口。第一次跑通这个示例,你就已经走完了 90% 的路——后面的复杂能力都是在这个接口之上做叠加。

跑通以后有两点小提醒:

  • API Key 不要硬编码在代码里,用环境变量或者配置中心,避免泄露到 Git 仓库。
  • modelName 要根据你实际购买或部署的模型名填写,别想当然用默认值。DashScope 的模型名跟 OpenAI 不一样,需要到厂商文档里确认。

3. 核心组件拆解:从 ChatLanguageModel 到 AiServices

3.1 ChatLanguageModel 是一切功能的基石

前面示例里的 ChatLanguageModel,是整个 LangChain4j 最重要、最底层的抽象。它对应的概念就是"一个大模型客户端",任何跟模型交互的操作最终都会落到它身上。

这个接口里最常用的方法是:

  • String generate(String userMessage):单轮对话,最简单。
  • Response<ChatMessage> generate(List<ChatMessage> messages):传入多轮消息,返回响应对象。
  • String generate(String systemMessage, String userMessage):带系统提示词的对话。

而当你需要流式输出(像 ChatGPT 那样一个字一个字往外吐)时,要用另一个接口 StreamingChatLanguageModel:

StreamingChatLanguageModel model = OpenAiStreamingChatModel.builder() .apiKey(apiKey) .modelName("gpt-4o-mini") .build(); model.generate("给我讲一个程序员的笑话", new StreamingChatResponseHandler() { @Override public void onPartialResponse(String partialResponse) { // 每次回调返回一段增量文本 System.out.print(partialResponse); } @Override public void onCompleteResponse(ChatResponse completeResponse) { System.out.println("\n--- 完成 ---"); } @Override public void onError(Throwable error) { error.printStackTrace(); } });

生产环境做客服机器人几乎都用流式,因为用户等不了完整响应——一个 300 字的回答如果 10 秒后才一次性弹出来,体验极差;流式输出能做到几百毫秒开始显示第一字。注意,流式接口和普通接口是分开设计的,因为流式模式需要回调,没法用同步返回值。

ChatLanguageModel 还有两个很实用的扩展点。第一个是参数校验:构建模型时可以设置 maxRetries(失败重试次数)、timeout(超时时间)、temperature(温度)。第二个是它内部已经处理好了与厂商 API 的协议差异,比如 OpenAI 的消息格式、DashScope 的响应结构,你在代码层面感知不到区别。

3.2 AiServices,把 LLM 调用变成 Java 接口

如果说 ChatLanguageModel 是地基,那 AiServices 就是 LangChain4j 最让人上头的高级功能。它解决的核心痛点是:你不会想在自己的业务代码里手动拼 Prompt、手动解析大模型返回的 JSON。

AiServices 的用法很像 Feign 或者 MyBatis Mapper——先定义一个接口,然后框架自动给你生成实现代理:

interface Assistant { String chat(String userMessage); }

没错,就一行接口方法。然后用 AiServices 构建实例:

Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .build(); String answer = assistant.chat("你好,我想查一下订单状态");

你调用 assistant.chat() 的时候,框架内部干了这些事情:把你传进来的 userMessage 包装成标准消息结构、调用模型 API、拿到模型返回、解析出文本。如果以后你要加系统 prompt、记忆、工具,也在这个 builder 上逐项配置就行,业务代码里还是只调 assistant.chat()。

我最早学这个的时候,深受启发:它其实是一层非常漂亮的防腐层。业务代码依赖的是你自己定义的 Assistant 接口,而不是某个具体的模型客户端、某段 Prompt 模板。以后想从 DashScope 换成其他模型,或者想调整 Prompt,对业务代码几乎零侵入。

AiServices 还能返回结构化结果。比如你希望 AI 输出一个 Java 对象而不是字符串:

record OrderInfo(String orderId, String status, BigDecimal amount) {} interface OrderAssistant { OrderInfo extractOrderInfo(String text); } OrderAssistant assistant = AiServices.builder(OrderAssistant.class) .chatLanguageModel(model) .build(); OrderInfo info = assistant.extractOrderInfo("我的订单 20250101,金额 99 元,已经发货了");

框架会告诉模型"你要返回符合这个类型定义的 JSON",然后自动把 JSON 反序列化成 record。这一点对 Java 工程师特别友好,强类型、IDE 能提示、编译期能发现问题,比 Python 那种把一切都交给 dict 的方式严谨得多。

3.3 @Tool 注解与 Function Calling 的原理

前面提到过 @Tool,这里展开说原理。大模型本身不具备查询数据库、调用外部 API 的能力,它只能"决定"要不要调用某个函数。Function Calling 的完整流程是:

  1. 你通过 LangChain4j 把 Java 方法的名称、描述、参数列表告诉模型。
  2. 模型根据用户问题判断需要调用哪个方法,返回一段结构化的调用指令(包含方法名和参数值)。
  3. LangChain4j 收到指令后,用反射机制调用你真实写好的 Java 方法,拿到返回值。
  4. 框架把方法返回值作为上下文送给模型,让它生成最终回答。

这个过程模型全程不接触你的系统和数据,既是优点也是风险点。优点是安全边界清晰,缺点是你要承担参数校验和结果校验的责任。

直接上代码。比如你想让 AI 助手帮你查订单状态:

import dev.langchain4j.agent.tool.Tool; import dev.langchain4j.agent.tool.P; class OrderTool { private final OrderMapper orderMapper; public OrderTool(OrderMapper orderMapper) { this.orderMapper = orderMapper; } @Tool("根据订单号查询订单状态,订单号是纯数字字符串") public String queryOrderStatus(@P("订单号") String orderId) { if (orderId == null || orderId.isBlank()) { return "订单号不能为空"; } // 调用 MyBatis-Plus 之类的 ORM 查数据库 Order order = orderMapper.selectById(orderId); if (order == null) { return "订单不存在"; } return "订单 " + orderId + " 当前状态是:" + order.getStatus(); } }

@Tool 注解里的文本描述非常重要,它直接决定模型能不能"猜到"该用这个工具。我建议:工具名称用业务名,参数名用中文含义,注解描述里写明使用场景和参数规则。

把你自定义的工具注册进 AiServices:

OrderAssistant assistant = AiServices.builder(OrderAssistant.class) .chatLanguageModel(model) .tools(new OrderTool(orderMapper)) .build(); String answer = assistant.chat("帮我查一下订单 20250101 到哪里了");

遇到这类查询,LangChain4j 就会自动触发 Function Calling,把真实订单状态返回给模型,模型再用自然语言组织回答:"您的订单 20250101 已发货,预计明天送达。" 用户在界面上感觉这个 AI 真的"懂"你的业务系统。

这里要重点强调一个容易踩的坑:工具数量不要塞太多。我在一个项目里注册了十几个工具,结果模型经常选错工具或同时调用多个无关工具。后来精简到 5 个核心工具,准确率明显上升。工具多了以后,模型做工具选择本身就成了一个难题。必要的做法是给工具描述加更严格的限定词,或者对工具做分组。

顺便提一句 MCP,如果你对 2024 年后半年火起来的 Model Context Protocol 有了解,会发现 LangChain4j 的 @Tool 机制跟 MCP 的工具声明理念是一脉相承的。LangChain4j 也支持把 Java 类中标注 @Tool 的方法暴露为 MCP 服务,或者反过来作为 MCP 客户端调用外部工具。如果你的公司已经有一堆现成的 REST 接口,可以考虑通过这种适配方式把它们接入 AI 生态,技术上是可行的,只是需要配置几层转换逻辑。

3.4 ChatMemory 多轮对话记忆机制

用过 ChatGPT 的人都知道连续对话的意义。但直接调模型 API 时,模型本身是无状态的——它不记得你上一轮说了什么。每次请求都是独立的,你需要把所有历史对话一起传过去。

LangChain4j 里的 ChatMemory 就是管这件事的。最简单实用的实现是 MessageWindowChatMemory:

ChatMemory chatMemory = MessageWindowChatMemory.builder() .maxMessages(20) .build(); Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .chatMemory(chatMemory) .build(); // 第一次对话 String answer1 = assistant.chat("我叫张三"); // 第二次对话,模型能记住你的名字 String answer2 = assistant.chat("我叫什么名字?");

maxMessages 控制保留最近多少条消息,超过之后最老的消息自动移除。这个参数很关键:消息条数太多会超 Token 限额,太少又会"失忆"。我的经验值是普通客服场景 20 条左右比较合适。

但它有个明显的局限:ChatMemory 默认是基于会话内的单机内存,服务重启就丢了。如果你的 AI 助手需要跨设备、跨会话的长期记忆,比如用户昨天咨询过的问题今天继续问,那就得把 ChatMemory 持久化。LangChain4j 提供了 PersistentChatMemory 相关接口,你可以把消息历史存到 Redis 或数据库表里,每次会话开始再加载。但要注意序列化格式要稳定,我见过有人直接用 JDK 序列化存 Redis,后来又切 JSON,结果历史数据全作废了。

另外还要提醒一点:不要把所有应用共用一个 ChatMemory。我见过一个线上事故,AI 助手的上下文把客户 A 的信息串到了客户 B 身上。多租户场景下,ChatMemory 必须按用户维度隔离,比如用 ConcurrentHashMap 以 userId 为 key 管理各自的记忆实例。

4. 实战:Spring Boot 里给 AI 助手打通 RAG 和业务工具

4.1 一个最小可用的工程结构

理论讲再多不如一个案例实在。下面是我实际项目中用过的结构,可以完整帮助你落地。项目背景是给电商后台做一个"订单答疑助手",需要回答两类问题:一类是根据订单查询状态和退款进度,另一类是解读公司最新的售后政策文档。

工程目录:

src/main/java/com/example/aiassist/ ├── AiAssistApplication.java ├── config/ │ └── LlmConfig.java ├── controller/ │ └── ChatController.java ├── assistant/ │ ├── Assistant.java │ └── AssistantImpl.java ├── tool/ │ ├── OrderTool.java │ └── PolicyTool.java ├── rag/ │ ├── PolicyDocumentLoader.java │ ├── PolicyRetriever.java │ └── VectorStoreConfig.java └── common/ └── ApiResponse.java

核心是 config 里的模型配置,用 @Configuration 把 ChatLanguageModel 和 Assistant 注册成 Spring Bean:

@Configuration public class LlmConfig { @Value("${llm.api-key}") private String apiKey; @Value("${llm.base-url}") private String baseUrl; @Value("${llm.model-name}") private String modelName; @Bean public ChatLanguageModel chatLanguageModel() { return OpenAiChatModel.builder() .apiKey(apiKey) .baseUrl(baseUrl) .modelName(modelName) .logRequests(true) .logResponses(true) .build(); } @Bean public Assistant assistant(ChatLanguageModel model, OrderTool orderTool, PolicyTool policyTool) { return AiServices.builder(Assistant.class) .chatLanguageModel(model) .chatMemory(MessageWindowChatMemory.withMaxMessages(20)) .tools(orderTool, policyTool) .build(); } }

注意我把 logRequests 和 logResponses 打开了,这个在开发调试阶段非常好用,你能直接看到每次请求发给模型的完整内容、模型返回的原始 JSON。生产环境记得关掉,因为日志量太大并且可能泄露 Prompt 内容和用户对话。

4.2 接入知识库:RAG 文档问答

客服场景里最常见的需求是"解读文档"。比如售后政策更新了,你不能把这几十页 PDF 硬塞到 Prompt 里,那样既超 Token 又慢。正确做法是 RAG:先把文档切块、向量化、存入向量存储,用户提问时先检索出最相关片段,再把这些片段作为上下文发给模型。

LangChain4j 里完整实现一轮 RAG 需要四样东西:

  • Document:原始文档。
  • DocumentSplitter:把长文切成小块。
  • EmbeddingModel:把文本转成向量。
  • EmbeddingStore:存放向量并能做相似度查询。

先用 EmbeddingStoreIngestor 把文档灌进去。我以加载售后政策为例子:

EmbeddingModel embeddingModel = OpenAiEmbeddingModel.builder() .apiKey(apiKey) .baseUrl(baseUrl) .modelName("text-embedding-3-small") .build(); EmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>(); EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder() .documentSplitter(DocumentSplitters.recursive(500, 80)) .embeddingModel(embeddingModel) .embeddingStore(embeddingStore) .build(); // 读取 PDF 或 word 文档 Document document = Document.from(""" 第一章 退换货规则... """); ingestor.ingest(document);

DocumentSplitters.recursive(500, 80) 的参数需要解释一下:500 是切分后每块的字符数,80 是相邻块之间的重叠度。为什么要重叠?因为如果一句话被从中间切断,检索到的片段会语义残缺,影响回答质量。重叠部分避免关键信息恰好落在切缝上。切块大小没有标准答案,需要根据你的文档类型试:太小的块语义不完整,太大的块浪费 Token 且检索噪声高。

用户提问时,用 EmbeddingStore 做相似度检索:

// 在 Assistant 的 chat 方法里,先做检索再交给模型 List<TextSegment> relevant = embeddingStore.findRelevant(userMessage, 3); StringBuilder context = new StringBuilder(); for (TextSegment segment : relevant) { context.append(segment.text()).append("\n\n"); }

然后在 System Prompt 里带上检索到的上下文,并告诉模型:"请仅根据以下资料回答,资料中没有的信息就明确说不知道。"

生产环境千万别用 InMemoryEmbeddingStore,服务一重启知识就全没了。选型上我比较推荐 PostgreSQL 的 pgvector 插件,因为大多数 Java 项目本来就用 PostgreSQL,加个插件就能把向量存进同一套库里,省去额外维护一个向量数据库的麻烦。LangChain4j 有专门模块:

<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-pgvector</artifactId> </dependency>

用 PGVector 时,需要自己建一张存储向量数据的表,LangChain4j 提供了对应的 SQL 初始化脚本,照抄就行。建表时注意把主键设为 UUID,并建立 HNSW 索引,不然数据量大了查询会非常慢。

4.3 工具调用:把 AI 接入真实业务服务

RAG 负责"查文档",Tool 负责"查系统"。在订单答疑助手这个案例里,我把订单查询封装成工具,前面已经给过代码。再补充一个退款进度的例子,让模型能自己判断该调哪个工具:

@Tool("根据退款单号查询退款进度,退款单号以R开头") public String queryRefundProgress(@P("退款单号") String refundId) { RefundRecord record = refundMapper.selectByRefundId(refundId); if (record == null) { return "未找到退款记录"; } return "退款单 " + refundId + " 当前阶段:" + record.getStage() + ",预计到账时间:" + record.getExpectedTime(); }

当用户问"我那个 R20250101 的退款到账没",模型会先判断这个问题跟订单查询无关,转而调用 queryRefundProgress,拿到结果以后组织成自然语言回复。

这样一个工具类写起来很简单,但有几个要点需要注意。第一,工具方法的返回值建议全部用 String,不要返回复杂对象。因为模型的上下文是纯文本的,复杂对象序列化后噪声太多。第二,工具方法要有明确的边界和异常兜底。模型调用工具时传的参数可能让你意外:比如把"刚下单的订单号"说成"昨天退款单的订单号",所以方法内部一定要做空值判断和异常捕获,保证任何输入都返回一句可读信息,而不是抛异常。

我遇到过的最恶心的问题:用户说"帮我取消订单",模型立刻调用了 cancelOrder 工具,结果那个工具还在开发中,接口返回异常。好在工具方法把异常 catch 住,返回了"该功能正在升级中"的兜底话术,用户没有看到系统报错。如果异常直接暴露给模型,模型会一本正经地编一个更离谱的回答出来。

4.4 生产化要注意:流式响应、限流和链路追踪

上面案例跑通以后,你会发现 Controller 还是同步返回整段文本,这在生产环境基本不可用。至少要做三件事。

第一,用流式响应。把 ChatController 的接口改成返回 SseEmitter 或者 WebSocket 消息,模型逐字吐字,用户体验好得多。LangChain4j 的 StreamingChatLanguageModel 配合 Spring 的 SseEmitter 实现起来并不复杂,网上有现成例子。

第二,加限流和成本控制。每调用一次模型都是在花钱,我见过开发环境有人写了个死循环把团队一个月的额度调没了的。用 Redis 做用户维度的限流,比如每个用户每分钟最多 20 次对话请求,同时给单次请求设置合理的超时时间,防止外部服务异常拖死你的业务线程。

第三,日志和链路追踪。把每次请求的模型名、Token 消耗、响应耗时、工具调用记录都结构化落库,这事不只是为了排查问题,更是为了后续做 Prompt 调优。你会发现很多问题不看日志根本定位不了。

REST 接口通过工具接入模型之后,链路就是:用户 -> Controller -> AiServices -> 模型 -> 工具 -> 业务服务。整条链路任何一个环节出问题,排查起来都比普通接口复杂,所以链路追踪 ID 必填。

5. 避坑指南:我踩过的这些坑

5.1 常见报错与排查速查表

这段时间没少踩坑,我把最典型的整理成了速查表:

问题现象可能原因解决办法
NoSuchMethodError/ClassNotFoundException传递依赖版本冲突,Jackson 或 OkHttp 版本不一致统一 BOM 或显式声明版本,用 Maven dependency tree 分析
请求超时、连接被拒绝baseUrl 配错,或目标模型服务不可达逐一核对 baseUrl、端口、模型名,先用 curl 验证接口
返回结果一直空字符串模型配置了过高的 temperature 导致随机性增大,或 Prompt 让模型模式化降低 temperature 到 0.2 左右,检查日志里模型返回的 content
HTTP 429触发了服务商限流增加 maxRetries,代码里做退避重试,同时排查并发标识配置
Tool 被错误调用工具描述不清晰、数量过多优化 @Tool 描述,减少工具数量,必要时加输入校验
中文乱码响应流编码处理不当确保项目 UTF-8,HTTP 客户端设置字符编码,数据库连接串加 characterEncoding
注入模型后 Spring Bean 为 nullAiServices 代理生成的对象没注册为 Spring Bean确保 Assistant 是接口并且由 Spring 扫描或 @Bean 手动注册
向量检索结果质量差文档切块粒度不合适、Embedding 模型与主模型不一致调整切块大小和重叠度,统一使用质量较高的 embedding 模型

第九个对新手特别隐蔽:AiServices 会给你的接口生成一个动态代理对象,但如果你把它当成普通 Bean 注入到 Controller,Spring 会先尝试按类型匹配。如果 Assistant 是接口,Spring 默认的 JDK 动态代理是认的;如果 Assistant 是类且方法没有接口,就要注意 CGLIB 代理的规则,否则会出现运行时 ClassCastException。

5.2 调参笔记:temperature、maxTokens 和系统提示词

很多新手拿到 ChatLanguageModel,build 的时候看见什么参数都默认值,其实这几个参数对输出质量影响巨大。

temperature 控制随机性,0 到 2 之间。客服回复、订单查询这些事实型任务我一般调到 0.2 以下,让模型尽可能稳定、照着检索结果回答。做文案生成、头脑风暴这种创意型任务可以调到 0.8 到 1.0。但注意,同样的问题在同一个模型上跑两次,temperature 不同结果差异可能很大,测试时必须固定。

maxTokens 控制生成上限。不设的话,有些模型默认 2048 或者更少,一个长回答可能被硬生生截断在中间。我习惯把文档解读类任务的 maxTokens 放宽到 4096,查询类任务则保持在 1024 以内,省得模型一本正经地给你写小作文,又慢又费钱。

系统提示词是决定任务质量的三成以上因素。LangChain4j 里可以在 AiServices 上通过 SystemMessageProvider 配置。

AiServices<Assistant> services = AiServices.builder(Assistant.class) .chatLanguageModel(model) .systemMessageProvider(memoryId -> """ 你是一个电商平台的售后客服助手。 回答必须简洁,禁止编造信息。 当且仅当工具或检索资料中提供的信息充分时,才回答用户; 信息不足时,回复"我需要查询一下,请稍等"。 """) .build();

我强烈建议把 System Prompt 放到配置中心而不是硬编码在代码里。因为线上 Prompt 要经常调优,每次改 Prompt 都要发版,太痛苦了。对 Prompt 的管理要像管理数据表结构一样严肃,要有版本记录。

5.3 让 AI 应用更可控的几个思考

最后说点比代码更重要的东西。我做了几个 AI 功能以后,最大的感受是:大模型是非确定性的,你没法保证它每一次输出都符合预期。所以生产级 AI 应用要想可控,必须在架构上做约束。

第一个经验是给模型"划地盘"。能用规则解决的问题,绝不让模型决策。比如前面的订单查询,实际上用户只要说了订单号,你完全可以在 Controller 里用正则提取出来直接去数据库查,这不叫 AI 应用,但比 AI 更稳更省。AI 只负责处理那些规则搞不定的自然语言理解和组织回答的部分。把规则和 AI 混在一起做,是最容易失控的写法。

第二个经验是输出格式尽量用例程约束。AiServices 即使返回 String,你也可以让它"只输出 JSON",然后再用 Jackson 解析,这样至少数据能被校验。更好的方式是直接让方法返回 Java record,框架负责序列化,出错能被尽早发现。

第三个经验是灰度发布。AI 功能上线别一把梭,先对内部账号开放,观察一轮真实的用户对话日志,看看哪些问题模型答错了、哪些工具调用失败了,再调整 Prompt 和工具。我见过某个系统直接对全部用户开放 AI 客服,结果模型被用户诱导说出了一些不该说的话,运营同事收拾了一周烂摊子。

我个人在实际操作中的体会是:LangChain4j 的学习曲线不算陡,只要你 Sping Boot 玩得熟,真正难的是理解大模型应用的工作套路——记忆、工具、检索这些概念想通了,剩下的全是 API 调用。我的路径是先跑通最小示例,然后做 AiServices + 工具调用,最后再补 RAG 和流式。别一上来就想搞一个大而全的 Agent,先把单点能力做扎实,再往复杂方向迭代。这套思路,写进你们公司的技术方案里一样好用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询