☰
LangChain4j 实战:从 @Tool 到 RAG 与 Agent 流水线
2026/10/7 5:07:46 网站建设 项目流程

1. 为什么我最终选择了 LangChain4j 这条路线

1.1 从一个真实需求说起

去年底我接手了一个企业内部知识助手的项目,需求说起来很简单:把散落在 Confluence、飞书文档和本地 PDF 里的技术资料整合起来,让同事能用自然语言提问,系统给出带出处的答案。一开始我想得很轻松,不就是 RAG 那一套吗,向量化、检索、拼 prompt、调模型,网上教程一抓一大把。

真动手才发现坑比想象中多。第一版我用 Python 生态搭的,LangChain 加 FAISS 加 OpenAI,跑 demo 很顺,但一上生产就露馅了:公司要求私有化部署,模型得用本地推理服务,Java 团队维护不了 Python 那套依赖,而且业务系统本身就是 Spring Boot 写的,跨语言调用链路长得离谱。那段时间我几乎把主流的 Java AI 框架都翻了一遍,Spring AI 当时还比较早期,功能覆盖不全,最后落到 LangChain4j 上。

选它的核心理由其实很朴素:它把 LLM 应用开发里那些重复的脏活累活都封装好了,同时保留了 Java 开发者熟悉的编程范式。你不需要理解 Python 的异步生态,不需要折腾虚拟环境,一个 Maven 依赖引进来,@Tool注解一贴,方法就能被大模型调用。这种"一个库打全套"的体验,是我在别的框架里没找到的。

这篇文章我想把从最基础的@Tool到完整的 Agent 流水线这条路径讲透,包括 RAG 怎么接、多路召回怎么做、Agent 编排怎么设计、踩过哪些坑。适合已经写过一点 LangChain4j demo、想往生产级别推进的 Java 开发者,也适合正在选型、想搞清楚这套东西到底能干什么的技术负责人。

1.2 LangChain4j 到底解决了什么问题

很多人第一次接触 LangChain4j 会把它当成"Java 版的 LangChain",这个理解对了一半。它确实借鉴了 LangChain 的抽象思路,但设计哲学不太一样。LangChain 追求的是极致的灵活性和组件丰富度,代价是抽象层数多、版本迭代快、API 经常变。LangChain4j 走的是另一条路:用尽量少的抽象覆盖尽量多的场景,API 稳定,贴近 Java 工程习惯。

具体来说,它主要解决四类问题。

第一类是模型接入的统一。OpenAI、Anthropic、通义千问、Ollama 本地模型、各种兼容 OpenAI 协议的推理服务,LangChain4j 都提供了对应的ChatModel实现。你换模型的时候,业务代码基本不用动,改个配置就行。这对需要做模型对比或者多模型兜底的场景特别有用。

第二类是提示词与结构化输出的管理。手写 prompt 拼接字符串是噩梦,尤其是需要模型返回 JSON 的时候。LangChain4j 的AiServices允许你用接口加注解的方式定义 AI 服务,方法返回值可以直接是 POJO,框架帮你处理序列化和反序列化。

第三类是工具调用与 Agent 编排。这就是@Tool注解的用武之地。你写一个普通的 Java 方法,贴上注解,描述清楚它是干什么的,模型在需要的时候就会主动调用它。多个工具加上多轮推理,就构成了 Agent 的基础。

第四类是RAG 全链路支持。从文档加载、切分、向量化、存储到检索、重排、注入上下文,LangChain4j 提供了一整套组件,而且每个环节都可以替换成你自己实现的版本。

这四类能力叠起来,基本覆盖了一个 LLM 应用从原型到生产的全部需求。我后面会按这个顺序,一层一层往下拆。

2. @Tool 注解:让大模型真正"动手"的第一步

2.1 @Tool 的本质是什么

先说清楚一个概念,大模型本身是不会执行任何代码的,它只会输出文本。所谓"工具调用",本质上是模型输出了一段结构化的文本,告诉你的程序"我想调用某个函数,参数是这些",然后你的程序去执行,再把结果塞回给模型。这个来回的过程,业界叫 Function Calling 或者 Tool Calling。

@Tool注解做的事情,就是把你写的 Java 方法"翻译"成模型能理解的功能描述,注册到请求里。模型看到这些描述,就知道自己有哪些能力可用。我举个最直观的例子:

