☰
Agent Client Protocol:智能体客户端通信协议设计与实践
2026/10/17 16:14:41 网站建设 项目流程

如果你最近在搭任何带对话框的 AI 应用——不管是 Web 聊天助手、IDE 里的智能补全插件,还是给客服用的智能体后台——迟早都会遇到同一个问题:客户端到底用什么方式跟 Agent 服务通信才靠谱?我最早做这类项目时理所当然地想,后端给个接口,前端去调,不就完了。真接上之后发现完全不是这么回事:模型推理是流式吐字的,工具调用中间可能卡十几秒,用户还随时会打断、追加新指令,一个普通 HTTP 请求根本接不住这种交互节奏。于是我开始系统梳理这层"智能体客户端到 Agent 服务之间"的通信协议设计,也就是标题里说的 Agent Client Protocol。这篇文章不是某个官方文档的复述,而是我把整条通信链拆开揉碎之后的全景总结,覆盖传输选型、消息结构、会话恢复、安全鉴权、错误重试,以及一些只有真跑过才讲得出来的坑。适合后端工程师、客户端开发者和正在设计 AI 应用架构的同学参考。

1. Agent Client Protocol 的定位:它不是又一个 API,而是会话的载体

1.1 纯 REST 为什么接不住智能体交互

先说一个反直觉的事:REST API 已经够用二十年了,为什么到 Agent 场景就捉襟见肘?因为 REST 的思维模型是"一次请求对应一次响应",请求发出、响应返回、连接结束,状态靠数据库慢慢捋。智能体交互完全不同,它更像一场多人会议,不是一句对一句的对讲机。

举一个实际例子。用户让 Agent"分析季度财报并生成一份简报",这个任务的执行路径可能是:先调用数据库查询工具,再调用代码执行工具做指标计算,再调用文档生成工具整理结果,中间模型还要反复思考,每次工具调用之间可能隔几十秒。如果客户端用纯 REST,你就得靠轮询去问"出结果了没",或者用回调接口让服务器反向通知,而一旦用户说"等一下,我只想先看营收部分",你连"插话"的通道都没有。

REST 在这里有三个绕不开的短板:第一,服务器没法主动往客户端推消息,只能靠客户端反复轮询,轮询本身就是一种资源浪费;第二,每次请求都要重新携带上下文、身份、任务信息,一旦对话变长,token 成本和延迟都不可控;第三,中断和改指令这种"带外操作"没有语义化的表达方式,只能靠新开一个请求去强行覆盖旧任务。所以智能体场景需要的是一个真正的"会话级协议",而不是一堆独立请求的拼凑。

1.2 协议要压住的三个核心问题

Agent Client Protocol 这一层要解决的核心问题,归纳起来就三个。

第一个是双向消息通道。客户端要能给 Agent 发指令、取消任务、追加约束,Agent 要能往客户端推输出内容、工具执行状态、运行日志。注意这里是"随时",不是"你给我请求我再给你响应"。

第二个是流式反馈。模型输出天然是 token 级别的增量,工具执行有中间状态,长任务要持续报进度。协议设计时必须允许一条"逻辑消息"被拆成多个消息片段传输,并且客户端能把这些片段按顺序拼回一个完整结果。

第三个是会话的连续性。聊到一半 WebSocket 断了,客户端重连之后要让 Agent 记得刚才聊到哪了;多轮工具调用中间崩溃了,要能恢复现场而不是从头再来。连接是脆弱的,会话是持久的,协议必须把这两个概念彻底分开。

这三点听起来简单,但每一点往下挖都有大量细节,后面几章我会逐个展开。

1.3 这一层协议到底归谁定义

有一个问题经常被忽略:Agent Client Protocol 目前并没有一个像 HTTP 那样全球统一的标准,它更多是一类"设计范式"。

不同团队会给出不同实现,有的基于 WebSocket 自己封装消息格式,有的直接借用 JSON-RPC 2.0 加扩展,有的干脆把 MCP 的通信层直接拿来做 Agent 和客户端之间的通道。但这不代表可以随便乱来,恰恰因为标准缺失,团队内部更需要一份清晰、带版本号的协议规范文档,否则前后端联调时就只能靠猜。

