
如果你最近在搭任何带对话框的 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. 传输层选型为什么默认 WebSocketSSE 和 HTTP 轮询各自卡在哪2.1 WebSocket 成为默认选择的原因传输层是 Agent Client Protocol 的地基。我的结论是默认用 WebSocket除非你有非常明确的理由不用它。原因很直接。WebSocket 是全双工的长连接客户端和服务器可以在同一条连接上随时互相发消息天然满足我前面说的双向通道需求消息有独立的帧结构底层帮你处理了分帧和掩码应用层拿到手的是一条完整消息不用自己面对 TCP 粘包半包的脏活延迟低首包可以做到十几毫秒以内对流式输出特别友好。从实现成本看WebSocket 在主流语言里都是标准库级别支持浏览器原生 API 也很成熟。我常跟人讲一个判断——如果这个功能 WebSocket 做不了大概率不是传输层的问题而是协议设计的问题。流式输出走 WebSocket 是加分项双向推消息、主动取消任务、实时工具状态这些都只有在 WebSocket 上才顺手。当然WebSocket 也不是免费的。它需要处理心跳保活要面对网关和负载均衡器对空闲连接的静默掐断还要处理断线重连的消息对账。这些问题我放到后面踩坑部分详说但结论不变在智能体实时交互这个场景里WebSocket 的收益远大于成本。2.2 SSE 与 HTTP 轮询的场景边界SSEServer-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 负责决定调用哪个工具、传什么参数二者之间的往返完全靠协议编排。完整闭环一般是这样走的客户端发request内容是帮我分析营收数据。Agent 内部推理后回一条tool.call消息带上call_id、工具名analyse_data和参数。客户端收到后执行真实工具调用比如查询数据库、跑一段脚本。客户端把结果封装成tool.result消息同样带上call_id回给 Agent。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 同时在线客户端第一次连到了 AA 的内存里维护着会话状态。第二次连接被负载均衡转到了 BB 不认识这个 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服务端已经开始执行客户端断连后重连如果机械地重发消息 AAgent 可能在断线期间已经完成了任务这会儿就重复执行了。正确做法是服务端维护一个已处理消息id的去重集合重发时返回已经处理过这是当时的结果而不是重新执行。退避公式用最经典的指数退避加抖动就行sleep min(cap, base * 2^retry_count random_jitter)比如base200mscap5s每次重试后等待时间指数上涨且带随机扰动避免断线风暴时所有客户端同时重连打爆服务器。实测下来抖动那一项虽然看似微不足道但在大规模重连场景能把峰值请求削掉很大一部分。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 之间的话音交互、流式、工具调用团队内自行明确定义、文档化、版本化模型上下文协议MCPAgent ↔ 工具 / 数据源 之间的标准化连接直接采用不要重复造轮子智能体间通信协议A2AAgent ↔ Agent 之间的协作交互按需采用多智能体场景再引入我的观点很明确工具接入层直接用现成的标准化协议不要自己发明客户端到 Agent 的这层协议现阶段自己定义是合理的但必须当作一等公民来对待——有版本、有测试、有文档。因为 Agent 的应用形态还在快速演进很多团队的业务逻辑都压在这一层上直接用通用框架的默认协议当然省事但一旦遇到定制需求比如多模态输入、特殊的中断语义就容易被框架的限制卡住。最后分享一个我个人的经验习惯协议规范文档的第一版别追求大而全先解决三个问题——消息格式、错误码、会话恢复其余能力协商、限流这些等真正需要时再加。我见过太多团队第一版协议就写了上百页结果开发三天妥协一个季度。协议是给沟通立规矩的工具不是炫技的舞台。另外调试智能体通信协议时我会在本地用把每个原始消息按 JSON Lines 格式追加到日志文件的办法既能完整复盘某次对话过程又对真实转发链路零干扰。这一招帮我好几次定位到那种偶发、难以复现的时序问题强烈建议你也试试。