☰
Java 后端 AI Agent 工程实践:LangChain4j 的 @Tool、RAG 与多智能体流水线
2026/10/5 5:20:40 网站建设 项目流程

前阵子有个朋友问我:公司全栈 Java,老板让搞 AI Agent,网上教程翻来覆去全是 Python 的 LangChain、LlamaIndex,难道要把智能体服务整体用 Python 重写一遍?我当时给的答复是:如果你只想要个 demo,用 Dify 这类平台确实快;但如果是正经 Java 团队,LangChain4j 这套库其实能让你从 @Tool 到 Agent 流水线一路做完,全程不换栈。这篇文章就把这条路完整捋一遍:@Tool 注解怎么让模型调用你的方法,AiServices 怎么把工具、记忆挂载成可对话的智能体,RAG 多路召回怎么接进流水线,以及最后怎么用 Java 代码编排出多 Agent 协作的业务流程。

适合看这篇的人有两类:一是已经在用 LangChain4j 写基础 LLM 调用、但对 Agent 化改造还没下手的 Java 后端;二是被各种"Java 不适合做 AI"论调劝退、想验证一下工程可行性的技术负责人。后面的内容基本按我实际开发的路径来写,没有理论堆砌,全部是能拷贝到项目里改一改就跑的代码。

1. Java 团队做 Agent 的尴尬,和 LangChain4j 的破局点

先说大多数人遇到的实际场景。公司已有的业务系统是 Spring Boot + MySQL + Redis,服务部署在自建机房或云主机上。现在要接入大模型能力,最简单的做法是直接调 OpenAI 或国内大模型厂商的 HTTP 接口,用 RestTemplate 发请求,再把返回的字符串塞回页面。这种玩法做聊天机器人没问题,但一旦涉及"让模型根据用户意图调用咱们内部系统的接口",麻烦就来了。

最原始的方案是手写 JSON Schema,把每个接口的参数定义、描述、枚举值全部写成模型能看懂的结构,然后塞进 system prompt 里。模型返回一个 function_call 的 JSON,你再去解析、反射、调用本地方法。这么干几天之后你会崩溃:接口一多,Schema 维护就是噩梦;模型偶尔会给错参数类型;函数名稍微含糊一点它就选错工具。更别提还要自己管理多轮对话历史、向量检索、上下文压缩这些周边能力。

这也是为什么 LangChain4j 这几年的存在感越来越高。它的思路和 Python 生态的 LangChain 一致,但 API 设计完全面向 Java 开发者的习惯——注解、接口代理、链式 builder,甚至比 Python 版本更符合 Java 的静态类型直觉。你在方法上写一个@Tool注解,它自动把方法签名变成 JSON Schema;你用AiServices.builder()构建一个接口的代理实例,工具、记忆、RAG 组件全部通过 builder 挂进去。一套链路下来,确实能做到"一个库打全套"。

不过要提醒一点:LangChain4j 目前 API 变动仍然频繁,早期 0.x 版本和 1.x 版本的写法差异很大。网上大量教程还是 0.3x 时代的老代码,很多类名和 builder 方法已经改了。建议直接用 1.x 最新版,以官方文档的 API 名称为准。我这篇文章里的示例代码也按 1.x 的 API 风格来写。

2. @Tool 不是魔法注解:从方法签名到 LLM 函数调用意图的完整链路

2.1 LLM 不会执行你的代码,它只产生"调用意图"

理解 @Tool 之前,必须先搞明白一个核心事实:大模型本身不会执行任何代码。你给它一个函数的 JSON Schema,它只是在生成文本时,额外输出一段结构化的"我想调用某个函数,参数是这些"的内容。真正执行函数、拿到结果、把结果送回给模型的,是你的业务系统。

这个过程通常叫 Function Calling,中文常译为工具调用或函数调用。LangChain4j 的@Tool注解做的,就是把"方法签名 → JSON Schema"这个繁琐步骤自动化,并在模型返回调用意图后自动反射调用你的方法,把返回值塞回对话上下文。整条链路是这样的:

  1. 你定义带@Tool注解的方法,结构包括方法名、描述、参数名、参数描述。
  2. LangChain4j 启动时扫描这些注解,生成对应的 JSON Schema,随请求发给模型。
  3. 用户在对话中表达需求,模型判断"这个需求需要调用某个工具来完成"。
  4. 模型输出 function call 的 JSON,包含工具名和参数值。
  5. LangChain4j 解析 JSON,反射调用你对应的方法,拿到返回值。
  6. 返回值作为一条消息追加进对话历史,再次发给模型,模型根据结果组织最终回复。