我在设计自己的协议时就定了一条铁律:任何消息字段、任何状态流转、任何错误码,都必须写进文档,并且文档版本和代码版本一起发布。协议这东西,最怕的不是设计得不够完美,而是改起来没人知道。

2. 传输层选型:为什么默认 WebSocket,SSE 和 HTTP 轮询各自卡在哪

2.1 WebSocket 成为默认选择的原因

传输层是 Agent Client Protocol 的地基。我的结论是:默认用 WebSocket,除非你有非常明确的理由不用它。

原因很直接。WebSocket 是全双工的长连接,客户端和服务器可以在同一条连接上随时互相发消息,天然满足我前面说的"双向通道"需求;消息有独立的帧结构,底层帮你处理了分帧和掩码,应用层拿到手的是一条完整消息,不用自己面对 TCP 粘包半包的脏活;延迟低,首包可以做到十几毫秒以内,对流式输出特别友好。

从实现成本看,WebSocket 在主流语言里都是标准库级别支持,浏览器原生 API 也很成熟。我常跟人讲一个判断——如果这个功能 WebSocket 做不了,大概率不是传输层的问题,而是协议设计的问题。流式输出走 WebSocket 是加分项,双向推消息、主动取消任务、实时工具状态,这些都只有在 WebSocket 上才顺手。

当然,WebSocket 也不是免费的。它需要处理心跳保活,要面对网关和负载均衡器对空闲连接的静默掐断,还要处理断线重连的消息对账。这些问题我放到后面踩坑部分详说,但结论不变:在"智能体实时交互"这个场景里,WebSocket 的收益远大于成本。

2.2 SSE 与 HTTP 轮询的场景边界

SSE(Server-Sent Events,服务器推送事件)经常被拿来和 WebSocket 对比。它的特点是:建立在普通 HTTP 之上,服务器可以持续往客户端推数据,客户端断开后浏览器自动重连。实现极简单,用起来就是一个不断追加的数据流。

但 SSE 有个结构性限制——它是单向的,客户端只能通过单独的普通 POST 请求往服务器发消息,没法在同一条流里做双向沟通。拿它做智能体交互,流式输出没问题,但 Agent 想中途问客户端要一个确认就非常别扭,你得另开一套请求通道,等于把一条对话拆成两半。

HTTP 轮询就更不用说了,它是保底方案:客户端定时发请求查状态。适合的场景是低频、无状态、容忍秒级延迟的简单任务,比如"查一下这个任务完成没有"。智能体场景的延迟和消息量都远超轮询的舒适区,不建议作为主体方案。

下面这张表可以快速帮你做选择:

方案连接方向自动重连实现复杂度适合场景
WebSocket双向全双工需自行实现中实时对话、工具调用、双向消息
SSE服务器到客户端单向浏览器内置低模型流式输出、日志推送、通知
HTTP 轮询客户端请求、服务器响应无低低频任务状态查询、临时兜底

2.3 网关与负载均衡对长连接的隐形限制

WebSocket 真正让人头疼的往往不在协议本身,而在连接路上的基础设施。

第一个经典问题:负载均衡器的空闲超时。很多网关默认几十秒到几分钟内没有数据交互就掐断连接,WebSocket 看起来连着,实际已经断了。解决办法是应用层心跳——定期发 ping 帧,心跳间隔必须明显短于网关的空闲超时时间。我一般设 20 到 30 秒,同时把服务端和网关两侧的超时配置统一核查一遍,这类问题排查起来很费劲但落手很简单。

第二个问题是代理穿透。客户端所在网络环境可能经过各种企业代理或网关,对 WebSocket 的 Upgrade 握手支持不一致,有的代理不认识 Upgrade 头,直接给掐了。上线前一定要在不同网络环境下做一次真实的握手验证,别只在本地 localhost 测试通过就以为万事大吉。

还有一个原则性问题:不要在传输层自己设计加密协议。无论用 WebSocket 还是 SSE,一律走 TLS,也就是 wss:// 和 https://,永远不要发明自己的"加密",这是绝对的底线。

3. 消息结构设计:一条消息从发出到落地的完整生命周期

3.1 统一消息外壳:id、seq、role 各管什么

