文章定位 :全栈架构 / AI Agent 通识教学 核心标签 :
AI AgentJSON-RPC 2.0CompletableFutureMCP协议Java并发
很多开发者在接触 AI Agent 的工具调用(Tool Calling)时,往往只停留在"大模型返回了一段 JSON 参数"这一层。但一个工业级的 Agent 框架(如基于 MCP - Model Context Protocol 构建的系统),究竟是如何把大模型的意图,精准、安全地传输给本地脚本或远端微服务的?
如果遇到本地脚本死循环,Java 主线程会不会被彻底拖垮?
本文将从 Agent 的 RPC 2.0 协议入手,带你硬核拆解一条完整的 Agent 工具通信链路 ,并深度解析如何利用 CompletableFuture 实现优雅的 Pending(挂起)请求与超时管理。这不仅是后端开发的高频面试题,更是构建稳健 AI 系统的核心基石。
1. 破冰:大模型 ToolCall 与真实工具执行的"鸿沟"
当大模型(LLM)决定调用工具时,它输出的仅仅是一个意图结构,比如:
json
{
"id": "call_1",
"function": {
"name": "mcp__chrome-devtools__navigate_page",
"arguments": "{\"url\":\"https://github.com\"}"
}
}
这个结构是 Agent 体系的"输入",但底层的工具服务(比如一个用 Node.js 写的浏览器控制脚本,或者一个 Python 写的远端 RAG 服务)根本不认识这个对象。
为了抹平语言和进程间的差异,工业界引入了标准协议------JSON-RPC 2.0。我们需要一个组装层,把 LLM 的意图翻译成标准 RPC 请求:
json
{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "navigate_page",
"arguments": {
"url": "https://github.com"
}
}
}
2. 架构拆解:协议层与传输层的正交设计
在优秀的 Agent 底层通信链路中,"发什么包"和"怎么发包"必须被严格拆分开来。
协议层(JsonRpcClient)
全权负责 JSON-RPC 2.0 的请求/响应组装、ID 分配以及生命周期(Pending)管理。它不关心底层是跑在同一个机器上的脚本,还是远在天边的云服务。
传输层(Transport)
负责真正的 I/O 交互。根据工具的部署形态,我们通常会做两种路由:
| 传输模式 | 适用场景 | 底层实现核心 |
|---|---|---|
| StdioTransport | 本地脚本工具(如 npx、uvx 启动的工具) |
ProcessBuilder 启动子进程,通过标准输入(stdin)写入 JSON,标准输出(stdout)读取响应 |
| HttpTransport | 远端微服务(如部署在云端的搜索服务) | OkHttp 发送 POST 请求,支持普通 JSON 响应及 Server-Sent Events (SSE) 持续输出 |
路由判定逻辑极简 :读取配置驱动。如果配置了 url 则走 HTTP;如果配置了 command 则走 Stdio。
3. 核心难点:如何管理 JSON-RPC 的 Pending 请求?
这是全链路中最关键的护城河,也是最高频的面经考点。
业务痛点: Agent 发起了一个本地脚本工具调用,如果这个 Python/Node 脚本发生了死循环(没有向 stdout 返回 JSON 结果),你的 Java 主线程(业务线程)会不会一直卡死在读取等待上?
破局点:CompletableFuture 结合定时调度器
在 JsonRpcClient 发送请求时,我们不让主线程直接阻塞在 I/O 上,而是利用 ConcurrentHashMap 与 CompletableFuture 构建请求级超时。
核心落地代码:
java
// 存放挂起请求的容器
private final Map<Long, CompletableFuture<JsonNode>> pending = new ConcurrentHashMap<>();
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
public CompletableFuture<JsonNode> request(String method, JsonNode params, long timeoutSeconds) {
long id = ids.getAndIncrement();
// 1. 组装 JSON-RPC 2.0 包
ObjectNode request = MAPPER.createObjectNode();
request.put("jsonrpc", "2.0");
request.put("id", id);
request.put("method", method);
request.set("params", params);
// 2. 创建 Future 并注册到 Pending Map
CompletableFuture<JsonNode> future = new CompletableFuture<>();
pending.put(id, future);
// 3. 埋下定时"炸弹" (协议级超时)
scheduler.schedule(() -> {
// 注意:必须使用 remove,避免与正常响应并发竞争
CompletableFuture<JsonNode> removed = pending.remove(id);
if (removed != null) {
removed.completeExceptionally(new TimeoutException("JSON-RPC request timed out: " + method));
}
}, timeoutSeconds, TimeUnit.SECONDS);
// 4. 交给底层的 Transport 发送
transport.send(request);
return future;
}
为什么是 CompletableFuture?
- 天然防卡死 :业务线程调用
future.get(timeout + 1, TimeUnit.SECONDS)。如果超时,定时器会自动将 Future 标记为异常完成,业务线程立刻解锁抛出异常,绝不会被永远拖死。 - 外部手动唤醒 :当底层的 Stdio Daemon 线程或 HTTP 异步线程收到响应时,可以根据 ID 找到这个 Future,并调用
future.complete(result)主动唤醒业务线程。 - 并发安全与幂等 :网络响应与超时调度存在并发竞争,利用
ConcurrentHashMap.remove()抢夺任务,谁先拿到谁执行complete,防重复回调。
4. 边界澄清:请求级超时 ≠ 进程级清理
一个极其严谨的架构师(或 AI Agent)必须认清这里的工程边界:
如果本地子进程死循环触发了上层的 TimeoutException,主线程得救了,但死循环的子进程本身并没有被杀掉 。 因为单次工具请求的超时,不应该直接引发暴力的 kill -9。真正的进程级回收(process.destroy() 或 destroyForcibly())应该交给 McpTransport.close(),在 Server 级生命周期结束或重启时统一执行。这是一种经典的"请求级释放,进程级回收"的分层哲学。
5. 一图胜千言:Agent 工具通信完整链路
为了方便各位直接背诵或喂给 Agent 记忆,这里提供一张核心通信架构图:
text
┌──────────────────────────────────────┐
│ Agent / ToolRegistry │
│ LLM 意图翻译为内部 ToolCall 对象 │
└──────────────────────────────────────┘
│ (配置路由分发)
▼
┌──────────────────────────────────────┐
│ JsonRpcClient │
│ - 组装 JSON-RPC 2.0 标准报文 │
│ - pending[id] = CompletableFuture │
│ - scheduler 执行协议级超时控制 │
└──────────────────────────────────────┘
│ │
(走本地脚本) │ │ (走远端服务)
┌───────────┘ └───────────┐
▼ ▼
┌────────────────────────────┐ ┌────────────────────────────┐
│ StdioTransport │ │ StreamableHttpTransport │
│ (ProcessBuilder 启动进程) │ │ (OkHttp POST 传输) │
│ ├─ stdin ← send(JSON) │ │ ├─ body: JSON-RPC 2.0 │
│ ├─ stdout → 异步读取响应 │ │ ├─ 支持 普通JSON/SSE │
└────────────────────────────┘ └────────────────────────────┘
│ │
└──────────────────────┬──────────────────────────┘
▼
┌──────────────────────────────────────┐
│ ToolExecutionResult 回灌 │
│ 作为 tool role 消息返回给下一轮 LLM │
└──────────────────────────────────────┘
总结:可以喂给 Agent 的"面试八股"标准格式
如果你正在开发一个能应对高压面试的 CodeRAG 智能体,请把以下逻辑注入它的知识库:
Q:Agent 的工具调用链路为什么要拆分协议层和传输层? A :为了正交解耦。"协议层"负责把请求统一包装成 JSON-RPC 2.0,并利用 CompletableFuture 处理 Pending 匹配和超时,解决"发什么、怎么管"的问题;"传输层"负责底层 I/O 交互(Stdio 进程通信或 HTTP 网络请求),解决"通道"问题。这样新增任何第三方工具形态,上层逻辑都无需重构。
Q:如何保证工具卡死时主线程不崩溃? A :通过两层超时控制。底层有 HTTP 客户端的网络超时;协议层利用 ConcurrentHashMap 管理 CompletableFuture,并配合 ScheduledExecutorService 实现请求级超时。发生阻塞时,定时器会主动 completeExceptionally 唤醒业务线程,防止主流程被永久挂起。同时遵循工程边界:单次请求超时释放线程资源,而卡死的本地进程留存至 Transport 关闭时统一销毁。