AI Agent 开发是当前后端开发者讨论最多的话题之一。我遇到过不少带着“想转 AI 应用开发”想法来做 Java 面试辅导的同学,也接过许多号称要做 Agent 项目但实际上第一步就卡在 Maven 依赖上的咨询。做了大量一对一辅导和代码问题排查后,我的判断没有变:Agent 开发真正的难点不在提示词,而在工程底座。下面以 Java 后端的视角,拆解 Agent 开发需要的 Java 知识点,给出一个最小可运行的日志分析 Agent 示例,并把高概率出现的报错和排查路径列出来。
1. 先搞清楚:Agent 开发真正考验的是工程底座,不是提示词技巧
1.1 为什么“AI Agent 辅导”听起来热门,却很难直接交付
AI Agent 被很多文章描述成一个“只要会和大模型对话,就能做出智能应用”的方向。实际接触过 Agent 项目的人会很清楚,一个可交付的 Agent 至少要解决这些问题:
- 用户输入如何进入系统,如何做校验和会话隔离。
- Agent 如何选择工具,调用 Elasticsearch、数据库、外部接口后如何拿到结构化结果。
- 大模型返回的 JSON 如何解析,解析失败如何处理。
- 单次调用超时怎么办,多个工具并发调用如何控制线程池。
- 上下文怎么保存,重启之后会话记忆是否还在。
- 模型调用消耗多少 Token,哪一轮调用导致线上故障,如何通过日志还原。
这些能力没有一样是提示词能替代的。纯模板式“Agent 辅导”只能帮助刚入门的人理解什么是 Agent、怎么跑通一个模型调用 demo。一旦进入企业私有化部署、日志检索、权限控制、稳定性治理,问题最终还是落在 Java 后端基本功上。
1.2 Agent 开发到底由哪几部分组成
把一个 Agent 系统拆开看,通常包含五层:
- 模型层:负责与 LLM 推理服务通信,通常是一个 HTTP 接口,输入 messages,输出 text 或 tool_calls。
- 工具层:给 Agent 提供真实能力,例如查询日志、执行 SQL、调用订单接口。
- 记忆层:保存短期对话上下文和长期业务记忆,常见载体是 Redis、MySQL 或向量数据库。
- 规划层:决定下一步调用哪个工具,可以是一个提示词模板,也可以是一段状态机逻辑。
- 执行与观测层:真正执行工具调用,记录每一步的输入、输出、耗时、错误和 Token 消耗。
这些层用 Java 实现时,对应技术非常明确。模型层用 HttpClient 或 Feign;工具层用 Elasticsearch Java API Client、RedisTemplate、MyBatis;记忆层用 Redis 和 MySQL;规划层是一段可测试的 Java 逻辑;执行层需要线程池、异步任务和结构化日志。只看框架概念,不看这些工程组件,很难独立完成一个上线的 Agent 服务。
1.3 用 Java 做 Agent,和后端开发的关系
用 Java 做 Agent,本质上不是发明一套新框架,而是把一个后端系统接入大模型。Spring Boot 的 Bean 管理、Maven 的依赖机制、连接池、事务、异常处理、日志、监控、部署,这些知识一个都不能少。很多同学觉得 Java 面试八股文和 Agent 开发没有关系,其实关系非常直接:
- 面试考线程池参数,因为 Agent 的工具调用天然是高并发场景。
- 面试考 HashMap 和 Stream,因为要处理模型输出的 JSON 结构。
- 面试考 Redis 缓存和分布式锁,因为多实例部署时 Agent 会话状态需要共享。
- 面试考 Elasticsearch 查询,因为很多 Agent 的“检索能力”就是通过 ES 实现的。
所以不要急着把 Java 基础扔掉。先承认 Java 后端能力是 Agent 开发的地基,后面学习 Agent 才会顺。
2. 我在 Java 面试辅导中看到的三类 Agent 学习误区
2.1 误区一:只学 Agent 框架,不读源码,遇到问题只能重启
不少人在学习 Agent 时,第一步是安装某个 Agent 编排框架,第二步是复制一个示例,第三步是跑通 demo。程序能运行时会觉得很顺利,一旦报错就会进入“重启、换模型、改提示词”的死循环。
问题不在于框架不好,而在于没有理解框架内部的调用链。一个 Agent 编排框架至少包含 Tool 注册、模型调用、消息组装、结果解析、异常处理这几层。如果不懂这些组件之间的数据流,看到tool call failed这类错误时,根本不知道是该检查工具入参,还是检查模型返回格式,还是检查鉴权配置。
正确做法是先找一个足够简单的调用链手写一遍。比如 Java 程序发起 HTTP 请求调用模型服务,拿到返回后解析出工具名称和参数,再调用本地方法,把结果拼接回上下文。这个过程不需要复杂框架,但能训练排查问题的能力。
2.2 误区二:把“大模型调用”当成 Agent 的全部
还有一种常见认知是“只要调通了大模型,Agent 就做完了”。实际项目中,大模型调用只是链路中的一环。模型可能返回空内容,可能返回不合法 JSON,可能长时间不响应,可能被恶意 prompt 诱导产生错误行为。这些都需要在工程层面处理。
Java 里有几个非常具体的能力要求:
- 使用 Jackson 解析模型输出时要处理未知字段和缺失字段。
- 调用模型接口时要设置连接超时和读取超时。
- 针对模型接口的 5xx 错误要做重试和熔断。
- 对用户输入要做长度限制和内容过滤。
- 对系统提示词和用户输入要拼接成结构化消息,而不是简单字符串拼接。
这些内容才是 Agent 开发和普通 CRUD 明显不同的地方,但它们本质上是后端工程问题。
2.3 误区三:概念学了一堆,Java 工程一个都没写
学习路线错位是最可惜的。有人先背了 Agent、Tool、Memory、Plan 一大串术语,再学框架,最后才发现连 Maven 依赖都不会配置。概念帮助理解方向,但真正检验学习效果的是能不能在本地跑起一个 Java 工程。
我在辅导中经常看到这样的情况:
| 误区表现 | 在咨询中常见的原话 | 正确做法 |
|---|---|---|
| 只学框架 | “我学了某个框架的 demo,但改不动了” | 先读一个工具调用链,写单测覆盖 |
| 只调大模型 | “模型返回不对,我就换提示词” | 先检查输入上下文、原始日志和解析逻辑 |
| 只背概念 | “Agent 记忆我知道有短期和长期” | 用 Redis 存会话,用 ES 存日志,动手写一遍 |
技术学习里,概念只是索引,代码才是正文。每学一个新概念,都要用 Java 实现一个最小闭环。
3. Agent 开发前,先用这份 Java 清单给自己体检
3.1 Java 基础自测清单
不用等到把所有 Java 知识学完再碰 Agent。但下面这张清单里的内容,是 Agent 工程里出现频率最高的 Java 能力项,建议逐条自查。
| 模块 | 需要掌握到什么程度 | 为什么 Agent 开发需要 |
|---|---|---|
| 集合 | 知道 HashMap 原理,会使用 Stream 处理 List | 组装消息上下文,处理工具调用结果 |
| 并发 | 线程池参数、CompletableFuture、锁 | Agent 多工具调用、异步归并 |
| IO/HTTP | HttpClient、文件读写、流处理 | 调用大模型服务,读取日志文件 |
| JSON | Jackson 反序列化、泛型处理 | 解析模型输出和 ES 返回结果 |
| 数据库 | 连接池、事务、慢查询排查 | 持久化会话与记忆 |
| Spring | IoC、AOP、事务、配置外置 | 工程化组织 Agent 服务 |
| 网络/认证 | REST API 概念、鉴权方式 | 工具调用、模型网关接入 |
| 日志/监控 | SLF4J、结构化日志、链路追踪 | Agent 调试和线上观测 |
如果清单里超过三项不熟,建议先不要急着做 Agent 项目,把对应的 Java 基础补起来。特别是并发和 JSON,这两项在 Agent 工程里几乎每天都要用。
3.2 并发和资源隔离:Agent 后端最容易翻车的地方
Agent 调用大模型通常是阻塞式 HTTP 调用,单次耗时可能几秒到几十秒。如果使用 Tomcat 默认线程池处理请求,一个用户的多轮问答就可能占满线程。更危险的是,多个用户同时触发 Agent 工具调用时,线程池很可能被打满,随后出现接口超时和线程饥饿。
常见做法是给 Agent 模型调用单独配置线程池,并把工具调用与业务线程隔离。下面是一个最小示例:
ExecutorService agentPool = new ThreadPoolExecutor( 8, // 核心线程数 16, // 最大线程数 60L, // 空闲线程存活时间 TimeUnit.SECONDS, new LinkedBlockingQueue<>(200), new ThreadPoolExecutor.CallerRunsPolicy() );核心线程数不能开太大,因为大模型接口有并发限制和成本限制。队列长度也不宜无限,否则内存可能先被打满。CallerRunsPolicy是一种降级策略,线程池满时由调用线程执行任务,虽然会拖慢调用方,但至少不会直接丢弃任务。
3.3 环境准备:JDK、Maven、Elasticsearch
做下面的日志分析 Agent 示例前,先确认环境符合要求:
- JDK 17 或更高版本。
- Maven 3.8 或更高版本。
- Elasticsearch 8.x,本地或远程可访问。
- 可选:一个兼容 OpenAI 格式的大模型推理服务。
先执行下面三条命令检查环境:
java -version mvn -version curl http://localhost:9200/_cluster/health最后一条命令如果返回包含"status" : "green"或"status" : "yellow"的 JSON,说明 ES 可以访问。如果连接不上,需要先排查 ES 服务是否启动、端口是否正确、是否开启了认证。
注意:Elasticsearch Java API Client 的服务端版本和客户端版本尽量保持一致,至少大版本不能差异过大,否则协议不兼容,会出现序列化或请求失败的问题。
4. 最小可运行案例:Java + Elasticsearch REST API 实现日志分析 Agent
4.1 需求设计
这个例子的目标是模拟“日志分析 Agent”:用户输入一个关于错误日志的问题,Java 程序从 Elasticsearch 查询最近 ERROR 日志,把日志内容组装成提示词,调用大模型服务,返回分析结论。
分成三个模块:
LogQueryTool:查询 Elasticsearch,返回日志文本。LlmClient:调用大模型 HTTP 接口。LogAnalyzerAgent:把日志查询和模型调用组合起来。
如果本地没有大模型服务,可以先让LogAnalyzerAgent只打印 Prompt,确认日志查询链路没有问题后再接入模型。
4.2 工程结构
在本地创建一个 Maven 项目,目录结构如下:
log-analyzer-agent/ ├── pom.xml └── src/main/java/com/beifeng/agent/ ├── LogAnalyzerApp.java ├── LogAnalyzerAgent.java ├── LogQueryTool.java ├── LlmClient.java └── LogEntry.java为了保持示例简单,不引入 Spring Boot。实际生产项目可以把这些类注册为 Spring Bean,外包 RestClient 和线程池。
4.3 依赖配置
pom.xml中使用 Java 17,并加入 Elasticsearch Java Client 和 Jackson 依赖:
<project> <modelVersion>4.0.0</modelVersion> <groupId>com.beifeng</groupId> <artifactId>log-analyzer-agent</artifactId> <version>1.0.0</version> <properties> <maven.compiler.release>17</maven.compiler.release> </properties> <dependencies> <dependency> <groupId>co.elastic.clients</groupId> <artifactId>elasticsearch-java</artifactId> <version>8.11.3</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.16.1</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.30</version> <scope>provided</scope> </dependency> </dependencies> </project>版本号可能随官方发布变化,落地前要确认你所用的 ES 服务端版本与客户端版本是否兼容。Lombok 依赖可以根据团队习惯取舍,这个例子中即便不用 Lombok,手写 getter/setter 也能运行。
4.4 核心代码:查询日志、组装 Prompt、调用模型
先定义日志实体LogEntry.java:
package com.beifeng.agent; public class LogEntry { private String level; private String message; private String timestamp; public String getLevel() { return level; } public void setLevel(String level) { this.level = level; } public String getMessage() { return message; } public void setMessage(String message) { this.message = message; } public String getTimestamp() { return timestamp; } public void setTimestamp(String timestamp) { this.timestamp = timestamp; } }定义工具层LogQueryTool.java,负责查询 Elasticsearch 中的 ERROR 日志:
package com.beifeng.agent; import co.elastic.clients.elasticsearch.ElasticsearchClient; import co.elastic.clients.elasticsearch.core.SearchRequest; import co.elastic.clients.elasticsearch.core.SearchResponse; import co.elastic.clients.elasticsearch.core.search.Hit; import co.elastic.clients.json.jackson.JacksonJsonpMapper; import co.elastic.clients.transport.ElasticsearchTransport; import co.elastic.clients.transport.rest_client.RestClientTransport; import org.apache.http.HttpHost; import org.elasticsearch.client.RestClient; import java.io.IOException; import java.util.stream.Collectors; public class LogQueryTool { private final ElasticsearchClient esClient; private final String index; public LogQueryTool(String host, int port, String index) { RestClient restClient = RestClient.builder(new HttpHost(host, port, "http")).build(); ElasticsearchTransport transport = new RestClientTransport(restClient, new JacksonJsonpMapper()); this.esClient = new ElasticsearchClient(transport); this.index = index; } public String queryErrorLogs(int size) throws IOException { SearchRequest request = SearchRequest.of(s -> s .index(index) .query(q -> q.match(t -> t.field("level").query("ERROR"))) .size(size) ); SearchResponse<LogEntry> response = esClient.search(request, LogEntry.class); return response.hits().hits().stream() .map(Hit::source) .filter(source -> source != null) .map(LogEntry::getMessage) .collect(Collectors.joining("\n")); } }这里的关键点是查询条件。示例只匹配了level=ERROR,生产环境通常还要加时间范围,避免一次查出全量历史日志。还应该限制返回条数并做分页,防止 ES 返回超大数据导致内存溢出。
定义模型调用客户端LlmClient.java,使用 Java 自带的HttpClient:
package com.beifeng.agent; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public class LlmClient { private final String endpoint; private final String apiKey; private final HttpClient httpClient = HttpClient.newHttpClient(); private final ObjectMapper objectMapper = new ObjectMapper(); public LlmClient(String endpoint, String apiKey) { this.endpoint = endpoint; this.apiKey = apiKey; } public String chat(String prompt) throws Exception { String safePrompt = prompt.replace("\\", "\\\\") .replace("\"", "\\\"") .replace("\n", "\\n"); String body = """ { "model": "log-analyzer", "messages": [ {"role": "system", "content": "你是日志分析助手,回答要简洁、可操作。"}, {"role": "user", "content": "%s"} ] } """.formatted(safePrompt); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(endpoint)) .header("Content-Type", "application/json") .header("Authorization", "Bearer " + apiKey) .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() != 200) { throw new RuntimeException("LLM 服务返回异常,HTTP " + response.statusCode() + ",响应:" + response.body()); } return extractContent(response.body()); } private String extractContent(String responseBody) throws Exception { JsonNode root = objectMapper.readTree(responseBody); return root.path("choices").path(0).path("message").path("content").asText(); } }这里默认模型服务返回的是常见的choices[0].message.content结构。不同服务商的字段可能不同,实际接入时需要根据 API 文档调整请求体和响应解析。
定义编排层LogAnalyzerAgent.java:
package com.beifeng.agent; public class LogAnalyzerAgent { private final LogQueryTool logQueryTool; private final LlmClient llmClient; public LogAnalyzerAgent(LogQueryTool logQueryTool, LlmClient llmClient) { this.logQueryTool = logQueryTool; this.llmClient = llmClient; } public String analyze(String question) throws Exception { String logs = logQueryTool.queryErrorLogs(20); String prompt = "你是日志分析助手。请根据给定 ERROR 日志回答用户问题。" + "要求:先写关键错误,再写可能原因,最后写排查建议。\n\n" + "用户问题:\n" + question + "\n\n" + "最近错误日志:\n" + logs; return llmClient.chat(prompt); } }最后写入口LogAnalyzerApp.java:
package com.beifeng.agent; public class LogAnalyzerApp { public static void main(String[] args) throws Exception { String esHost = args.length > 0 ? args[0] : "localhost"; int esPort = args.length > 1 ? Integer.parseInt(args[1]) : 9200; String index = args.length > 2 ? args[2] : "app-logs"; String question = args.length > 3 ? args[3] : "分析最近的错误日志,给出核心原因和排查建议"; String llmEndpoint = System.getenv("LLM_ENDPOINT"); String llmApiKey = System.getenv("LLM_API_KEY"); if (llmEndpoint == null || llmEndpoint.isBlank()) { throw new IllegalArgumentException("请先设置 LLM_ENDPOINT 环境变量"); } LogQueryTool logQueryTool = new LogQueryTool(esHost, esPort, index); LlmClient llmClient = new LlmClient(llmEndpoint, llmApiKey == null ? "" : llmApiKey); LogAnalyzerAgent agent = new LogAnalyzerAgent(logQueryTool, llmClient); String answer = agent.analyze(question); System.out.println(answer); } }4.5 运行验证
先用 Maven 编译:
mvn clean compile然后设置大模型服务地址:
export LLM_ENDPOINT=http://localhost:8000/v1/chat/completions export LLM_API_KEY=local-test-key运行入口类时,需要让 Maven 执行 Java 程序。可以在pom.xml中引入exec-maven-plugin,然后在命令行执行:
mvn exec:java -Dexec.mainClass=com.beifeng.agent.LogAnalyzerApp \ -Dexec.args="localhost 9200 app-logs 分析最近的错误日志"在 Elasticsearch 中有 ERROR 日志,且大模型服务可用的情况下,控制台会输出分析结论。
关键错误:系统出现大量数据库连接超时。 可能原因:连接池满、慢 SQL 占用连接、数据库负载过高。 排查建议:先看连接池使用率,再查慢查询日志,最后检查数据库节点负载。如果不想先接大模型,可以临时把LlmClient.chat改成打印 prompt 并返回固定字符串,先把 ES 查询链路验证通,再接入真实模型。
注意:运行成功不等于链路可用。还要验证索引名是否正确、查询条件是否命中日志、大模型响应是否被正确解析。建议先用 curl 直接查询 ES,确认原始数据存在。
5. 从 demo 到生产:Agent 项目的分层、配置和可观测性
5.1 分层设计
上面的示例是一个最小结构。生产环境建议按职责拆成更多模块,避免所有逻辑都堆在一个 Agent 类里。
| 层级 | 职责 | 典型实现 |
|---|---|---|
| 接入层 | 暴露 HTTP API,做参数校验和鉴权 | Spring Boot Controller |
| 编排层 | 管理 Agent 状态、调用工具、组装多轮上下文 | AgentService、StateMachine |
| 工具层 | 封装 ES、数据库、Redis、外部系统 | LogQueryTool、OrderQueryTool |
| 模型层 | 统一封装大模型调用、重试、超时 | LlmClient、ModelGateway |
| 存储层 | 保存会话、记忆、日志 | MySQL、Redis、Elasticsearch |
分层的好处是每一层都可以单独测试。比如LogQueryTool不依赖模型,可以单独写单元测试;LlmClient不依赖业务逻辑,可以用 Mock 服务验证。
5.2 配置外置和密钥管理
不要把 ES 密码、API Key 写在代码里。本地示例可以通过环境变量传入,生产环境建议使用配置中心或密钥管理服务。
下面是一个环境变量的配置示例:
export ES_HOST=10.0.0.10 export ES_PORT=9200 export ES_USERNAME=elastic export ES_PASSWORD=your-password export LLM_ENDPOINT=https://your-llm-gateway.internal/v1/chat/completions export LLM_API_KEY=your-api-keyJava 里使用System.getenv读取,而不是在代码中写死。生产环境还应该为模型服务和 ES 都设置网络白名单,避免接口暴露到公网。
5.3 日志、链路追踪和评估
Agent 调试比普通接口调试更麻烦,因为一次回答可能涉及多轮模型调用和多次工具调用。建议给每个会话生成一个traceId,并在日志中记录:
- 用户输入。
- 每一步工具调用的入参和返回摘要。
- 大模型请求的消息数量、Token 消耗、耗时。
- 工具调用是否失败。
- 最终回答的截断摘要。
日志结构可以打印成 JSON,便于在 Elasticsearch 或 SkyWalking 中聚合分析。例如