public class CalculatorTool { @Tool("计算两个数字的和") public double add(double a, double b) { return a + b; } @Tool("计算两个数字的乘积") public double multiply(double a, double b) { return a * b; } }

当用户问"3 加 5 等于多少,再乘以 2 呢",模型会先调用add(3, 5)得到 8,再调用multiply(8, 2)得到 16,最后用自然语言回答"结果是 16"。整个过程模型自己规划,你只需要把工具准备好。

这里有个关键点很多人忽略:@Tool里的描述文字质量,直接决定了模型会不会正确调用你的工具。描述写得太笼统,模型可能该调的时候不调,或者调错工具。我见过有人写@Tool("查询数据"),结果模型面对"查一下用户信息"和"查一下订单信息"两个工具时完全懵了。描述要具体到"这个工具能做什么、输入是什么、输出是什么"。

2.2 工具描述怎么写才靠谱

我总结了一套写工具描述的实践,实测下来模型调用准确率提升很明显。

第一,动词开头,说清楚动作。不要写"用户信息",要写"根据用户 ID 查询用户的姓名、邮箱和注册时间"。

第二,参数含义写进描述里。LangChain4j 会把参数名和类型传给模型,但模型不一定能猜准语义。比如参数叫id,你得说明这是用户 ID 还是订单 ID。

第三,明确边界和限制。如果某个工具只能查最近 30 天的数据,一定要写进去,否则模型会拿它去查历史数据然后返回空结果,用户一脸懵。

第四,返回值格式说明。如果工具返回的是 JSON 字符串,告诉模型这个 JSON 的结构,它解析起来会稳很多。

