用 Java + Spring 搭一个简单的 Agent

  created  by  鱼鱼 {{tag}}
创建于 2026年09月30日 14:51:51 最后修改于 2026年09月30日 15:28:17

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

一、背景:为什么用 Java 做 Agent

很多企业的后端系统本身就是 Java 写的:订单、库存、权限、审批流都在 Spring 服务里。让 Agent 去“调用这些能力”,有两种思路:

  1. 用 Python 另起一个 Agent 服务,通过 HTTP 调用 Java 业务接口。好处是 Python 生态工具丰富,坏处是多一套技术栈,团队要同时维护两套部署、监控、权限体系。

  2. 直接在 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 到哪了?”

把上面的组件串起来,看一次请求的完整时序,有助于你在出问题时快速定位:

  1. 前端调用 POST /api/agent/chat,Controller 校验输入长度,并从认证信息里拿到当前用户;

  2. AgentService 构造初始消息(系统提示 + 用户问题),第一次调用 LlmClient;

  3. 模型返回“调用 query_order,参数 orderId=A123456”;

  4. ToolRegistry 找到 QueryOrderTool,工具先校验订单号格式,再查库,检查订单是否属于当前用户,最后返回一句精简的状态描述;

  5. 这句描述作为 tool 消息追加到历史,第二次调用模型;

  6. 模型根据订单状态组织一句自然语言回答,没有新的工具调用,循环结束,结果返回给前端。

如果第 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:

  1. 先跑通“一个工具 + 一个循环”,用日志确认每一步的输入输出;

  2. 加入最大步数、超时与错误回传,保证不会卡死;

  3. 接入会话存储,让多轮对话有上下文;

  4. 再引入 Micrometer 指标和流式输出,为上线做准备;

  5. 最后才考虑向量库、多 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 的官方示例开始。

下一步建议:

  1. 把本文的骨架在本地跑通,用假模型测试循环,再接入真实模型;

  2. 选一个你们业务里只读、低风险的场景(例如查订单状态)做第一个工具;

  3. 补上监控与评测样本,再逐步扩展到写操作与更多工具;

  4. 结合本系列其他文章,依次加入记忆、规划、安全与评估能力。

评论区
评论
{{comment.creator}}
{{comment.createTime}} {{comment.index}}楼
评论

用 Java + Spring 搭一个简单的 Agent

用 Java + Spring 搭一个简单的 Agent

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

一、背景:为什么用 Java 做 Agent

很多企业的后端系统本身就是 Java 写的:订单、库存、权限、审批流都在 Spring 服务里。让 Agent 去“调用这些能力”,有两种思路:

  1. 用 Python 另起一个 Agent 服务,通过 HTTP 调用 Java 业务接口。好处是 Python 生态工具丰富,坏处是多一套技术栈,团队要同时维护两套部署、监控、权限体系。

  2. 直接在 Java 服务里实现 Agent。好处是可以直接调用业务代码(不必再包一层 HTTP)、复用已有的事务、权限、日志、监控与配置体系,团队技能栈统一;坏处是部分新框架和示例以 Python 为主,Java 社区需要多花点时间适配。

选哪条路,取决于团队的现状。对以 Java 为主的团队,把“简单、边界清楚、强依赖业务系统”的 Agent 放在 Java 里,往往更省心。

二、技术选型

Java 生态里常见的选择有:

这些框架的 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 到哪了?”

把上面的组件串起来,看一次请求的完整时序,有助于你在出问题时快速定位:

  1. 前端调用 POST /api/agent/chat,Controller 校验输入长度,并从认证信息里拿到当前用户;

  2. AgentService 构造初始消息(系统提示 + 用户问题),第一次调用 LlmClient;

  3. 模型返回“调用 query_order,参数 orderId=A123456”;

  4. ToolRegistry 找到 QueryOrderTool,工具先校验订单号格式,再查库,检查订单是否属于当前用户,最后返回一句精简的状态描述;

  5. 这句描述作为 tool 消息追加到历史,第二次调用模型;

  6. 模型根据订单状态组织一句自然语言回答,没有新的工具调用,循环结束,结果返回给前端。

如果第 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 配置连接与读取超时,并限制并发
在工具里用了“当前用户” 异步/线程池中取不到登录信息 显式传递用户上下文,或使用支持上下文传播的线程池
密钥写进代码或日志 泄露风险 使用环境变量或密钥管理服务;日志脱敏
会话历史无限增长 成本和延迟越来越高 设置最大轮数,做摘要或裁剪
流式输出断开后仍在运行 无人接收但持续消耗模型调用 监听连接关闭事件,取消后台任务
只在本地用“假数据”测试 上线后遇到真实模型的格式差异 用真实模型小规模评测,再上线

十二、上线前检查清单

十三、从哪里开始迭代

建议按下面的顺序逐步完善你的 Java Agent:

  1. 先跑通“一个工具 + 一个循环”,用日志确认每一步的输入输出;

  2. 加入最大步数、超时与错误回传,保证不会卡死;

  3. 接入会话存储,让多轮对话有上下文;

  4. 再引入 Micrometer 指标和流式输出,为上线做准备;

  5. 最后才考虑向量库、多 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 的官方示例开始。

下一步建议:

  1. 把本文的骨架在本地跑通,用假模型测试循环,再接入真实模型;

  2. 选一个你们业务里只读、低风险的场景(例如查订单状态)做第一个工具;

  3. 补上监控与评测样本,再逐步扩展到写操作与更多工具;

  4. 结合本系列其他文章,依次加入记忆、规划、安全与评估能力。


用 Java + Spring 搭一个简单的 Agent2026-09-30鱼鱼

{{commentTitle}}

评论   ctrl+Enter 发送评论