☰
用LangChain4j在Spring Boot中落地AI Agent:逃离Python舒适区的工程实践
2026/10/21 3:03:55 网站建设 项目流程

聊到 AI Agent,很多人第一反应还是 Python + LangChain。这套组合的上手体验确实没得挑:生态全、教程多、随便写几十行脚本就能接上某个大模型 API,跑出一个会“思考”的对话机器人。我自己就是从这条路走过来的,也一度很享受这种舒适区。真正把我踹出来的不是技术本身,而是它在工程化阶段暴露出来的问题——环境割裂、类型失控、和企业 Java 技术栈的体系格格不入。

我所在的团队在把 Agent 从原型推向生产时,连续碰了好几堵墙。后来我抱着试试看的心态,用 LangChain4j 在 Spring Boot 里把同样一套 Agent 架构重写了一遍,结果发现这条“逃离 Python 舒适区”的路,比想象中值得走。这篇文章会完整讲清楚我为什么换栈、LangChain4j 到底比 LangChain 多做了什么、在 Spring Boot 里落地 Agent 架构时需要哪些关键组件,以及我踩过的那些文档里不会写的坑。如果你正在一个以 Java 为主的技术团队里做 AI 应用,或者已经被 Python 工程化折磨得头疼,这篇文章应该能帮你少走很多弯路。

1. 被“Python 舒适区”惯坏的 Agent 工程,到底卡在哪一环

1.1 舒适区是怎么形成的

我最早接触 AI 应用开发时,走的路线和绝大多数人一样:写一段 Python 脚本,直接调用大模型接口,先验证“能不能聊起来”,再逐步把工具函数、提示词模板、向量检索一层层叠上去。Python 的动态类型和交互式环境让这个过程近乎顺滑——你不需要在一开始就把接口定义清楚,变量传进传出都随性又自然,跑不通就在 Notebook 里改两行重新来。

LangChain 早期版本给人带来的也是同一种体验:Chain 可以随意拼接,提示词模板、检索器、模型像积木一样组合。这种设计在探索阶段几乎是完美的,因为它把“想法到代码”的时间压缩到了极限。也正是这种体验,让很多人误以为 AI Agent 开发就该是这份自由的延续,直到某一天,系统要上线了。

1.2 三个让我决定换栈的现实问题

真正让我想逃离舒适区的,不是某个具体的 Bug,而是三个层面的系统性问题。

第一个是环境与依赖的割裂感。Python 的依赖安装方式在个人电脑上很顺,但换成团队协作、容器镜像、私有化部署时,pyenv、conda、pip 的组合经常拆了东墙补西墙。C 扩展在某些基础镜像上编译失败,numpy、tokenizers 这类带原生代码的包版本一变,行为就可能跟着变。Java 这边用 Maven/Gradle 声明依赖,JDK 版本用工具链统一管理,虽然第一次拉依赖的时间长一点,但“构建产物是什么就是什么”的确定性让人安心得多。

第二个是动态类型在系统变大后的失控。玩票阶段,一个人的代码怎么潇洒都行。但 Agent 项目一旦接入订单查询、权限校验、工单流转这类真实业务,代码量很快过万行,参与的人也从一个人变成一整个后端组。Python 的动态类型让“改一个函数签名,所有调用点跟着改”这件事完全依赖人的自觉,运行时 TypeError 在测试覆盖薄弱的 AI 项目里尤其容易漏过。Java 的接口、泛型和强类型检查,能把一大批低级错误挡在编译期。

第三个是企业级基础设施的融入成本。我所在团队的主技术栈是 Spring Boot:服务注册、配置中心、RPC 调用、全链路监控、灰度发布,都有现成的 Java 组件支撑。一个 Python 服务想融入这套体系,要么额外搭桥,要么在运维侧单独维护一套部署和监控链路,成本远高于在代码里多写几行。AI Agent 说到底也是业务系统的一部分,它需要和存量系统共享同一套治理能力。

1.3 换栈不是否定 Python,而是换一种生产方式

我到现在仍认为,Python 在研究、探索、算法验证方面的地位无可替代。写一页纸的公式推导、快速验证一个新的提示词思路,拿 Python 做实验比在 Java 里折腾半天高效得多。但“验证想法”和“交付系统”是两种不同的生产方式:前者追求低摩擦,后者追求确定性、可维护性、可观测性。我换栈,本质上是从追求低摩擦切换到了追求确定性,而不是对 Python 本身有什么偏见。

2. LangChain4j 不是“Java 版 LangChain”,是按 Java 习惯重写的一版

2.1 抽象层:用接口和构建器取代动态编排

