ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MCP与A2A协议联调实战:智能体工具调用与任务编排避坑指南

MCP与A2A协议联调实战:智能体工具调用与任务编排避坑指南 简介智能体Agent正成为连接大模型与实际业务系统的关键形态而协议层是它能否稳定落地的基础。MCP模型上下文协议负责让模型按统一方式调用外部工具与数据资源A2A智能体间协议则解决不同智能体之间任务下发、状态同步与结果交付。理解两者的边界与协同方式是构建可编排、可观测的多智能体系统的前提。在实际工程中将 MCP 的短连接工具调用与 A2A 的长生命周期任务状态机组合使用时常遇到参数类型失真、stdio 进程异常、任务状态不推进、身份语义串线等问题。这套联调方法适用于 Agent 编排、MCP Server 开发、多智能体协作平台建设等场景能帮助开发者快速定位边界并搭建最小可用骨架。1. 当你手头同时出现 MCP 和 A2A先搞清它们不是替代关系做智能体联调的时候我拿到过一份《A2A协议与MCP协议解析》的 PPT翻到后面才发现真正容易翻车的不是单个协议本身而是把两者当成同一类东西去对比选择。MCPModel Context Protocol解决的是“模型怎么调用外部工具”A2AAgent-to-Agent解决的是“两个智能体怎么互相交付任务”。一个管模型的手一个管智能体的嘴两者不是竞争而是上下游。这份资源把两条协议线放在同一个框架里讲适合正在做 Agent 编排、MCP Server 开发或者想把已有智能体接入统一 Agent 网络的人。下面我把 PPT 里的协议模型拆开再补上联调时的参数、边界和踩坑记录。2. MCP 协议拆解四层模型与一次工具调用的完整生命周期2.1 MCP 的三层角色Host、Client、Server 到底谁在干活MCP 协议里最容易搞混的就是 Host 和 Client。PPT 里给了一个很直接的判断标准Host 是你正在运行的应用比如一个 IDE 插件、一个聊天界面、一个自动化脚本它负责决定“要不要用某个工具”Client 是协议层面的连接器负责维持会话、处理 JSON-RPC 消息、管理请求生命周期。Server 则是持有工具和数据的进程它不关心用户是谁只响应 Client 的请求。我一般把这三者理解成Host 是老板Client 是秘书Server 是专家。老板Host说“我要查一下今天的库存”秘书Client就把这个需求翻译成 JSON-RPC 请求发给专家Server专家执行完把结果通过秘书传回给老板。在这个链条里协议只约束秘书和专家的沟通方式老板和专家之间没有任何直接的、可复用的耦合。PPT 里还强调了一个容易漏掉的角色Agent。Agent 在 MCP 语境里不是一个独立的协议角色而是 Host、Client、Server 的组装结果。比如一个无人机巡检 Agent内部可能用 MCP Client 去调一个摄像头控制 Server、一个地图数据 Server但对外它是给操作员用自然语言下指令的入口。这个组装关系搞清楚之后再看 A2A 就顺了。2.2 一次 MCP 工具调用的完整时序从 initialize 到 tools/callMCP 基于 JSON-RPC 2.0传输层常见两种stdio标准输入输出和 streamable HTTP可流式 HTTP。PPT 里拆了一个最小工具调用的完整时序我在实际项目中验证过流程基本固定Client 发起initialize请求交换协议版本与能力。Server 回复initialize结果包含 Server 支持的 capabilities比如 tools 是否开启。Client 发送notifications/initialized通知表示完成握手。Client 调用tools/list拿到工具清单与每个工具的参数 schema。Client 根据 schema 构造参数提交tools/call。Server 执行工具返回content结果内容或isError为 true 的错误结果。整个过程里任意一方都可以发送resources/list、prompts/list来按需发现能力。下面是一段我在 Demo 里反复用的 TypeScript Server 骨架模拟一个带两个工具的 MCP Server代码里注释了每段职责// 基于官方 TypeScript SDK 的最小 MCP Server import { McpServer } from modelcontextprotocol/sdk/server/mcp.js; import { StdioServerTransport } from modelcontextprotocol/sdk/server/stdio.js; const server new McpServer({ name: inventory-server, version: 0.1.0, }); // 注册一个查询库存的工具 server.tool( inventory.query, // 工具名Client 通过这个名字调用 { sku: { type: string } }, // 入参 schemaJSON Schema 形式 async ({ sku }) { // 业务逻辑查数据库、调内部 API或者直接返回模拟数据 const stock sku.startsWith(A) ? 42 : 0; return { content: [{ type: text, text: JSON.stringify({ sku, stock }) }], }; } ); // 使用 stdio 传输进程由父进程拉起协议包走标准输入输出 const transport new StdioServerTransport(); await server.connect(transport);这段代码里server.tool是注册工具的统一入口第一个参数是工具标识第二个参数是参数 schema第三个是执行函数。Client 拿到的tools/list结果里会把这个 schema 原样透传所以 schema 写错了比如把type: integer写成type: number大模型可能会生成一个数值但 Agent 层因为类型校验太旧而拒绝。我一般建议 schema 保持最简能用string和number解决就不要嵌套对象。2.3 参数 meaning 与边界stdio 和 streamable HTTP 怎么选PPT 里专门给了一页传输方式的对比表我把它归纳成一句话单机、进程内联调选 stdio跨网络、需要鉴权和多客户端时选 streamable HTTP。stdio 的优点是启动零配置Client 直接 spawn Server 进程协议走管道。缺点是 Server 的日志不能随便往 stdout 打否则会污染协议流。很多新手第一次跑通 MCP Server 之后发现 Client 一直收不到结果十有八九是日志打到 stdout 混进了协议包。streamable HTTP 则要求 Server 开一个 HTTP 端点Client 通过 POST 发 JSON-RPC 请求。它对网络环境有要求比如中间有网关时得保证把application/json-rpc的 Content-Type 透传好还要处理 SSEServer-Sent Events回流的半长连接。做生产级应用时我更倾向于 streamable HTTP因为可以用现成的鉴权中间件也能在网关层做请求日志但做本地工具联调时stdio 的调试效率明显更高。3. A2A 协议拆解Agent Card 与任务状态机的完整语义3.1 A2A 不是在 MCP 之上堆一层而是补上“智能体间主权”PPT 里有一句话我记得很清楚MCP 假设客户端是主导者A2A 假设双方是平等的智能体。MCP 的场景里模型/Agent 是发起方Server 是被动响应者A2A 的场景里一个 Agent 可以向另一个 Agent 下发任务接收方可以自主决定是否接受、如何执行、何时汇报。这套协议的形态非常接近“带工单的 HTTP 服务”每个 Agent 发布一张 Agent Card描述自己的能力、端点、认证方式客户端 Agent 通过message/send下发任务服务端 Agent 返回一个 task 对象里面带任务状态和消息列表。之后客户端 Agent 继续通过tasks/get或订阅推送来跟踪进展。我在看 PPT 之前一直以为 A2A 和 MCP 是二选一实际做了一遍才发现最自然的组合是A2A 负责智能体之间派活MCP 负责单个智能体内部调用工具。一个智能体对外暴露 A2A 接口对内跑若干个 MCP Client 去调工具 Server两者互补不重叠。3.2 Agent Card 是入口但不是唯一入口Agent Card 本质是一个 JSON 文档放在/.well-known/agent-card.json路径。卡片的字段包含 Agent 的名字、描述、能力技能列表、端点 URL、认证方式比如 API key 或 OAuth2以及安全策略。下面是 PPT 里示例卡片的核心结构片段我在自建系统的 memory 里也沿用这套字段{ identifier: dev-agent-inventory, name: 库存查询助手, description: 查询 SKU 维度的库存数量与批次信息, url: https://agent.example.internal/inventory, skills: [ { id: inventory.query, name: 查询库存, description: 根据 SKU 返回库存余量, inputModes: [text], outputModes: [text] } ], security: { authSchemes: [ { scheme: bearer, credentialsDescription: 服务方发放的访问令牌 } ] } }identifier是全局标识客户端缓存卡片时用它做 keyskills里的id会被下游 Agent 当作任务类型引用。我在实际联调里发现很多团队把 C end 点路径写错了却没有返回到主页的链接导致对方 Agent 抓了卡片不知道往哪发任务。如果卡片配置了多个技能但url只有一个就需要在协议消息体里用kind字段进一步区分具体调哪个技能。3.3 任务状态机working / awaiting-input / completed / failed / canceledA2A 用任务状态机来解耦异步执行。PPT 里画了一张六个节点的状态图我简化成五个核心状态working、awaiting-input、completed、failed、canceled。注意input-required在较新版本里统一进了awaiting-input这是老文档和最新协议的一个明显差异点。一次标准的任务交付流程只需要两步请求第一步客户端 Agent 发message/send{ jsonrpc: 2.0, method: message/send, params: { message: { role: user, parts: [ { kind: text, text: 请查询 SKU A1001 的库存并给出可用量 } ] } } }第二步服务端 Agent 同步返回一个Task对象表示已受理{ jsonrpc: 2.0, result: { task: { id: task_8f1a2b, status: working, statusMessage: 已受理正在查询库存系统 }, history: [ { role: user, parts: [{ kind: text, text: 请查询 SKU A1001 的库存 }] } ] } }之后客户端 Agent 拿到task.id用tasks/get轮询或者用message/send作为对任务的追问。PPT 里特别点了一个容易忽略的细节history里的每条消息都有messageId字段客户端追加上下文时最好带上最后一条消息的messageId否则服务端可能把追问当作新任务。我一般在本地做 A2A 联调时会准备一个简单的只读轮询脚本逻辑不超过 20 行主要用来验证状态机是否正常推进。下面是这个脚本的核心片段import requests import time TASK_URL https://agent.example.internal/inventory/tasks def poll_task(task_id: str, timeout: int 60): 轮询任务状态直到 completed/failed/canceled 或超时 deadline time.time() timeout while time.time() deadline: resp requests.get(f{TASK_URL}/{task_id}, timeout10) payload resp.json() status payload[result][task][status] print(status, payload[result][task].get(statusMessage, )) if status in (completed, failed, canceled): return payload time.sleep(3) raise TimeoutError(ftask {task_id} 在 {timeout}s 内未终结)这段脚本的poll_task做了超时保护避免因为服务端卡在working而无限循环。参数timeout一般设 60 到 120 秒如果任务本身的执行时间波动很大建议拆成“轮询间隔”和“总超时”两个参数避免把快速失败任务拖到超时。4. MCP 与 A2A 联调的四个高频坑从黑匣子到可观测4.1 工具参数类型被大模型“自由发挥”成字符串现象MCP Server 端定义的是number但实际收到的参数是字符串比如{sku: A1001, quantity: 10}并且在服务端校验时报 400。原因大模型在生成 JSON 参数时倾向于把所有值都填成字符串尤其是当函数描述里写了“数量”这样的词但没有强调类型时。MCP Client 层如果直接用解析后的 JSON 传给 Server就会把字符串带进去。解决在 MCP Server 的工具执行函数里做一次显式类型转换而不是寄希望于模型老实。具体做法入参统一不加严格 JSON Schema 约束执行函数内部用Number()/String()做归一化并在转换失败时抛出带字段名的错误信息。从那以后我凡是在生产环境挂工具第一版一定加一层“参数清洗”不在协议边界做隐式信任。4.2 stdio 传输下 Server 进程被杀但 Client 不报错现象Client 调用tools/call后长时间没有响应也没有超时进程列表里看不到 MCP Server 进程。原因在 stdio 模式下MCP Server 是由 Client 的父进程直接 spawn 的。如果 Server 代码里抛出未捕获异常或者系统 OOM 把子进程干掉Client 只看到 stdout 流关闭但很多 SDK 默认没有把 EOF 映射为协议层错误就一直等着。解决给 Client 侧加 SS E/流式读取的空闲超时同时让 Server 进程在启动时捕获全局uncaughtException和unhandledRejection把错误写入 stderr 而不是 stdout确保日志与协议流分离。此外在部署脚本里用timeout命令或进程管理工具包一层超过 3 分钟无输出就主动重启子进程是最快保命的后悔药。4.3 A2A 任务卡在 working轮询永远拿不到终态现象任务提交后第一次查询状态是working之后无论怎么轮询都是working直到超时。原因服务端实现里只管把任务放进队列没有在业务代码跑完后把status更新为completed或failed或者更新状态时写到了内存的另一个副本上导致轮询请求读取的不是同一份状态存储。PPT 里提到的“任务状态必须与消息历史绑定持久化”常被忽略一重启服务状态就丢了。解决给每个 task 用一个独立的持久化存储本地 SQLite、Redis 均可状态更新和消息追加写到同一作用域里同时在每次轮询响应里带上timestamp客户端可以打印请求与响应的耗时来定位是网络延迟还是状态存储没有更新。我在本地联调时写过一个简单的状态检查脚本每 2 秒打印一次task.status与history长度一眼就能看出来是执行器没推进还是存储没生效。4.4 MCP 的 Client 被 A2A 的 Client 搞混身份语义串线现象一个 Agent 同时作为 MCP Client 和 A2A Client 接入系统时本地调试发现某些请求打到 MCP Server 时报错“unknown session”或者 A2A 收到的任务错误地走到了 MCP 的工具调用通道。原因MCP 的会话是 Client 与 Server 之间的短暂协议连接A2A 的任务却是跨 Agent 的长生命周期会话。代码里如果复用了同一个 HTTP 客户端实例或者把 A2A 的task.id直接当作 MCP 的sessionId两边的状态管理就会互相污染。解决架构上强制分成两个独立的连接层面向本进程工具的走 MCP Client面向外部智能体的走 A2A Client两者不要共用同一个 HTTP session 对象和 ID 生成器。规范的做法是在 Agent 内部用一个工厂函数分别创建“工具平面”和“对话平面”各自的连接状态各自管理。5. 落地第一步把 MCP Server 封装进 A2A Agent 的最小代码骨架当你决定把两种协议合起来用时最快的落地方式不是写通用框架而是先搭一个“内部跑 MCP、外部露 A2A”的最小骨架。这个骨架只需要三片东西一个 MCP Server持有技能与数据一个 Agent 层内部用 MCP Client 调工具以及一个 A2A 端点暴露 Agent Card 并处理任务下发。我在本地模拟项目里搭过一套 200 行的验证脚本MCP Server 只提供两个工具查库存、下缺货单A2A 端点负责接收“帮我看下 A 类商品库存够不够”这类任务。流程是A2A 收到任务 → Agent 拆解意图 → 内部走 MCP 调库存工具 → 汇总结果 → 更新 A2A 任务状态为 completed。这个骨架最关键的代码是把 MCP 调用结果映射成 A2A 消息片段。比如 MCP 工具返回{ stock: 42 }Agent 层要把它转成一段自然语言回复并作为 A2A 的parts回传协议本身不会替你翻译。映射逻辑建议放在 Agent 的主循环之外做一个独立函数方便单独测试。我用一个简单的选型表来总结这套联调的判断依据维度选 MCP选 A2A职责模型调用工具、读取资源智能体之间派发任务消息模型请求-响应短生命周期任务状态机长生命周期传输stdio / streamable HTTPHTTP JSON-RPC身份Client 主导双方平等凭 Agent Card 互认典型场景工具接入、RAG 取数多智能体编排、人机协同上线前我习惯做三个验证动作第一个用curl拉对方 Agent Card确认 URL 可达且skills字段不为空第二个用一个本地 mock 客户端发一条空任务服务端应当返回带task对象的响应而不是 500第三个观察日志里tools/call与task.status的先后顺序确认 MCP 执行先于 A2A 终态更新避免出现“状态完成但工具结果还没写全”的时序问题。这套骨架跑通之后再做鉴权、超时、重试、审计日志就是往里面填肉的事。我的习惯是每次联调新 Agent 之前强制走一遍“拉 Agent Card 再发任务再等 completed 再看工具”四步流程权当给自己一份可重复执行的后悔药。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进