1. 为什么我最终把整套 Agent 流水线压进了 LangChain4j
1.1 从一次“工具越写越乱”的真实翻车说起
去年下半年我接手一个企业内部知识助手项目,需求听起来不复杂:能查内部文档、能调几个业务接口、能根据用户问题自动决定要不要检索知识库。最开始我的做法很朴素,用最原始的 HTTP 客户端加手写 prompt 拼接,工具调用靠正则匹配模型输出里的 JSON。前两周跑得挺顺,第三周开始崩:模型偶尔把工具名写错、参数少一个字段、连续调用两个工具时上下文丢失、并发一上来日志里全是解析失败。
那段时间我每天的工作就是给正则打补丁。后来我意识到问题不在模型,而在我把“工具定义、调用协议、上下文管理、结果回填”这些本该由框架兜底的事情全自己扛了。于是我开始认真评估 Agent 框架,最后落到 LangChain4j 上。原因很直接:我的主技术栈是 Java,团队没人愿意为了一个助手项目再维护一套 Python 服务,而 LangChain4j 把@Tool注解、Agent 编排、RAG 检索这几块能力都做进了同一个库,一个依赖就能打全套。
这篇内容我想聊的不是“LangChain4j 是什么”,而是一个 Java 后端怎么从零把@Tool用到 Agent 流水线,再把 RAG 接进来,最后扛住并发。适合已经写过一点大模型调用、但工具一多就乱、RAG 一上就慢的同学。全程按我实际项目的落地顺序讲,参数和踩坑都会给到。
1.2 先厘清几个容易混的概念,不然后面全乱
热词里有一堆看着像但完全不同的词:Agent、Agentic、RAG、tool、agent 框架、harness 和 agent 区别。我在团队内部分享时发现,很多人卡住不是因为不会写代码,而是概念没对齐,导致选型时把不同层的东西混在一起比。
我一般这么区分:
- Tool(工具):一个具体的能力单元,比如“查订单”“搜文档”“发邮件”。在 LangChain4j 里就是一个带
@Tool注解的方法。 - Agent(智能体):一个能自己决定“要不要调工具、调哪个、调几次”的执行体。它 = 模型 + 工具集 + 循环控制逻辑。
- Agentic(智能体化):一种设计风格,指系统具备自主规划、多步执行、自我修正的特征,不是某个具体组件。
- RAG(检索增强生成):在生成前先检索外部知识,把结果塞进上下文。它可以是 Agent 的一个工具,也可以独立于 Agent 存在。
- harness 和 agent 的区别:harness 更像“测试/驱动外壳”,负责给 Agent 喂输入、收集输出、做评测;Agent 是真正干活的主体。两者不是竞争关系。
把这些摆清楚之后,你会发现 LangChain4j 的定位很清晰:它同时提供了 Tool 抽象、Agent 编排、RAG 组件,所以你不需要在三个库之间来回倒腾。这也是标题里“一个库打全套”的真正含义——不是它什么都能干,而是这条链路上的关键环节它都覆盖了,省掉了胶水层。
2. 核心设计拆解:@Tool、Agent、RAG 到底怎么串起来
2.1 @Tool 注解背后的机制,别只当成语法糖
很多人第一次用@Tool会觉得它就是个标记,跟 Spring 的@Component差不多。其实它承担了三件事:描述暴露、参数 schema 生成、调用路由。模型看到的工具说明,就是从注解的value和参数类型推断出来的。
一个我实际项目里的工具长这样:
public class OrderTools { @Tool("根据订单号查询订单状态,返回状态码和预计送达时间") public OrderStatus queryOrder(@P("订单号,格式为 ORD 开头的12位字符串") String orderId) { return orderService.find(orderId); } }这里有两个细节值得说。第一,@Tool里的描述不是给人看的,是给模型看的,所以写法要像给一个新同事交代任务:说清楚“什么时候用、返回什么”。我见过有人写“查询订单”,模型经常在用户问“我的包裹到哪了”时不敢调,因为描述里没有“包裹/物流”这类语义线索。第二,@P注解的参数描述同样重要,模型生成参数时全靠它。参数格式、取值范围、示例,能写就写。
注意:工具方法的参数类型尽量用简单类型(String、int、枚举),复杂对象会让模型生成参数时出错率飙升。如果确实需要结构化输入,拆成多个简单参数,或者让模型先调一个“构造参数”的工具。
2.2 Agent 编排:为什么我放弃了手写 ReAct 循环
早期我自己写过 ReAct 循环:把工具列表拼进 prompt,让模型输出Thought/Action/Observation,然后解析、执行、回填、再循环。写出来不到 200 行,但维护成本极高——模型换个版本,输出格式就飘,解析逻辑就得改。
LangChain4j 的 Agent 抽象把这层接过去了。它内部维护了工具调用协议、多轮循环、最大迭代次数控制。我实际用的是AiServices配合工具类的方式,大致结构:
interface Assistant { String chat(String userMessage); } Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .tools(new OrderTools(), new KnowledgeTools()) .chatMemory(MessageWindowChatMemory.withMaxMessages(20)) .build();这段代码背后,框架会自动完成:把工具描述注入系统提示、解析模型的工具调用请求、反射调用对应方法、把结果作为工具消息回填、继续下一轮直到模型给出最终回答。我要做的只是定义工具和记忆策略。
为什么选它而不是自己写:一是协议稳定性,框架跟着模型 API 更新;二是记忆管理,MessageWindowChatMemory这种滑动窗口策略自己写容易出边界 bug;三是可观测性,框架留了监听接口,方便打日志。
2.3 RAG 在 Agent 里的两种接法,我踩过坑
RAG 接进 Agent 有两条路,我两种都试过:
第一种:把检索做成一个 Tool。模型自己决定要不要检索、检索什么关键词。优点是灵活,用户问“你好”时不会白白检索一次;缺点是模型可能该检索时不检索,尤其是问题里没有明显“查资料”信号时。
第二种:前置检索,把结果直接塞进上下文。每次请求先检索,再让模型基于检索结果回答。优点是稳定,缺点是浪费——闲聊也检索,而且检索质量差时反而干扰模型。
我最终采用的是混合策略:默认走工具式检索,但在系统提示里明确写“涉及内部政策、产品参数、流程规范的问题必须先调用知识库检索工具”。同时在检索工具内部做了一层判断,如果 query 太短或明显是寒暄,直接返回“无需检索”。这个判断逻辑很土但很有效:
@Tool("检索内部知识库,用于回答政策、流程、产品参数类问题") public String searchKnowledge(@P("检索关键词,尽量具体") String query) { if (query.length() < 4 || smallTalkPattern.matcher(query).find()) { return "该问题无需检索知识库"; } List<Content> docs = retriever.retrieve(query); return docs.stream().map(Content::text).collect(Collectors.joining("\n---\n")); }2.4 多路召回:热词里问得最多的一块
“langchain4j 多路召回”这个词搜索量很高,说明大家确实卡在这。单路向量检索的问题很明显:语义相似但关键词不匹配的文档召不回,专有名词、型号、编号这类内容向量模型经常抓瞎。
我的做法是向量召回 + 关键词召回并行,再融合排序。LangChain4j 本身提供了EmbeddingStoreContentRetriever,关键词那路我用数据库的全文索引或者简单的 BM25 实现,然后做 RRF(Reciprocal Rank Fusion)融合。RRF 的好处是不需要调权重,对两路分数尺度不一致的情况很鲁棒:
Map<String, Double> fused = new HashMap<>(); for (int i = 0; i < vectorResults.size(); i++) { fused.merge(vectorResults.get(i).id(), 1.0 / (60 + i + 1), Double::sum); } for (int i = 0; i < keywordResults.size(); i++) { fused.merge(keywordResults.get(i).id(), 1.0 / (60 + i + 1), Double::sum); }60 这个常数是 RRF 论文里的经验值,实测下来对大多数场景都够用,不用纠结。融合后取 Top-K 再送进模型。
实操心得:多路召回真正的瓶颈往往不在融合算法,而在两路召回的候选集大小。我一开始每路只取 5 条,融合后效果还不如单路。后来每路取 20 条再融合取 5 条,召回率明显提升。候选集要足够大,融合才有意义。
3. 完整实操:从零搭一条能跑的 Agent 流水线
3.1 依赖与模型接入,先把地基打稳
我用的构建工具是 Maven,核心依赖就两个:LangChain4j 核心包和对应模型提供方的集成包。版本上我建议锁定一个稳定版,别追最新,Agent 相关 API 在早期版本变动比较频繁。
<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>0.35.0</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-open-ai</artifactId> <version>0.35.0</version> </dependency>模型接入这块,我用的是兼容 OpenAI 协议的本地推理服务,配置方式:
ChatLanguageModel model = OpenAiChatModel.builder() .baseUrl("http://localhost:8000/v1") .apiKey("not-needed") .modelName("qwen2.5-7b-instruct") .temperature(0.2) .timeout(Duration.ofSeconds(60)) .build();参数选择理由:temperature设 0.2 是因为 Agent 场景要的是稳定决策,不是创意;工具调用时温度高了模型容易“发挥”,生成不存在的工具名。timeout给 60 秒是因为本地 7B 模型在长上下文下首 token 延迟可能到十几秒,设太短会频繁超时。
注意:如果你用的是需要工具调用能力的模型,务必确认该模型支持 function calling 或至少能稳定输出结构化内容。我试过几个不支持工具调用的小模型,框架会退化成“让模型输出 JSON 再解析”,稳定性差很多。
3.2 工具类的组织方式,别全塞一个类
工具一多,全塞一个类会变成几千行的怪物,而且模型看到的工具描述会互相干扰。我的组织原则是按业务域拆类:订单工具、知识库工具、通知工具各一个类,注册时按需传入。
Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .tools(new OrderTools(orderService), new KnowledgeTools(retriever), new NotifyTools(mailClient)) .chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(20)) .build();chatMemoryProvider这个用法很关键。单用户场景用chatMemory就行,但多用户并发时必须用 provider,按会话 ID 隔离记忆。我一开始没注意,所有用户共享一份记忆,结果 A 用户问的订单号出现在 B 用户的上下文里,差点出事故。
3.3 记忆策略:窗口大小不是越大越好
MessageWindowChatMemory.withMaxMessages(20)里的 20 指的是保留最近 20 条消息。这个数字我调过好几轮:设 10 时多轮任务容易丢上下文,设 50 时 token 消耗暴涨且模型开始被无关历史干扰。20 是我在“任务连续性”和“成本”之间的平衡点。
如果你的场景涉及很长的多步任务,可以考虑做摘要记忆:把早期消息压缩成一段摘要,只保留最近几轮原文。LangChain4j 提供了相关接口,但需要自己实现摘要逻辑,我一般用同一个模型做摘要,prompt 就一句“用三句话概括以下对话的关键信息”。
3.4 RAG 知识库的构建,切分比模型更重要
RAG 效果差,八成问题出在切分,不是 embedding 模型。我见过太多人上来就换更大的 embedding 模型,结果毫无改善,因为文档切得稀碎,语义单元都被切断了。
我的切分策略是按语义结构切,不按固定字数切。Markdown 文档按标题层级切,每个二级标题下的内容作为一个 chunk;PDF 按段落切,遇到表格单独处理。chunk 大小控制在 300 到 800 字之间,太短语义不完整,太长检索精度下降。
DocumentSplitter splitter = DocumentSplitters.recursive(500, 50);recursive分割器会优先按段落、句子边界切,500是目标 chunk 大小,50是重叠长度。重叠是为了防止关键信息正好落在切分点上被割裂。
实操心得:中文文档的 chunk 大小要比英文小一些。英文一个 token 约等于 4 个字符,中文一个汉字往往就是一个 token,所以同样 500 的配置,中文实际信息量更大。我中文场景一般用 300 到 400。
3.5 把 RAG 接进 Agent 的完整代码
把前面几块拼起来,一个能跑的 Agent 流水线大概是这样:
// 1. 构建检索器 EmbeddingStore<TextSegment> store = new InMemoryEmbeddingStore<>(); EmbeddingModel embeddingModel = new AllMiniLmL6V2EmbeddingModel(); EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder() .documentSplitter(DocumentSplitters.recursive(400, 50)) .embeddingModel(embeddingModel) .embeddingStore(store) .build(); ingestor.ingest(documents); ContentRetriever retriever = EmbeddingStoreContentRetriever.builder() .embeddingStore(store) .embeddingModel(embeddingModel) .maxResults(20) .minScore(0.6) .build(); // 2. 组装 Agent Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .tools(new KnowledgeTools(retriever), new OrderTools(orderService)) .chatMemoryProvider(id -> MessageWindowChatMemory.withMaxMessages(20)) .build(); // 3. 调用 String answer = assistant.chat("我们的退货政策对生鲜类商品是怎么规定的?");maxResults(20)配合minScore(0.6)是我实测比较稳的组合:先多召回,再用分数阈值过滤掉明显不相关的。阈值设太低会引入噪声,设太高会漏召回,0.6 是个不错的起点,具体要按你的 embedding 模型调。
4. 并发、性能与常见问题排查实录
4.1 AI Agent 怎么扛并发,这是绕不过去的坎
“ai agent 怎么扛并发”是热词里最实际的问题。Agent 比普通接口重得多:一次请求可能触发多轮模型调用、多次工具执行、多次检索。我压测过,单实例不加优化的情况下,本地 7B 模型 QPS 大概只有个位数。
我的优化顺序是这样的:
第一,模型调用层做限流和排队。用一个信号量控制同时进行的模型调用数,超出的请求排队而不是直接拒绝。信号量大小按你的推理服务承载能力设,我本地单卡设 4。
Semaphore modelSemaphore = new Semaphore(4); public String chatWithLimit(String msg) throws InterruptedException { modelSemaphore.acquire(); try { return assistant.chat(msg); } finally { modelSemaphore.release(); } }第二,检索层加缓存。相同或相似的 query 没必要重复检索。我用 Caffeine 做了一层 LRU 缓存,key 是归一化后的 query,TTL 设 10 分钟。命中率在真实流量下能到 30% 左右,直接省掉这部分检索开销。
第三,工具执行异步化。如果一次 Agent 循环里要调多个互不依赖的工具,可以并行执行。不过要注意,LangChain4j 默认是串行执行工具调用的,并行需要自己控制,且要小心模型对结果顺序的依赖。
第四,控制最大迭代次数。Agent 循环如果不设上限,模型可能陷入“调工具-不满意-再调”的死循环。我一般设maxIterations为 5 到 8,超过就强制返回当前结果。
4.2 常见问题速查表
下面这张表是我和团队实际遇到并解决过的问题,按出现频率排序:
| 问题现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 模型不调用工具,直接编答案 | 工具描述不清晰,或系统提示没强调 | 在工具描述里写清使用场景,系统提示加“涉及X类问题必须调用工具” |
| 工具参数生成错误 | 参数描述缺失或类型复杂 | 补全@P描述,参数改简单类型,给出格式示例 |
| 多用户上下文串了 | 用了共享 chatMemory | 改用 chatMemoryProvider 按会话隔离 |
| RAG 召回不相关 | chunk 切分不合理或阈值太低 | 调整切分策略,提高 minScore,加多路召回 |
| 响应特别慢 | 模型调用无限制、检索无缓存 | 加信号量限流、加检索缓存、减少 maxResults |
| 工具调用死循环 | 没设最大迭代次数 | 设置 maxIterations,并在提示里说明“信息足够就回答” |
| 长对话后答非所问 | 记忆窗口太大引入噪声 | 缩小窗口,或改用摘要记忆 |
4.3 几个只有踩过才知道的坑
坑一:工具方法抛异常会中断整个 Agent 循环。我一开始工具里直接抛业务异常,结果模型收到的是框架包装后的错误,经常理解不了。后来改成工具内部捕获异常,返回一句人类可读的说明,比如“订单号格式不正确,请确认后重试”,模型反而能据此引导用户。
坑二:embedding 模型和检索模型要匹配。换 embedding 模型后必须重新灌库,否则向量空间不一致,检索结果全是乱的。这个坑我在换模型时踩过一次,排查了半天才发现是旧向量没清。
坑三:系统提示里的工具说明和@Tool描述会叠加。如果两边都写得很啰嗦,会挤占上下文。我的做法是@Tool里写“怎么用”,系统提示里只写“什么情况下必须用”,各司其职。
坑四:本地小模型的工具调用能力参差不齐。7B 级别模型在工具数量超过 5 个时,选错工具的概率明显上升。如果工具很多,考虑做工具分组,或者用路由先判断该用哪组工具。
4.4 关于 RAG 的几个进阶方向
热词里还有“ontology rag”“kg 知识库和 rag 知识库区分”这类问题,说明大家开始不满足于纯向量检索。我的看法是:纯 RAG 适合非结构化文档问答,知识图谱适合关系推理和精确查询,两者不是替代关系。
我实际项目里做过一个混合方案:实体和关系类问题走图谱查询,开放性问题走向量检索,用一个轻量路由判断走哪条路。图谱那部分我用的是简单的三元组存储,没有上重型图数据库,因为业务关系不复杂。如果你的场景涉及大量实体关联推理,再考虑上专业图库。
至于“rag 知识库能存储图片嘛”,答案是能,但要看怎么用。图片本身不能直接进向量库,需要先用多模态模型生成图片描述,把描述文本入库,检索到后再把原图一起返回。我做过一个产品手册的场景,图片配文字描述入库,效果不错。
5. 我在这套流水线上的几点真实体会
从最初手写正则解析工具调用,到现在用 LangChain4j 把@Tool、Agent、RAG 串成一条流水线,最大的感受是:框架的价值不在于让你少写代码,而在于让你少写那些容易出错的代码。工具调用协议、记忆管理、循环控制这些地方,自己写能跑,但一到并发和边界情况就露馅。
另一个体会是,Agent 项目的成败往往不在模型,而在工程细节。工具描述写得好不好、chunk 切得合不合理、并发控制做没做,这些看起来“不 AI”的东西,才是决定线上能不能用的关键。我见过太多 demo 惊艳、上线就崩的项目,问题几乎都出在这些地方。
最后分享一个我一直在用的小技巧:给 Agent 加一个“调试模式”,把每一轮的模型输入、工具调用、工具返回、模型输出全打到日志里。排查问题时,这份日志比任何监控指标都管用。我甚至会在开发环境把这份日志渲染成一个可折叠的页面,一眼就能看出模型在哪一步走偏了。这个投入在项目后期回报极高,强烈建议一开始就做。