☰
LangChain4j实战:从@Tool到Agent流水线全流程拆解
2026/10/5 5:20:00 网站建设 项目流程

最近在 Java 服务里折腾 LangChain4j,从最早只是用@Tool暴露一个查询方法,到后来把十几个工具按场景编排成一条完整的 Agent 流水线,整个过程踩了不少坑,也沉淀下来一套能直接复用的套路。如果你也在做 Java 生态的 AI 应用,想搞清楚@Tool、Agent、流水线这三层到底怎么组织,这篇文章把我的实战路径完整拆给你看。我不会只讲概念,而是把从注解到编排、从单轮调用到多路召回、从单机演示到扛并发的完整过程都摆出来,你跟着走一遍基本就能在自己的项目里落地了。

1. 整体设计思路:为什么要把 @Tool 升级成 Agent 流水线

1.1 一个工具方法和一个 Agent 引擎差在哪

最早我用@Tool只是想把一个 Java 方法暴露给大模型。比如用户说“帮我查一下订单状态”,模型识别出这个意图,然后把订单号填进我的方法参数里,方法执行完把结果返回给模型,模型再组织语言回复用户。这套流程在局部场景下是够用的,本质上就是一次“意图识别 + 函数调用 + 结果回填”。

但真正做业务落地时你会发现,单工具的链条太短了。用户问“这个月哪类商品退款率最高,顺便说说原因”,这个问题涉及订单查询、退款统计、商品类目映射、甚至售后备注分析,不是一个@Tool方法能直接回答的。更常见的是,模型需要先调用 A 工具拿到一批数据,根据数据判断下一步调 B 还是调 C,如果结果不理想还要再调 A 换参数重试。这种“模型自主决策 + 多工具循环调用”的过程,就是我说的 Agent 引擎。

所以我一贯的思路是:能用@Tool直接解决的,绝不硬上 Agent;但是一旦业务需要多步骤决策,必须把工具调用放进一个循环里,让模型反复观察结果、调整动作,直到它认为信息足够再输出最终答案。这个循环就是 Agent 流水线的内核。

1.2 LangChain4j 的“一个库打全套”定位

很多 Java 团队一提到 AI 应用,第一反应是接 OpenAI SDK,或者去搞一套 Python 的 LangChain。但 LangChain4j 提供的是一整套 Java 原生抽象,覆盖了我上面说的所有环节。

  • 模型接入层:统一的ChatLanguageModel、EmbeddingModel接口,底层可以用 OpenAI、Ollama、 vLLM、各类国产模型网关,只要提供兼容接口就能切。
  • 工具调用层:@Tool注解扫描方法签名,自动生成模型需要的 JSON Schema,自动解析模型返回的 tool call 参数并反射调用 Java 方法。
  • Agent 编排层:AiServices把工具注册、记忆管理、流式输出、RAG 检索、输出校验全部串起来,声明式就能构建一个智能体服务。
  • RAG 与检索层:EmbeddingStore、ContentRetriever、多路召回都可以在这个抽象下实现。

我的经验是,框架的意义不是帮你省掉所有代码,而是把 AI 应用最繁琐的“协议适配”和“循环调度”固定成标准姿势,让你把精力花在业务工具本身的正确性上。这也是我推荐 Java 团队直接选它的核心理由。

1.3 流水线分层的通用架构

我用一句话概括我最后落地的架构:入口路由 → Agent 决策循环 → 工具执行层 → 知识检索层 → 输出后处理。每个请求进来,先判断走哪个 Agent(客服、分析、运维),Agent 内部维护多轮对话记忆,模型每一步选择调用哪些工具,工具层负责与业务系统交互,知识检索层负责从向量库和关键词索引里找上下文,最后统一做流式返回。

这里有个很关键的分层原则:@Tool方法只做“原子操作”,不要包含决策逻辑。Agent 循环负责决策,工具只负责执行和返回结构化结果。如果你把决策逻辑写进工具方法里,后面每次调整策略都要动业务代码,非常痛苦。

2. @Tool 注解再深入:从简单方法到可信工具集

2.1 把第一个 @Tool 方法跑起来

@Tool的使用门槛极低。定义一个类,方法上打注解,方法名和参数名都能被模型感知。下面是最常见的写法。

public class OrderTools { @Tool("根据订单号查询订单状态,返回订单状态、金额和最近更新时间") public String queryOrderStatus(String orderNo) { Order order = orderService.findByNo(orderNo); if (order == null) { return "未找到订单,请核实订单号"; } return String.format("订单号:%s, 状态:%s, 金额:%.2f, 更新时间:%s", order.getOrderNo(), order.getStatus(), order.getAmount(), order.getUpdateTime()); } }