这里最容易误解的地方是:有人觉得 @Tool 方法一定得放在被 AI 调用的那个 Agent 类里。实际上完全不需要。你可以把工具方法放在普通的 Service 类、Repository 类,甚至独立的工具类中,只要方法上有@Tool注解,并且这个对象被传入.tools(...),LangChain4j 就能提取到。

2.2 一个能直接跑的菜单查询工具

我用一个"内部订餐助手"的场景来演示。假设公司有个餐饮系统,员工问 AI "今天有什么川菜?" Agent 需要调用本地方法查询菜单数据库。下面是工具的写法:

import dev.langchain4j.agent.tool.Tool; import dev.langchain4j.agent.tool.P; public class MenuTools { private final MenuRepository menuRepository; public MenuTools(MenuRepository menuRepository) { this.menuRepository = menuRepository; } @Tool("查询今日菜单中指定分类的菜品列表,分类如:川菜、粤菜、甜品、饮品") public List<DishBrief> listDishes( @P("菜品分类") String category) { return menuRepository.findByCategory(category); } @Tool("根据菜品ID查询菜品详细信息,包括价格、辣度、剩余份数") public DishDetail getDishDetail( @P("菜品ID") Long dishId) { return menuRepository.findById(dishId); } @Tool("根据关键词搜索菜品,支持模糊匹配菜名或配料") public List<DishBrief> searchDishes( @P("搜索关键词") String keyword, @P(value = "最多返回条数", defaultValue = "5") Integer limit) { return menuRepository.search(keyword, limit); } }

这里有几个细节值得展开。

@Tool注解里的 description 直接决定模型对该工具的认知。描述里最好说清楚"这个工具是做什么的、什么场景用它、参数大概怎么填"。模型选工具时,本质上是拿用户的自然语言和你给出的工具描述做语义匹配。描述写得太笼统,比如只写"查询菜单",模型可能在有更具体工具可用时仍然选错;写得太啰嗦又会挤占上下文窗口。我一般控制在 20 到 50 个字,把关键约束(比如分类枚举、是否模糊匹配)放进去。

@P注解的 description 同样重要。模型从用户的话里抽取参数值时,就是靠这个描述来定位信息。举个例子,用户说"来一份辣一点的川菜",如果你的参数描述是"菜品ID",模型会一脸茫然,因为对话里没有 ID 信息;如果描述是"菜品分类,如川菜、粤菜",模型就能准确抽出"川菜"。参数描述本质上是给模型做的"信息锚点"。

返回值这块,我用的是DishBrief和DishDetail两个轻量 record,而不是直接把 JPA 实体扔回去。原因有两个:一是实体里常有createTime、updateTime、内部状态码这类对模型毫无意义的字段,白白占用 token;二是某些字段比如成本价、供应商联系方式,一旦被模型拿到,用户通过 prompt 注入可能套出来。工具返回值应当遵循最小暴露原则,只返回模型组织答案时真正需要的信息。

2.3 必填参数与可选参数的坑

Java 方法签名里有"必填"和"可选"的天然区分:基本类型(int,long,boolean)和final字段通常被视为必填,而Integer、String这类包装类型或带默认值的方法,LangChain4j 倾向于把它们判定为可选。我之前遇到过一个问题:工具方法是searchDishes(String keyword, Integer limit),我没给limit加描述,模型调用时就经常不传这个参数,导致 NPE。后来改成@P(value = "最多返回条数", defaultValue = "5") Integer limit,问题立刻消失。

还有一种情况:参数确实是可选的,但模型不如实传。比如查询接口支持status参数,不传就默认查全部。模型可能自作聪明地往描述里没有的值上猜,比如status="available",结果查出来是空。我现在的习惯是,所有可选参数都在@P的 description 里写明"不传默认XX",并且方法内部做一次兜底,null 或空字符串都落到默认行为。这样模型的自由度被约束在可控范围内。

再补充一个返回值类型的经验:如果工具方法的返回值结构复杂,模型在组织自然语言时反而容易出错,比如漏掉某个重要字段,或者把数字格式转错。最稳妥的做法是返回一个结构扁平的 record,或者干脆返回格式化好的 String。我在后面做 RAG 检索工具时就吃了这个亏,后面章节会细说。

3. AiServices 挂载工具与记忆:让 Agent 真正拥有"手脚"和"短期记忆"

3.1 手动拼 prompt 的老路与 AiServices 的代理机制

