从单向流到双向 RPC:Agent 通信协议的范式分叉与 ACP 协议实战拆解
把一个长驻型 AI Agent 接入了自己的桌面应用,过程中发现当前 Agent 生态里存在两种根本不同的通信范式,而大多数文章只讲 Agent 的 Prompt 工程、Context 工程,几乎没人系统讲过传输层。这篇就来填这个坑。
一、Agent 不只是"一问一答"
大多数人对 AI Agent 的认知停留在一个画面:打开对话框,输入问题,等回答,结束。
但 2026 年的 Agent 早就不是这样了。Claude Code 能自主跑完一个完整的代码重构任务;OpenClaw 能 7×24 小时挂在服务器上帮你盯邮件、跑定时任务;Nous Research 的 Hermes Agent 更进一步——它是个"自进化"Agent,运行时间越长,能力越强(关于 Hermes 的自进化机制,我们之前写过一篇五万字的源码级拆解,这里不展开)。
这些 Agent 有一个共同特征:它们不是一问一答的。它们有会话、有状态、有多轮交互、有主动推送。
问题来了:如果你的应用(IDE 插件、桌面客户端、CLI 工具)想接入这些 Agent,你怎么跟它通信?
这不是一个"调个 API"就能回答的问题。我们实际接入了五个不同的 AI Agent 运行时,发现它们的通信模型存在一个根本性的分叉——而这个分叉,决定了你整个集成架构的走向。
二、两种传输范式:不止接口差异,更是架构区别
范式 A:单向流(Unidirectional Stream)
我们接入的前四个 Agent——Claude Code、Codex、Qwen Code、Gemini CLI——进程模型惊人地一致:
spawn 进程 → stdin 投 prompt → stdout 流式吐 JSONL → 干完活进程退出
多轮对话怎么办?两条路:要么每轮重新 spawn 一个进程;要么用类似 Claude Code 的 stream-json 模式,让进程别退出,继续往 stdin 写下一轮 prompt。
这种模型的特征是:数据只往一个方向流。Client 往 stdin 写,Agent 往 stdout 吐,没有 Agent 主动向 Client 发起调用的概念。所有输出都是"流"——一串 newline-delimited 的 JSON 事件,Client 逐行解析。
我们内部把解析入口叫 selectParser——根据 Agent 类型分发到不同的 stream handler,逻辑闭合,四个 Agent 共用一套管线,干净利落。
范式 B:双向 RPC(Bidirectional JSON-RPC)
然后 Hermes 来了。
Hermes 的进程模型完全不同:它是一个长驻的 JSON-RPC server。你 spawn 它之后,进程不退出。多轮对话不是"继续往 stdin 写一段文本",而是发一个 JSON-RPC request(session/prompt),等一个 response。更关键的是——Agent 会主动向 Client 推送事件(session/update notification),不需要 Client 先问。
spawn 进程(长驻)
→ Client 发 initialize request,Agent 回 response
→ Client 发 session/new request,Agent 回 response(带 sessionId)
→ Client 发 session/prompt request
← Agent 推送 session/update notification(文本 chunk、工具进度……)
← Agent 推送更多 notification……
→ Agent 回 session/prompt response(带 stopReason,turn 结束)
→ 下一轮……
看出区别了吗?这不是"接口格式不同"——JSON 还是 JSONL,字段名不一样——这是两种根本不同的两种架构:

硬把这两种范式塞进同一套抽象,会非常拧巴。
单向流的核心操作是"解析"——读 stdout,切行,反序列化,分发事件。双向 RPC 的核心操作是"请求-响应配对 + 事件订阅"——写 stdin 发 request,按 id 字段匹配 response,同时处理 Agent 主动推来的 notification。
我们的做法是:在传输层分叉,而不是在解析层打补丁。新建一个专门处理双向 RPC 的传输层抽象,和原有的单向流解析管线并行存在,互不干扰。分叉的依据是一个字段——transport: 'acp-jsonrpc'——数据驱动,配置里标了走 RPC,没标的走原来的流解析。
最终浓缩成一句不变式:1 次用户会话 = 1 个 RPC session = 1 个 Agent 进程。
这句话不是架构层,是注释。但它钉死了所有实现决策。
Turn 边界:一个意外的简化
单向流模式下判断"一轮对话结束了"其实挺脆的。以 Claude Code 的 stream-json 为例,你得在事件流里找 type: 'result' + stop_reason,如果流格式稍有变化,判断逻辑就得跟着改。
双向 RPC 天然解决了这个问题:session/prompt 是一个 request——这个 request 的 Promise resolve 的那一刻,就是 turn 结束的那一刻。stopReason 直接在 response payload 里。在 await 期间,Agent 通过 session/update 推送的所有中间事件(文本 chunk、工具调用进度),走另一条路径流入事件管线。
request/response 边界就是 turn 边界。不需要推断,不需要在流里找 pattern。这种"结构天然携带语义"的感觉,做过流式解析的人都懂有多爽。
三、ACP 协议拆解:四个方法、一个生态
Hermes 之所以能被干净地接入,不是因为它自己发明了一套私有协议,而是因为它实现了一个正在成形的标准——ACP(Agent Client Protocol)。
ACP 是什么
ACP 是 Zed 编辑器团队主推的 Agent 通信协议。本质是 JSON-RPC 2.0 over stdio,newline-delimited 帧。设计目标很明确:让外部客户端(IDE、桌面应用、CLI wrapper)用统一协议接入任意兼容的 AI Agent,不必为每个 Agent 单独写解析器。
注意和 MCP 的区别:MCP(Model Context Protocol)是 Agent 调工具的协议(Agent→Tool),ACP 是 Client 调 Agent 的协议(Client→Agent)。两者正交互补——一个 Agent 可以同时是 ACP 的 server(对外提供会话能力)和 MCP 的 client(对内调用工具)。
四个核心方法
ACP 的核心交互就四个方法,两个 request、一个 notification、一个握手:
| 方法 | 类型 | 方向 | 用途 |
|---|---|---|---|
initialize |
request | Client→Agent | 握手。必须传 protocolVersion,协商能力 |
session/new |
request | Client→Agent | 创建会话。Agent 返回 sessionId + 可用模型列表 |
session/prompt |
request | Client→Agent | 发一轮对话。Agent 处理完后回 response,带 stopReason |
session/update |
notification | Agent→Client | Agent 主动推送的事件:文本 chunk、工具调用进度、状态变更等 |
一个完整的单轮交互时序:

几个设计细节值得注意:
- stdout 只跑 RPC,日志全走 stderr。这是 Client 能干净切帧的前提——不会有一行日志混进 JSON-RPC 帧流里。
session/update是 notification,不是 request。Agent 推完不等回复,Client 只负责消费。这意味着 Client 不能通过 notification 做流控——如果 Agent 推得太快,Client 得自己缓冲。sessionId由 Agent 生成。Client 在session/new里不传 sessionId,Agent 分配一个 UUID 回来。这是一个容易被忽略的设计决策——它意味着 Client 不能"恢复"一个旧 session,session 的生命周期完全由 Agent 控制。
为什么 ACP 值得关注——生态化
ACP 目前还不像 MCP 那样人尽皆知,但它解决的问题是真实的:Agent 生态正在碎片化。每出一个新 Agent,IDE 和桌面应用就得写一套新的解析器。Claude Code 一套、Codex 一套、Gemini CLI 一套、OpenClaw 一套——我们接了五个 Agent,前四个的解析逻辑大同小异但就是不能复用,因为帧格式、事件类型、turn 边界判断全不一样。
ACP 试图终结这种碎片化。如果 Agent 都实现 ACP,那 Client 只需要写一次传输层,新接一个 Agent 就是加一条配置的事。Hermes 是目前实现 ACP 最完整的开源 Agent 之一——它的 acp_adapter/ 目录专门把 TUI 逻辑剥离,暴露一个无 TUI 的 JSON-RPC server 入口 hermes-acp。这就是给外部客户端准备的官方接入点。
我们这次接入,相当于提前验证了 ACP 传输层的可行性。以后再接其他 ACP 兼容的 Agent,传输层零改动,只写一个几十行的运行时配置。
四、协议探测方法论:schema 不等于行为
这一节是实操部分,也是我们认为最有普适价值的部分。
一个教训
设计阶段,我们读了 ACP 的协议文档和 Hermes 的 schema 源码,列了一堆"应该是这样"的假设。然后写了个探测脚本,本地 spawn hermes-acp,做真实 RPC 往返。
结果:一半的假设是错的。
六条实测发现
以下是光看 schema / 文档看不出来,必须真实跑一遍才知道的细节:
1. initialize 必须传 protocolVersion: 1
schema 里这个字段标的是 optional。不传?直接 -32602 Invalid params,连接建立失败。
2. session/new 的 mcpServers 是 list,不是 object
传 {} 报 list_type 校验错。必须传 []。一个空花括号和一个空方括号的距离,就是"能跑"和"不能跑"的距离。
3. sessionId 由 Agent 生成
我们最初以为 Client 可以指定 sessionId(很多 RPC 协议都这样)。不是。Client 在 session/new 里不传 sessionId,Agent 在 response 里返回一个 UUID。Client 必须持有这个 UUID,后续所有 session/prompt 都带着它。
4. session/new 是慢调用
3.5 秒以上。因为 Agent 要在这个调用里加载 51 个内置插件、连接 LLM provider。如果你用 initialize 的超时来套 session/new,大概率超时失败。不同的 RPC 方法需要不同的超时策略——这种信息在 schema 里永远不会出现。
5. 未知 sessionId 不报 error,返回 stopReason: 'refusal'
用不存在的 sessionId 调 session/prompt,Agent 不返回 JSON-RPC error(你预期的 -32602 或自定义 error code),而是正常返回一个 response,stopReason 是 'refusal'。
这意味着什么?Client 不能只靠 RPC error 兜底。你必须自己保证 sessionId 的持有和传递是正确的,并且在业务层检查 stopReason。如果你只 catch RPC error,这种"静默拒绝"会让你以为 Agent 回了个空消息。
6. session/new 的 response 自带模型列表
models.availableModels(20+ 个模型)+ currentModelId,直接在 response 里。这意味着 Client 可以在第一次会话建立后就拿到真实可用模型列表,动态填充 UI,而不是维护一份静态的模型下拉框。
抽象成方法论
这六条发现背后是一条通用原则:
Schema 告诉你"可以传什么",不告诉你"必须传什么"、"实际会怎样"、"多快会回"。协议的行为边界,只有真实往返才能确认。
我们把这个过程叫协议探测(Protocol Probing),步骤很简单:
- 写一个独立的探测脚本(不是正式代码),spawn 目标 Agent 进程
- 逐方法做真实 RPC 往返:
initialize→session/new→session/prompt,每步打印完整的 request 和 response - 故意传错参数:缺字段、类型错、值非法——记录 Agent 的实际反应(是 RPC error?是静默忽略?是业务层拒绝?)
- 测边界:超时行为、大 payload、并发调用、进程中途退出
- 把实测结论写进设计文档,标注"schema 说的 vs 实际跑的"
这套方法不只适用于 ACP。MCP、LSP、任何 JSON-RPC / JSON-over-stdio 协议,都适用。读十遍 schema,不如跑一遍往返。
五、长驻 Agent 的可靠性工程
单向流模型下,Agent 干完活就退出,可靠性问题相对简单——进程退了,这轮就结束了。
长驻 Agent 完全不同。进程一直活着,意味着你要处理一系列单向流模型下不存在的可靠性问题。以下是我们实际踩过的几个:
5.1 超时策略:基于活动,而非绝对时间
我们最初给 Agent 进程设了 60 秒 idle timeout——60 秒没有 stdout 输出就判定进程挂了,杀掉。
然后 Hermes 跑了一次 OCR。OCR 是 Agent 调的一个子工具,作为 subprocess 运行,输出走的是子进程自己的 stdout/stderr——Hermes 主进程在工具执行期间零输出。50 页 OCR 要 2-3 分钟。60 秒一到,idle timer 直接把整个会话杀在半路。
修法不是简单地把超时从 60 秒改成 5 分钟。更本质的修法是把超时基准从"绝对时间"换成"输出活动":只要 Agent 还在产生输出(stdout 或 stderr),就重置计时器。冷启动慢(加载 51 个插件要 3.5 秒)?没关系,只要还在打日志就续命。真正该触发超时的,是"Agent 彻底沉默了 N 秒"——那才可能是挂了。
这个模式我们叫 Activity-Driven Idle Timer,对所有需要管理长驻子进程的场景都适用。
5.2 进程退出的语义歧义
单向流模型下,进程退出码 0 = 成功,非 0 = 失败,简单明了。
长驻 Agent 不是这样。如果有 prompt 正在 in-flight(request 发出去了,response 还没回来),这时进程退出了——哪怕退出码是 0——也意味着那个 prompt 永远不会被处理。退出码 0 不等于任务成功。
我们的处理是在 close handler 里加一层检查:有没有 pending 的 RPC request?有没有被用户取消的 session?有 pending → 标记 failed;被取消 → 标记 canceled;都没有 → 才看退出码。
5.3 stderr 噪声风暴
长驻进程的 stderr 是个被严重低估的问题。Hermes 的 stderr 会持续刷大量 INFO / WARNING / DEBUG 日志——插件注册、provider 连接、MCP 工具发现、健康检查……正常运行时也在刷。
如果我们把所有 stderr 都当成 error 事件抛给 UI,用户会看到满屏的"错误"日志,实际上系统运行完全正常。
修法是给 stderr 做分级过滤:行首匹配
^\d{4}-\d{2}-\d{2}-\d{2}:\d{2}:\d{2}[(INFO|WARNING|DEBUG)\]
的,丢弃;只保留 [ERROR] 级别和 Python traceback。
这个问题的本质是:长驻进程的"正常噪声"和"真正错误"混在同一个 fd 里,你必须自己做信噪分离。单向流模型下进程活不过几秒,这个问题根本不会暴露。
5.4 子 Agent 的隔离边界
Hermes 支持子 Agent 并行执行——主 Agent 可以 fork 出子 Agent 同时跑多个任务。但子 Agent 有严格的隔离约束:不能再创建子 Agent(防递归爆炸),不能反向询问主 Agent(单向线性并行)。
对宿主应用来说,这意味着你收到的 session/update 事件流里,可能交错着主 Agent 和多个子 Agent 的输出。你需要在事件里携带 Agent 层级信息,否则 UI 会把子 Agent 的工具调用进度和主 Agent 的文本输出混在一起。
六、工程哲学:不变式优先于抽象
最后聊一个工程决策上的取舍。
做这种"新范式接入"时,最自然的冲动是"建一个大的抽象层"——统一事件类型、统一进程管理、统一健康检查、统一多协议路由。AI code review 也特别爱建议这些:"你应该加一个 NormalizedAgentEvent 类型"、"你需要一个 ProcessManager 做 health check 和 zombie cleanup"、"考虑一下 multi-protocol runtime mesh"。
我们回推了大部分。
理由很简单:Phase 1 只有一种双向 RPC Agent。 为一种实现建抽象层,不叫架构设计,叫过度设计。
我们选择的做法是:
- 不新建事件类型。已有的事件管线够用,加几个 variant 就行。
- 不建进程管理器。
child.on('exit')+ 已有的 shutdown 钩子,够了。 - 不做 framing 探测器。已经验证了就是 newline-delimited JSON,不需要"自动嗅探帧格式"。加一个 smoke test 就行。
- 不建"状态一致性层"。不变式就一句话——1 次会话 = 1 个 session = 1 个进程——这是注释,不是架构。
先把不变式实现到极致,等真正出现第二个 ACP Agent、或者多 session 共享进程的需求时,再抽象。
很多人会说你这不是在偷懒嘛。这可真不是,这是在"建错了拆掉"和"等需求来了再建"之间,选择后者。前者是确定性浪费,后者是概率性投入。
七、结语:传输层是 Agent 生态的下一个瓶颈
Agent 领域过去两年的注意力几乎全在模型侧——更大的上下文窗口、更好的工具调用、更强的推理能力。Prompt Engineering、Context Engineering,最近是Loop Engineering, 诸如此类的文章汗牛充栋。
但当你真的要把多个 Agent 接进一个应用,你会发现传输层才是真正的瓶颈。每个 Agent 一套帧格式、一套事件语义、一套 turn 边界判断、一套错误处理——这些"管道"层面的差异,消耗的工程精力远超预期。
ACP 是这个方向上第一个认真尝试标准化的协议。它还很年轻,实现者还不多,但方向是对的:把 Agent 的能力通过标准协议暴露出来,让 Client 只写一次传输层。
Hermes 的自进化能力(Skill 动态生成 + RL 训练闭环)让它成为一个独特的 Agent——关于这部分的技术拆解,可以读我们之前的深度解析文章。但今天这篇想说的不是 Hermes 有多强,而是一个更基础的工程判断:
从"自主"到"自进化",不只是 Agent 内部的事。宿主应用的传输层,也得跟着进化。
单向流的时代,我们写解析器。双向 RPC 的时代,我们写传输层。下一个时代是什么?也许是 Agent 之间的通信协议——Agent 调 Agent,不再是主从 fork,而是对等协商。但那是另一个故事了。
本文由Molio生成发布。想了解文中的真实的 Agent 集成工程实践,欢迎github关注zhuzhaoyun获取。技术咨询与合作,可以添加微信:Damondut
浙公网安备 33010602011771号