A2A:让一个 Agent 把任务委托给"远端另一个 Agent"
前面所有协作(sequential/hierarchical)都发生在同一个进程、同一个 Crew 内。但真实世界里,"财务 Agent"可能是另一个团队部署在另一台服务器上的独立服务——你只有它的 HTTP 端点。A2A(Agent-to-Agent)是一个跨系统调用另一个 Agent 的开放协议。今天读 a2a/:协议的类型与版本、A2AConfig 怎么描述一个远端 Agent、wrap_agent_with_a2a_instance 怎么给普通 Agent 偷偷换上带委托能力的方法、以及委托时那个"多轮对话直到远端完成"的核心循环。这是 CrewAI 迈向分布式多智能体的关键一步。
execute_task——你几乎无感,只是多配了个远端地址。痛点:怎么调用"不在我进程里"的 Agent
agents=[...]。你只有一个 URL。怎么让你的 Agent 像调用本地同事一样,把子任务委托给这个远端 Agent,还能多轮澄清、拿回结构化结果?如果每家 Agent 服务接口都不一样,那就是 N×N 的对接地狱。需要一个标准协议。A2AConfig 描述远端 Agent(端点/认证/超时/最多轮数);② wrap_agent_with_a2a_instance 把本地 Agent 的 execute_task 悄悄替换成"带委托能力"的版本;③ 当 Agent 决定委托时,进入一个多轮循环:发请求 → 收远端回复 → 若远端还要更多信息就继续、若完成就收尾。你的业务代码几乎不用改。协议的类型与版本
A2A 的类型定义把"协议是标准"这件事钉死(a2a/types.py:37):
# a2a/types.py:37
TransportType = Literal["JSONRPC", "GRPC", "HTTP+JSON"] # 三种传输方式
ProtocolVersion = Literal["0.2.0","0.2.1",...,"0.3.0","0.4.0"] # 明确的协议版本枚举
# :60 远端 Agent 回复的协议形状
@runtime_checkable
class AgentResponseProtocol(Protocol):
a2a_ids: tuple[str, ...]
message: str
is_a2a: bool # 这次回复是不是一个"委托指令"
# :81 消息片段(文本 + 可选 JSON schema 元数据)
class PartsDict(TypedDict):
text: str
metadata: NotRequired[PartsMetadataDict]
# :99 更新机制注册表:轮询/流式/推送三选一
HANDLER_REGISTRY: dict[type[UpdateConfig], HandlerType] = {
PollingConfig: PollingHandler,
StreamingConfig: StreamingHandler,
PushNotificationConfig: PushNotificationHandler,
}
TransportType (Literal)三种传输:JSON-RPC / gRPC / HTTP+JSON。用 Literal 而非自由字符串——协议约定的东西必须是封闭集合,写错立刻类型报错。ProtocolVersion 枚举列出所有支持的协议版本。跨系统对接最怕版本漂移,显式枚举让"我们支持哪些版本"一目了然。AgentResponseProtocol远端回复的"合同形状":带 is_a2a 标志表明"本地 Agent 是不是想委托"。用 Protocol 做鸭子类型,不绑定具体类。HANDLER_REGISTRY三种"获取远端进度"的方式:轮询(Polling)、流式(Streaming)、推送通知(Push)。按配置选一个 handler——长任务不同场景各有所需。A2AConfig:一张"外包合同"
描述一个远端 Agent 的配置(a2a/config.py:370):
# a2a/config.py:370
class A2AConfig(BaseModel):
"""Deprecated: Use A2AClientConfig instead."""
model_config = ConfigDict(extra="forbid") # 多写一个字段就报错(防手滑)
endpoint: Url = Field(description="A2A agent endpoint URL") # 远端地址
auth: ClientAuthScheme | None = Field(default=None, description="Authentication scheme")
timeout: int = Field(default=120, description="Request timeout in seconds")
max_turns: int = Field(default=10, description="Maximum conversation turns with A2A agent")
response_model: type[BaseModel] | None = Field(default=None,
description="Optional Pydantic model for structured A2A agent responses")
fail_fast: bool = Field(default=True,
description="If True, raise error when agent unreachable; if False, skip")
trust_remote_completion_status: bool = Field(default=False,
description="If True, return A2A result directly when completed")
updates: UpdateConfig = Field(default_factory=_get_default_update_config)
transport: ClientTransportConfig = Field(default_factory=ClientTransportConfig)
extra="forbid"严格模式:配置里多写一个不认识的字段直接报错。跨系统对接容不得"我以为设了、其实拼错了"的静默失败。endpoint: Url核心——远端 Agent 的地址。Url 类型(types.py:52)会做 HttpUrl 校验,非法地址早早拦下。max_turns=10★最多来回几轮。跨系统多轮对话必须有上限,否则两个 Agent 可能无限互相追问、烧钱不止(见 L07)。fail_fast远端联系不上时:True=直接抛错(关键依赖);False=跳过继续(可选依赖)。让你按"这个远端是否必需"来选。trust_remote_completion_status是否信任远端说的"我完成了"。True=直接采纳其结果;False=本地再判断。信任边界是分布式系统的核心决策。A2AConfig 顶部 docstring 标了 Deprecated,新代码应用 A2AClientConfig(:465)/A2AServerConfig(:552)。这是活跃演进中的模块——读源码要留意"这是老接口还是新接口",别照着废弃 API 抄。三者字段大同小异,理解 A2AConfig 即可迁移。wrap:给普通 Agent 偷偷换上委托方法
能力是"包"上去的,不改 Agent 类本身(a2a/wrapper.py:97):
# a2a/wrapper.py:97
def wrap_agent_with_a2a_instance(agent, extension_registry=None):
if extension_registry is None:
extension_registry = ExtensionRegistry()
extension_registry.inject_all_tools(agent) # :113 注入 A2A 相关工具
original_execute_task = agent.execute_task.__func__ # 存下原方法
@wraps(original_execute_task)
def execute_task_with_a2a(self, task, context=None, tools=None) -> str:
if not self.a2a: # 没配 a2a → 走原逻辑
return original_execute_task(self, task, context, tools)
a2a_agents, agent_response_model = get_a2a_agents_and_response_model(self.a2a)
return _execute_task_with_a2a(self=self, a2a_agents=a2a_agents,
original_fn=original_execute_task, task=task, ...) # 有 a2a → 走委托逻辑
object.__setattr__(agent, "execute_task", MethodType(execute_task_with_a2a, agent)) # :166 换掉
... # kickoff / aexecute_task 同样被包裹
agent.execute_task.__func__先把 Agent 原本的 execute_task 存起来。委托能力是叠加在原能力之上,不是替换掉。if not self.a2a: 走原逻辑★关键降级:没配远端就调原方法。所以"包了 A2A"的 Agent 依然能当普通 Agent 用——零破坏。object.__setattr__Agent 是 pydantic 模型(默认限制赋值),用 object.__setattr__ 绕过限制,把实例方法动态替换成带委托的版本。MethodType(fn, agent)把普通函数绑成"这个 agent 实例的方法",让它能拿到正确的 self。这和 Day 55 元类注入方法是同一思想的运行时版。a2a= 就有),而非"必须是某个子类"。这和 Day 56 钩子、Day 55 元类一脉相承:CrewAI 偏爱"组合/包裹"而非"继承"。委托的核心:一个"多轮直到完成"的循环
真正的委托发生在 _delegate_to_a2a 的 for 循环里(a2a/wrapper.py:1231):
# a2a/wrapper.py:1261
ctx = _prepare_delegation_context(self, agent_response, task, original_task_description)
state = _init_delegation_state(ctx, agent_cards)
current_request = state.current_request
context_id = state.context_id; task_id = state.task_id
conversation_history = state.conversation_history
for turn_num in range(ctx.max_turns): # ★最多 max_turns 轮
agent_branch, accepted_output_modes = _get_turn_context(ctx.agent_config)
a2a_result = execute_a2a_delegation( # 发一次请求给远端
endpoint=ctx.agent_config.endpoint,
task_description=current_request,
context_id=context_id, task_id=task_id, # 带上会话/任务 id 维持连续性
conversation_history=conversation_history,
turn_number=turn_num + 1,
transport=ctx.agent_config.transport, ...)
conversation_history = a2a_result.get("history", []) # 更新对话历史
if conversation_history: # 记住远端分配的 id
latest = conversation_history[-1]
if latest.task_id is not None: task_id = latest.task_id
if latest.context_id is not None: context_id = latest.context_id
if a2a_result["status"] in [TaskState.completed, TaskState.input_required]:
... # 完成 或 需要更多输入 → 收尾/继续(见 L06)
for turn_num in range(max_turns)★委托的灵魂:和 Day 08 的 while 异曲同工,但这里是跨系统的多轮——每轮一个 HTTP 往返。execute_a2a_delegation(delegation.py:135) 真正发请求给远端 Agent。把当前请求、会话 id、历史一起发过去。context_id / task_id★维持"这是同一次对话"的关键。像人类客服的"工单号"——每轮都带上,远端才知道接的是上文,不是新对话。status: completed / input_required两种关键状态:远端做完了,或远端还需要更多信息(就像外包问你"这个字段填啥?")。据此决定收尾还是再来一轮。上下文与状态:DelegationContext / DelegationState
委托把"不变的配置"和"每轮变的状态"分成两个 NamedTuple(a2a/wrapper.py:61):
# a2a/wrapper.py:61
class DelegationContext(NamedTuple):
"""Context prepared for A2A delegation.(整个委托过程不变)"""
a2a_agents: list[A2AConfig | A2AClientConfig]
agent_response_model: type[BaseModel] | None
current_request: str
agent_id: str
agent_config: A2AConfig | A2AClientConfig
context_id: str | None; task_id: str | None
max_turns: int
# :81
class DelegationState(NamedTuple):
"""Mutable state for A2A delegation loop.(每轮可能变)"""
current_request: str
context_id: str | None; task_id: str | None
reference_task_ids: list[str]
conversation_history: list[Message]
agent_card: AgentCard | None
agent_name: str | None
Context vs State 分离★设计清晰:Context 装"从头到尾不变"的(端点、max_turns、Agent 配置);State 装"每轮会变"的(当前请求、对话历史、工单号)。读代码一眼就知道什么会变。NamedTuple用不可变元组而非普通 dict:字段名清晰、类型明确、且 Context 不可变(防止循环中途被误改配置)。conversation_history累积的多轮消息。每轮发给远端时带上,让远端有完整上下文——跨系统也要"记得聊过什么"。reference_task_ids引用的历史任务 id 列表。支持"基于之前那个任务继续"的复杂委托链。input_required:"请提供合同适用的司法管辖区"(turn 1 结束,需更多信息)→ 本地补充"中国大陆",current_request 更新,带上同一 context_id(turn 2)→ 远端回 completed + 审查意见 → 循环退出,把结果返回给本地任务。整个过程 2 轮,未超 max_turns=10。max_turns 刹车与"信任远端"的边界
转满 max_turns 还没完成,有专门的收尾(a2a/wrapper.py:761):
# a2a/wrapper.py:761
def _handle_max_turns_exceeded(conversation_history, max_turns, ...) -> str:
"""Handle the case when max turns is exceeded."""
if conversation_history:
for msg in reversed(conversation_history): # 从最新往回找
if msg.role == Role.agent:
text_parts = [part.root.text for part in msg.parts if part.root.kind == "text"]
final_message = " ".join(text_parts) if text_parts else "Conversation completed"
crewai_event_bus.emit(None, ...) # 发事件(Day 26 审计)
# ... 返回目前拿到的最好结果,而不是直接崩
range(max_turns) 兜底循环本身 for turn in range(max_turns),天然封顶。跨系统多轮最怕失控——硬上限是安全底线。reversed 找最后回复超轮时不是两手空空——从历史里捞出远端最后一条有意义的回复当结果。有下限地降级,别丢掉半成品。event_bus.emit超轮是异常情况,发事件让监控/审计知道"这次委托没正常完成"。可观测性(呼应 Day 54 telemetry)。completed / input_required 分流回看 L05:只有远端明确 completed 才算成功;input_required 会消耗一轮继续问。状态机清晰。trust_remote_completion_status —— 要不要信远端说的"我完成了"?
远端 Agent 说"任务完成,结果是 X"。信(True):直接采纳 X 返回,省事、快。不信(False,默认):本地 Agent 拿到 X 后自己再判断/加工,更安全但多一轮推理。这是分布式系统永恒的信任边界问题——你不能假设远端总是对的(它可能被攻击、可能理解错任务)。CrewAI 默认不盲信(False),把最终裁量权留在本地;但允许你在"远端可信、追求效率"时打开。安全默认值 + 可选放宽,和 Day 08"哪些错该抛"的哲学一致。fail_fast=True(默认)会抛错中断整个任务——适合"法律审查是必需的,没它就不能继续"。但如果这个远端只是"锦上添花"(比如可选的翻译润色),fail_fast=True 会让一个非关键依赖挂掉全局。把可选的远端设成 fail_fast=False(跳过继续),否则一个边缘服务抖动就能拖垮主任务。选错这个开关是生产事故常见根因。👶 小白:A2A 和 Day 21 的 hierarchical(manager 分派)有啥区别?都是"把活交给别人"啊。
👨🏫 老师:关键区别是边界。hierarchical 的 manager 和下属 Agent 都在同一个进程、同一个 Crew里——是"自己团队内部分工",共享内存、直接调用。A2A 是把活委托给另一台服务器上、可能用别的框架写的、你只有 URL 的独立 Agent——是"跨公司外包",走 HTTP、要处理网络失败、要考虑信任边界。一个是进程内协作,一个是跨系统协作,复杂度和关注点完全不同。
🧠 今天你应该能回答
- A2A 解决什么问题?为什么需要一个"标准协议"而不是各调各的?
- 为什么用"运行时包裹实例方法"给 Agent 加委托能力,而不是继承?
- 委托的多轮循环靠什么维持"这是同一次对话"?(context_id/task_id)
- completed 和 input_required 两个状态分别触发什么?
- DelegationContext 和 DelegationState 为什么要分开?
- trust_remote_completion_status / fail_fast 各自的取舍?
✋ 10 分钟动手
P=lib/crewai/src/crewai/a2a
sed -n '37,104p' $P/types.py # 协议类型/版本/传输/更新机制
sed -n '370,434p' $P/config.py # A2AConfig 字段(合同条款)
sed -n '97,169p' $P/wrapper.py # wrap_agent_with_a2a_instance 包裹
sed -n '1261,1320p' $P/wrapper.py # _delegate_to_a2a 多轮循环
sed -n '61,95p' $P/wrapper.py # DelegationContext / DelegationState
ls $P/updates $P/extensions # 轮询/流式/推送 + 扩展
crewai_cli——crewai create/run/train/replay/test/deploy 这些命令怎么用 click 组织、crewai run 怎么在子进程里跑起你的 Crew、以及一个 CrewAI 项目从创建到部署的完整生命周期。