有了@Tool注解,下一步是怎么把这些工具挂载到一个"能对话的智能体"上。老写法是每次调用模型时,手动把工具列表塞进请求里,然后自己处理 messages 的历史累积。代码会变成一团乱麻:

List<ChatMessage> messages = new ArrayList<>(); messages.add(SystemMessage.from("你是订餐助手...")); messages.add(UserMessage.from(userInput)); // 还要自己维护历史、自己传工具定义、自己解析function call...

LangChain4j 的AiServices解决的就是这个问题的抽象层。它的核心思路是:你定义一个 Java 接口,接口里的方法就是智能体对外暴露的对话入口;AiServices.builder()接收这个接口,返回一个代理实现。所有工具调用、记忆管理、上下文组装都在代理内部完成,代码层面你只管调用接口方法。

import dev.langchain4j.service.AiServices; import dev.langchain4j.service.SystemMessage; import dev.langchain4j.memory.chat.MessageWindowChatMemory; public interface Assistant { String chat(String userMessage); } // 构建智能体 Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(openAiChatModel) .tools(new MenuTools(menuRepository), new OrderTools(orderRepository)) .chatMemory(MessageWindowChatMemory.builder() .maxMessages(20) .build()) .build(); // 调用 String answer = assistant.chat("今天有哪些川菜?推荐一个辣的");

这段代码背后发生了什么?AiServices.builder()根据Assistant接口生成一个动态代理,把chat()方法的调用包装成一次完整的 LLM 对话:加载历史消息 → 加上 system prompt → 带上工具 schema → 发送模型 → 如果返回 function call 就执行工具 → 把结果回传模型 → 返回最终回复。同时,chatMemory会自动把每一轮用户消息和 AI 回复追加到内存窗口中,下一轮对话时自动带上。

你会发现,自己只写了一个接口和一个 builder,剩下的事情全靠框架完成。这就是"一个库打全套"的第一个体验:你不用关心 function call 的解析、消息历史的拼接、工具结果的回传,这些全部是标准路径。

3.2 记忆窗口不是越大越好

MessageWindowChatMemory是 LangChain4j 内置的滑动窗口记忆实现,按条数而不是 token 数截断。很多人上来就设maxMessages(100),觉得这样模型"记得多"。实际上窗口越大,单次请求的 token 越多,费用越高,响应越慢;更麻烦的是,如果用户和 Agent 之间夹杂了多次工具调用,这些工具调用产生的中间消息也会占用窗口。

我个人的经验值:纯对话类场景,maxMessages(10)到20就够用;需要连续多轮操作任务的场景(比如先查询菜品、再下单、再确认配送时间),可以放宽到30。超过这个量,不如引入摘要记忆——把更早的历史用模型压缩成摘要,塞进 system prompt,而不是把原始消息全量带着。

ChatMemory chatMemory = MessageWindowChatMemory.builder() .maxMessages(20) .build();

这里还有个小坑:MessageWindowChatMemory是单实例内存态,意味着如果你把它直接挂在单例 Assistant 上,所有用户共享同一个对话历史。这在开发环境无所谓,一旦上了生产就是灾难。用户 A 问完"我要点宫保鸡丁",用户 B 下一条消息里模型可能还记得 A 的上下文——因为 window 里存的是所有混合消息。解决办法放在第五章讲,这里先记住结论:生产环境必须按用户隔离记忆。

3.3 工具之间怎么"配合"

当 Agent 同时挂了多个工具对象时,LangChain4j 会把所有对象里带@Tool注解的方法全部提取出来,合并成一个工具列表交给模型。模型在单轮对话里可以连续调用多个工具,LangChain4j 会循环执行"模型返回调用 → 执行工具 → 把结果回传 → 模型继续决策"的过程,直到模型认为信息足够、生成最终回复。

实际跑下来最顺的场景是这样的:

用户:"我想吃辣的,预算 30 以内,帮我看看有什么推荐。"

模型内部决策过程大概会是这样:

  1. 调用searchDishes(keyword="辣"),拿到辣味菜品列表。
  2. 调用getDishDetail(dishId=...),查看其中几个菜的价格。
  3. 比对价格和预算,从结果中挑出合适的菜品。
  4. 组合成推荐语回复用户。

