最近我在折腾一个智能客服类的 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. 实操:在你自己的应用中集成 ACP
3.1 建立安全通道与最小集成流程
如果今天你打算在项目里接入 ACP,我会建议按下面的最小流程走:
- 在服务端实现 ACP 网关,负责统一接收客户端事件流请求。
- 配置 OAuth 2.0 授权服务,给前端签发访问令牌。
- 客户端创建一个 Session,拿到 sessionId。
- 客户端通过 SSE(Server-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: Record<string, 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): Promise<Session> { 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,不少朋友会混淆。我自己的理解是,这三个协议解决的是“智能体基建”的三个不同问题:
- MCP(Model Context Protocol):定义模型与工具之间的边界。解决的是“Agent 怎么调用外部数据源和工具”。
- A2A(Agent-to-Agent):定义两个 Agent 之间如何协作。解决的是“一个复杂的任务如何在多个专业 Agent 间分配”。
- ACP(Agent 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,参照它的会话与事件模型去设计自己的协议,也能少走很多弯路。