最近在给一个 Agent Harness 项目设计工具调用通信层时,我干了一件看起来特别“复古”的事:把 JSON-RPC 2.0 从旧文档里翻出来重新读了一遍。这份规范不长,总共才几页,但它几乎一字不改地成了我这一整套 Agent 框架的数据交换底座。说实话,做 AI Agent 开发做到一定深度,你一定会撞上一个绕不开的话题:模型负责“想”,工具负责“做”,那 Agent 和工具之间到底怎么说话?很多人第一反应是上 REST,第二反应是上 gRPC,但真正把 Agent Harness 落地一遍之后,你会发现 JSON-RPC 2.0 这个被冷落多年的老协议,反而是最合适的选择。
这篇文章就当是这个系列的“前置篇”。我准备把 Agent Harness 是什么讲清楚,再把 JSON-RPC 2.0 的规则过一遍,然后重点聊一个实际问题:为什么偏偏是它被 AI Agent 重新捧红,以及你在自己的 Agent 工程里应该怎么落地这套协议。适合正在做 Agent 应用开发、在研究 Agent 框架源码,或者单纯想搞清楚 MCP 这类协议底层逻辑的读者。我不打算写成教科书式的协议解读,而是按我实际踩过的坑、做过的权衡来聊,这样你拿去就能用。
1. 先说结论:Agent Harness 到底解决什么问题
1.1 为什么“Agent 唯一要做的就是决定”这句漂亮话不能直接用
现在聊 AI Agent,很多人喜欢说一句话:模型负责决策,外部工具负责执行,Agent 只需要在大脑里“想”清楚调用哪个工具。这话理论上没错,但你真正写代码的时候会发现,从“模型说想调用 get_weather”到“get_weather 真的被安全地执行完并拿到结果”,中间隔着一条巨大的工程鸿沟。
模型只是一个文本输入输出的推理单元,它自己不发起 HTTP 请求,不读文件,不操作数据库。它给出的 tool_call 充其量是一个结构化的“意图”,比如:
{ "name": "get_weather", "arguments": { "city": "杭州" } }接下来谁来把这个意图变成真实动作?谁来保证它不会调用一个不该调用的工具?谁来给这个工具注入密钥和配置?谁在工具抛异常的时候决定是重试还是换一条路径?谁把执行结果再包装成模型能读的上下文?这些活如果全部散落在业务代码里,你的 Agent 很快就会长成一坨没法维护的“面条代码”。
这就是 Agent Harness 存在的意义。你可以把它理解成“Agent 的执行外壳”或者“外骨骼”。Agent“决定”做什么,Harness 负责让这个决定能发生、能完成、能被观测、能被控制。没有 Harness,模型是裸奔的;有了 Harness,模型才能在受控环境里安全地调用工具、处理上下文、完成多步任务。
1.2 Harness 与 Agent:谁是谁的“外骨骼”
这个词的英文原文就很有意思。harness 本身是“马具、挽具、安全绳”的意思,动词是“给马套上挽具”或者“把人用绳索固定住”。延伸一下,Agent Harness 的意思就是“给 Agent 套上一层控制与执行的挽具”。很多读者在搜 harness 和 agent 的区别,说白了就是这两个概念经常被混在一起讲。
我习惯用一个比喻:Agent 是“司机”,Harness 是“汽车”。司机决定要去哪儿、走哪条路,这是智能决策;但发动机、刹车、方向盘、仪表盘、安全带,这些基础设施归汽车管。没有汽车,司机再聪明也挪不了半步;没有司机,汽车也只是停在原地的一堆金属。但在软件工程里,这两层往往被写进同一个进程,所以很多人感受不到边界。
实际拆开看,Harness 至少要承担以下几件事:
- 工具注册与发现:Agent 能调用哪些工具,每个工具的入口在哪里,参数结构是什么。
- 参数校验与权限控制:模型生成的参数不一定合法,也不一定安全,Harness 要在执行前拦截。
- 上下文管理:多轮对话里哪些历史信息要保留,工具执行结果如何压缩回填。
- 执行调度与错误恢复:步骤失败后是否重试、是否换工具、是否终止。
- 可观测性:每一步的输入输出都要有日志,出了问题能回放。
而这些工作里,最核心、最底层的一项,就是“通信”。无论注册、调用、返回结果,还是上报状态,Harness 都需要一套协议来管理 Agent 与工具之间的数据流。这套协议,在我的实践里,选来选去最后还是落在了 JSON-RPC 2.0 上。
2. JSON-RPC 2.0 不是新东西,但规矩值得重新过一遍
2.1 一个请求对象的结构,就这么点东西
JSON-RPC 2.0 规范短到让人怀疑人生。请求对象总共就四个字段:
{ "jsonrpc": "2.0", "method": "tools/call", "params": { "tool": "get_weather", "arguments": { "city": "杭州" } }, "id": 1 }每个字段的含义都很朴素:jsonrpc固定是"2.0",method是字符串,表示要调用的方法名,params是参数,id是请求的唯一标识。规范允许params有两种形态:按位置传的数组,或者按名字传的对象。在我的 Agent 工具调用场景里,我强烈建议用对象形态,因为工具函数的参数几乎都有名字,用数组很容易在参数顺序上翻车。
响应对象也异常简单。要么是成功:
{ "jsonrpc": "2.0", "result": { "temperature": 26, "condition": "晴" }, "id": 1 }要么是失败:
{ "jsonrpc": "2.0", "error": { "code": -32601, "message": "Method not found" }, "id": 1 }这里有个规矩必须记住:result和error只能出现一个,不能同时存在。error对象里code是整数,message是字符串,data是可选的附加信息。你哪怕把协议全忘了,只记住“请求有 id,响应要带同一个 id”这一条,也能把通信跑通大半。
最容易被忽略的是id的设计。如果请求里没有id,这就不再是一个普通请求,而是一个“通知”。服务端收到通知后,不会返回任何响应。很多初学的朋友在调试时报错“我发了请求但收不到响应”,一查才发现,请求对象里忘了写id,被服务端静默当成通知处理了。还有一点要注意:虽然规范里id允许是null,但实务中千万别用null,因为有的服务端会把id: null也当成通知处理,或者干脆返回Invalid Request。
2.2 通知、批量、错误码:容易被忽视的三件套
很多人对 JSON-RPC 的印象就是“一个请求配一个响应”,但规范里还有两个容易被忽视的玩法:通知和批量。
通知就是没有id的请求,比如:
{ "jsonrpc": "2.0", "method": "log/emit", "params": { "level": "info", "message": "agent started" } }服务端处理完就完事,不返回任何东西。这个机制在 Agent Harness 里特别适合做日志上报、心跳检测、进度通知这类“不需要结果”的调用。注意,如果你发了通知,就不要傻等响应;如果你需要结果,哪怕是空结果,也要带上id发普通请求。
批量请求就是把多个请求对象放进一个数组,一次性发给服务端:
[ {"jsonrpc": "2.0", "method": "fs/read", "params": {"path": "/tmp/a"}, "id": 1}, {"jsonrpc": "2.0", "method": "fs/read", "params": {"path": "/tmp/b"}, "id": 2} ]服务端会返回一个同样按顺序排列的响应数组。这个能力在 Harness 做“并行工具调用”时很有用,可以一次并行发起多个独立的工具请求,减少网络往返。但要注意,如果这批请求里全是通知,那服务端就不该返回任何东西。
错误码也有标准约定,我直接列个表:
| 错误码 | 含义 |
|---|---|
| -32700 | 解析错误,收到的文本不是合法 JSON |
| -32600 | 无效请求,请求对象结构不对 |
| -32601 | 方法不存在,method 没注册 |
| -32602 | 无效参数,params 与预期不符 |
| -32603 | 内部错误,服务端执行时抛异常 |
| -32000 到 -32099 | 服务端自定义错误,预留范围 |
这个表放在开发文档里很实用。你在 Harness 层排查问题时,只要看错误码就能快速定位是协议层问题、参数问题还是业务执行问题,不用一层层去翻日志。
3. 为什么偏偏是 JSON-RPC,而不是 REST 或 gRPC
3.1 工具调用本质上是 RPC,不是资源增删改查
这是我最想强调的一点。很多人一开口就问:为什么不用 REST?我理解 REST 在大众认知里太成功了,但它和 Agent 工具调用在本质上是有错位的。
REST 的核心是“资源”和“对资源的操作”,你设计的是 URL 和 HTTP 方法,比如GET /users/1、POST /orders。但 Agent 的工具调用是“动作”,是“函数”,是“方法调用”。你要的是get_weather(city="杭州"),这在 REST 里该怎么建模?是POST /tools/get_weather,还是GET /weather?city=杭州?如果有一百个工具,你是不是要设计一百套 URL 和参数映射规则?更麻烦的是,很多工具行为并不是简单的资源操作,比如“运行一段代码”“发送一条消息”“查询两个系统并合并结果”,硬套 REST 概念反而别扭。
RPC 的思维模型就完全不同。它天然面向“调用一个远程方法,传参数,拿结果”。而 JSON-RPC 又是 RPC 家族里最轻量的一种:不需要 IDL 文件,不需要代码生成,不需要复杂的序列化框架。你只要会写 JSON,就能实现一个服务端。对于 Agent 这种“工具类型无限多、参数结构千奇百怪”的场景,RPC 风格明显更贴近真实需求。
3.2 和 gRPC 相比,JSON-RPC 的“笨”反而是优势
gRPC 在内部微服务架构里确实很优秀,有强类型约束、有 protobuf 二进制序列化、有 HTTP/2 多路复用,性能也更好。但放到 AI Agent 场景里,它有几个硬伤。
第一条就是:LLM 生成的是文本,生成 JSON 很容易,生成二进制帧几乎不可能。模型的tool_call输出天然是 JSON 结构,如果你想走 gRPC,就得先在代码里把 JSON 转成 protobuf 结构体,这层转换不仅多此一举,还会引入额外的序列化代码和潜在类型错误。JSON-RPC 就不存在这个问题,模型吐出来的 JSON 结构直接就是协议请求体。
第二条是调试和可观测性。JSON-RPC 的请求和响应可以原样打印到日志里,人一眼能看懂,也可以直接复制下来做回放测试。gRPC 的二进制协议要借助额外的工具才能看到内容,在处理 Agent 这种“需要反复看完整调用链”的场景时,体验差得不是一星半点。
第三条是跨语言生态。Agent 的工具端五花八门,有 Python 写的,有 Node 写的,有 Go 写的,甚至可能是一个 Shell 脚本。JSON-RPC 的客户端和服务端在任何语言里都只需要几十行代码就能实现,不需要安装特定的协议生成插件。微型工具可能根本不想引一堆依赖,一个 JSON 解析器就够了。
3.3 JSON-RPC 的短板与 Agent 场景的补法
说了这么多好话,我也得讲讲 JSON-RPC 的短板。它没有内建鉴权,没有内建加密,没有内建服务发现,甚至连“用什么传输层”都没规定。这些在现代分布式系统里都属于基础能力,缺了让人很不安。
但在 Agent Harness 里,这些短板恰恰可以被架构层面自然覆盖。鉴权和权限控制在 Harness 层做,因为 Harness 本身就是控制面;传输层完全自由,本机工具走 stdio,远程工具走 HTTP,实时通信走 WebSocket,协议语义完全一致,这是 REST 和 gRPC 很难做到的灵活性。你看看现在比较火的 MCP,底层也是基于 JSON-RPC 2.0 的,它的做法就是定义了不同的 transport(stdio、HTTP、WebSocket),但方法调用的语义始终统一。这种“一次定义,多传输适用”的特性,是 JSON-RPC 能在 Agent 时代翻红的重要原因。
所以我的结论是:JSON-RPC 2.0 不是没缺点,而是它的缺点在 Agent Harness 的架构里都不致命,它的优点却精准命中了工具调用的核心需求。
4. Agent Harness 里怎么落地 JSON-RPC:一个最小通信层设计
4.1 一次完整工具调用的数据流
理解了协议,接下来要解决的是“在 Harness 里怎么用”。基于我自己的实践,一次典型的工具调用会经历下面这条链路:
- Agent 大脑(LLM)在多轮对话里生成了一个
tool_call,内容是“调用 get_weather,参数 city=杭州”。 - Harness 截获这个
tool_call,先做参数校验,检查方法名是否在工具注册表里,参数是否符合 JSON Schema。 - 校验通过后,Harness 把
tool_call翻译成一个 JSON-RPC 请求,发送给对应的工具服务端。 - 工具服务端执行真实动作,返回一个 JSON-RPC 响应。
- Harness 收到响应后,把
result包装成一条“工具结果消息”,回填给 Agent 大脑。 - Agent 大脑根据结果生成最终回复,或者继续发起下一个工具调用。
关键点在第 3 步到第 5 步。Harness 在这里扮演的不只是“转发器”,还是“翻译官”和“安全闸门”。工具服务端根本不需要关心调用它的是一个 LLM 还是一个普通客户端,它只认 JSON-RPC,这让工具可以被不同 Agent 复用的同时,也保证了工具层的绝对独立。
举个例子,我用 Python 写一个极简的 Harness 分发逻辑,大概就是这种感觉:
def dispatch_tool_call(tool_call: dict) -> dict: method = tool_call["name"] arguments = tool_call.get("arguments", {}) # 1. 参数校验,失败则直接返回标准错误 if method not in tool_registry: return {"jsonrpc": "2.0", "error": {"code": -32601, "message": f"tool '{method}' not found"}, "id": tool_call.get("id")} # 2. 构造 JSON-RPC 请求 req = { "jsonrpc": "2.0", "method": f"tools/{method}", "params": arguments, "id": generate_request_id(), } # 3. 发送到工具服务端,等待响应 resp = rpc_call(tool_registry[method].endpoint, req) # 4. 把 resp 返回给上层,最终回填给 LLM return resp这段代码看着简单,但把 Harness 的职责体现得很完整:校验、翻译、转发、回传。
4.2 id、超时、重试、通知:这几个细节决定健壮性
协议本身很简单,但真正让系统变得健壮的,往往是几个容易被忽视的细节。
id的设计一定要认真。在并发场景里,id是匹配请求和响应的唯一依据。如果每次请求都用固定的id,一旦并发,响应就会串号。我推荐用全局唯一 ID,比如 UUID,或者在进程内用“实例前缀 + 自增序号”,比如agent-1-42。这样即使多个 Harness 实例共用同一个日志系统,也能一眼看出某个请求来自哪个实例、是第几个请求。
超时和重试也必须在 Harness 层做,因为协议本身没有规定。超时时间要根据工具类型区分:文件读取给 5 秒,外部 API 调用给 15 秒,批量数据分析给 60 秒,不要一刀切。重试更有讲究,只有幂等的工具方法才能安全重试,比如“读取文件”“查询天气”可以重试;“发送邮件”“扣款”这类操作重试就要非常谨慎。另外,如果工具的响应里有业务侧的错误码,Harness 可以考虑把错误信息回传给 LLM,让模型自己决定是重试、换参数还是换工具,而不是机械地在代码层重试。
通知模式的使用也要克制。通知适合“不需要结果”的场景,比如日志、指标上报、状态变更广播。很多开发者在刚接触通知时容易手滑,把需要结果的请求也写成通知,结果一调一个不响。我的经验是:默认都用带id的请求,明确知道自己不需要结果时,才用通知。
5. 实操:用 Python 5 分钟搭一个最小示例
5.1 服务端:一个极简 JSON-RPC 工具服务
理论说得再多,不如动手写一遍。我建议你花五分钟跑通一个最小示例,之后再去看任何 Agent 框架的源码,都会觉得亲切很多。下面这个服务端只用 Python 标准库,不依赖 Flask 或 FastAPI,为的是突出“协议无关传输”的概念。
import json from http.server import BaseHTTPRequestHandler, HTTPServer TOOLS = { "get_weather": lambda city, unit="celsius": { "city": city, "temperature": 26 if unit == "celsius" else 79, "unit": unit }, "add": lambda a, b: {"result": a + b}, } class RPCService(BaseHTTPRequestHandler): def do_POST(self): length = int(self.headers.get("Content-Length", 0)) payload = json.loads(self.rfile.read(length) or b"{}") # 这里先处理单请求,批量请求留到客户端示例里讲 req_id = payload.get("id") method = payload.get("method") params = payload.get("params", {}) try: fn = TOOLS[method] result = fn(**params) if isinstance(params, dict) else fn(*params) response = {"jsonrpc": "2.0", "result": result, "id": req_id} except KeyError: response = { "jsonrpc": "2.0", "error": {"code": -32601, "message": f"method '{method}' not found"}, "id": req_id, } except Exception as exc: response = { "jsonrpc": "2.0", "error": {"code": -32603, "message": str(exc)}, "id": req_id, } body = json.dumps(response).encode() self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def log_message(self, fmt, *args): # 保持控制台干净,真实项目建议把请求和响应都打进系统日志 pass if __name__ == "__main__": HTTPServer(("127.0.0.1", 8080), RPCService).serve_forever()这段代码的核心思想是:HTTP 只是传输载体之一,协议本身的语义没有绑定任何 HTTP 概念。你换成 WebSocket 或者 stdio,请求和响应对象可以原样复用。
启动服务后,用 curl 测一下:
curl -X POST http://127.0.0.1:8080/rpc \ -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","method":"get_weather","params":{"city":"杭州"},"id":1}'返回结果应该类似:
{"jsonrpc": "2.0", "result": {"city": "杭州", "temperature": 26, "unit": "celsius"}, "id": 1}如果你把method改成一个不存在的名字,就会得到-32601 Method not found。这个错误码就是协议规范里定义的,客户端可以根据它快速判断是“方法名拼错”还是“服务端执行异常”。
5.2 客户端调用与批量请求示例
客户端就更简单了,本质上就是构造 JSON 请求,然后等一个 JSON 响应。我用 Python 的requests演示:
import requests import json def rpc_call(endpoint, method, params=None, req_id=1): payload = {"jsonrpc": "2.0", "method": method, "params": params or {}, "id": req_id} resp = requests.post(endpoint, json=payload, timeout=10) return resp.json() # 单次调用 result = rpc_call("http://127.0.0.1:8080/rpc", "get_weather", {"city": "上海"}, req_id=7) print(result)批量请求就是发数组:
batch = [ {"jsonrpc": "2.0", "method": "get_weather", "params": {"city": "北京"}, "id": 2}, {"jsonrpc": "2.0", "method": "add", "params": {"a": 1, "b": 2}, "id": 3}, ] resp = requests.post("http://127.0.0.1:8080/rpc", json=batch, timeout=10) print(resp.json())注意批量请求里每个子请求的id不能重复。响应数组的顺序不一定和请求顺序完全一致,协议不保证这一点,所以一定要根据id来匹配结果,而不是按数组下标。
到这里,你可能已经发现这个最小示例离一个完整 Agent Harness 还差很远,但它已经足够帮助你理解通信层的核心逻辑了。接下来要做的,就是在这个协议之上补充工具注册表、参数校验、权限控制、日志追踪这些 Harness 组件。
6. 常见问题与排查技巧实录
6.1 Harness runtime 加载失败这类“起不来”的问题
我见过很多朋友在装好 Agent 框架后,启动时直接报错,类似这样:
error: agent harness runtime "codex" is unavailable because its plugin registration failed看到这个报错先别慌,这类问题一般不在协议层,而在 Harness 的插件机制上。它表达的意思是:Harness 尝试加载名为codex的 runtime,但因为某个插件注册失败,这个 runtime 最终没被启用。常见原因有这么几个:
- 插件目录或配置文件路径不对,Harness 找不到插件的加载入口。
- 插件 SDK 版本与 Harness 主程序版本不匹配,注册时接口对不上。
- 插件依赖缺失,比如某个动态库没装、某个 Python 包没装、某个可执行文件不在 PATH 里。
- 文件权限问题,Harness 进程没有权限读取插件的配置文件。
排查顺序建议从日志入手。先找 Harness 启动日志里更详细的错误输出,定位具体是哪个插件注册失败;再确认 runtime 名称在配置文件和插件元数据里是否完全一致;接着检查插件依赖的版本和运行环境;最后尝试把插件配置最小化,逐个子插件启用,找到罪魁祸首。
这类问题提示我们,Agent Harness 的插件架构虽然灵活,但也意味着“配置出错”的代价更高。我的经验是,在正式开发前先把插件目录结构和版本约束写进文档,并且用一个启动脚本统一检查环境变量和依赖,能省下不少排错时间。
6.2 调用期容易踩的坑
Harness 能正常启动了,真正的麻烦往往在运行时才出现。我把实际踩过的坑整理成一个速查表:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 发请求后没有响应 | 请求里漏写id,被当成通知 | 检查请求对象,确保id是数字或字符串,不是 null |
| params 传数组但工具函数是关键字参数 | 协议参数形态和工具签名不匹配 | 统一在 Harness 层把 params 转成 object 形态 |
返回-32602无效参数 | 模型生成的参数类型或字段名出错 | 在 Harness 层加 JSON Schema 校验,把错误信息回灌给 LLM 重试 |
| 并发响应串号 | 多个请求共用了同一个id | 使用全局唯一 id,比如 UUID 或实例前缀加自增 |
| 工具执行很慢导致客户端超时 | 单个 HTTP 请求超时时间设置太短 | 按工具类型区分超时,或者改用异步通知加回调 |
| 返回结果太大,上下文爆炸 | 工具返回长文本、长日志 | 在 Harness 层对结果截断或摘要,再回填给 LLM |
其中最值得展开的就是:模型生成的参数不可靠。LLM 在工具调用时,偶尔会把{"city": "杭州"}生成成{"citi": "杭州"},或者把数字26生成成字符串"26"。如果直接把这种请求发给工具服务端,大概率会得到-32602。更聪明的做法是,在 Harness 层把校验失败的错误信息告诉 LLM,让它自己修正参数后重试。
比如你可以向模型返回这样一条消息:
工具 get_weather 调用失败:参数 city 的值为 null,但该字段必填。 请参考以下 JSON Schema 重新生成参数: {"type":"object","properties":{"city":{"type":"string"},"unit":{"type":"string","enum":["celsius","fahrenheit"]}},"required":["city"]}这个机制在实践中效果非常明显。LLM 看到具体的错误描述和 Schema 后,大概率能自己修正,成倍提高工具调用的成功率。这是 Agent Harness 特有的“错误反馈闭环”,普通 RPC 客户端不会做这种事。
6.3 几个让 Agent 生成的请求更稳定的经验
最后分享几条实操经验。第一,工具描述里一定要写清楚参数结构,最好直接附 JSON Schema。LLM 并不知道你的工具函数是怎么定义的,它只能从工具描述里学习。描述写得越规范,它生成的arguments就越准确。
第二,不要在 Harness 层无条件信任模型输出。即使是模型生成的调用,也要走一遍参数校验和白名单检查。模型不是每次都会严格遵守约束,harness 就是为了兜底。
第三,日志里一定要保留完整的请求和响应。调试 Agent 的时候,“看得见”比“跑得快”重要得多。我在日志系统里会把tool_call_id、method、params、result、error全部结构化记录,这样出了问题可以直接检索和回放。
第四,控制上下文长度。工具返回的结果,有些很长,比如一个文件内容、一段数据库查询结果。直接全部塞回给 LLM,不仅浪费 token,还容易被无关信息干扰。Harness 需要根据场景设置截断长度,或者让模型按需读取“摘要后再去读详情”。
这也算是我整个实践下来最核心的一条心得:Agent 能不能稳定工作,关键不在于模型多聪明,而在于 Harness 这个“外骨骼”够不够稳。而 Harness 的地基,恰恰就是 JSON-RPC 2.0 这套看起来毫不起眼、却极其严谨的老协议。老实说,我刚入这行时觉得 JSON-RPC 很“老土”,现在反而觉得它是大道至简。如果你也在做 Agent 基建,我的建议是别自己发明一套通信协议,直接站在 JSON-RPC 2.0 的肩膀上,把精力花在工具注册、权限控制、错误反馈这些真正影响体验的地方,你会走得更快。