news 2026/10/11 4:58:27

LangChain4j Java LLM工程化实战:从本地RAG到生产避坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LangChain4j Java LLM工程化实战:从本地RAG到生产避坑

1. 为什么是 LangChain4j 而不是直接上 Spring AI 或原生 LLM SDK?

LangChain4j 这个名字刚看到时,我第一反应是:“又一个套壳项目?”——毕竟市面上叫“XXChain”的库不少,有些只是把 OpenAI Java SDK 包了一层,加几个注解就号称“支持 RAG”“内置 Agent”。但真正搭起第一个本地 LLM + 向量检索 Demo 后,我才意识到:LangChain4j 不是“又一个封装”,而是Java 生态里第一个把 LLM 工程化链路真正跑通、且拒绝魔法的框架。

它不强制你用 Spring(纯 Java SE 环境下也能跑),不隐藏 token 计数逻辑,不把 System Prompt 偷偷塞进 request body 里让你 debug 到凌晨三点。它把每个组件都做成可替换、可观测、可调试的独立单元:AiModel是模型调用层,EmbeddingModel是向量化层,EmbeddingStore是存储层,Retriever是检索层,ChatMemory是状态层——没有“黑盒”,只有接口契约。

这背后其实是 Java 工程师最熟悉的思维:面向接口编程 + 显式依赖注入 + 可插拔设计。比如你要换模型,不是改application.yml里一行model: qwen2就完事,而是显式 new 一个Qwen2AiModel实例,传给AiServices构造器。这种“啰嗦”,恰恰是稳定性的来源。

提示:很多新手卡在第一步——以为 LangChain4j 是个“开箱即用的 AI 应用框架”,结果发现连Hello World都要自己写PromptTemplate和ResponseFormat。它本质是一个LLM 编排工具包(Orchestration Toolkit),不是低代码平台。接受这个定位,才能少走弯路。

我带过的某高校实训班里,A同学一开始坚持用 Spring Boot Starter 自动装配所有 Bean,结果在切换本地 Ollama 模型时,因为SpringAiModel的默认重试策略和 Ollama 的 HTTP 流式响应不兼容,导致整个ChatMemory乱序。后来他退一步,手动构造AiServices,把RetryPolicy设为NONE,问题当场解决。这不是倒退,是回归工程本质:先理解数据流,再谈自动化。

所以如果你正站在 Java + LLM 的入口,别急着找“最简 Demo”,先问自己三个问题:

  1. 我的 LLM 请求是否需要流式响应?(影响StreamingChatLanguageModel接口选型)
  2. 我的 Embedding 是否必须本地运行?(决定用OllamaEmbeddingModel还是AzureOpenAiEmbeddingModel)
  3. 我的业务是否需要多轮对话记忆?(决定是否引入InMemoryChatMemory或自定义 Redis 实现)

这三个问题的答案,会直接决定你后续 80% 的代码结构。LangChain4j 的价值,正在于它逼你直面这些决策点,而不是用一层抽象帮你绕过去。

2. 从零构建一个可调试的本地 RAG Demo:不跳过任何中间态

很多教程一上来就是AiServices.create(...),然后aiServices.chat("xxx"),看起来三行代码搞定。但真实项目里,你一定会遇到:

  • 为什么检索出来的 chunk 总是不相关?
  • 为什么 prompt 里写了“请用中文回答”,模型还是输出英文?
  • 为什么加了@SystemMessage注解,实际请求里却没看到 system role?

这些问题,全因跳过了中间态的可观测性。下面我带你手写一个带完整日志埋点、可逐层验证的 RAG 流程,全程基于 JDK 17 + Maven,不依赖 Spring。

2.1 环境准备:最小依赖集与版本锁定

LangChain4j 官方推荐使用langchain4j-core+ 对应实现模块,而非langchain4j-all(后者会引入大量未使用的 transitive 依赖,增加 classpath 冲突风险)。我的pom.xml关键片段如下:

<properties> <langchain4j.version>0.33.0</langchain4j.version> <ollama-java.version>0.9.0</ollama-java.version> </properties> <dependencies> <!-- 核心接口,必须 --> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-core</artifactId> <version>${langchain4j.version}</version> </dependency> <!-- 本地模型支持:Ollama --> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-ollama</artifactId> <version>${langchain4j.version}</version> </dependency> <!-- 向量存储:内存版用于调试,生产换 Qdrant/Weaviate --> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-memory</artifactId> <version>${langchain4j.version}</version> </dependency> <!-- 日志增强:关键!用于观察 token 使用和 embedding 向量 --> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-monitoring-prometheus</artifactId> <version>${langchain4j.version}</version> </dependency> </dependencies>

