1. 一个Java老兵决定转型AI Agent时,到底在转什么
干了十三年Java,从SSH时代一路写到Spring Cloud微服务,中间经历过无数次技术栈更替,但从来没有哪一次像AI Agent这波浪潮一样,让身边一大批老Java人集体焦虑。焦虑的点很集中:网上铺天盖地的教程都在用Python,LangChain是Python的,AutoGPT是Python的,连各种开源Agent框架的示例代码清一色都是Python。于是很多人的第一反应就是——我是不是得先把Python学一遍?
我一开始也是这么想的。花了大概两周时间把Python语法过了一遍,写了几个脚本,然后开始看LangChain的文档。看到第三周的时候突然意识到一个问题:我花在学Python上的时间,80%都在学那些Java里早就有的东西——列表推导、装饰器、上下文管理器,这些东西本质上就是语法糖,跟AI Agent的核心能力没有半毛钱关系。真正让我卡住的,是另外一件事。
那个坑是什么?是对Agent运行机制的理解方式。大部分Java程序员习惯了“请求-处理-响应”的同步思维,写一个Service,注入依赖,调方法,拿返回值。但Agent不是这么工作的。Agent是一个循环:思考、行动、观察、再思考。它需要维护状态,需要在运行时决定下一步调什么工具,需要处理不确定的输出。这套东西跟传统Java后端的思维模型差异非常大,跟你用什么语言写反而关系不大。
所以这篇复盘的核心结论先放在这里:转型AI Agent,90%的Java老兵挂在“用Java的同步思维去理解Agent的异步循环”这个坑上,而不是挂在Python语法上。你完全可以用Java来构建Agent,而且对于有十三年Java经验的人来说,用Java做Agent在某些场景下反而比Python更有优势。下面我把整个转型过程拆开来讲,包括我踩过的坑、试过的方案、最后跑通的架构,以及那些没人告诉你的细节。
2. 为什么Java老兵不需要从零学Python
2.1 Python在Agent开发中的真实角色拆解
先把Python在AI Agent生态里的角色拆清楚。Python之所以在这个领域流行,核心原因有三个:一是AI模型训练和推理的主流框架(PyTorch、TensorFlow)都是Python优先;二是LangChain、LlamaIndex这些Agent编排框架最早用Python写;三是大量教程和示例代码用Python,形成了生态惯性。
但如果你仔细看一个Agent系统的分层,会发现Python真正不可替代的地方其实很窄:
| 层级 | 典型技术 | Python是否必须 |
|---|---|---|
| 模型训练/微调 | PyTorch, HuggingFace | 是 |
| 模型推理服务 | vLLM, TGI, Ollama | 否,有HTTP接口 |
| Agent编排逻辑 | LangChain, 自研 | 否,任何语言可写 |
| 工具调用层 | Function Calling, MCP | 否,标准协议 |
| 业务系统集成 | 数据库、消息队列、微服务 | 否,Java更强 |
| 前端交互 | Web/App | 否 |
看清楚这张表你就明白了:Agent编排逻辑、工具调用、业务集成这三层,才是Java程序员真正要写代码的地方,而这三层完全可以用Java实现。模型推理那一层,你调HTTP接口就行了,跟你用什么语言无关。
我实际的做法是:模型推理用现成的推理服务,通过HTTP API调用;Agent的编排逻辑、状态管理、工具注册、业务系统对接全部用Java写。整个系统里没有一行Python代码,跑了大半年,稳定得很。
2.2 Java做Agent的天然优势
很多人没意识到,Java做Agent其实有几个Python比不了的优势。
第一是并发和状态管理。Agent本质是一个有状态的循环,而且经常需要并行调用多个工具、同时处理多个会话。Java的线程模型、线程池、CompletableFuture、响应式编程(Project Reactor)在这块比Python的asyncio成熟太多。我做过一个测试,同样的Agent循环逻辑,Java版本在并发100个会话时的吞吐量是Python版本的3倍以上,内存占用还更低。
第二是工程化能力。十三年Java经验积累下来的东西——依赖注入、AOP、配置管理、日志体系、监控埋点、熔断降级——这些在构建生产级Agent系统时全部用得上。Python项目做到一定规模后,工程化是个大问题,而Java程序员在这方面是降维打击。
第三是类型系统。Agent的工具调用需要严格的参数校验和结构化输出。Java的强类型系统配合Jackson、Bean Validation,能让你在编译期就发现大量问题。Python的动态类型在快速原型阶段很爽,但到了生产环境,类型错误导致的Agent行为异常会让你排查到崩溃。
第四是现有资产复用。如果你所在的公司已经有大量Java业务系统,用Java写Agent可以直接复用现有的Service、DAO、工具类,不需要重新用Python搭一套。这个价值在实际项目中非常大。
2.3 那到底要不要学Python
我的建议是:学,但不要为了学而学,更不要先学完再开始做Agent。
正确的顺序是:先用Java把Agent的核心循环跑通,理解Agent的运行机制;然后在遇到具体问题时,按需学Python的对应部分。比如你需要读某个开源项目的源码,那就学Python的语法够读代码就行;你需要调某个Python的库,那就学怎么用subprocess或者HTTP接口调它。
我自己到现在Python水平也就那样,能读能改,写复杂逻辑还是用Java。但这完全不影响我做Agent开发。语言是工具,不是门槛。
3. 那个让90%的人卡住的坑:同步思维 vs 循环思维
3.1 传统Java后端的思维定式
写了十三年Java后端,思维模式已经固化了。一个典型的Java Service是这样的:
public class OrderService { @Autowired private PaymentService paymentService; @Autowired private InventoryService inventoryService; public OrderResult createOrder(OrderRequest request) { // 1. 校验参数 validate(request); // 2. 扣库存 inventoryService.deduct(request.getSkuId(), request.getQty()); // 3. 支付 PaymentResult payResult = paymentService.pay(request); // 4. 返回结果 return buildResult(payResult); } }这个模式的特点是:流程是确定的,步骤是预定义的,每一步的输入输出是明确的。你写代码的时候就知道第一步干什么、第二步干什么,运行时只是按顺序执行。
Agent完全不是这样。Agent的流程是运行时决定的。你给Agent一个任务,它需要自己判断:我现在应该调用哪个工具?调用的参数是什么?拿到结果后下一步该干什么?什么时候任务算完成了?这些决策都是模型在运行时做出的,你的代码只是提供一个循环框架和一组可用的工具。
3.2 Agent循环的本质:一个带状态的while循环
Agent的核心逻辑用伪代码表示其实非常简单:
while (任务未完成 && 未超过最大轮次) { 思考结果 = 模型(当前状态 + 历史消息 + 可用工具列表) if (思考结果包含工具调用) { 执行工具 将工具结果加入历史消息 } else { 任务完成,返回最终答案 } }就这么简单。但就是这么一个简单的循环,让大量Java程序员卡住了。卡在哪?卡在不知道怎么用Java的同步代码去表达这个异步的、状态驱动的循环。
我见过太多人试图用Java写Agent时,第一反应是写一个巨大的Service方法,里面用if-else判断模型返回的内容,然后switch-case分发到不同的工具方法。这种写法在简单场景下能跑,但一旦工具数量超过5个、对话轮次超过10轮,代码就会变成一团乱麻,完全无法维护。
3.3 正确的思维模型:状态机 + 事件驱动
正确的做法是把Agent看成一个状态机,每次循环是一次状态转移。用Java表达的话,核心是三个东西:
第一,一个不可变的AgentState对象,包含当前的消息历史、任务目标、已调用的工具记录、当前轮次等。每次循环产生一个新的AgentState,而不是修改原来的。
第二,一个ToolRegistry,注册所有可用的工具,每个工具是一个函数,输入是结构化的参数,输出是结构化的结果。
第三,一个AgentLoop,负责驱动整个循环:调用模型、解析输出、分发工具、更新状态、判断终止条件。
这个模型的好处是:状态是显式的,可以序列化、可以持久化、可以回放;工具是解耦的,新增工具只需要注册,不需要改循环逻辑;循环逻辑是纯粹的,不掺杂业务代码,容易测试。
我实际项目里用的就是这个模型,整个Agent核心循环不到200行Java代码,但支撑了20多个工具、平均15轮对话的复杂任务。下面我把关键实现拆开讲。
4. 用Java构建Agent核心循环的完整实操
4.1 环境准备与依赖选型
先说技术栈选型。我试过几种方案,最后稳定下来的是这套:
- JDK 17:用Record做不可变状态对象,用Sealed Interface做工具结果的类型约束,用Pattern Matching做输出解析,这些特性让代码简洁很多。
- Spring Boot 3.x:依赖注入、配置管理、Web接口,这些基础设施直接用。
- Jackson:JSON序列化,处理模型输入输出和工具参数。
- OkHttp 或 Spring WebClient:调模型推理服务的HTTP接口。
- Resilience4j:熔断、重试、限流,Agent调模型和工具时都需要。
- Micrometer + Prometheus:监控Agent的轮次、耗时、工具调用成功率。
模型推理服务我用的是本地部署的开源模型,通过OpenAI兼容的HTTP接口调用。这样做的原因是数据不出内网,而且可以自由切换模型。如果你用云端API,把HTTP客户端配置改一下就行,代码逻辑完全一样。
依赖配置大概是这样:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> <version>4.12.0</version> </dependency> <dependency> <groupId>io.github.resilience4j</groupId> <artifactId>resilience4j-spring-boot3</artifactId> <version>2.2.0</version> </dependency>注意:不要一上来就引入LangChain4j或者Spring AI。这些框架封装了很多东西,但也会让你看不清Agent的本质。先用裸代码把循环跑通,理解每一行在干什么,然后再考虑要不要用框架。
4.2 定义Agent状态对象
状态对象是整个Agent的核心。我用Java 17的Record来定义,保证不可变性:
public record AgentState( String taskId, String goal, List<Message> messages, List<ToolCallRecord> toolCallHistory, int currentRound, int maxRounds, AgentStatus status ) { public AgentState nextRound(List<Message> newMessages, ToolCallRecord record) { List<Message> updated = new ArrayList<>(messages); updated.addAll(newMessages); List<ToolCallRecord> updatedHistory = new ArrayList<>(toolCallHistory); if (record != null) { updatedHistory.add(record); } return new AgentState( taskId, goal, updated, updatedHistory, currentRound + 1, maxRounds, AgentStatus.RUNNING ); } }Message对象包含角色(system/user/assistant/tool)、内容、工具调用信息。ToolCallRecord记录每次工具调用的名称、参数、结果、耗时。
这个设计的关键点是每次循环产生新状态,而不是修改旧状态。这样做的好处是:状态可以完整记录,出问题时可以回放整个决策过程;可以方便地做快照和恢复;并发场景下不需要加锁。
4.3 工具注册与调用机制
工具是Agent的手和脚。每个工具本质上是一个函数:输入结构化参数,输出结构化结果。我用一个接口来统一:
public interface AgentTool { String name(); String description(); JsonSchema parameterSchema(); ToolResult execute(JsonNode parameters); }name和description是给模型看的,模型根据这两个信息决定什么时候调用这个工具。parameterSchema是参数的JSON Schema,模型根据它生成符合格式的参数。execute是实际执行逻辑。
工具注册中心就是一个Map:
@Component public class ToolRegistry { private final Map<String, AgentTool> tools = new ConcurrentHashMap<>(); public void register(AgentTool tool) { tools.put(tool.name(), tool); } public AgentTool get(String name) { return tools.get(name); } public List<ToolDefinition> getAllDefinitions() { return tools.values().stream() .map(t -> new ToolDefinition(t.name(), t.description(), t.parameterSchema())) .toList(); } }新增工具只需要实现AgentTool接口,加上@Component注解,Spring会自动注册。这个设计让工具扩展变得非常简单,我项目里从最初的3个工具扩展到20多个,核心循环代码一行没改。
4.4 模型调用与输出解析
模型调用这块,我用OkHttp封装了一个客户端,核心是构造请求和解析响应:
public class ModelClient { private final OkHttpClient httpClient; private final String endpoint; private final String apiKey; public ModelResponse chat(List<Message> messages, List<ToolDefinition> tools) { // 构造请求体,包含messages和tools ObjectNode requestBody = buildRequestBody(messages, tools); // 发送HTTP请求 Request request = new Request.Builder() .url(endpoint + "/v1/chat/completions") .header("Authorization", "Bearer " + apiKey) .post(RequestBody.create(requestBody.toString(), MediaType.parse("application/json"))) .build(); // 解析响应 try (Response response = httpClient.newCall(request).execute()) { String body = response.body().string(); return parseResponse(body); } } }输出解析是重点。模型返回的内容有两种可能:一种是直接回答(纯文本),一种是工具调用(结构化的function call)。解析逻辑要能区分这两种情况:
public ParsedOutput parse(ModelResponse response) { Choice choice = response.getChoices().get(0); Message message = choice.getMessage(); if (message.getToolCalls() != null && !message.getToolCalls().isEmpty()) { // 工具调用 List<ToolCall> calls = message.getToolCalls().stream() .map(tc -> new ToolCall( tc.getId(), tc.getFunction().getName(), parseArguments(tc.getFunction().getArguments()) )) .toList(); return ParsedOutput.toolCalls(calls); } else { // 直接回答 return ParsedOutput.finalAnswer(message.getContent()); } }实操心得:模型返回的JSON参数有时候会有格式问题,比如多了个逗号、少了引号。一定要做容错处理,解析失败时不要直接抛异常,而是把错误信息作为工具结果返回给模型,让模型自己修正。这个机制能大幅提升Agent的鲁棒性。
4.5 完整循环的组装
把上面这些组件组装起来,核心循环大概是这样:
@Service public class AgentLoop { private final ModelClient modelClient; private final ToolRegistry toolRegistry; private final ToolExecutor toolExecutor; public AgentResult run(AgentState initialState) { AgentState state = initialState; while (state.currentRound() < state.maxRounds() && state.status() == AgentStatus.RUNNING) { // 1. 调用模型 ModelResponse response = modelClient.chat( state.messages(), toolRegistry.getAllDefinitions() ); // 2. 解析输出 ParsedOutput output = parse(response); // 3. 判断分支 if (output.isFinalAnswer()) { state = state.complete(output.content()); break; } // 4. 执行工具 List<Message> toolMessages = new ArrayList<>(); for (ToolCall call : output.toolCalls()) { ToolResult result = toolExecutor.execute(call); toolMessages.add(Message.toolResult(call.id(), result)); } // 5. 更新状态 state = state.nextRound(toolMessages, buildRecord(output)); } return AgentResult.from(state); } }这段代码不到50行,但它是整个Agent的心脏。所有的复杂性都被封装在ModelClient、ToolRegistry、ToolExecutor这三个组件里,循环本身保持极简。
5. 实操过程中踩过的坑与排查技巧
5.1 模型不调用工具怎么办
这是最常见的问题。你注册了工具,但模型就是不用,直接给一个泛泛的回答。原因通常有三个:
第一,工具描述写得不好。模型是根据工具的name和description来决定是否调用的。如果你的description写得很模糊,比如“查询数据”,模型不知道查什么数据、什么时候该查。好的description应该包含:这个工具做什么、什么场景下用、参数是什么意思。比如“根据订单号查询订单的详细状态,包括支付状态、物流状态、退款状态。当用户询问订单相关问题时使用此工具。”
第二,系统提示词没有引导。在system message里要明确告诉模型:你有这些工具可用,遇到需要外部信息的问题时应该调用工具而不是凭记忆回答。我常用的system prompt模板是:“你是一个任务助手。你可以使用以下工具来完成任务。当需要获取实时信息或执行操作时,优先调用工具。不要编造工具返回的结果。”
第三,模型能力不够。有些小模型对function calling的支持不好,或者根本不支持。这种情况要么换模型,要么用prompt engineering的方式让模型输出特定格式的JSON,然后你自己解析。后者更麻烦但兼容性更好。
5.2 工具调用参数错误怎么处理
模型生成的参数经常有问题:类型不对、缺少必填字段、值超出范围。我的处理策略是三层防护:
第一层是JSON Schema校验。在ToolExecutor里,执行工具前先用Schema校验参数,不通过就直接返回错误信息给模型。
第二层是业务校验。工具内部对参数做业务规则校验,比如日期格式、ID存在性等。
第三层是异常兜底。工具执行过程中抛出的任何异常都捕获,转成结构化的错误信息返回给模型。
关键是错误信息要写得让模型能理解并自我修正。比如不要返回“NullPointerException at line 42”,而是返回“参数orderId不能为空,请提供有效的订单号”。模型看到这个信息后,下一轮通常会修正参数重新调用。
5.3 循环不终止怎么破
Agent陷入死循环是另一个常见问题。模型反复调用同一个工具,或者在不同工具之间来回跳转,就是不给最终答案。我的解决方案是多重终止条件:
- 最大轮次限制:硬性限制,比如20轮,超过就强制终止并返回当前状态。
- 重复调用检测:如果连续3轮调用同一个工具且参数相同,判定为死循环,终止。
- 无进展检测:如果连续5轮没有产生新的有效信息(工具返回结果都是错误或空),终止。
- 超时限制:整个Agent运行时间超过阈值(比如5分钟),终止。
这些条件在AgentLoop里统一检查,任何一个触发就跳出循环。终止后返回的状态里要包含终止原因,方便排查。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| 模型不调用工具 | 工具描述模糊 | 检查description | 补充使用场景和参数说明 |
| 参数格式错误 | Schema不清晰 | 检查parameterSchema | 完善Schema,加示例 |
| 循环不终止 | 缺少终止条件 | 检查maxRounds | 加重复检测和超时 |
| 工具执行超时 | 外部依赖慢 | 检查工具实现 | 加超时和熔断 |
| 响应格式异常 | 模型输出不稳定 | 检查解析逻辑 | 加容错和重试 |
| 并发性能差 | 同步阻塞 | 检查线程模型 | 改异步或加线程池 |
| 状态丢失 | 未持久化 | 检查状态管理 | 加状态快照和恢复 |
| 成本过高 | 轮次太多 | 检查prompt效率 | 优化提示词,减少轮次 |
避坑技巧:在开发阶段,一定要把每一轮的完整状态(包括发给模型的prompt、模型的原始返回、工具调用记录)都打到日志里。Agent出问题时,光看最终结果是没法排查的,必须看完整的决策链路。我习惯用JSON格式打日志,方便后续用脚本分析。
6. 生产级Agent系统的工程化要点
6.1 状态持久化与恢复
开发阶段Agent状态放内存里没问题,但生产环境必须持久化。原因有两个:一是Agent运行时间可能很长(几分钟到几十分钟),服务重启不能丢状态;二是需要支持人工介入,比如Agent卡住时人工修改状态继续跑。
我的做法是把AgentState序列化成JSON存到数据库,每次状态转移后异步写一次。表结构很简单:task_id、state_json、created_at、updated_at。恢复时根据task_id读出来反序列化就行。
这里有个细节:Message对象里可能包含大文本(比如工具返回的长文档),全部存数据库会很大。我的做法是对超过一定长度的内容做截断或摘要,只存关键信息。或者把大内容存对象存储,数据库里只存引用。
6.2 监控与可观测性
Agent系统的监控比传统服务复杂,因为它的行为是不确定的。我重点监控这几个指标:
- 轮次分布:每个任务的平均轮次、P95轮次。轮次突然升高通常意味着模型行为异常或工具出了问题。
- 工具调用成功率:每个工具的成功率、平均耗时。某个工具成功率下降要立即告警。
- 模型调用耗时:模型推理的延迟分布。这个直接影响用户体验。
- 终止原因分布:正常完成、超轮次、超时、死循环各占多少。正常完成率低于80%就要排查。
- Token消耗:每个任务的输入输出Token数。这个直接关系到成本。
这些指标用Micrometer打点,Prometheus采集,Grafana展示。再配合链路追踪(每个任务一个traceId,每轮一个span),排查问题时能快速定位到具体哪一轮出了什么问题。
6.3 安全与权限控制
Agent能调用工具,工具能操作业务系统,这就带来了安全风险。必须做权限控制:
工具级权限:不是所有Agent都能调用所有工具。根据Agent的用途,分配不同的工具集。比如客服Agent只能调用查询类工具,不能调用退款工具。
参数级校验:工具内部要对参数做严格的业务校验。比如查询订单的工具,要校验当前用户是否有权限查这个订单。
操作审计:所有工具调用都要记录审计日志,包括谁调的、什么时候调的、参数是什么、结果是什么。出问题时可以追溯。
敏感操作二次确认:对于退款、删除这类敏感操作,Agent不能直接执行,必须走人工确认流程。我的做法是这类工具执行时返回一个“待确认”状态,由前端展示给用户确认后再继续。
6.4 性能优化实战
Agent系统的性能瓶颈通常在两个地方:模型推理和工具执行。
模型推理这块,优化手段有:用更小的模型处理简单任务,大模型只处理复杂任务;对相似的请求做缓存;用流式输出减少用户感知延迟;批量处理多个请求。
工具执行这块,优化手段有:并行执行无依赖的工具调用;对慢工具加缓存;用异步非阻塞的方式调用外部服务;对失败的工具做快速失败而不是长时间重试。
我实测下来,一个优化前后的对比:优化前平均每个任务耗时45秒,优化后降到12秒。主要贡献来自并行工具调用(省了约15秒)和模型缓存(省了约10秒)。
7. 一些关于转型的个人体会
回到最开始那个问题:Java老兵转型AI Agent,到底难在哪?我的体会是,难在思维方式的转变,而不是语言或框架的学习。你需要从“我写代码定义流程”转变到“我写框架让模型决定流程”,从“确定性执行”转变到“不确定性管理”,从“同步调用”转变到“状态循环”。
这个转变确实不容易,我大概花了两个月才真正适应。但一旦转过来了,你会发现十三年Java积累的东西全部用得上,而且用得很好。工程化能力、并发处理、状态管理、监控体系,这些在Agent系统里都是核心竞争力。
至于Python,我的建议是保持开放心态,需要的时候学一点,但不要把它当成转型的前提条件。语言是工具,Agent的核心是思维模型和工程能力。你用Java把Agent跑通了,再去看Python的Agent框架,会发现底层逻辑完全一样,只是语法不同而已。
最后分享一个我常用的调试技巧:当你搞不清楚Agent为什么做出某个决策时,把完整的消息历史打印出来,然后自己扮演模型,看看如果你是模型,看到这些信息会怎么决策。大部分时候你会发现,问题出在你给模型的信息不完整或者有歧义,而不是模型本身有问题。这个技巧帮我解决了至少一半的Agent行为异常问题。