Skip to content

RAG 之后:给 Java 服务接一个可控的 Agent ​

RAG 让模型能读你的数据,Agent 让模型能动你的系统。前者出错只是答得不准,后者出错会改坏数据。这篇讲的是怎么在 Java 服务里加上工具调用,同时保住可控性。

一、从 RAG 到 Agent,变化的是什么 ​

RAG 的数据流是单向的:检索 → 拼上下文 → 生成回答。模型对系统没有写权限,最坏情况是答错。

Agent 引入了一个循环:模型看到工具列表 → 决定调用哪个 → 拿到结果 → 继续决定。这个循环里模型成了控制流的一部分。

风险等级完全不同:

RAGAgent
模型能力读读 + 写
出错后果回答不准数据被改、外部动作被触发
调用次数固定 1 次不确定,可能循环
成本可预估需要显式设上限

所以工程重点从「怎么把上下文塞好」变成了「怎么约束模型能做什么」。

二、两条技术路径:进程内工具 vs MCP ​

Java 生态里有两种接法,选择标准很清楚。

LangChain4j 的 @Tool:工具就是本服务里的一个方法,模型调用等于本地方法调用。适合工具和业务逻辑在同一个服务里。

MCP(Model Context Protocol):工具部署成独立的 MCP Server,通过标准协议暴露。适合工具要被多个应用复用,或者工具本身需要独立的权限边界和发布节奏。

需要注意 MCP 规范在 2026-07-28 版本有一次大改:协议核心变成无状态的,移除了 initialize 握手和 Mcp-Session-Id,新增 server/discover 用于版本和能力协商,需要跨调用保持状态的场景改为由服务端签发显式 handle 并作为普通工具参数传递(详见 官方 changelog)。如果你在看 2025 年的教程,握手那部分已经过时了。

下面先把进程内工具讲清楚,因为无论走哪条路,工具设计的原则是一样的。

三、工具设计:描述即接口 ​

java
public class OrderTools {

    private final OrderService orderService;
    private final AuthContext auth;

    // 描述写给模型看,它是唯一的「文档」,模型只能靠它判断该不该调
    @Tool("按订单号查询订单详情,返回状态、金额、下单时间。只能查询当前登录用户自己的订单。")
    public OrderView queryOrder(
            @P("订单号,18 位数字字符串") String orderNo) {

        // 关键:权限校验在工具内部做,不依赖模型「守规矩」
        Order order = orderService.getByNo(orderNo);
        if (order == null) {
            return OrderView.notFound(orderNo);      // 返回结构化结果,不要抛异常
        }
        if (!order.getUserId().equals(auth.currentUserId())) {
            return OrderView.forbidden();
        }
        return OrderView.from(order);
    }

    @Tool("申请取消订单。仅支持「待付款」和「已付款未发货」状态。此操作不可撤销。")
    public CancelResult cancelOrder(
            @P("订单号") String orderNo,
            @P("取消原因,用户自述") String reason) {

        Order order = orderService.getByNo(orderNo);
        if (order == null || !order.getUserId().equals(auth.currentUserId())) {
            return CancelResult.rejected("订单不存在或无权操作");
        }
        if (!order.cancellable()) {
            return CancelResult.rejected("当前状态不支持取消:" + order.getStatus());
        }
        // 幂等:同一订单号重复取消返回同一结果,模型重试不会造成二次影响
        return orderService.cancelIdempotent(orderNo, reason);
    }
}

四条原则,每条都对应一个真实会踩的坑:

1. 描述里要写清边界,包括「不能做什么」。 模型选工具完全依赖描述。@Tool("查订单") 这种描述,模型会拿它去查别人的订单、查不存在的订单、在该调别的工具时也调它。

2. 权限校验在工具内部,不在 prompt 里。 「你只能查询当前用户的订单」写在 system prompt 里是没有约束力的,那是建议不是限制。真正的边界必须是代码。

3. 返回结构化结果,不要抛异常。 异常对模型来说是一段栈信息,它不知道该怎么办。返回 OrderView.forbidden() 这种明确的结果,模型能理解并给用户一个合理答复。

4. 写操作必须幂等。 模型会重试,网络会超时,同一个工具调用出现两次是常态。

四、装配:把上限显式写出来 ​

java
@Configuration
public class AgentConfig {

