LangChain4j实战:用@Tool和Agent构建Java AI流水线
聊一个我最近反复在讲的话题:LangChain4j。注意,我没说 Spring AI,虽然它俩是竞品,但 LangChain4j 更接近我心目中“一个库打全套”的样子。它的核心价值在于,让 Java 开发者不用离开熟悉的生态,就能用@Tool把模型变成能动手干活的实体,再通过Agent的决策循环把工具串成一条真正的流水线。
这篇东西不是入门教程,默认你已经知道 ChatModel、Prompt 这些基础概念。我会从 @Tool 的底层逻辑开始,一步步讲到 Agent 编排、流水线设计、并发与记忆管理,最后给一个完整可跑的示例工程思路。里面夹带的全是我实际踩过坑之后总结的经验,比官方文档直白得多。
1. 先搞明白:LangChain4j 在 Java 生态里到底解决了什么
1.1 为什么 Java 做 AI 应用总是“差一步”
在 LangChain4j 出现之前,Java 开发者接入 LLM 的体验是割裂的。你需要自己包 HTTP 客户端、自己处理 JSON、自己维护对话历史、自己写工具调用的循环逻辑。写出来的代码往往是一次性的,换个模型就要改一批。
Python 那边 LangChain、LlamaIndex 早就把工具调用、记忆、检索这些东西封装成高级抽象,Java 这边一直没有对等物。Spring AI 出来后好了一些,但它在 Agent 和 Tool 上的表达力,我个人觉得仍然没有 LangChain4j 流畅。LangChain4j 的设计理念很直接:把 AI 能力建模成 Java 接口方法,你写业务代码时感觉不到“我在调 LLM”。
这套思路带来的最直观体验是:你定义一个接口,比如Assistant,里面声明一个方法String chat(String message)。LangChain4j 会在运行时自动生成这个接口的实现,内部帮你完成模型调用、工具调度、记忆管理。这跟 MyBatis 生成 Mapper 实现是一个套路,Java 开发者接受成本极低。
1.2 “一个库打全套”这句口号,实际包含哪些能力
“一个库打全套”在 LangChain4j 这里不是一个营销词,而是一个覆盖面相当广的愿景。拆开来看,它至少覆盖六块能力:
- 模型接入层:OpenAI、通义、智谱、Ollama、本地 vLLM,几十种实现,互相可切换。
- 结构化输出:模型输出直接映射成 Java 对象,不用手写解析逻辑。
- 工具调用(@Tool):这是核心。模型在推理过程中可以决定调用你注册的方法。
- 记忆管理:对话窗口、持久化存储、消息压缩策略,统一的 API。
- Agent 运行框架:基于 ReAct 等模式构建决策循环。
- RAG 支持:Embedding、向量存储、检索增强生成。对应的就是大家常说的“知识库流水线”。
一个人日常工作里最常遇到的 AI 应用场景,这套能力覆盖得七七八八。不用再去拼凑不同的库,配合自身已有的 Spring Boot、Micronaut 等框架,打一套组合拳就够了。
1.3 学之前要有的基础
你想顺畅地读下去,至少要有这些底子:Java 8 以上语法没问题,熟悉 Spring Boot 的基本写法(因为官方示例大多基于 Spring Boot),用过或了解过 LLM 的 Chat Completion 接口,大概知道什么是 System Prompt、User Message、Assistant Message。如果这些都具备,那接下来内容对你来说应该没有障碍。
如果你是 Python 那边转过来的,可能更熟悉 LangChain。LangChain4j 很多概念和 LangChain 相似,但实现上更贴合 Java 的风格——依赖注入、注解、泛型这些 Java 特色很强。带着对比视角去读会有意思得多。
2. @Tool:把 Java 方法变成 AI 能调用的“手”
2.1 @Tool 的工作机制:模型为什么能“看见”你的方法
先看一段最基础的代码:
import dev.langchain4j.agent.tool.Tool; public class DateTimeTools { @Tool("返回当前日期和时间,格式为 yyyy-MM-dd HH:mm:ss") public String currentDateTime() { return DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") .format(LocalDateTime.now()); } }这段代码从业务角度看毫无特别之处,就是一个普通方法。但加了@Tool注解之后,它就不再是普通方法了。
LangChain4j 在初始化阶段会扫描注册进来的 Tool 对象,通过反射读取方法名、参数类型、注解上的描述信息,然后把这些信息拼装成模型需要的 function schema。在 OpenAI 兼容接口里,这个 schema 就是 JSON 格式的functions参数,里面描述了每个工具的名字、用途、参数结构。模型在生成回复前多了一步:它会根据用户消息和工具列表,预测是否需要调用某个工具,如果需要,就生成一个包含tool_calls的响应。
这一步是“让模型看见工具”的关键。模型不是真的执行了你的代码,而是用它的推理能力做了选择和参数填空。真正的执行发生在 Java 这边——LangChain4j 解析模型输出的工具调用请求,通过反射把你注册的方法调起来。整个过程你完全感受不到 JSON 的存在。
从这里你应该能理解一个重要的结论:@Tool 描述文字写得好不好,直接决定了模型会不会正确调用、会不会传对参数。
2.2 参数设计:AI 传参不等于 Java 传参
当一个工具方法有形参的时候,事情就没那么简单了。
@Tool("查询指定城市的当前天气") public String getWeather(String city, String unit) { // ... }模型看到这个方法的 schema 后,会负责生成city和unit的具体值。问题是:模型并不知道你的 Java 类型约束,它只是“尽力而为”地填值。比如你的参数其实要求是yyyy-MM-dd格式的日期字符串,但模型可能给你生成2024/6/1这种格式。
我在实际项目中常用的策略是:参数类型尽量简单,约束尽量用描述文字写清楚。能用 String 就不要用复杂的嵌套对象,能用枚举值就明确列出来。
@Tool("查询天气。城市参数必须填写全名,如'上海市',不要用简称;单位只能填 'celsius' 或 'fahrenheit'") public String getWeather(String city, String unit) { if (!"celsius".equals(unit) && !"fahrenheit".equals(unit)) { unit = "celsius"; } // 业务逻辑 }另外一个值得补充的点:LangChain4j 支持从方法参数上的 Java 注解(如@Parameter)来描述每个参数的含义,这在参数多的时候特别有用。如果你只有一个长描述里带了参数说明,模型偶尔会搞混参数位置,逐参数描述能明显降低错误率。
2.3 返回值设计:别让模型“只拿到一句话”
工具执行的返回值是要送回给模型看的。模型会根据返回值内容决定下一步动作,是继续调用别的工具还是回复用户。所以返回值本质上也是模型的“推理材料”,你返回什么东西,直接决定了它的后续判断。
经验之谈:尽可能返回结构化数据,不要返回已经渲染好的自然语言。
举个例子。你的工具查询了数据库里的订单列表,如果你返回“订单总数 12 个,总金额 5600 元”,模型就只知道这个层面的信息。但如果你返回 JSON 数组,里面有每一条订单的编号、金额、状态、创建时间,模型就可以基于这些信息做二次加工,比如“哪笔订单金额最大”“这个月失败的订单有几笔”。
@Tool("查询指定用户的订单列表") public List<Order> getOrders(@Parameter("用户ID") String userId) { // 实际项目里这里去查数据库或远程接口 return orderService.findByUserId(userId); }只要返回类型是普通 Java 对象,LangChain4j 会自动序列化成 JSON。模型拿到 JSON 之后,理解粒度完全不一样。
我在项目里还有一个习惯:工具执行结束后在返回值里附带一些机器可读的状态信息,比如{ "success": false, "errorCode": "ORDER_NOT_FOUND", "message": "..." },而不是抛异常让链路中断。因为异常会导致整个 Agent 循环失败,而返回错误标志可以让模型尝试别的路径,或者给出更友好的提示。
2.4 工具的注册与权限控制
工具不是自动出现在模型面前的,你需要显式注册。最常用的方式是在构建 Agent 时传入工具对象:
Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .tools(new DateTimeTools(), new WeatherTools()) .build();这里有一个容易被忽略的细节:tools 方法接收的是 Object 数组,你可以传多个工具实例,系统会自动收集所有标注了 @Tool 的方法。类的继承场景下,工具方法会从父类或接口继承过来,反射扫描时都能找得到。
权限方面,因为 @Tool 方法本质是你自己的代码,天然就处于你的控制范围内,不需要额外做“沙箱”。但业务上你一定得做一层“敏感操作”的保护逻辑。比如一个工具是“删除数据库记录”,你可以在工具方法内部校验当前用户的权限标识,不要在 Agent 层裸奔。原因很简单:模型只会根据上下文决定是否调用,它没有“这个操作是否越权”的判断力。
3. 从 @Tool 到 Agent:模型怎么自己决定调用链路
3.1 ReAct 循环:思考—行动—观察的闭环
如果你只有一个 @Tool,其实用不用得上 Agent 都行,那叫“function calling 单次调用”。真正体现出 Agent 价值的是多工具、多步骤的场景。用户问“帮我看下明天的日程,如果下午有空就约个会议”,这需要先查日程、再判断空档、再创建会议。模型一次推理完成不了,需要分多步执行。
LangChain4j 的 Agent 核心是一个循环,说白了就是 ReAct 模式:Thought(思考)→ Action(行动/调用工具)→ Observation(观察结果)→ 重复。模型的每一次响应可能是文字,也可能是工具调用请求。如果是后者,LangChain4j 会执行工具,把结果作为 Observation 再传回给模型,模型继续思考、继续行动,直到它认为自己有足够信息回复用户,或者达到了最大迭代次数。
这个循环从使用者的视角看,就好像模型在你面前一步步“干活”。你不需要自己写 while 循环,LangChain4j 在AiServices的代理层内部已经处理好了。
这里要顺带提一个很容易被误解的概念:Agent 不等于“长上下文自动续写”。很多人以为 Agent 就是模型能记住很多轮对话,其实 Agent 的核心是“能调用外部工具”,记忆只是辅助。LangChain4j 里 Runnable Agent 和轻量 Agent 的区别也更多体现在工具决策和记忆管理上。
3.2 AiServices:真正干活的代理工厂
AiServices 是 LangChain4j 最值得品味的一个类。它的用法神奇又简单:你定义一个接口,给出方法的输入输出类型,它帮你生成实现。
interface Assistant { String chat(String userMessage); }然后:
Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(openAiChatModel) .tools(weatherTools, calendarTools) .chatMemory(MessageWindowChatMemory.withMaxMessages(20)) .build(); String answer = assistant.chat("北京明天穿什么衣服?");你看到的是一次方法调用,背后发生的事包括:消息组装、模型调用、工具决策、工具执行、多轮循环、记忆更新。这套机制跟 Spring 的@Transactional有点像——你只声明需求,框架处理细节。
如果你的接口方法不止一个,比如你想让 Agent 既能聊天又能总结文档,可以定义多个方法,使用@SystemMessage给方法配置不同的系统提示词。LangChain4j 会为每个方法维护独立的行为配置,这在实际业务里太好用了。
注意:AiServices 生成的代理对象不是魔法,本质上还是动态代理 + LLM 调用链的封装。理解这点你就不会期望它处理那些模型本身做不到的事。
3.3 多工具冲突:让模型做出正确选择
工具多了之后,模型就会面临“选择困难症”。比如你既有一个“查询天气”的工具,又有一个“查询当前日期”的工具,用户问“今天热不热”,模型需要先取日期、再取天气,这其实是好的。但如果你提供了两个描述过于相似的工具,比如“查询订单”和“查询订单详情”,模型就很容易选错。
我的实践原则有三条:
- 工具数量控制在 10 个以内。超过这个数,模型的工具选择准确率下降很快,尤其是一些小参模型。
- 工具描述要有差异度,不要用模糊动词。应该写“查询订单基本信息(金额、状态、收货人)”,而不是“查询订单数据”。
- 对易混工具做显式引导。在 System Prompt 里写明“如果需要查询订单的物流信息,请使用 getTrackingInfo 而不是 getOrderInfo”。
还有一个隐藏的策略:把高频组合封装成单一工具。比如“查询订单并预估送达时间”作为一个整体工具,比让模型先查订单、再查物流、再计算时间更可靠、更快。工具是“原子操作”还是“组合操作”,取决于你在准确性和灵活性之间的取舍。
3.4 Agent 记忆:上下文窗口的管理艺术
Agent 跟模型的每一次交互都在消耗上下文,工具调用产生的 JSON 也在其中。如果你不管理记忆,ChatMemory 会无限增长,直到超过模型上下文窗口导致报错。
LangChain4j 提供了MessageWindowChatMemory和ChatMemoryProvider,前者适合单用户场景,后者适合多用户场景——可以按用户或会话维度分配不同的记忆空间。我在项目里的配置通常是:
ChatMemory chatMemory = MessageWindowChatMemory.builder() .maxMessages(20) .chatMemoryStore(myRedisStore) .build();maxMessages(20)是个经验值,它只保留最近 20 条消息。注意:工具调用产生的消息也会被计入,所以如果你的 Agent 每次要跑很多步,20 条可能很快就满了。实践中我会把它调到 30~50 之间,再配合一个消息压缩的预处理逻辑。
真正的坑在于:记忆窗口满了之后,早期的重要信息(比如用户的身份信息、初始需求)会被挤掉。这会导致 Agent“失忆”,后半段的行为完全跑偏。我的做法是:如果用户的原始诉求很关键,把摘要放进 System Prompt 里,而不是依赖 ChatMemory 去记住。
4. Agent 流水线:从单次调用到工程化链路
4.1 为什么需要流水线
单个 Agent 可以完成多步工具调用,但真实的业务场景往往不是“一个 Agent 从头干到尾”,而是多个阶段配合,每阶段有各自的工具集和目标。比如“智能客服工单系统”:
- 意图识别阶段:判断是咨询、投诉还是退换货。
- 信息收集阶段:通过工具查询用户身份、订单信息。
- 方案决策阶段:根据规则或另一个模型决定处理方案。
- 回复生成阶段:生成自然的回复文案。
如果你把 20 个工具全部塞给一个 Agent,让它自行决定所有事情,模型会频繁出错,而且排查链路问题极其痛苦。正确的做法是把链路拆成多个 Agent,每个 Agent 只负责一个阶段,阶段之间通过结构化的数据流转。这就构成了所谓的“Agent 流水线”。
之前有同事问过我:LangChain4j 里有 Agent 流水线的概念吗?严格说,LangChain4j 主库里没有像 LangGraph 那样的显式“图编排”模块,但你可以用 Java 的 CompletableFuture、Spring 的异步任务,或者自己写一个简单的状态机来编排多个 AiServices 实例。这根本不是什么缺陷,因为流水线的本质是把流程显式化,用的是你日常工程能力,而不是某个库提供的魔法。
4.2 与 Dify 知识库流水线的思路对比
提到流水线,很多人自然会想到 Dify 里的“知识库/Agent 工作流”。Dify 提供的是图形化编排界面,节点有 LLM、知识检索、工具调用、条件分支等。你在 Dify 里搭一条流水线,本质上也是在定义“阶段化的处理链路”,和我在 Java 侧写编排代码做的事一样。
两者的区别在控制粒度上。Dify 上手快、调试可视化强,适合产品同学或后端快速搭原型;但你很难把 Dify 的节点编排跟已有的 Java 服务无缝集成,性能调优、自定义逻辑、灰度发布都是痛点。LangChain4j 这边,流水线完全在你的代码里,你可以用 Spring 的事务、缓存、限流、监控等手段把整条链路纳入既有的治理体系。
如果你正在做 To B 项目,我的建议是:知识库检索这类相对固定的链路适合用 Dify 拉起来快速验证,涉及核心业务流程的 Agent 编排尽量在自己代码里做,也方便跟内部系统做深度集成。当你听到“多路召回”这个词时,对应的就是 Dify 里的多个知识检索节点合并出结果,LangChain4j 里你同样可以启动多个 embedding 召回。LangChain4j 里多路召回的常见做法是定义多个串并联的 retriever,比如 BM25 一路、向量检索一路、倒排索引一路,最后合并排序。没有现成的“多路召回节点”可以拖拽,但写起来也不过是几个方法的事。
4.3 流水线中的状态传递与失败重试
流水线最容易被轻视的就是状态传递。多个 Agent 串联时,上一个 Agent 的输出要成为下一个 Agent 的输入。这里的“输出”不能是自然语言,一定要是结构化对象。
我给你看一个反例。第一个 Agent 负责“抽取用户意图”,返回一段话“用户想要退换货”。第二个 Agent 拿这句话去查退款规则,结果模型理解出现偏差。正确做法是第一个 Agent 用结构化输出,直接返回一个IntentResult对象,字段明确:intentType、targetOrderId、confidence。第二个 Agent 按对象字段去动作,基本不存在理解偏差。
record IntentResult(String intentType, String targetOrderId, double confidence) {} // 结构化输出的定义方式 interface IntentExtractor { @SystemMessage("你是意图识别引擎,请抽取用户请求中的意图和涉及的订单号") IntentResult extract(String message); }失败重试在流水线里也是一个被反复讨论的点。我的建议是分三层处理:
- 第一层:工具调用失败(网络超时、接口 500)。工具方法内部做好异常捕获,返回错误 JSON,由模型决定是继续尝试还是换方案。
- 第二层:Agent 循环达到最大迭代次数。这是 LangChain4j 默认支持的参数,设置为 5~10,设置太小容易完不成任务,太大浪费 token 且可能陷入死循环。
- 第三层:流水线整体失败。在编排层用 try-catch + 降级策略,比如走人工客服兜底。
4.4 并发场景:Agent 怎么扛住业务压力
热搜词里有一个很有意思的问题:“AI Agent 怎么扛并发”。这其实是两个问题:模型接口的并发能力,以及 Agent 内部无状态性。
先说模型接口。你调用的是外部模型服务,对方有 QPS 限制。Agent 每一步工具调用都是模型调用,链路越长,消耗配额越大。所以并发上来了,第一个瓶颈往往不是你的 Java 服务,而是模型 API 的 rate limit。应对方式也很朴素:限流 + 缓冲 + 多模型散流。在客户端层面用 Resilience4j 或者 Sentinel 做限流降级,高峰期让请求排队而不是直接报错。
更核心的是 Agent 本身要保持无状态。如果要用 ChatMemory,绝不能用一个全局静态变量存所有用户的消息,那会变成一场灾难。我在生产环境里用 Redis 做 ChatMemoryStore,按 sessionId 隔离,每个用户的对话历史互不干扰。
public class RedisChatMemoryStore implements ChatMemoryStore { private final RedisTemplate<String, String> redisTemplate; @Override public List<ChatMessage> getMessages(Object memoryId) { // 从 Redis 读取并反序列化 } @Override public void updateMessages(Object memoryId, List<ChatMessage> messages) { // 写入 Redis } }另外,工具方法本身要设计成线程安全的。@Tool 标记的方法通常会被并发调用,如果你的工具内部持有可变状态(比如一个计数器、一个 list),务必加锁或改成 ThreadLocal。否则一旦流量进来,大概率出现诡异的数据错乱。
5. 完整示例:一个日程助手从 @Tool 到 Agent 流水线
5.1 需求定义
为了让上面的理论落地,我设计一个完整场景:个人日程助手。它需要做三件事:
- 查询当前日期时间。
- 查询用户日程列表(按日期过滤)。
- 自动为指定时间创建日程。
用户的消息可能是“明天上午十点有没有会”、“帮我在周五下午两点安排一个产品评审”。
换一个复杂一点的:用户说“我最近太忙了,帮我看看这周哪天下午有空,安排一次一小时的产品评审”。这就必须走流水线了:先查本周日程列表,查找空挡,再创建日程。如果一切顺利,返回确认信息。
5.2 工具层:三个 @Tool 的实现
public class CalendarTools { @Tool("获取当前日期和时间,格式 yyyy-MM-dd HH:mm:ss") public String now() { return LocalDateTime.now() .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); } @Tool("查询日期范围内的日程列表,返回 JSON 数组,字段包括 title、startTime、endTime") public List<ScheduleItem> listSchedules( @Parameter("开始日期,格式 yyyy-MM-dd") String startDate, @Parameter("结束日期,格式 yyyy-MM-dd") String endDate) { return scheduleRepository.findBetween(startDate, endDate); } @Tool("创建日程。如果该时间段已有日程则返回冲突信息,否则创建成功返回日程详情") public ScheduleItem createSchedule( @Parameter("日程标题") String title, @Parameter("开始时间,格式 yyyy-MM-dd HH:mm") String startTime, @Parameter("时长(分钟)") Integer durationMinutes, @Parameter("可选的说明备注") String note) { ScheduleItem created = scheduleRepository.create(...); return created; } }注意我的描述写法:每个工具的@Tool文字我都会写清“输入要什么格式、返回什么内容以及关键业务规则”。特别是createSchedule,我特意写了一句“如果该时间段已有日程则返回冲突信息”。这句话很重要,它会让模型把这个风险考虑进去,而不是愣头愣脑直接创建。
5.3 Agent 组装与流水线串联
先定义接口:
interface ScheduleAssistant { String chat(String userMessage); }ScheduleTools scheduleTools = new ScheduleTools(); ScheduleAssistant assistant = AiServices.builder(ScheduleAssistant.class) .chatLanguageModel(chatModel) .tools(scheduleTools) .chatMemory(MessageWindowChatMemory.withMaxMessages(30)) .build(); String answer = assistant.chat("帮我看看这周哪天下午有空,安排一次一小时的产品评审");这条链路内部会发生什么?我跟你推演一下典型轨迹:
- 模型收到用户请求,判断需要依次调用
now和listSchedules。它可能先调now确认今天是星期几。 - 工具返回当前时间。模型再次调用
listSchedules,参数覆盖本周。 - 返回本周日程列表 JSON。模型比对后,找出某个下午的空挡。
- 模型调用
createSchedule,传入标题“产品评审”和推算出的开始时间。 - 返回创建成功的信息。模型组织最终自然语言回复用户。
整个过程对调用者完全透明,你只面向一个chat方法。
当时我用这组工具测试了不少用例,发现一个高频现象:模型在createSchedule的参数里算错时间,比如把“本周五下午两点”算成了过去的时间点。这不是 LangChain4j 的问题,是模型时间推理能力本来就一般。解法是增加一个辅助工具,专门计算“某日某时刻”或“从当前时间起 N 小时后”,把时间计算从模型推理中剥离出来。
5.4 验证结果与调参
调参是所有 Agent 项目里最耗时的一件事。我依经验列出几个优先级的调整项:
- 模型选择:优先选支持 function calling 的模型,比如 OpenAI 的 gpt-4o 或国产几个支持工具调用的模型。如果模型本身不支持 tool call,LangChain4j 会降级为提示词驱动的 JSON 输出,效果差很多。
- Temperature 调低:工具调用场景我基本都设置成 0.1~0.3。温度太高,模型会在工具选择上“发挥创造力”,不是好事。
- 工具描述精修:每次看到工具被误调用,不要急着改代码,先想想是不是描述有歧义。
- 最大迭代次数:默认值通常偏大,建议设成 5。如果你发现任务经常完不成,再往上加,同时结合日志观察是卡在哪一步。
很多团队把 Agent 项目做失败,不是模型不行,而是这些参数没有校准。把这些调一遍,效果往往比换更强的大模型还要明显。
6. 实测中遇到的坑与排查清单
6.1 模型就是不调用我的工具
最经典的问题,没有之一。模型宁可瞎编,也不调用你的工具。通常原因有三类:
- 模型不支持 function calling,或者当前用的模型服务商没有开通这个能力。先确认这一点,别在代码里找原因。
- 工具描述不清晰。模型看不懂这个工具到底什么时候该用、参数怎么填。把描述改成一个具体场景,比如“当用户想查天气时使用”。
- System Prompt 把话说死了。你让模型“只做翻译”,它自然不会主动调工具。System Prompt 里要给工具调用留出空间。
6.2 工具参数解析失败
模型有时会把整数传成字符串,甚至把日期格式完全填错。LangChain4j 在解析失败时会抛异常,连带整个对话失败。遇到这个问题的应对重点是在工具方法内部做宽容处理。
我写过一套很实用的工具方法基类,内部统一做参数清理:把字符串两侧空白去掉、处理空值、日期格式用多个 pattern 轮询解析、数字用NumberFormat兜底。这样即使模型传入“2024年6月1日”这种格式,我也能解析成功。代码层面,你可以在工具方法里手动处理,不需要依赖框架的“强转换”。
6.3 Agent 陷入死循环
表现是:模型反复调用同一个工具,结果不变,然后继续调用。这种情况的典型原因是工具返回值无法让模型形成有效的下一步判断。排查方法是在日志里打开工具调用记录,看模型每一轮的工具入参和返回。
我的规避办法有两层。一是设置最大迭代次数兜底。二是工具方法内部做幂等保护:比如创建日程时,如果相同标题、相同时间的日程已存在,直接返回“已存在”,而不是每次创建一个新的,避免模型自己给自己制造新问题。
6.4 并发环境下的记忆错乱
生产环境最常见的坑。你如果只用了MessageWindowChatMemory而没有给它配独立的ChatMemoryStore,那它就是一份进程内内存。多用户并发访问时,所有用户的对话都往同一个池子里塞,Session A 的消息混进 Session B 的上下文,模型的回答必然乱套。
正确的做法是使用带 memoryId 的机制。ChatMemoryProvider可以根据你的会话标识返回独立的 ChatMemory 实例:
ChatMemoryProvider provider = memoryId -> MessageWindowChatMemory.builder() .id(memoryId) .maxMessages(30) .chatMemoryStore(redisStore) .build();在AiServices组装时传入这个 provider,然后每次调用 Agent 时传memoryId(可能是用户ID或会话ID)。只要 ID 设计合理,并发隔离就没有大问题。
6.5 一个更隐蔽的安全问题
最后提一个安全性层面的坑。Agent 的工具链如果包含外部网络访问能力(比如一个“抓取网页内容”的 Tool),那它实际上成了 SSRF 的入口。攻击者可能通过构造提示词,诱导 Agent 去请求内网地址。在工具方法内部必须做目标地址的白名单校验或内网段拦截。这个问题经常被忽略,等你上线之后被安全团队扫出来就麻烦了。
这类问题本质上不是 LangChain4j 特有的,而是所有 Agent 平台都要面对的。但 Java 团队的开发者容易把 Agent 当成普通 API 来写,忘了把关。多加一层校验,总没坏处。
工具链的描述与数量规划、流水线的显式编排、记忆的隔离方案,这三件事做好,一个可用度比较高的 Agent 系统基本就立住了。很多团队做出来的 Agent 像玩具,缺的往往不是模型能力,而是工程细节。LangChain4j 把基础能力都给你准备好了,剩下的事情——准确率、稳定性、安全性——都需要你在自己的代码里填进去。我个人实测下来的体会是:这套东西用到生产环境完全可行,前提是你愿意花时间打磨那些“看起来不起眼”的工具描述、参数校验和异常兜底逻辑。