注意几个细节。第一,@Tool注解里的描述要写清楚“方法是干什么的”,模型靠这段描述来决定要不要调用这个方法。描述越具体,误调用越少。第二,返回值建议直接返回人类可读的字符串,因为模型的最终回复是基于这段文字组织的,你返回一个对象 JSON 也能用,但可读性差,模型转述时容易丢信息。第三,方法名要动词开头,比如queryXxx、calculateXxx,让模型一看就知道这是动作类工具。

注册方式很简单。在AiServices构建时传入工具实例即可。

CustomerAssistant assistant = AiServices.builder(CustomerAssistant.class) .chatLanguageModel(model) .tools(new OrderTools()) .build();

tools()支持传多个对象,你只需要把工具类实例放进去,框架会扫描该类中所有@Tool方法并注册。

2.2 参数绑定的进阶玩法与类型选择

@Tool方法参数的绑定方式,本质上是由框架把所有参数转成 JSON Schema 发给模型,模型按 schema 生成参数值。底层逻辑不复杂,但参数类型的选择直接决定调用成功率。

常见的建议是这样的:

参数类型推荐度原因
String、int、double、boolean最推荐模型生成简单值准确率高,少出错
枚举类型推荐枚举值固定, schema 能约束可选项
复杂 Java 对象(List、嵌套对象)慎用模型生成嵌套 JSON 极易出错,字段少时可用
Map 泛型不推荐模型不知道 key 应该填什么,经常乱传

如果你确实需要让模型传入一组结构化参数,比如“筛选条件包含时间范围和状态”,建议定义一个简单类,字段用基本类型,并且每个字段加上 JSON 注释,让 schema 更清晰。