如果你像我一样先熟悉了 LangChain,再来看 LangChain4j,会有一个很直接的感受:它没有尝试把 LangChain 的 Python 接口机械翻译成 Java,而是按 Java 社区的习惯重新设计了抽象。

最核心的几个概念对应关系大概是这样的。LangChain 里的 LLM 封装,对应ChatLanguageModel接口;消息体对应UserMessage、AiMessage、SystemMessage、ToolMessage这一组类型;记忆对应抽象的ChatMemory;而最值得注意的是AiServices——它用“一个普通接口 + 构建器”的方式组装 Agent,而不是用动态的 Chain 串起来。Java 接口天然能表达契约,调用方看到的不是一堆可以随意改动的对象,而是一个明确的方法签名。

LangChain 里的 LCEL 用管道符号构建调用链,靠运行时反射处理各类组件,这在 Python 里很自然。但 Java 的泛型擦除和类型系统决定了,完全复刻这一套会让代码变得难以调试。LangChain4j 的选择是:把“流程编排”降级为普通的 Java 代码,逻辑组合放在 Service 层,编译器帮你去掉一大半低级失误。

LangChain(Python)LangChain4j(Java)我感受到的差异
Chain / LCEL普通 Java 方法 + AiServices动态编排变成类型安全的组合
面向协议的模型封装ChatLanguageModel 接口模型访问收敛到统一抽象
记忆组件需要自己拼ChatMemory + ChatMemoryStore记忆边界更容易划清
工具函数任意传入@Tool 注解 + Spring Bean工具注册和依赖注入无缝衔接

2.2 与 Spring Boot 的整合:依赖注入让组件“长”在项目里

LangChain4j 官方提供了针对 Spring Boot 的 starter,它的设计思路不是“把 AI 能力包成一个黑盒 SDK 给你调用”,而是把 AI 组件当成一等公民纳入 Spring 容器。模型对象可以来自自动配置,也可以自己在配置类里定义 Bean;工具类只需要标上@Tool注解并注册为 Spring Bean,组装 Agent 时就能自动收集;会话记忆虽然是运行时状态,但存储部分也能交给 Spring 管理的 Bean。

这种设计的实际效果是:写一个 Agent 的组装过程,和写一个普通 Spring 应用的依赖装配几乎一样。你不需要学习一套新的“编排语言”,只要理解接口、注入、配置这三个 Spring 世界观里的基本动作就够了。团队里任何一个熟悉 Spring Boot 的后端工程师,都能很快接手这个项目继续开发。

3. 在 Spring Boot 里搭一个最小可运行的 AI Agent 工程

3.1 依赖与版本选择

我建议直接用 Spring Boot 3.x + Java 17 以上的组合。项目里加这么几个依赖:

<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>0.36.2</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-spring-boot-starter</artifactId> <version>0.36.2</version> </dependency>

版本号不用纠结于我写的这个,去 Maven Central 看一眼当前稳定版,选 0.3x 以上的版本一般都没问题。另外,根据你要接的模型服务类型,再加一个对应的适配模块。不同模型厂商适配模块的坐标前缀不同,但引入方式和用法基本一致,我下面用本地模型服务举例。

3.2 模型服务配置:本地优先,成本可控

在 application.yml 里,配置项大概是这样的(不同厂商模块的属性前缀略有差异,以你引入的模块文档为准):

langchain4j: ollama: chat-model: base-url: http://127.0.0.1:11434 model-name: my-llm:latest temperature: 0.7

用本地模型做开发和单元测试的好处是:不花钱、不依赖外网服务、可控性强。等逻辑验证通过,再把配置切到云端厂商的模型,应用代码一行都不用改——这就是把模型访问收敛在框架抽象层后面的红利。如果项目里用了 starter 的自动配置,模型对象会直接进容器,你在代码里注入ChatLanguageModel就能用。

3.3 定义 Assistant 接口并用 AiServices 组装

LangChain4j 里最有“Java 味”的用法,是先定义一个业务接口,比如:

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

然后用 AiServices 把它和模型、记忆、工具组装起来:

var model = OllamaChatModel.builder() .baseUrl("http://127.0.0.1:11434") .modelName("my-llm:latest") .temperature(0.7) .build(); CustomerAssistant assistant = AiServices.builder(CustomerAssistant.class) .chatLanguageModel(model) .chatMemory(MessageWindowChatMemory.withMaxMessages(20)) .build();

最后在控制器里暴露一个 REST 接口:

@RestController public class ChatController { private final CustomerAssistant assistant; public ChatController(CustomerAssistant assistant) { this.assistant = assistant; } @PostMapping("/api/chat") public String chat(@RequestBody ChatRequest request) { return assistant.chat(request.message()); } }