如果其中某个工具调用失败,LangChain4j 默认会把异常信息回传给模型,模型可能尝试换参数重试。这个机制有好有坏——好的一面是它能自愈,比如用户没说清楚分类,模型可以根据异常提示反问他;坏的一面是异常信息如果包含敏感堆栈,等于变相暴露内部结构。我建议工具方法内部做一层对外包装,任何异常都转成"用户能理解的中文描述",不要把SQLException、NullPointerException这类堆栈直接抛给模型。

4. 把 RAG 接进流水线:多路召回与 Easy RAG 的落地姿势

4.1 为什么单靠向量检索不够用

Agent 只有工具和记忆还不够,很多业务知识(比如公司制度、产品文档、历史客服对话)没法预先写死在 prompt 里,得靠 RAG 检索。但"RAG 检索"四个字背后有无数细节,其中最常见的问题是:单路向量检索的召回质量不够稳定。

举个例子。用户问"公司年假怎么休",如果 FAQ 库里正好有一条"年假休假制度",语义相似度很高,向量检索能命中。但如果用户问得比较口语化,比如"我入职一年能休几天",向量检索可能匹配不到精确答案,只召回一些相关度一般的片段。这种时候,如果能同时用关键词检索(比如 BM25)去抓"年假""休""入职"这些词,再和向量召回结果合并去重,整体命中率会明显提升。这就是关键词里出现"多路召回"的原因——不是炫技,是被单路检索的召回率逼出来的。

4.2 LangChain4j 的 RAG 组件拆解

LangChain4j 的 RAG 能力不是一个大而全的模块,而是由几个可替换的组件组合而成。核心包括:

  • EmbeddingModel:负责把文本转成向量,可以接 OpenAI 的 embedding 接口,也可以用本地模型。
  • EmbeddingStore:向量数据库的抽象,支持内存、PGVector、Redis、Milvus 等实现。
  • ContentRetriever:检索器,负责根据用户查询从 EmbeddingStore 里召回相关内容。
  • ContentAggregator/ContentInjector:对召回结果做合并、压缩、注入 prompt 的环节。

Easy RAG 的说法来自官方文档里的快速集成方式:你把 EmbeddingStore、EmbeddingModel 配好,用ContentRetriever绑定到 AiServices 上,它就会自动走"查询 → 向量召回 → 注入上下文 → 模型回答"的标准链路。

EmbeddingModel embeddingModel = OpenAiEmbeddingModel.builder() .apiKey(apiKey) .modelName("text-embedding-3-small") .build(); EmbeddingStore<TextSegment> embeddingStore = InMemoryEmbeddingStore .fromJsonFile("embeddings.json"); ContentRetriever retriever = ContentRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(3) .minScore(0.5) .build(); Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(chatModel) .contentRetriever(retriever) .build();

这段配置跑起来很容易,但真正到生产环境你会发现一个问题:默认的ContentRetriever只有一路向量召回,没有关键词召回,更没 rerank。如果知识库里条目很多、用户问题经常口语化,召回质量就全靠向量模型的质量和minScore设得够不够低。设得太高召回为空,设得太低又进来一堆噪声。

4.3 自己实现一个 CompositeRetriever 做多路召回

LangChain4j 的ContentRetriever是个接口,意味着你可以自己实现,把多路召回逻辑封装进去。我的做法是写一个CompositeRetriever:内部同时持有向量检索器和 BM25 检索器,召回后合并、去重、按融合分数排序。

import dev.langchain4j.rag.content.Content; import dev.langchain4j.rag.content.retriever.ContentRetriever; import dev.langchain4j.rag.query.Query; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; public class CompositeRetriever implements ContentRetriever { private final ContentRetriever vectorRetriever; private final ContentRetriever keywordRetriever; public CompositeRetriever(ContentRetriever vectorRetriever, ContentRetriever keywordRetriever) { this.vectorRetriever = vectorRetriever; this.keywordRetriever = keywordRetriever; } @Override public List<Content> retrieve(Query query) { List<Content> vectorHits = vectorRetriever.retrieve(query); List<Content> keywordHits = keywordRetriever.retrieve(query); Set<String> seen = new HashSet<>(); List<Content> merged = new ArrayList<>(); for (Content hit : vectorHits) { String key = hit.textSegment().text(); if (seen.add(key)) { merged.add(hit); } } for (Content hit : keywordHits) { String key = hit.textSegment().text(); if (seen.add(key)) { merged.add(hit); } } return merged; } }

这个类的实现逻辑很简单:先分别拿到两路结果,按文本内容去重合并,最后把合并后的列表交给模型。排序时向量检索的结果在前,关键词检索的结果兜底在后,这样既保留语义相关性高的片段,又不会漏掉关键词硬命中的内容。