传输层定了之后,接下来要想清楚的一个问题是:消息长什么样。我强烈建议所有消息共用一个统一外壳,而不是每个消息类型各写各的字段。统一外壳解析简单、便于框架统一处理,日志也好排查。下面这个结构是我打磨过很多版之后的形态:

{ "id": "msg_a1b2c3d4", "seq": 42, "type": "agent.content", "role": "assistant", "session_id": "sess_20250001", "trace_id": "trace_9f8e7d", "content": [ {"type": "text", "text": "这是第一段增量。"} ], "timestamp": "2025-01-15T10:23:45.123Z", "ack": false }

逐字段讲讲为什么这么设计。id是消息的唯一标识,客户端用它做幂等去重,服务器用它做日志关联,重试的时候只需要说"请把 id 为 xxx 的消息再发一次",而不是含糊地"重发最后那条"。seq是客户端和服务端各自维护的消息序号,用于检测消息是否乱序或丢失。role标记这条消息来自 user 还是 assistant,也可能会是 tool,它保证消息归属清晰。content设计成数组而不是简单字符串,是为了将来支持文本、图片、工具调用参数等混合内容,加新类型不需要推翻旧格式。

type则是语义层的关键。我的协议里只保留三个基础类型:request(客户端发起的指令)、response(对 request 的最终应答)、event(一些不绑定请求的事件,比如连接就绪、服务器主动通知)。其余都是它们的扩展子类型,比如agent.content、stream.chunk都在event下面。

3.2 流式增量:chunk 与 final 的分工

流式输出是 Agent 场景最核心的体验点,系统设计上很容易忽略"一条逻辑消息 vs 多条传输消息"的区别。模型最终回答的是一整段文本,但传输时是几十个 token 增量依次到达。协议必须在两者之间架一座桥。

我的方案是两条消息类型配合。第一条是stream.chunk,只携带增量内容,比如上面那个 JSON 里的content[0].text就是"这是第一段增量"。第二条是stream.final,携带这条逻辑消息的完整结果,以及 token 统计、耗时等信息,客户端收到final之后可以关闭流式渲染、做收尾处理。

{ "id": "msg_a1b2c3d4_final", "seq": 58, "type": "stream.final", "session_id": "sess_20250001", "content": [ { "type": "text", "text": "这是完整回答的全文……" } ], "usage": {"input_tokens": 218, "output_tokens": 87}, "duration_ms": 1240 }

这里有个工程细节:客户端渲染时遇到stream.chunk就按顺序追加文本,千万不要把 chunk 当作可独立渲染的完整片段,因为增量可能是不完整的多字节字符。stream.final出来前,所有 UI 上的"停止生成"按钮都只是本地逻辑,真正让 Agent 停止的是另一条request消息。流式协议设计得干净,前端的加载状态、打字机动画、流式日志这些体验就都顺理成章了。

3.3 工具调用闭环:大脑与手的往返

工具调用是 Agent 协议里最绕的一环,但也是最能体现协议设计价值的一环。客户端负责执行工具,Agent 负责决定调用哪个工具、传什么参数,二者之间的往返完全靠协议编排。

完整闭环一般是这样走的:

  1. 客户端发request,内容是"帮我分析营收数据"。
  2. Agent 内部推理后回一条tool.call消息,带上call_id、工具名analyse_data和参数。
  3. 客户端收到后执行真实工具调用(比如查询数据库、跑一段脚本)。
  4. 客户端把结果封装成tool.result消息,同样带上call_id回给 Agent。
  5. Agent 拿到结果继续推理,可能再次发tool.call,也可能直接发stream.final结束整个任务。

这个过程里最容易被坑的地方是:call_id必须一一对应,且客户端必须能容忍乱序到达的多个工具调用结果。我在早期设计时曾天真地假设"一次并发最多一个工具调用",结果模型产生并行工具调用(比如同时查天气和查航班)时,消息顺序彻底错乱。后来我把call_id作为消息关联的唯一键,seq只用来检测丢消息,工具调用的并行度才彻底放开——这也是统一消息外壳带来的红利,遇到类似问题不用改传输层就能容纳。

4. 会话恢复、能力协商与多实例路由:连接断了不等于对话没了

4.1 session 的建立、持久化与恢复

