1. 从增删改查到智能体:一个Java老兵为什么要折腾AI Agent
我写Java写了八年,前六年基本都在跟Spring Boot、MyBatis、MySQL打交道,每天的工作节奏就是接需求、写Controller、调Service、改Mapper,偶尔优化一下慢SQL,日子过得不算轻松但也算安稳。真正让我开始焦虑的,是2024年下半年开始,团队里陆续出现了一些“不写业务代码”的岗位需求——不是运维,不是测试,而是要求“能搭建AI Agent、能调通大模型接口、能把现有业务系统改造成Agent可调用的工具”。我当时的第一反应是:这玩意儿跟我有什么关系?我一个写Java的,难道要去学Python?
后来我花了大半年时间,从零开始把AI Agent这套东西啃下来,中间踩了无数坑,也走了不少弯路。现在回头看,Java转AI Agent这件事,核心不是“换语言”,而是“换思维”。你不需要把Java扔掉,恰恰相反,Java的工程化能力在Agent落地阶段反而是优势。但你必须补上几块关键拼图,否则你写出来的东西永远停留在“调个API返回一段文本”的水平,根本算不上Agent。
这篇文章就是把我这大半年的转型过程完整复盘一遍。我会讲清楚Java开发者转AI Agent到底要补什么、为什么补这些、怎么补最省时间,以及我在实际项目中踩过的坑和总结出来的经验。如果你也是一个写了几年Java、正在观望AI Agent方向的工程师,或者你已经动手试过但觉得“好像能跑但不知道对不对”,那这篇内容应该能帮你少走至少三个月的弯路。
先说结论:Java转AI Agent,要补的不是Python语法,而是四样东西——大模型交互的基本认知、Agent的核心架构理解、工具调用与函数编排能力、以及工程化落地的思维转换。下面我逐块拆开讲。
2. 认知重构:Java工程师理解AI Agent的正确姿势
2.1 别把Agent当成一个“更聪明的接口”
我刚接触AI Agent的时候,最大的认知误区就是把它当成一个“输入问题、输出答案”的接口。因为Java开发者习惯了RESTful API的思维模式:我发一个请求,你返回一个JSON,我解析完事。但Agent完全不是这个逻辑。
Agent的本质是一个循环决策系统。它拿到一个目标后,会自己决定下一步做什么、调用什么工具、拿到结果后判断是否继续、直到任务完成或达到终止条件。这跟Java里的状态机有点像,但区别在于:状态机的状态转移是你写死的,而Agent的下一步动作是大模型动态决定的。
举个例子。你让一个Agent“帮我查一下上个月销售额最高的三个产品,然后给对应的负责人发一封汇总邮件”。传统Java写法是:查数据库、排序、取Top3、查负责人信息、调邮件服务、发送。每一步都是你写死的。但Agent的做法是:它先理解这个任务需要拆成几步,然后自己决定先调数据库查询工具、再调邮件发送工具,中间如果发现负责人信息缺失,它还会自己去调通讯录接口补全。这个“自己决定”的能力,就是Agent和普通接口的本质区别。
理解这一点非常重要,因为它决定了你后面学的东西的方向。如果你还用“接口思维”去写Agent,你会把所有逻辑都写死在代码里,那Agent就退化成了一个普通的业务接口,大模型的存在感几乎为零。
2.2 Java在Agent时代的真实定位
很多Java工程师焦虑的点是“AI都是Python写的,Java是不是没戏了”。我实际做下来发现,这个担心是多余的,但也不能盲目乐观。
Python在AI Agent领域的优势确实明显:LangChain、LlamaIndex、AutoGen这些主流框架都是Python优先,模型训练和微调生态也基本围绕Python。但Agent落地到企业级应用时,问题就来了:你的业务系统是Java写的,你的数据库连接池是Java管的,你的权限体系是Java实现的,你的消息队列是Java在消费的。你不可能为了跑一个Agent就把整个后端重写一遍。
所以Java在Agent时代的真实定位是:Agent的执行层和集成层。大模型负责决策,Java负责执行。Agent决定“要查数据库”,Java的Service去查;Agent决定“要发消息”,Java的消息服务去发;Agent决定“要调第三方接口”,Java的HTTP客户端去调。这个分工是合理的,也是企业最容易接受的。
我现在的做法是:用Python做Agent的编排和实验,验证跑通后,把核心的工具调用层用Java实现,通过HTTP或消息队列对接。这样既享受了Python生态的便利,又保留了Java的工程稳定性。
2.3 必须搞清楚的几个核心概念
在动手之前,有几个概念你必须搞清楚,否则看文档都看不懂。
Token:这是大模型处理文本的基本单位。你可以粗略理解为“一个汉字约等于1到2个Token,一个英文单词约等于1到1.5个Token”。为什么重要?因为模型的上下文窗口是有限的,比如8K、32K、128K Token。你传给模型的提示词、历史对话、工具返回结果,全都占Token。超了就要截断或压缩,这是Agent开发中最常见的坑之一。
Function Calling:这是大模型调用外部工具的标准机制。你告诉模型“我有这几个函数可用”,模型根据用户问题决定调哪个、传什么参数,然后你的代码去执行这个函数,把结果再喂回模型。这是Agent能“做事”的关键。
ReAct模式:Reasoning + Acting的缩写。核心思路是让模型先“想一步”(Reasoning),再“做一步”(Acting),循环往复。这是目前最主流的Agent架构模式之一。
RAG:检索增强生成。简单说就是先从知识库里检索相关内容,再让模型基于检索结果回答。解决的是模型“不知道你公司内部文档”的问题。
这几个概念你不需要一开始就理解得很深,但至少要知道它们大概是什么、解决什么问题。我当初就是跳过了这一步直接看代码,结果看了三天都没搞明白为什么要有“工具描述”这个东西。
3. 技术补全:Java工程师需要补齐的四块拼图
3.1 第一块拼图:大模型交互基础
Java调大模型接口,技术上没有任何难度。用HttpClient或者OkHttp发个POST请求,body里带上model、messages、temperature这些参数,就能拿到返回。但“能调通”和“调得好”是两回事。
我一开始犯的错误是:把大模型当成一个“问答机器人”,用户问什么我就直接转发什么。结果就是模型经常答非所问,或者输出格式乱七八糟。后来我才明白,提示词的质量直接决定输出质量。你需要学会写System Prompt,告诉模型它的角色是什么、要遵守什么规则、输出格式是什么。
比如我在做一个客服Agent时,System Prompt是这样写的:
String systemPrompt = """ 你是一个电商客服助手。你的职责是回答用户关于订单、退换货、物流的问题。 规则: 1. 如果用户问题涉及具体订单,必须先调用queryOrder工具查询订单状态。 2. 如果用户要求退换货,必须先确认订单是否在退换货期限内。 3. 如果用户问题超出你的知识范围,回复“这个问题我需要转接人工客服”。 4. 所有回复必须简洁,不超过三句话。 """;这个Prompt里包含了角色定义、工具调用规则、边界处理和输出约束。写和不写,效果差距巨大。
另外,temperature参数也值得说一下。这个参数控制输出的随机性,范围一般是0到2。做Agent的时候,我一般设0.1到0.3,因为Agent需要稳定、可预测的输出,不需要“创意”。做文案生成时可以设0.7到1.0。这个参数的选择逻辑是:任务越需要确定性,temperature越低。
3.2 第二块拼图:Agent核心架构理解
目前主流的Agent架构可以归纳为三种模式,我用Java开发者熟悉的方式类比一下。
第一种:单Agent + 工具调用。这就像一个有多个Service的Controller。Agent本身是一个大模型,它手里有几个工具(函数),根据用户请求决定调哪个工具。适合场景明确的简单任务,比如“查天气”“查订单”“发邮件”。
第二种:多Agent协作。这就像微服务架构。每个Agent负责一个领域,有一个协调者Agent负责分发任务和汇总结果。比如一个“电商运营Agent”下面有“数据分析Agent”“文案生成Agent”“客服Agent”,协调者根据任务类型分发给对应的子Agent。适合复杂业务流程。
第三种:Plan-and-Execute模式。这就像工作流引擎。Agent先制定一个完整的执行计划,然后逐步执行,每执行一步可以调整后续计划。适合步骤多、依赖关系复杂的任务。
我实际项目中用得最多的是第一种和第二种的结合:一个主Agent负责理解用户意图和分发任务,子Agent各自负责具体执行。这种架构的好处是职责清晰,每个Agent的Prompt可以写得很聚焦,输出稳定性高。
3.3 第三块拼图:工具调用与函数编排
这是Java工程师最有优势的地方,也是最容易踩坑的地方。
工具调用的本质是:你把Java方法暴露给大模型,大模型决定什么时候调、传什么参数。但大模型不认识你的Java代码,它只认识你给它的“工具描述”。所以你需要为每个工具写一段描述,告诉模型这个工具是干什么的、参数是什么、什么时候用。
我踩过的坑是:工具描述写得太简略,导致模型不知道该用哪个工具。比如我有两个工具,一个叫queryOrder,一个叫queryLogistics,描述分别写“查询订单”和“查询物流”。结果用户问“我的包裹到哪了”,模型有时候调queryOrder,有时候调queryLogistics,因为它分不清这两个的区别。
后来我把描述改成了:
queryOrder:根据订单号查询订单的详细信息,包括商品、金额、下单时间、支付状态。不包含物流信息。queryLogistics:根据订单号查询物流轨迹,包括当前所在城市、预计送达时间、配送员联系方式。仅在有物流单号时可用。
改完之后,模型的选择准确率明显提升。这个经验告诉我:工具描述要写到“一个新人看了也知道什么时候用”的程度。
另一个坑是参数校验。大模型生成的参数不一定符合你的预期,可能类型不对、可能缺字段、可能传了不存在的值。所以每个工具方法内部必须做严格的参数校验,不能直接信任模型传来的参数。我的做法是在工具方法入口加一层校验,参数不合法就返回明确的错误信息,让模型知道“你传的参数有问题,请重新生成”。
3.4 第四块拼图:工程化落地思维
Java工程师做Agent最大的优势就是工程化思维,但这也可能变成劣势,因为Agent系统的不确定性和传统Java系统的确定性是冲突的。
传统Java系统里,你调一个方法,要么成功要么抛异常,结果是确定的。但Agent系统里,同样的输入,模型可能给出不同的输出,调用的工具可能不同,执行路径可能不同。这种不确定性对测试、监控、日志都提出了新的要求。
我的做法是:在Agent的每个关键节点加日志和埋点。模型输入是什么、输出是什么、调了哪个工具、参数是什么、返回结果是什么、耗时多少,全部记录下来。这样出问题的时候可以回溯,也方便后续做效果分析。
另外,超时和重试机制必须做好。大模型接口的响应时间波动很大,有时候2秒,有时候20秒。工具调用也可能超时。我的做法是给每个环节设置独立的超时时间,模型调用设30秒,工具调用设10秒,超时后走降级逻辑。
还有一个容易被忽略的点是成本控制。大模型调用是按Token计费的,Agent的循环调用很容易把Token消耗拉高。我一开始没注意,一个测试用例跑了十几轮循环,消耗了几十万Token。后来我加了两个限制:最大循环次数设5次,单次会话总Token数超过阈值就强制终止。这两个限制能有效防止“Agent陷入死循环烧钱”的问题。
4. 实战落地:从零搭建一个Java版Agent的完整过程
4.1 环境准备与技术选型
我的技术栈是这样的:Java 17 + Spring Boot 3.x + OkHttp + Jackson + 一个大模型API。没有用任何Agent框架,因为我想先理解底层原理,而且Java生态里成熟的Agent框架确实不多。
如果你刚开始,我建议你也先不要上框架。框架帮你封装了很多东西,但也屏蔽了很多细节。等你把底层跑通了,再考虑用框架提效。
依赖方面,核心就几个:
<dependency> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> <version>4.12.0</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.17.0</version> </dependency>OkHttp负责发HTTP请求,Jackson负责JSON序列化和反序列化。这两个就够了。
4.2 核心代码结构设计
我的Agent项目结构大概是这样的:
agent-core/ ├── model/ # 请求和响应的数据模型 │ ├── ChatMessage.java │ ├── ChatRequest.java │ └── ChatResponse.java ├── llm/ # 大模型客户端 │ └── LlmClient.java ├── tool/ # 工具定义和注册 │ ├── ToolDefinition.java │ ├── ToolRegistry.java │ └── impl/ │ ├── OrderQueryTool.java │ └── EmailSendTool.java ├── agent/ # Agent核心逻辑 │ └── ReActAgent.java └── controller/ # 对外接口 └── AgentController.java这个结构的好处是职责清晰:model管数据、llm管通信、tool管能力、agent管编排、controller管入口。跟Java的MVC分层是一个思路。
4.3 大模型调用层的实现
先看最基础的模型调用。核心方法是发一个POST请求,把消息列表传过去,拿到返回。
public class LlmClient { private final OkHttpClient client = new OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .build(); private final ObjectMapper mapper = new ObjectMapper(); private final String apiKey; private final String apiUrl; public LlmClient(String apiKey, String apiUrl) { this.apiKey = apiKey; this.apiUrl = apiUrl; } public ChatResponse chat(List<ChatMessage> messages, List<ToolDefinition> tools) { Map<String, Object> body = new HashMap<>(); body.put("model", "your-model-name"); body.put("messages", messages); body.put("temperature", 0.2); if (tools != null && !tools.isEmpty()) { body.put("tools", tools); } String json = mapper.writeValueAsString(body); Request request = new Request.Builder() .url(apiUrl) .header("Authorization", "Bearer " + apiKey) .header("Content-Type", "application/json") .post(RequestBody.create(json, MediaType.parse("application/json"))) .build(); try (Response response = client.newCall(request).execute()) { String responseBody = response.body().string(); return mapper.readValue(responseBody, ChatResponse.class); } } }这段代码里几个关键点:超时时间要设合理,readTimeout我设了30秒,因为模型生成有时候确实慢;temperature设0.2保证输出稳定;tools参数是可选的,不传就是普通对话,传了就是Agent模式。
4.4 工具注册与调用机制
工具的定义需要包含名称、描述、参数Schema。参数Schema用JSON Schema格式描述,告诉模型每个参数的类型和含义。
public class ToolDefinition { private String name; private String description; private Map<String, Object> parameters; // JSON Schema格式 // getter/setter省略 }工具注册中心负责管理所有可用工具,并提供执行入口:
public class ToolRegistry { private final Map<String, ToolExecutor> executors = new HashMap<>(); public void register(String name, ToolExecutor executor) { executors.put(name, executor); } public String execute(String name, Map<String, Object> args) { ToolExecutor executor = executors.get(name); if (executor == null) { return "错误:未找到工具 " + name; } try { return executor.execute(args); } catch (Exception e) { return "错误:工具执行失败 - " + e.getMessage(); } } public List<ToolDefinition> getDefinitions() { // 返回所有工具的定义列表 } }这里有个重要设计:工具执行失败时,不要抛异常,而是返回错误信息字符串。因为错误信息会喂回给模型,模型看到错误后可以决定重试或者换一种方式。如果你直接抛异常,整个Agent循环就断了。
4.5 ReAct循环的完整实现
ReAct循环是Agent的核心。逻辑是:把用户问题发给模型,模型返回要么是最终答案,要么是工具调用请求。如果是工具调用,执行工具,把结果追加到消息列表,再次发给模型,如此循环。
public class ReActAgent { private final LlmClient llmClient; private final ToolRegistry toolRegistry; private static final int MAX_ITERATIONS = 5; public String run(String userInput) { List<ChatMessage> messages = new ArrayList<>(); messages.add(ChatMessage.system(buildSystemPrompt())); messages.add(ChatMessage.user(userInput)); for (int i = 0; i < MAX_ITERATIONS; i++) { ChatResponse response = llmClient.chat(messages, toolRegistry.getDefinitions()); ChatMessage assistantMsg = response.getFirstChoice().getMessage(); messages.add(assistantMsg); if (assistantMsg.hasToolCalls()) { for (ToolCall call : assistantMsg.getToolCalls()) { String result = toolRegistry.execute( call.getName(), call.getArguments() ); messages.add(ChatMessage.tool(call.getId(), result)); } } else { return assistantMsg.getContent(); } } return "任务执行超过最大轮次,已终止。"; } }这段代码虽然短,但包含了Agent的所有核心要素:系统提示词、消息历史管理、工具调用判断、工具执行、结果回传、循环控制、终止条件。你把这几十行代码理解透了,Agent的原理就通了。
4.6 一个完整案例:订单查询Agent
我拿一个实际场景来串一遍。用户说“帮我查一下订单ORD-20240101的状态,如果还没发货就取消掉”。
第一轮:模型收到用户消息和工具列表,判断需要先查订单。返回工具调用queryOrder,参数{"orderId": "ORD-20240101"}。
执行工具:查数据库,返回{"status": "待发货", "amount": 299, "createTime": "2024-01-01"}。
第二轮:模型看到订单状态是“待发货”,判断需要调用取消订单工具。返回工具调用cancelOrder,参数{"orderId": "ORD-20240101"}。
执行工具:调用取消逻辑,返回{"success": true, "message": "订单已取消"}。
第三轮:模型看到取消成功,生成最终回复:“订单ORD-20240101当前状态为待发货,已为您成功取消。”
整个过程三轮循环,两次工具调用。这就是一个完整的Agent执行链路。
5. 踩坑实录:那些文档里不会告诉你的问题
5.1 模型不按格式返回怎么办
这是最常见的问题。你明明在Prompt里写了“请以JSON格式返回”,但模型有时候就是返回一段带解释的文字。我的解决方案是三层防护:
第一层,Prompt里明确格式要求,并给出示例。第二层,代码里做解析容错,尝试从返回文本中提取JSON部分。第三层,如果解析失败,把错误信息喂回模型,让它重新生成。
实测下来,加了示例之后,格式错误率从大概30%降到了5%以下。剩下5%靠代码容错和重试兜底。
5.2 工具调用参数类型不匹配
模型生成的参数类型经常和你的预期不一致。比如你期望一个整数,模型传了字符串"123";你期望一个数组,模型传了逗号分隔的字符串。我的做法是在工具执行前加一层类型转换和校验,能转就转,不能转就返回错误让模型重试。
5.3 循环次数失控
Agent有时候会陷入“调工具、失败、重试、再失败”的死循环。我遇到过最夸张的一次,一个工具因为网络问题一直超时,Agent重试了十几次,烧了不少Token。后来我加了两个硬限制:最大循环次数5次,单个工具连续失败3次就强制终止并返回错误。
5.4 上下文Token超限
多轮对话加上工具返回结果,消息列表会越来越长。超过模型上下文窗口后,要么报错,要么模型开始“遗忘”前面的内容。我的处理策略是:保留System Prompt和最近N轮对话,中间的工具调用结果做摘要压缩。具体保留多少轮,取决于你的上下文窗口大小和单轮平均Token数。
5.5 并发场景下的状态管理
Agent是有状态的,消息列表在循环中不断增长。如果你的Agent服务是多线程的,每个请求必须有自己的消息列表实例,不能共享。我一开始犯过这个错误,把消息列表定义成了类的成员变量,结果两个用户同时请求时消息串了。后来改成每次请求创建新的列表,问题解决。
6. 学习路线与资源推荐
6.1 分阶段学习路径
如果你现在完全零基础,我建议按这个顺序来:
第一阶段(1到2周):理解大模型基本概念,能调通API,能写有效的Prompt。这个阶段不需要写复杂代码,用Postman或者简单的Java main方法就行。
第二阶段(2到3周):理解Function Calling机制,能定义工具、注册工具、处理工具调用。这个阶段把ReAct循环跑通。
第三阶段(3到4周):做一个完整的Agent项目,包含至少3个工具、错误处理、日志记录、超时控制。这个阶段的目标是“能演示”。
第四阶段(持续):优化Prompt、优化工具描述、加监控、加成本控制、处理边界情况。这个阶段的目标是“能上线”。
6.2 Java工程师的差异化优势
不要试图把自己变成Python工程师,那是舍本逐末。你的优势在于:工程化能力、系统设计能力、稳定性保障能力。Agent的Demo谁都能跑,但能把Agent稳定运行在生产环境、能处理各种异常、能控制成本、能做监控告警的,还是需要Java工程师的功底。
我现在的定位很清晰:我不做模型训练,不做算法调优,我做的是Agent的工程化落地。把大模型的能力通过Java服务稳定地输出给业务系统,这就是我的价值。
6.3 常见面试题准备
如果你在准备转型面试,这几个问题大概率会被问到:
- Agent和普通大模型调用的区别是什么?
- Function Calling的工作原理是什么?
- 如何防止Agent陷入死循环?
- 多轮对话中如何管理上下文?
- 如何评估一个Agent的效果?
这些问题的答案,你在前面的内容里都能找到。关键是要结合自己的实际项目经验来回答,不要背概念。
7. 我个人的一些实操心得
最后分享几个我在实际项目中总结出来的小技巧,都是文档里不会写的。
工具数量控制在7个以内。工具太多,模型选择准确率会下降。如果确实需要很多工具,先做一层分类,让主Agent先选类别,再选具体工具。
System Prompt里加“思考步骤”引导。比如写“在调用工具前,先用一句话说明你为什么选择这个工具”。这样模型的决策过程会体现在输出里,方便你调试。
工具返回结果要精简。不要把整个数据库查询结果都返回给模型,只返回模型需要的关键字段。返回结果越长,Token消耗越大,模型理解成本也越高。
做好降级方案。模型服务不可用时,Agent应该能降级到“抱歉,当前服务繁忙”的兜底回复,而不是直接报错。
定期回顾日志。我每周会抽时间看一遍Agent的执行日志,分析哪些工具调用频繁、哪些Prompt效果不好、哪些场景模型容易出错。这个习惯帮我发现了很多优化点。
转型这件事,说难也难,说简单也简单。难的是迈出第一步,简单的是只要你动手做了,后面的路会越来越清晰。我用了大半年时间从零走到能独立交付Agent项目,你如果每天能投入两小时,三个月应该能到差不多的水平。关键不是学多少理论,而是动手把第一个Agent跑起来。跑起来之后,一切都会变得具体。