BM25 那一路在 Java 生态里一般用 Lucene 或者 OpenSearch 的关键词查询来实现。如果你的知识库本身就存在 OpenSearch / Elasticsearch 里,那这路召回直接查它的match查询就行,不需要额外引入组件。多路召回的关键不在算法多高级,而在"多路覆盖不同的召回信号":语义信号、关键词信号、有时还有用户画像或热门程度的信号。召回之后,如果追求更高精度,再接一个 rerank 模型把最终 top K 排得更准。

4.4 RAG 的另一种姿势:把检索器封装成 @Tool

LangChain4j 里接 RAG 有两种姿势。上面那种是ContentRetriever标准流程,模型每次回答前自动检索、自动注入上下文。另一种是把检索能力封装成一个@Tool方法,让模型自己决定"什么时候需要查知识库、查哪类知识库、查完还要不要继续追问"。

第二种姿势更适合知识库数量多、需要模型判断检索来源的场景。比如公司内部有制度库、产品手册、历史工单三个知识库,模型每次回答前并不需要全查一遍,而是根据用户问题判断该查哪个。这种"按需检索"用标准 ContentRetriever 很难实现,但封装成工具就非常自然:

@Tool("查询员工手册中关于年假、病假、调休等假期的规定") public String searchPolicy(String question) { List<Content> hits = policyRetriever.retrieve(Query.from(question)); return formatHits(hits); } @Tool("查询产品使用手册中关于功能操作的问题") public String searchManual(String question) { List<Content> hits = manualRetriever.retrieve(Query.from(question)); return formatHits(hits); }

模型拿到这些工具后,会自动根据问题内容选择查哪个库。如果问题同时涉及制度和操作,它甚至会把两个工具都调一遍再综合回答。这种"RAG 工具化"的思路,比把所有内容塞进一个 EmbeddingStore 更可控,也更适合企业里知识库本来就按域划分的场景。

需要提醒的是,工具化 RAG 的返回格式非常重要。formatHits里我会把每段内容加上来源文档名、章节标题、页码,方便模型在回答时引用出处,也方便前端展示来源链接。

5. 多用户并发下的 Agent 沙盒:记忆隔离与虚拟线程实践

5.1 记忆串线是生产事故级的 bug

前面提过MessageWindowChatMemory是单实例内存态,直接共用会导致用户 A 和用户 B 的上下文搅在一起。这在 Agent 场景里比普通聊天更致命:因为工具调用是有状态的——用户 A 让 Agent 查了菜品并下了单,如果记忆里混入了用户 B 的消息,模型可能误以为 B 也要下单,或者把 A 的订单信息告诉 B。

正确做法是按用户隔离记忆,LangChain4j 提供了ChatMemoryProvider来做这件事。它本质上是一个工厂,根据 memoryId 返回不同的 ChatMemory 实例:

import dev.langchain4j.memory.ChatMemory; import dev.langchain4j.memory.chat.MessageWindowChatMemory; import dev.langchain4j.service.AiServices; import java.util.concurrent.ConcurrentHashMap; public class PerUserChatMemoryProvider implements ChatMemoryProvider { private final ConcurrentHashMap<Object, ChatMemory> memories = new ConcurrentHashMap<>(); @Override public ChatMemory get(Object memoryId) { return memories.computeIfAbsent(memoryId, id -> MessageWindowChatMemory.builder() .maxMessages(20) .build()); } } Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(chatModel) .tools(menuTools, orderTools) .chatMemoryProvider(new PerUserChatMemoryProvider()) .build();

调用时就要把用户 ID 传进去:

String reply = assistant.chat(userId, "帮我看看有什么川菜");

这里chat()方法是我在Assistant接口里额外声明的带 memoryId 的重载方法。AiServices能识别出带有@MemoryId参数的方法签名:

public interface Assistant { String chat(@MemoryId String userId, String userMessage); }

@MemoryId注解的作用是告诉 AiServices:对话历史按这个参数的值来隔离。用户 ID 不同,各用各的 ChatMemory 实例,互不干扰。生产环境强烈建议用这个方案,而不是自己写 ConcurrentHashMap 然后每次手动拼记忆。

还有一个细节:ConcurrentHashMap会一直持有所有用户的记忆,用户量大了会占内存。需要给每个用户的记忆加过期清理,比如基于 LRU 或者定时任务,把超过 N 小时不活跃的用户记忆释放掉。这个不做的话,跑几个月内存就爆了。

