A2A协议深度解析(流式返回以及多agent协同)
继续聊聊Google推出的A2A协议,也就是Agent to Agent协议,这个协议用于让多个Agent互相沟通交流,完成一项复杂的任务。
在上一篇文章里面讲述了A2A协议的基本使用场景,还通过两个Agent的同步调用梳理了协议的核心链路。这期来看看两个更进阶的内容:
• 1、A2A协议是如何处理流式返回的?
• 2、更多数量的Agent协作流程。
之前的链路都只涉及两个Agent,不够有代表性,因此把Agent的数量扩大,来看看三个甚至更多的Agent是如何协作的。
话不多说,直接开始。
鉴于可能有人不太清楚流式返回是什么,这里简单解释一下,已经了解的小伙伴可以直接跳过这一部分。
一般情况下使用HTTP访问一个网站的时候,浏览器会发送给目标服务器一个请求,比如说要访问一个HTML页面、一个图片、一个JS脚本之类的,目标服务器会返回对应的结果,一去一回,一次交互就完成了。
这个交互方式有个缺陷,它处理不了服务器连续发回多次响应的情况。比如说经常用的大模型聊天页面,模型的结果都是几个字几个字地返回,只是一去一回的话,显然无法做到这种效果。所以目前主流的大模型聊天页面用的都是流式返回。
它的特点是浏览器只需要请求一次,服务器接收到请求之后会连续发送多次响应,每次响应的内容都是几个字,而浏览器接收到几个字就显示几个字,这样用户就可以及时接收到模型的返回,出来几个字就看几个字,体验就会好很多。
等到所有的结果都返回完毕之后,服务器会发送一个完成的标识,浏览器接收到标识之后关闭连接,页面显示模型回答完毕,整个流程也就结束了。
了解了流式返回的概念之后,就来看一看A2A是如何定义流式返回协议的。这次用一个查询机票信息的场景举例,整个流程里面有三个角色,分别是用户、调度Agent和机票Agent。
、
用户询问:2025年5月1日从西雅图飞往纽约的航班中还有哪些航班有余票?用户的问题会首先发往调度Agent,调度Agent会转发给机票Agent,机票Agent返回航班信息,调度Agent接到这个信息之后稍作加工就返回给用户,这样用户也就得到了最终答案。
这个就是整个流程,可以看出这里面涉及到两个Agent,下面的任务就是去启动这两个Agent,然后跑通整个流程。
• 首先是调度Agent,这个Agent写在a2a-samples仓库里面,和平台放在一起,都是在demo/ui这个文件夹里面。在上个视频里面已经演示过启动方法了,所以这里面就不赘述了,直接就给它启动开了。可以看出它开放的接口是12000,直接打开这个地址,没问题。到这里平台和调度Agent就创建完成了。
• 下面来启动机票Agent,这个机票Agent是我自己写的,放在这个GitHub仓库下面。进入到这个GitHub仓库里面,然后进入到这个目录中,打开flight文件夹,然后再执行uv run .这个命令,就可以启动这个机票Agent了。这样两个Agent都启动完毕了。
• 下面打开Wireshark抓包软件,选择loopback,也就是说只抓本地的网络包。然后设定过滤条件为:http and tcp.dstport == 10001。为什么是10001?因为刚才开放的机票Agent的端口就是10001,所以就想抓发往机票Agent的各种网络包。
• 然后到这里Wireshark的操作也结束了,再回到平台这里,点击Agents,再点击这里,注册机票Agent,这样机票Agent就注册成功了。
• 然后新建一个对话,输入问题:2025年5月1日从西雅图飞往纽约的航班中还有哪些航班有余票?可以看出平台直接给出了答案。
有些观众可能会在这个时候产生一些疑惑,因为好像结果也没有流式返回,讲的不就是流式的场景吗,是不是搞错了?其实没有搞错,机票Agent确实是流式返回的,只不过调度Agent会等流式返回的所有结果都返回回来之后才整理出一份答案,然后再给到用户。
所以从用户的角度来看,好像这个过程并没有涉及到流式返回什么事情,但是机票Agent的返回确实是流式的。由于平台没有流式显示机票Agent的返回结果,所以才有了整个过程并没有流式进行的错觉。
不信就来看一下抓的网络包,验证一下想法。再回到Wireshark这里,可以看到平台一共是向机票Agent发送了三个请求,其中前两个是用来请求Agent Card的,这个是在注册Agent的时候发送的。这两个请求其实本质上都是一样的,随便点开一个来看一下。
这个没有格式化,我来给大家格式化一下Agent Card的结构,在上个视频里面已经聊过了,这里只重点强调其中的两个地方。
• 第一、机票Agent有个Skill可以用来查询机票信息,也就是这个地方。所以当问机票相关的问题的时候,调度Agent就可以去调用机票Agent,拿到相关的答案。
• 第二、机票Agent是支持流式返回的,对应的就是这里面的streaming,值为true,所以后面就可以看到调度Agent在请求机票Agent的时候会要求它流式返回结果。
Agent Card就看到这里,再来看发往机票Agent的问题,也就是这里面的第三个请求。这个请求是问完问题之后调度Agent发给机票Agent的,这整个界面分为两个部分,上面红色的是调度Agent的请求,下面紫色的这大片是机票Agent的返回。
可以看出机票Agent的返回有多个,每一个都包含一部分信息,这也就是流式返回了,一次返回一部分。先来一点点看,首先来看一下它的请求是什么样子的,我再来给大家格式化一下请求的结构,在上个视频里面也聊过了,这里面只重点强调其中的几个部分。
{ "id": "0a96020b-5876-4011-94d4-b8fc4743b911", "jsonrpc": "2.0", "method": "message/stream", "params": { "configuration": { "acceptedOutputModes": [ "text", "text/plain", "image/png" ] }, "message": { "contextId": "b5cdbc7f-504e-4174-a543-ba8aef6e85a9", "kind": "message", "messageId": "9a022c61-4948-4731-b0b0-2671c66b0b43", "parts": [ { "kind": "text", "text": "请帮我查询2025年5月1日从西雅图飞往纽约的航班有哪些?" } ], "role": "user" } } }• 第一、要强调的就是这里面的method,请求中的method设置的是message/stream,这个跟上个文章里面聊的是不一样的。在上个文章里面调度Agent请求天气Agent的时候使用的method是message/send,这个就是期望天气Agent一次性返回所有的结果。而这里面所使用的这个message/stream就代表调度Agent希望得到流式的返回,因为机票Agent在它的Agent Card里面写明了它是支持流式返回的,所以调度Agent就优先请求流式结果了。
• 第二、请求剩余的内容就跟之前聊的差不多了,比如说这里面就是调度Agent要问机票Agent的问题。
data: {"id":"0a96020b-5876-4011-94d4-b8fc4743b911","jsonrpc":"2.0","result":{"contextId":"b5cdbc7f-504e-4174-a543-ba8aef6e85a9","history":[{"contextId":"b5cdbc7f-504e-4174-a543-ba8aef6e85a9","kind":"message","messageId":"9a022c61-4948-4731-b0b0-2671c66b0b43","parts":[{"kind":"text","text":"请帮我查询2025年5月1日从西雅图飞往纽约的航班有哪些?"}],"role":"user","taskId":"41469774-41a2-4b33-b0fb-1f12f6bbef6e"}],"id":"41469774-41a2-4b33-b0fb-1f12f6bbef6e","kind":"task","status":{"state":"submitted"}}} data: {"id":"0a96020b-5876-4011-94d4-b8fc4743b911","jsonrpc":"2.0","result":{"append":false,"artifact":{"artifactId":"6b457be7-8c24-4ca2-879d-cd03fe3528a1","parts":[{"kind":"text","text":"你要查询的机票"}]},"contextId":"b5cdbc7f-504e-4174-a543-ba8aef6e85a9","kind":"artifact-update","lastChunk":false,"taskId":"41469774-41a2-4b33-b0fb-1f12f6bbef6e"}} data: {"id":"0a96020b-5876-4011-94d4-b8fc4743b911","jsonrpc":"2.0","result":{"append":true,"artifact":{"artifactId":"6b457be7-8c24-4ca2-879d-cd03fe3528a1","parts":[{"kind":"text","text":"如下:"}]},"contextId":"b5cdbc7f-504e-4174-a543-ba8aef6e85a9","kind":"artifact-update","lastChunk":false,"taskId":"41469774-41a2-4b33-b0fb-1f12f6bbef6e"}} data: {"id":"0a96020b-5876-4011-94d4-b8fc4743b911","jsonrpc":"2.0","result":{"append":true,"artifact":{"artifactId":"6b457be7-8c24-4ca2-879d-cd03fe3528a1","parts":[{"kind":"text","text":"1. 航班号 FAKE-001,起飞时间 20:00,余票 30 张;2. 航班号 FAKE-002,起飞时间 23:00,余票 50 张"}]},"contextId":"b5cdbc7f-504e-4174-a543-ba8aef6e85a9","kind":"artifact-update","lastChunk":true,"taskId":"41469774-41a2-4b33-b0fb-1f12f6bbef6e"}} data: {"id":"0a96020b-5876-4011-94d4-b8fc4743b911","jsonrpc":"2.0","result":{"contextId":"b5cdbc7f-504e-4174-a543-ba8aef6e85a9","final":true,"kind":"status-update","status":{"state":"completed"},"taskId":"41469774-41a2-4b33-b0fb-1f12f6bbef6e"}}
下面来看一下机票Agent的返回,首先来看一下第一个,我来给大家复制粘贴,然后再格式化一下,格式化之后是这个样子。这个结构体的大部分属性在上个文章里面都已经聊过了,这里重点看一下status这个属性,它的state值被设成了submitted,意思就是说机票Agent接收到了调度Agent的请求了,它已经在内部提交了任务。如果任务状态有更新,那么机票Agent后面还会接着返回。
没错,第一个返回要表达的意思基本上就是这样了,它还没有回答调度Agent的问题,这里只是创建了一个任务,并且把任务的状态设置为了已提交。
• 然后再看一下第二个,第二个返回开始有了些实质性的内容。首先是机票Agent产出了一段文本内容,就是“你要查询的机票”,这里面的kind设置成为了artifact-update,artifact就是这一部分的内容了,它代表机票Agent的产物。我们的产物主要是文本,kind设置成为artifact-update,意思就是说当前的这个消息的功能是提供artifact的一部分内容,后面还会接到更多kind为artifact-update的消息,把这些消息连在一起才能够拿到一个完整的artifact。lastChunk的值设为了false,代表后面还会有更多kind为artifact-update的消息。
• 第二个返回到这里其实也就差不多了,其实也就是携带了这样的一部分的文本。
• 再看第三个返回,第三个返回跟第二个返回很像,它的文本换成了“如下:”,跟第二个返回的文本连起来就是“你要查询的机票如下:”,kind是artifact-update,lastChunk的值是false,这两个跟第二个返回也是一样的,说明后面还有别的内容。
• 再看第四个返回,第四个返回又追加了一些文本,也就是这里面的内容,这些就是具体的航班信息了,这里写的航班号是FAKE-001和FAKE-002,可以看出都是假的,因为这些数据都是机票Agent造的,并不是真的。为了简化链路,机票Agent的内部并没有真的去请求航空公司的API,它返回的都是固定文案,这个大家不必在意,只需要保证A2A协议的链路是真的就可以了。
另外lastChunk变成了true,代表artifact的所有信息都已经返回了,把前面第二个、第三个和这一个的文本连在一起就可以得到完整的文本内容了。不过大家注意,lastChunk为true只是代表了artifact的所有信息都返回完毕,这并不代表整个流式都结束了,后面还有一个消息,也就是这第五个消息,还需要第五个消息来更新整个任务的状态。
下面就一起来看一下第五个消息的具体内容,同样我给大家格式化一下,可以看到第五个返回的kind所对应的值是status-update,代表当前的这个消息的功能是更新任务状态,status里面的state值为completed,代表任务的状态已完成,到这里整个返回才算真正的结束了。
所以总结一下,机票Agent一共是返回了五条消息,其中第一条和最后一条是用来更新任务状态的,第一条用于把任务状态更新为已提交,最后一条用于把任务状态更新为已完成。中间的三个消息则是各自携带了artifact的一部分信息,先提交任务,再更新artifact,最后再完成任务,这个就是A2A协议里面流式返回的一般模式了。
之前聊的都是两个Agent交互的场景,这个代表性不够高,再来看看三个Agent之间是如何沟通的。还是照例先把平台和调度Agent启动开,它们都在demo/ui这个文件夹下面,按照之前的那种启动方法启动它们就可以了。
然后再把机票Agent也给启动开,除了这两个程序之外还需要再启动一个Agent,就是天气Agent了,这个Agent在之前文章里面提到过,把它启动开。
这些都启动完毕之后再打开Wireshark,选择这里面的loopback,然后填入筛选条件,这个筛选条件的意思是要抓取HTTP请求,并且目标端口是10000或者是10001,这两个端口就分别对应了天气Agent和机票Agent。
然后来到平台这里注册这两个Agents,首先是天气Agent,它的端口是10000,然后再注册机票Agent,它的端口是10001,都注册好了之后来问一个复杂点的问题,让这三个Agent都行动起来。
我们的问题是:我计划在5月1日至3日之间从西雅图飞往纽约,想选择出发当天阳光明媚的日子,请帮我查看这三天西雅图的天气,选择天气最好的那一天,并提供该日的机票信息。
这个问题是需要先调用天气Agent查询西雅图的天气,然后再调用机票Agent查询机票相关的信息。可以看出平台给出了答案,它从这三天里面挑选了5月1日,应该是因为天气Agent告诉它这天天气很好,然后它查询了5月1日这天的机票信息,并且在这里面都显示了出来。
来到Wireshark这里面大体看一下,可以看到一共是拿到了六个请求,其中前两个请求的是天气Agent的Agent Card,这两个请求其实都一样,随便打开一个来看一下,这个就是天气Agent的相关的信息了,中间的这两个则是请求了机票Agent的Agent Card,同样它们两个也是基本类似,所以跳看一下,这个就是机票Agent的相关信息了。
然后后面两个请求则分别是发往了天气Agent和机票Agent,一个一个看一下。
• 首先看看发往天气Agent的这个请求是什么样子的,这个是给天气Agent的问题“请提供5月1日至3日的西雅图天气预报”。可以看出这个问题与原始问题是不一样的,这个问题是调度Agent整理出来用于问天气Agent的,用户问题中的其他部分,比如说与机票相关的内容其实与天气Agent并没有什么关系,所以不必包含在天气Agent的请求里面。
• 再来看天气Agent的返回,天气Agent给我们的返回如下:“你要查询的天气信息是这样子的,5月1日晴天,5月2日小雨,5月3日大雨”,这就是为什么后面调度Agent会选择5月1日作为出发日期的一个原因,因为5月1日是这三天里面唯一一天是晴天的。
这个就是天气Agent的请求和返回了,再来看一下机票Agent的请求,调度Agent发往机票Agent的问题是“请提供5月1日从西雅图飞往纽约的航班信息”,因为之前天气Agent给出了5月1日是一个晴天,所以它这里面只查询了5月1日的机票信息。
然后机票Agent就按照刚才讨论的这五个消息的内容返回了结果,这个返回结果之前讨论过了,所以我这里也不再赘述了。
• 接收到了天气Agent和机票Agent的返回之后,调度Agent就根据这两个返回整理出了一份答案发给了平台,然后平台返回给了用户,整个流程到此也就结束了。
给这里面的问答部分画个流程图,整个流程图里面一共有五个角色,分别是用户、平台、调度Agent、天气Agent和机票Agent。
• 首先用户向平台发送了一个问题,这个问题简单来说就是查天气选机票,平台把问题转发给了调度Agent,调度Agent发现需要分别调用天气Agent和机票Agent才能够解决用户的问题,因此它就先问了天气Agent一个问题“获取5月1日到5月3日这三天的天气预报”,然后它从结果中获知5月1日天气比较好,那就选5月1日出发了。
• 后面查机票的时候也只查5月1日的就好了,所以紧接着它就问了机票Agent一个问题“获取5月1日那天的机票信息”,机票Agent的返回是流式的,一共是返回了五个消息,在机票Agent的五个消息都返回完毕之后,调度Agent就根据天气Agent和机票Agent的返回整理了一份答案发给了平台,平台又发给了用户,整个流程就到此结束了。
有些小伙伴可能会问,如果Agent的数量不止三个,有十个、二十个,这怎么处理?一样的,首先必须要有一个调度Agent,它负责寻找并下发任务给其他的Agents,并在最后根据其他Agent的回复总结答案发回给用户。所以只要Agent的数量不是特别夸张,这个方案就是可用的。