连接会断,服务会重启,客户端会切后台,这些在移动场景都是常态。协议必须保证一件事:对话的上下文,不依赖某一条具体连接存活。

所以我把"连接"和"会话"当成两个完全独立的概念。一个 session 由session_id标识,服务端负责生成并维护它的生命周期;连接只是会话的一条"通路",断了可以再建。客户端连接时带上session_id,服务端就能根据内存或持久化存储恢复上下文。

恢复之后怎么对账?这是细节中的细节。协议上我要求服务端在重连握手成功时回一条session.resumed事件,携带客户端最后确认的消息序号,客户端根据这个序号补齐缺失的消息。客户端侧的做法是:本地维护所有已确认消息的id集合,重连后统计去重,不能傻乎乎地重发所有请求,否则 Agent 在断线期间已经执行完的任务会被重复执行一遍。这个坑我踩得很实,后面踩坑章节会专门展开。

另一个容易忽视的点是:服务端到底存什么。我坚持"服务端存状态、客户端存渲染"的原则。session 里保存对话历史、未完成的任务、工具调用中间态,客户端只保存已经渲染出来的 UI 数据。两边一分离,断线恢复才能做到"重新开口就是继续聊,而不是重新开始聊"。

4.2 capabilities 协商:双方先亮底牌

Agent 的能力是会演化的,今天支持流式输出,明天加了工具调用,后天又上了某个新的输入类型。如果协议假设双方能力永远不变,每次升级都会变成末日。解决思路是握手时做能力协商,一下就能定下本次连接的契约,逻辑上很像 TLS 握手时的 ClientHello/ServerHello。

客户端连接时就发一份自己的能力清单,内容大致是:

{ "type": "hello", "session_id": "sess_20250001", "client_info": {"name": "web-app", "version": "1.2.0"}, "capabilities": { "streaming": true, "tool_callbacks": true, "max_message_size": 16384 } }

服务端收到后,回一条hello.ack,附上服务器能力,比如支持的模型列表、工具列表、上下文窗口大小、并发限制等。客户端拿到后就知道:哦,这个 Agent 支持流式但不支持图片输入,那我就不渲染图片按钮。

这种协商机制最大的价值是向后兼容。老客户端遇到新服务端时,服务端可以根据客户端的能力降级处理——不支持流式就一次性返回完整结果;老服务端遇到新客户端时,客户端也能不调用它不认识的能力。没有这套协商,协议一升级线上就出乱子。

4.3 多副本部署下的粘性路由

当流量上来,Agent 服务从单实例扩到多实例,一个此前被忽略的问题会突然浮出水面:会话是有状态的,请求不能随便轮询分发。

假设两个副本 A 和 B 同时在线,客户端第一次连到了 A,A 的内存里维护着会话状态。第二次连接被负载均衡转到了 B,B 不认识这个 session,要么拒连,要么被迫从持久化存储里重新加载上下文——性能损耗不说,加载失败就直接断档了。

常规解法就是会话粘性路由:按session_id做一致性哈希,让同一个 session 的请求永远落在同一个副本上。服务端内存保留会话状态,Redis 或共享 KV 做一层兜底快照,这样既保证了性能,也防止单副本宕机时全量丢状态。

我特别想提醒一点:粘性路由要在一开始上多副本时就设计好,而不是等会话丢失事故发生了再补。单实例阶段完全无感,一旦扩容就立刻踩雷,且排查起来极度痛苦,因为问题表现是间歇性的"偶尔聊着聊着就失忆了"。

5. 安全鉴权、错误码与重试策略:协议上线前的三道坎

5.1 认证、授权与最小权限原则

很多人在搭内部系统时容易想"反正是内网,认证随便搞搞就行"。我建议 Agent 场景一定不能这么干,因为 Agent 的特殊性在于——它拿的是系统权限去调工具,一旦被滥用,危害面比普通 API 大得多。

认证层我的标准做法是短时 token 机制:客户端拿短期 access token 建立 WebSocket 连接,token 有效期设置短一些,比如 15 分钟,过期后通过刷新流程换新。注意,长连接内的 token 过期检测要用服务端主动踢下线的机制,而不是等连接自己断,否则挂着的连接可以无限期使用过期凭据。

