☰
Java后端接入大模型:LangChain4j工具调用与Agent流水线实战
2026/10/8 3:29:49 网站建设 项目流程

做Java后端的小伙伴,这两年应该没少被各种AI应用刷屏。但如果你想把大模型真正接进自己的业务系统,让模型帮你查订单、算运费、写总结,而不是停留在聊天机器人层面,就必须面对工具调用和Agent编排这两道坎。LangChain4j正好是Java生态里少有的、能把这两件事都收拾利索的库:你用@Tool注解把普通方法暴露给模型,再用AiServices把方法、模型、记忆串成一条Agent流水线。这篇我按自己的实战路径,从@Tool写到Agent流水线,重点讲设计思路、代码细节和踩坑点,目标是让你读完能直接上手。

不管你是被老板要求一个星期做个AI助手,还是纯粹想给自己的Spring Boot项目加点智能化,这套思路都适用。我会用一个电商订单场景贯穿全文,从定义库存工具、运费工具,到编排一条“查订单状态→算运费→生成答复”的流水线,每段都给出能复制的代码和配置。文章不会讲太多空泛概念,更多的是我在真实项目里留下的笔记。

1. 为什么是 LangChain4j:“一个库打全套”的底气

1.1 业务系统接入LLM时的真实痛点

直接调OpenAI或者通义的SDK,拿到的只是一个“会说话”的接口。它知道你问什么,但不能直接操作你的数据库,也不能调用你们公司内部系统的API。于是你不得不自己封装函数调用、自己维护多轮记忆、自己处理工具结果和模型输出之间的转换。这些工作散落在代码各处,最后就是逻辑混乱、难以维护。

我自己第一次做类似功能的时候,就是手写function call逻辑:维护参数JSON Schema、解析模型返回的tool调用、分派给对应方法、再拼回历史消息。一开始还挺得意,等到要加第二个工具、第三个工具,代码膨胀得很快,而且每次模型返回的参数稍微不规范,整个对话就崩了。LangChain4j把模型接入、工具调用、消息记忆、Agent编排都收敛成一套标准API,所以你可以把精力集中在业务逻辑上,而不是重复造轮子。

1.2 核心模块分工

LangChain4j不是一个“大而全”的玩具框架,它的模块划分非常清晰,每个组件都能单独用,也能拼在一起用。下面这张表是我平时最常用到的模块:

模块作用对应业务场景
ChatLanguageModel统一对话、流式响应对接 OpenAI、Ollama、通义等模型
@Tool + ToolProvider把业务方法暴露给模型调用查库存、算运费、写工单
AiServices生成动态 Agent多工具自动编排
ChatMemory保存会话上下文多轮对话与状态保持
EmbeddingStore向量存储RAG 知识库检索
OutputParser解析模型输出抽取结构化字段

模块之间没有强制依赖。你可以在一个只做意图识别的服务里只依赖模型接口,也可以在另一个服务里同时挂上工具和记忆。这种“活字印刷式”的设计,让“一个库打全套”不再是一个口号,而是真的可以按需取用。

1.3 和 Spring AI、手写 Prompt 相比

Spring AI 也提供了工具调用能力,但它在 Agent 编排、记忆管理和工具链的深度绑定上,目前没有 LangChain4j 来得直接。手写 Prompt 加 function call 的做法,短期能跑通,长期会被三个问题缠住:一是参数序列化谁来保证?二是多工具返回结果冲突时怎么决策?三是多轮对话里历史消息要怎么裁剪、怎么持久化?

LangChain4j 把这三点都作为一等公民支持,尤其是 @Tool 和 AiServices 的组合,几乎就是为“让 Java 程序员少掉头发”设计的。选型时我就是看中这一点,没必要自己造轮子。

2. 从 @Tool 开始:如何定义让模型“看得懂”的工具

2.1 @Tool 注解的底层行为

先理解一件事:模型看不到你的代码,只能看到你塞给它的工具描述。LangChain4j 会把标注了 @Tool 的方法,在运行时转换成大模型需要的 function schema,再随用户消息一起发给模型。模型根据这些描述决定“要不要调用、传什么参数”。

所以,工具方法能不能被正确触发,取决于两件事:第一,方法上 @Tool 的 description 写得是否清楚;第二,方法参数的 @P 注释是否让模型知道每个字段是什么意思。很多新手只写了方法名和参数名,结果模型一头雾水。

