Day 58 / 共 60 天 · 阶段9 进阶与生态

A2A:让一个 Agent 把任务委托给"远端另一个 Agent"

前面所有协作(sequential/hierarchical)都发生在同一个进程、同一个 Crew 内。但真实世界里,"财务 Agent"可能是另一个团队部署在另一台服务器上的独立服务——你只有它的 HTTP 端点。A2A(Agent-to-Agent)是一个跨系统调用另一个 Agent 的开放协议。今天读 a2a/协议的类型与版本、A2AConfig 怎么描述一个远端 Agent、wrap_agent_with_a2a_instance 怎么给普通 Agent 偷偷换上带委托能力的方法、以及委托时那个"多轮对话直到远端完成"的核心循环。这是 CrewAI 迈向分布式多智能体的关键一步。

📍 你在 60 天里的位置(阶段9 进阶与生态 · D55-58)
阶段8 LLM 集成 D55 @CrewBase D56 hooks D57 security D58 a2a 协作 阶段10 收官 D59-60
💡 先用一个类比兜住今天 A2A 就像公司之间的"外包对接"。你(本地 Agent)接到活,发现有一部分得找外部专业公司(远端 Agent)做。你不会把对方的员工搬进自己办公室——你通过合同上的对接电话(HTTP 端点)联系他们,来回沟通几轮(多轮对话),直到对方交付成果。你需要:对方的名片(AgentCard)、合同条款(A2AConfig:超时、最多几轮、是否信任对方的"已完成"声明)。CrewAI 把这套对接逻辑无缝包进你原本的 execute_task——你几乎无感,只是多配了个远端地址。
L01

痛点:怎么调用"不在我进程里"的 Agent

🤔 痛点你的 Crew 需要一个"法律审查 Agent",但它是法务团队用别的框架、部署在别的服务器上的独立服务。你不能 import 它的代码、不能塞进 agents=[...]。你只有一个 URL。怎么让你的 Agent 像调用本地同事一样,把子任务委托给这个远端 Agent,还能多轮澄清、拿回结构化结果?如果每家 Agent 服务接口都不一样,那就是 N×N 的对接地狱。需要一个标准协议。
💡 一句话本质 A2A 是一个标准化的"Agent 互调"协议(有明确的协议版本、传输方式、消息格式、AgentCard 名片)。CrewAI 的做法:① 用 A2AConfig 描述远端 Agent(端点/认证/超时/最多轮数);② wrap_agent_with_a2a_instance 把本地 Agent 的 execute_task 悄悄替换成"带委托能力"的版本;③ 当 Agent 决定委托时,进入一个多轮循环:发请求 → 收远端回复 → 若远端还要更多信息就继续、若完成就收尾。你的业务代码几乎不用改。
L02

协议的类型与版本

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——长任务不同场景各有所需。
💡 设计取舍①:为什么协议要支持三种传输 + 三种更新机制? 远端 Agent 的活可能是"1 秒返回",也可能是"跑 10 分钟的深度研究"。短任务:HTTP+JSON 同步等就行。长任务:不能一直挂着连接——要么轮询(我隔几秒问一次好没好)、要么流式(你有进展就推给我)、要么推送通知(好了给我回调)。gRPC 适合高性能内网。协议不替你决定,而是把选择权留给部署场景——代价是实现复杂(三套 handler),换来的是适配面广。这是开放协议的典型"以复杂度换通用性"。
L03

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 即可迁移。
L04

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 元类注入方法是同一思想的运行时版。
💡 为什么用"运行时包裹实例"而不是"继承一个 A2AAgent 类"?因为 A2A 是一个横切能力——你希望任意 Agent(包括 @CrewBase 里定义的、第三方的)都能临时具备委托能力,而不必改它的继承链。运行时替换实例方法,让"有没有 A2A"变成一个实例级开关(配了 a2a= 就有),而非"必须是某个子类"。这和 Day 56 钩子、Day 55 元类一脉相承:CrewAI 偏爱"组合/包裹"而非"继承"
L05

委托的核心:一个"多轮直到完成"的循环

真正的委托发生在 _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两种关键状态:远端做完了,或远端还需要更多信息(就像外包问你"这个字段填啥?")。据此决定收尾还是再来一轮。
控制流:A2A 多轮委托循环 本地 Agent 决定委托 远端 Agent HTTP 服务 请求(带 context_id/task_id/history) 回复(status + history) input_required? completed → 收尾 需更多信息 → 补充后回到"请求"再来一轮(≤ max_turns)
图注:本地与远端 Agent 通过带工单号(context_id/task_id)的多轮 HTTP 往返协作,直到 completed 或到达 max_turns。
L06

上下文与状态: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 列表。支持"基于之前那个任务继续"的复杂委托链。
数据结构:不变的 Context vs 每轮变的 State DelegationContext(不变) endpoint / agent_config max_turns agent_id / response_model 整个委托过程锁定不动 DelegationState(每轮变) current_request context_id / task_id (工单号) conversation_history 每一轮往返后被更新
图注:把"配置"和"会话状态"分成两个 NamedTuple——读代码一眼看清循环里什么恒定、什么在变。
📝 例子:一次带澄清的委托 本地 Agent 委托远端"审查这份合同"(turn 1)→ 远端回 input_required:"请提供合同适用的司法管辖区"(turn 1 结束,需更多信息)→ 本地补充"中国大陆",current_request 更新,带上同一 context_id(turn 2)→ 远端回 completed + 审查意见 → 循环退出,把结果返回给本地任务。整个过程 2 轮,未超 max_turns=10
L07

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 决定生死 网络抖动、远端宕机时,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         # 轮询/流式/推送 + 扩展
明日预告 · Day 59:进阶生态读完了。明天进入阶段10 收官:读 crewai_cli——crewai create/run/train/replay/test/deploy 这些命令怎么用 click 组织、crewai run 怎么在子进程里跑起你的 Crew、以及一个 CrewAI 项目从创建到部署的完整生命周期。
← Day 57 security 安全 Day 59 · CLI 与部署 →