这里有一点很关键:CustomerAssistant没有实现类,AiServices 会在运行时生成一个动态实现对象,把接口方法的调用转成对模型的调用。这个设计让业务代码看起来就像调用一个普通 Spring Service,测试时也可以很方便地用 mock 替换掉整个 LLM 部分。

3.4 第一次跑通的检查清单

跑通以上代码后,先别急着加功能,按清单做一轮确认:日志里能看到模型的输入消息,说明模型接入正确;连续对话时,历史消息确实被带入下一轮,说明记忆没丢;如果加了工具方法,日志里能看到模型发起工具调用的记录,说明工具描述已经被模型正确理解。把这些基础环节确认清楚,再往上搭业务,排查问题时心里才有底。

4. 把 Agent 的“手脚”接上:@Tool 工具调用的设计思路与踩坑记录

4.1 工具调用到底是怎么一回事

Agent 和普通聊天机器人最大的区别就是能“做事”。而这种能力的底层机制,在主流大模型服务商那里基本统一:模型本身不执行业务逻辑,它只负责判断“当前问题要不要调用工具、调用哪个工具、传入什么参数”,然后返回一个结构化的工具调用请求;框架收到请求后,在应用侧执行真正的 Java 方法,再把执行结果作为新的消息回传给模型,模型据此继续推理,直到给出最终回答。

这个循环里,真正由开发者控制的部分有两个:一是工具方法本身的实现,二是工具的描述信息。大模型没有“看代码”的能力,它对工具的全部理解来自你在@Tool注解和方法参数上写的文字。所以描述写得好不好,直接决定 Agent 会不会在关键时刻调错工具、传错参数。

4.2 一个能用的 @Tool 长什么样

我用一个订单查询工具来演示:

@Component public class OrderTools { private final OrderQueryService orderQueryService; public OrderTools(OrderQueryService orderQueryService) { this.orderQueryService = orderQueryService; } @Tool("查询指定订单的最新状态,orderId 是订单号,例如 ORD20240518001") public String queryOrderStatus(String orderId) { OrderInfo info = orderQueryService.queryByOrderId(orderId); if (info == null) { return "未找到订单,请确认订单号是否正确"; } return "订单状态:" + info.status() + ",更新时间:" + info.updatedAt(); } }

这里值得注意的细节是:返回类型统一用 String,并且用完整的一句话描述订单状态,而不是返回一个 Java 对象或 JSON。原因很简单,模型的下一步推理完全依赖这份文本,你把结果整理得越“像人说的人话”,后续推理就越稳。另外,方法名本身最好也能自解释,模型在判断选择哪个工具时,方法名和注解描述是同样重要的信号。

4.3 我在工具调用上踩过的三个坑

第一个坑是参数设计得太复杂。早期我把工具方法写成接收多个参数,其中包括一个带嵌套结构体的对象,模型在生成这类复杂参数时准确率明显下降,经常出现字段缺漏或拼错。后来我把复杂参数统一改成“字符串描述 + 方法内部解析”,模型要做的判断就变得非常简单:它只需要把用户原话里的订单号抄进参数里。凡是模型容易生成错的,就不要让它生成;宁可把复杂性留在 Java 方法内部。

第二个坑是工具方法内部抛异常。最开始我在工具方法里直接让异常往外抛,结果框架把异常信息包装后回传给模型,模型开始一本正经地编造“系统错误”的原因。后来所有工具方法都改成 try-catch 包裹,失败时返回一段清晰的业务提示,比如“查询失败,原因:订单服务超时”,让模型基于事实继续处理和用户解释,而不是让它自己脑补。

第三个坑是并发调用带来的副作用。现在的模型经常在一次回复里同时请求多个工具调用,框架会逐个执行。查询类工具并行执行问题不大,但写操作、状态变更类工具如果被并行执行,就可能出现边界问题。我的处理原则是:写操作类工具要么实现幂等(同一个请求重复执行结果一致),要么在开头加防重校验;绝不可以默认 Agent 只会按顺序一个工具一个工具地慢慢调。

5. 会话记忆、上下文窗口与成本控制的实战取舍

5.1 ChatMemory 两种窗口的差别,以及按会话隔离的必要性

LangChain4j 的 ChatMemory 抽象解决了“Agent 怎么记住上下文”的问题,但不同窗口策略的差别必须搞清楚。MessageWindowChatMemory按消息条数保留,实现简单直观,但不同消息的 token 长度差异很大,一个 3000 token 的长文档和一句 20 token 的问候都算一条,很容易把窗口撑爆。TokenWindowChatMemory按 token 预算裁剪,更贴近模型的实际消耗,代价是需要让框架知道怎么数 token,通常会借用所选模型的 tokenizer。我的习惯是正式项目优先用带 token 预算的记忆窗口,配合持久化存储。