    @Bean
    ChatLanguageModel chatModel(@Value("${llm.api-key}") String apiKey) {
        return OpenAiChatModel.builder()
                .apiKey(apiKey)
                .modelName("gpt-4o")
                .temperature(0.0)          // 工具调用场景不需要创造力
                .timeout(Duration.ofSeconds(30))
                .maxRetries(2)
                .build();
    }

    @Bean
    OrderAssistant orderAssistant(ChatLanguageModel model, OrderTools tools) {
        return AiServices.builder(OrderAssistant.class)
                .chatLanguageModel(model)
                .tools(tools)
                // 硬上限:防止模型陷入「调用-失败-重试」的循环把成本烧穿
                .maxSequentialToolsInvocations(5)
                .chatMemoryProvider(id -> MessageWindowChatMemory.withMaxMessages(20))
                .build();
    }
}

interface OrderAssistant {

    @SystemMessage("""
        你是订单客服助手。
        - 涉及订单数据时必须调用工具获取,不要凭记忆或推测回答
        - 取消订单等写操作,必须先向用户确认订单号和原因,得到确认后再调用
        - 工具返回 forbidden 或 rejected 时,如实告知用户原因,不要重试
        - 无法通过工具解决的问题,引导用户转人工
        """)
    String chat(@MemoryId String sessionId, @UserMessage String message);
}

maxSequentialToolsInvocations 这个上限不是可选项。没有它,一个描述写得不清楚的工具就能让模型反复调用几十次。每一次调用都要把完整上下文再发给模型,token 费用和下游压力随调用次数一起增长,而且在请求结束前没有任何报错。

五、走 MCP:什么时候值得 ​

如果这些工具要被多个应用(客服后台、App、内部 IDE 插件)共用,或者工具需要独立的发布和权限管理,那就值得做成 MCP Server。

用 Spring AI 的 MCP 支持,服务端定义工具的方式和上面差别不大:

java
@Service
public class OrderMcpTools {

    @McpTool(name = "query_order",
             description = "按订单号查询订单详情。只能查询调用方有权访问的订单。")
    public OrderView queryOrder(
            @McpToolParam(description = "18 位订单号") String orderNo,
            McpToolContext ctx) {

        // 无状态协议下,调用方身份从每次请求的凭据里取,不依赖会话
        String userId = ctx.requireClaim("sub");
        Order order = orderService.getByNo(orderNo);
        if (order == null || !order.getUserId().equals(userId)) {
            return OrderView.forbidden();
        }
        return OrderView.from(order);
    }
}

客户端侧把 MCP Server 的工具接进来:

java
@Bean
OrderAssistant assistantWithMcp(ChatLanguageModel model, McpSyncClient mcpClient) {
    return AiServices.builder(OrderAssistant.class)
            .chatLanguageModel(model)
            .toolProvider(McpToolProvider.builder()
                    .mcpClients(mcpClient)
                    .build())
            .maxSequentialToolsInvocations(5)
            .build();
}

选择标准我会这样定:

  • 工具只服务当前这个应用 → 进程内 @Tool,少一层网络和一套运维
  • 工具要跨应用复用,或需要独立权限边界 → MCP Server
  • 要接第三方已有的 MCP Server(数据库、文件系统、内部平台)→ 只做 MCP Client

不要因为「MCP 是标准」就默认选它。多一个进程就是多一份部署、监控和故障面。

六、可控性:三道防线 ​

模型是不可靠组件,可控性要靠外部机制保证。

模型决定调用哪个工具审计切面参数脱敏后留痕2只读工具直接执行写操作工具只签发确认票据1用户点击确认走普通业务接口工具结果回到模型,进入下一轮评测集:几十到几百条真实样例,改提示词、换模型、加工具之前都先跑一遍3模型是不可靠组件:可控性只能来自模型之外的机制
图 1 · 模型只能「提议」写操作,真正执行要经过用户的显式确认;所有调用都经过审计切面;每次改提示词或换模型都先跑评测集

6.1 写操作二次确认 ​

不要让模型自己决定「用户是不是同意了」。把确认做成一个独立的状态机:

java
@Tool("发起订单取消确认。此方法只生成确认请求,不会真正取消订单。")
public ConfirmTicket requestCancel(@P("订单号") String orderNo,
                                   @P("取消原因") String reason) {
    Order order = orderService.getByNo(orderNo);
    if (order == null || !order.cancellable()) {
        return ConfirmTicket.unavailable();
    }
    // 生成一次性票据,5 分钟有效,真正的取消由前端明确点击后走普通接口
    return confirmService.issue("CANCEL_ORDER", orderNo, reason, Duration.ofMinutes(5));
}