授权层要特别强调最小权限。服务端在收到客户端的工具调用请求时要做的事:先鉴权——用户是否允许调用这个工具,而非 Agent 是否声明要调用。比如客户端执行"删除数据库记录"这个工具,协议里必须携带用户的身份上下文,工具权限校验按用户身份走,不能按 Agent 身份走。否则只要 Agent 被诱导蒙骗,工具调用就可能越权执行到其他用户的数据上,这就是横向越权的漏洞。

5.2 错误码语义与重试退避

协议里的错误码设计不需要花哨,但语义必须明确。我的经验是:错误码要能回答两件事——"谁错了"和"能不能重试"。单一笼统的"500"在智能体交互里是灾难,因为你根本无从判断是该让用户重新输入、还是该让客户端自动重连、还是该让 Agent 换个工具再试。

下面是我实际用过的一套分类:

错误类别典型错误码是否可重试建议处理方式
客户端错误400 参数错误、401 鉴权失败否提示用户修复输入或重新登录
服务端错误500 内部异常、503 依赖不可用是指数退避重试,设最大次数上限
限流错误429 请求过快是按 Retry-After 或退避等待
工具执行错误1001 工具不存在、1002 执行超时分情况只重试失败的工具调用,不重跑整个任务
连接错误1006 连接异常中断是自动重连并做会话恢复

重试策略有一个我踩过坑的要点:重试的应是"未确认的消息",而不是"最后一条指令"。一旦客户端发出消息 A,服务端已经开始执行,客户端断连后重连,如果机械地重发消息 A,Agent 可能在断线期间已经完成了任务,这会儿就重复执行了。正确做法是服务端维护一个已处理消息id的去重集合,重发时返回"已经处理过,这是当时的结果",而不是重新执行。

退避公式用最经典的指数退避加抖动就行:

sleep = min(cap, base * 2^retry_count + random_jitter)

比如base=200ms,cap=5s,每次重试后等待时间指数上涨且带随机扰动,避免断线风暴时所有客户端同时重连打爆服务器。实测下来,抖动那一项虽然看似微不足道,但在大规模重连场景能把峰值请求削掉很大一部分。

5.3 协议审计、限流与全链路追踪

最后这三件事是为了让协议能"经营"起来。分开说:

限流要同时限制几个维度:连接数、消息速率、消息大小。单条消息建议设硬上限,比如 16KB,超过就拒绝并返回明确错误。如果业务确实要传大文本,不要硬塞进一条消息,而是提供分片接口或转成文件引用,客户端再单独拉取。

审计指的是所有协议消息都要能留痕。我要求服务端对关键事件(鉴权、会话建立、工具调用、执行结果)打审计日志,日志和业务日志分开存储。出了安全事故,能否快速还原"这个 session 到底干了什么"全靠这一步。

全链路追踪用trace_id贯穿所有消息。现在微服务架构下,Agent 内部一次任务会经过网关、模型服务、工具服务,没有 trace_id 根本没法把一次用户请求串联起来。协议里强制每个消息都带trace_id,成本极低但排查时报错效率提升好几个量级。

6. 实战踩坑与测试压测:从可用到好用的最后一公里

6.1 四个只有灰度期才会暴露的坑

这一节写点血泪经验,都是我接手的实际项目里真实发生过的问题。

第一个坑:长连接被网关静默掐断。现象是 WebSocket 看起来一直连着,但客户端已经收不到任何推送。排查下来发现是负载均衡器有一个空闲超时,超过时间没有数据交互就断开连接,而且断开时不会主动通知客户端。修复方式是应用层心跳:客户端每 20 秒发一个ping,服务端返回pong,双方都把"超过 2 个周期没收到心跳"视为连接死亡,主动重连。设置心跳间隔时要去确认网关上真实的空闲超时是多少,不要盲目相信默认配置。

第二个坑:消息重放导致 Agent 重复执行任务。生产环境出现用户反馈"我明明只让 Agent 发了一次邮件,结果发了两封"。根因是客户端断线重连后,把断线前没收到确认的请求原样重发,而服务端没有做幂等去重。后来我在服务端维护一个近期消息id的缓存表,重复消息直接返回已处理结果,问题立刻消除。这个id去重表要设置合理的过期时间(比如 5 分钟),否则内存会越积越大。