5.2 虚拟线程处理高并发对话

Java 21 之后,处理大量并发对话用虚拟线程几乎是天然的选择。Agent 调用模型的耗时通常在几秒到几十秒,期间线程大部分时间在等待网络 IO,用平台线程扛会很快耗尽线程池。虚拟线程则完全没这个压力。

ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); List<CompletableFuture<String>> futures = userIds.stream() .map(userId -> CompletableFuture.supplyAsync( () -> assistant.chat(userId, "帮我推荐一款适合下午茶的甜品"), executor)) .toList(); List<String> replies = futures.stream() .map(CompletableFuture::join) .toList();

这里要注意CompletableFuture的默认线程池是 ForkJoinPool.commonPool,不适合做 IO 密集型任务,所以一定要传自定义 executor。用虚拟线程的好处是,每个用户请求都能分到一个轻量线程,系统能同时处理的对话数大幅提升,而不会像平台线程那样受限于几百个的线程数上限。

不过虚拟线程并不是银弹。如果线程里跑的是 CPU 密集计算(比如向量重排、本地 embedding),虚拟线程反而可能因为竞争导致吞吐下降。这类任务应该丢到专门的固定线程池里。原则上:IO 等待用虚拟线程,CPU 计算用固定线程池。

5.3 熔断、限流与超时兜底

Agent 化之后,你的服务对模型 API 的依赖会成倍增加。一次对话可能触发三四个工具调用,每个工具调用又是一次模型请求。这意味着模型 API 的 rate limit 很快就可能被打爆。

我实际处理方案分三层。第一层是应用层限流:每个用户每分钟最多 N 次对话请求,用令牌桶或简单计数器实现,超出的请求返回"请稍后再试"。第二层是超时控制:调用模型和工具的整个链路设置总超时,比如 30 秒。超过就中断,返回兜底话术"暂时无法处理,请稍后重试"。第三层是熔断:连续 N 次模型 API 调用失败,打开熔断开关,直接降级为不调用模型、返回固定提示,避免雪崩。

String reply; try { reply = CompletableFuture.supplyAsync(() -> assistant.chat(userId, prompt), executor) .get(30, TimeUnit.SECONDS); } catch (TimeoutException e) { reply = "我的处理超时了,请换一种问法试试。"; } catch (Exception e) { reply = "当前服务不稳定,请稍后再试。"; }

顺便说一句,工具调用的超时一样要控制。如果某个工具方法内部调外部接口很慢,整个 Agent 响应会被拖死。我一般给每个工具方法内部加自己的超时,比如数据库查询 3 秒超时、外部 HTTP 调用 5 秒超时,宁可不查也不阻塞整个链路。

5.4 工具权限的最小化原则

Agent 有了工具就相当于有了"手",能直接操作系统、操作数据。如果工具权限设计得不好,用户完全可以通过 prompt 注入让 Agent 帮他执行非授权操作。比如订餐系统里,普通员工如果能让 Agent 调用"修改菜品价格"的工具,那事情就大了。

我习惯在每个工具方法上加断言,判断当前调用方是否有权限。userId从@MemoryId参数进来,工具方法里再根据 userId 查角色,权限不够直接拒绝。不要相信模型帮你做权限判断——模型只会根据对话内容决定调不调工具,它不负责鉴权。工具执行前的权限校验必须落在你的代码里,这是不能省的安全底线。

另一个容易被忽略的点是:不要把删除类、修改类工具和查询类工具混在一个对象里交给模型。模型对"删除订单"和"查询订单"的边界判断并不总是可靠的。更稳妥的做法是,不同角色的用户挂载不同的工具集合,员工端 Agent 只挂查询工具,管理员端 Agent 才挂修改工具。这样即使模型判断失误,底层也没有执行路径。

6. 从单 Agent 到流水线:多个 AiServices 是怎么协作出一整套业务流程的

6.1 流水线不等于一个 Agent 干所有事

很多人以为"Agent 流水线"就是把一个 Agent 做得无比强大,什么工具都挂上,什么问题都能解决。实际开发中这恰恰是灾难的开始。工具一多,模型的选择空间就大,误选、漏选、来回试探的概率跟着飙升。更麻烦的是,如果你的业务流程有严格的先后顺序(比如先查库存、再锁库存、再下单、再通知仓库),让模型自己控制顺序根本不可靠,它可能跳过某一步。