@Tool("按条件查询退款订单") public String queryRefundOrders(@ToolParameter("开始时间,格式yyyy-MM-dd") String startDate, @ToolParameter("结束时间,格式yyyy-MM-dd") String endDate, @ToolParameter("退款状态: PENDING, PROCESSING, DONE") String status) { // 业务逻辑 }

@ToolParameter是可选的,但我的建议是业务关键字段只要有可能让模型误解,就一定要写描述。尤其是日期格式、状态枚举取值,模型如果不知道格式,会生成五花八门的内容导致方法内部解析失败。

2.3 工具注册的批量管理与隔离

当工具数量超过十个,把全部工具塞进一个 Agent 里会让模型“选择困难”。我的做法是把工具按业务域拆成多个类,再按 Agent 场景分组注册。

比如客服 Agent 只注册订单查询、物流查询、售后申请工具;数据运营 Agent 只注册统计、报表、多路检索工具。这和微服务拆分的思想是一致的,目的都是缩小模型的决策范围,提升工具命中准确率。

另外,如果你有几十个工具需要统一管理,可以用ToolProvider接口做动态供给。框架在每次模型请求时调用ToolProvider拿到工具列表,你可以根据当前上下文、用户身份、甚至灰度开关动态决定暴露哪些工具。这在多租户场景下特别有用。

提示:工具不是越多越好。我实测过,把一个 Agent 的工具从 15 个减到 6 个,工具误调用率下降了一半以上。做工具集要做减法。

3. Agent 编排与流水线:让模型自己学会叫工具

3.1 理解 Agent 循环的手工版本

在学习AiServices之前,我强烈建议先理解 Agent 循环的底层实现。说白了就是三步:模型生成内容,解析其中是否有工具调用请求;如果有,执行工具并把结果作为消息追加进对话;继续让模型生成,直到模型不再请求工具为止。下面是我早期手写的简化版流程。

List<ChatMessage> messages = new ArrayList<>(); messages.add(new SystemMessage(systemPrompt)); messages.add(new UserMessage(userInput)); int maxSteps = 5; int step = 0; boolean finished = false; while (!finished && step < maxSteps) { Response<AiMessage> response = model.generate(messages); AiMessage aiMessage = response.content(); messages.add(aiMessage); List<ToolExecutionRequest> requests = aiMessage.toolExecutionRequests(); if (requests == null || requests.isEmpty()) { finished = true; break; } for (ToolExecutionRequest request : requests) { String result = toolExecutor.execute(request); messages.add(ToolExecutionResultMessage.from(request, result)); } step++; }

这段代码解释了 Agent 循环的一切关键点。第一,循环必须有最大步数限制,不设上限的 Agent 在小模型上经常陷入死循环或高频调用,白白烧钱。第二,模型每次生成的内容都必须加到消息列表里,工具执行结果也要以ToolExecutionResultMessage加上去,这样模型才能“看到”工具返回的内容。第三,一定要处理模型一次请求多个工具的情况,循环里逐个执行再回填。

我早期踩过一个坑:把工具执行结果拼接成普通字符串塞进 user message,结果模型完全不理解那段文本是工具结果,开始胡编乱造。改成ToolExecutionResultMessage之后,模型对工具结果的引用准确了非常多。

3.2 用 AiServices 把循环封装成声明式服务

手写循环能帮你理解原理,但生产环境我不会用裸循环。AiServices才是 LangChain4j 封装 Agent 循环的核心入口。它的原理其实和我上面的代码一样,但帮你处理了记忆、流式、多工具并发、消息转换等一堆细节。

public interface CustomerAssistant { String chat(String userId, String message); } CustomerAssistant assistant = AiServices.builder(CustomerAssistant.class) .chatLanguageModel(model) .chatMemory(MessageWindowChatMemory.withMaxMessages(30)) .tools(new OrderTools(), new RmaTools()) .build(); String answer = assistant.chat("U123456", "帮我查一下订单 NO20240101 到哪了");

注意到这里我传了userId,LangChain4j 的chatMemory会按 userId 自动隔离每个用户的对话上下文。不同用户的消息不会串,这是生产级多用户服务必须要有的能力。

流式输出也一样简单,接口返回类型换成Flux<String>或者Stream<String>,框架会自动把模型输出按 token 流式推给你。前端再配合 SSE,体验拉满。

3.3 Agent 流水线:扇出、扇入与路由

很多教程讲完AiServices就停了,但真实业务里 Agent 往往不只一条链。我在自己的项目里落地了三种编排模式。

  • 顺序流水线:一步的结果是下一步的输入。比如先查订单归属仓库,再用仓库 ID 查库存状态。
  • 并行扇出:一个请求同时触发多个独立工具,收集所有结果后统一回复。比如用户问“这个订单发货没、能退不、运费谁出”,三个问题互相独立,可以在一次模型决策中并行调用三个工具。
  • 条件路由:模型根据当前状态决定走哪条子流水线。比如判断用户是投诉还是咨询,投诉走升级流程,咨询走标准答复。

对于并行扇出,我遇到过比较多的坑是模型往往会请求多个工具,如果串行执行,总耗时等于所有工具耗时之和,用户体验很差。LangChain4j 在较新版本支持了ToolExecutionRequest的并行执行,你可以通过AiServices的配置开启。如果你用的是早期版本,可以自己在工具方法内部用CompletableFuture做并发聚合,但注意工具方法要是幂等的。

3.4 记忆管理与上下文裁剪

Agent 流水线跑久了,聊天记录会无限增长。每轮对话都把全部历史发给模型,token 成本会快速飙升,而且超出模型上下文窗口之后直接报错。我的建议是用MessageWindowChatMemory做滑动窗口,保留最近 N 条消息就够了。比如客服场景 20 条足够,复杂分析场景可以到 40 条。

如果你需要跨会话持久化记忆,LangChain4j 提供了PersistentChatMemoryStore接口。我生产环境是把它实现到 Redis 里,以 userId 为 key 存消息列表,这样服务重启后用户还能接着上次对话聊。别小看记忆持久化,用户换一台设备就忘记之前聊过的内容,这种体验在客服场景是完全不能接受的。

4. RAG 与多路召回:让 Agent 的回答“有据可依”

4.1 从单路向量检索到多路召回

Agent 光会调工具还不够,很多问题需要结合企业内部知识库来回答。RAG 的标准链路是:用户问题 → 检索相关文档 → 拼接成上下文 → 交给模型生成答案。最朴素的实现是只用向量相似度检索,但它的缺陷非常明显:专有名词、缩写、编号这类内容向量召回效果不稳定,经常漏掉关键信息。

所以我后来在生产环境换成了多路召回。简单说就是同时用向量检索和关键词检索,再把各路结果融合排序。向量召回擅长语义匹配,关键词召回擅长精确匹配,两者互补之后,召回质量明显提升。

4.2 LangChain4j 里的多路召回实现

LangChain4j 的Retriever接口就是做召回用的,你可以自己写一个CompositeRetriever,内部并发调用多个召回源。

public class HybridRetriever implements Retriever { private final EmbeddingStoreRetriever vectorRetriever; private final Retriever keywordRetriever; private final int topK; @Override public List<Document> findRelevant(String query) { CompletableFuture<List<Document>> vectorFuture = CompletableFuture.supplyAsync(() -> vectorRetriever.findRelevant(query)); CompletableFuture<List<Document>> keywordFuture = CompletableFuture.supplyAsync(() -> keywordRetriever.findRelevant(query)); List<Document> vectorDocs = vectorFuture.join(); List<Document> keywordDocs = keywordFuture.join(); return mergeByRRF(vectorDocs, keywordDocs, topK); } }

多路召回的融合排序,我统一用 RRF(Reciprocal Rank Fusion)。它的思路很巧妙,不看分数的绝对值,只看每一路里的排名,然后计算每个文档在多个排名里的倒数和。公式是:

score(d) = Σ 1 / (k + rank(d))

这里rank(d)是文档 d 在某一路召回结果里的排名,k 一般取 60。举个例子,假设向量召回结果里文档 A 是第 2 名、文档 B 是第 1 名,关键词召回结果里文档 A 是第 5 名、文档 B 没被召回,那么:

score(A) = 1/(60+2) + 1/(60+5) = 0.0161 + 0.0154 = 0.0315 score(B) = 1/(60+1) + 0 = 0.0164

虽然文档 B 在向量召回中是第 1 名,但在融合排序后 A 反而排在前面,因为 A 在两路都被命中。这正是 RRF 的价值:它偏爱“在多个来源中都有证据”的文档,比单一来源的最高分更稳定。

4.3 检索上下文与 Agent 流水线的结合

多路召回的结果最终是要拼进 prompt 的。在AiServices中注册ContentRetriever,框架就会在每次模型调用前自动检索并把文档作为系统消息注入,你不需要手动拼接。

ContentRetriever hybridRetriever = new HybridRetriever(embeddingStore, embeddingModel, keywordClient); Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .contentRetriever(hybridRetriever) .chatMemory(memory) .tools(new OrderTools()) .build();

这里的细节是控制检索文档数量和长度。我建议单轮检索最多返回 4 到 6 个文档,每个文档裁剪到 1000 字符以内。文档太多太长,模型会分不清主次,回答时会东拉西扯;文档太少又不够支撑。我的一般做法是向量和关键词各取前 5 个文档,做 RRF 融合后保留 Top 4,效果比较稳定。

5. 生产化与并发:AI Agent 怎么扛住真实流量

5.1 Agent 并发比普通接口难在哪

一个普通的 REST 接口,瓶颈基本在数据库或下游服务。但 AI Agent 的请求不是简单的“请求-响应”,它内部是多次模型调用和工具调用的循环,单请求耗时长,且每次模型调用都要消耗外部 API 配额和带宽。流量一上来,最常见的现象是:线程池被占满,后面的请求排队超时;模型 API 触发限流报错;内存中保存的对话消息列表膨胀导致 OOM。

我拆解过 Agent 请求的耗时分布,模型生成占大头,一个复杂的多步工具调用链,整体耗时可能从几秒到几十秒不等。因此它确实比普通接口更难“扛并发”,但这不代表不能扛。

5.2 限流、超时、重试与幂等设计

先说完整体系,再给参数建议。

  • 信号量限流:限制同时进行的 Agent 请求数量。比如一台 4C8G 的容器,我给单容器并发上限设为 10 到 20。超出的请求进队列或者直接返回忙。
  • 超时控制:模型调用必须有超时,我一般设 60 秒,工具调用设 10 到 15 秒。Agent 总循环步数限制为 5 步。
  • 重试策略:对模型 API 的瞬时错误做退避重试,但工具调用不能盲目重试,尤其是写操作。工具重试前要确认是不是幂等方法。
  • 幂等设计:工具方法必须支持重复调用。最典型的例子是“创建工单”,模型可能会因为网络超时重试同一次请求,导致创建两张工单。我的做法是在工具参数里要求模型传一个业务幂等键,比如 userId + 时间戳,后端收到重复键直接返回已有结果。

5.3 Java 虚拟线程:Agent 并发的救星

一个 Agent 循环里往往有多次外部调用,每次调用都在等待网络 IO。传统线程池在这种“IO 密集型 + 长时间等待”的场景下非常浪费线程资源。我从 Java 21 开始全面切了虚拟线程,效果立竿见影。

ExecutorService agentExecutor = Executors.newVirtualThreadPerTaskExecutor(); CompletableFuture<String> future = CompletableFuture.supplyAsync( () -> assistant.chat(userId, message), agentExecutor );

虚拟线程的调度开销极低,几万路请求也能轻松创建线程,你不必再纠结线程池大小。如果你的服务还在 Java 17,建议至少用异步客户端加CompletableFuture做并发聚合,不要用同步调用串行执行工具。

注意:虚拟线程下,所有工具方法也要避免阻塞操作。如果你的@Tool方法里有synchronized代码块或者数据库连接池取连接,它们依然可能成为并发瓶颈。这跟线程模型无关,是业务底层资源限制。

5.4 缓存:被很多人忽略的 Agent 并发利器

对于高频且结果可复用的请求,缓存能大幅降低模型 API 调用量。我做了两层缓存。

第一层是语义缓存,把用户问题的嵌入向量存入向量库,新请求来了先算出向量,在缓存里找相似度超过 0.95 的旧问答,直接命中返回。第二层是工具结果缓存,比如天气预报、库存数量这类短时内不变的数据,工具方法内部加本地缓存,避免每个请求都去拉一次外部接口。

语义缓存需要控制相似度阈值,设太高命中率低,设太低容易答非所问。我实测 0.93 到 0.97 之间比较合理,你可以根据自己的业务调。

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

6.1 工具调用不触发或者反复调用

这是新手最常见的问题,现象分两种。

一种是模型始终不调用工具,自己凭记忆瞎回答。这种情况九成原因是工具描述不清楚,模型根本不知道你有这个工具。排查方法是打印发给模型的 system prompt 和 tool schema,看工具是否真的被注册进去,描述是否写清了“什么场景用什么工具”。另一种是模型陷入死循环,反复调用同一个工具。这通常是工具返回结果让模型觉得“信息不够”,又不知道下一步该干嘛。我的解法是:工具返回里明确告诉模型下一步建议,或者在系统提示词里加上“如果同一工具调用超过三次仍无法解决,请直接告知用户需要人工介入”。

6.2 模型报错:provider rejected the request schema or tool payload

这个报错我遇到过好几次,它说的是我们发给模型的工具 schema 非法,或者工具调用参数无法被模型服务端校验通过。常见诱因有三个:

  • @Tool方法参数包含了框架无法序列化成合法 JSON Schema 的类型,比如 Java 8 的Optional字段、自循环引用的对象。
  • 参数名或者描述里含有非法字符,模型生成时出错。
  • 工具返回的字符串过大,塞爆了模型上下文,服务端直接拒绝。

解法很直接:工具参数一律用基本类型加简单注解;返回值做长度裁剪,超长内容分页或者只截取摘要;手动用ToolSpecifications构建一遍 schema 发给模型调试工具校验。

6.3 并发一高就超时或 OOM

超时通常不是模型慢,而是你的执行线程被占满后排队。先看线程池是不是太小,再看是否有同步阻塞调用卡住了线程。如果线程池不小还超时,抓线程栈看热点。OOM 则要怀疑聊天记忆膨胀。如果你的ChatMemory没有做窗口限制,消息列表会无限增长,长期运行的 Agent 内存必爆。务必设置MessageWindowChatMemory.withMaxMessages(...),并且给持久化存储加 TTL。

下面是我整理的排查速查表,你可以直接收藏。

现象可能原因优先排查项
工具从不触发工具描述不清 / 未注册打印 tool schema 检查注册信息
工具反复调用返回信息不足 / 步数限制缺失设置 maxSteps,优化工具返回提示
schema payload 被拒绝参数类型非法 / 返回内容过大简化参数类型,裁剪返回长度
请求超时线程池满 / 模型 API 慢看线程池活跃数,抓线程栈
内存持续上涨聊天记忆无窗口限制加 maxMessages 和持久化 TTL
并发后回答质量下降上下文太长被截断 / 工具过多裁剪历史,缩小工具集

6.4 用 traceId 追踪 Agent 全链路

最后分享一个我自己一直在用的习惯。Agent 请求链路长,涉及模型调用、工具调用、检索调用,排查问题如果没有统一链路 ID,会非常痛苦。我在AiServices外层包了一个拦截器,请求进来时生成 traceId,透传到所有工具方法和检索模块,结构化日志全部带上这个 traceId。

这样用户反馈“回答不对”时,我能直接查到这个请求整个链路:模型决策了什么、调了哪些工具、每步耗时多少、哪一步返回了错误。没有这套追踪机制,排查 Agent 问题就像在黑屋子里找东西,效率极低。我的建议是,Agent 从开发第一天就接入 traceId,不要等出了问题再补。


一路写下来,我自己最大的感受是:LangChain4j 能不能“一个库打全套”,关键不在于库本身有多少功能,而在于你如何把工具、记忆、检索、并发这几个维度组织成适合自己业务的流水线。@Tool只是起点,Agent 循环和检索融合才是把 AI 能力落到业务里的真正分水岭。如果你正准备从单工具调用迈向完整的 Agent 流水线,建议第一步就把上面第 2 章的工具设计规范落实到位,再往后铺检索和并发框架,后面你会少踩很多我踩过的坑。

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

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

立即咨询