作为 Java 爱好者,很多人会问:Agent 开发是不是只能用 Python?其实不是。Agent 的核心是“调用模型 + 执行工具 + 循环”,用 Java 同样可以优雅地实现,并且能直接复用 Spring 生态里的依赖注入、配置、监控等能力。本文会一步步搭出一个可以扩展的 Agent 骨架:定义工具接口与注册表、封装模型客户端、实现带保护的 Agent 循环、暴露 REST 接口与会话存储,最后给出监控、测试、常见坑与迭代路线。

一、背景:为什么用 Java 做 Agent
很多企业的后端系统本身就是 Java 写的:订单、库存、权限、审批流都在 Spring 服务里。让 Agent 去“调用这些能力”,有两种思路:
用 Python 另起一个 Agent 服务,通过 HTTP 调用 Java 业务接口。好处是 Python 生态工具丰富,坏处是多一套技术栈,团队要同时维护两套部署、监控、权限体系。
直接在 Java 服务里实现 Agent。好处是可以直接调用业务代码(不必再包一层 HTTP)、复用已有的事务、权限、日志、监控与配置体系,团队技能栈统一;坏处是部分新框架和示例以 Python 为主,Java 社区需要多花点时间适配。
选哪条路,取决于团队的现状。对以 Java 为主的团队,把“简单、边界清楚、强依赖业务系统”的 Agent 放在 Java 里,往往更省心。
二、技术选型
Java 生态里常见的选择有:
Spring AI:Spring 官方项目,提供
ChatClient、工具调用、向量库抽象等能力;LangChain4j:Java 版的 LLM 应用开发库,支持工具、记忆、RAG 等;
直接调用 HTTP API:使用
HttpClient或RestClient对接模型服务,最灵活,也最便于理解原理。
这些框架的 API 更新较快,具体类名和方法请以你使用版本的官方文档为准。本文为了便于理解,用 自己定义接口 的方式展示核心结构,你可以很容易地替换为具体框架。
| 方案 | 上手难度 | 灵活度 | 适合场景 | 需要注意 |
|---|---|---|---|---|
| Spring AI | 低(Spring 开发者熟悉) | 中 | 与 Spring 项目深度集成 | API 迭代较快,注意版本 |
| LangChain4j | 中 | 中 | 需要现成的记忆、RAG 组件 | 与 Spring 集成需额外配置 |
| 直接调 HTTP | 中高(自己处理细节) | 高 | 想理解原理、需要完全控制 | 要自己处理重试、流式、格式差异 |
三、整体结构
Controller ──▶ AgentService(循环控制) │ ┌─────────┼──────────┐ ▼ ▼ ▼ LlmClient ToolRegistry Memory (调用模型) (Spring Bean) (会话历史)