"流水线"在这里的正确理解是:把一次复杂的用户请求拆分成多个阶段,每个阶段由一个职责单一的 Agent 处理,阶段之间的流转由 Java 代码控制,而不是让 LLM 自由发挥。模型只负责它擅长的事——理解意图、抽取信息、生成语言;流程控制、状态流转、数据一致性由你的代码保证。

6.2 一个完整的订餐流水线拆解

我用订餐场景做一个流水线的例子。整个流程分四个阶段:

  1. 意图识别 Agent:判断用户是想查看菜单、下单、还是催单。
  2. 信息收集 Agent:从对话中抽取菜品、数量、配送地址等结构化信息。
  3. 订单执行服务:调用业务接口完成下单、扣库存,不需要 LLM 参与。
  4. 结果回复 Agent:把订单结果转成自然语言反馈给用户。

第一阶段和第二阶段的 Agent 可以共享同一个大模型,但挂载的工具完全不同。意图识别 Agent 甚至可以不挂工具,因为它的输出就是分类标签。

// 第一阶段:意图识别 public enum IntentType { QUERY_MENU, PLACE_ORDER, CHECK_ORDER, CHITCHAT } public interface IntentAgent { @SystemMessage("你是一个意图识别器。只输出以下枚举值之一:QUERY_MENU、PLACE_ORDER、CHECK_ORDER、CHITCHAT。不要输出其他内容。") IntentType classify(String userMessage); }

这里有个技巧:让模型输出枚举值时,直接把枚举名写死在 system prompt 里,并要求"只输出枚举值"。LangChain4j 会尝试把模型输出映射为枚举实例,如果模型不按规矩输出,可以用@UserMessage加 few-shot 示例来约束。

public interface IntentAgent { @SystemMessage("...") @UserMessage(""" 用户说:我要一份宫保鸡丁,少辣。 意图:PLACE_ORDER 用户说:今天有什么汤? 意图:QUERY_MENU 用户说:{{userMessage}} 意图:""") IntentType classify(@UserMessage String userMessage); }

放在@UserMessage里的模板会提示模型按照示例格式输出,实际效果比单纯在 system prompt 里强调"只输出枚举"稳定很多。这个模式我在多个项目里用过,准确率通常在九成以上。

第二阶段的信息收集 Agent 挂的工具主要是"菜单查询"和"订单模板校验"。它要做的事是从用户语句里抽出菜品名、数量、备注,必要时调用工具确认菜品是否存在、是否有货。然后流程进入第三阶段,这一步是纯代码:

// 第三阶段:业务执行,不经过LLM OrderDraft draft = OrderDraft.builder() .dishName(intentResult.getDishName()) .quantity(intentResult.getQuantity()) .note(intentResult.getNote()) .build(); OrderResult result = orderService.createOrder(draft);

最后第四阶段,把OrderResult交给一个"结果回复 Agent",让它基于结构化结果生成一段自然语言回复,比如"您的宫保鸡丁已下单,预计 30 分钟送达,订单号是 20250312xxxx"。

6.3 编排器是流水线的骨架

四个阶段串起来,需要一个编排器。它的职责是:接收用户消息 → 调意图识别 → 按意图走不同分支 → 收集信息 → 执行业务 → 生成回复。整个过程用 Java 代码写清楚,LLM 不参与流程决策。

public class OrderPipeline { private final IntentAgent intentAgent; private final InformationAgent infoAgent; private final OrderService orderService; private final ReplyAgent replyAgent; public String execute(String userId, String userMessage) { IntentType intent = intentAgent.classify(userMessage); return switch (intent) { case QUERY_MENU -> handleQueryMenu(userId, userMessage); case PLACE_ORDER -> handlePlaceOrder(userId, userMessage); case CHECK_ORDER -> handleCheckOrder(userId, userMessage); case CHITCHAT -> "我是订餐助手,可以帮您查菜单、下单、查订单。"; }; } private String handlePlaceOrder(String userId, String userMessage) { OrderDraft draft = infoAgent.extractOrder(userMessage); OrderResult result = orderService.createOrder(draft); return replyAgent.reply(userId, result); } }

这个编排器看起来很朴素,但它保证了业务流的确定性:用户说下单,代码一定走"收集信息 → 创建订单 → 回复"这条链路,中间不会因为模型的自由发挥而跳出流程。LLM 的灵活性集中体现在意图识别和信息抽取两个点上,恰恰是它擅长的部分。