还有一个所有做真实产品的人都会遇到的坑:记忆必须按会话隔离。如果服务里只放一个共享的 ChatMemory,所有用户的消息会混在一起,A 用户上一句问的话,B 用户下一句能看到,这是线上事故级别的低级错误。LangChain4j 提供 ChatMemoryStore 接口,按 conversationId 存取历史,你可以基于 Redis 或数据库实现一份,再把会话 ID 通过路径参数或请求头从控制器一路传到组装层。

5.2 一次真实的 token 账与裁剪策略

为了说明上下文控制的重要性,我列一笔比较典型的账。假设系统提示词固定 500 token,工具定义占 800 token,用户每轮对话平均 600 token,助手回复平均 400 token:

组成部分大约 token 数
系统提示词500
工具定义(描述 + 参数结构)800
每轮用户消息600
每轮助手回复400
8192 token 窗口下大约能支撑7 轮左右

如果用户的会话里充满了长文档粘贴,窗口消耗速度会快得多,可能三到五轮就到裁剪线。我实际采用的策略是三层组合:第一层,用消息条数硬限制避免无限增长;第二层,把“历史中的关键结论”每隔几轮做一次摘要,用摘要块替换老的历史原文;第三层,在进入模型前对用户消息做长度上限校验,超长内容先走一遍检索再进提示词。这个组合没有绝对正确的最优解,但能保证 Agent 在长会话里既不丢关键上下文,也不被历史淹没。

5.3 从“能跑”到“能上线”还差的几件事

跑通 Agent 只是开始,上线之前,我建议认真补这几项:一是接入统一网关做鉴权和限流,防止 Agent 接口被调用方当成免费聊天工具刷爆,费用超支这种事往往发生在一个平静的夜里;二是设置调用超时和失败重试,大模型 API 的延迟波动是常态,超时策略别写死;三是日志和可观测性,每次调用记录输入输出摘要、耗时、token 用量,出了问题时没有这些数据等于盲人摸象;四是响应方式,交互型 Agent 尽量用流式输出,长回答的等待体验和一次性返回完全不在一个量级。

6. 迁移复盘:Java 没有辜负这次冒险,但也不是万能解药

6.1 换栈之后立刻感受到的好处

先说说立竿见影的好处。第一,代码 review 的体验完全不同了——接口签名就是契约,改一个方法的返回值,编译器和 IDE 会帮你把所有调用点标出来,而不是等测试在某个深夜报错。第二,和团队现有基础设施的融合成本趋近于零,Spring Boot 应用该怎么部署、怎么监控、怎么灰度,整个组织已经很熟,Agent 只是一个普通的 Spring 服务。第三,AI 组件的可测试性提高了,AiServices 生成的动态实现可以用 mock 轻易替换,工具方法本身是普通 Java 方法,单元测试不需要真的连模型。

6.2 仍然不如 Python 的地方

也必须诚实地说几个不如意的地方。LangChain4j 的社区规模、教程数量、第三方组件丰富度,和 Python 的 LangChain 生态还有明显差距;AI 研究社区的新工具、新方法,首发支持几乎都在 Python 侧,Java 这边要么等社区跟进,要么干脆等不到。JVM 的启动速度和内存占用也是实打实的成本,在需要快速弹缩的纯 AI 服务场景,Java 的冷启动劣势会被放大。这些都是选择 Java 之前必须想清楚的代价。

6.3 给同样想“逃离舒适区”的人三条建议

第一条,别因为“Python 被很多人吐槽”就换栈,换栈的唯一理由是你们团队的工程环境和交付物需要这份确定性。第二条,迁移时先做一个“最小闭环”——一个模型、一个工具、一段记忆,跑通之后再逐步平移原来 Python 项目的功能,不要一上来就试图完整复刻一个复杂的 Agent 应用。第三条,把 LLM 当外部依赖对待,给它加抽象层、加 mock、加超时和降级,和对待数据库、缓存一样谨慎,这样无论底层模型怎么换,你的业务架构都稳得住。

最后再分享一个小技巧吧。在 Spring Boot 里,我会把CustomerAssistant这类接口放在独立的包,不让控制器直接依赖 AiServices 内部的实现类;测试里用一个极小的假模型返回预设文本,就能把整个 Agent 的业务分支跑完,完全不需要真实模型参与。这个习惯让我在后续调整提示词和工具描述时,敢大胆改、快速验证,而不怕把线上行为改坏。逃离舒适区的意义,大概就是重新拿回了这种“敢改”的底气。

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

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

立即咨询