Java 后端开发这几年最明显的变化,不是某个框架的版本号又跳了一格,而是招聘 JD 里开始频繁出现“熟悉 AI Agent 开发”“有大模型应用落地经验优先”这类描述。很多写了五六年 CRUD 的工程师第一反应是:这玩意儿跟我有啥关系?我又不搞算法。但实际情况恰恰相反——AI Agent 的工程化落地,拼的根本不是模型训练能力,而是后端工程师最擅长的那套东西:服务编排、状态管理、异常重试、并发控制、接口抽象。LangChain4j 和 Spring AI 这两个框架的出现,本质上就是把 Agent 的开发范式拉回到了 Java 工程师熟悉的舒适区。
这篇内容面向的是有 Java 基础、想切入 AI Agent 方向但不知道从哪下手的后端开发。我会从 Agent 的核心运行原理讲起,把 ReAct 模式拆开揉碎,然后落到 LangChain4j 和 Spring AI 的具体代码实现,最后聊并发、RAG、工具调用这些真正上生产才会遇到的问题。不堆概念,只讲能跑起来的东西。
1. 先搞清楚 AI Agent 到底比普通接口调用多了什么
1.1 从“一问一答”到“自主决策循环”的本质差异
大部分人第一次接触大模型,都是通过一个简单的 HTTP 接口:传一段 prompt 进去,拿一段回复出来。这本质上跟调用一个普通的 REST API 没有区别——输入确定,输出确定,中间没有决策过程。但 Agent 不一样,它的核心特征是多轮自主决策:给定一个目标,它会自己判断下一步该做什么、该调用哪个工具、拿到结果后是否继续、什么时候停止。
举个具体的例子。你让普通接口“帮我查一下北京今天的天气”,它只能根据训练数据瞎编一个答案。但如果你给 Agent 配备了天气查询工具,它会这样运转:先理解你的意图是查天气,然后决定调用天气 API,传入“北京”和“今天”两个参数,拿到返回结果后组织成自然语言回复你。这个“理解意图→选择工具→构造参数→执行→整合结果”的循环,就是 Agent 的最小工作单元。
用后端工程师熟悉的话来说,普通接口调用是同步的一问一答,Agent 是带状态机的异步任务编排。你之前写过的那些工作流引擎、状态机、责任链模式,在这里全部用得上。
1.2 ReAct 模式:Agent 的“思考-行动”循环到底怎么转
ReAct(Reasoning + Acting)是目前最主流的 Agent 运行范式,它的核心思想非常朴素:让模型在每一步都先输出一段“思考”,再输出一个“行动”,然后根据行动的结果继续下一轮思考。整个循环长这样:
- Thought(思考):模型分析当前状态,判断需要做什么
- Action(行动):模型选择一个工具并给出调用参数
- Observation(观察):系统执行工具,把结果返回给模型
- 重复 1-3,直到模型认为任务完成,输出 Final Answer
这个循环用伪代码表示大概是这样:
while (!taskCompleted) { String thought = llm.reason(context); ToolCall action = llm.selectAction(thought); if (action == null) { return llm.finalAnswer(context); } String observation = toolExecutor.execute(action); context.append(thought, action, observation); }看起来简单,但工程上的坑全在细节里。比如:模型可能陷入死循环,反复调用同一个工具;模型可能构造出格式错误的参数,导致工具执行抛异常;模型可能在拿到足够信息后仍然不停止。这些问题在后面讲 LangChain4j 实现时会具体展开。
1.3 Java 工程师做 Agent 的天然优势在哪
我见过不少 Java 后端转 AI 方向时特别不自信,觉得自己不懂 PyTorch、没读过 Transformer 论文,做不了这个。但实际上,Agent 开发中真正难的部分,跟深度学习关系不大。
Agent 落地要解决的核心问题包括:工具调用的参数校验和异常处理、多轮对话的上下文管理和截断策略、并发场景下的会话隔离、外部 API 调用的超时和重试、RAG 检索的向量库选型和性能调优。这些东西,哪一个不是后端工程师天天在干的活?你写过的 Feign 客户端、Hystrix 熔断、Redis 会话缓存、MyBatis 分页查询,换个场景就是 Agent 的基础设施。
LangChain4j 和 Spring AI 之所以在 Java 圈火起来,就是因为它们把 Agent 的抽象层做得跟 Spring 的编程模型高度一致——注解式声明工具、依赖注入管理组件、AOP 处理横切逻辑。你不需要学新语言,不需要换技术栈,用现有的 Java 工程能力就能把 Agent 搭起来。
2. LangChain4j 和 Spring AI 的选型逻辑与核心抽象
2.1 两个框架的定位差异:一个偏底层灵活,一个偏生态整合
LangChain4j 和 Spring AI 经常被拿来比较,但它们的设计哲学其实不太一样。LangChain4j 更像是一个独立的 Agent 开发工具包,它不依赖 Spring 容器,你可以把它用在任何 Java 项目里,甚至是一个简单的 main 方法。它的抽象层次更贴近“我需要什么就组装什么”,灵活度高,但需要自己管理组件的生命周期。
Spring AI 则是深度绑定 Spring 生态的产物。它的核心优势在于:如果你已经在用 Spring Boot 写业务系统,引入 Spring AI 几乎零成本——自动配置、依赖注入、Actuator 监控、配置中心,全部无缝衔接。它的 API 设计也更有“Spring 味”,比如用@Tool注解声明工具,用ChatClient链式调用构建对话。
选型上我的建议很直接:新项目且技术栈是 Spring Boot,优先 Spring AI;需要嵌入到非 Spring 的老系统,或者需要更细粒度控制 Agent 循环,选 LangChain4j。两者并不互斥,LangChain4j 的一些组件(比如向量库集成)在 Spring AI 里也有对应实现,概念是相通的。
| 对比维度 | LangChain4j | Spring AI |
|---|---|---|
| 容器依赖 | 无,可独立运行 | 强依赖 Spring 容器 |
| 工具声明方式 | 接口 + 注解 | @Tool注解 + 方法 |
| 对话记忆 | ChatMemory接口 | ChatMemory+ 自动配置 |
| RAG 支持 | 内置多种向量库 | 内置多种向量库 |
| 学习曲线 | 中等,需自己组装 | 低,Spring 开发者友好 |
| 适合场景 | 独立 Agent 服务、嵌入式 | Spring Boot 业务系统集成 |
2.2 工具调用(Function Calling)的底层机制
Agent 能“干活”的关键在于工具调用。不管哪个框架,底层机制都是一样的:把 Java 方法的签名转换成模型能理解的 JSON Schema,模型返回一个结构化的调用请求,框架再反射调用对应的方法。
以 LangChain4j 为例,你定义一个工具类:
public class WeatherTool { @Tool("查询指定城市的天气") public String getWeather(@P("城市名称") String city) { // 实际调用天气 API return weatherApi.query(city); } }框架在运行时会把这个方法转换成类似这样的描述传给模型:
{ "name": "getWeather", "description": "查询指定城市的天气", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称" } }, "required": ["city"] } }模型看到这个描述后,如果判断需要查天气,就会返回一个tool_call,里面包含方法名和参数。框架解析后反射调用getWeather("北京"),把返回值作为 Observation 塞回上下文。
这里有个容易被忽略的细节:方法的 description 和参数的 description 直接决定了模型能不能正确调用工具。我踩过的坑是,工具方法名叫query,描述写的是“查询数据”,结果模型根本不知道什么时候该用它。后来改成queryOrderStatus,描述写“根据订单号查询订单的当前状态,返回待支付/已支付/已发货/已完成”,调用准确率立刻上来了。工具描述要写得像给一个新同事解释这个方法是干嘛的,越具体越好。
2.3 对话记忆(ChatMemory)的实现与陷阱
Agent 的多轮对话依赖记忆机制。LangChain4j 和 Spring AI 都提供了ChatMemory抽象,核心逻辑是维护一个消息列表,每次调用模型时把历史消息一起传进去。
最简单的实现是MessageWindowChatMemory,它只保留最近 N 条消息。但这里有个陷阱:如果 Agent 执行了很多轮工具调用,消息列表会迅速膨胀。一次完整的 ReAct 循环可能产生 10 条以上的消息(用户输入、模型思考、工具调用、工具结果、模型再思考……),如果窗口设成 20,可能两轮任务就把窗口占满了,导致早期的关键信息被挤掉。
我的做法是分层管理:系统提示词和用户原始问题永远保留,中间的思考-行动-观察过程按需截断。LangChain4j 允许你自定义ChatMemory实现,我通常会写一个TokenWindowChatMemory,按 token 数而不是消息条数来截断,并且给系统消息和用户首条消息设置“不可驱逐”标记。
public class CustomChatMemory implements ChatMemory { private final List<ChatMessage> messages = new ArrayList<>(); private final int maxTokens; @Override public void add(ChatMessage message) { messages.add(message); trimIfNeeded(); } private void trimIfNeeded() { // 保留系统消息和首条用户消息,从中间开始驱逐 while (countTokens(messages) > maxTokens) { // 找到第一条可驱逐的消息并移除 } } }3. 用 LangChain4j 搭一个能跑起来的 Agent
3.1 最小可运行 Demo 的依赖与配置
先看依赖。LangChain4j 的模块化做得比较细,你需要什么就引什么:
<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>如果你用的是国产模型(比如通义千问、DeepSeek),LangChain4j 也有对应的集成模块,或者用 OpenAI 兼容协议接入。配置上核心就是三样东西:API Key、Base URL、模型名称。
ChatLanguageModel model = OpenAiChatModel.builder() .apiKey(System.getenv("API_KEY")) .baseUrl("https://your-api-endpoint/v1") .modelName("qwen-plus") .temperature(0.7) .timeout(Duration.ofSeconds(60)) .build();这里timeout一定要设。Agent 场景下模型调用可能涉及多轮,默认超时往往不够,但设太长又会拖垮整个请求链路。我的经验值是单次模型调用 60 秒,整个 Agent 任务总超时 5 分钟,超过就中断并返回已完成的部分。
3.2 定义工具并接入 Agent 执行链
有了模型之后,定义工具并组装 Agent:
public interface Assistant { String chat(String userMessage); } WeatherTool weatherTool = new WeatherTool(); OrderTool orderTool = new OrderTool(); Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .tools(weatherTool, orderTool) .chatMemory(MessageWindowChatMemory.withMaxMessages(20)) .build(); String answer = assistant.chat("帮我查一下北京天气,然后看看我订单12345的状态");AiServices是 LangChain4j 的核心入口,它用动态代理把接口方法转换成 Agent 调用。当你调用chat方法时,框架会自动:把用户消息加入记忆、把工具描述传给模型、进入 ReAct 循环、执行工具调用、整合结果返回。
实测下来,这个最小 Demo 在工具数量少于 5 个、任务步骤少于 3 步的场景下表现很稳。但一旦工具数量上去、任务变复杂,就需要做额外优化,后面会讲。
3.3 工具执行失败的兜底策略
工具执行失败是必然会发生的事——外部 API 超时、参数格式错误、返回结果为空,各种情况都有。如果不在框架层面做兜底,模型会收到一个异常堆栈,然后大概率陷入“重试同一个错误调用”的死循环。
我的做法是在工具方法内部就把异常消化掉,返回一个结构化的错误信息:
@Tool("根据订单号查询订单状态") public String queryOrderStatus(@P("订单号") String orderId) { try { Order order = orderService.getById(orderId); if (order == null) { return "未找到订单号为 " + orderId + " 的订单,请确认订单号是否正确"; } return "订单状态:" + order.getStatus(); } catch (Exception e) { log.error("查询订单失败", e); return "查询订单时发生系统错误,请稍后重试"; } }关键在于:返回给模型的是自然语言的错误描述,而不是异常堆栈。模型看到“未找到订单”这样的描述,会自然地调整策略(比如询问用户确认订单号),而不是反复重试。这个技巧在 ReAct 模式下特别重要,因为模型对自然语言的理解远好于对错误码的理解。
4. Spring AI 的工程化落地:从配置到生产
4.1 Spring Boot 项目中的自动配置与 ChatClient
Spring AI 最大的卖点就是跟 Spring Boot 的无缝集成。引入 starter 之后,大部分配置都可以放在application.yml里:
spring: ai: openai: api-key: ${API_KEY} base-url: https://your-api-endpoint chat: options: model: qwen-plus temperature: 0.7然后在代码里直接注入ChatClient:
@Service public class AgentService { private final ChatClient chatClient; public AgentService(ChatClient.Builder builder, OrderTool orderTool) { this.chatClient = builder .defaultSystem("你是一个订单助手,帮助用户查询和处理订单") .defaultTools(orderTool) .build(); } public String handle(String message) { return chatClient.prompt() .user(message) .call() .content(); } }ChatClient的链式 API 设计得很顺手,prompt().user().call().content()这套写法比 LangChain4j 的接口代理模式更直观,尤其是需要动态调整系统提示词或工具集的场景。
4.2 用 @Tool 注解声明工具的最佳实践
Spring AI 的工具声明用@Tool注解,跟 LangChain4j 类似但更简洁:
@Component public class OrderTool { @Tool(description = "根据订单号查询订单的当前状态") public String queryOrderStatus( @ToolParam(description = "订单号,格式为纯数字") String orderId) { // ... } }这里有个 Spring AI 特有的优势:工具类本身就是一个 Spring Bean,可以直接注入OrderService、RedisTemplate等任何依赖。这意味着你现有的业务逻辑可以几乎零改造地暴露成 Agent 工具。
但要注意一个坑:工具方法的返回值会被序列化成字符串传给模型,如果返回的是一个复杂的 Java 对象,默认的 JSON 序列化可能产生大量冗余字段。我建议工具方法直接返回精简后的字符串,或者用一个专门的 DTO 控制序列化字段。
4.3 多模型切换与配置隔离
生产环境往往需要同时对接多个模型——比如用便宜的小模型处理简单意图识别,用大模型处理复杂推理。Spring AI 支持通过配置多个ChatClientBean 来实现:
@Configuration public class ModelConfig { @Bean("fastClient") public ChatClient fastClient(ChatModel fastModel) { return ChatClient.builder(fastModel).build(); } @Bean("smartClient") public ChatClient smartClient(ChatModel smartModel) { return ChatClient.builder(smartModel).build(); } }然后在业务代码里按需注入。这种隔离方式比在代码里硬编码模型名称要干净得多,也方便后续做灰度切换。
5. 上生产才会遇到的并发与性能问题
5.1 Agent 请求的并发模型与线程安全
这是 Java 工程师最关心的问题,也是面试里高频出现的“AI Agent 怎么扛并发”。先说结论:Agent 服务的并发瓶颈不在模型调用本身,而在会话状态管理和工具执行。
模型调用通常是 HTTP 请求,天然支持并发,你只需要控制好连接池大小和超时。真正麻烦的是ChatMemory——如果你把会话状态存在 JVM 内存里,多线程访问必然出问题。LangChain4j 的MessageWindowChatMemory默认不是线程安全的,多个请求同时操作同一个会话会导致消息错乱。
解决方案有两个方向:一是每个会话独立实例,用ConcurrentHashMap管理会话 ID 到 Memory 的映射;二是把会话状态外置到 Redis。前者适合单机部署,后者适合多实例水平扩展。
@Service public class SessionManager { private final ConcurrentHashMap<String, ChatMemory> sessions = new ConcurrentHashMap<>(); public ChatMemory getOrCreate(String sessionId) { return sessions.computeIfAbsent(sessionId, id -> MessageWindowChatMemory.withMaxMessages(20)); } }如果要用 Redis 存储,需要自己实现ChatMemory接口,把消息列表序列化后存到 Redis List 里。注意序列化时要保留消息类型(UserMessage、AiMessage、ToolExecutionResultMessage),否则反序列化后框架无法正确识别。
5.2 工具调用的超时控制与熔断
Agent 执行过程中可能调用多个外部工具,任何一个工具卡住都会拖垮整个请求。必须给每个工具调用设置独立的超时,并且在整个 Agent 任务层面设置总超时。
我的做法是用CompletableFuture包装工具执行:
public String executeWithTimeout(ToolCall call, Duration timeout) { CompletableFuture<String> future = CompletableFuture.supplyAsync( () -> toolExecutor.execute(call), toolExecutorPool); try { return future.get(timeout.toMillis(), TimeUnit.MILLISECONDS); } catch (TimeoutException e) { future.cancel(true); return "工具执行超时,请稍后重试"; } }工具执行线程池要跟 Web 请求线程池隔离,避免工具阻塞把 Tomcat 线程占满。线程池大小根据工具的平均耗时和 QPS 来算,一般 10-20 个核心线程够用。
5.3 流式输出与前端交互的配合
Agent 的响应时间通常比普通接口长,用户等 10 秒才看到结果体验很差。流式输出(SSE)是标配。Spring AI 和 LangChain4j 都支持流式返回:
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> stream(@RequestParam String message) { return chatClient.prompt() .user(message) .stream() .content(); }但流式输出跟工具调用结合时有个问题:工具执行阶段是没有内容输出的,用户会看到一段空白。我的处理方式是,在工具调用前后插入状态提示,比如“正在查询订单信息……”,让用户知道系统在工作。Spring AI 的流式 API 允许你在工具调用回调里发送自定义事件,前端根据事件类型展示不同的加载状态。
6. RAG 与多路召回在 Agent 中的实际应用
6.1 为什么 Agent 需要 RAG
模型的知识有截止日期,而且不知道你公司的内部文档。RAG(检索增强生成)解决的就是这个问题:先把相关文档检索出来,作为上下文塞给模型,让模型基于这些文档回答。
在 Agent 场景下,RAG 通常作为一个工具存在。用户问“我们公司的报销标准是什么”,Agent 判断需要查内部知识库,调用 RAG 工具检索相关文档片段,然后基于检索结果生成回答。
6.2 LangChain4j 的 Easy RAG 与多路召回
LangChain4j 提供了EasyRAG模块,几行代码就能搭一个基础 RAG:
EmbeddingStore<TextSegment> store = new InMemoryEmbeddingStore<>(); EmbeddingModel embeddingModel = new OpenAiEmbeddingModel(...); EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder() .embeddingModel(embeddingModel) .embeddingStore(store) .build(); ingestor.ingest(Document.fromFile("knowledge.pdf")); ContentRetriever retriever = EmbeddingStoreContentRetriever.builder() .embeddingStore(store) .embeddingModel(embeddingModel) .maxResults(5) .minScore(0.7) .build();但单路向量检索在专业领域效果往往不够。多路召回的思路是同时用多种检索策略(向量检索、关键词检索、BM25),然后合并去重。LangChain4j 支持自定义ContentRetriever,你可以组合多个检索器:
ContentRetriever hybridRetriever = new HybridContentRetriever( vectorRetriever, // 向量检索 keywordRetriever // 关键词检索 );实测下来,多路召回在专业术语密集的场景下召回率能提升 20% 以上。代价是检索耗时增加,需要根据场景权衡。
6.3 检索结果的重排序与上下文压缩
检索出来的文档片段往往包含大量无关内容,直接塞给模型会浪费 token 且干扰判断。两个优化手段:重排序(Rerank)和上下文压缩。
重排序是用一个专门的模型对检索结果重新打分,把最相关的排前面。LangChain4j 支持接入 Cohere Rerank 等重排序服务。上下文压缩则是用模型对每个片段做摘要,只保留跟问题相关的部分。这两个手段都能显著提升回答质量,但都会增加延迟,建议在离线评估确认收益后再上线。
7. 从 Demo 到生产的几个关键决策
7.1 会话状态存哪里:内存、Redis 还是数据库
小规模用内存,多实例用 Redis,需要审计和回溯用数据库。我的建议是生产环境直接用 Redis,因为 Agent 会话的读写频率高、数据量不大、对持久化要求不高,Redis 的 List 或 Hash 结构刚好合适。数据库可以作为异步落库用于审计,但不作为主存储。
7.2 工具数量膨胀后的路由策略
当工具数量超过 10 个,把所有工具描述都塞给模型会导致两个问题:token 消耗大、模型选择准确率下降。解决方案是工具分组 + 意图路由:先用一个小模型判断用户意图属于哪个领域,然后只加载该领域的工具。
public List<Tool> routeTools(String userMessage) { String domain = intentClassifier.classify(userMessage); return toolRegistry.getToolsByDomain(domain); }这个思路跟微服务里的 API 网关路由是一个道理,本质上是把“全量选择”变成“先分类再选择”。
7.3 可观测性:日志、指标与链路追踪
Agent 的调试比普通接口难得多,因为中间经过了多轮模型调用和工具执行。必须做好可观测性:每次模型调用的输入输出、每次工具调用的参数和结果、整个 Agent 任务的耗时分解,全部要打日志。指标方面重点关注:模型调用 P99 延迟、工具调用失败率、Agent 任务平均轮数、token 消耗量。
链路追踪可以用 Micrometer + OpenTelemetry,把 Agent 任务作为一个 Span,内部的模型调用和工具调用作为子 Span。这样出问题时能快速定位是模型慢还是工具慢。
8. 一些踩过的坑和实际体会
工具描述写得太抽象是新手最容易犯的错。我见过有人写“处理数据”,模型完全不知道什么时候该调用。描述要具体到“输入什么、输出什么、什么场景用”。
ReAct 循环一定要设最大轮数限制。我遇到过模型反复调用同一个工具十几次的情况,最后是加了maxIterations(10)才止住。超过轮数就强制返回当前已有信息,并提示用户任务未完成。
流式输出和工具调用同时使用时,前端要做好状态管理。用户看到的应该是“思考中→调用工具→生成回答”这样的过程,而不是一段长时间的空白。
模型选择上不要迷信大模型。意图识别、参数提取这类任务,小模型完全够用,成本只有大模型的十分之一。把大模型留给真正需要复杂推理的环节。
最后说一个实际体会:Agent 项目的复杂度不在模型,而在工程。你把工具调用的异常处理、会话的并发控制、超时熔断这些基础设施搭好了,换任何模型都能跑。这些恰恰是 Java 工程师积累多年的东西。所以转型这件事,与其说是学 AI,不如说是把后端工程能力迁移到一个新场景。