
最近我在折腾一个智能客服类的 Agent 项目遇到了一个特别典型的问题大模型 Agent 本身能回答、能调工具、能查订单但怎么把这种能力干净地接到我们的网页、IM、甚至企业微信这类客户端上一直没有让人省心的办法。各家都在自己定义消息格式前端适配逻辑越堆越多换个场景就得重写一层。后来我翻到一份关于 Agent Client Protocol 的公开草案顿时觉得这个思路太对口了。Agent Client Protocol简称 ACP核心要做的事情就是定义“运行在服务端的 Agent”和“运行在客户端的应用”之间的交互语言。它和我们常说的 MCP 不一样MCP 解决的是模型怎么调用外部工具而 ACP 解决的是“用户面前的应用”怎么跟“自主决策的 Agent”对话。这篇文章我会把 ACP 的来龙去脉、核心机制、实际落地的接线方式以及我在模拟项目里踩过的坑都梳理一遍适合正在做 Agent 产品、或者准备把大模型能力嵌入到现有客户端的开发者参考。1. 为什么需要 Agent Client Protocol人和智能体协作的协议断层1.1 现有交互方式的尴尬先说说我自己的真实经历。之前做那个智能客服项目时整个链路分三段前端页面、后端网关、大模型推理服务。前端把用户消息发给网关网关整理上下文后丢给模型模型返回结果后再由网关拼装成 JSON 回给前端。这看起来没什么问题但当 Agent 开始“自主”时事情就变了。自主意味着 Agent 不是简单的一次一问一答。它可能要先去查一下用户订单状态再判断是否需要调用退款接口退款成功后还要主动向用户确认。这个过程中前端不知道 Agent 内部进行到哪一步它只能被动接收一个最终的、可能延迟好几秒的结果。用户看到一个“正在输入”的转圈心里就发毛这到底是挂了还是在干活如果 Agent 中途要补充问一句“您确定要退款吗”前端还得额外设计一个“追问”接口。一个两个还好功能一多自定义协议就膨胀得没法维护。我见过不止一个团队在会话接口里塞了几十个字段每个字段还分好几种状态。今天加个“工具调用中”明天加个“等待用户确认”后天再加个“上下文已过期”。这些都是在给“没有标准的 Agent 通信”打补丁。ACP 的思路就是把这些补丁统一掉让服务端 Agent 与客户端应用之间有一套固定的、语义清晰的交互协议。1.2 从 REST 到事件流ACP 的核心思路ACP 给我最大的启发是它把交互模型从“请求-响应”切换成了“事件流”。REST 式交互很像查快递单号你得不断发请求去问“到哪了”问到就返回一个快照。如果 Agent 正在调工具、正在思考这个快照就是“暂无更新”然后你还要自己设计轮询策略、超时时间。而事件流式交互更像是快递轨迹推送你订阅了这个会话任何状态变化都会主动推给你。Agent 开始处理一条消息推一个“消息已接收”开始调工具推一个“工具调用已开始”工具返回了再推一个“工具调用完成”最后模型生成了回答推一个最终消息。整个过程客户端根本不需要关心服务端内部是怎么编排的只需要把事件按顺序渲染出来就行。这种设计对用户体验的提升非常直接。用户能看到 Agent 的“工作进度”哪怕中间等待很久也知道系统在正常执行而不是卡死了。开发者角度也有好处前端逻辑从“拼接自定义状态机”简化为“渲染事件列表”服务端只要保证事件的顺序和准确性。2. 协议核心机制拆解2.1 会话与消息模型如果把 ACP 类比成一个“网页版聊天室协议”那它的核心对象可以分成四层会话Session、轮次Turn、消息Message和事件Event。会话是整个交互的容器。一个用户打开一个客服网页创建一个会话这个会话里所有消息、事件都归属于它。会话会有生命周期从创建开始到显式结束或者超时销毁为止。轮次通常对应一次完整的用户输入到 Agent 输出结束的周期。比如用户问“我的订单到哪了”Agent 内部调一次物流查询接口然后生成一段文字回复这就是一个轮次。消息是轮次内的具体内容可能来自用户也可能来自 Agent还可以包含文本、图片、附件等多种类型。事件则是最底层的“信号”。客户端实际上收的是事件流比如“接收到新消息”“工具调用开始”“模型输出流式片段”“错误发生”等等。我刚才做前端集成的时侯就是把事件当成“UI 的驱动源”来一个“开始事件”就显示一个小图标来一个“流式片段”就追加文本来一个“结束事件”就收起等待状态。这里有一个设计关键点协议不要求客户端去理解 Agent 内部的“思考过程”。工具调用可以展示成“正在查询订单系统…”也可以不展示完全由客户端来决定。这种“把选择权交给客户端”的设计让 ACP 能够适配网页、桌面、移动端甚至语音助手等完全不同的 UI 形态。2.2 认证与安全设计既然 ACP 是企业级场景下的产物安全设计就不能只是加一个 Token。公开草案里能看到几个层次的安全考虑包括传输层加密、会话鉴权和用户身份绑定。最基础的是传输层必须走 TLS这在现代 API 设计中已经是底线了。比较有意思的是面向客户端的授权流程客户端应用在发起 ACP 连接之前需要先获取一个访问令牌这个令牌通常通过 OAuth 2.0 设备授权流程或者 Authorization Code 流程获得。设备授权适用于电视、终端这类不方便输入密码的设备普通网页应用则用 Authorization Code。用户身份绑定这块ACP 会把“会话”与“用户”关联起来。也就是说Agent 不仅知道你在跟“某个浏览器实例”对话还知道你在跟“哪个账号”对话。这样一来Agent 在执行涉及敏感信息的操作时可以做更细粒度的权限判断。我在模拟项目里试过把用户身份字段直接嵌入到会话创建请求中服务端用这个信息决定要不要给该用户显示“退款”按钮效果比前端硬编码权限要灵活得多。安全上我还要提一个容易被忽略的点令牌刷新。ACP 会话可能持续很长时间而访问令牌通常几十分钟就过期了。客户端必须在令牌过期前主动刷新否则事件流会突然中断。后面我会在排查章节详细讲这个坑。2.3 工具调用与多模态扩展工具调用是 ACP 协议里最能体现“Agent 特色”的部分。在传统的聊天 API 里模型返回一段文本就结束了。而在 ACP 的世界里Agent 可以发出一个“工具调用请求”要么让客户端代为执行要么客户端显示一个可点击的确认按钮等用户授权后端再执行。我之前做过一个对比实验。一套系统里让 Agent 直接调用内部订单接口另一套系统里让 Agent 先把“即将调用订单接口”的事件推给前端前端展示一个“查询订单”的卡片用户点了之后才真正调接口。后者的用户安全感明显更好尤其涉及到退款、改地址这类敏感操作时用户希望自己有“最后一道闸门”。多模态支持方面ACP 的消息类型不只限于纯文本。图片、文件引用、结构化卡片都可以作为消息内容承载。这就让 Agent 能做的事情大大扩展你问“这张发票是哪个订单的”Agent 可以一边收图片一边查订单然后在回复里附带一个“订单详情卡片”而不是干巴巴的 JSON。我在项目里就用这种卡片式消息替代了之前自定义的一堆富文本协议前端只需实现一个通用卡片渲染器新需求来了只要后端增加一种卡片类型即可。3. 实操在你自己的应用中集成 ACP3.1 建立安全通道与最小集成流程如果今天你打算在项目里接入 ACP我会建议按下面的最小流程走在服务端实现 ACP 网关负责统一接收客户端事件流请求。配置 OAuth 2.0 授权服务给前端签发访问令牌。客户端创建一个 Session拿到 sessionId。客户端通过 SSEServer-Sent Events或 WebSocket 建立事件流连接。用户发送消息客户端把消息 POST 给服务端。客户端持续监听事件流按事件类型更新 UI。这里我强烈建议第一步先选 SSE 而不是 WebSocket。原因很朴素WebSocket 是全双工的意味着还要管理服务端到客户端和客户端到服务端两套消息路由、心跳检测和二进制帧解析复杂度直接翻倍。而 ACP 的消息方向本身就很明确用户消息与命令走普通的 HTTPS 请求事件推送走 SSE 单向流就够了。在模拟项目里我用了一个非常轻量的 Node.js 服务做 ACP 网关配合 Postgres 存储会话和消息记录再接一个开源的 Agent 调度服务。整个环境在半天内就能跑通。3.2 用 TypeScript 实现一个事件流客户端我写一个精简版的事件流客户端代码代码思路可以直接搬到 React 或 Vue 项目里。interface ACPEvent { eventId: string; type: message.updated | tool.started | tool.finished | error.occurred; timestamp: string; data: Recordstring, unknown; } interface Session { sessionId: string; status: active | ended; } class ACPClient { private eventSource: EventSource | null null; private session: Session | null null; private baseUrl https://your-gateway.example.com/acp/v1; async createSession(userToken: string): PromiseSession { const resp await fetch(${this.baseUrl}/sessions, { method: POST, headers: { Authorization: Bearer ${userToken} }, body: JSON.stringify({ userId: user_123456 }) }); const data await resp.json(); this.session data; return this.session; } connectSession(token: string) { if (!this.session) throw new Error(session not ready); const url ${this.baseUrl}/sessions/${this.session.sessionId}/events; this.eventSource new EventSource(url, { headers: { Authorization: Bearer ${token} } }); this.eventSource.addEventListener(message, (event) { const payload: ACPEvent JSON.parse(event.data); this.onEvent(payload); }); this.eventSource.onerror () { // 触发重连逻辑或者提示用户检查网络 console.warn(event stream error); }; } async sendUserMessage(text: string) { if (!this.session) throw new Error(session not ready); await fetch(${this.baseUrl}/sessions/${this.session.sessionId}/messages, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ role: user, content: text }) }); } onEvent(evt: ACPEvent) { switch (evt.type) { case message.updated: // 渲染流式文本 break; case tool.started: // 展示工具调用过程 break; case tool.finished: // 更新工具调用状态 break; case error.occurred: // 展示错误信息 break; } } }这段代码有两点值得注意。一是EventSource浏览器原生只支持 GET 请求所以授权信息通常塞在 URL 查询参数或 Cookie 里。如果你一定要在 EventSource 请求头里带 Bearer Token就得像上面代码里写的那样用 polyfill 或者改走 fetch-based SSE否则会踩浏览器的坑。二是message.updated这种事件要放在所有事件处理的最前面因为模型输出是流式的同一个消息 ID 可能会反复出现多次UI 需要做“追加更新”而不是整体替换。3.3 会话生命周期与状态管理ACP 会话的生命周期比一般 REST API 要长这也意味着状态管理得格外上心。我建议直接建一张会话状态表字段大致包含 session_id、user_id、status、created_at、last_active_at、context_key。last_active_at 字段特别重要服务端可以用它判断一个会话是否超时需要回收。客户端则要在页面隐藏或应用切换时主动“暂停”事件流等用户回来再重新连上。一个容易忽略的问题是会话消息顺序。事件流虽然能保证在不丢包的情况下按序到达但前端 UI 的渲染如果没做防抖可能在高速消息流下出现闪烁。我处理办法是维护一个“待渲染队列”每一条消息在收到“完成”事件之前只更新缓冲区收到“完成”之后一次性写入 DOM。这样既保证了流畅度也不会让用户看到模型输出反复跳变。还有一个实际场景用户在网页里输入了一条消息结果网络断了几秒钟重新连上事件流后发现 Agent 已经给出了完整回复。这里客户端就要注意“拉取历史消息”的接口。ACP 通常会提供历史消息查询能力前端重连后应主动同步一次增量消息把服务端最新的消息状态拉下来覆盖掉本地缓存。4. 常见问题与排查技巧实录4.1 事件流重连导致的消息重复渲染我在一次联调中遇到过这个问题客户端断线重连后新事件流开始重复推送之前的消息前端把重复消息渲染了出来界面上出现两段一样的 Agent 回复。排查思路是先去重而不是先去改协议。我给每个事件加了一个递增的 sequenceId客户端只接受比当前已处理的最大 sequenceId 更大的事件其余直接丢弃。这样即使 SSE 服务端在恢复连接时从断点重新推送客户端也能稳得住。如果你用的是自己开发的网关建议在网关上保留会话的最近事件游标。客户端重连时带上 lastEventId网关从该位置续推事件而不是从头推起。SSE 原生支持 Last-Event-ID 头来标记断点这个机制一定要用起来不然在线用户数一多重复推送会浪费大量带宽。4.2 长轮次任务卡住的排查思路有一次模拟项目里Agent 在处理一个复杂的多步骤任务前端显示“正在调用工具”但几分钟都没有结束事件。我一开始以为是模型推理太慢后来才发现是网关里一个超时设置太小导致工具回调结果在网关侧被丢弃了。排查这种“长任务卡住”的问题我建议按这个顺序查先确认 Agent 服务端进程是否真的有在输出如果日志里工具调用已经返回但事件没推到客户端那就是网关到客户端这一段出了问题如果日志里工具调用根本没返回那就是工具侧的问题如果在模型等待结果时整个会话被强制关闭再去翻网关的超时配置。这里给一个常用参数参考表现象可能原因解决方向客户端长时间无事件事件流连接断开检查心跳与重连机制工具调用无返回工具执行超时调大工具调用超时阈值会话无响应但进程正常上下文窗口不足参数压缩或清理历史消息事件流偶发断开心跳间隔过短调整负载均衡空闲超时还有一个偏门但常见的问题某些网关或反向代理默认空闲超时只有 60 秒SSE 连接超过这个时间就会被切断。后来我在客户端加了应用层心跳每 30 秒发一个注释帧SSE 的: keep-alive问题就消失了。4.3 访问令牌过期造成的连接中断这个问题最隐蔽。我在联调环境里一直正常但部署到测试环境后每次运行超过三十分钟事件流肯定断一次。查到最后是 OAuth 令牌的有效期只有 30 分钟SSE 连接一旦建立服务端不会主动检查令牌是否过期代理在令牌过期后拒绝续传连接就断了。解决办法是双管齐下客户端在每 20 分钟提前刷新令牌刷新成功后把新令牌传给事件流服务服务端更新鉴权信息同时服务端对已建立的 SSE 连接增加“软鉴权”机制也就是连接建立后不因为令牌过期立刻掐断连接而是标记为“待验证”等客户端带上新令牌后再恢复正常。如果客户端超过一定时间没有更新令牌服务端再主动断开。这块网上资料很少基本是踩了坑才知道的。如果你们的 ACP 网关是自己写的我建议把令牌状态也纳入会话状态机而不要只依赖连接层的鉴权。5. 与 MCP、A2A 的关系一个体系里的不同层5.1 三者定位与边界最近 MCP 和 A2A 讨论很多再加上 ACP不少朋友会混淆。我自己的理解是这三个协议解决的是“智能体基建”的三个不同问题MCPModel Context Protocol定义模型与工具之间的边界。解决的是“Agent 怎么调用外部数据源和工具”。A2AAgent-to-Agent定义两个 Agent 之间如何协作。解决的是“一个复杂的任务如何在多个专业 Agent 间分配”。ACPAgent Client Protocol定义 Agent 与人所处的客户端应用之间的边界。解决的是“用户如何看到、控制、干预 Agent 的执行过程”。用一个类比MCP 相当于服务端里模块间的接口规范A2A 相当于微服务之间调用规范而 ACP 则相当于用户前端的交互协议。三者不在一个层次也不是替代关系。5.2 实际项目中的组合用法我在一个模拟项目里把这三种协议组合起来用过。场景是一个企业内部的报销助手。用户在企业微信里触发报销流程后端有一个调度 Agent这个 Agent 通过 A2A 协议把“查报销政策”子任务发给另一个专用 Agent然后那个专用 Agent 通过 MCP 调用公司内部知识库的查询工具最后所有中间结果汇总裁度由调度 Agent 将状态通过 ACP 事件流推给前端。整个过程前端只看到事件流一开始是“查询中”然后是“匹配到三个报销条目”最后是“报销单创建成功”。它根本不知道背后发生了 A2A 的 Agent 间对话也不知道 MCP 调了哪些工具。这种“按需隐藏复杂性”的能力正是 ACP 在整套体系里的独特价值。如果你正在设计一个新 Agent 产品我的建议是先把 MCP 这一层建好让 Agent 的能力边界清晰然后看产品形态如果需要与其他 Agent 协作再引入 A2A最后不管你用什么框架客户端交互这层都要认真考虑 ACP 或类 ACP 的事件流方案否则你迟早会陷入自研协议膨胀的泥潭。我在实际项目里最大的一点体会是Agent 类产品最后比拼的往往不是模型本身而是 Agent 与用户之间的沟通质量。ACP 这种协议把中间那些无标准的地带梳理清楚了让开发者能把精力放回产品逻辑上而不是每天跟自定义 JSON 字段搏斗。如果你们团队准备做 Agent 与客户端的交互层建议从最薄的 SSE 事件流集成做起先接一个会话、一种事件类型跑通了再慢慢补全工具调用和认证授权。哪怕不直接用 ACP参照它的会话与事件模型去设计自己的协议也能少走很多弯路。