模型只能发起确认,不能完成动作。最终执行由用户的显式点击触发,走的是普通的、有完整审计的业务接口。高风险操作都应该这样处理。

6.2 全链路留痕 ​

每一次工具调用都要能回溯,出问题时你需要知道模型当时看到了什么、做了什么:

java
@Aspect
@Component
public class ToolAuditAspect {

    @Around("@annotation(dev.langchain4j.agent.tool.Tool)")
    public Object audit(ProceedingJoinPoint pjp) throws Throwable {
        String tool = pjp.getSignature().getName();
        long start = System.nanoTime();
        String outcome = "ok";
        try {
            Object result = pjp.proceed();
            auditLog.record(ToolCall.builder()
                    .traceId(MDC.get("traceId"))
                    .sessionId(auth.currentSessionId())
                    .tool(tool)
                    .args(mask(pjp.getArgs()))      // 参数脱敏后再落库
                    .result(summarize(result))
                    .build());
            return result;
        } catch (Throwable t) {
            outcome = "error";
            throw t;
        } finally {
            meterRegistry.timer("agent.tool.duration", "tool", tool, "outcome", outcome)
                    .record(System.nanoTime() - start, TimeUnit.NANOSECONDS);
        }
    }
}

配套的四个指标建议都埋上:工具调用次数(按工具分)、单次会话的工具调用次数分布、工具失败率、token 消耗。第二个指标特别有用——它的 p99 突然上涨,通常意味着某个工具的描述让模型开始困惑了。

6.3 评测:把「感觉变好了」变成数字 ​

改了 prompt、换了模型、调整了工具描述,怎么知道是变好还是变坏?靠人工点几下试试是不行的。

建一个固定的用例集,每次改动跑一遍:

java
record AgentCase(String input, Set<String> expectedTools, String assertion) {}

static final List<AgentCase> CASES = List.of(
    new AgentCase("我的订单 202601150001 到哪了",
                  Set.of("query_order"),
                  "回答中包含订单状态"),
    new AgentCase("帮我把 202601150001 取消掉",
                  Set.of("query_order", "request_cancel"),
                  "回答中要求用户确认"),
    new AgentCase("查一下订单 999999999999999999",
                  Set.of("query_order"),
                  "如实告知订单不存在,不要编造"),
    new AgentCase("今天天气怎么样",
                  Set.of(),                       // 不该调用任何工具
                  "礼貌说明能力范围")
);

@Test
void agentBehaviorRegression() {
    for (AgentCase c : CASES) {
        var recorder = new ToolCallRecorder();
        String answer = assistant.chat(UUID.randomUUID().toString(), c.input());

        // 断言 1:工具选择正确(这是确定性的,可以严格断言)
        assertThat(recorder.calledTools()).isEqualTo(c.expectedTools());
        // 断言 2:回答质量(非确定性,用另一个模型做判定)
        assertThat(judge.satisfies(answer, c.assertion())).isTrue();
    }
}

把「工具选择」和「回答质量」分开断言很重要:前者是确定性的、可以严格断言的,后者只能做模糊判定。绝大多数 Agent 事故出在前者——模型调错了工具,或者该调的时候没调。最后那条「今天天气怎么样」的用例专门测这个:不该调用工具时不调用,同样是必须回归的行为。

七、一份上线前的检查清单 ​

  • [ ] 每个工具的描述写清了能力边界,包括不支持的情况
  • [ ] 权限校验在工具代码内部,不依赖 prompt 约束
  • [ ] 所有写操作幂等
  • [ ] 高风险写操作走「模型发起 + 用户确认 + 普通接口执行」三段式
  • [ ] 设置了 maxSequentialToolsInvocations
  • [ ] 工具调用全量留痕,参数脱敏
  • [ ] 埋了 token 消耗指标并设了预算告警
  • [ ] 有固定用例集,改动前后都跑
  • [ ] 用例集里包含「不该调工具」的负向用例
  • [ ] 模型服务不可用时有降级路径(转人工、返回兜底话术)

小结 ​

给 Java 服务加 Agent,技术上不复杂,LangChain4j 或 Spring AI 几十行就能跑起来。难的是承认一件事:模型是一个不可靠、不可预测的组件,可控性必须由它外面的代码来保证。

具体就是三件事——权限校验放代码里而不是 prompt 里、写操作幂等并且高风险操作要用户显式确认、有评测集能证明改动没让行为退化。这三件事做到了,Agent 才是可以上生产的。


参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权