我见过一些团队一上来就追求"Agent 自主规划全流程",结果上线后经常出现模型跳过支付验证、漏掉库存检查这类问题。把稳定部分放在代码里、语义理解部分留给模型,是我踩坑后的核心结论。所谓"Harness"和"Agent"之争也在于此:Agent 负责感知和决策,Harness 提供执行环境和流程边界,LangChain4j 里没有一个完整的 Harness 抽象,但用 Java 编排器完全可以实现同样的效果。

6.4 流水线里的状态管理

多 Agent 协作时,状态管理需要注意。每个 Agent 是一个独立的 AiServices 实例,它们之间不共享对话记忆。如果第二阶段的信息收集 Agent 需要知道第一阶段已经识别出用户想下单,就得把上下文通过参数传过去,而不是指望它"记得"上一轮。

我的做法是定义统一的上下文对象,在流水线各阶段之间传递:

public class OrderContext { private String userId; private String originalMessage; private IntentType intent; private OrderDraft draft; private OrderResult result; }

OrderContext在编排器里创建,每个阶段从里面取需要的输入、写入自己的输出。这样做的好处是,每个 Agent 保持无状态,流水线天然可重跑、可观测、方便加日志和 trace。如果你想做更复杂的图编排(一个 Agent 的结果分发给另外两个 Agent),也能在这个基础上扩展,只是别让 Agent 之间直接互相调用,容易把依赖搞成乱麻。

7. 上线前必须避开的 5 个坑(以及我是怎么修的)

7.1 工具方法名冲突,模型选错工具

当工具数量超过 10 个,模型偶尔会搞混名字相近的工具。比如getOrderStatus和getOrderDetail,模型可能用错参数或选错工具。规避办法是命名时加上业务前缀,比如order_status、order_detail,别用太泛的query、get。同时把工具描述写得差异化,让模型能通过描述精确区分。

7.2 工具返回了大量无关字段,把上下文撑爆

第一次把 JPA 实体直接作为工具返回值的开发,几乎都会踩这个坑。实体有二十多个字段,模型一遍遍把全部字段读进来,几分钟对话就把上下文窗口塞满了。我现在的规范化做法是:为每个工具定义专用的 record 作为返回类型,只保留模型需要的最小字段;如果数据量很大,优先返回摘要而非明细,让模型在需要时再通过另一个工具查详情。这既能省 token,也能避免无关数据泄露。

7.3 模型把工具异常信息原样读走

前面提到工具方法内部异常会回传给模型。有次我的某个 Agent 工具直接抛出了数据库连接异常的原生堆栈,模型在回复时居然把连接池参数也复述了出来。从那以后,所有工具方法出口统一 try-catch,对外只暴露"查询失败,请稍后重试"或"菜品不存在,请更换名称"这类中性描述。内部堆栈只进日志,不进对话上下文。

7.4 记忆窗口和系统提示词互相挤占

MessageWindowChatMemory的窗口条数上限,包含系统消息和工具调用产生的中间消息。如果 system prompt 很长,再加上几轮工具调用,模型实际能看到的用户历史对话可能很少。排查手段是打开 LangChain4j 的请求日志,看每次请求实际发送的消息条数和 token 数。如果发现工具调用消息占了大部分窗口,可以把工具调用结果转成精简摘要再放回上下文。

7.5 并发场景的共享状态污染

除了用户记忆要隔离,工具对象本身也要注意线程安全。如果你的工具类里有SimpleDateFormat或非线程安全的缓存字段,在虚拟线程并发调用下会出各种诡异问题。我习惯让工具类保持无状态,所有依赖通过构造器注入,方法内部只操作局部变量。有状态的地方(比如用户临时购物车)显式放到 ConcurrentHashMap 之类并发容器里,按 userId 隔离。

8. 最后说点实在话

LangChain4j 给我的感觉是"Java 生态里终于有一套能认真做 Agent 的框架了"。它不完美,API 还在快速演化,部分模块(比如复杂编排、Graph 流控)需要自己用代码补齐,但核心链路确实做到了一个库覆盖:模型接入、工具调用、记忆管理、RAG、多轮对话,到我这篇文章里的多 Agent 流水线,没有引入第二个重量级框架。

如果你正准备从零开始做 Agent 化改造,我建议按这个顺序落地:先只写 2 到 3 个@Tool,用AiServices跑通单 Agent 对话;然后加上记忆、按用户隔离;再往前走再接 RAG,先单路召回跑通,再逐步加多路和 rerank;最后才考虑拆流水线。步子不迈太大,每层都验证稳定了再往上叠。这套路线我实际走下来,比一上来就想搞"全自动多 Agent 编排"靠谱得多。

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

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

立即咨询