第三个坑:工具调用死锁。场景是 Agent 发了一个tool.call,客户端执行工具时又向同一个 session 发起了一个新请求,而服务端处理新请求的线程在等工具调用结束,两边互相等待,直接把 session 卡死。修复方案是在协议层和服务端实现上都做了超时控制:工具调用超时时间设了上限,超过就返回1002 工具执行超时给 Agent,客户端发消息也设置独立的发送超时,不让一条消息阻塞整个 session。死锁问题单独靠协议设计很难完全避免,必须在运行时有兜底超时。

第四个坑:大消息把缓冲撑爆。某次 Agent 把一个十几万字符的日志塞进了一条消息,客户端和服务端的缓冲区直接溢出了。后来我做了三个处理:单条消息大小限制 16KB;超过限制的分片传输并加序号;再把"大量数据"的语义从协议里剥离出去,改成"文件引用"——消息里只带一个引用 ID,客户端通过普通文件接口去拉取。这个改法让协议骤然变轻,也顺带解决了限流问题。

6.2 三层测试:单测、集成、压测

协议测试和普通功能测试不一样,它需要同时验证状态机和时序逻辑。我的实践是分三层。

第一层是单元测试,mock 掉真实 Agent,用预置的消息序列驱动协议状态机,验证每个状态迁移是否正确。比如断线重连时,给客户端发一个session.resumed,检查它是否正确补齐了缺失序号的消息。这层测试跑得最快,适合开发阶段持续执行。

第二层是集成测试,真实起一个测试环境,用真实浏览器或移动端客户端连真实的 Agent 测试服务,跑完整的端到端对话流程。我会写自动化脚本模拟用户:发起会话 → 等待流式输出 → 触发一次工具调用 → 中断 → 恢复 → 继续对话。每个环节都断言最终结果符合预期。

第三层是压测,关注三个指标:并发会话数、每秒消息吞吐量、P95 延迟。压测时最值得关注的是"断线重连风暴"——一次性杀掉一半连接,看服务端能不能在重连高峰下存活下来。这类压测很容易暴露前面说的幂等问题和会话恢复性能,我几乎每次都能从中挖出新的隐患。

我还建议开发一个简单的协议调试工具,本质就是一条命令连上服务端,持续收发消息并打印原始 JSON。早期协议调试时,它比任何图形化客户端都好用,因为你能直观看到消息的每一个字段和顺序。

6.3 标准化现状与"要不要自己造轮子"

离开头说的"协议没有统一标准"再延展一下。现在业界常见的相关协议大致分三层:

协议层面解决什么我的建议
Agent Client Protocol(本文讨论的一层)客户端 ↔ Agent 之间的话音交互、流式、工具调用团队内自行明确定义、文档化、版本化
模型上下文协议(MCP)Agent ↔ 工具 / 数据源 之间的标准化连接直接采用,不要重复造轮子
智能体间通信协议(A2A)Agent ↔ Agent 之间的协作交互按需采用,多智能体场景再引入

我的观点很明确:工具接入层直接用现成的标准化协议,不要自己发明;客户端到 Agent 的这层协议,现阶段自己定义是合理的,但必须当作一等公民来对待——有版本、有测试、有文档。因为 Agent 的应用形态还在快速演进,很多团队的业务逻辑都压在这一层上,直接用通用框架的默认协议当然省事,但一旦遇到定制需求(比如多模态输入、特殊的中断语义),就容易被框架的限制卡住。

最后分享一个我个人的经验习惯:协议规范文档的第一版别追求大而全,先解决三个问题——消息格式、错误码、会话恢复,其余能力协商、限流这些等真正需要时再加。我见过太多团队第一版协议就写了上百页,结果开发三天,妥协一个季度。协议是给沟通立规矩的工具,不是炫技的舞台。

另外,调试智能体通信协议时,我会在本地用"把每个原始消息按 JSON Lines 格式追加到日志文件"的办法,既能完整复盘某次对话过程,又对真实转发链路零干扰。这一招帮我好几次定位到那种"偶发、难以复现"的时序问题,强烈建议你也试试。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询