1. 老协议的新甲方:AI Agent 这轮热潮为什么偏偏带火了 JSON-RPC 2.0
我第一次意识到 JSON-RPC 2.0 又火了,是在翻某个 AI Agent 开源项目的源码时。代码仓库里没有我想象中的 REST 接口设计,取而代之的是一排排类似{"jsonrpc": "2.0", "method": "tools.call", "params": {...}, "id": 7}的消息。说实话,那一瞬间我有种穿越感——这玩意儿不是 2010 年就该被 REST 拍死在沙滩上的老古董吗?怎么现在成了 Agent 生态里最眼熟的面孔?
这不是偶然。从 Language Server Protocol(LSP)到 Model Context Protocol(MCP),再到各种 Agent Harness 内部把工具调用、技能注册、会话管理串起来的通信层,JSON-RPC 2.0 的身影无处不在。它原本诞生在 XML-RPC 和 SOAP 混战的年代,却在十几年后等来了一个全新的甲方:AI Agent。
这篇文章是"漫话 Agent Harness"系列的前置篇。在聊 harness 本身之前,我们必须先把这套通信语言聊透——因为在几乎所有 Agent Harness 里,JSON-RPC 2.0 就是那个连接"智能决策"和"具体工具"的脐带。你不搞懂它,后面看 harness 的源码就像看一台没有说明书的机器。
1.1 先理清一个高频热词:harness 和 agent 到底什么关系
最近"harness 和 agent 区别"这个搜索词一直在涨,说明很多人被这两个概念绕晕了。我从实战视角给一个直白的划分:agent 是那颗会思考的脑子,harness 是支撑脑子运转的整个驾驶舱。
agent 负责规划、推理、决定下一步调用什么工具;它本身不关心消息怎么编码、进程怎么拉起、插件怎么注册、超时怎么处理。而 harness 管的是这些脏活累活:启动和销毁运行时、维护工具注册表、把 agent 的意图翻译成具体协议消息、隔离单个工具崩溃造成的损害、收集日志和轨迹供调试。
举个实际的例子,很多人搜过这么一条报错:agent harness runtime "codex" is unavailable because its plugin registration failed。注意关键词——plugin registration。这是 harness 层面的问题:某个插件在注册阶段挂了,导致整个 runtime 不可用。这时候 agent 本身可能毫不知情,它还在那儿等工具返回结果呢。这就是典型的"脑子没坏,驾驶舱出了故障"。
而 JSON-RPC 2.0,恰好就是驾驶舱里各个仪表盘、操纵杆和控制台之间通用的"语言"。你按下一个按钮(method 调用),仪表盘亮起一个数值(result 返回),所有设备之间都用统一格式的指令卡交流。这就是为什么聊 Agent Harness 之前,必须先聊这套协议。
1.2 为什么 2025 年回头看,这个协议反而成了最优解
在 AI Agent 爆发之前,JSON-RPC 2.0 最广为人知的"就业岗位"是 LSP——Visual Studio Code 里那个让编辑器懂得各种语言智能提示的协议。前端开发者天天用它,却很少有人意识到自己其实一直在跟 RPC 打交道。
Agent 热潮起来之后,情况完全不同了。工具调用(function calling)、技能注册(skill registration)、MCP 服务器、Agent SDK 的运行时通信,全都需要一个共同的东西:一种纯文本、无状态、能双向发起调用、并且天然带"请求-响应"关联机制的消息格式。翻遍现有的协议,JSON-RPC 2.0 几乎是唯一一个各方面都不需要做伤筋动骨改造的老将。后面我会展开讲它到底哪里适配,先记住这个结论:它不是被时代淘汰后又被挖出来的化石,而是一把本来就在,只是之前没被人用在刀刃上的钥匙。
2. JSON-RPC 2.0 的语法全景:三种句型加一张错误码表
不管你在哪个 Agent 项目里见到 JSON-RPC 2.0,它翻来覆去其实就那几种消息。学它不需要看几百页规范,把下面这套"句型"吃透就够用了。
2.1 Request / Response / Notification 三件套
Request(请求)是所有调用的起点。它长这样:
{ "jsonrpc": "2.0", "method": "tool.get_weather", "params": { "city": "Beijing", "unit": "celsius" }, "id": 1 }四个字段各司其职。"jsonrpc" 固定是 "2.0",用来告诉接收方"按哪个版本的规范理解我"。"method" 是被调用方法的名称,在 Agent 生态里,它对应的通常就是某个工具名、某个技能名、或者某个插件的公开接口。"params" 是参数,可以是数组(按位置传参)也可以是对象(按名称传参),我个人强烈建议在 Agent 场景一律用对象——因为 LLM 生成的参数天然是键值对,按位置传参很容易被模型搞错顺序。"id" 是这个请求的唯一标识,响应靠它来"对上号"。
Response(响应)是对请求的回应。成功时返回 result:
{ "jsonrpc": "2.0", "result": { "temperature": 23, "condition": "sunny" }, "id": 1 }失败时返回 error:
{ "jsonrpc": "2.0", "error": { "code": -32602, "message": "Invalid params: missing required field 'city'", "data": { "field": "city" } }, "id": 1 }注意 error 对象的三个子字段:code 是错误码,message 是人类可读的说明,data 是可选的附加信息。在 Agent Harness 里,data 这个字段特别有用——你可以把 LLM 需要拿到的结构化错误信息塞进去,让模型下一次调用时能自我纠正。
Notification(通知)是特殊形态的请求:它不带 id。
{ "jsonrpc": "2.0", "method": "agent.log", "params": { "level": "info", "message": "tool call finished" } }协议规范规定:通知不需要也不允许有响应。接收方处理完通知,绝不能回消息。这个设计的价值在于日志、进度上报、取消信号这类"说了就行、不用等回复"的场景。但在实际工程项目里,通知也是最容易被误用的地方——我后面专门有坑要讲。
2.2 错误对象与保留 code 区间
JSON-RPC 2.0 规范保留了下面几个标准错误码,实现方必须遵守:
| code | 含义 | 什么时候出现 |
|---|---|---|
| -32700 | Parse error | JSON 都解析不了,通常发生在传输层拿到一串乱码 |
| -32600 | Invalid Request | 消息结构不合法,比如缺 method、jsonrpc 版本不对 |
| -32601 | Method not found | 方法名在注册表里查不到 |
| -32602 | Invalid params | 参数类型或数量不对 |
| -32603 | Internal error | 方法内部抛了没兜住的异常 |
| -32000 到 -32099 | Server error | 给服务端实现自定义错误用的保留区间 |
从 -32768 到 -32000 这个范围是协议预留的,除了标准错误码,你可以在合规的前提下自定义。在 Agent Harness 里,一个很常见的做法是用 -32000 区间定义自己的语义,比如 -32001 表示"工具执行超时",-32002 表示"模型上下文过长"。
2.3 Batch 机制:一次投递一叠请求
Batch 指的是把多个请求打包成一个 JSON 数组一次发送:
[ {"jsonrpc": "2.0", "method": "tool.list", "id": 1}, {"jsonrpc": "2.0", "method": "tool.add", "params": [1, 2], "id": 2}, {"jsonrpc": "2.0", "method": "agent.log", "params": {"msg": "batch"}} ]响应则是一个数组,里面只包含那些带 id 的请求的响应。也就是说,上例中第三个通知在响应数组里不会出现。这个"数量不一定对等"的细节,坑过无数人。
Batch 在 Agent 场景里最常见的用法是并行工具调用:Agent 一次决定要同时查询三个城市的天气,harness 可以把三个工具调用打包成一批发出去,再集中收结果。这样能显著降低进程间通信的往返次数。
2.4 跟 REST、gRPC、原生 WebSocket 的一次正面比较
既然要做选型,就得知道为什么 Agent Harness 不选 REST 也不选 gRPC。我给几个关键维度的对比:
| 维度 | JSON-RPC 2.0 | REST | gRPC | 原生 WebSocket |
|---|---|---|---|---|
| 消息模型 | 命令式 RPC | 资源式 CRUD | 强类型 RPC | 任意消息 |
| 响应关联 | 内建 id 字段 | 天然一一对应 | 调用上下文 | 无内建机制 |
| 流式支持 | 规范不支持 | 靠 SSE 补丁 | 原生支持 | 天然支持 |
| 耦合程度 | 极低,无代码生成 | 中 | 高,需生成桩代码 | 低 |
| 可读性 | 好,纯 JSON | 好 | 差,二进制 | 视消息而定 |
| 服务发现 | 不管 | 不管 | 需要配套生态 | 不管 |
REST 的核心思想是"对资源进行增删改查",但 Agent 世界里你面对的是"请你执行一个动作"——查天气、跑代码、搜知识库,这些动词化语义硬套 REST 要么变成一堆自定义 action 端点,要么变成 POST 一个万能 body,最后只会比现在更乱。gRPC 的问题是太重:你得先定义 .proto 文件、生成客户端和服务端代码、处理 HTTP/2 连接管理,这对一个可能只有几十个方法的 Agent 工具层来说性价比太低了。而且 gRPC 的响应是二进制的,你没法拿一条消息喂给 LLM 做人肉调试。
JSON-RPC 2.0 正好在中间:足够结构化,又足够轻。纯文本意味着任何语言、任何环境都能解析;无状态意味着会话上下文完全由 harness 自己管理,协议层不需要记忆任何东西;id 机制让并发调用和批次调用天然可控。
3. 为什么 Agent Harness 偏偏选中它:从协议特性反推设计动机
技术选型从来不是看功能列表有多长,而是看几个核心痛点是否被精确命中。JSON-RPC 2.0 能被 Agent 生态反复 pick,靠的其实就是四个"恰好"。
3.1 method 名跟工具、技能、插件天然一一对应
Agent Harness 要解决的核心问题之一,是把一堆零散的工具、技能、插件整理成统一的调用入口。JSON-RPC 2.0 的 method 字段就是一个字符串——它可以是"tool.get_weather"、"skill.code_review"、"plugin.git_operations.push"。这种带命名空间的字符串在注册、过滤、鉴权上都非常干净。
我来给你看一下实际工程里的映射关系。一个 Agent 技能(skill)通常是一段预定义的提示词加上一个可执行函数。当 harness 启动时,它会扫描所有已注册的技能,把它们登记到方法注册表里:
methods = {} for skill in skills: methods[f"skill.{skill.name}"] = skill.executeLLM 在推理时输出的工具调用,经过解析之后变成这样的格式:
Call: {"method": "skill.code_review", "params": {"repo_path": "/tmp/demo", "language": "python"}}harness 拿到这个结构,把 method 拆解成命名空间和函数名,去注册表里找到对应处理函数,然后执行。你可以把 JSON-RPC 2.0 的 method 字段理解成"函数指针的名字"——整个工具注册表就是一个 string-to-function 的映射,没有任何多余概念。
3.2 无状态加显式关联,让上下文由外部管理
JSON-RPC 2.0 本身不维护任何会话状态。每个请求都是独立的,靠 id 字段完成响应关联,发起方自己管理"这个请求还在不在等待"。
这个设计在 Agent 场景里反而是优点。因为 Agent Harness 需要极其精细地控制上下文生命周期:一个 Agent 会话可能包含系统提示词、工具调用历史、token 统计、用户偏好等多层状态,如果协议层也掺和进来搞 session、搞 cookie,状态管理的复杂度会爆炸。让协议保持纯粹,把上下文全权交给 harness,这是一个非常干净的分层。
这么说吧:JSON-RPC 2.0 负责"把消息准确送到",harness 负责"记住一切"。协议层笨一点,上层才好控制。
3.3 双向调用:角色模糊才是 Agent 通信的常态
传统 C/S 架构里,角色是清晰的:客户端请求,服务端响应。但在 Agent 生态里,调用关系是双向的、动态的。
harness 可以调用 agent 的决策接口(比如"请根据以下工具结果做出下一步计划"),agent 需要调用工具获取环境信息,工具执行过程中可能通过 notification 向 harness 上报进度,甚至工具可以反过来调用一个"回调型"方法请求 harness 提供更多上下文。JSON-RPC 2.0 对"谁来发起请求"没有预设,它只定义了消息格式和关联规则,这让任何一端都能成为调用方。
MCP(Model Context Protocol)就是最典型的例子。它的整个会话流程——initialize、notifications/initialized、tools/list、tools/call——全部是 JSON-RPC 2.0 风格的消息。客户端可以调服务端的工具,服务端也可以通过 notification 主动推送资源变更。这种自由度,REST 那种"客户端永远主动"的模式根本给不了。
3.4 短板也很明显:流式、二进制、服务发现它全不管
说完了优点,必须泼盆冷水。JSON-RPC 2.0 规范本身就那么几页纸,它把所有"高级功能"都留给了传输层或者上层实现。具体来说有三大短板。
第一是流式响应。规范里没有流式这个概念,一个请求只能对应一个完整响应。但 Agent 场景里,LLM 生成令牌本身就是流式的,工具执行过程的中间输出也是流式的。怎么办?社区的实际做法是:在 JSON-RPC 2.0 之上做扩展,比如 MCP 用 notification 来传递流式片段,或者干脆把 transport 换成长连接,用多条递增 id 的消息模拟流。说白了,这是一层补丁。
第二是服务发现。JSON-RPC 2.0 不回答"对方有哪些方法可以调用"这个问题。所以 MCP 里专门设计了tools/list这个类方法,harness 要主动查询能力列表。这其实是个巧妙的补充——把发现机制本身也做成了 RPC 调用。
第三是安全性。因为协议没有内置鉴权,暴露一个裸 JSON-RPC 端点就等于把你的所有方法公之于众。我见过不止一个团队把 agent 的 JSON-RPC 端口直接绑到公网,method 名随便猜都能被调用。这种错误很致命:你想想,一个shell.run方法如果被匿名调用,会发生什么。后面我会专门讲防护的做法。
4. 手写一个最小 JSON-RPC 2.0 端点,并塞进 Agent 调用链
光说不练假把式。我带你从零写一个能跑的最小实现,然后把它接进一层极简的 harness 骨架里。这个实现我用 Python 标准库就能写完,不用装任何第三方包,你可以直接抄。
4.1 先定传输层:我选标准输入/标准输出
JSON-RPC 2.0 协议本身不规定传输方式,HTTP、WebSocket、stdio、Unix socket 都行。在本地 Agent Harness 场景里,最常用的做法是子进程 + stdio——harness 启动一个工具服务器子进程,往它的 stdin 写单行 JSON 消息,从它的 stdout 读单行 JSON 响应。
为什么选 stdio?因为它是进程间通信里最简单、最不容易受环境变量和端口冲突影响的方式。你不需要开端口、不需要处理 CORS、不需要担心防火墙,子进程被 harness 拉起,当 harness 退出时子进程也跟着结束,生命周期管理天然对齐。MCP 的本地模式走的就是这条路。
4.2 实现服务端核心:注册表与分发逻辑
下面这个文件保存为rpc_server.py,它构建了一个极简但完整的 JSON-RPC 2.0 服务端。
#!/usr/bin/env python3 """极简 JSON-RPC 2.0 服务端:基于 stdin/stdout 传输。""" import json import sys class RpcError(Exception): def __init__(self, code, message, data=None): super().__init__(message) self.code = code self.message = message self.data = data class RpcServer: def __init__(self): self._methods = {} def method(self, name): """注册方法:把函数名映射到调用名字符串。""" def decorator(fn): self._methods[name] = fn return fn return decorator def _dispatch(self, request): if not isinstance(request, dict): raise RpcError(-32600, "Invalid Request") if request.get("jsonrpc") != "2.0" or "method" not in request: raise RpcError(-32600, "Invalid Request") method = request["method"] params = request.get("params", []) handler = self._methods.get(method) if handler is None: raise RpcError(-32601, f"Method not found: {method}") if isinstance(params, dict): return handler(**params) if isinstance(params, list): return handler(*params) return handler() def _handle_one(self, request): is_notification = "id" not in request try: result = self._dispatch(request) except RpcError as e: error = {"code": e.code, "message": e.message, "data": e.data} except Exception as e: # 业务中没兜住的异常,统一转成 -32603 error = {"code": -32603, "message": "Internal error", "data": str(e)} else: error = None if is_notification: return None response = {"jsonrpc": "2.0", "id": request.get("id")} if error is not None: response["error"] = error else: response["result"] = result return response def handle_line(self, line): try: payload = json.loads(line) except json.JSONDecodeError: # 解析失败时 id 无从得知,规范要求 id 为 null return {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "Parse error"}} if isinstance(payload, list): # Batch 响应:只保留带 id 的请求的响应 responses = [] for item in payload: if isinstance(item, dict) and "id" in item: r = self._handle_one(item) if r is not None: responses.append(r) return responses or None if isinstance(payload, dict): return self._handle_one(payload) return {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Invalid Request"}} def main(): server = RpcServer() @server.method("tool.add") def tool_add(a, b): return a + b @server.method("tool.list") def tool_list(): return ["tool.add", "tool.get_status"] @server.method("agent.log") def agent_log(level, message): # 这是通知型方法的典型用法,写入日志后不产生实际结果 print(f"[{level}] {message}", file=sys.stderr) return None for line in sys.stdin: line = line.strip() if not line: continue result = server.handle_line(line) if result is not None: print(json.dumps(result, ensure_ascii=False), flush=True) if __name__ == "__main__": main()这段代码的骨架在真实项目里可以直接套用。几个设计要点:
_handle_one里先判断是不是 notification(没有 id),是的话不管成功失败都不返回响应。这个判断必须放在错误处理之前,否则一个出错的 notification 会被错误地返回一个 error 响应,违反协议。- 内部异常全部兜底转成 -32603,避免堆栈信息直接泄漏到协议层。你可以把详细堆栈打进 stderr 日志,但返回给调用方的 message 要干净。
- Batch 处理时跳过 notification 的响应,保证响应数组里每一项都能跟请求 id 对上。
4.3 用一条命令验证服务端
把上面的文件存好后,直接测试:
printf '{"jsonrpc":"2.0","method":"tool.add","params":{"a":3,"b":4},"id":1}\n' | python3 rpc_server.py输出:
{"jsonrpc": "2.0", "id": 1, "result": 7}再测一个错误场景:
printf '{"jsonrpc":"2.0","method":"tool.nope","params":{},"id":2}\n' | python3 rpc_server.py输出:
{"jsonrpc": "2.0", "id": 2, "error": {"code": -32601, "message": "Method not found: tool.nope"}}4.4 接一层极简 harness:让 LLM 的意图变成协议调用
服务端只是半个故事,另一半是 harness 这侧的调用逻辑。下面是一个不能再简的 harness 骨架,它做的事情是:接收 LLM 输出的工具调用指令,转成 JSON-RPC 请求,发送给服务端子进程,再把响应结果返回给 model loop:
#!/usr/bin/env python3 """极简 Agent Harness 骨架:负责把 agent 的意图翻译成 JSON-RPC 调用。""" import json import subprocess import sys class ToolClient: """连接 RPC 服务端的客户端,负责请求生命周期管理。""" def __init__(self, proc): self.proc = proc self._next_id = 0 self._pending = {} def start(): proc = subprocess.Popen( [sys.executable, "rpc_server.py"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) return ToolClient(proc) def call(self, method, params=None, timeout=10.0): self._next_id += 1 req_id = self._next_id request = {"jsonrpc": "2.0", "method": method, "params": params or {}, "id": req_id} self.proc.stdin.write(json.dumps(request) + "\n") self.proc.stdin.flush() # 同步读一行响应;真实 harness 会并发读取并建立 id -> future 映射 line = self.proc.stdout.readline() if not line: raise RuntimeError("RPC server closed unexpectedly") response = json.loads(line) if response.get("id") != req_id: raise RuntimeError(f"id mismatch: expected {req_id}, got {response.get('id')}") if "error" in response: raise RuntimeError(response["error"]["message"]) return response.get("result") def notify(self, method, params=None): """发送一个不需要响应的通知。""" request = {"jsonrpc": "2.0", "method": method, "params": params or {}} self.proc.stdin.write(json.dumps(request) + "\n") self.proc.stdin.flush()在真实的模型循环里,调用链是这样的:LLM 输出一个带 tool_calls 的回复 → harness 解析出method和params→ 调用client.call(...)→ 拿到 result → 把 result 拼回对话上下文 → 再次把上下文交给 LLM。就这么个循环,已经构成了 Agent 最基本的行为闭环。
我特别想强调一下 id 管理,这是很多初学者最容易忽略的地方。上面的实现用了一个单调递增的计数器,看起来简单,但一旦你要支持并发调用、超时重试、或者把调用分流到多个服务端进程,光靠一个递增数字就不够了。现实的方案是生成 UUID 作为 id,同时本地维护一张 id → future 的映射表,响应一进来就查到对应 future 并 set 结果。我在第五部分会细讲这个坑。
5. 集成过程中我实际踩过的四个坑
无论你是在写工具服务器、MCP 服务器还是自己的 Agent Harness,下面这四个坑我都在真实项目里踩过,每一个都花过我至少一个下午来排查。
5.1 坑一:id 重复,所有响应全部串线
第一次写多请求并行时,我图省事,把请求 id 直接用了 LLM 返回的消息序号。结果模型从 0 开始计数,harness 上一次会话也从 0 开始,两边一交叉,id: 3的响应被匹配到了完全不同的请求上,工具结果张冠李戴,agent 对着北京的天气做出了"东京适合带伞"的决策。
排查了很久才发现,问题不在模型,也不在工具逻辑,而是 id 根本不是一个全局唯一标识。
这个坑的根本原因,是 id 的语义由调用方自己保证。JSON-RPC 2.0 规范只要求"发起方用自己的方式保证 id 唯一",但这个唯一性在多客户端、多轮次、多会话场景下非常容易被打破。我的经验是:任何跨进程通信中的请求 id,一律用 UUID 或毫秒时间戳 + 随机数的组合,不要用自增数字。自增数字看起来清晰,但它只在一个进程内、一个时刻线上有意义。
5.2 坑二:把 Notification 当请求发,等回应等到天荒地老
这个坑特别隐蔽,因为它发生在你调用别人写的工具服务器时。某个方法文档里写着"支持 log 通知",你看着像是个普通方法,于是发了这么一条:
{"jsonrpc": "2.0", "method": "agent.log", "params": {...}, "id": 42}注意,你在消息里加了id: 42,但服务端把它当成普通请求处理了——等等,问题恰恰相反。真正隐蔽的坑是:文档说某个方法"通常作为 notification 发送",你照做了,但没意识到这个方法其实有返回值。
举例来说,很多工具服务器有一个session.heartbeat方法,作用是告诉服务端"我还活着"。它在协议定义里是 notification(无 id),服务端收到后不需要回复。但你如果以为所有方法都应该有响应,发完就阻塞等待,那就会一直卡住,直到超时。
处理这个问题的唯一可靠办法是:在 harness 的客户端层强制区分 call 和 notify 两种 API。只有真正需要响应的方法才能调 call,明确标记为通知的方法只能走 notify。收到一个没有 id 的响应消息时,直接丢弃并打 warning,因为那本身就违反了协议。
5.3 坑三:-32700 Parse error 的 id 是 null,你根本不知道谁挂了
JSON-RPC 2.0 规范规定,当请求消息连 JSON 都解析不了的时候,服务端应当返回:
{"jsonrpc": "2.0", "id": null, "error": {"code": -32700, "message": "Parse error"}}id 强制是 null,因为服务端压根不知道原始消息里写了什么。这在单连接、串行通信时问题不大——反正只有一条管道,出错了我直接把这条响应当成"刚才那条请求的失败结果"就行。
但一旦你开始用并发、用 Batch、用多个连接,问题就来了:一个 -32700 响应进来,你不知道它对应哪条请求,也不知道是哪个客户端发来的。更麻烦的是,如果客户端因为网络抖动把一条完整消息拆成了两半,服务端会在第一条看到\n之后就尝试解析,必然报 -32700。
我的修为在于:-32700 不应该是客户端主处理逻辑的一部分,而应该是日志系统的一部分。收到 -32700 的客户端要做两件事:一是把堆栈现场完整打成日志,二是触发主动超时兜底——让所有 pending 的请求在超时后统一报错重试。不要妄图从 -32700 里推断出是哪条请求坏了,那不现实。
5.4 坑四:Batch 请求里"沉默的响应"和部分成功陷阱
Batch 的问题我在 2.3 提过一句:通知在响应数组里不占位。如果你写的客户端拿到响应数组后,按请求数组的下标去对齐,恭喜你,下一次通知多了的时候数据全错位。
正确的对齐方式只有一个:遍历响应数组,按每一项里的 id 字段去找对应的请求,绝不能用位置去匹配。记住,位置匹配在 JSON-RPC 2.0 的 Batch 场景里永远都是错的,因为协议从一开始就没承诺响应顺序与原请求顺序一致。
另外 Batch 还有另一个隐蔽点:部分成功。一批请求发过去,可能其中三个成功、两个失败、一个是通知。响应数组里就是三个 result 一个 error,顺序还是乱的。你的客户端必须能够容忍"这一批没全成功"的情况,逐个按 id 处理结果,该重试的重试,该回滚的回滚。
6. 这个"前置"到底前置了什么:向 Agent Harness 正篇过渡
读到这里,你可能已经写出了一个能跑的 JSON-RPC 2.0 服务端和客户端,也对协议的各种边角细节有了感觉。但容我提醒一句:这些还只是地基,Agent Harness 真正复杂的东西,全在协议层之上。
6.1 通信协议是协议,运行框架是框架,两码事
JSON-RPC 2.0 解决的只是"两个进程之间怎么交头接耳"。它不告诉你 agent 的生命周期怎么管理,不告诉你技能文件怎么组织和加载,不告诉你工具注册失败时整个 runtime 该不该停摆,不告诉你上下文窗口快满的时候该做摘要还是裁剪。
而这些,恰恰是 harness 的核心职责。还记得开头那条 codex 报错吗?agent harness runtime "codex" is unavailable because its plugin registration failed。注册失败、runtime 不可用、插件隔离——这些概念全都在 JSON-RPC 2.0 的视野之外。harness 要做的是把这些工程问题全部收敛起来,让上层 agent 只管做决策。
我的建议是:你可以把整个 Agent Harness 理解成三层。最底层是传输与协议层(JSON-RPC 2.0 就在这一层);中间是运行时与注册层(插件扫描、方法注册、故障隔离、生命周期管理);最顶层是决策与编排层(prompt 组装、工具调用循环、上下文管理、与用户的交互)。很多 Agent 项目在这三层之间边界模糊,代码变成一锅粥——这是因为协议层和框架层被混在了一起。
6.2 从协议往上爬,你还会撞上这些设计问题
如果你准备深入 Agent Harness,下面几个问题是绕不开的,它们都在正篇里展开:
- 插件注册的失败策略:某个插件加载失败,是整体失败还是降级跳过?codex 那条报错选择的是整体失败,但并不是所有 harness 都该这么做。
- 工具调用的超时与取消:LLM 发起一个工具调用,如果工具卡死了,harness 是等待还是强制中断?怎么向服务端传递"取消"信号?很多实现靠的是一个
tool.cancel方法,参数里带着原请求的 id。 - 上下文管理:工具返回的结果要不要全部塞回模型上下文?怎么判断哪些结果值得保留、哪些只值得写日志?这直接决定 token 消耗和推理质量。
- 安全边界:哪些方法允许 LLM 调用,哪些必须经过用户确认?JSON-RPC 2.0 的 method 只是个字符串,得靠 harness 的鉴权层把住口子。
6.3 三条继续深入的路,看你的目标选
如果你想让这系列前置篇真正落地,我建议你根据自己需求选一条路走:
路线一:实现一个自己的工具服务器。用第 4 节的骨架,往里面注册你自己的业务工具,比如搜索、计算器、读取本地笔记。目标不是写得多花哨,而是把 JSON-RPC 2.0 的请求、响应、通知、Batch 全部跑通,体会协议给你带来的约束和自由。
路线二:深入观察一个成熟 harness 的源码。重点看它的插件注册机制、方法分发器、以及超时取消的实现。找到那行runtime "xxx" is unavailable because its plugin registration failed对应的代码路径,看看一个插件注册失败是怎么冒泡到 runtime 层的,这对理解故障隔离非常有用。
路线三:研究 MCP 规范。MCP 是 JSON-RPC 2.0 在 Agent 生态里最成功的应用,它把协议和工具发现机制结合得很好。你去看它的 initialize 握手、tools/list、tools/call 这套流程,能直观感受到"为什么当初要选 JSON-RPC 2.0"。
我个人带了这么多项目,最大的体会是:一个协议的流行程度从来不取决于它的新,而取决于它是否恰好卡在某个时代痛点的时间窗口上。JSON-RPC 2.0 是一个 2010 年的老家伙,但它"极简到不适合做大事"的缺陷,在 Agent Harness 这个极度需要轻量、灵活、可插拔的战场上反而成了最大优势。这系列的正篇,咱们就从这个矛盾与和解开始讲。