各层职责如下:
| 层次 | 职责 | 不应该做的事 |
|---|---|---|
| Controller | 接收请求、鉴权、返回结果 | 不写 Agent 循环逻辑 |
| AgentService | 控制循环、步数、错误处理 | 不直接拼接模型厂商的 HTTP 细节 |
| LlmClient | 封装对模型服务的调用 | 不关心具体有哪些业务工具 |
| ToolRegistry | 管理并查找工具 | 不做业务逻辑 |
| Memory | 保存会话历史 | 不参与决策 |
四、定义工具:用接口 + Spring Bean
让每个工具实现统一接口,由 Spring 自动收集成注册表:
public interface AgentTool {
String name();
String description();
String parametersSchema(); // JSON Schema 字符串
String execute(Map<String, Object> args) throws Exception;
}
@Component
public class CurrentTimeTool implements AgentTool {
public String name() { return "current_time"; }
public String description() { return "获取服务器当前时间,格式 yyyy-MM-dd HH:mm:ss"; }
public String parametersSchema() { return "{\"type\":\"object\",\"properties\":{}}"; }
public String execute(Map<String, Object> args) {
return LocalDateTime.now().format(
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
}
@Component
public class ToolRegistry {
private final Map<String, AgentTool> tools;
// Spring 会注入所有 AgentTool 实现
public ToolRegistry(List<AgentTool> list) {
this.tools = list.stream()
.collect(Collectors.toMap(AgentTool::name, Function.identity()));
}
public Optional<AgentTool> find(String name) { return Optional.ofNullable(tools.get(name)); }
public Collection<AgentTool> all() { return tools.values(); }
}
这样新增一个工具,只需要新增一个 @Component 类,无需修改其他代码——这正是 Spring 依赖注入带来的好处。
再写一个贴近业务的工具:查询订单
下面的工具演示了参数校验、权限检查、结果精简三件事。OrderService 是你已有的业务服务,CurrentUser 用来获取当前登录用户(这里是示意,按你的项目实际实现):
@Component
public class QueryOrderTool implements AgentTool {
private final OrderService orderService; // 已有的业务服务,直接注入复用
private final CurrentUser currentUser;
public QueryOrderTool(OrderService orderService, CurrentUser currentUser) {
this.orderService = orderService;
this.currentUser = currentUser;
}
@Override public String name() { return "query_order"; }
@Override public String description() {
return "根据订单号查询当前用户自己的订单状态。只能查询本人订单;"
+ "不能查询他人订单,也不能修改订单。orderId 形如 A123456。";
}
@Override public String parametersSchema() {
return "{\"type\":\"object\",\"properties\":{"
+ "\"orderId\":{\"type\":\"string\",\"pattern\":\"^A\\\\d{6}$\","
+ "\"description\":\"订单号,例如 A123456\"}},"
+ "\"required\":[\"orderId\"]}";
}
@Override
public String execute(Map<String, Object> args) {
Object raw = args.get("orderId");
if (!(raw instanceof String orderId) || !orderId.matches("^A\\d{6}$")) {
return "参数不合法:orderId 必须形如 A123456"; // 程序侧二次校验,不信任模型
}
Order order = orderService.findById(orderId);
if (order == null) {
return "订单 " + orderId + " 不存在,请让用户核对订单号";
}
if (!order.getUserId().equals(currentUser.id())) { // 权限检查:只能看自己的订单
return "无权查看该订单";
}
// 只返回模型需要的精简信息,不返回整个实体
return "订单 " + orderId + ":状态=" + order.getStatus()
+ ",金额=" + order.getAmount() + " 元,下单时间=" + order.getCreatedAt();
}
}
注意几点:描述里明确写了“只能查本人订单”;参数在 Schema 里声明,程序侧仍然校验一遍;权限检查使用的是当前登录用户,而不是模型传来的用户 ID;返回值只含必要字段。
五、封装模型客户端
把对模型服务的调用封装在一个接口后面,Agent 循环就不依赖任何厂商。下面先定义数据结构和接口,再给一个基于 RestClient 的骨架实现(请求和响应的具体字段因服务商不同而异,请按所用服务的文档填写):
public record Message(String role, String content, String toolName, String toolArgsJson) {
public static Message system(String c) { return new Message("system", c, null, null); }
public static Message user(String c) { return new Message("user", c, null, null); }
public static Message tool(String name, String c) { return new Message("tool", c, name, null); }
public static Message assistantToolCall(LlmReply r) {
return new Message("assistant", null, r.toolName(), r.argumentsJson());
}
}
public record LlmReply(String content, String toolName, Map<String, Object> arguments,
String argumentsJson, int totalTokens) {
public boolean hasToolCall() { return toolName != null; }
}
public interface LlmClient {
LlmReply chat(List<Message> messages, Collection<AgentTool> tools);
}
@Component
public class HttpLlmClient implements LlmClient {
private final RestClient http;
private final AgentProperties props;
public HttpLlmClient(RestClient.Builder builder, AgentProperties props) {
this.props = props;
this.http = builder.baseUrl(props.getLlm().getBaseUrl())
.defaultHeader("Authorization", "Bearer " + props.getLlm().getApiKey())
.build();
}
@Override
public LlmReply chat(List<Message> messages, Collection<AgentTool> tools) {
// 1) 按所用服务的格式构造请求体:messages + tools 的 name/description/parameters
Map<String, Object> body = buildRequestBody(messages, tools);
// 2) 发送请求;超时在 RestClient 的 requestFactory 中统一配置
String json = http.post().uri("/chat/completions")
.contentType(MediaType.APPLICATION_JSON)
.body(body)
.retrieve()
.body(String.class);
// 3) 解析:有工具调用则填 toolName/arguments,否则填 content
return parseReply(json);
}
// buildRequestBody / parseReply 依赖具体服务商的字段格式,这里省略
}
这里的 /chat/completions 只是常见的路径示意,真实路径和字段请以你使用的模型服务文档为准。把这部分隔离在一个类里,以后换服务商只需要改这一个类。
六、Agent 循环
@Service
public class AgentService {
private static final int MAX_STEPS = 8;
private final LlmClient llm;
private final ToolRegistry registry;
public AgentService(LlmClient llm, ToolRegistry registry) {
this.llm = llm;
this.registry = registry;
}
public String run(String userInput) {
List<Message> messages = new ArrayList<>();
messages.add(Message.system("你是一个会使用工具的助手。"));
messages.add(Message.user(userInput));
for (int step = 0; step < MAX_STEPS; step++) {
LlmReply reply = llm.chat(messages, registry.all());
if (!reply.hasToolCall()) {
return reply.content(); // 最终答案
}
String observation;
try {
observation = registry.find(reply.toolName())
.orElseThrow(() -> new IllegalArgumentException("未知工具"))
.execute(reply.arguments());
} catch (Exception e) {
observation = "工具执行失败:" + e.getMessage(); // 回传给模型
}
messages.add(Message.assistantToolCall(reply));
messages.add(Message.tool(reply.toolName(), observation));
}
return "已达到最大步数,任务未完成。";
}
}
这段代码与前面 Python 版本的结构完全一致:循环 + 最大步数 + 工具错误回传。
加固版:超时、预算、重复调用检测与日志
上面的版本用于理解原理。真正准备上线前,建议至少补上下面几项:单次任务总耗时限制、token 预算、对“连续重复同一调用”的检测,以及结构化日志。
@Service
public class RobustAgentService {
private static final Logger log = LoggerFactory.getLogger(RobustAgentService.class);
private final LlmClient llm;
private final ToolRegistry registry;
private final AgentProperties props;
private final MeterRegistry metrics;
public RobustAgentService(LlmClient llm, ToolRegistry registry,
AgentProperties props, MeterRegistry metrics) {
this.llm = llm; this.registry = registry; this.props = props; this.metrics = metrics;
}
public String run(String userInput) {
String traceId = UUID.randomUUID().toString().substring(0, 8);
long deadline = System.nanoTime() + props.getMaxSeconds() * 1_000_000_000L;
int tokens = 0;
String lastCallSignature = null;
int repeatCount = 0;
List<Message> messages = new ArrayList<>();
messages.add(Message.system("你是一个会使用工具的助手。信息不足时先调用工具;不确定就直说。"));
messages.add(Message.user(userInput));
for (int step = 1; step <= props.getMaxSteps(); step++) {
if (System.nanoTime() > deadline) return "任务耗时超限,已停止。";
if (tokens > props.getMaxTokens()) return "token 预算已用尽,已停止。";
LlmReply reply = llm.chat(messages, registry.all());
tokens += reply.totalTokens();
if (!reply.hasToolCall()) {
log.info("[{}] step={} 最终答案,累计 tokens={}", traceId, step, tokens);
return reply.content();
}
// 检测连续重复:同一工具 + 同样参数连续出现,多半是陷入了循环
String signature = reply.toolName() + ":" + reply.argumentsJson();
repeatCount = signature.equals(lastCallSignature) ? repeatCount + 1 : 0;
lastCallSignature = signature;
if (repeatCount >= 2) {
messages.add(Message.user("你已重复调用同一工具且参数相同,请换一种方式或直接说明无法完成。"));
continue;
}
String observation = invokeTool(traceId, step, reply);
messages.add(Message.assistantToolCall(reply));
messages.add(Message.tool(reply.toolName(), observation));
}
return "已达到最大步数,任务未完成。";
}
private String invokeTool(String traceId, int step, LlmReply reply) {
long t0 = System.currentTimeMillis();
String outcome = "success";
try {
AgentTool tool = registry.find(reply.toolName())
.orElseThrow(() -> new IllegalArgumentException(
"未知工具 " + reply.toolName() + ",可用:" + registry.names()));
String out = tool.execute(reply.arguments());
return out.length() > 2000 ? out.substring(0, 2000) + "…(已截断)" : out;
} catch (Exception e) {
outcome = "error";
return "工具执行失败:" + e.getClass().getSimpleName() + ": " + e.getMessage();
} finally {
long cost = System.currentTimeMillis() - t0;
// 只记录工具名、耗时和结果类型;参数可能含敏感数据,需脱敏后再记录
log.info("[{}] step={} tool={} outcome={} cost={}ms",
traceId, step, reply.toolName(), outcome, cost);
metrics.timer("agent.tool.duration", "tool", reply.toolName(), "outcome", outcome)
.record(cost, TimeUnit.MILLISECONDS);
}
}
}
这里用到的 registry.names() 只需要在 ToolRegistry 中补一个返回工具名集合的方法即可。
走一遍:“我的订单 A123456 到哪了?”
把上面的组件串起来,看一次请求的完整时序,有助于你在出问题时快速定位:
前端调用
POST /api/agent/chat,Controller 校验输入长度,并从认证信息里拿到当前用户;AgentService构造初始消息(系统提示 + 用户问题),第一次调用LlmClient;模型返回“调用
query_order,参数orderId=A123456”;ToolRegistry找到QueryOrderTool,工具先校验订单号格式,再查库,检查订单是否属于当前用户,最后返回一句精简的状态描述;这句描述作为
tool消息追加到历史,第二次调用模型;模型根据订单状态组织一句自然语言回答,没有新的工具调用,循环结束,结果返回给前端。
如果第 4 步中订单不属于当前用户,工具返回“无权查看该订单”,模型会据此回复用户“无法查询该订单”,而不会拿到任何他人订单的数据——权限判断发生在代码里,而不是依赖模型的“自觉”。这也是为什么我们强调工具层必须自己做权限检查。
七、用 Spring 的能力增强 Agent
| 需求 | Spring 中的做法 |
|---|---|
| 配置模型地址与密钥 | application.yml + @ConfigurationProperties,密钥用环境变量 |
| 超时与重试 | RestClient 超时设置,配合 Spring Retry |
| 限制并发 | 线程池 / 信号量,避免同时请求过多模型 |
| 监控指标 | Micrometer 记录调用次数、耗时、token 用量 |
| 会话存储 | Redis 或数据库保存历史,按用户隔离 |
| 流式输出 | SseEmitter 或 WebFlux 把模型输出实时推给前端 |
agent:
llm:
base-url: ${LLM_BASE_ URL}
api-key: ${LLM_API_KEY} # 不要把密钥写进代码仓库
timeout-seconds: 60
max-steps: 8
max-tokens: 30000
max-seconds: 90
对应的配置类:
@ConfigurationProperties(prefix = "agent")
public class AgentProperties {
private Llm llm = new Llm();
private int maxSteps = 8;
private int maxTokens = 30000;
private int maxSeconds = 90;
public static class Llm {
private String baseUrl;
private String apiKey;
private int timeoutSeconds = 60;
// getter / setter 省略
}
// getter / setter 省略
}
别忘了在启动类或配置类上使用 @EnableConfigurationProperties(AgentProperties.class)(或 @ConfigurationPropertiesScan)让它生效。

八、对外暴露:REST 接口与会话存储
有了 AgentService,对外提供接口就很简单。下面演示一个带会话 ID 的接口,会话历史的存取抽象成接口,开发阶段可以用内存实现,上线时替换成 Redis 或数据库:
public interface ConversationStore {
List<Message> load(String userId, String conversationId);
void save(String userId, String conversationId, List<Message> messages);
}
@RestController
@RequestMapping("/api/agent")
public class AgentController {
private final AgentService agentService;
public AgentController(AgentService agentService) {
this.agentService = agentService;
}
public record ChatRequest(@NotBlank @Size(max = 2000) String message) {}
public record ChatResponse(String answer) {}
@PostMapping("/chat")
public ChatResponse chat(@Valid @RequestBody ChatRequest req, Principal principal) {
// principal 来自认证体系,绝不从请求体里读取用户 ID
String answer = agentService.run(req.message());
return new ChatResponse(answer);
}
}
几个需要留意的细节:输入长度要限制(防止超长输入拖垮成本);用户身份来自认证信息,而不是请求参数;会话存储要按用户隔离,并设置过期时间;如果要做流式输出,可以把 AgentService 的回调接到 SseEmitter,但同时要处理客户端中途断开时取消后台的 Agent 任务,避免“没人看了还在烧钱”。
九、测试:不用真实模型也能测 Agent 循环
Agent 的循环逻辑完全可以用“假模型”来测试,既快又稳定。下面用 JUnit 5 写一个最小的测试:脚本化的 FakeLlmClient 先请求调用工具,再给出最终答案。
class AgentServiceTest {
/** 按预设脚本依次返回回复的假模型 */
static class FakeLlmClient implements LlmClient {
private final Deque<LlmReply> script;
FakeLlmClient(LlmReply... replies) { this.script = new ArrayDeque<>(List.of(replies)); }
@Override
public LlmReply chat(List<Message> messages, Collection<AgentTool> tools) {
return script.removeFirst();
}
}
@Test
void shouldCallToolThenAnswer() {
LlmClient fake = new FakeLlmClient(
new LlmReply(null, "current_time", Map.of(), "{}", 10),
new LlmReply("现在是测试时间", null, null, null, 10));
AgentTool timeTool = new CurrentTimeTool();
AgentService service = new AgentService(fake, new ToolRegistry(List.of(timeTool)));
String answer = service.run("现在几点?");
assertEquals("现在是测试时间", answer);
}
@Test
void shouldStopAtMaxSteps() {
// 模型永远请求调用工具,验证不会无限循环
LlmClient looping = (messages, tools) ->
new LlmReply(null, "current_time", Map.of(), "{}", 10);
AgentService service = new AgentService(looping,
new ToolRegistry(List.of(new CurrentTimeTool())));
assertTrue(service.run("test").contains("最大步数"));
}
}
这两个测试覆盖了最重要的两个行为:正常的“调用工具—回答”流程,以及不会无限循环。再加上“工具抛异常时错误会回传给模型”“未知工具名不会崩溃”,核心循环就有了不错的保障。至于模型本身的回答质量,则需要另外的评测集来衡量,不属于单元测试的范畴。
十、几点提醒
工具方法里要做参数校验和权限检查,不要因为“是模型调用的”就放松警惕。
写操作类工具(删除、发送)建议加入人工确认。
给模型调用设置超时与熔断,避免拖垮整个应用。
日志里记录每一步的工具名和耗时,但要避免记录密钥和敏感数据。
十一、常见坑与解决办法
| 坑 | 表现 | 解决办法 |
|---|---|---|
| 工具在 Spring 里没被注册 | 模型看不到新工具 | 确认工具类有 @Component 且在扫描范围内;启动日志打印已注册工具名 |
| 工具名重复 | 启动时 Collectors.toMap 抛 IllegalStateException |
保证名称唯一,或在注册表中给出更友好的报错 |
| 请求超时没设置 | 模型服务变慢时线程被长期占用 | 给 RestClient 配置连接与读取超时,并限制并发 |
| 在工具里用了“当前用户” | 异步/线程池中取不到登录信息 | 显式传递用户上下文,或使用支持上下文传播的线程池 |
| 密钥写进代码或日志 | 泄露风险 | 使用环境变量或密钥管理服务;日志脱敏 |
| 会话历史无限增长 | 成本和延迟越来越高 | 设置最大轮数,做摘要或裁剪 |
| 流式输出断开后仍在运行 | 无人接收但持续消耗模型调用 | 监听连接关闭事件,取消后台任务 |
| 只在本地用“假数据”测试 | 上线后遇到真实模型的格式差异 | 用真实模型小规模评测,再上线 |
十二、上线前检查清单
☐ 所有工具都有参数校验与权限检查,并使用认证信息而非模型传入的身份。
☐ 最大步数、最大 token、最大耗时均已配置。
☐ 模型调用有超时、有限次重试、并发受控。
☐ 写操作与高风险工具需要人工确认。
☐ 日志带 trace id,记录工具名与耗时,敏感信息已脱敏。
☐ 有 Micrometer 指标,能看到调用量、耗时、错误率、token 用量。
☐ 核心循环有单元测试(正常流程、最大步数、工具异常)。
☐ 准备了一批评测样本,用真实模型回归。
☐ 密钥来自环境变量或密钥管理服务。
十三、从哪里开始迭代
建议按下面的顺序逐步完善你的 Java Agent:
先跑通“一个工具 + 一个循环”,用日志确认每一步的输入输出;
加入最大步数、超时与错误回传,保证不会卡死;
接入会话存储,让多轮对话有上下文;
再引入 Micrometer 指标和流式输出,为上线做准备;
最后才考虑向量库、多 Agent 等更复杂的能力。
十四、常见问题(FAQ)
Q1:一定要用 Spring AI 或 LangChain4j 吗?不一定。它们能减少样板代码、提供现成的抽象,但如果需求简单,或想完全掌控细节,自己封装一个 LlmClient 也完全可行。建议先用自定义接口跑通流程,再评估是否引入框架。
Q2:工具方法可以直接调用我现有的 Service 吗?可以,这正是用 Java 做 Agent 的优势之一。但要注意:现有 Service 的权限假设可能是“调用者已经过身份验证并被信任”,而 Agent 场景下参数来自模型,所以必须在工具层补上参数校验和权限检查。
Q3:Agent 调用很慢,接口会不会超时?会有这个风险。一次任务包含多次模型调用,耗时可能较长。可以考虑:流式输出让用户尽早看到进展;把长任务改成异步执行,前端轮询或通过推送获取结果;并且始终设置总耗时上限。
Q4:怎么在 Java 里支持并行工具调用?当模型一次返回多个互不依赖的工具调用时,可以用 ExecutorService 或 CompletableFuture 并行执行,再把所有结果按顺序回传。并行时要注意线程安全、用户上下文传播,以及整体超时。
十五、小结
用 Java 写 Agent,关键不在语言,而在对“循环、工具、记忆”的理解。借助 Spring 的注入、配置和监控体系,你可以把一个小小的 Demo 逐步做成可维护的服务。如果想快速上手,可以从 Spring AI 或 LangChain4j 的官方示例开始。
下一步建议:
把本文的骨架在本地跑通,用假模型测试循环,再接入真实模型;
选一个你们业务里只读、低风险的场景(例如查订单状态)做第一个工具;
补上监控与评测样本,再逐步扩展到写操作与更多工具;
结合本系列其他文章,依次加入记忆、规划、安全与评估能力。


2026-09-30鱼鱼