@Tool("根据用户ID查询用户详细信息。参数userId为必填,是系统内的用户唯一标识。" + "返回JSON格式,包含name、email、registerTime三个字段。" + "注意:只能查询已激活状态的用户,未激活用户会返回空。") public String queryUser(@P("用户唯一标识") String userId) { // 实际查询逻辑 }

@P注解用来给参数加描述,这个细节很多人不知道,但对复杂参数特别有用。

2.3 工具调用的执行流程拆解

理解执行流程对排查问题至关重要。一次完整的工具调用大概经历这几个阶段:

  1. 用户输入问题,你的程序把问题、系统提示词、所有工具的描述打包发给模型。
  2. 模型判断是否需要调用工具。如果需要,它输出一个结构化的调用请求,包含工具名和参数。
  3. LangChain4j 解析这个请求,找到对应的 Java 方法,反射调用,拿到返回值。
  4. 返回值被包装成消息追加到对话历史里,再次发给模型。
  5. 模型基于工具返回的结果,决定是继续调用别的工具,还是给出最终回答。

这个循环会一直持续,直到模型不再请求工具调用,或者达到你设置的最大迭代次数。最大迭代次数这个参数一定要设,我踩过坑,有个工具因为参数问题一直返回错误信息,模型就一直重试,最后 token 烧了一大截。一般设 5 到 10 次比较合理。

提示:工具方法内部一定要做好异常处理。如果方法抛异常,LangChain4j 会把异常信息作为工具结果返回给模型,模型可能会基于错误信息做出奇怪的决策。建议在工具内部 catch 异常,返回结构化的错误提示。

2.4 工具设计的一个常见误区

新手最容易犯的错误是把工具设计得太粗。比如设计一个executeSql(String sql)工具,让模型自己写 SQL。听起来很强大,实际上非常危险,模型可能写出删库的语句,也可能写出性能极差的查询。

正确的做法是把工具设计成业务语义明确、参数受控的粒度。不要给模型executeSql,而是给queryOrderByUserId(String userId)、queryOrderByDateRange(String start, String end)这样的工具。模型负责选择用哪个工具、填什么参数,具体的 SQL 由你在 Java 代码里写好。这样既安全,又稳定。

这个原则我称之为"工具是能力,不是权限"。你给模型的是完成特定任务的能力,而不是操作底层系统的权限。

3. RAG 接入:从文档到可检索知识库

3.1 RAG 的核心链路与常见瓶颈

RAG 这个词现在被说烂了,但真正跑通一条高质量链路的人不多。它的核心逻辑其实就四步:文档加载、切分、向量化、检索。听起来简单,每一步都有坑。

先说文档加载。企业里的文档格式五花八门,PDF、Word、Markdown、HTML、Excel,还有各种在线文档导出的格式。LangChain4j 提供了DocumentLoader的各种实现,PDF 用ApachePdfBoxDocumentParser,Word 用ApachePoiDocumentParser,Markdown 和纯文本直接读。但实际用起来,PDF 的解析质量参差不齐,扫描件、双栏排版、表格嵌套的 PDF 经常解析出乱码。我的经验是,PDF 解析后一定要做一次人工抽检,尤其是关键文档,别指望全自动。

再说切分。这是 RAG 质量的分水岭。切得太碎,单块信息不完整,检索出来答非所问;切得太大,一块里混了好几个主题,向量表示被稀释,检索精度下降。LangChain4j 默认的DocumentSplitters.recursive用字符数加重叠窗口的方式切,对结构规整的文档够用,但对技术文档效果一般。

我现在的做法是按文档结构切分。Markdown 按标题层级切,代码文档按函数或类切,FAQ 按问答对切。LangChain4j 允许你自定义DocumentSplitter,实现起来不难,但效果比通用切分好一大截。

3.2 向量化与向量库选型

向量化就是把文本块转成高维向量,让语义相近的文本在向量空间里距离更近。LangChain4j 支持多种 embedding 模型,OpenAI 的text-embedding-3-small、通义千问的 embedding、还有本地部署的 BGE 系列。

选 embedding 模型有几个考量点。维度影响存储和检索速度,1536 维是常见选择,768 维更省资源但精度略低。语言支持很关键,如果你的文档是中英混合,一定要选多语言模型,纯英文模型对中文的语义捕捉很差。成本方面,本地模型没有调用费用但需要 GPU,云服务按 token 计费但省心。

向量库的选择更多。LangChain4j 支持内存向量库InMemoryEmbeddingStore、Redis、Milvus、PgVector、Elasticsearch 等。我的建议是:

场景推荐方案理由
原型验证、小数据量InMemoryEmbeddingStore零配置,重启数据丢失可接受
已有 PostgreSQLPgVector复用现有数据库,运维成本低
数据量百万级以上Milvus专为向量设计,检索性能强
已有 ES 集群Elasticsearch支持混合检索,文本加向量一起用

我自己的项目用的是 PgVector,因为公司本来就有 PostgreSQL,加个扩展就行,不用引入新的中间件。数据量在几十万块这个级别,检索延迟完全能接受。

3.3 检索环节的优化:多路召回

单一向量检索有个天然缺陷:它对关键词精确匹配不敏感。用户问"ERR_5001 这个错误码怎么解决",向量检索可能返回一堆泛泛的错误处理文档,因为"ERR_5001"这个具体字符串在向量空间里没有特殊权重。

解决办法是多路召回,也就是同时用多种检索策略,然后合并结果。常见的组合是:

  • 向量检索:负责语义相似,处理"意思相近但用词不同"的情况。
  • 关键词检索:负责精确匹配,处理错误码、专有名词、型号这类内容。
  • 元数据过滤:按文档类型、时间、部门等维度先筛一遍。

LangChain4j 本身对多路召回的支持需要你自己组装,但组件都是现成的。你可以建两个EmbeddingStore,一个存向量,一个用 ES 做全文索引,检索时并行查,然后用Reciprocal Rank Fusion或者简单的加权分数合并。

// 伪代码示意多路召回合并 List<Content> vectorResults = vectorRetriever.retrieve(query); List<Content> keywordResults = keywordRetriever.retrieve(query); List<Content> merged = rrfMerge(vectorResults, keywordResults, 60);

RRF 的公式很简单,每个文档的得分是1 / (k + rank)的累加,k 一般取 60。这个算法不需要调权重,对异构检索结果的合并特别友好。我实测下来,加了关键词召回之后,包含具体错误码、API 名称的查询准确率提升非常明显。

3.4 重排:RAG 质量的最后一道关

召回阶段追求的是"不漏",所以会返回比较多的候选,比如 top 20。但塞给模型的上下文不能太多,token 有限,而且无关内容会干扰模型判断。这时候就需要重排,用一个更精细的模型对候选重新打分,选出最相关的 top 3 到 5 个。

重排模型和 embedding 模型不一样,它是 cross-encoder 结构,把 query 和文档拼在一起输入,直接输出相关性分数。精度比向量相似度高很多,但速度慢,所以只用在召回后的精排阶段。LangChain4j 可以接入 Cohere Rerank、BGE Reranker 等。

我的经验是,重排对 RAG 效果的提升是立竿见影的,尤其是候选集里有很多"看起来相关实际不相关"的文档时。如果预算允许,这一步强烈建议加上。

注意:重排模型的选择要和你的文档语言匹配。中文文档用中文重排模型,中英混合用多语言模型。用错模型,重排效果可能还不如不排。

4. Agent 流水线:把工具、RAG 和记忆串起来

4.1 Agent 和普通 Chain 的区别

很多人分不清 Agent 和普通的调用链。简单说,Chain 是固定流程,Agent 是动态决策。Chain 里你先检索再生成,步骤写死;Agent 里模型自己决定要不要检索、检索几次、要不要调工具、调哪个工具。

这个区别带来的直接后果是:Agent 更灵活,能处理开放式任务,但更难控制,更容易跑偏。所以生产环境里,我一般不会做"全自主 Agent",而是做有限自主的 Agent,也就是把可用的工具和决策空间限制在一个明确的范围内。

LangChain4j 的AiServices配合@Tool就能构建 Agent。你定义一个接口,把工具类注册进去,模型就会在对话中自主调用。如果要更复杂的编排,比如多 Agent 协作、条件分支,就需要自己写编排逻辑,或者用 LangChain4j 的Agent相关 API。

4.2 Agent 记忆管理怎么做

Agent 要能多轮对话,就得有记忆。LangChain4j 提供了ChatMemory抽象,最常用的是MessageWindowChatMemory,保留最近 N 条消息。

但纯窗口记忆有个问题:对话长了之后,早期的重要信息会被挤掉。比如用户第一轮说了自己的订单号,聊了十轮之后 Agent 就忘了。解决办法有几种:

  • 增大窗口:简单粗暴,但 token 消耗大。
  • 摘要记忆:把早期对话压缩成摘要,保留关键信息。
  • 外部记忆:把重要信息存到向量库,需要时检索回来。

我现在的项目用的是组合方案:窗口保留最近 10 轮,同时把每轮对话的关键实体抽取出来存到结构化存储里,Agent 需要时通过工具查询。这样既控制了 token,又不会丢关键信息。

ChatMemory memory = MessageWindowChatMemory.withMaxMessages(10);

这行代码看着简单,但withMaxMessages这个数字要根据你的模型上下文窗口和平均消息长度来算。上下文 8k 的模型,消息平均 200 token,那 10 条就是 2000 token,加上系统提示词和工具描述,留给生成的空间就不多了。一定要算清楚,别让记忆把上下文撑爆。

4.3 一个完整的 Agent 流水线设计

我把项目里的 Agent 流水线拆成四层,分享出来供参考。

第一层是意图识别。用户输入进来,先用一个轻量模型或者规则判断意图类型:是知识问答、是数据查询、还是操作指令。不同意图走不同分支,避免所有请求都走最重的链路。

第二层是工具路由。根据意图,把相关的工具子集注册给 Agent。不要把所有工具一股脑塞给模型,工具太多模型会选错。按场景分组,每次只给当前场景需要的工具。

第三层是执行与反思。Agent 执行工具调用,拿到结果后做一次质量检查。如果结果为空或者明显异常,触发重试或者换策略。这一步是很多 demo 没有的,但生产环境必须有。

第四层是结果组装。把 Agent 的最终回答、引用的文档出处、调用的工具记录一起返回给前端。出处很重要,用户看到答案能追溯到原文,信任度完全不一样。

这四层里,第二层的工具路由是最容易被低估的。我一开始把所有工具都注册进去,结果模型经常在知识问答场景里调用数据查询工具,浪费 token 还答非所问。后来按场景分组,准确率立刻上来了。

4.4 Agent 安全与边界控制

Agent 能调工具就意味着能产生副作用,安全必须重视。我总结了几个必须做的控制。

工具分级。把工具分成只读和写入两类。只读工具随便调,写入工具(比如创建工单、发送通知)必须加确认机制,或者限制在特定条件下才能调用。

参数校验。不要信任模型传过来的参数,在工具方法内部做严格校验。模型可能传空值、传超长字符串、传格式不对的 ID,这些都要在 Java 层拦住。

调用频率限制。给每个工具加调用次数上限,防止模型陷入循环。LangChain4j 的最大迭代次数是一层保护,但工具级别的限制更细。

审计日志。每次工具调用都记日志,包括入参、出参、耗时、调用者。出问题的时候这是唯一的排查依据。

提示:Agent 的自主性越强,需要的护栏就越多。宁可前期多花时间做限制,也不要等出了事故再补。

5. 实操中踩过的坑与排查技巧

5.1 工具调用不触发的排查思路

模型该调工具却不调,这是最高频的问题。排查顺序我一般是这样的。

先看工具描述。描述是不是太模糊,模型没理解这个工具能干什么。把描述改具体,加上使用场景的说明。

再看系统提示词。有些系统提示词会引导模型"直接回答",这会压制工具调用。检查提示词里有没有类似"尽量不调用外部工具"的表述。

然后看模型能力。不是所有模型都擅长工具调用,小参数模型经常调不准。换个工具调用能力强的模型试试,如果换了就好,那就是模型问题。

最后看参数格式。如果工具参数是复杂对象,模型可能不知道怎么填。把参数拆成简单类型,或者用@P注解把每个参数说清楚。

5.2 RAG 检索结果不相关的优化

检索出来的内容和问题不沾边,原因可能出在切分、embedding 或者检索策略上。

先检查切分。把检索到的块打印出来看,如果一块里混了好几个不相关的主题,那就是切分粒度问题,需要调整切分策略。

再检查 embedding。用几个典型 query 手动算一下和文档块的相似度,如果明显相关的文档相似度却很低,可能是 embedding 模型不适合你的领域,考虑换模型或者做微调。

最后检查检索策略。单一向量检索对关键词不敏感,加上关键词召回和重排,通常能解决大部分问题。

5.3 常见问题速查表

问题现象可能原因排查方向
工具不被调用描述模糊、提示词压制、模型能力不足改描述、查提示词、换模型
工具调用参数错误参数类型复杂、描述不清简化参数、加 @P 注解
检索结果不相关切分不当、embedding 不匹配调切分、换 embedding、加重排
回答超出上下文记忆窗口过大、检索块太多缩小窗口、限制检索数量
响应慢模型推理慢、检索链路长换小模型、并行检索、加缓存
token 消耗高工具描述冗长、记忆未压缩精简描述、加摘要记忆

5.4 性能优化的几个实用技巧

缓存是性价比最高的优化。embedding 结果可以缓存,相同文本不用重复算;检索结果可以缓存,热点 query 直接返回;模型回答也可以缓存,相同问题不用重复生成。LangChain4j 本身不提供缓存,但你可以用 Caffeine 或者 Redis 在调用层包一层。

并行化能显著降低延迟。多路召回可以并行执行,多个工具的调用如果互不依赖也可以并行。Java 的CompletableFuture用起来很方便。

流式输出改善体验。LangChain4j 支持StreamingChatLanguageModel,模型生成一个字就返回一个字,用户感知的等待时间大幅缩短。虽然总耗时没变,但体验完全不一样。

批处理降低 embedding 成本。文档向量化的时候,不要一条一条调 API,攒成一批一起发,既快又省。

6. 关于这套技术栈的一些个人体会

从最开始写@Tool的 demo,到后来搭出完整的 Agent 流水线,中间大概花了三个月。回头看,最大的感受是:LangChain4j 降低了入门门槛,但生产级别的质量靠的是工程细节,不是框架本身。

框架帮你把模型调用、工具注册、RAG 链路这些基础设施搭好了,但工具描述怎么写、文档怎么切、检索怎么优化、Agent 边界怎么控制,这些都得靠自己对业务的理解和反复调试。我见过太多人 demo 跑通就以为完事了,结果一上真实数据就崩。

如果让我给正在走这条路的人一个建议,那就是先把 RAG 做扎实,再上 Agent。RAG 是地基,Agent 是上层建筑。地基不稳,Agent 再花哨也是空中楼阁。我自己的项目就是先花了一个多月把检索质量调到满意,才开始做 Agent 编排的。

另外,别迷信全自主 Agent。生产环境里,可控性比自主性重要得多。把 Agent 的能力限制在明确的范围内,加上足够的护栏,比追求"什么都能干"要靠谱。用户要的是准确可靠的答案,不是炫技。

最后分享一个小技巧:调试 Agent 的时候,把每次模型请求和响应都完整打日志,包括工具调用的中间过程。这些日志在排查问题时价值极高,比任何文档都管用。我现在的日志里能看到完整的对话轨迹、每次工具调用的入参出参、每轮检索的候选和得分,出问题的时候一眼就能定位到哪一环。

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

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

立即咨询