RAG 之后:给 Java 服务接一个可控的 Agent
RAG 让模型能读你的数据,Agent 让模型能动你的系统。前者出错只是答得不准,后者出错会改坏数据。这篇讲的是怎么在 Java 服务里加上工具调用,同时保住可控性。
一、从 RAG 到 Agent,变化的是什么
RAG 的数据流是单向的:检索 → 拼上下文 → 生成回答。模型对系统没有写权限,最坏情况是答错。
Agent 引入了一个循环:模型看到工具列表 → 决定调用哪个 → 拿到结果 → 继续决定。这个循环里模型成了控制流的一部分。
风险等级完全不同:
| RAG | Agent | |
|---|---|---|
| 模型能力 | 读 | 读 + 写 |
| 出错后果 | 回答不准 | 数据被改、外部动作被触发 |
| 调用次数 | 固定 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 年的教程,握手那部分已经过时了。
下面先把进程内工具讲清楚,因为无论走哪条路,工具设计的原则是一样的。
三、工具设计:描述即接口
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. 写操作必须幂等。 模型会重试,网络会超时,同一个工具调用出现两次是常态。
四、装配:把上限显式写出来
@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 支持,服务端定义工具的方式和上面差别不大:
@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 的工具接进来:
@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 是标准」就默认选它。多一个进程就是多一份部署、监控和故障面。
六、可控性:三道防线
模型是不可靠组件,可控性要靠外部机制保证。
6.1 写操作二次确认
不要让模型自己决定「用户是不是同意了」。把确认做成一个独立的状态机:
@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 全链路留痕
每一次工具调用都要能回溯,出问题时你需要知道模型当时看到了什么、做了什么:
@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、换了模型、调整了工具描述,怎么知道是变好还是变坏?靠人工点几下试试是不行的。
建一个固定的用例集,每次改动跑一遍:
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 才是可以上生产的。
参考资料