来看一个最基础的写法:

import dev.langchain4j.agent.tool.P; import dev.langchain4j.agent.tool.Tool; public class OrderTools { @Tool("根据订单号查询订单当前状态,返回订单状态和预计送达时间") public String queryOrderStatus(@P("订单号,例如 OD123456") String orderId) { // 这里写真实逻辑:查数据库、调远程接口都可以 return "{\"orderId\":\"" + orderId + "\", \"status\":\"已发货\", \"eta\":\"明天18点前\"}"; } }

方法返回的是字符串,因为模型最后拿到的是文本。直接返回 JSON 字符串,会让模型更容易抽取关键信息,比返回一个对象更稳妥。

2.2 写工具描述的三个原则

我踩过的第一个坑,是工具描述写得太随意。后来总结出三个原则:

  • 明确触发条件:description 里要写清楚“什么情况下用这个工具”。比如“当用户输入订单号时,使用此工具查询订单状态,订单号格式通常以 OD 开头”。这样模型在遇到相关提问时才会优先想到它。
  • 参数说明写完整:每个参数都要有 @P,并且把格式和示例写进去。模型不是人,你不给它示例,它就可能瞎猜。
  • 返回格式稳定:工具内部最好统一输出 JSON 文本,并固定字段名。比如 status、eta、error,这样即使后续还有一步“总结答案”,模型也能稳定解析。

上面这个例子里的描述就包含了触发条件(有订单号)和返回内容(状态和预计送达时间),实际测试中命中率会高很多。

2.3 完整示例:库存查询与运费计算

下面我用电商场景,定义两个业务工具:一个查库存,一个算运费。这两个方法后面都会交给同一个 Agent 使用。

public class ProductTools { @Tool("根据商品ID查询当前库存数量,返回库存JSON") public String queryStock(@P("商品ID,例如 SKU1001") String skuId) { // 模拟数据库查询 return "{\"skuId\":\"" + skuId + "\", \"stock\": 120}"; } @Tool("根据收货省份和商品重量计算运费,返回运费JSON") public String calculateShipping( @P("收货省份,例如 广东") String province, @P("商品重量,单位千克,例如 2.5") double weightKg) { double amount = "广东".equals(province) ? 8 : 15; return "{\"province\":\"" + province + "\", \"amount\":" + amount + "}"; } }

两个工具方法都很简单,但描述精准、参数完整。后面 Agent 在回答“这个商品包邮吗”“明天能到吗”之类的问题时,就会自动决定先调哪个、再调哪个。

2.4 工具注册与冲突处理

工具定义好以后,要交给 AiServices 使用。假设你定义了三个工具类,可以这样注册:

AiServices<Assistant> services = AiServices.builder(Assistant.class) .chatLanguageModel(model) .tools(new OrderTools(), new ProductTools()) .build();

这里有两个容易忽略的细节。第一,如果多个工具类里有同名方法,注册前需要确认描述不冲突,否则模型可能会选错。第二,工具数量不宜太多,我试过一次性挂二十个工具,很多模型在选择时会“犹豫”,还容易把相似的工具搞混。手段是:能合并的工具就合并,或者在运行时用 ToolProvider 控制哪些工具对当前请求可见,真正做到“不同场景注册不同工具”。

3. 构建 Agent:让模型自动决策调用哪个工具

3.1 AiServices 的原理

AiServices 是 LangChain4j 里最核心的“魔法”之一。它允许你定义一个普通接口,然后框架自动帮你生成实现类。接口里的方法签名就是你给 Agent 定的“入口”,而实现逻辑由模型和工具共同完成。

最简单的接口定义长这样:

public interface Assistant { String chat(String userMessage); }

如果你只想要一个“问答机器”,接口里定义 chat 方法就够了。但如果想让 Agent 有记忆,方法里就需要加一个 @MemoryId 参数,后面我会专门讲。AiServices 会用 LLM 做推理,遇到需要业务数据的地方,自动调用你注册的工具。

3.2 从单工具到多工具 Agent

把上一节的两个工具类都注册到同一个 Assistant:

Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .tools(new ProductTools(), new OrderTools()) .chatMemory(MessageWindowMemory.withMaxMessages(10)) .build();

用户问题进来后,模型会进行自己的“判断”,比如用户问:

“SKU1001 现在有货吗?发广东大概多少钱?”

模型可能会连续调用两次工具:先 queryStock,再 calculateShipping,然后把两次结果合并成自然语言回复给你。这个过程中,Agent 自己决定了工具调用顺序,就像内部有一条动态生成的流水线。LangChain4j 会替你管理中间的 JSON Schema 拼接、参数解析和结果回填。

3.3 固定流程的 Agent 流水线

动态自由调用适合开放场景,但业务系统里很多流程是固定的。比如“查订单”之前必须先“校验用户身份”,否则任何订单信息都不能泄漏。这种时候就不能完全交给模型自由发挥了,我们需要在 Agent 外部编排一条固定流水线。

我通常用一个简单的 Pipeline 类来串行组织阶段:

public class OrderAgentPipeline { private final UserAuthTool authTool = new UserAuthTool(); private final OrderTools orderTools = new OrderTools(); private final ChatLanguageModel model; public OrderAgentPipeline(ChatLanguageModel model) { this.model = model; } public String run(String userId, String userMessage) { // 阶段1:身份校验,不通过直接拒绝 String authResult = authTool.checkUser(userId); if (authResult.contains("invalid")) { return "身份校验失败,不能查询订单信息。"; } // 阶段2:让 Agent 在“已通过校验”的上下文里查订单 String toolResult = orderTools.queryOrderStatus("OD123456"); // 阶段3:用模型生成面向用户的最终答复 return model.generate("用户查询订单结果如下:" + toolResult + ",请用中文友好地回复用户。"); } }

这样做的优点是逻辑透明、可控性强,而且中间任意一步失败都能及时止损。缺点是你牺牲了一部分模型的自由发挥空间,但绝大多数业务场景下,可控比“聪明”更重要。

3.4 条件分支与重试策略

固定流水线里经常会遇到分支。比如校验通过的走查询,校验不通过走提示;库存大于 0 走下单提示,库存等于 0 走到货提醒。这些分支如果用动态 Agent 来表达,需要用大量 Prompt 约束,不可控。放在 Pipeline 代码里,就是普通的 if-else,反而简单明快。

重试策略也是一样。工具方法内部如果调用第三方 HTTP 接口,建议在工具方法里做一次短超时重试,超时返回一个固定错误串。而 Pipeline 代码则负责决定:单次失败后是否直接终止,还是重试整条流水线。我一般遵循两个原则:写操作不盲目重试,读操作最多重试两次。

4. 上下文与记忆管理:流水线不“断片”

4.1 ChatMemory 的作用

Agent 让人惊艳,也让人头疼的地方在于上下文。如果每次请求都把所有历史消息塞给模型,很快 token 就会暴涨,成本跟着起飞。LangChain4j 提供了 ChatMemory,用于管理会话历史。最常见的两种策略是:

  • MessageWindowMemory:只保留最近 N 条消息,超出后自动丢弃最老的。
  • PersistentChatMemory:把历史存到数据库或 Redis,支持跨会话加载。

如果你的 Agent 只是“查一下答案”,用 MessageWindowMemory 就够了。如果是真正的客服助手,建议把历史持久化到存储里,用户下次再来还能记得上次聊到哪。

4.2 在 AiServices 中接入记忆

在 AiServices 里接入记忆很简单,builder 上直接设置 ChatMemory:

ChatMemory chatMemory = MessageWindowMemory.withMaxMessages(20); Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .chatMemory(chatMemory) .tools(new ProductTools()) .build();

这样同一个 Assistant 实例会维护一份会话记忆。但生产环境通常一个用户一个会话,这时就要用 @MemoryId 来区分。

4.3 流水线中间结果的保存

固定流水线里,各阶段结果如果不保存,最后模型生成回复时就会“失忆”。我习惯定义一份 PipelineContext 对象,跑完一个阶段就往里塞一个字段,最后把整个 context 拼成一个精简文本,交给模型生成最终回复。

public class PipelineContext { public String userId; public String orderId; public String orderStatus; public String shippingAmount; public String format() { return "userId=" + userId + ", orderId=" + orderId + ", status=" + orderStatus + ", shippingAmount=" + shippingAmount; } }

用这个 context 的好处是,即使某一阶段失败,你也能清楚知道“数据是谁提供的、谁还没跑”。而不是让模型从一堆原始日志里自己猜。

4.4 多会话隔离

接口方法里加入 @MemoryId,AiServices 会自动帮你按会话 ID 隔离上下文:

public interface SessionAwareAssistant { String chat(@MemoryId String sessionId, @UserMessage String userMessage); }

sessionId 可以是登录用户的 ID,也可以是前端生成的 UUID。每次消息进来,框架只加载该 sessionId 对应的历史记录,不会跨用户串线。这个功能一定要用,尤其是在涉及订单、售后等敏感数据时。

5. 常见问题与排查技巧实录

5.1 模型就是不调用工具,怎么办

这是我在群里被问得最多的问题。通常有四个原因:第一,工具描述太模糊,模型觉得没必要调用;第二,模型版本不支持 function calling,需要换支持工具调用的模型,比如 OpenAI 的 gpt-4o 系列或支持 tool calling 的国产模型;第三,工具方法参数类型太复杂,模型不知道怎么生成参数值;第四,工具注册没生效,检查 AiServices 的 tools 方法是否真的传入了实例。

一个很有效的排查技巧:把模型收到的请求体打印出来,确认里面是否包含 tools 数组。如果 tools 数组为空,说明工具没有注册成功;如果 tools 数组存在但不调用,多半是描述的问题。

5.2 参数绑定报错和序列化问题

我踩过最经典的一个坑,是工具方法参数用了一个自定义对象,结果模型返回的参数只有部分字段,导致解析失败。解决办法是让工具方法的参数尽量用基本类型:String、Integer、Double、Boolean。如果必须传对象,建议在 @P 描述里写清楚每个子字段的含义,或者在方法里做一些默认值兜底。

另外,方法返回类型不要用 Optional,也不要返回 null。返回 null 会让 Agent 在后续解析时直接砸锅,宁可返回一个带有 error 字段的 JSON 字符串。

5.3 流水线超时和卡死

外部接口慢、工具方法重试逻辑不当,都会让 Agent 响应变慢。我的处理手段很朴素:所有工具方法内部的 HTTP 调用,超时时间统一设置在 3 到 5 秒;Pipeline 整体再用 CompletableFuture 做异步,前端先返回“稍等”,后台跑完再通知。这样用户体验不会因为单个工具超时而彻底卡死。

重试逻辑只放在“幂等读取”的工具方法里,凡是插入、更新、下单这类操作,一律不重试,避免重复扣钱或建单。

5.4 成本飞涨的隐形元凶

很多同学刚开始跑通 Agent 很开心,一看账单傻眼了。成本飙升通常不是因为用户消息多,而是因为每次请求都要把工具 schema 一起发给模型。工具越多,schema 越大,token 消耗就越大。

建议:按业务场景动态注册工具。比如用户在售后页面,只注册售后相关工具;在商品页,只注册库存和价格工具。LangChain4j 的 ToolProvider 接口可以帮你基于当前请求上下文,动态决定暴露哪些工具。这个功能我用下来,成本降了将近三分之一。

5.5 经验总结:从 Demo 到上线的关键一跃

Demo 阶段随便写都能跑,但真正上线前,我建议至少做三件事:第一,把工具方法改成纯读写分离,所有外部依赖都走超时和错误码;第二,给 Agent 生成回复加一层输出校验,至少确认不含业务禁止的内容;第三,关键流水线加日志,不需要记全量 prompt,但每个阶段的入参和出参必须留痕,否则出问题很难回溯。

我个人实际使用下来的体会是:LangChain4j 最打动我的不是某一个大功能,而是它对 Java 后端的友好程度。你可以清清白白地写出业务代码,不用为了接 AI 完全推翻原有架构。@Tool 和 Agent 流水线的组合,基本上能覆盖我遇到的大部分“让模型干活”的场景。

最后再分享一个小技巧:把一个工具类里所有的方法描述打印出来,自己站在用户的角度读一遍,如果连你都想不出“什么时候可以调用这个方法”,那模型大概率也不会调用。工具描述本质上不是写给机器看的,是写给业务逻辑看的。把这块打磨扎实,后面整条 Agent 流水线都会顺畅很多。

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

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

立即咨询