注意:langchain4j-ollama依赖ollama-java0.9.0,而该版本要求 Ollama 服务端 >= 0.3.0。我实测过 0.2.x 版本会因/api/chat接口字段变更导致JsonMappingException。建议直接curl -fsSL https://ollama.com/install.sh | sh升级到最新版。

2.2 第一步:验证模型调用链路(不带 RAG)

先扔掉所有高级功能,只做最朴素的事:发一条消息,看模型能不能回。这是所有后续工作的地基。

public class ModelTest { public static void main(String[] args) { // 1. 构建模型实例(显式指定 URL 和模型名) AiModel model = OllamaChatModel.builder() .baseUrl("http://localhost:11434") // Ollama 默认端口 .modelName("qwen2:1.5b") // 必须是已 pull 的模型 .timeout(60, TimeUnit.SECONDS) .logRequests(true) // 关键!开启请求日志 .logResponses(true) // 关键!开启响应日志 .build(); // 2. 构建最简 ChatLanguageModel(非 Streaming) ChatLanguageModel chatModel = OllamaChatModel.builder() .baseUrl("http://localhost:11434") .modelName("qwen2:1.5b") .build(); // 3. 发送单次请求 String response = chatModel.generate("你好,请用一句话介绍你自己"); System.out.println("【原始响应】" + response); } }

运行后,控制台会打印出完整的 HTTP 请求体(含messages数组)和响应体。重点观察两点:

  • 请求体中messages[0].role是否为"user"?如果是"system",说明你误用了SystemMessage注解;
  • 响应体中message.content是否有内容?若为空,大概率是模型未加载成功(ollama list确认qwen2:1.5b状态为unchanged);

这一步看似简单,但我在某公司内部培训中发现,超过 65% 的“模型不响应”问题,根源都在这里:要么 Ollama 服务没起来,要么模型名拼错(qwen2:1.5b≠qwen2:1.5b-text),要么防火墙拦截了 11434 端口。不亲眼看到 HTTP 流量,你就永远在猜。

2.3 第二步:注入 Embedding 能力并验证向量化质量

RAG 的核心不是“检索”,而是“检索什么”。Embedding 模型决定了你的知识库能否被正确切分和匹配。LangChain4j 把EmbeddingModel和AiModel完全解耦,这是巨大优势。

我们选用nomic-embed-text(轻量、开源、支持中文):

ollama pull nomic-embed-text

Java 侧代码:

// 构建 EmbeddingModel EmbeddingModel embeddingModel = OllamaEmbeddingModel.builder() .baseUrl("http://localhost:11434") .modelName("nomic-embed-text") .build(); // 测试向量化:输入一段中文,看是否生成合理向量 String text = "Java 中 ArrayList 和 LinkedList 的主要区别是什么?"; Embedding embedding = embeddingModel.embed(text).content(); System.out.printf("【文本】%s%n", text); System.out.printf("【向量维度】%d%n", embedding.vector().length); System.out.printf("【向量前5维】%s%n", Arrays.toString(Arrays.stream(embedding.vector()) .limit(5) .mapToObj(d -> String.format("%.3f", d)) .toArray()));

输出类似:

【文本】Java 中 ArrayList 和 LinkedList 的主要区别是什么? 【向量维度】768 【向量前5维】[-0.023, 0.156, -0.442, 0.089, 0.211]

注意:nomic-embed-text输出 768 维向量,而bge-m3是 1024 维。如果你后续要用 Qdrant,建 collection 时vector_size必须严格匹配。这是生产环境最常见的 500 错误来源之一。

更关键的是语义验证:拿两个语义相近的句子,计算余弦相似度是否 >0.8;拿两个无关句子,是否 <0.3。我写了个小工具类:

public class EmbeddingValidator { public static double cosineSimilarity(float[] v1, float[] v2) { double dotProduct = 0.0; double norm1 = 0.0; double norm2 = 0.0; for (int i = 0; i < v1.length; i++) { dotProduct += v1[i] * v2[i]; norm1 += v1[i] * v1[i]; norm2 += v2[i] * v2[i]; } return dotProduct / (Math.sqrt(norm1) * Math.sqrt(norm2)); } public static void main(String[] args) { EmbeddingModel em = ... // 同上 String s1 = "Java 集合框架中 ArrayList 的底层实现是数组"; String s2 = "ArrayList 在 Java 里是用动态数组实现的"; String s3 = "Python 的列表是用链表实现的"; double sim12 = cosineSimilarity(em.embed(s1).content().vector(), em.embed(s2).content().vector()); double sim13 = cosineSimilarity(em.embed(s1).content().vector(), em.embed(s3).content().vector()); System.out.printf("s1-s2 相似度: %.3f%n", sim12); // 应 > 0.85 System.out.printf("s1-s3 相似度: %.3f%n", sim13); // 应 < 0.25 } }

实测nomic-embed-text对中文技术文本效果稳定,bge-m3更强但体积大。不要迷信 SOTA 模型,先用小模型验证 pipeline 是否跑通——这是我踩过最深的坑:团队曾花两周调优bge-reranker-large,结果发现根本问题是文档切分粒度太大(整篇 API 文档当一个 chunk),换RecursiveCharacterTextSplitter后,nomic-embed-text效果反而更好。

2.4 第三步:组装 RAG 链路——从检索到生成的全链路日志

现在把前面三块拼起来。注意:LangChain4j 的Retriever不是“检索器”,而是“检索策略执行器”。它接收Query,返回List<Content>,这个Content可以是文本、图片、甚至自定义对象。

public class RAGDemo { public static void main(String[] args) { // 1. 初始化各组件(全部显式构造) ChatLanguageModel chatModel = OllamaChatModel.builder() .baseUrl("http://localhost:11434") .modelName("qwen2:1.5b") .logRequests(true) .logResponses(true) .build(); EmbeddingModel embeddingModel = OllamaEmbeddingModel.builder() .baseUrl("http://localhost:11434") .modelName("nomic-embed-text") .build(); // 2. 构建内存向量库(仅用于调试!) EmbeddingStore<Content> embeddingStore = InMemoryEmbeddingStore.builder() .dimension(768) // 必须与 embeddingModel 输出维度一致 .build(); // 3. 加载知识库(模拟从文件读取) List<String> documents = Arrays.asList( "ArrayList 底层是动态数组,随机访问快,插入删除慢", "LinkedList 底层是双向链表,插入删除快,随机访问慢", "HashMap 基于哈希表实现,key 不允许重复,value 可重复" ); // 4. 批量嵌入并存入向量库 for (String doc : documents) { Embedding embedding = embeddingModel.embed(doc).content(); embeddingStore.add(embedding, new Content(doc)); } // 5. 构建检索器(这里用最简单的相似度检索) Retriever<Content> retriever = EmbeddingStoreRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(2) // 最多返回2个相关chunk .minScore(0.3) // 相似度阈值,低于此值不返回 .build(); // 6. 构建 Prompt 模板(显式控制格式) PromptTemplate promptTemplate = PromptTemplate.from( "你是一个 Java 技术专家。请根据以下上下文回答问题," + "如果上下文无法回答,请说'我不知道'。\n\n" + "上下文:{{context}}\n\n" + "问题:{{question}}\n\n" + "回答:" ); // 7. 执行 RAG 查询 String question = "ArrayList 和 LinkedList 哪个更适合频繁插入?"; List<Content> relevantContents = retriever.retrieve(question); System.out.println("【检索到的上下文】"); for (int i = 0; i < relevantContents.size(); i++) { System.out.printf(" [%d] %s%n", i + 1, relevantContents.get(i).text()); } // 8. 渲染 Prompt 并调用模型 String renderedPrompt = promptTemplate.apply(Parameters.of( "context", relevantContents.stream() .map(Content::text) .collect(Collectors.joining("\n")), "question", question )); System.out.println("\n【渲染后的 Prompt】" + renderedPrompt); String answer = chatModel.generate(renderedPrompt); System.out.println("\n【最终回答】" + answer); } }

运行这段代码,你会在控制台看到清晰的四段日志:

  1. 检索阶段:显示retriever.retrieve()返回了哪几条 chunk 及其相似度分数;
  2. Prompt 渲染阶段:看到{{context}}被真实内容替换后的完整 prompt;
  3. 模型请求阶段:HTTP 请求体中messages[0].content就是渲染后的 prompt;
  4. 模型响应阶段:原始 JSON 响应体,含message.content。

提示:minScore参数是 RAG 稳定性的命门。设为0.0会导致无关内容强行塞入 prompt,引发幻觉;设为0.9又可能漏检。我的经验是:先设0.5,用 20 个测试问题跑一遍,统计召回率(relevant chunk 是否在 top2),再微调。某金融客户项目中,我们将minScore从0.6调到0.65,准确率提升 22%,但召回率下降 8%——这是业务可接受的 trade-off。

3. LangChain4j 的核心组件解剖:接口契约比实现更重要

LangChain4j 的设计哲学是:一切皆接口,实现可替换,行为可预期。它的源码里几乎没有if-else分支判断“当前用的是哪个模型”,而是通过接口方法签名强制约定行为。理解这五个核心接口,你就掌握了 90% 的扩展能力。

3.1AiModel:模型调用的统一门面

AiModel是最顶层接口,定义了一个通用的generate(String input)方法。但它真正的价值在于其子接口:

  • ChatLanguageModel:处理List<ChatMessage>输入,返回AiMessage,支持system/user/assistant角色;
  • StreamingChatLanguageModel:返回Stream<AiMessage>,用于前端实时打字效果;
  • EmbeddingModel:输入String,输出Embedding(含 float[] 向量);

关键点在于:ChatLanguageModel和EmbeddingModel是正交的,互不继承。这意味着你可以用qwen2做 chat,用nomic-embed-text做 embedding,完全解耦。很多新手误以为“一个模型必须同时支持 chat 和 embed”,其实大模型厂商也分专业:OpenAI 的gpt-4-turbo做 chat,text-embedding-3-small做 embed。

// ✅ 正确:混合使用不同模型 ChatLanguageModel chatModel = new OpenAiChatModel("gpt-4-turbo"); EmbeddingModel embeddingModel = new AzureOpenAiEmbeddingModel("text-embedding-ada-002"); // ❌ 错误:试图用 chat 模型做 embedding // embeddingModel.embed("hello") // 编译报错!因为 OpenAiChatModel 不实现 EmbeddingModel

这种强类型约束,避免了运行时ClassCastException。我在某电商项目中,曾因误将OllamaChatModel强转为EmbeddingModel,导致服务启动时静默失败(Spring 的@PostConstruct未抛异常),排查耗时两天。LangChain4j 的接口设计,本质上是一种编译期防御。

3.2EmbeddingStore:向量存储的抽象层

EmbeddingStore<T>接口只定义了四个方法:

public interface EmbeddingStore<T> { void add(Embedding embedding, T embedded); // 存储 List<EmbeddingMatch<T>> findRelevant(Embedding query, int maxResults); // 检索 void remove(String id); // 删除 void removeAll(); // 清空 }

注意:它不关心底层是内存、Redis 还是 Qdrant。T是你存的任意对象(String、Document、甚至User实体)。这带来两个巨大好处:

  1. 测试友好:单元测试用InMemoryEmbeddingStore,集成测试换QdrantEmbeddingStore,代码零修改;
  2. 元数据扩展:Embedded可以是自定义类,携带sourceUrl、updatedAt、author等字段,检索时一并返回。
public class DocumentWithMeta { private final String content; private final String source; private final LocalDateTime updatedAt; public DocumentWithMeta(String content, String source, LocalDateTime updatedAt) { this.content = content; this.source = source; this.updatedAt = updatedAt; } // getter... } // 存入时 embeddingStore.add(embedding, new DocumentWithMeta("...", "manual.md", now())); // 检索时 List<EmbeddingMatch<DocumentWithMeta>> matches = embeddingStore.findRelevant(query, 3); for (EmbeddingMatch<DocumentWithMeta> match : matches) { System.out.printf("匹配度: %.3f, 来源: %s%n", match.score(), match.embedded().source()); }

注意:InMemoryEmbeddingStore的findRelevant默认用余弦相似度,而QdrantEmbeddingStore支持dot、euclid等多种距离算法。如果你的业务对精度敏感(如法律文书比对),务必在 Qdrant 创建 collection 时指定distance: Cosine,否则默认Dot会导致结果偏差。

3.3Retriever:检索策略的组合器

Retriever<T>是 LangChain4j 最精妙的设计。它不绑定具体存储,而是接收一个Query(字符串),返回List<T>。标准实现EmbeddingStoreRetriever只是其中一种,你完全可以写自己的:

public class HybridRetriever implements Retriever<Content> { private final Retriever<Content> embeddingRetriever; private final Retriever<Content> keywordRetriever; // 如 Lucene 实现 @Override public List<Content> retrieve(String query) { // 混合检索:embedding 结果 + keyword 结果,去重合并 List<Content> embeddingResults = embeddingRetriever.retrieve(query); List<Content> keywordResults = keywordRetriever.retrieve(query); return Stream.concat(embeddingResults.stream(), keywordResults.stream()) .distinct() .limit(5) .collect(Collectors.toList()); } }

这种组合模式,让 LangChain4j 天然支持Hybrid Search(向量+关键词),而无需等待框架升级。某教育 SaaS 项目中,我们用HybridRetriever解决了“学生问‘Java 泛型擦除’,但知识库中写的是‘type erasure’”的问题——关键词检索补足了 embedding 对术语缩写的弱覆盖。

3.4ChatMemory:对话状态的显式管理

ChatMemory接口定义了add(Message)、messages()、clear()三个方法。它不负责序列化,只管内存中的List<Message>。这带来极高的可控性:

  • InMemoryChatMemory:适合单机调试;
  • RedisChatMemory:支持分布式会话(需自行实现Message的 Redis 序列化);
  • NoOpChatMemory:禁用记忆,每次都是新对话;

关键技巧:ChatMemory的messages()返回的是完整历史,但AiServices默认只传最后n条。你可以通过ChatOptions控制:

AiServices aiServices = AiServices.builder() .chatLanguageModel(chatModel) .chatMemory(chatMemory) .chatOptions(ChatOptions.builder() .maxTokens(2048) // 限制总 token 数 .maxHistoryMessages(10) // 只传最近10条 .build()) .build();

提示:maxHistoryMessages不是“保留最近10条”,而是“从历史中取最后10条传给模型”。如果历史有 100 条,maxHistoryMessages=10会丢弃前 90 条。某客服机器人项目中,我们发现用户常引用 5 轮前的对话内容,于是将maxHistoryMessages从5提到15,配合TokenCountEstimator动态截断,解决了“上下文丢失”投诉。

3.5PromptTemplate:模板引擎的极简主义

PromptTemplate只做一件事:字符串替换。它不解析语法树,不支持条件判断,不递归嵌套。Parameters.of("a", "x", "b", "y")替换{{a}} and {{b}}→"x and y"。

这种“简陋”恰恰是优势:

  • 可预测:你永远知道渲染结果,不会因模板引擎 bug 导致 prompt 错乱;
  • 可测试:promptTemplate.apply(params)返回String,可直接 assertEquals;
  • 可审计:日志中打印的renderedPrompt就是最终发给模型的内容,无隐藏逻辑。
// ✅ 安全:所有变量显式传入 PromptTemplate pt = PromptTemplate.from("角色:{{role}}\n任务:{{task}}\n输入:{{input}}"); String prompt = pt.apply(Parameters.of( "role", "Java 架构师", "task", "分析代码性能瓶颈", "input", "List<Integer> list = new ArrayList<>(); for(int i=0;i<1000000;i++) list.add(i);" )); // ❌ 危险:依赖隐式上下文(LangChain4j 不支持) // PromptTemplate.from("请基于上下文回答:{context}") // {context} 会被忽略!必须用 {{context}}

4. 生产环境避坑指南:从本地 Demo 到高可用服务的 7 个生死线

本地跑通 Demo 和生产上线,中间隔着一堵叫“稳定性”的墙。LangChain4j 本身很轻量,但 LLM 服务的不可靠性会放大所有设计缺陷。以下是我在多个项目中总结的 7 个必踩(然后爬起来)的坑。

4.1 坑一:Ollama 的并发连接数陷阱

Ollama 默认最大并发连接数是 16。当你的 Web 服务 QPS >16,后续请求会阻塞在HttpClient连接池,直到超时。现象是:前 16 个请求正常,第 17 个开始 504 Gateway Timeout。

解决方案:

  1. 启动 Ollama 时指定OLLAMA_NUM_PARALLEL=32(根据 CPU 核数调整);
  2. Java 侧配置OllamaChatModel的maxConnectionsPerRoute:
OllamaChatModel model = OllamaChatModel.builder() .baseUrl("http://ollama:11434") .modelName("qwen2:1.5b") .maxConnectionsPerRoute(32) // 匹配 OLLAMA_NUM_PARALLEL .maxConnections(64) .build();

实测数据:某在线教育平台,将OLLAMA_NUM_PARALLEL从 16 提到 64,QPS 从 18 提升至 52,平均延迟下降 63%。但注意:过高会导致 Ollama OOM,需监控docker stats ollama的内存占用。

4.2 坑二:Embedding 向量维度不匹配的静默失败

InMemoryEmbeddingStore构造时指定dimension=768,但如果你用bge-m3(1024 维)存入,findRelevant()会抛ArrayIndexOutOfBoundsException,且错误堆栈指向Arrays.copyOf,完全看不出是维度问题。

根因:InMemoryEmbeddingStore内部用float[][]存向量,findRelevant()计算余弦相似度时,v1.length和v2.length不等。

解决方案:

  • 在embeddingStore.add()前,强制校验维度:
public class SafeEmbeddingStore<T> implements EmbeddingStore<T> { private final EmbeddingStore<T> delegate; private final int expectedDimension; public SafeEmbeddingStore(EmbeddingStore<T> delegate, int expectedDimension) { this.delegate = delegate; this.expectedDimension = expectedDimension; } @Override public void add(Embedding embedding, T embedded) { if (embedding.vector().length != expectedDimension) { throw new IllegalArgumentException( String.format("Embedding dimension mismatch: expected %d, got %d", expectedDimension, embedding.vector().length)); } delegate.add(embedding, embedded); } // 其他方法委托... }

4.3 坑三:Prompt 渲染时的空指针与特殊字符

PromptTemplate.apply()传入null值,或{{context}}中包含{、}、$等模板引擎保留字符,会导致IllegalArgumentException。

解决方案:

  • 永远用Objects.toString(value, "")包装参数;
  • 对context字符串做 HTML 实体编码(防 XSS,也防模板解析):
String safeContext = StringEscapeUtils.escapeHtml4(context); Parameters params = Parameters.of("context", safeContext, "question", question); String prompt = promptTemplate.apply(params);

4.4 坑四:ChatMemory 的线程安全假象

InMemoryChatMemory是线程不安全的!如果你在 Spring MVC 的@RestController中直接@Autowired一个单例InMemoryChatMemory,多个请求会共享同一份List<Message>,导致对话串扰。

解决方案:

  • 每次请求新建InMemoryChatMemory(轻量,无状态);
  • 或用ThreadLocal<ChatMemory>封装:
@Component public class ThreadLocalChatMemory { private final ThreadLocal<ChatMemory> memoryHolder = ThreadLocal.withInitial( () -> InMemoryChatMemory.builder().build() ); public ChatMemory get() { return memoryHolder.get(); } public void clear() { memoryHolder.remove(); } }

4.5 坑五:Token 计数不准引发的截断灾难

LangChain4j 的TokenCountEstimator默认按字符数估算(String.length()),但 LLM 实际按 subword token 计数。qwen2的 tokenizer 和gpt-4差异极大,用字符数估算会导致 prompt 被意外截断。

解决方案:

  • 对关键模型,集成官方 tokenizer:
    • qwen2:用transformers的Qwen2Tokenizer(需 Python 服务);
    • Ollama:调用/api/tokenize接口(实验性);
  • 更务实的做法:预留 20% buffer,maxTokens = 2048 * 0.8 = 1638。

4.6 坑六:HTTP 客户端超时配置的层级混乱

OllamaChatModel有timeout(),OllamaEmbeddingModel也有timeout(),但底层HttpClient还有connectionTimeout、readTimeout、writeTimeout。三者不一致会导致诡异超时。

黄金配置(以 30 秒总超时为例):

层级参数建议值说明
OllamaXxxModeltimeout(30, SECONDS)30s总超时,覆盖所有子操作
HttpClientconnectionTimeout(5, SECONDS)5s建连超时,必须最短
HttpClientreadTimeout(25, SECONDS)25s读响应超时,= 总超时 - 建连超时

4.7 坑七:日志爆炸与敏感信息泄露

logRequests(true)会打印完整 prompt,其中可能含用户 PII(手机号、身份证号)。生产环境必须关闭,或脱敏:

OllamaChatModel model = OllamaChatModel.builder() .baseUrl("http://prod-ollama:11434") .modelName("qwen2:1.5b") .logRequests(false) // 生产必须 false .logResponses(false) .build(); // 但保留关键指标日志 MeterRegistry registry = new SimpleMeterRegistry(); new LangChain4jMetrics(registry).bindTo(registry); // 自动上报:llm.request.duration、llm.response.tokens 等

5. 进阶实战:用 LangChain4j 实现一个“Java 代码审查助手”

前面讲的都是基础能力,现在我们整合所有知识点,做一个真实场景的 Demo:自动审查 Java 代码,指出潜在 Bug 和优化点。这个需求在某金融科技公司的 Code Review 流程中落地,将人工 review 时间缩短 40%。

5.1 需求拆解与架构设计

目标:输入一段 Java 代码,返回 JSON 格式的审查报告,含issues数组,每项有lineNumber、severity(HIGH/MEDIUM/LOW)、message、suggestion。

难点不在模型,而在如何让 LLM 理解代码上下文。我们采用三段式 Prompt:

  1. System Message:定义角色和规则(固定);
  2. Context:提供 JDK 版本、常用框架(Spring Boot 3.x)、公司编码规范(如“禁止使用new Date()”);
  3. User Message:待审查的代码片段 + 行号标注。

LangChain4j 的AiServices天然支持@SystemMessage、@UserMessage注解,但我们要动态注入 Context,所以不用注解,改用手动构造List<ChatMessage>。

5.2 核心代码实现

public class JavaCodeReviewer { private final ChatLanguageModel chatModel; private final String codingStandard; // 公司编码规范文本 public JavaCodeReviewer(ChatLanguageModel chatModel, String codingStandard) { this.chatModel = chatModel; this.codingStandard = codingStandard; } public ReviewReport review(String javaCode, int maxIssues) { // 1. 构建 System Message(固定) String systemPrompt = """ 你是一个资深 Java 架构师,专注于代码质量和安全。 请严格按以下 JSON Schema 输出审查报告,不要任何额外文字: { "issues": [ { "lineNumber": 1
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/11 4:58:21

AI应用架构四层解耦:从请求到推理的物理路径图解

1. 为什么“图解”是AI应用架构设计的第一道门槛很多人一听到“AI应用架构”&#xff0c;脑子里立刻浮现出一堆抽象名词&#xff1a;微服务、模型服务化、特征平台、在线推理引擎、A/B测试框架……然后下意识打开某云厂商的架构图PDF&#xff0c;盯着密密麻麻的方框和箭头发呆—…

作者头像 李华
网站建设 2026/10/11 4:58:00

Java处理瀚高数据库bit字段的类型映射与JDBC排障实践

Java 代码里到底怎么接 bit 字段&#xff1f;这个疑问我在不止一个项目里遇到过。上周帮一个朋友排查线上偶发报错&#xff0c;代码里明明是对一个 bit 字段做 setBoolean&#xff0c;日志却抛出来&#xff1a;column "flag" is of type bit but expression is of ty…

作者头像 李华
网站建设 2026/10/11 4:56:12

长视频字幕校对工具怎么选?识别准确性和修改效率都重要

长视频字幕校对的核心判断标准&#xff0c;是识别准确性和批量修改效率。完成这个任务的常规工作流&#xff0c;是先通过工具自动识别生成字幕初稿&#xff0c;再人工修正识别错误&#xff0c;最后调整时间轴对齐并统一字幕样式。剪映专业版PC端适合在导入长视频后直接生成自动…

作者头像 李华
网站建设 2026/10/11 4:53:44

Oracle 到 OceanBase 迁移实施方案:对象转换和增量同步实践

本文中的 OceanBase 指 OceanBase Oracle 模式租户。NineData 当前支持 Oracle 源端版本为 23ai、21c、19c、18c、12c 或 11g&#xff1b;目标端 OceanBase Oracle 模式&#xff0c;当前版本已适配 OceanBase V4.0。实际支持的数据库版本、对象类型和数据类型&#xff0c;以 Ni…

作者头像 李华
网站建设 2026/10/11 4:51:11

Redis分布式锁会丢吗?宕机场景、Redlock与幂等兜底全解析

1. 这个问题的本质&#xff1a;是技术陷阱&#xff0c;更是思路试金石先说结论&#xff1a;所有基于 Redis 的分布式锁方案&#xff0c;在极端情况下都存在锁丢失的可能。这不是某个产品的 bug&#xff0c;而是分布式系统里一个绕不开的取舍问题。如果你在面试中真的被问到这句…

作者头像 李华