【大模型专题】别再用 HTTP 直连 Agent 了:用 Kafka 承载 A2A 协议,从 PoC 走到生产

别再用 HTTP 直连 Agent 了:用 Kafka 承载 A2A 协议,从 PoC 走到生产

目录

  • [别再用 HTTP 直连 Agent 了:用 Kafka 承载 A2A 协议,从 PoC 走到生产](#别再用 HTTP 直连 Agent 了:用 Kafka 承载 A2A 协议,从 PoC 走到生产)
    • [前言:A2A 解决了「说什么」,但没解决「怎么说给一万个 Agent 听」](#前言:A2A 解决了「说什么」,但没解决「怎么说给一万个 Agent 听」)
    • [一、先对齐概念:A2A v1.0 的三层模型](#一、先对齐概念:A2A v1.0 的三层模型)
      • [1.1 三层分离设计:数据模型 / 抽象操作 / 协议绑定](#1.1 三层分离设计:数据模型 / 抽象操作 / 协议绑定)
      • [1.2 Task 生命周期](#1.2 Task 生命周期)
      • [1.3 流式与推送的契约](#1.3 流式与推送的契约)
    • [二、为什么点对点 HTTP 撑不起企业级 Agent 协作](#二、为什么点对点 HTTP 撑不起企业级 Agent 协作)
      • [2.1 连接数是个乘法问题](#2.1 连接数是个乘法问题)
      • [2.2 点对点 HTTP 的四宗罪](#2.2 点对点 HTTP 的四宗罪)
      • [2.3 对照微服务演进史](#2.3 对照微服务演进史)
      • [2.4 代价清单:上了 Kafka 你会失去什么](#2.4 代价清单:上了 Kafka 你会失去什么)
    • 三、三种落地架构:不是配方,是选型
      • [3.1 模式 A:Kafka 作为传输层(Kafka as Transport)](#3.1 模式 A:Kafka 作为传输层(Kafka as Transport))
      • [3.2 模式 B:Kafka 做旁路扇出(Task Routing & Fan-Out)](#3.2 模式 B:Kafka 做旁路扇出(Task Routing & Fan-Out))
      • [3.3 模式 C:混合编排(Hybrid Orchestration)](#3.3 模式 C:混合编排(Hybrid Orchestration))
      • [3.4 组合矩阵与选择依据](#3.4 组合矩阵与选择依据)
    • [四、A2A over Kafka 的协议映射设计](#四、A2A over Kafka 的协议映射设计)
      • [4.1 Topic 拓扑设计](#4.1 Topic 拓扑设计)
      • [4.2 消息头设计(路由四要素)](#4.2 消息头设计(路由四要素))
      • [4.3 Agent Card 的 Kafka 扩展](#4.3 Agent Card 的 Kafka 扩展)
      • [4.4 信封协议(Envelope)](#4.4 信封协议(Envelope))
    • [五、环境准备:单机 KRaft Kafka](#五、环境准备:单机 KRaft Kafka)
      • [5.1 版本选择的背景](#5.1 版本选择的背景)
      • [5.2 docker-compose.yml](#5.2 docker-compose.yml)
      • [5.3 创建 topic](#5.3 创建 topic)
      • [5.4 验证 Share Groups 能力](#5.4 验证 Share Groups 能力)
    • [六、客户端实现:带 correlationId 的 RPC 模拟(Python)](#六、客户端实现:带 correlationId 的 RPC 模拟(Python))
      • [6.1 核心难点](#6.1 核心难点)
      • [6.2 Python 依赖](#6.2 Python 依赖)
      • [6.3 CorrelationManager:RPC 与流式的双通道管理](#6.3 CorrelationManager:RPC 与流式的双通道管理)
      • [6.4 KafkaClientTransport:传输实现](#6.4 KafkaClientTransport:传输实现)
      • [6.5 使用示例](#6.5 使用示例)
    • [七、服务端实现:KafkaHandler 协议适配器(Python)](#七、服务端实现:KafkaHandler 协议适配器(Python))
      • [7.1 handle_request 五步](#7.1 handle_request 五步)
      • [7.2 完整实现](#7.2 完整实现)
      • [7.3 统一异常兜底为什么是必须的](#7.3 统一异常兜底为什么是必须的)
      • [7.4 PoC 与生产的差距清单](#7.4 PoC 与生产的差距清单)
    • [八、流式与推送:把 SSE 换成 topic](#八、流式与推送:把 SSE 换成 topic)
      • [8.1 本质差异](#8.1 本质差异)
      • [8.2 流式的客户端实现](#8.2 流式的客户端实现)
      • [8.3 推送通知(Push Notification)的 Kafka 化](#8.3 推送通知(Push Notification)的 Kafka 化)
      • [8.4 三种下发方式对比](#8.4 三种下发方式对比)
    • [九、可靠性工程:把 A2A 的 at-least-once 兜住](#九、可靠性工程:把 A2A 的 at-least-once 兜住)
      • [9.1 幂等生产](#9.1 幂等生产)
      • [9.2 Outbox 模式](#9.2 Outbox 模式)
      • [9.3 消费者幂等](#9.3 消费者幂等)
      • [9.4 DLQ 与重试](#9.4 DLQ 与重试)
      • [9.5 Share Groups 做任务队列](#9.5 Share Groups 做任务队列)
    • 十、可观测性与治理
      • [10.1 OpenTelemetry 上下文透传](#10.1 OpenTelemetry 上下文透传)
      • [10.2 Schema Registry 把 A2A 消息体当契约管理](#10.2 Schema Registry 把 A2A 消息体当契约管理)
      • [10.3 可观测性三支柱在 A2A over Kafka 下的落点](#10.3 可观测性三支柱在 A2A over Kafka 下的落点)
      • [10.4 治理层面的两个硬问题](#10.4 治理层面的两个硬问题)
    • 十一、踩坑记录
      • [坑 1:reply topic 没提前创建,消息静默丢失](#坑 1:reply topic 没提前创建,消息静默丢失)
      • [坑 2:correlationId 复用导致响应串台](#坑 2:correlationId 复用导致响应串台)
      • [坑 3:用随机 key 导致 task 状态乱序](#坑 3:用随机 key 导致 task 状态乱序)
      • [坑 4:`auto.offset.reset` 用默认值导致重启后漏消息](#坑 4:auto.offset.reset 用默认值导致重启后漏消息)
      • [坑 5:未来得及发送的 Future 造成内存泄漏](#坑 5:未来得及发送的 Future 造成内存泄漏)
      • [坑 6:把 Share Consumer 用在有状态会话上导致状态错乱](#坑 6:把 Share Consumer 用在有状态会话上导致状态错乱)
      • [坑 7:消息体超 `max.request.size` 导致大 artifact 发送失败](#坑 7:消息体超 max.request.size 导致大 artifact 发送失败)
      • [坑 8:DLQ 里没有原始 header 导致无法重放](#坑 8:DLQ 里没有原始 header 导致无法重放)
    • 十二、总结与展望
      • [12.1 一句话总结](#12.1 一句话总结)
      • [12.2 三个需要记住的判断](#12.2 三个需要记住的判断)
      • [12.3 生态展望:接下来 12 个月会发生什么](#12.3 生态展望:接下来 12 个月会发生什么)
      • [12.4 给读者的行动建议(分三档)](#12.4 给读者的行动建议(分三档))
    • 参考资料

前言:A2A 解决了「说什么」,但没解决「怎么说给一万个 Agent 听」

2025 年 4 月,Google 发布了 Agent2Agent(A2A)协议,试图给「一个 Agent 怎么调用另一个 Agent」这件事定义一个公共语言。2025 年 6 月,A2A 被捐给 Linux Foundation 托管,摆脱了单一厂商的标签。到 2026 年初,A2A v1.0 正式发布,生态侧已经积累了 150 多个采纳组织、5 个官方 SDK(Python / Go / JavaScript / Java / .NET),GitHub 上相关仓库累计 22k+ stars。

如果你只看官方文档和 Demo,会觉得问题已经解决了:定义好 Agent Card,双方用 HTTP + JSON-RPC 互相调用,需要流式就上 SSE,需要异步通知就配 webhook。这套东西跑通两个 Agent 的 Demo 大概只要半小时。

但真正在企业内部署过 Agent 协作平台的人会知道,问题恰恰出在这一步之后。

A2A 的默认协议绑定是 HTTP + JSON-RPC + SSE,这个设计的本质是**点对点(point-to-point)**的。它非常优雅地描述了「消息长什么样」,却几乎完全没有回答「消息怎么送到一万个 Agent 那里」。当你的 Agent 数量从 2 个变成 20 个、200 个,当这些 Agent 由不同团队、不同部门甚至不同公司维护,当合规部门要求你提供「三个月前那条任务到底谁发了什么、谁回了什么」的完整链路时,点对点 HTTP 的每一处优点都会变成负担。

这篇文章的观点前置:Kafka 不是要替代 A2A,而是给 A2A 换一条能横向扩展的传输底座。 A2A 定义语义,Kafka 负责送达。两者是分层关系,不是竞争关系。

这篇文章不写「Kafka 是什么」这种基础内容,默认你有 Kafka 使用经验(至少写过 producer / consumer)。我们要解决的是一个具体工程问题:如何把 A2A v1.0 的抽象操作,忠实且不丢语义地映射到 Kafka topic 上,并且让它在生产环境里活下来。

下面会包含 5 张 Mermaid 图、29 张对比表和决策表、41 个可复制运行的代码块,其中 Python 代码基于 3.10+ 语法与 aiokafka,Java 代码基于 Kafka 官方 client。


一、先对齐概念:A2A v1.0 的三层模型

这一节要解决的问题是:A2A 规范里到底哪一层是可替换的? 如果搞不清楚这一点,后面的所有 Kafka 化改造都是瞎改。

1.1 三层分离设计:数据模型 / 抽象操作 / 协议绑定

A2A v1.0 规范最值得称赞的一个设计决策,是它把协议明确切成了三层:

Layer 1:规范数据模型(Canonical Data Model)

这一层定义的是「Agent 之间传递的实体长什么样」,核心类型包括:

类型 语义 关键字段
AgentCard 能力声明,类似服务注册 name、description、version、supportedInterfaces、capabilities、skills
Task 一次有状态的工作单元 id、contextId、status、artifacts、history、metadata
Message 一次对话消息(不承载状态机) messageId、role、parts、contextId、taskId
Part 消息内容的最小单元 text / file / data 三种之一
Artifact 任务产出物 artifactId、name、parts、metadata
Extension 厂商或社区扩展声明 uri、description、required

这一层用 Protocol Buffers(协议缓冲区) 定义,官方明确说明 proto 是唯一的权威定义(source of truth),JSON Schema 与其他语言绑定都是从 proto 生成的。这个细节很重要:它意味着 A2A 的类型系统是强类型的、可演进的,而不是「随便一个 JSON 就行」。

Layer 2:抽象操作(Abstract Operations)

这一层定义的是「Agent 之间能做什么」,是一组与传输无关的语义动作。v1.0 定义了 11 个操作:

操作名 语义 是否流式
SendMessage 发送消息,阻塞等待最终结果
SendStreamingMessage 发送消息,以流方式接收增量事件
GetTask 按 taskId 查询任务状态
ListTasks 按条件列出任务(v1.0 新增)
CancelTask 取消一个进行中的任务
SubscribeToTask 订阅已有任务的后续事件流
CreateTaskPushNotificationConfig 注册 webhook 推送配置
GetTaskPushNotificationConfig 查询推送配置
ListTaskPushNotificationConfigs 列出推送配置(v1.0 新增)
DeleteTaskPushNotificationConfig 删除推送配置
GetExtendedAgentCard 获取带鉴权信息的扩展 Agent Card

注意 ListTasksListTaskPushNotificationConfigsGetExtendedAgentCard 是 v1.0 才补齐的。前两个的出现说明 A2A 开始从「点对点调用」朝着「可管理的任务平台」演进------这是个很重要的信号,因为任务可列举意味着任务需要持久化存储和索引,而这正是事件流平台擅长的。

Layer 3:协议绑定(Protocol Bindings)

这一层定义「抽象操作具体怎么在网络上跑」。v1.0 官方提供三个绑定:JSONRPCGRPCHTTP+JSON。声明方式是在 AgentCardsupportedInterfaces[] 数组里,每一项包含:

json 复制代码
{
  "supportedInterfaces": [
    {
      "url": "https://agent.example.com/a2a/v1",
      "protocolBinding": "JSONRPC",
      "protocolVersion": "1.0",
      "tenant": "acme-corp"
    },
    {
      "url": "grpc://agent.example.com:443",
      "protocolBinding": "GRPC",
      "protocolVersion": "1.0",
      "tenant": "acme-corp"
    }
  ]
}

supportedInterfaces 是个数组这一点是本节的关键洞察 :规范允许一个 Agent 同时声明多个绑定。也就是说,绑定层在设计上就是可替换、可插拔的 。这直接构成了本文的立论基础------Kafka 完全可以作为第四个自定义绑定(protocolBinding = "KAFKA")插进来,而不需要修改 A2A 的数据模型或抽象操作。

换句话说,把 A2A 搬到 Kafka 上,不是 hack,而是规范明确预留的扩展点。当然,「预留了扩展点」和「社区已经有成熟实现」是两回事,这一点会在第四节和第七节反复强调。
#mermaid-svg-rB5PN2NNybD0enkV{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-rB5PN2NNybD0enkV .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-rB5PN2NNybD0enkV .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-rB5PN2NNybD0enkV .error-icon{fill:#552222;}#mermaid-svg-rB5PN2NNybD0enkV .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-rB5PN2NNybD0enkV .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-rB5PN2NNybD0enkV .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-rB5PN2NNybD0enkV .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-rB5PN2NNybD0enkV .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-rB5PN2NNybD0enkV .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-rB5PN2NNybD0enkV .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-rB5PN2NNybD0enkV .marker{fill:#333333;stroke:#333333;}#mermaid-svg-rB5PN2NNybD0enkV .marker.cross{stroke:#333333;}#mermaid-svg-rB5PN2NNybD0enkV svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-rB5PN2NNybD0enkV p{margin:0;}#mermaid-svg-rB5PN2NNybD0enkV .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-rB5PN2NNybD0enkV .cluster-label text{fill:#333;}#mermaid-svg-rB5PN2NNybD0enkV .cluster-label span{color:#333;}#mermaid-svg-rB5PN2NNybD0enkV .cluster-label span p{background-color:transparent;}#mermaid-svg-rB5PN2NNybD0enkV .label text,#mermaid-svg-rB5PN2NNybD0enkV span{fill:#333;color:#333;}#mermaid-svg-rB5PN2NNybD0enkV .node rect,#mermaid-svg-rB5PN2NNybD0enkV .node circle,#mermaid-svg-rB5PN2NNybD0enkV .node ellipse,#mermaid-svg-rB5PN2NNybD0enkV .node polygon,#mermaid-svg-rB5PN2NNybD0enkV .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-rB5PN2NNybD0enkV .rough-node .label text,#mermaid-svg-rB5PN2NNybD0enkV .node .label text,#mermaid-svg-rB5PN2NNybD0enkV .image-shape .label,#mermaid-svg-rB5PN2NNybD0enkV .icon-shape .label{text-anchor:middle;}#mermaid-svg-rB5PN2NNybD0enkV .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-rB5PN2NNybD0enkV .rough-node .label,#mermaid-svg-rB5PN2NNybD0enkV .node .label,#mermaid-svg-rB5PN2NNybD0enkV .image-shape .label,#mermaid-svg-rB5PN2NNybD0enkV .icon-shape .label{text-align:center;}#mermaid-svg-rB5PN2NNybD0enkV .node.clickable{cursor:pointer;}#mermaid-svg-rB5PN2NNybD0enkV .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-rB5PN2NNybD0enkV .arrowheadPath{fill:#333333;}#mermaid-svg-rB5PN2NNybD0enkV .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-rB5PN2NNybD0enkV .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-rB5PN2NNybD0enkV .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rB5PN2NNybD0enkV .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-rB5PN2NNybD0enkV .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rB5PN2NNybD0enkV .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-rB5PN2NNybD0enkV .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-rB5PN2NNybD0enkV .cluster text{fill:#333;}#mermaid-svg-rB5PN2NNybD0enkV .cluster span{color:#333;}#mermaid-svg-rB5PN2NNybD0enkV div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-rB5PN2NNybD0enkV .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-rB5PN2NNybD0enkV rect.text{fill:none;stroke-width:0;}#mermaid-svg-rB5PN2NNybD0enkV .icon-shape,#mermaid-svg-rB5PN2NNybD0enkV .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rB5PN2NNybD0enkV .icon-shape p,#mermaid-svg-rB5PN2NNybD0enkV .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-rB5PN2NNybD0enkV .icon-shape .label rect,#mermaid-svg-rB5PN2NNybD0enkV .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rB5PN2NNybD0enkV .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-rB5PN2NNybD0enkV .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-rB5PN2NNybD0enkV :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Layer 3 协议绑定 Protocol Bindings
可替换
可替换
JSONRPC 绑定
KAFKA 自定义绑定 本文主题
GRPC 绑定
HTTP加JSON 绑定
Layer 2 抽象操作 Abstract Operations
SendMessage
SendStreamingMessage
GetTask 与 ListTasks
CancelTask
SubscribeToTask
PushNotificationConfig 系列
Layer 1 规范数据模型 Canonical Data Model
AgentCard 能力声明
Task 任务状态机
Message 消息
Part 内容单元
Artifact 产出物
Extension 扩展

1.2 Task 生命周期

如果说 Layer 1 里有一个对象决定了整套系统的复杂度,那就是 Task。因为它是有状态的,而状态机意味着顺序、幂等和恢复------这三样东西恰好是分布式系统里最难的部分。

A2A v1.0 定义了八个任务状态:

状态 分类 语义 后续动作
TASK_STATE_SUBMITTED 进行中 已受理,尚未开始执行 等待转 WORKING
TASK_STATE_WORKING 进行中 正在执行 可发消息追加输入,可取消
TASK_STATE_INPUT_REQUIRED 中断态 需补充输入才能继续 客户端必须发消息补充
TASK_STATE_AUTH_REQUIRED 中断态 需完成鉴权才能继续 客户端需重新授权
TASK_STATE_COMPLETED 终态 正常完成 只读,不可再交互
TASK_STATE_FAILED 终态 执行失败 只读,可重试新任务
TASK_STATE_CANCELED 终态 被取消 只读
TASK_STATE_REJECTED 终态 服务端拒绝受理 只读

分类很重要,因为它决定了三条硬性规则:

  1. 向终态任务发消息必须返回 UnsupportedOperationError 这不是建议,是必须。否则客户端会以为自己还能继续对话,产生「僵尸任务」。
  2. SubscribeToTask 订阅到终态任务时,必须立即终止流。 不能挂起等待,因为永远不会有新事件了。
  3. 中断态不是终态。 它意味着任务暂停在等外部输入,服务端需要保留下载上下文(context),并在超时后自行决定是 FAILED 还是继续等待。这是一个规范故意留白的地方,各实现的策略不同。

另外还有两种调用模式必须区分:

  • 阻塞模式SendMessagereturnImmediately = false(默认值)。服务端一直等到任务进入终态或中断态才返回。
  • 非阻塞模式returnImmediately = true。立即返回当前 Task 快照(通常是 SUBMITTED),客户端后续靠 SubscribeToTaskGetTask 跟进。

这个区分在 Kafka 化改造时非常关键:阻塞模式在事件流上是反模式的,因为它要求消费者线程被占住。第七节会给出具体处理方式。
#mermaid-svg-2SvTmFK7fEll3qMl{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-2SvTmFK7fEll3qMl .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-2SvTmFK7fEll3qMl .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-2SvTmFK7fEll3qMl .error-icon{fill:#552222;}#mermaid-svg-2SvTmFK7fEll3qMl .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-2SvTmFK7fEll3qMl .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-2SvTmFK7fEll3qMl .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-2SvTmFK7fEll3qMl .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-2SvTmFK7fEll3qMl .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-2SvTmFK7fEll3qMl .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-2SvTmFK7fEll3qMl .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-2SvTmFK7fEll3qMl .marker{fill:#333333;stroke:#333333;}#mermaid-svg-2SvTmFK7fEll3qMl .marker.cross{stroke:#333333;}#mermaid-svg-2SvTmFK7fEll3qMl svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-2SvTmFK7fEll3qMl p{margin:0;}#mermaid-svg-2SvTmFK7fEll3qMl defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-2SvTmFK7fEll3qMl g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-2SvTmFK7fEll3qMl g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-2SvTmFK7fEll3qMl g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-2SvTmFK7fEll3qMl g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-2SvTmFK7fEll3qMl g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-2SvTmFK7fEll3qMl .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-2SvTmFK7fEll3qMl .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-2SvTmFK7fEll3qMl .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-2SvTmFK7fEll3qMl .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-2SvTmFK7fEll3qMl .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-2SvTmFK7fEll3qMl .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-2SvTmFK7fEll3qMl .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-2SvTmFK7fEll3qMl .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-2SvTmFK7fEll3qMl .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-2SvTmFK7fEll3qMl .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-2SvTmFK7fEll3qMl .edgeLabel .label text{fill:#333;}#mermaid-svg-2SvTmFK7fEll3qMl .label div .edgeLabel{color:#333;}#mermaid-svg-2SvTmFK7fEll3qMl .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-2SvTmFK7fEll3qMl .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-2SvTmFK7fEll3qMl .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-2SvTmFK7fEll3qMl .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-2SvTmFK7fEll3qMl .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-2SvTmFK7fEll3qMl .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-2SvTmFK7fEll3qMl .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-2SvTmFK7fEll3qMl #statediagram-barbEnd{fill:#333333;}#mermaid-svg-2SvTmFK7fEll3qMl .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-2SvTmFK7fEll3qMl .cluster-label,#mermaid-svg-2SvTmFK7fEll3qMl .nodeLabel{color:#131300;}#mermaid-svg-2SvTmFK7fEll3qMl .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-2SvTmFK7fEll3qMl .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-2SvTmFK7fEll3qMl .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-2SvTmFK7fEll3qMl .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-2SvTmFK7fEll3qMl .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-2SvTmFK7fEll3qMl .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-2SvTmFK7fEll3qMl .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-2SvTmFK7fEll3qMl .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-2SvTmFK7fEll3qMl .note-edge{stroke-dasharray:5;}#mermaid-svg-2SvTmFK7fEll3qMl .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-2SvTmFK7fEll3qMl .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-2SvTmFK7fEll3qMl .statediagram-note text{fill:black;}#mermaid-svg-2SvTmFK7fEll3qMl .statediagram-note .nodeLabel{color:black;}#mermaid-svg-2SvTmFK7fEll3qMl .statediagram .edgeLabel{color:red;}#mermaid-svg-2SvTmFK7fEll3qMl #dependencyStart,#mermaid-svg-2SvTmFK7fEll3qMl #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-2SvTmFK7fEll3qMl .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-2SvTmFK7fEll3qMl :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} SendMessage
开始执行
需要用户补充输入
收到补充消息
需要重新鉴权
鉴权完成
正常结束
执行异常
收到 CancelTask
服务端拒绝
收到 CancelTask
SUBMITTED
WORKING
INPUT_REQUIRED
AUTH_REQUIRED
COMPLETED
FAILED
CANCELED
REJECTED
终态 只读

再发消息返回

UnsupportedOperationError
中断态而非终态

必须保留上下文

等待客户端补输入

1.3 流式与推送的契约

这一节的内容是后面 Kafka 化改造的映射目标。你要改造什么,取决于原契约承诺了什么。

流式响应(Streaming)

SendStreamingMessageSubscribeToTask 返回的是 StreamResponse,它的定义是「恰好四选一」:

字段 类型 语义
task Task 任务快照(通常在流开始时发一次)
message Message 一条完整消息(非增量)
statusUpdate TaskStatusUpdateEvent 状态变更事件
artifactUpdate TaskArtifactUpdateEvent 产出物增量事件

注意是「恰好四选一」(oneof),不是「可以随便组合」。这个约束在 Kafka 上映射时会变成一个具体的序列化问题:Kafka 一条消息只能承载一个事件,所以一条 StreamResponse 天然对应一条 Kafka 消息,不需要合并。

两个事件类型的字段定义:

事件 字段 说明
TaskStatusUpdateEvent taskId 任务 ID,必填
contextId 会话 ID,必填
status 当前 TaskStatus(含 state 与 message)
metadata 任意附加信息
TaskArtifactUpdateEvent taskId 任务 ID,必填
contextId 会话 ID,必填
artifact 产出物内容
append 是否追加到已有 artifact
lastChunk 是否为本 artifact 最后一块
metadata 任意附加信息

这里有一条必须严格遵守的顺序规则 :事件必须按生成顺序投递,不得重排序。因为 artifactUpdate 是靠 append 语义拼装的,一旦乱序,拼出来的文档就是错的。这条规则直接决定了第四节的 Kafka 分区键设计------你不可能用一个随机 key 还指望顺序正确。

另一条规则是:同一个任务可以被多个并发流订阅,事件必须广播到所有活跃流。 这在 HTTP/SSE 上意味着服务端要维护「连接 → 流」的映射表;在 Kafka 上则天然成立,因为多个消费者组读同一个 topic 本来就各自拿到完整副本。这是 Kafka 在这个场景下的一个真实优势。

推送通知(Push Notification)

当客户端不适合保持长连接(比如移动端、或者任务耗时几小时)时,A2A 提供了 Push Notification 机制:

  • 服务端向客户端预先注册的 webhook URL 发 POST
  • Content-Type 必须是 application/a2a+json
  • payload 复用 StreamResponse 格式(所以解析逻辑可以复用)
  • 语义是至少一次投递(at-least-once) ,客户端必须返回 2xx ,并且应该实现幂等
  • 规范建议的超时时间是 10--30 秒

「至少一次 + 必须幂等」这个组合,是 A2A 规范里少有的直接承认分布式现实的地方。它意味着客户端必须自己处理重复通知。这一点在后面做 Kafka 化时会变得更突出,因为 Kafka 本身也是 at-least-once 语义,两层 at-least-once 叠加,重复概率是相乘而不是相加的。
Webhook 端点 服务端 Agent 客户端 Agent Webhook 端点 服务端 Agent 客户端 Agent #mermaid-svg-u9pMwusoaEasa7ou{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-u9pMwusoaEasa7ou .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-u9pMwusoaEasa7ou .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-u9pMwusoaEasa7ou .error-icon{fill:#552222;}#mermaid-svg-u9pMwusoaEasa7ou .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-u9pMwusoaEasa7ou .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-u9pMwusoaEasa7ou .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-u9pMwusoaEasa7ou .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-u9pMwusoaEasa7ou .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-u9pMwusoaEasa7ou .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-u9pMwusoaEasa7ou .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-u9pMwusoaEasa7ou .marker{fill:#333333;stroke:#333333;}#mermaid-svg-u9pMwusoaEasa7ou .marker.cross{stroke:#333333;}#mermaid-svg-u9pMwusoaEasa7ou svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-u9pMwusoaEasa7ou p{margin:0;}#mermaid-svg-u9pMwusoaEasa7ou .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-u9pMwusoaEasa7ou text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-u9pMwusoaEasa7ou .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-u9pMwusoaEasa7ou .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-u9pMwusoaEasa7ou .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-u9pMwusoaEasa7ou .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-u9pMwusoaEasa7ou #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-u9pMwusoaEasa7ou .sequenceNumber{fill:white;}#mermaid-svg-u9pMwusoaEasa7ou #sequencenumber{fill:#333;}#mermaid-svg-u9pMwusoaEasa7ou #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-u9pMwusoaEasa7ou .messageText{fill:#333;stroke:none;}#mermaid-svg-u9pMwusoaEasa7ou .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-u9pMwusoaEasa7ou .labelText,#mermaid-svg-u9pMwusoaEasa7ou .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-u9pMwusoaEasa7ou .loopText,#mermaid-svg-u9pMwusoaEasa7ou .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-u9pMwusoaEasa7ou .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-u9pMwusoaEasa7ou .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-u9pMwusoaEasa7ou .noteText,#mermaid-svg-u9pMwusoaEasa7ou .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-u9pMwusoaEasa7ou .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-u9pMwusoaEasa7ou .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-u9pMwusoaEasa7ou .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-u9pMwusoaEasa7ou .actorPopupMenu{position:absolute;}#mermaid-svg-u9pMwusoaEasa7ou .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-u9pMwusoaEasa7ou .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-u9pMwusoaEasa7ou .actor-man circle,#mermaid-svg-u9pMwusoaEasa7ou line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-u9pMwusoaEasa7ou :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 若非流式场景 改用推送 SendStreamingMessage 返回流 StreamResponse 携带 Task 快照 TaskStatusUpdateEvent 进入 WORKING TaskArtifactUpdateEvent append true TaskArtifactUpdateEvent lastChunk true TaskStatusUpdateEvent 进入 COMPLETED 流正常结束 POST application 斜杠 a2a 加 json 200 OK 幂等处理重复投递

HTTP/SSE 语义到 Kafka 语义的映射表

下面这张表是全文的核心表之一。后面的所有实现,本质上都是在填这张表的空格。

HTTP/SSE 语义 Kafka 对应机制 差异与代价
一次 HTTP 请求 一条发往 request topic 的消息 无连接概念,必须自带 correlationId
HTTP 响应 一条发往 reply topic 的消息 响应路由靠 header 而非连接
SSE 长连接 多条消息靠 correlationId 聚合 流生命周期必须应用层显式表达
SSE 流关闭 一条 end_of_stream 控制信封 必须自己定义,否则客户端无法退出循环
webhook URL 客户端私有 reply topic 注册动作等价于创建 topic 或登记订阅
连接超时 asyncio.wait_for + Future 必须手动清理 pending 状态防泄漏
重试(客户端发起) Kafka producer retries 配合幂等生产避免重复
服务端 5xx 错误 错误信封或 DLQ 错误语义要重新设计
背压(TCP 窗口) consumer poll 节奏 / max.poll.records 语义更弱,需要业务层兜底
请求级隔离 消费者组 + 分区分配 跨 Agent 隔离要额外设计

二、为什么点对点 HTTP 撑不起企业级 Agent 协作

这一节要回答的问题是:「A2A 已经有 HTTP 绑定了,为什么还要多此一举加 Kafka?」 如果你的场景不需要,这一节也应该让你明确知道「我不需要」。

2.1 连接数是个乘法问题

先看一个纯粹的数量级问题。假设你有 N 个 Agent,每个 Agent 都可能需要调用其他任一 Agent:

  • 点对点模式:需要维护的调用关系是 N×(N−1) 条。5 个 Agent 是 20 条,20 个 Agent 是 380 条,100 个 Agent 是 9900 条。
  • 引入 broker 后:每个 Agent 只需要连接 broker,链路数是 N + M(M 为 broker/分区数),100 个 Agent 加上 6 个 broker 就是 106 条。

这不是「多走一跳」那么简单,而是运维复杂度的维度差异。在点对点模型下,你要为每一条 A→B 的关系单独考虑:超时配置、重试策略、TLS 证书、访问控制、限流。100 个 Agent 意味着 9900 份配置。而在 broker 模型下,这些是集中配置的。

当然,现实中大多数企业并不会让 Agent 两两互调,通常是「少量编排者 + 大量执行者」的星型结构。但即使是星型结构,20 个执行者对应 20 条入向链路,编排者仍然是 20 个连接的管理者,而每个执行者都必须暴露可被访问的 HTTP 端点------这在跨网络的容器环境里意味着 20 套 Ingress、20 套证书、20 套鉴权。

2.2 点对点 HTTP 的四宗罪

第一宗:连接爆炸与端点爆炸

如上所述,不只是连接数,更是「必须可被访问」的端点数量。每个 Agent 都要是一个可路由的 HTTP 服务。这意味着每个 Agent 都要有 DNS、TLS 证书、健康检查、Ingress 规则。而 Kafka 模式下,Agent 只需要是一个客户端------它主动连出去,不需要被连。

这个差异在安全和网络策略上的影响是巨大的:Agent 变成 outbound-only 的组件,防火墙只需要开一个方向。

第二宗:强耦合------对方下线就断

点对点调用天然是同步的(除非你自己实现异步回调,而那实际上就是在重新发明 broker)。服务端 Agent 一旦下线、升级、扩缩容,调用方就在报错。

有人会说「加个重试就好了」。但重试解决的是偶发故障,解决不了计划内的下线。服务端要发版,重启 30 秒,这 30 秒内的所有任务谁来接?重试 3 次、每次超时 30 秒,用户等了 90 秒然后报错。在 Kafka 上,消息就在 topic 里,服务端恢复后自然继续消费,客户端完全无感知。

第三宗:可观测性为零

这是最容易被低估、但在企业里最致命的一条。A→B 的 HTTP 调用发生在两个 Agent 之间,第三方完全看不到。你想做「所有 Agent 交互的统一审计」,只能要求每个 Agent 自己上报日志,质量参差不齐。你想做「实时大盘看当前有多少任务在跑」,没有数据源。你想做「回放三个月前那次错误的完整链路」,得从几十个日志系统里拼。

而在 Kafka 上,所有消息天然经过一个中心点。topic 本身就是一份不可篡改的日志。审计、回放、监控、数仓接入,全部变成「读这个 topic」这一件事。

第四宗:难以编排

当流程变成「A 完成后触发 B,B 和 C 并行,都完成后触发 D」时,点对点 HTTP 的编排逻辑会以「回调地狱」的形式分散在各个 Agent 内部。每个 Agent 都要知道「我之后该调谁」,这是典型的编排逻辑泄漏到执行单元。

事件流模式下,编排逻辑集中在编排器(或直接写在 topic 拓扑 + Streams 拓扑里),执行 Agent 只需要「订阅自己关心的输入、产出自己的输出」,对上下游一无所知。

2.3 对照微服务演进史

这段历史值得认真看一眼,因为 Agent 生态正在精确地重走这条路

阶段 微服务 Agent 生态(对应阶段)
阶段一 单体应用,进程内方法调用 单个大模型 + 一堆工具函数(function calling)
阶段二 同步 REST/gRPC 微服务,点对点调用 A2A over HTTP,Agent 互相调用
阶段三 事件驱动微服务,Kafka 作为骨干 A2A over Kafka(本文主题)

微服务在 2015---2018 年普遍走到阶段二,然后在 2018---2021 年大规模迁往阶段三,原因和今天 Agent 面临的一模一样:同步点对点调用在服务数量增长后必然崩溃。这不是理论推演,是已经被验证过的行业经验。

所以对 Agent 生态的预测是相当确定的:阶段二到阶段三是必然的,问题只是什么时候、以及用哪个中间件。 Kafka 不是唯一答案(NATS、Pulsar、RabbitMQ、云厂商的消息队列都是候选),但它目前在企业级市场的生态成熟度最高,这一点很难被短期改变。

2.4 代价清单:上了 Kafka 你会失去什么

写到这里如果不提代价,就是不负责任的布道文。上 Kafka 的代价是实打实的:

  • 多一跳延迟。 点对点 HTTP 的端到端 P99 可能是 15ms,走 Kafka 后会变成 30--60ms(取决于 linger.ms、分区数、副本数、网络跳数)。对于低延迟交互场景(比如实时语音 Agent),这是不可接受的。如果你的场景要求 P99 低于 50ms,不要上 Kafka。
  • 多一套集群要运维。 Kafka 集群、Schema Registry、监控、容量规划、版本升级(KRaft 迁移、Share Groups 兼容性)。这是持续的团队成本,不是一次性投入。
  • 调试范式变了。 点对点调用出问题,你可以把请求重放一遍看到完整错误。事件流出问题,你要面对的是「消息进去了,但输出不对」,需要在多个 topic 之间追踪 correlationId,还原事件序列。排查难度明显上升。
  • 最终一致性取代强一致。 你无法再写出「调用返回时结果已确定」的代码。所有逻辑都要写成「事件到达时处理」。这个心智模型的转换对很多团队是真正的门槛,比技术门槛更高。
  • 顺序保证是有条件的。 Kafka 只保证单分区有序。一旦你的 key 设计不好,或者消费者数超过分区数,顺序就没了。而 Task 状态机对顺序是敏感的。

正因为这些代价,第三节要讲的不是「怎么上 Kafka」,而是「什么时候不该上 Kafka」

维度 HTTP 点对点 Kafka 事件总线
同步性 天然同步,请求-响应 天然异步,需自己模拟 RPC
耦合度 强耦合,需知道对方地址 弱耦合,只需知道 topic 名
消费者数量 1 对 1 1 对 N(消费者组 + 独立组)
历史回放 不支持(除非自己存) 原生支持,改 offset 即可
可观测性 分散在各 Agent 日志 中心化,topic 即审计日志
重试与 DLQ 需自己实现 主题 + 重试 topic + DLQ 成熟模式
背压 TCP 窗口,语义清晰 需业务层设计,语义较弱
端到端延迟 低(10--20ms 级) 较高(30--60ms 级)
顺序保证 单连接内天然有序 单分区内有序,需正确设计 key
运维成本 低(无需中间件) 高(集群 + 监控 + 容量规划)
故障隔离 差(对方下线即失败) 好(消息堆积,恢复后继续)
适合规模 1--3 个 Agent 5 个以上或跨团队

三、三种落地架构:不是配方,是选型

先把话说在前面:下面这三种模式,是业界(Confluent、Google、Kai Wähner 等)在实践中总结出来的模式光谱,不是唯一解,也不是官方规范。 真实项目里往往是组合使用,甚至只用其中一部分。选择依据是你的约束条件,不是别人的架构图。 如果一个咨询顾问直接告诉你「应该用模式 A」,而他没问过你 Agent 数量、延迟要求和团队运维能力,那这个建议没有价值。

这一节的目标是给你判断依据,让你能自己推导出该用哪个。

3.1 模式 A:Kafka 作为传输层(Kafka as Transport)

核心特征 :A2A 消息整体走 Kafka topic,HTTP 完全不出现在 Agent 间通信中。Agent Card 里通过自定义绑定声明 Kafka 端点。

数据流是:客户端把 A2A 请求序列化后发到 a2a.requests.<agent-id>,服务端消费、执行、把响应发回客户端声明的 reply topic。整个过程没有 HTTP 请求产生。

什么时候选它

  • Agent 数量 ≥ 5,且会持续增长
  • 团队已有 Kafka 运维能力(这一点是硬门槛)
  • 需要完整的审计与回放能力
  • 需要把任务当队列分发(配合 Share Groups,见 9.5)
  • 上下游本来就是事件驱动的系统

什么时候不要选它

  • Agent 只有 2--3 个,且短期内不会增长
  • 延迟敏感(P99 < 50ms)
  • 团队没人懂 Kafka,且不打算投入学习成本
  • 需要和外部组织(非本公司的 Agent)通信------对方很可能不接受 Kafka 端点

主要代价:你要自己实现 RPC 语义(correlationId、timeout、错误传播),这部分代码不简单,而且极易写出内存泄漏(第六节会详细讲)。

3.2 模式 B:Kafka 做旁路扇出(Task Routing & Fan-Out)

核心特征 :保留 A2A 原生 HTTP 调用承载实际任务执行,同时 把 task 提交、状态变更、artifact 产出镜像到 Kafka,供监控、数仓、审计、告警系统消费。

这是成本最低的起步方式,因为它不改变任何现有的调用链路。你只需要在每个 Agent 里加一段「发事件到 Kafka」的代码,就能立刻获得中心化的可观测性。

它解决的是模式二的第三宗罪(可观测性为零),而不解决连接爆炸和强耦合。

必须写清楚的代价:双写(Dual Write)问题。

这是模式 B 最本质的缺陷。你的 Agent 现在要做两件事:一是把结果返回给调用方(HTTP 响应或写数据库),二是把事件发到 Kafka。这两件事不在同一个事务里,所以必然存在三种不一致:

场景 后果 严重程度
HTTP 返回成功,Kafka 写入失败 业务已发生但审计缺失 中(审计不完整)
Kafka 写入成功,HTTP 返回失败 审计显示成功,实际调用方认为失败 高(数据误导)
两者都成功但顺序颠倒 下游看到的时序错乱 中(分析偏差)

缓解手段

  1. Outbox 模式(推荐):把「要发的事件」和业务数据写在同一个数据库事务里,再由独立 relay 进程从 outbox 表读到 Kafka。这样至少保证了「业务数据 + 待发事件」的原子性。第九节会给出完整实现。
  2. 事务性生产 + 幂等消费:只在 Kafka 内部链路有效,一旦涉及外部系统(数据库、LLM API)仍然需要自己幂等。
  3. 干脆切到模式 A:如果双写问题让你痛苦,这其实是规范在告诉你「你已经在做事件驱动了,不如做彻底一点」。

什么时候选它:想低成本获得可观测性、暂时不想动调用链路、Agent 数量 5--10 个、有审计合规要求但不要求实时。

什么时候不要选它 :如果旁路事件的下游会被用于业务决策(而不只是看板),双写不一致会造成实际损失,此时应该直接上模式 A。

3.3 模式 C:混合编排(Hybrid Orchestration)

核心特征 :Kafka 做事件驱动的编排骨架------监听业务事件(如「销售线索已合格」「订单已支付」),由编排器决定发起哪些 A2A 任务给下游 Agent;下游 Agent 之间仍然用 A2A over HTTP 或 A2A over Kafka 直接通信。

这类模式在业务系统集成里最常见,因为它的上游本来就不是人,而是业务事件。比如 CRM 系统产生一条线索合格事件,编排器监听到之后,需要同时触发「资质核验 Agent」「额度评估 Agent」「客户画像 Agent」,三个都完成后触发「话术生成 Agent」。

什么时候选它

  • 上游是业务事件(数据库 CDC、业务系统消息),不是人的直接操作
  • 编排逻辑复杂,有分支、并行、聚合、超时补偿
  • 需要跨系统、跨部门的长流程

什么时候不要选它 :如果流程是线性的、同步的、简单的(A 调 B,B 调 C),上编排框架是过度设计。先问自己:我的流程有没有「等待某个外部事件」这一环? 如果没有,模式 C 的价值有限。

3.4 组合矩阵与选择依据

三种模式不是互斥的,真实项目里的组合方式:

组合 描述 适用场景
B 起步 → A 演进 先旁路扇出拿到可观测性,稳定后把调用链路也搬过去 最推荐的演进路径
A + C 编排走 Kafka,Agent 间通信也走 Kafka,全事件驱动 大型平台,全栈事件驱动
B + C 编排走 Kafka,执行走 HTTP + 旁路镜像 上游是业务事件但下游 Agent 是外部厂商
仅 B 只做旁路,最小改动 合规审计驱动的项目
仅 A 只做传输层,不做编排 Agent 数量多但流程简单

按 Agent 规模的推荐表

Agent 规模 推荐模式 理由 不推荐的做法
1--3 个 不上 Kafka,用 HTTP A2A 引入 Kafka 的成本远大于收益,点对点 3 个 Agent 只有 6 条链路,完全可控 为了「架构先进」上 Kafka
4--10 个 模式 B 起步 成本最低,改动最小,先解决可观测性;此规模下双写不一致通常可容忍 直接上模式 A,团队会低估 RPC 模拟的复杂度
10+ 个 模式 A,配 Outbox 连接数和运维复杂度已经不可控;必须解决顺序与一致性问题 继续用模式 B,双写不一致会变成系统性风险
任意规模 + 上游是业务事件 模式 C 编排逻辑本来就不属于 Agent 把编排逻辑塞进每个 Agent
任意规模 + 要求强一致 模式 A + Outbox 或事务 双写不可接受时,必须走事务化路径 模式 B(双写必然不一致)
任意规模 + 延迟敏感(P99 < 50ms) 不上 Kafka 多一跳的延迟无法消除 强行上 Kafka 再想办法优化延迟

决策树
#mermaid-svg-ObxnUKDeekUpxH4R{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ObxnUKDeekUpxH4R .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ObxnUKDeekUpxH4R .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ObxnUKDeekUpxH4R .error-icon{fill:#552222;}#mermaid-svg-ObxnUKDeekUpxH4R .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ObxnUKDeekUpxH4R .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ObxnUKDeekUpxH4R .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ObxnUKDeekUpxH4R .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ObxnUKDeekUpxH4R .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ObxnUKDeekUpxH4R .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ObxnUKDeekUpxH4R .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ObxnUKDeekUpxH4R .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ObxnUKDeekUpxH4R .marker.cross{stroke:#333333;}#mermaid-svg-ObxnUKDeekUpxH4R svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ObxnUKDeekUpxH4R p{margin:0;}#mermaid-svg-ObxnUKDeekUpxH4R .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ObxnUKDeekUpxH4R .cluster-label text{fill:#333;}#mermaid-svg-ObxnUKDeekUpxH4R .cluster-label span{color:#333;}#mermaid-svg-ObxnUKDeekUpxH4R .cluster-label span p{background-color:transparent;}#mermaid-svg-ObxnUKDeekUpxH4R .label text,#mermaid-svg-ObxnUKDeekUpxH4R span{fill:#333;color:#333;}#mermaid-svg-ObxnUKDeekUpxH4R .node rect,#mermaid-svg-ObxnUKDeekUpxH4R .node circle,#mermaid-svg-ObxnUKDeekUpxH4R .node ellipse,#mermaid-svg-ObxnUKDeekUpxH4R .node polygon,#mermaid-svg-ObxnUKDeekUpxH4R .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ObxnUKDeekUpxH4R .rough-node .label text,#mermaid-svg-ObxnUKDeekUpxH4R .node .label text,#mermaid-svg-ObxnUKDeekUpxH4R .image-shape .label,#mermaid-svg-ObxnUKDeekUpxH4R .icon-shape .label{text-anchor:middle;}#mermaid-svg-ObxnUKDeekUpxH4R .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ObxnUKDeekUpxH4R .rough-node .label,#mermaid-svg-ObxnUKDeekUpxH4R .node .label,#mermaid-svg-ObxnUKDeekUpxH4R .image-shape .label,#mermaid-svg-ObxnUKDeekUpxH4R .icon-shape .label{text-align:center;}#mermaid-svg-ObxnUKDeekUpxH4R .node.clickable{cursor:pointer;}#mermaid-svg-ObxnUKDeekUpxH4R .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ObxnUKDeekUpxH4R .arrowheadPath{fill:#333333;}#mermaid-svg-ObxnUKDeekUpxH4R .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ObxnUKDeekUpxH4R .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ObxnUKDeekUpxH4R .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ObxnUKDeekUpxH4R .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ObxnUKDeekUpxH4R .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ObxnUKDeekUpxH4R .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ObxnUKDeekUpxH4R .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ObxnUKDeekUpxH4R .cluster text{fill:#333;}#mermaid-svg-ObxnUKDeekUpxH4R .cluster span{color:#333;}#mermaid-svg-ObxnUKDeekUpxH4R div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ObxnUKDeekUpxH4R .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ObxnUKDeekUpxH4R rect.text{fill:none;stroke-width:0;}#mermaid-svg-ObxnUKDeekUpxH4R .icon-shape,#mermaid-svg-ObxnUKDeekUpxH4R .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ObxnUKDeekUpxH4R .icon-shape p,#mermaid-svg-ObxnUKDeekUpxH4R .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ObxnUKDeekUpxH4R .icon-shape .label rect,#mermaid-svg-ObxnUKDeekUpxH4R .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ObxnUKDeekUpxH4R .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ObxnUKDeekUpxH4R .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ObxnUKDeekUpxH4R :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否 只有 1 到 3 个

是 不允许双写不一致


否 可接受最终一致
业务事件 如订单已支付
具备
不具备
人的直接操作


开始评估
Agent 数量是否大于等于 5
不要上 Kafka
用 A2A over HTTP 即可
结论 点对点 6 条链路完全可控
是否要求强一致
模式 A Kafka 作为传输层
任务是否突发且量大
A 加 Share Groups 做任务队列
A 加 传统 Consumer Group 按 contextId 分区
必须配 Outbox 或事务保证一致性
结论 全事件驱动
上游是业务事件还是人
模式 C 混合编排
Kafka 运维能力是否具备
编排与通信都走 Kafka
编排走 Kafka 通信走 HTTP
模式 B 旁路扇出起步
成本最低 先拿可观测性
后续是否需要审计回放驱动业务
演进到模式 A
停留在模式 B


四、A2A over Kafka 的协议映射设计

这一节要解决的问题是:A2A 的抽象操作,具体怎么落到 topic、header、消息体上。 这是全文技术密度最高的部分。

4.1 Topic 拓扑设计

topic 命名约定(建议,不是规范):

topic 名 语义 key 分区数建议 保留策略
a2a.requests.<agent-id> 发往某 Agent 的请求 contextId 6--12 delete,7 天
a2a.replies.<client-id> 某客户端的私有响应 correlationId 3--6 delete,1 天
a2a.status.<task-id> 单任务状态变更 taskId 1--3 delete,30 天
a2a.artifacts.<task-id> 单任务产出物 taskId 1--3 delete,30 天
a2a.tasks.state 任务状态快照(全局) taskId 12 compact
a2a.dlq.<agent-id> 死信 原 key 3 delete,90 天
a2a.retry.5s 短延迟重试 原 key 6 delete,1 天
a2a.retry.1m 长延迟重试 原 key 6 delete,3 天
a2a.audit.<domain> 审计归档 contextId 12 delete,365 天

几点设计说明:

为什么 reply topic 要按客户端私有? 如果所有响应都发到一个 a2a.replies topic,那么每个客户端都会收到所有客户端的响应,需要靠 correlationId 过滤。这在小规模下能跑,但会带来三个问题:一是放大带宽(N 个客户端各读一份全量),二是权限无法隔离(客户端 A 能看到客户端 B 的响应,这是安全问题),三是消费者一多就会有人忘记过滤导致串台。私有 reply topic 是必须的,不是优化。

为什么 a2a.tasks.state 用 compact 而其他用 delete? 见下文。

分区键设计(本节最重要的部分)

Kafka 只保证单分区内有序。所以分区键的选择直接决定了你能不能拿到正确的顺序。

键选择 保证的顺序 适用场景 风险
contextId 同一会话内所有消息有序 多轮对话、需要跨任务顺序 单会话消息量大时热点分区
taskId 单任务内状态机有序 任务状态更新、artifact 拼接 不保证同会话跨任务顺序
correlationId 单次请求-响应对有序 reply topic 无跨请求顺序
随机 / null 无任何顺序保证 无状态、可完全并行 状态更新必然乱序

最重要的结论:绝不要用随机 key(或不设 key)来处理 Task 状态事件。 因为 TaskStatusUpdateEvent 是有状态机的,SUBMITTED → WORKING → COMPLETED 一旦乱序,客户端会看到「先 COMPLETED 后 WORKING」,逻辑直接崩溃。而且这个 bug 在低负载时不会出现、高负载时才出现,是最难排查的那类问题。第十一节会把这条列进踩坑清单。

具体怎么选

  • 如果任务之间独立、只需要单任务有序 → 用 taskId
  • 如果任务之间有会话上下文(比如 INPUT_REQUIRED 后要关联前序对话)→ 用 contextId
  • 实际项目中的折中方案 :请求 topic 用 contextId(保证会话顺序),状态和 artifact topic 用 taskId(保证任务顺序),reply topic 用 correlationId。三者各司其职。

日志压缩 vs 时间保留的取舍

Kafka 的 cleanup.policy 有两种,选错了会付出真实代价:

策略 行为 适合的数据 代价
delete 按时间/大小删除旧段 事件流、审计日志 历史会消失,无法查很久前的状态
compact 按 key 保留最新值 状态快照、配置、任务当前状态 不删除,磁盘持续增长(需配 tombstone)

判断依据 :问自己「我关心的是发生了什么 ,还是现在是什么」。

  • 关心「发生了什么」→ delete。比如 artifact 增量、状态变更事件流。
  • 关心「现在是什么」→ compact。比如「任务 X 当前状态」,你只需要最新值。

一个常见的错误是给状态事件 topic 配 compact,然后发现「我想回放状态变更历史,但历史被压掉了」。正确做法是双写:一条 event 流(delete)用于回放,一个 state topic(compact)用于快速查当前状态。这不是冗余,是两种不同的访问模式。

4.2 消息头设计(路由四要素)

Kafka 消息的 value 承载 A2A 载荷,路由信息必须放在 header 里 。原因是消费端需要在反序列化之前就能决定「这条消息该往哪走、用哪个方法处理」,如果把路由信息塞进 value,就必须先完整解析 JSON 才能路由,性能和错误处理都会变差。

header 作用 是否必需 缺失后果
a2a-reply-topic 客户端私有响应 topic,服务端据此回传 必需 服务端不知道往哪回,请求永久挂起
a2a-correlation-id 请求-响应匹配的唯一 ID 必需 客户端无法匹配响应,Future 永不完成
a2a-method A2A 抽象操作名(SendMessage / CancelTask ...) 必需 服务端无法路由到处理方法
a2a-context-id 会话 ID,用于分区与追踪 建议 丢失会话维度的追踪能力
a2a-task-id 任务 ID,用于分区与追踪 建议 同上
a2a-protocol-version 协议版本,用于向后兼容 建议 版本升级时无法灰度
a2a-tenant 租户标识,多租户隔离 多租户时必需 租户数据串台
traceparent W3C Trace Context,透传 OpenTelemetry 建议 链路在 broker 处断裂
content-type application/a2a+json 建议 与 Kafka 其他业务数据混用时无法区分

一条硬性规则:任一必需 header 缺失,必须记录错误日志并终止处理,绝不能静默丢弃。

这条规则听起来很基础,但它是分布式系统里最常见的「设计事故」来源。如果服务端在 header 缺失时静默丢弃消息(比如直接 return),客户端会一直等 correlationId 对应的响应,直到超时。如果客户端没有设置超时(比如用了 await future 而不是 wait_for),就会永久挂起

正确的做法是:无法路由的消息要么发到 DLQ 并记录,要么发一条错误信封回 reply topic(如果能拿到 reply topic 的话)。

4.3 Agent Card 的 Kafka 扩展

Agent Card 是 A2A 的「服务发现」机制。要让一个 Agent 声明「我可以通过 Kafka 访问」,需要在 supportedInterfaces 里加一项,并提供 Kafka 特有的连接信息。

json 复制代码
{
  "name": "Example Kafka Agent",
  "description": "An agent accessible via Kafka.",
  "version": "1.0.0",
  "supportedInterfaces": [
    {
      "url": "kafka://kafka1:9092,kafka2:9092",
      "protocolBinding": "KAFKA",
      "protocolVersion": "1.0",
      "tenant": "default"
    }
  ],
  "kafka": {
    "bootstrapServers": "kafka1:9092,kafka2:9092",
    "securityConfig": {
      "securityProtocol": "SASL_SSL",
      "saslMechanism": "SCRAM-SHA-512"
    },
    "requestTopic": "a2a.requests.example-agent",
    "serializationFormat": "json"
  },
  "capabilities": {
    "streaming": true,
    "pushNotifications": true
  },
  "defaultInputModes": ["text/plain"],
  "defaultOutputModes": ["text/plain"],
  "skills": [
    {
      "id": "flight-booking",
      "name": "Flight Booking",
      "description": "查询与预订航班,支持多段行程与改签",
      "tags": ["travel", "booking"]
    }
  ]
}

各字段用途:

字段 用途 谁消费
supportedInterfaces[].url 自定义 scheme kafka:// 标识 bootstrap 地址 客户端 SDK
supportedInterfaces[].protocolBinding 固定为 KAFKA,客户端据此选择传输实现 客户端 SDK
kafka.bootstrapServers 实际连接用的 broker 地址(比 url 更明确) 客户端 Kafka producer/consumer
kafka.securityConfig 安全协议与 SASL 机制 客户端
kafka.requestTopic 该 Agent 的请求入口 topic 客户端 producer
kafka.serializationFormat 序列化格式(json / avro / protobuf) 双方

必须说清楚的一件事:kafka 这个顶层字段是社区 PoC 实现的自定义字段,不是 A2A v1.0 规范里的内容。

A2A v1.0 规范里并没有定义一个叫 kafka 的字段。上面这段 JSON 来自社区(Google Codelabs 的 a2a-python-kafka)在 PoC 中自行扩展的结构。因为这个字段不在规范里:

  • 客户端如果严格按规范校验 Agent Card,会忽略或拒绝这个字段
  • 不同实现的字段名可能不同(有叫 kafka 的,有叫 transportConfig 的)
  • 一旦规范后续定义了官方扩展,你的实现会面临迁移

生产上更稳妥的三种做法(按推荐度排序):

  1. supportedInterfaces[].protocolBinding = "KAFKA" + extensions 机制 声明一个扩展 URI,比如 https://your-company.example/extensions/kafka-transport/v1,把 Kafka 配置塞在扩展里,并在 capabilities.extensions 中声明。这样至少符合规范的扩展模型。
  2. 把 Kafka 连接信息放到带外配置 (配置中心、环境变量、服务发现),Agent Card 里只声明 protocolBinding = "KAFKA" 和逻辑 topic 名。这是最干净的做法,也是企业内网最常见的做法。
  3. 跟着社区 PoC 的 kafka 字段走,承认它不规范,但在团队内约定统一。适合 PoC 和内部系统。

不要做的是:把一个自定义字段包装成「A2A 规范的一部分」对外宣传。这会误导团队。

4.4 信封协议(Envelope)

问题背景:Kafka 的一条消息天然对应一个事件。而 A2A 的流式语义需要三种状态:「有数据」「流结束」「出错了」。Kafka 没有「流结束」这个概念------topic 是无限的。所以必须自己定义一个信封(Envelope)来表达这三种状态。

json 复制代码
{
  "type": "data",
  "payload": {
    "statusUpdate": {
      "taskId": "task-7f3a",
      "contextId": "ctx-91b2",
      "status": {
        "state": "TASK_STATE_WORKING",
        "timestamp": "2026-09-16T10:30:00Z"
      }
    }
  }
}
json 复制代码
{
  "type": "control",
  "signal": "end_of_stream"
}
json 复制代码
{
  "type": "error",
  "error": {
    "code": -32000,
    "message": "Upstream LLM provider returned 429 after 3 retries",
    "data": { "retryable": true }
  }
}

为什么必须有显式的 end_of_stream

这是本节的核心理由。看客户端代码的写法:

python 复制代码
async def consume_stream(self, correlation_id: str):
    async for envelope in self._iterate(correlation_id):
        yield envelope["payload"]

如果流没有显式结束信号,这个 async for 永远不会退出 。因为 Kafka consumer 的 poll 本身是无限循环的,它不知道「这个 correlationId 的事件已经发完了」。客户端只能靠超时退出,但超时退出有两个问题:

  1. :必须等到超时才返回,用户感知为卡顿。
  2. 区分不了正常结束和异常:超时了,到底是任务已完成、还是服务端崩了?无法判断,只能报「超时」这个模糊错误。

有了 end_of_stream,客户端可以精确地 break 循环,并且能把「正常结束」和「超时未结束」区分开来。

error 信封的必要性同理:如果没有错误信封,服务端出错时只能「不发任何消息」,客户端就又要等超时了。错误信封让错误能主动、快速地传播。

信封设计的三个取舍点

设计选择 方案 A 方案 B 建议
结束信号位置 独立 control 消息 挂在最后一条 data 上(last: true 独立消息 ,避免客户端漏读 last 字段
错误编码 复用 JSON-RPC 错误码 自定义错误码 复用 JSON-RPC(-32000 系列),A2A 的 JSONRPC 绑定已定义
多流复用 每流一个 reply topic 每流一个 correlationId correlationId,topic 数量会爆炸

五、环境准备:单机 KRaft Kafka

这一节要解决的问题是:让你能在 5 分钟内得到一个可运行的 Kafka,验证后面的所有代码。

5.1 版本选择的背景

首先明确一个事实:Kafka 4.0 起,ZooKeeper 已经被彻底移除(removed),不是「废弃(deprecated)」而是「删掉了」。 这意味着你在网上看到的绝大多数 Kafka 教程(那些带 zookeeper:2181 的 docker-compose)在 4.x 上直接跑不起来。KRaft 是唯一的元数据管理模式。

Kafka 4.2 于 2026-02-17 发布。版本能力矩阵:

能力 4.0 4.1 4.2 4.3
KRaft(ZooKeeper 已移除)
KIP-848 新消费者协议(GA)
Share Groups(KIP-932) Early Access GA GA 稳定 GA
Share Consumer RENEW(KIP-1222)
Kafka Streams 重平衡优化 部分
Kafka Streams 内建 DLQ(KIP-1034)
Cordon(节点摘除)
Share Group delivery count 可配 --- 基础

选版本建议:新项目直接用 4.2.x。4.0 的 Share Groups 还在 Early Access,接口不稳定;4.1 虽然 GA 但缺 RENEW 和 Streams DLQ;4.2 是第一个「Share Groups + DLQ 都能用」的版本。

5.2 docker-compose.yml

yaml 复制代码
services:
  kafka:
    image: apache/kafka:4.2.0
    container_name: a2a-kafka
    ports:
      - "9092:9092"
    environment:
      # ---- KRaft 模式:单节点同时充当 broker 与 controller ----
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: broker,controller
      KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093

      # ---- 监听器配置 ----
      KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093,INTERNAL://0.0.0.0:19092
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092,INTERNAL://kafka:19092
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT,INTERNAL:PLAINTEXT
      KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL
      KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER

      # ---- 关键:开启 classic / consumer / share 三种重平衡协议 ----
      KAFKA_GROUP_COORDINATOR_REBALANCE_PROTOCOLS: classic,consumer,share
      KAFKA_UNSTABLE_API_VERSIONS_ENABLE: "true"

      # ---- Share Group 相关调优 ----
      KAFKA_SHARE_COORDINATOR_STATE_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_SHARE_COORDINATOR_STATE_TOPIC_MIN_ISR: 1
      KAFKA_GROUP_SHARE_DELIVERY_COUNT_LIMIT: 5
      KAFKA_GROUP_SHARE_RECORD_LOCK_DURATION_MS: 30000

      # ---- 开发环境:允许自动创建 topic,生产必须关闭 ----
      KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true"
      KAFKA_NUM_PARTITIONS: 3
      KAFKA_DEFAULT_REPLICATION_FACTOR: 1
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1

      # ---- 日志 ----
      KAFKA_LOG_RETENTION_HOURS: 24
      KAFKA_LOG_DIRS: /var/lib/kafka/data
    volumes:
      - kafka-data:/var/lib/kafka/data
    healthcheck:
      test: ["CMD-SHELL", "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092 > /dev/null 2>&1"]
      interval: 10s
      timeout: 10s
      retries: 10
      start_period: 30s

volumes:
  kafka-data:

关于 KAFKA_AUTO_CREATE_TOPICS_ENABLE:开发环境方便,生产必须关。第十一节会把「依赖自动创建 topic 导致消息丢失」列为一条坑。

5.3 创建 topic

bash 复制代码
# 启动集群
docker compose up -d

# 等待健康检查通过
docker compose ps

# ---------- 请求 topic:每 Agent 一个,key 用 contextId ----------
docker exec a2a-kafka /opt/kafka/bin/kafka-topics.sh \
  --bootstrap-server localhost:9092 \
  --create \
  --topic a2a.requests.example-agent \
  --partitions 6 \
  --replication-factor 1 \
  --config cleanup.policy=delete \
  --config retention.ms=604800000 \
  --config max.message.bytes=10485760

# ---------- 私有 reply topic:每客户端一个,key 用 correlationId ----------
docker exec a2a-kafka /opt/kafka/bin/kafka-topics.sh \
  --bootstrap-server localhost:9092 \
  --create \
  --topic a2a.replies.client-001 \
  --partitions 3 \
  --replication-factor 1 \
  --config cleanup.policy=delete \
  --config retention.ms=86400000

# ---------- 任务状态快照 topic:用 compact,只保留每个 taskId 的最新状态 ----------
docker exec a2a-kafka /opt/kafka/bin/kafka-topics.sh \
  --bootstrap-server localhost:9092 \
  --create \
  --topic a2a.tasks.state \
  --partitions 12 \
  --replication-factor 1 \
  --config cleanup.policy=compact \
  --config min.cleanable.dirty.ratio=0.1 \
  --config segment.ms=600000 \
  --config delete.retention.ms=86400000

# ---------- 重试与死信 ----------
for T in a2a.retry.5s a2a.retry.1m a2a.dlq.example-agent; do
  docker exec a2a-kafka /opt/kafka/bin/kafka-topics.sh \
    --bootstrap-server localhost:9092 \
    --create \
    --topic "$T" \
    --partitions 6 \
    --replication-factor 1 \
    --config cleanup.policy=delete \
    --config retention.ms=7776000000
done

# ---------- 查看全部 topic ----------
docker exec a2a-kafka /opt/kafka/bin/kafka-topics.sh \
  --bootstrap-server localhost:9092 --list

# ---------- 查看某个 topic 的详细配置,确认 compact 生效 ----------
docker exec a2a-kafka /opt/kafka/bin/kafka-topics.sh \
  --bootstrap-server localhost:9092 \
  --describe \
  --topic a2a.tasks.state

5.4 验证 Share Groups 能力

这一步很重要,因为如果 broker 没有开启 share 重平衡协议,后面 9.5 节的代码会直接报错。

bash 复制代码
# 1. 确认 share 特性已启用(输出里应能看到 share 相关条目)
docker exec a2a-kafka /opt/kafka/bin/kafka-features.sh \
  --bootstrap-server localhost:9092 describe | grep -i share

# 2. 列出 share group(初始为空是正常的)
docker exec a2a-kafka /opt/kafka/bin/kafka-share-groups.sh \
  --bootstrap-server localhost:9092 --list

# 3. 确认 broker 支持的 group 重平衡协议里包含 share
docker exec a2a-kafka /opt/kafka/bin/kafka-configs.sh \
  --bootstrap-server localhost:9092 \
  --entity-type brokers --entity-name 1 --describe \
  | grep -i rebalance

# 4. 查看 share group 的详细状态(运行 9.5 节代码后可用)
docker exec a2a-kafka /opt/kafka/bin/kafka-share-groups.sh \
  --bootstrap-server localhost:9092 \
  --describe --group a2a-task-workers

# 5. 生产一条测试消息,确认 topic 可写
echo '{"jsonrpc":"2.0","id":"smoke-1","method":"SendMessage","params":{}}' | \
  docker exec -i a2a-kafka /opt/kafka/bin/kafka-console-producer.sh \
  --bootstrap-server localhost:9092 \
  --topic a2a.requests.example-agent

如果第 1 步没有输出,说明 KAFKA_GROUP_COORDINATOR_REBALANCE_PROTOCOLS 没配好,回去检查 compose 文件。


六、客户端实现:带 correlationId 的 RPC 模拟(Python)

这一节要解决的问题是:Kafka 是流式平台,天生不为请求-响应设计,怎么在它上面做出可靠的 RPC?

6.1 核心难点

先把这个问题的本质说透。Kafka 的消费模型是「拉取 + 偏移量」:你订阅一个 topic,然后在一个循环里不断 poll,拿到一批消息,处理,提交偏移量。这个模型没有「等待某一条特定消息」的概念

而 RPC 要求的是:「我发一条消息,然后阻塞等待那一条对应的响应」。

这两者之间的鸿沟就是我们要填的东西。填法有三种,各有代价:

方案 实现 优势 代价
轮询等待 循环 poll,检查有没有匹配的 correlationId 实现简单 无法同时等多个请求;CPU 空转;超时难处理
回调(callback) 注册 correlationId -> callable 无阻塞 回调地狱;异常传播困难
Future 注册 correlationId -> asyncio.Future,响应到达时 set_result 可并发等待;天然支持超时;代码同步风格 需要正确清理,否则内存泄漏

选 Future,理由有三

  1. 并发:一个客户端可能同时有 50 个在途请求,Future 方案天然支持,轮询方案要么串行要么写复杂的分发逻辑。
  2. 超时asyncio.wait_for(future, timeout) 直接可用,不需要手写超时逻辑。
  3. 风格 :调用方写起来像同步 RPC(await send_message(...)),心智负担最低。

代价是必须手动管理 Future 的生命周期------这也是最容易出 bug 的地方,后面会专门讲。

6.2 Python 依赖

bash 复制代码
# 核心依赖
pip install "aiokafka>=0.12.0" "pydantic>=2.7" "a2a-sdk>=1.0"

# 注意:官方 SDK 提供了 telemetry 和 grpc 等 extra,
# 社区也提供过 kafka extra(a2a-sdk[kafka]),但该扩展目前仍是 PoC 级别,
# 接口可能随规范与实现变化,不建议直接用于生产。
pip install "a2a-sdk[telemetry]"

6.3 CorrelationManager:RPC 与流式的双通道管理

这个类的核心职责是维护两张表:

  • pending_requests: dict[str, asyncio.Future] ------ 处理「一问一答」的 RPC
  • streaming_queues: dict[str, asyncio.Queue] ------ 处理「一问多答」的流式

为什么要分开两张表? 因为两者的生命周期模型完全不同。RPC 的 Future 在 set_result 之后就完成了,是一次性的;而流的 Queue 要一直存在,直到收到 end_of_stream,并且要允许消费者边收边处理(如果用 Future 就只能等全部到齐,那就退化成非流式了)。

python 复制代码
from __future__ import annotations

import asyncio
import logging
from typing import Any

logger = logging.getLogger(__name__)


class CorrelationManager:
    """管理请求-响应关联状态。

    两张表分别对应两种交互模式:
      - pending_requests:  RPC 模式,一问一答,Future 在响应到达时完成
      - streaming_queues: 流式模式,一问多答,Queue 直到 end_of_stream 才关闭

    线程/协程安全说明:本类的方法全部在同一个事件循环中被调用,
    因此不需要加锁。如果要在多线程中使用,必须换成
    asyncio.run_coroutine_threadsafe 或使用 threading.Lock。
    """

    def __init__(self) -> None:
        self.pending_requests: dict[str, asyncio.Future[dict[str, Any]]] = {}
        self.streaming_queues: dict[str, asyncio.Queue[dict[str, Any] | None]] = {}
        # 每个 correlationId 的超时时间,超时后用于判断是否为「真实超时」
        self._registered_at: dict[str, float] = {}

    # ---------------- RPC 通道 ----------------

    def create_future(self, correlation_id: str) -> asyncio.Future[dict[str, Any]]:
        """为一次 RPC 调用登记一个 Future。"""
        loop = asyncio.get_running_loop()
        fut: asyncio.Future[dict[str, Any]] = loop.create_future()
        self.pending_requests[correlation_id] = fut
        logger.debug("registered rpc future cid=%s", correlation_id)
        return fut

    def resolve_future(self, correlation_id: str, envelope: dict[str, Any]) -> bool:
        """响应到达时完成对应的 Future。

        返回 True 表示成功匹配并交付,False 表示没有对应的等待者
        (可能已超时被清理,或 correlationId 被误用)。
        """
        fut = self.pending_requests.pop(correlation_id, None)
        if fut is None:
            logger.warning("orphan response cid=%s 无等待者,可能已超时", correlation_id)
            return False
        if fut.done():
            logger.warning("duplicate response cid=%s 已被完成过", correlation_id)
            return False
        fut.set_result(envelope)
        return True

    def fail_future(self, correlation_id: str, exc: BaseException) -> None:
        fut = self.pending_requests.pop(correlation_id, None)
        if fut is not None and not fut.done():
            fut.set_exception(exc)

    # ---------------- 流式通道 ----------------

    def create_stream(self, correlation_id: str) -> asyncio.Queue[dict[str, Any] | None]:
        if correlation_id in self.streaming_queues:
            raise ValueError(f"duplicate stream cid={correlation_id}")
        q: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue(maxsize=256)
        self.streaming_queues[correlation_id] = q
        return q

    async def push_stream_event(self, correlation_id: str, envelope: dict[str, Any]) -> None:
        """把一个流事件推入队列。

        这里用 await(而不是 put_nowait)是有意为之:
        当消费端处理慢时,await 会自然形成背压,
        避免生产者无限堆积导致 OOM。
        """
        q = self.streaming_queues.get(correlation_id)
        if q is None:
            logger.warning("orphan stream event cid=%s", correlation_id)
            return
        await q.put(envelope)

    async def close_stream(self, correlation_id: str) -> None:
        """放入哨兵值 None 表示流结束,并移除登记。

        注意:这里不能 pop 掉队列本身,因为消费端可能还在 await get()。
        必须靠哨兵值让消费端自己退出循环,消费端退出后再调用
        release_stream 做清理。
        """
        q = self.streaming_queues.get(correlation_id)
        if q is None:
            return
        await q.put(None)

    def release_stream(self, correlation_id: str) -> None:
        self.streaming_queues.pop(correlation_id, None)

    # ---------------- 清理 ----------------

    def cleanup(self, correlation_id: str) -> None:
        """超时或异常后必须调用,否则 pending_requests 会持续增长。

        这是本类最重要的一个方法:一个长时间运行的客户端进程,
        如果每次超时都不清理,字典会无限膨胀,最终 OOM。
        """
        fut = self.pending_requests.pop(correlation_id, None)
        if fut is not None and not fut.done():
            fut.cancel()
        self.streaming_queues.pop(correlation_id, None)

    def stats(self) -> dict[str, int]:
        return {
            "pending_requests": len(self.pending_requests),
            "streaming_queues": len(self.streaming_queues),
        }

6.4 KafkaClientTransport:传输实现

python 复制代码
from __future__ import annotations

import asyncio
import json
import logging
import uuid
from typing import Any, AsyncIterator

from aiokafka import AIOKafkaConsumer, AIOKafkaProducer

from correlation_manager import CorrelationManager

logger = logging.getLogger(__name__)


class A2ATimeoutError(TimeoutError):
    """客户端侧超时。注意与「服务端返回错误信封」区分。"""


class KafkaClientTransport:
    """A2A over Kafka 的客户端传输实现。

    生命周期:
        transport = KafkaClientTransport(...)
        await transport.connect()
        ...
        await transport.close()
    """

    def __init__(
        self,
        bootstrap_servers: str,
        client_id: str,
        default_timeout: float = 30.0,
        reply_topic: str | None = None,
    ) -> None:
        self.bootstrap_servers = bootstrap_servers
        self.client_id = client_id
        self.default_timeout = default_timeout
        # reply topic 必须私有:多个客户端共用一个 topic 会导致
        # 权限无法隔离、带宽放大、以及忘记过滤时的响应串台。
        self.reply_topic = reply_topic or f"a2a.replies.{client_id}"

        self.corr = CorrelationManager()
        self._producer: AIOKafkaProducer | None = None
        self._consumer: AIOKafkaConsumer | None = None
        self._consumer_task: asyncio.Task[None] | None = None
        self._closed = asyncio.Event()

    # ---------------- 连接管理 ----------------

    async def connect(self) -> None:
        self._producer = AIOKafkaProducer(
            bootstrap_servers=self.bootstrap_servers,
            client_id=self.client_id,
            # 幂等生产:防止 producer 内部重试造成重复。
            # 注意它只保证「单分区 + 单 producer 会话内」不重复,
            # 不是端到端保证,跨会话重发仍可能重复。
            enable_idempotence=True,
            acks="all",
            compression_type="lz4",
            linger_ms=5,
            max_request_size=10 * 1024 * 1024,
        )
        await self._producer.start()

        self._consumer = AIOKafkaConsumer(
            self.reply_topic,
            bootstrap_servers=self.bootstrap_servers,
            client_id=f"{self.client_id}-reply-consumer",
            group_id=None,  # 客户端读自己的私有 topic,不需要消费者组
            # earliest 而不是默认的 latest:
            # 客户端进程重启期间到达的响应不能丢,
            # 否则 Future 会一直等到超时。
            auto_offset_reset="earliest",
            enable_auto_commit=False,
        )
        await self._consumer.start()
        self._consumer_task = asyncio.create_task(self._consume_loop())
        logger.info("transport connected reply_topic=%s", self.reply_topic)

    async def close(self) -> None:
        self._closed.set()
        if self._consumer_task is not None:
            self._consumer_task.cancel()
            try:
                await self._consumer_task
            except asyncio.CancelledError:
                pass
        if self._consumer is not None:
            await self._consumer.stop()
        if self._producer is not None:
            await self._producer.stop()

    # ---------------- 后台消费循环 ----------------

    async def _consume_loop(self) -> None:
        """轮询 reply topic,把消息分发到 Future 或 Queue。"""
        assert self._consumer is not None
        while not self._closed.is_set():
            try:
                batches = await self._consumer.getmany(timeout_ms=200, max_records=100)
            except asyncio.CancelledError:
                raise
            except Exception:
                logger.exception("reply consumer poll 失败,1 秒后重试")
                await asyncio.sleep(1.0)
                continue

            for _tp, messages in batches.items():
                for msg in messages:
                    try:
                        await self._dispatch(msg)
                    except Exception:
                        logger.exception("分发响应失败 offset=%s", msg.offset)

    async def _dispatch(self, msg: Any) -> None:
        headers = {k: v.decode() for k, v in (msg.headers or [])}
        cid = headers.get("a2a-correlation-id")
        if not cid:
            logger.error("响应缺少 a2a-correlation-id,丢弃 offset=%s", msg.offset)
            return

        envelope = json.loads(msg.value)

        if envelope.get("type") == "error":
            # 服务端主动报错,用异常通知等待者,而不是让它等到超时
            self.corr.fail_future(
                cid,
                RuntimeError(f"远端错误 {envelope['error']['code']}: {envelope['error']['message']}"),
            )
            return

        if cid in self.corr.streaming_queues:
            if envelope.get("type") == "control" and envelope.get("signal") == "end_of_stream":
                await self.corr.close_stream(cid)
            else:
                await self.corr.push_stream_event(cid, envelope)
            return

        # 到这里是普通 RPC 响应
        if not self.corr.resolve_future(cid, envelope):
            logger.warning("响应无人认领 cid=%s,可能是 correlationId 复用", cid)

    # ---------------- 发送 ----------------

    async def _send(
        self,
        request_topic: str,
        method: str,
        params: dict[str, Any],
        context_id: str,
        task_id: str | None = None,
        timeout: float | None = None,
    ) -> tuple[str, bytes]:
        """构造并发送一条 A2A 请求,返回 (correlation_id, key)。"""
        assert self._producer is not None
        cid = str(uuid.uuid4())

        headers = [
            ("a2a-reply-topic", self.reply_topic.encode()),
            ("a2a-correlation-id", cid.encode()),
            ("a2a-method", method.encode()),
            ("a2a-context-id", context_id.encode()),
            ("a2a-protocol-version", b"1.0"),
            ("content-type", b"application/a2a+json"),
            # traceparent 由 OpenTelemetry 注入,这里是占位说明:
            # 生产代码应从当前 span 的 carrier 中取出,见第十节。
        ]
        if task_id:
            headers.append(("a2a-task-id", task_id.encode()))

        body = json.dumps(
            {
                "jsonrpc": "2.0",
                "id": cid,
                "method": method,
                "params": params,
            },
            ensure_ascii=False,
        ).encode()

        # 分区键用 contextId:保证同一会话内的消息严格有序
        await self._producer.send_and_wait(
            request_topic,
            key=context_id.encode(),
            value=body,
            headers=headers,
        )
        return cid, context_id.encode()

    async def send_message(
        self,
        request_topic: str,
        params: dict[str, Any],
        context_id: str,
        timeout: float | None = None,
    ) -> dict[str, Any]:
        """阻塞式 RPC:发一条,等一条。

        这是 SendMessage(returnImmediately=false)的等价实现。
        """
        timeout = timeout or self.default_timeout
        cid, _ = await self._send(request_topic, "SendMessage", params, context_id)
        fut = self.corr.create_future(cid)
        try:
            return await asyncio.wait_for(fut, timeout=timeout)
        except asyncio.TimeoutError as exc:
            # 关键:超时后必须清理,否则后续响应会认领到这个已废弃的 Future,
            # 而且 pending_requests 会随超时次数线性增长直至 OOM。
            self.corr.cleanup(cid)
            raise A2ATimeoutError(f"SendMessage 超时 cid={cid} timeout={timeout}s") from exc
        except BaseException:
            self.corr.cleanup(cid)
            raise

    async def send_message_streaming(
        self,
        request_topic: str,
        params: dict[str, Any],
        context_id: str,
        timeout: float | None = None,
    ) -> AsyncIterator[dict[str, Any]]:
        """流式 RPC:发一条,收多条,直到 end_of_stream。

        返回 AsyncGenerator;调用方用 async for 消费。
        """
        timeout = timeout or self.default_timeout
        cid, _ = await self._send(request_topic, "SendStreamingMessage", params, context_id)
        queue = self.corr.create_stream(cid)

        try:
            while True:
                try:
                    item = await asyncio.wait_for(queue.get(), timeout=timeout)
                except asyncio.TimeoutError as exc:
                    raise A2ATimeoutError(
                        f"流式响应超时 cid={cid} 已等待 {timeout}s 未收到 end_of_stream"
                    ) from exc

                if item is None:
                    # 收到哨兵,流正常结束
                    return

                if item.get("type") == "error":
                    err = item["error"]
                    raise RuntimeError(f"流内错误 {err['code']}: {err['message']}")

                yield item["payload"]
        finally:
            # finally 保证无论是正常结束、超时还是调用方 break,都会清理
            self.corr.release_stream(cid)

三个关键设计点的论证

为什么用 Future 而不是轮询? 除了前面的并发和超时理由,还有一个更实际的原因:轮询方案在「多个并发请求」场景下会退化成 while True: poll(); for cid in waiters: check(),而 poll 的返回是批量的,你要为每批消息遍历所有等待者,复杂度是 O(消息数 × 等待者数)。Future 方案是 O(1) 查表。

为什么超时后必须清理? 这是本实现里最容易漏掉的一步,危害有两层:

  1. 内存泄漏pending_requests 字典持有 Future 引用,而 Future 又持有它的回调(包含闭包),永不释放。一个每天处理 10 万请求、5% 超时的服务,一天就积累 5000 个僵尸 Future。跑一周就是 3.5 万个。
  2. 逻辑错误 :更隐蔽。如果超时后没清理,而服务端最终迟到的响应 到了(比如超时 30s,服务端 45s 才回),resolve_future 会把这个响应交给一个已经被放弃的 Future。而此时如果 correlationId 被复用(虽然本实现用 uuid4 基本不会),就会串台。即使不复用,也会出现「响应被静默吃掉」的现象,日志上看起来什么都没发生。

正确做法就是 finally 里无条件清理。

为什么 reply topic 必须私有? 三个理由,按严重性排序:

  • 安全:共用 topic 时,客户端 A 能读到客户端 B 的响应内容(除非做了 topic 级 ACL,但那样还不如直接分 topic)。这在多租户场景是数据泄漏。
  • 串台 :如果两个客户端生成的 correlationId 碰撞(比如都用了自增 ID 或时间戳),响应会送到错误的等待者手里,且极难排查
  • 成本:N 个客户端各读一份全量消息,带宽是 N 倍。100 个客户端就是 100 倍放大。

6.5 使用示例

python 复制代码
import asyncio
import uuid


async def main() -> None:
    transport = KafkaClientTransport(
        bootstrap_servers="localhost:9092",
        client_id="client-001",
        default_timeout=60.0,
    )
    await transport.connect()
    try:
        context_id = f"ctx-{uuid.uuid4()}"

        # ---- 场景一:阻塞式 RPC ----
        resp = await transport.send_message(
            request_topic="a2a.requests.example-agent",
            params={
                "message": {
                    "messageId": str(uuid.uuid4()),
                    "role": "ROLE_USER",
                    "parts": [{"text": "帮我查一下 9 月 20 日北京到上海的航班"}],
                    "contextId": context_id,
                }
            },
            context_id=context_id,
        )
        print("阻塞模式结果:", resp)

        # ---- 场景二:流式 ----
        async for payload in transport.send_message_streaming(
            request_topic="a2a.requests.example-agent",
            params={
                "message": {
                    "messageId": str(uuid.uuid4()),
                    "role": "ROLE_USER",
                    "parts": [{"text": "生成一份出行方案"}],
                    "contextId": context_id,
                }
            },
            context_id=context_id,
        ):
            if "artifactUpdate" in payload:
                ev = payload["artifactUpdate"]
                print("分片到达 append=", ev.get("append"), "lastChunk=", ev.get("lastChunk"))
            elif "statusUpdate" in payload:
                print("状态变更:", payload["statusUpdate"]["status"]["state"])
    finally:
        await transport.close()
        print("清理状态:", transport.corr.stats())


if __name__ == "__main__":
    asyncio.run(main())

七、服务端实现:KafkaHandler 协议适配器(Python)

这一节要解决的问题是:服务端怎么把 Kafka 上的消息还原成 A2A 的抽象操作,并把结果信封化回传。

7.1 handle_request 五步

服务端的处理流程是固定的五步,每一步都有失败的可能,且都必须有明确行为:

  1. 解析 header ------ 取出 a2a-reply-topica2a-correlation-ida2a-method
  2. 反序列化 value,提取 method / params
  3. _method_map 调度表查表 ------ 把抽象操作名映射到具体处理函数
  4. try / except 执行 ------ 区分单值返回和异步生成器返回
  5. 包装信封,回传 reply_topic

为什么用调度表(dict)而不是 if/elif 链? 因为 A2A 有 11 个抽象操作,而实现会持续增加(比如将来有 v1.1 的新操作)。调度表的好处是:新增操作只改一个 dict 字面量,不动控制流,而且可以做「未实现操作」的统一兜底(返回 MethodNotFound 而不是 500)。

7.2 完整实现

python 复制代码
from __future__ import annotations

import asyncio
import json
import logging
from typing import Any, Awaitable, Callable, AsyncIterator

from aiokafka import AIOKafkaConsumer, AIOKafkaProducer

logger = logging.getLogger(__name__)

HandlerFunc = Callable[[dict[str, Any]], Awaitable[Any]]


class A2AError(Exception):
    """A2A 层错误,会被包装成 error 信封回传。"""

    def __init__(self, code: int, message: str, data: dict[str, Any] | None = None) -> None:
        super().__init__(message)
        self.code = code
        self.message = message
        self.data = data or {}

    def to_envelope(self) -> dict[str, Any]:
        return {"type": "error", "error": {"code": self.code, "message": self.message, "data": self.data}}


class KafkaHandler:
    """A2A over Kafka 的服务端协议适配器。

    ⚠️ 重要声明:
    本实现参考的是 Google Codelabs 的社区 PoC(a2a-python-kafka),
    属于教学用途,不可直接用于生产。详见 7.4 节的差距清单。
    """

    def __init__(
        self,
        bootstrap_servers: str,
        request_topic: str,
        agent_id: str,
        task_handlers: dict[str, HandlerFunc],
    ) -> None:
        self.bootstrap_servers = bootstrap_servers
        self.request_topic = request_topic
        self.agent_id = agent_id
        self.task_handlers = task_handlers

        # 调度表:抽象操作名 -> 协程函数
        self._method_map: dict[str, HandlerFunc] = {
            "SendMessage": self._send_message,
            "SendStreamingMessage": self._send_streaming_message,
            "GetTask": self._get_task,
            "ListTasks": self._list_tasks,
            "CancelTask": self._cancel_task,
            "SubscribeToTask": self._subscribe_to_task,
            "CreateTaskPushNotificationConfig": self._create_push_config,
            "GetTaskPushNotificationConfig": self._get_push_config,
            "ListTaskPushNotificationConfigs": self._list_push_configs,
            "DeleteTaskPushNotificationConfig": self._delete_push_config,
            "GetExtendedAgentCard": self._get_extended_agent_card,
        }

        self._producer: AIOKafkaProducer | None = None
        self._consumer: AIOKafkaConsumer | None = None

    # ---------------- 生命周期 ----------------

    async def start(self) -> None:
        self._producer = AIOKafkaProducer(
            bootstrap_servers=self.bootstrap_servers,
            client_id=f"a2a-server-{self.agent_id}",
            enable_idempotence=True,
            acks="all",
            compression_type="lz4",
        )
        await self._producer.start()

        self._consumer = AIOKafkaConsumer(
            self.request_topic,
            bootstrap_servers=self.bootstrap_servers,
            group_id=f"a2a-agent-{self.agent_id}",
            auto_offset_reset="earliest",
            enable_auto_commit=False,  # 手动提交,保证「处理成功才提交」
            max_poll_records=50,
            max_poll_interval_ms=300_000,
        )
        await self._consumer.start()
        logger.info("server started agent=%s topic=%s", self.agent_id, self.request_topic)

    async def stop(self) -> None:
        if self._consumer:
            await self._consumer.stop()
        if self._producer:
            await self._producer.stop()

    async def run(self) -> None:
        """主消费循环。"""
        assert self._consumer is not None
        while True:
            batches = await self._consumer.getmany(timeout_ms=500, max_records=50)
            for _tp, messages in batches.items():
                for msg in messages:
                    try:
                        await self.handle_request(msg)
                    except Exception:
                        # 兜底:handle_request 内部已有 try/except,
                        # 走到这里说明是意外错误(比如 producer 故障)
                        logger.exception("handle_request 未捕获异常 offset=%s", msg.offset)
                await self._consumer.commit()

    # ---------------- 核心五步 ----------------

    async def handle_request(self, msg: Any) -> None:
        # Step 1: 解析 header
        headers = {k: v.decode() for k, v in (msg.headers or [])}
        reply_topic = headers.get("a2a-reply-topic")
        correlation_id = headers.get("a2a-correlation-id")
        method = headers.get("a2a-method")
        task_id = headers.get("a2a-task-id")

        # 必需 header 缺失:必须记错误并终止。
        # 绝对不能静默 return,否则客户端会一直等到超时。
        missing = [
            name
            for name, val in (
                ("a2a-reply-topic", reply_topic),
                ("a2a-correlation-id", correlation_id),
                ("a2a-method", method),
            )
            if not val
        ]
        if missing:
            logger.error(
                "缺少必需 header %s,无法路由,offset=%s 已跳过",
                ",".join(missing),
                msg.offset,
            )
            # 若还能拿到 reply topic,尽量回一个错误信封
            if reply_topic and correlation_id:
                await self._send_envelope(
                    reply_topic,
                    correlation_id,
                    A2AError(-32600, f"缺少必需 header: {','.join(missing)}").to_envelope(),
                )
            return

        # Step 2: 反序列化
        try:
            request = json.loads(msg.value)
            params = request.get("params", {})
        except (json.JSONDecodeError, AttributeError) as exc:
            logger.error("消息体解析失败 offset=%s err=%s", msg.offset, exc)
            await self._send_envelope(
                reply_topic, correlation_id, A2AError(-32700, f"JSON 解析失败: {exc}").to_envelope()
            )
            return

        # Step 3: 调度表查表
        handler = self._method_map.get(method)
        if handler is None:
            await self._send_envelope(
                reply_topic,
                correlation_id,
                A2AError(-32601, f"不支持的操作: {method}").to_envelope(),
            )
            return

        # Step 4 + 5: 执行并回传
        try:
            result = await handler(params)
            # 区分单值(协程返回值)和异步生成器
            if isinstance(result, AsyncIterator) or hasattr(result, "__aiter__"):
                await self._handle_stream_result(reply_topic, correlation_id, result)
            else:
                await self._handle_single_result(reply_topic, correlation_id, result, task_id)
        except A2AError as exc:
            await self._send_envelope(reply_topic, correlation_id, exc.to_envelope())
        except Exception as exc:  # noqa: BLE001
            logger.exception("处理请求失败 method=%s cid=%s", method, correlation_id)
            await self._send_envelope(
                reply_topic,
                correlation_id,
                A2AError(-32603, f"内部错误: {exc}").to_envelope(),
            )

    async def _handle_single_result(
        self,
        reply_topic: str,
        correlation_id: str,
        result: Any,
        task_id: str | None,
    ) -> None:
        """单值返回:一条 data 信封 + 一条 end_of_stream。

        即使是单值,也要发 end_of_stream,让客户端侧的消费逻辑统一。
        """
        envelope = {"type": "data", "payload": result}
        await self._send_envelope(reply_topic, correlation_id, envelope, task_id)
        await self._send_end_of_stream(reply_topic, correlation_id, task_id)

    async def _handle_stream_result(
        self,
        reply_topic: str,
        correlation_id: str,
        gen: AsyncIterator[Any],
    ) -> None:
        """异步生成器返回:逐个事件发送,最后补 end_of_stream。

        注意:如果没有这个显式的 end_of_stream,客户端 async for 永远不退出。
        """
        count = 0
        try:
            async for item in gen:
                await self._send_envelope(
                    reply_topic, correlation_id, {"type": "data", "payload": item}
                )
                count += 1
        except Exception as exc:  # noqa: BLE001
            logger.exception("流式处理中断 cid=%s", correlation_id)
            await self._send_envelope(
                reply_topic,
                correlation_id,
                A2AError(-32603, f"流式处理中断: {exc}").to_envelope(),
            )
            return  # 出错时不再发 end_of_stream,让错误信封成为流的终点
        finally:
            logger.info("流式完成 cid=%s events=%d", correlation_id, count)

        await self._send_end_of_stream(reply_topic, correlation_id)

    async def _send_envelope(
        self,
        reply_topic: str,
        correlation_id: str,
        envelope: dict[str, Any],
        task_id: str | None = None,
    ) -> None:
        assert self._producer is not None
        headers = [("a2a-correlation-id", correlation_id.encode()), ("content-type", b"application/a2a+json")]
        if task_id:
            headers.append(("a2a-task-id", task_id.encode()))
        await self._producer.send_and_wait(
            reply_topic,
            key=correlation_id.encode(),  # reply 用 correlationId 做 key
            value=json.dumps(envelope, ensure_ascii=False).encode(),
            headers=headers,
        )

    async def _send_end_of_stream(
        self, reply_topic: str, correlation_id: str, task_id: str | None = None
    ) -> None:
        await self._send_envelope(
            reply_topic, correlation_id, {"type": "control", "signal": "end_of_stream"}, task_id
        )

    # ---------------- 抽象操作实现(示例三个) ----------------

    async def _send_message(self, params: dict[str, Any]) -> dict[str, Any]:
        handler = self.task_handlers.get("send_message")
        if handler is None:
            raise A2AError(-32601, "该 Agent 未实现 send_message")
        return await handler(params)

    async def _send_streaming_message(self, params: dict[str, Any]) -> AsyncIterator[dict[str, Any]]:
        handler = self.task_handlers.get("send_streaming_message")
        if handler is None:
            raise A2AError(-32601, "该 Agent 未实现 send_streaming_message")
        return await handler(params)

    async def _get_task(self, params: dict[str, Any]) -> dict[str, Any]:
        handler = self.task_handlers.get("get_task")
        if handler is None:
            raise A2AError(-32601, "该 Agent 未实现 get_task")
        return await handler(params)

    async def _list_tasks(self, params: dict[str, Any]) -> dict[str, Any]:
        raise A2AError(-32601, "ListTasks 尚未实现")

    async def _cancel_task(self, params: dict[str, Any]) -> dict[str, Any]:
        raise A2AError(-32601, "CancelTask 尚未实现")

    async def _subscribe_to_task(self, params: dict[str, Any]) -> AsyncIterator[dict[str, Any]]:
        raise A2AError(-32601, "SubscribeToTask 尚未实现")

    async def _create_push_config(self, params: dict[str, Any]) -> dict[str, Any]:
        raise A2AError(-32601, "CreateTaskPushNotificationConfig 尚未实现")

    async def _get_push_config(self, params: dict[str, Any]) -> dict[str, Any]:
        raise A2AError(-32601, "GetTaskPushNotificationConfig 尚未实现")

    async def _list_push_configs(self, params: dict[str, Any]) -> dict[str, Any]:
        raise A2AError(-32601, "ListTaskPushNotificationConfigs 尚未实现")

    async def _delete_push_config(self, params: dict[str, Any]) -> dict[str, Any]:
        raise A2AError(-32601, "DeleteTaskPushNotificationConfig 尚未实现")

    async def _get_extended_agent_card(self, params: dict[str, Any]) -> dict[str, Any]:
        raise A2AError(-32601, "GetExtendedAgentCard 尚未实现")

7.3 统一异常兜底为什么是必须的

handle_requestexcept Exception 分支。这段代码如果去掉,会发生什么?

服务端在处理过程中抛异常 → run() 里的 except Exception 捕获 → 记日志 → 继续。客户端什么都没收到。 客户端的 wait_for(future, 30) 会一直等到 30 秒超时,然后报 A2ATimeoutError

这个错误的误导性在于:日志上看起来是「超时」,运维第一反应是「网络问题」或「服务端慢」,而真实原因是「服务端有个代码 bug 抛了异常」。这会浪费大量排查时间。

正确做法就是统一兜底成 error 信封 。客户端拿到 -32603 内部错误: xxx 立刻就知道是服务端的问题,而且错误信息里带了具体原因。

python 复制代码
# 错误对照:有无兜底的差异
#
# 无兜底:
#   客户端 30s 后 -> A2ATimeoutError: SendMessage 超时 cid=xxx
#   运维判断 -> 网络?服务端慢?加大超时?
#
# 有兜底:
#   客户端 0.2s 后 -> RuntimeError: 远端错误 -32603: 内部错误: division by zero
#   运维判断 -> 服务端代码有 bug,定位到具体位置

7.4 PoC 与生产的差距清单

这一节的内容必须认真对待。 上面的 KafkaHandler 参考的是 Google Codelabs 的社区实现 a2a-python-kafka,它是教学用的 PoC。直接拿去生产会出问题。

能力 PoC 现状 生产要求 后果
任务存储 InMemoryTaskStore,进程重启即丢 持久化(数据库 / compact topic) 重启后任务状态全部丢失,客户端 GetTask 报 404
鉴权 Kafka SASL + topic ACL + 应用层 tenant 校验 任何客户端可读写任何 topic
幂等 基于 messageId / taskId 去重 重复投递导致任务被执行多次
背压 有界队列 + max.poll.records 调优 消费慢时内存无限增长直至 OOM
顺序保证 没显式设计 key 按 contextId / taskId 精确分区 状态事件乱序,状态机崩溃
超时与取消 任务级超时 + CancelTask 真正生效 长任务挂死无人清理
错误恢复 异常直接吞 重试分级 + DLQ + 告警 消息静默丢失
可观测性 只有 print OTel trace + 指标 + 结构化日志 线上问题无法定位
多租户 tenant 维度隔离 topic 与 ACL 租户数据串台
优雅停机 处理完在途消息再退出 停机时消息丢失或重复
Agent Card 校验 严格校验必填字段 错误配置到运行时才暴露
流控 按 tenant / agent 限流 单个恶意客户端打垮整集群

结论 :PoC 可以帮你理解映射关系(这正是它的价值),但上生产必须补齐上面这 12 项。篇幅最值得投入的三项是:持久化任务存储、幂等消费、背压控制。 其余可以逐步补齐。


八、流式与推送:把 SSE 换成 topic

这一节要解决的问题是:A2A 的流式语义(长连接推事件)在 Kafka 这个没有「连接」概念的世界里,怎么保住。

8.1 本质差异

维度 SSE Kafka
传输单元 一条 TCP 连接上推多个事件 多条独立消息
流的身份 由连接本身标识 由 correlationId 在应用层标识
流结束 连接关闭(可被观测) 必须显式发 end_of_stream
断线恢复 Last-Event-ID 重连补发 靠 offset,但 correlationId 跨重启就断了
一对多 每连接各推一份 多个 consumer group 天然广播
服务器资源 每个长连接占一个 fd + 内存 无状态,消费者数可弹性伸缩

核心差异是最后一行 :SSE 的「流」是一个有状态的连接 ,服务端每多一个订阅者就多一个连接;Kafka 的「订阅」是无状态的读取位置,服务端完全不知道有多少人在读。

这个差异带来一个反直觉的结论:SSE 的「一对多广播」是要付出成本的(N 个连接),而 Kafka 的广播几乎是免费的(N 个 group 各自读各自的 offset,broker 侧无额外状态)。 所以在「一个大任务的结果要被 20 个系统同时消费」这个场景下,Kafka 明显优于 SSE。

但反过来,SSE 在「低延迟单播」上明显优于 Kafka ,因为 Kafka 有 linger.ms + 批量拉取的固有延迟。

8.2 流式的客户端实现

前面 6.4 节的 send_message_streaming 已经给出了完整实现,这里补充讲清 async for 的退出路径:

python 复制代码
import asyncio


async def consume_with_safety(transport, request_topic, context_id, hard_deadline=300.0):
    """展示流式消费的四条退出路径,全部必须走到 finally 清理。"""
    events: list[dict] = []
    try:
        # 整体硬超时:即使每一条消息都在 timeout 内到达,
        # 流本身也不应该无限期存在(防止服务端 bug 导致流永不结束)
        async with asyncio.timeout(hard_deadline):
            async for payload in transport.send_message_streaming(
                request_topic=request_topic,
                params={"message": {"parts": [{"text": "处理这个任务"}]}},
                context_id=context_id,
                timeout=30.0,
            ):
                events.append(payload)

                # 路径 1:业务上认为可以提前结束,主动 break,
                #         finally 会释放 streaming_queues 条目
                if should_stop(payload):
                    break
    except TimeoutError:
        # 路径 2:整体硬超时(Python 3.11+ 的 asyncio.timeout 抛 TimeoutError)
        print(f"流整体超时,已收到 {len(events)} 个事件")
    except RuntimeError as exc:
        # 路径 3:服务端返回 error 信封
        print(f"服务端错误: {exc}")
    # 路径 4:正常收到 end_of_stream,async for 自然退出
    finally:
        # 无论走哪条路径,corr.release_stream 都在 send_message_streaming 的
        # finally 里执行过了。这里做的是业务侧资源清理。
        print(f"流结束,共 {len(events)} 个事件")


def should_stop(payload: dict) -> bool:
    """业务层提前终止判断。例如已经拿到足够的信息。"""
    return False

8.3 推送通知(Push Notification)的 Kafka 化

A2A 的 Push Notification 是「服务端主动 POST 到客户端注册的 webhook」。映射到 Kafka,webhook URL 这个位置就变成客户端注册的 reply topic

注册流程在语义上完全等价:

python 复制代码
import json
import uuid


def build_push_notification_config(
    reply_topic: str,
    task_id: str,
    context_id: str,
    client_id: str,
) -> dict:
    """构造 CreateTaskPushNotificationConfig 请求的 params。

    对照 A2A 的 webhook 版本:
      HTTP 版:  {"url": "https://client.example.com/a2a/webhook",
                 "token": "shared-secret", "authentication": {...}}
      Kafka 版: {"url": "kafka://a2a.replies.client-001", ...}

    用 kafka:// scheme 而不是 http(s):// 是一个务实的做法:
    客户端和服务端可以复用同一套 URL 解析逻辑,
    只按 scheme 分派到不同的投递实现。
    """
    return {
        "taskId": task_id,
        "pushNotificationConfig": {
            # 位置语义等价于 webhook url
            "url": f"kafka://{reply_topic}",
            # 替代 HTTP 的 Bearer token:Kafka 侧靠 SASL 与 topic ACL 鉴权,
            # 这里保留一个共享密钥用于应用层校验(比如放进 message header)
            "token": str(uuid.uuid4()),
            "authentication": {
                "schemes": ["KAFKA_SASL_SCRAM"],
                # webhook 版的 credentials 这里改为 Kafka 的 principal
                "credentials": f"client-{client_id}",
            },
            # 自定义扩展:声明投递语义
            "metadata": {
                "deliverySemantics": "at-least-once",
                "contentType": "application/a2a+json",
                "expectAck": True,
            },
        },
    }

服务端侧的一对多广播:服务端需要维护「taskId → reply topic 列表」,在状态变更时向所有注册方投递。

python 复制代码
from __future__ import annotations

import asyncio
import json
import logging
from collections import defaultdict

logger = logging.getLogger(__name__)


class PushNotificationDispatcher:
    """把任务事件广播到所有注册的 reply topic。

    与 HTTP webhook 的对照:
      - webhook 版:服务端维护 taskId -> [url],逐个 POST,收集 2xx
      - Kafka 版:服务端维护 taskId -> [reply_topic],逐个 produce
    两者都是 at-least-once,都需要客户端幂等。
    """

    def __init__(self, producer) -> None:
        self._producer = producer
        self._registry: dict[str, set[str]] = defaultdict(set)
        self._lock = asyncio.Lock()

    async def register(self, task_id: str, reply_topic: str) -> None:
        async with self._lock:
            self._registry[task_id].add(reply_topic)
        logger.info("注册推送 task=%s topic=%s", task_id, reply_topic)

    async def unregister(self, task_id: str, reply_topic: str) -> None:
        async with self._lock:
            self._registry.get(task_id, set()).discard(reply_topic)

    async def broadcast(self, task_id: str, context_id: str, payload: dict) -> int:
        """向所有注册方投递,返回成功数。

        注意:这里用 gather 并发投递,但要设置 return_exceptions=True,
        否则一个 topic 的失败会导致其他 topic 也收不到。
        这是 at-least-once 语义下必须的:部分失败要能独立重试。
        """
        async with self._lock:
            targets = list(self._registry.get(task_id, ()))

        if not targets:
            return 0

        envelope = json.dumps({"type": "data", "payload": payload}, ensure_ascii=False).encode()

        async def _send(topic: str) -> bool:
            try:
                await self._producer.send_and_wait(
                    topic,
                    key=context_id.encode(),  # 保证同一会话内推送有序
                    value=envelope,
                    headers=[
                        ("a2a-task-id", task_id.encode()),
                        ("a2a-context-id", context_id.encode()),
                        ("content-type", b"application/a2a+json"),
                        ("a2a-notification-kind", b"push"),
                    ],
                )
                return True
            except Exception:
                logger.exception("推送失败 task=%s topic=%s", task_id, topic)
                return False

        results = await asyncio.gather(*(_send(t) for t in targets), return_exceptions=True)
        ok = sum(1 for r in results if r is True)
        logger.info("推送完成 task=%s 成功 %d/%d", task_id, ok, len(targets))
        return ok

8.4 三种下发方式对比

维度 SSE Webhook Kafka topic
连接语义 长连接,服务端持有状态 无连接,每次 POST 独立 无连接,消息发到 topic
断线恢复 Last-Event-ID 重连,可补发 服务端重试,客户端幂等 offset 可回退重放,但 correlationId 跨重启丢失
多消费者 每个订阅者一条连接 每个 webhook 一个端点 消费者组天然广播,成本近零
顺序保证 连接内严格有序 无保证(并发 POST) 单分区内有序,需正确设 key
延迟 最低(无缓冲) 中(一次网络往返) 较高(linger + 批量拉取)
运维成本 低(无需中间件) 中(要维护回调端点、重试、签名) 高(集群 + ACL + 容量)
客户端要求 必须保持连接 必须暴露公网端点 必须能连 broker
适用场景 交互式、低延迟、单消费者 跨组织、无法连 broker、低频通知 大规模、多消费者、需回放与审计

选型判断

  • 交互式场景(用户等结果)→ SSE,不要用 Kafka
  • 跨组织(对方不接受 Kafka)→ webhook
  • 内部大规模、需要审计回放、多系统同时消费 → Kafka topic
  • 任务耗时很长(小时级)→ Kafka 或 webhook,SSE 长连接撑不住

九、可靠性工程:把 A2A 的 at-least-once 兜住

这一节要解决的问题是:A2A 规范已经承诺了至少一次投递,Kafka 也是至少一次,两层叠加意味着重复是必然的。怎么让业务正确?

9.1 幂等生产

properties 复制代码
# producer 幂等配置
enable.idempotence=true
acks=all
max.in.flight.requests.per.connection=5
retries=2147483647

必须说清楚 enable.idempotence=true 的边界

它保证的是「单分区 + 单 producer 会话内,同一批消息不会因为 producer 内部重试而被写入多次」。它给每条消息分配 PID(Producer ID)+ 序列号,broker 侧去重。

不保证的是:

  • producer 会话重启后(PID 变了)重复发送 → 还是会重复
  • 应用层重试(用户点了两次提交)→ 还是会重复
  • 跨分区的事务性 → 需要 transactional.id
  • 端到端(Kafka → 数据库/LLM API)→ 完全管不着

所以 enable.idempotence=true必要条件而非充分条件。要真正确保不重复,还需要下游幂等。

9.2 Outbox 模式

它解决的具体问题:模式 B 的双写不一致(见 3.2 节)。当你的 Agent 既要写业务数据库、又要发 Kafka 时,这两个操作不在一个事务里。

核心思路:把「要发的事件」当成业务数据的一部分,写在同一个数据库事务里;再由独立进程把它搬到 Kafka。

表结构

sql 复制代码
-- outbox 表:与业务表在同一个库、同一个事务中写入
CREATE TABLE a2a_outbox (
    id              BIGSERIAL PRIMARY KEY,
    aggregate_type  VARCHAR(64)  NOT NULL,        -- 如 'a2a_task'
    aggregate_id    VARCHAR(128) NOT NULL,        -- 如 taskId
    event_type      VARCHAR(64)  NOT NULL,        -- 如 'TaskStatusUpdate'
    topic           VARCHAR(255) NOT NULL,        -- 目标 Kafka topic
    msg_key         VARCHAR(255),                 -- 分区键,通常 contextId 或 taskId
    headers         JSONB        NOT NULL DEFAULT '{}'::jsonb,
    payload         JSONB        NOT NULL,
    created_at      TIMESTAMPTZ  NOT NULL DEFAULT now(),
    processed_at    TIMESTAMPTZ,                  -- NULL 表示待发送
    retry_count     INT          NOT NULL DEFAULT 0,
    last_error      TEXT
);

-- 关键索引:relay 进程按 processed_at IS NULL 且按 id 顺序扫描
CREATE INDEX idx_a2a_outbox_pending
    ON a2a_outbox (id)
    WHERE processed_at IS NULL;

-- 防止同一事件被重复写入(业务侧配合业务唯一键)
CREATE UNIQUE INDEX idx_a2a_outbox_dedup
    ON a2a_outbox (aggregate_id, event_type, id);

-- 已处理记录的清理(保留 7 天便于排查)
CREATE INDEX idx_a2a_outbox_processed_at ON a2a_outbox (processed_at);

业务侧写入(Python,asyncpg)

python 复制代码
from __future__ import annotations

import json
import uuid
from datetime import datetime, timezone

import asyncpg


async def complete_task_with_event(
    pool: asyncpg.Pool,
    task_id: str,
    context_id: str,
    artifact: dict,
) -> None:
    """在一个数据库事务内:更新任务状态 + 写 outbox。

    这是 Outbox 模式的核心:
    「业务状态变更」和「待发事件」在同一个事务里,
    要么都成功、要么都失败,不存在双写不一致。
    """
    async with pool.acquire() as conn:
        async with conn.transaction():
            # 1. 更新业务表
            await conn.execute(
                """
                UPDATE a2a_tasks
                   SET state = 'TASK_STATE_COMPLETED',
                       artifact = $2::jsonb,
                       updated_at = now()
                 WHERE task_id = $1
                """,
                task_id,
                json.dumps(artifact),
            )

            # 2. 在同一个事务里写 outbox
            await conn.execute(
                """
                INSERT INTO a2a_outbox
                    (aggregate_type, aggregate_id, event_type, topic,
                     msg_key, headers, payload)
                VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb)
                """,
                "a2a_task",
                task_id,
                "TaskStatusUpdate",
                f"a2a.status.{task_id}",
                context_id,
                json.dumps(
                    {
                        "a2a-protocol-version": "1.0",
                        "content-type": "application/a2a+json",
                        "a2a-task-id": task_id,
                        "a2a-context-id": context_id,
                    }
                ),
                json.dumps(
                    {
                        "type": "data",
                        "payload": {
                            "statusUpdate": {
                                "taskId": task_id,
                                "contextId": context_id,
                                "status": {
                                    "state": "TASK_STATE_COMPLETED",
                                    "timestamp": datetime.now(timezone.utc).isoformat(),
                                },
                            }
                        },
                    }
                ),
            )

Relay 进程(独立部署,Python)

python 复制代码
from __future__ import annotations

import asyncio
import json
import logging

import asyncpg
from aiokafka import AIOKafkaProducer

logger = logging.getLogger(__name__)

BATCH_SIZE = 200
POLL_INTERVAL = 0.5


async def run_relay(pool: asyncpg.Pool, producer: AIOKafkaProducer, stop: asyncio.Event) -> None:
    """从 outbox 读待发事件,投递到 Kafka,然后标记 processed。

    关键设计:先发 Kafka 再标记 processed。
    如果发完 Kafka、标记之前崩溃,重启后会重发 ------ 这是 at-least-once。
    所以下游消费者必须幂等(见 9.3)。
    反过来先标记再发,则可能丢消息,不可接受。
    """
    while not stop.is_set():
        try:
            async with pool.acquire() as conn:
                rows = await conn.fetch(
                    """
                    SELECT id, topic, msg_key, headers, payload, retry_count
                      FROM a2a_outbox
                     WHERE processed_at IS NULL
                       AND retry_count < 10
                     ORDER BY id
                     LIMIT $1
                       FOR UPDATE SKIP LOCKED
                    """,
                    BATCH_SIZE,
                )

            if not rows:
                await asyncio.sleep(POLL_INTERVAL)
                continue

            for row in rows:
                headers = [
                    (k, str(v).encode()) for k, v in json.loads(row["headers"]).items()
                ]
                try:
                    await producer.send_and_wait(
                        row["topic"],
                        key=(row["msg_key"] or "").encode(),
                        value=json.dumps(row["payload"], ensure_ascii=False).encode(),
                        headers=headers,
                    )
                except Exception as exc:  # noqa: BLE001
                    logger.exception("relay 投递失败 outbox_id=%s", row["id"])
                    async with pool.acquire() as conn:
                        await conn.execute(
                            """
                            UPDATE a2a_outbox
                               SET retry_count = retry_count + 1,
                                   last_error = $2
                             WHERE id = $1
                            """,
                            row["id"],
                            str(exc)[:2000],
                        )
                    continue

                async with pool.acquire() as conn:
                    await conn.execute(
                        "UPDATE a2a_outbox SET processed_at = now() WHERE id = $1",
                        row["id"],
                    )

            logger.info("relay 批次完成 count=%d", len(rows))

        except asyncio.CancelledError:
            raise
        except Exception:
            logger.exception("relay 循环异常,1 秒后重试")
            await asyncio.sleep(1.0)

运维要点

  • relay 可以多实例水平扩展,靠 FOR UPDATE SKIP LOCKED 避免重复读取
  • aggregate_id + event_type 的顺序由 ORDER BY id 保证,单实例内有序
  • 多实例时不同分区可能乱序,所以如果顺序敏感,应让 relay 单实例或按 key 分片
  • 监控 SELECT count(*) FROM a2a_outbox WHERE processed_at IS NULL 的积压量,这是最重要的告警指标

9.3 消费者幂等

关键认知:Kafka 的 exactly-once 只在 Kafka → Kafka 链路成立。 一旦消费者要写外部系统(数据库、LLM API、第三方服务),exactly-once 就失效了,必须自己实现幂等。

去重表

sql 复制代码
CREATE TABLE a2a_processed_messages (
    task_id        VARCHAR(128) NOT NULL,
    message_id     VARCHAR(128) NOT NULL,
    consumer_group VARCHAR(255) NOT NULL,
    processed_at   TIMESTAMPTZ  NOT NULL DEFAULT now(),
    result_hash    CHAR(64),                      -- 可选:结果摘要,用于校验
    PRIMARY KEY (task_id, message_id, consumer_group)
);

-- 清理策略:保留 7 天。超过 7 天理论上不会再有重复(Kafka 保留期也到了)
CREATE INDEX idx_a2a_processed_at ON a2a_processed_messages (processed_at);

PRIMARY KEY (task_id, message_id, consumer_group) 的组成理由:

  • task_id + message_id:业务的唯一性单位。同一条消息在同一任务下只处理一次。
  • consumer_group这一项不能省。因为不同的消费者组本来就应该各自处理一遍(这是 Kafka 的语义)。如果只有两列做主键,第二个消费者组会被误判为重复。

消费者幂等实现

python 复制代码
from __future__ import annotations

import json
import logging

import asyncpg
from aiokafka import AIOKafkaConsumer

logger = logging.getLogger(__name__)


async def handle_with_idempotency(
    pool: asyncpg.Pool,
    consumer: AIOKafkaConsumer,
    handler,
) -> None:
    """带幂等保证的消费循环。

    流程:解析 -> 开启事务 -> 尝试插入去重记录 -> 执行业务 -> 提交事务 -> 提交 offset

    任何一步失败都回滚并重试,业务副作用只在去重记录插入成功后发生。
    """
    async for msg in consumer:
        headers = {k: v.decode() for k, v in (msg.headers or [])}
        task_id = headers.get("a2a-task-id")
        message_id = headers.get("a2a-correlation-id") or str(msg.offset)

        if not task_id:
            logger.warning("缺少 a2a-task-id offset=%s 跳过", msg.offset)
            await consumer.commit()
            continue

        payload = json.loads(msg.value)

        async with pool.acquire() as conn:
            async with conn.transaction():
                inserted = await conn.fetchval(
                    """
                    INSERT INTO a2a_processed_messages
                        (task_id, message_id, consumer_group)
                    VALUES ($1, $2, $3)
                    ON CONFLICT (task_id, message_id, consumer_group) DO NOTHING
                    RETURNING 1
                    """,
                    task_id,
                    message_id,
                    consumer._group_id,
                )

                if inserted is None:
                    # 已处理过,直接跳过。
                    # 注意:这里仍然要 commit offset,否则每次重启都会重新读这条消息。
                    logger.info("重复消息已跳过 task=%s msg=%s", task_id, message_id)
                    await consumer.commit()
                    continue

                # 第一次处理:在同一个事务里执行真正的业务逻辑
                await handler(payload, task_id)

        await consumer.commit()

这里有一个微妙的取舍必须说清楚 :上面的实现把「去重记录插入」和「业务逻辑执行」放在同一个数据库事务里,前提是业务逻辑只写同一个数据库。如果业务逻辑要调 LLM API(外部系统),那它不在事务范围内,就无法保证原子性。

这是分布式系统的本质限制,无法绕过。可行的缓解方式只有两种:

  1. 业务逻辑设计成幂等(比如 LLM 调用用相同输入 + 固定 seed,或者把结果缓存起来,重复调用直接返回缓存)
  2. 两阶段 :先去重表插入状态为 IN_PROGRESS 的记录,执行外部调用,成功后更新为 DONE。崩溃后看到 IN_PROGRESS 记录,需要人工或补偿逻辑判断是否已执行。

第二种方式会引入「不确定状态」,需要补偿逻辑,复杂度显著上升。所以更好的做法是第一种:把业务逻辑设计成天然幂等。

9.4 DLQ 与重试

重试 topic 分级:不要用一个 topic 做所有重试,而是按延迟分级。

topic 延迟 适用错误 消费者行为
a2a.retry.5s 5 秒 瞬时故障(网络抖动、限流 429) 立即重新消费
a2a.retry.1m 1 分钟 需等待恢复(服务重启、临时不可用) 延迟后再试
a2a.dlq.<agent-id> 不可恢复(数据格式错误、业务拒绝) 人工介入或离线重放

Kafka 4.2 的 Kafka Streams 内建 DLQ(KIP-1034)

这是 4.2 才有的能力,值得单独说。它让 Streams 拓扑在处理失败时能把消息路由到 DLQ topic,而不是让整个 topology 崩溃。

java 复制代码
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.streams.KafkaStreams;
import org.apache.kafka.streams.StreamsBuilder;
import org.apache.kafka.streams.StreamsConfig;
import org.apache.kafka.streams.errors.DeserializationExceptionHandler;
import org.apache.kafka.streams.errors.ProcessingExceptionHandler;
import org.apache.kafka.streams.errors.ProcessingExceptionHandler.ProcessingHandlerResponse;
import org.apache.kafka.streams.processor.api.ProcessorContext;
import org.apache.kafka.streams.StreamsUncaughtExceptionHandler;

import java.util.Properties;

public class A2AStreamsTopology {

    /**
     * 反序列化异常处理器:Kafka 4.2 的 KIP-1034 支持把坏消息投递到 DLQ。
     *
     * 为什么需要它:A2A 消息体来自多个 Agent(可能是不同版本),
     * 一旦某个 Agent 升级后改了字段类型,反序列化会抛异常。
     * 如果没有 DLQ,整个 Streams 应用会停机。
     */
    public static class A2ADeserializationExceptionHandler implements DeserializationExceptionHandler {

        private final String dlqTopic;

        public A2ADeserializationExceptionHandler(String dlqTopic) {
            this.dlqTopic = dlqTopic;
        }

        @Override
        public DeserializationHandlerResponse handle(
                ProcessorContext context,
                ConsumerRecord<byte[], byte[]> record,
                Exception exception) {

            // 关键:DLQ 必须保留原始 header,否则无法重放
            System.err.printf(
                    "反序列化失败 topic=%s partition=%d offset=%d headers=%s err=%s%n",
                    record.topic(), record.partition(), record.offset(),
                    record.headers(), exception.getMessage());

            // 生产代码:这里应把 record 原样(含 headers)写入 dlqTopic
            // 并额外附加一个 header 说明失败原因,例如 a2a-dlq-reason
            //
            // dlqProducer.send(new ProducerRecord<>(
            //         dlqTopic, null, record.key(), record.value(), record.headers()));

            // 返回 CONTINUE 表示跳过这条消息继续处理,
            // 返回 FAIL 则让整个应用停止(生产环境应选 CONTINUE + 告警)
            return DeserializationHandlerResponse.CONTINUE;
        }
    }

    /**
     * 处理异常处理器:业务逻辑抛异常时的兜底。
     */
    public static class A2AProcessingExceptionHandler implements ProcessingExceptionHandler {

        @Override
        public ProcessingHandlerResponse handle(ProcessorContext context, Object record, Exception exception) {
            System.err.printf("处理失败 record=%s err=%s%n", record, exception.getMessage());

            // 判断是否可重试:
            //   - 可重试(如 LLM 429、数据库连接池耗尽)-> RETRY
            //   - 不可重试(如 JSON schema 不匹配)-> 投递 DLQ 后 CONTINUE
            if (isRetryable(exception)) {
                return ProcessingHandlerResponse.RETRY;
            }
            return ProcessingHandlerResponse.CONTINUE;
        }

        private boolean isRetryable(Exception e) {
            String msg = e.getMessage() == null ? "" : e.getMessage();
            return msg.contains("429") || msg.contains("timeout") || msg.contains("Connection");
        }
    }

    public static KafkaStreams buildTopology(String bootstrapServers) {
        StreamsBuilder builder = new StreamsBuilder();

        builder.<String, String>stream("a2a.requests.example-agent")
               .filter((key, value) -> value != null && value.contains("SendMessage"))
               .mapValues(A2AStreamsTopology::processA2AMessage)
               .to("a2a.status.example-agent");

        Properties props = new Properties();
        props.put(StreamsConfig.APPLICATION_ID_CONFIG, "a2a-router");
        props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
        props.put(StreamsConfig.PROCESSING_GUARANTEE_CONFIG, StreamsConfig.EXACTLY_ONCE_V2);
        // KIP-1034:注册 DLQ 处理器
        props.put(
                StreamsConfig.DESERIALIZATION_EXCEPTION_HANDLER_CLASS_CONFIG,
                A2ADeserializationExceptionHandler.class.getName());
        props.put(
                StreamsConfig.PROCESSING_EXCEPTION_HANDLER_CLASS_CONFIG,
                A2AProcessingExceptionHandler.class.getName());

        KafkaStreams streams = new KafkaStreams(builder.build(), props);
        streams.setUncaughtExceptionHandler(throwable -> {
            // 未捕获异常:生产环境应记录并决定是关闭还是替换线程
            System.err.println("Streams 未捕获异常: " + throwable.getMessage());
            return KafkaStreams.UncaughtExceptionHandlerResponse.SHUTDOWN_APPLICATION;
        });
        return streams;
    }

    private static String processA2AMessage(String value) {
        // 实际业务逻辑:解析 A2A 消息并做路由
        return value;
    }
}

必须说清楚:Kafka Share Groups 目前没有内建 DLQ。

这是一个容易踩的坑。Share Groups 提供了 Reject 这个 ack 动作,可以让 broker 不再投递这条消息,但它不会自动把消息写到 DLQ topic。你要自己实现。

实现方式:在 Reject 之前,自己用普通 producer 把消息发到 DLQ topic,然后再 reject。

java 复制代码
import org.apache.kafka.clients.consumer.AcknowledgeType;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.KafkaShareConsumer;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.header.internals.RecordHeader;

import java.time.Duration;
import java.util.Collections;
import java.util.Properties;

public class A2ATaskWorker {

    private final KafkaShareConsumer<String, String> shareConsumer;
    private final KafkaProducer<String, String> dlqProducer;
    private final String dlqTopic;
    private final int deliveryCountLimit;

    public A2ATaskWorker(
            KafkaShareConsumer<String, String> shareConsumer,
            KafkaProducer<String, String> dlqProducer,
            String dlqTopic,
            int deliveryCountLimit) {
        this.shareConsumer = shareConsumer;
        this.dlqProducer = dlqProducer;
        this.dlqTopic = dlqTopic;
        this.deliveryCountLimit = deliveryCountLimit;
    }

    /**
     * Share Group 消费循环,带手写 DLQ。
     *
     * 三种 ack 语义:
     *   Accept  - 处理成功,broker 移除该消息
     *   Release - 处理失败但可重试,broker 重新投递给其他消费者
     *   Reject  - 不可重试,broker 移除该消息(但不写 DLQ,需自己写)
     *   RENEW   - Kafka 4.2 新增(KIP-1222),延长 acquisition lock
     */
    public void run() {
        shareConsumer.subscribe(Collections.singletonList("a2a.requests.example-agent"));

        while (true) {
            var records = shareConsumer.poll(Duration.ofMillis(500));

            for (ConsumerRecord<String, String> record : records) {
                int deliveryCount = record.deliveryCount();

                try {
                    processTask(record);

                    // 成功:Accept
                    shareConsumer.acknowledge(record, AcknowledgeType.ACCEPT);

                } catch (RetryableException e) {
                    if (deliveryCount >= deliveryCountLimit) {
                        // 超过投递次数上限:转入 DLQ。
                        // 关键:DLQ 必须带原始 header,否则无法重放
                        sendToDlq(record, "超过投递次数上限 " + deliveryCount + " 原因 " + e.getMessage());
                        shareConsumer.acknowledge(record, AcknowledgeType.REJECT);
                    } else {
                        // 仍可重试:Release,broker 稍后重新投递
                        sendToRetry(record, e);
                        shareConsumer.acknowledge(record, AcknowledgeType.RELEASE);
                    }

                } catch (NonRetryableException e) {
                    // 不可重试:直接 DLQ + Reject
                    sendToDlq(record, "不可重试 " + e.getMessage());
                    shareConsumer.acknowledge(record, AcknowledgeType.REJECT);

                } catch (Exception e) {
                    // 未知异常:保守起见当作可重试
                    sendToRetry(record, e);
                    shareConsumer.acknowledge(record, AcknowledgeType.RELEASE);
                }
            }
        }
    }

    /**
     * 长任务必须周期性 RENEW,否则会超过 acquisition lock(默认 30 秒)被 broker 回收,
     * 导致同一条消息被另一个消费者同时处理 ------ 这是 Share Group 最危险的坑。
     */
    public void processLongRunningTask(ConsumerRecord<String, String> record) {
        long start = System.currentTimeMillis();

        while (!isTaskDone(record)) {
            doChunkOfWork(record);

            // 每 10 秒 RENEW 一次,锁时长 30 秒,留足安全余量
            if (System.currentTimeMillis() - start > 10_000) {
                // Kafka 4.2 KIP-1222:延长当前记录的 acquisition lock
                shareConsumer.acknowledge(record, AcknowledgeType.RENEW);
                start = System.currentTimeMillis();
            }
        }

        shareConsumer.acknowledge(record, AcknowledgeType.ACCEPT);
    }

    private void sendToDlq(ConsumerRecord<String, String> record, String reason) {
        // 原样复制 headers,并追加失败原因
        var headers = record.headers();
        headers.add(new RecordHeader("a2a-dlq-reason", reason.getBytes()));
        headers.add(new RecordHeader("a2a-dlq-original-topic", record.topic().getBytes()));
        headers.add(new RecordHeader("a2a-dlq-original-offset",
                String.valueOf(record.offset()).getBytes()));
        headers.add(new RecordHeader("a2a-dlq-delivery-count",
                String.valueOf(record.deliveryCount()).getBytes()));

        dlqProducer.send(new ProducerRecord<>(dlqTopic, null, record.key(), record.value(), headers));
    }

    private void sendToRetry(ConsumerRecord<String, String> record, Exception e) {
        dlqProducer.send(new ProducerRecord<>("a2a.retry.5s", null, record.key(), record.value(),
                record.headers()));
    }

    private boolean isTaskDone(ConsumerRecord<String, String> record) { return true; }

    private void doChunkOfWork(ConsumerRecord<String, String> record) { /* 分块处理 */ }

    private void processTask(ConsumerRecord<String, String> record) { /* 处理逻辑 */ }

    static class RetryableException extends RuntimeException {
        public RetryableException(String m) { super(m); }
    }

    static class NonRetryableException extends RuntimeException {
        public NonRetryableException(String m) { super(m); }
    }
}

Share Group 相关配置

properties 复制代码
# broker 侧(server.properties)
group.coordinator.rebalance.protocols=classic,consumer,share
group.share.delivery.count.limit=5
group.share.record.lock.duration.ms=30000
group.share.max.size=200

# 客户端侧
group.id=a2a-task-workers
group.instance.id=worker-001

9.5 Share Groups 做任务队列

Share Groups(KIP-932)是 Kafka 4.x 最重要的新特性,它把 Kafka 从「发布订阅 + 分区独占」扩展到了「工作队列」模型。

它解决的具体问题:头部阻塞(Head-of-line Blocking)。

传统 consumer group 下,一个分区只能被组内一个消费者消费,且必须按顺序消费。假设一个分区里有 100 条任务消息,第 1 条是个耗时 10 分钟的 LLM 调用,那么后面 99 条都要等 10 分钟。这就是头部阻塞。

而 Share Group 下,同一个分区的消息可以被多个消费者并行处理,每条消息独立 ack。第 1 条慢消息不会阻塞后面的消息。

代码实现(Python 侧目前 aiokafka 对 Share Consumer 支持有限,这里给 Java 的官方 client):

java 复制代码
import org.apache.kafka.clients.consumer.AcknowledgeType;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.KafkaShareConsumer;

import java.time.Duration;
import java.util.Collections;
import java.util.Properties;

public class ShareGroupTaskConsumer {

    public static KafkaShareConsumer<String, String> createShareConsumer(String bootstrapServers) {
        Properties props = new Properties();
        props.put("bootstrap.servers", bootstrapServers);
        props.put("group.id", "a2a-task-workers");
        props.put("key.deserializer",
                "org.apache.kafka.common.serialization.StringDeserializer");
        props.put("value.deserializer",
                "org.apache.kafka.common.serialization.StringDeserializer");

        // 关键:必须显式选择 share 重平衡协议。
        // 如果不设置,client 会按 group.consumer 的默认协议协商,
        // 可能被判定为传统 consumer group。
        props.put("group.protocol", "share");

        // Share Group 只支持 at-least-once,不要设 enable.auto.commit
        // 这里的 ack 是显式调用 acknowledge() 的
        props.put("max.poll.records", "20");

        return new KafkaShareConsumer<>(props);
    }

    /**
     * 并行处理 A2A 任务的 Share Consumer。
     *
     * 适用:无状态、可并行、单任务耗时不可预测的 Agent 任务
     *      (批量文档翻译、图片处理、数据抽取)
     * 不适用:有状态的多轮 Task 会话(必须用传统 consumer group 按 contextId 分区)
     */
    public static void run(String bootstrapServers) {
        try (KafkaShareConsumer<String, String> consumer = createShareConsumer(bootstrapServers)) {
            consumer.subscribe(Collections.singletonList("a2a.requests.example-agent"));

            while (true) {
                var records = consumer.poll(Duration.ofMillis(500));

                for (ConsumerRecord<String, String> record : records) {
                    try {
                        String taskResult = executeAgentTask(record.value());
                        System.out.printf("任务完成 key=%s result=%s%n", record.key(), taskResult);

                        // Accept:处理成功,broker 移除这条消息
                        consumer.acknowledge(record, AcknowledgeType.ACCEPT);

                    } catch (TransientException e) {
                        // Release:可重试,broker 重新投递给其他消费者
                        // 注意:本分区的其他消息不受影响,这正是 Share Group 的价值
                        consumer.acknowledge(record, AcknowledgeType.RELEASE);

                    } catch (PermanentException e) {
                        // Reject:不可重试,broker 移除(DLQ 需自己写,见 9.4)
                        consumer.acknowledge(record, AcknowledgeType.REJECT);
                    }
                }
            }
        }
    }

    private static String executeAgentTask(String payload) { return "ok"; }

    static class TransientException extends RuntimeException {
        public TransientException(String m) { super(m); }
    }

    static class PermanentException extends RuntimeException {
        public PermanentException(String m) { super(m); }
    }
}

Share Groups 的限制(必须全部说清楚,这些是硬限制)

限制 说明 影响
无顺序保证 同一分区的消息可被乱序处理 有状态 Task 会话绝对不能用
只支持 at-least-once 不支持 exactly-once 必须自己幂等
无内建 DLQ Reject 只是丢弃,不写 DLQ 必须手写 DLQ 逻辑
默认 acquisition lock 30 秒 超时未 ack 会重新投递 长任务必须周期性 RENEW(4.2 新增)
消费者上限默认 200 group.share.max.size 超大规模需调参
不支持 group.instance.id 静态成员 与经典消费者不同 滚动重启需重新分配

选型对照表

维度 Consumer Group Share Group
分区分配 独占,一个分区一个消费者 共享,同分区多消费者并行
顺序保证 分区内严格有序 无保证
投递语义 at-least-once / exactly-once 仅 at-least-once
头部阻塞 存在(慢消息阻塞分区) 不存在
消费者数上限 受分区数限制(超出空闲) 可远超分区数(默认上限 200)
ack 粒度 offset 提交(批次) 单条消息 ack
DLQ 无内建,但有成熟模式 无内建,需手写
长任务支持 max.poll.interval.ms 调大 acquisition lock + RENEW
A2A 适用场景 有状态多轮 Task 会话(按 contextId 分区) 无状态任务分发(翻译、图片处理、抽取)

最终结论(这是本节最重要的一句话)

Share Groups 适合「无状态、可并行、单个任务耗时不可预测」的 Agent 任务;有状态的多轮 Task 会话仍然必须用传统 consumer group 按 contextId 分区。

原因很简单:一个多轮会话的 task 状态机是顺序敏感的(SUBMITTED → WORKING → COMPLETED,中间还可能有 INPUT_REQUIRED),而 Share Group 不保证顺序。把有状态会话放在 Share Group 上,会出现「INPUT_REQUIRED 事件先于 WORKING 事件被处理」这类状态错乱。第十一节会把这个列进踩坑清单。


十、可观测性与治理

这一节要解决的问题是:消息进了 Kafka 之后,链路追踪就断了。怎么把它接回来?

10.1 OpenTelemetry 上下文透传

问题的本质 :OpenTelemetry 的 trace context 通常靠 HTTP header 的 traceparent 传递。一旦请求变成 Kafka 消息,没有任何机制自动把它带过去------如果不显式处理,trace 会在 producer 处终止,consumer 侧起一个全新的 root span。

结果就是:你的 trace 系统里,Agent A 的 span 和 Agent B 的 span 是两个独立的 trace,看不到因果关系。排查问题时你只能靠时间戳猜。

A2A 原生支持 OpenTelemetry 与 W3C Trace Context ,SDK 提供了 a2a-sdk[telemetry] extra。但这个支持是针对 HTTP/SSE 绑定做的(它注入/提取 HTTP header)。一旦换成 Kafka 绑定,你必须自己把 context 注入到 Kafka header 里再提取出来,SDK 不会替你处理,因为它不认识 Kafka。

W3C Trace Context 的 traceparent 格式是固定的:

text 复制代码
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             ^  ^                                ^                ^
             |  |                                |                └─ flags(01 表示采样)
             |  |                                └─ parent-id(16 位 hex)
             |  └─ trace-id(32 位 hex)
             └─ version

Kafka 的 header value 是字节数组,直接 encode() 即可。下面是完整的注入与提取实现:

python 复制代码
from __future__ import annotations

import logging
from typing import Any

from opentelemetry import trace
from opentelemetry.context import Context
from opentelemetry.trace import SpanKind, TraceContextTextMapPropagator

logger = logging.getLogger(__name__)

# 全局单例:W3C Trace Context 传播器
_PROPAGATOR = TraceContextTextMapPropagator()
_TRACER = trace.get_tracer("a2a.kafka.transport")


def inject_trace_context(headers: list[tuple[str, bytes]]) -> list[tuple[str, bytes]]:
    """把当前 span 的 trace context 注入到 Kafka header 列表。

    在 producer 侧调用。如果不调用,consumer 侧拿不到 parent context,
    整条链路会在 broker 处断裂。

    注意:Kafka header 是 list[tuple[str, bytes]],而 propagator 需要
    一个 mapping-like 的 carrier。这里包一层 dict 做桥接。
    """
    carrier: dict[str, str] = {}
    _PROPAGATOR.inject(carrier, context=Context())

    existing_keys = {k for k, _ in headers}
    for key, value in carrier.items():
        if key in existing_keys:
            # 避免重复注入(比如调用方已经手动加过了)
            continue
        headers.append((key, value.encode()))

    return headers


def extract_trace_context(headers: dict[str, str]) -> Context:
    """从 Kafka header 里提取 trace context。

    在 consumer 侧调用,然后用 trace.use_span 或
    tracer.start_as_current_span(context=ctx) 创建子 span。
    """
    return _PROPAGATOR.extract(carrier=headers)


async def process_with_tracing(headers: dict[str, str], payload: dict[str, Any], task_id: str) -> None:
    """消费端:把 Kafka 消息处理挂到上游 trace 上。

    关键点:start_as_current_span 必须传 context=parent_ctx。
    如果不传,创建的 span 会成为新的 root span,链路依然断裂 ------
    这是接入 OTel 时最常见的错误。
    """
    parent_ctx = extract_trace_context(headers)

    with _TRACER.start_as_current_span(
        "a2a.task.process",
        context=parent_ctx,
        kind=SpanKind.CONSUMER,
    ) as span:
        span.set_attribute("a2a.task.id", task_id)
        span.set_attribute("a2a.context.id", headers.get("a2a-context-id", ""))
        span.set_attribute("messaging.system", "kafka")
        span.set_attribute("messaging.operation", "process")

        try:
            await do_actual_work(payload)
            span.set_status(trace.Status(trace.StatusCode.OK))
        except Exception as exc:  # noqa: BLE001
            span.record_exception(exc)
            span.set_status(trace.Status(trace.StatusCode.ERROR, str(exc)))
            raise


async def do_actual_work(payload: dict[str, Any]) -> None:
    """业务逻辑占位。"""
    return None

三个必须注意的点:

第一,inject_trace_context 必须显式调用。 它不会自动生效。很多团队接完 OpenTelemetry 之后发现「HTTP 链路是通的,Kafka 链路是断的」,就是因为 HTTP 有 auto-instrumentation 而 Kafka 没有------不写这一行,traceparent 就永远不进 header。

第二,consumer 侧 start_as_current_span 必须传 context=parent_ctx 这是最常见的错误。不传的话,创建的 span 是新的 root span,你会在 trace 列表里看到一堆互相独立的单 span trace,看起来「有链路数据」但完全没有因果关系,比没有数据更误导人。

第三,SpanKind 要用 CONSUMER / PRODUCER 用默认的 INTERNAL 也能跑,但在 Jaeger / Tempo 这类 UI 上无法正确渲染异步消息的跨服务连线,可视化会变成一堆孤立的方块。

另外提一个容易忽略的点:Kafka 的 header 是在消息级别而不是批次级别的 ,所以当你用批量消费(max.poll.records=500)时,每条消息各自带自己的 traceparent,不需要做任何特殊处理。这一点比 HTTP 的批量接口好处理。

10.2 Schema Registry 把 A2A 消息体当契约管理

它解决的具体问题 :A2A 的消息体是结构化数据,而 Agent 是由多个团队独立开发、独立发版的。当 Agent A 把 status.state 从字符串改成枚举对象,而 Agent B 还在按字符串解析时,就会在运行时反序列化失败。

在没有 Schema Registry 的世界里,这个问题的暴露方式极其糟糕:反序列化异常发生在消费循环里,如果没有好的异常处理,整个消费者会卡死或者疯狂重试同一条坏消息(poison pill)。

Kafka Schema Registry 的作用 :把消息体的 schema 当成一份版本化的契约,在 producer 发送时校验,不兼容的变更直接拒绝写入。

B2B 场景下三种 schema 格式的取舍:

维度 JSON Schema Avro Protobuf
A2A 官方定义 从 proto 生成 proto 是唯一权威定义
可读性 高(就是 JSON) 低(二进制) 中(需要 .proto 才能读)
体积 最小
兼容性检查 支持 支持(最强) 支持
多语言支持 最好
与 A2A 契合度 最高
建议 内部 PoC、调试 高吞吐纯事件流 A2A 消息体的首选

为什么 Protobuf 是 A2A 场景的首选 :因为 A2A v1.0 规范本身就是用 Protocol Buffers 定义的,proto 是 source of truth。用 Protobuf 做 Kafka 消息体,意味着你不需要维护两套类型定义 ------直接从官方的 .proto 文件生成即可,规范升级时跟着重新生成。

Schema Registry 的配置:

properties 复制代码
# Schema Registry 兼容性级别(按 subject)
# BACKWARD 是最常用的:新 schema 能读旧数据
#   -> 消费者可以先升级
# FORWARD:旧 schema 能读新数据
#   -> 生产者可以先升级
# FULL:双向兼容,最安全但最严格
#   -> A2A 场景推荐,因为 Agent 由不同团队独立发版,无法约定升级顺序
schema.compatibility.level=FULL_TRANSITIVE

# subject 命名策略
# TopicNameStrategy  -> <topic>-value,一个 topic 一个 schema(A2A 推荐)
# RecordNameStrategy -> 按记录全限定名,一个 topic 多种 schema
# TopicRecordNameStrategy -> 组合策略
# A2A 场景推荐 TopicNameStrategy:topic 已经是按语义划分的,
# 一个 topic 对应一类 A2A 消息最清晰。
schema.subject.name.strategy=io.confluent.kafka.serializers.subject.TopicNameStrategy

一个必须说的现实约束 :Confluent Schema Registry 是商业产品(有免费的 Developer 版但有限制)。开源的替代方案是 Apicurio Registry(Red Hat 主导,Apache 2.0),功能上够用,但在生态集成度上不如 Confluent。

如果你的团队没有 Schema Registry,有没有替代方案? 有,三个务实的做法:

  1. 把版本号写进消息 headera2a-protocol-version),消费端按版本分派解析逻辑。这是最轻量的做法,PoC 阶段够用。
  2. 用 proto 的字段编号兼容规则 :既然 A2A 已经是 proto 定义,只要双方都遵守「不修改已有字段编号、新增字段可选」的规则,向后兼容基本能保证。这条成本最低、收益最高,如果只能做一件事,做这个。
  3. 消费端容错解析:反序列化失败不抛异常,而是发到 DLQ 并继续。这不能解决契约问题,但能防止 poison pill 阻塞整个消费。

10.3 可观测性三支柱在 A2A over Kafka 下的落点

前面讲的都是机制,这一节给出具体该采什么

支柱 采集对象 A2A over Kafka 的具体指标 工具
Trace 跨组件链路 traceparent 透传;span 覆盖 a2a.senda2a.consumea2a.task.executea2a.publish;按 taskId 和 contextId 打标签 OpenTelemetry + Jaeger / Tempo
Metrics 数值型健康度 见下方细分 Prometheus + Grafana
Logs 结构化事件 结构化 JSON,必带 taskIdcontextIdcorrelationIdagentIdtraceId Loki / ELK

Metrics 细分清单(这是最需要按业务设计的一层):

分类 指标 类型 告警阈值建议
吞吐 a2a.requests.received.total Counter 骤降 50% 告警
吞吐 a2a.tasks.completed.total Counter ---
延迟 a2a.task.duration.seconds Histogram P99 > 任务 SLA 告警
延迟 a2a.rpc.roundtrip.seconds Histogram P95 > 5s 告警
错误 a2a.errors.total{code} Counter 按错误码分维度
错误 a2a.dlq.messages.total Counter > 0 立即告警
积压 kafka.consumer.lag{topic,group} Gauge 持续增长 5 分钟告警
积压 a2a.outbox.pending.rows Gauge > 1000 告警
状态 a2a.tasks.by_state{state} Gauge 中断态占比 > 20% 告警
资源 a2a.pending.futures / a2a.streaming.queues Gauge 持续增长说明有 Future 泄漏

最后一行 a2a.pending.futures 值得单独强调。这是检测第六节那个内存泄漏问题的唯一有效手段。 如果这个指标随时间单调增长而不回落,说明有超时未清理的 Future 在累积。把 corr.stats() 的输出暴露成指标,成本极低,收益极高:

python 复制代码
from prometheus_client import Gauge


pending_futures_gauge = Gauge(
    "a2a_pending_futures",
    "当前等待响应的 Future 数量,持续增长说明存在超时未清理的泄漏",
)
streaming_queues_gauge = Gauge(
    "a2a_streaming_queues",
    "当前活跃的流式队列数量",
)


def export_correlation_metrics(corr) -> None:
    """在客户端主循环里周期性调用(比如每 10 秒一次)。"""
    stats = corr.stats()
    pending_futures_gauge.set(stats["pending_requests"])
    streaming_queues_gauge.set(stats["streaming_queues"])

日志的一条纪律 :日志必须是结构化的,且必须带 traceId。否则当你在 trace 系统里发现一条异常 span,想找对应的日志时,只能靠时间戳模糊匹配,效率极低。

python 复制代码
import json
import logging


class StructuredFormatter(logging.Formatter):
    """把日志输出成单行 JSON,方便 Loki / ELK 索引。"""

    def format(self, record: logging.LogRecord) -> str:
        from opentelemetry import trace

        span = trace.get_current_span()
        span_ctx = span.get_span_context()

        payload = {
            "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S.%fZ"),
            "level": record.levelname,
            "logger": record.name,
            "msg": record.getMessage(),
            # 这三项是可观测性的关键:没有它们就无法关联
            "traceId": format(span_ctx.trace_id, "032x") if span_ctx.is_valid else None,
            "spanId": format(span_ctx.span_id, "016x") if span_ctx.is_valid else None,
            "taskId": getattr(record, "task_id", None),
            "contextId": getattr(record, "context_id", None),
            "correlationId": getattr(record, "correlation_id", None),
            "agentId": getattr(record, "agent_id", None),
        }
        if record.exc_info:
            payload["exception"] = self.formatException(record.exc_info)
        return json.dumps(payload, ensure_ascii=False)

10.4 治理层面的两个硬问题

上面讲的都是技术。但 A2A over Kafka 真正难的是治理,有两个问题必须在架构评审阶段就想清楚:

问题一:谁是 topic 的 owner? 当 20 个团队都在往 a2a.requests.* 写数据时,谁能改 topic 的配置?谁负责容量规划?如果没有明确的 owner,结果一定是「谁都往里写,谁都不管」。建议做法是每个 Agent 的请求 topic 由该 Agent 的团队 own,公共 topic(如 DLQ)由平台团队 own。

问题二:跨租户的隔离粒度是什么? 三种做法,代价递增:

隔离方式 实现 隔离强度 成本
Header 级 同一个 topic,靠 a2a-tenant header 区分 弱(ACL 管不到 header) 最低
Topic 前缀级 a2a.requests.<tenant>.<agent> 中(可用 topic ACL)
集群级 每租户独立 Kafka 集群 最高

判断依据 :如果租户是同一个公司内部部门,header 级 + 应用层校验够用;如果是不同客户(SaaS 场景),必须至少用 topic 前缀级,因为 header 级隔离无法防御「写错租户 ID」这类 bug,而 topic ACL 是内核级强制的。


十一、踩坑记录

这一节的每一条都来自真实项目会遇到的场景。每条的格式是「现象 → 根因 → 修复」。

坑 1:reply topic 没提前创建,消息静默丢失

现象:客户端发请求后等到超时,服务端日志显示「已发送响应成功」,但客户端就是收不到。重启客户端后,历史响应突然全部到达。

根因 :reply topic 不存在,而生产环境 auto.create.topics.enable=false(这个配置在生产是正确的),所以 send_and_wait 应该直接报错才对。但如果用的是 send() 没等结果,或者用了带缓冲的异步发送,异常被吞掉了。更糟的情况是 auto.create.topics.enable=true 但客户端用的是 auto.offset.reset=latest,topic 刚创建时消息已经写进去了,客户端启动时定位到末尾,历史消息全部跳过。

修复

  1. 所有 topic 必须由 IaC(Terraform / 初始化脚本)提前创建,禁止依赖自动创建。
  2. 发送一律用 send_and_wait,让失败立即可见。
  3. 客户端 auto_offset_resetearliest 而非默认的 latest(这条在坑 4 会展开)。
  4. 加一个启动自检:连接后 list_topics,检查所有依赖的 topic 是否都存在,不存在直接 fail fast。

坑 2:correlationId 复用导致响应串台

现象:客户端 A 收到了本该属于客户端 B 的响应内容。低频出现,压力大时概率上升。日志上完全看不出异常。

根因 :两个客户端用了同一种 correlationId 生成方式(比如都用「时间戳 + 自增」),在同一个毫秒内生成了相同的 ID,并且如果它们共用了 reply topic(比如都用默认的 a2a.replies),响应就会被错误的一方认领。

修复

  1. correlationId 必须用 UUIDv4(或至少 128 位随机),绝不用时间戳、自增 ID、或者「用户 ID + 时间」这类可预测且可能碰撞的方案。
  2. reply topic 必须按客户端私有化a2a.replies.<client-id>),这是根本性的隔离。
  3. 加一个守护断言:resolve_future 时如果 fut is None(无人认领),记 WARNING 级别日志。这个日志一旦出现就说明有串台或超时清理问题。

坑 3:用随机 key 导致 task 状态乱序

现象 :客户端收到 TASK_STATE_COMPLETED 之后又收到了 TASK_STATE_WORKING,状态机直接崩溃。低负载时完全正常,压测时才出现。

根因 :状态事件 topic 没有设置 key(或者用了 random / null key),Kafka 的默认分区器会把消息轮询分配到不同分区。而 Kafka 只保证单分区内有序,不保证跨分区有序。所以同一任务的连续两条状态事件被分到了不同分区,消费者并行读取后顺序就乱了。

修复

  1. 状态事件必须taskId 作为 key,artifact 事件同理;会话级事件用 contextId
  2. 在代码层面加断言:发送状态事件时如果 key 为 None,直接抛异常。把这类约束做成运行时的硬校验,而不是靠代码评审。
  3. 消费端加一个兜底:维护「taskId → 最后状态」,如果收到的新状态在状态机上是非法的转换(如 COMPLETED → WORKING),记 error 并丢弃,不要让状态机崩溃。

坑 4:auto.offset.reset 用默认值导致重启后漏消息

现象:服务重启后,重启期间到达的消息全部丢失。日志上没有任何错误。

根因auto.offset.reset 默认是 latest。当消费者组没有已提交的 offset 时(比如是全新的 group id,或者 offset 已经过期被清理),它会从 topic 末尾开始读。这导致两件事:一是启动瞬间就已存在的积压消息被跳过;二是如果 offset 因超过 offsets.retention.minutes 被清理,重启后会从末尾开始。

修复

  1. 客户端读私有 reply topic 时用 earliest:这个场景下「漏消息」的代价远大于「重复读」。
  2. 业务消费者组保持 latest 是合理的(避免处理历史积压),但要保证 offset 不会被清理 :把 offsets.retention.minutes 设得足够大(默认 10080 分钟 = 7 天),并确保消费者至少每周提交一次。
  3. 监控 kafka.consumer.lag,超过阈值告警。

坑 5:未来得及发送的 Future 造成内存泄漏

现象 :客户端进程运行几天后 OOM,pending_requests 字典有几万个条目。GC 日志显示大量 asyncio.Future 对象无法回收。

根因 :超时路径上没有清理 pending_requests。看下面这段错误代码:

python 复制代码
# ❌ 错误写法:超时后没有清理
async def send_message_bad(self, topic, params, context_id, timeout=30):
    cid, _ = await self._send(topic, "SendMessage", params, context_id)
    fut = self.corr.create_future(cid)
    try:
        return await asyncio.wait_for(fut, timeout=timeout)
    except asyncio.TimeoutError:
        raise  # ← 只抛异常,self.pending_requests[cid] 永远留在字典里

每次超时泄漏一个 Future,每个 Future 又持有它的回调链。同时因为 wait_for 超时会 cancel 这个 Future,而 cancel 后的 Future 如果后续收到 set_result 会抛 InvalidStateError------这就是为什么还必须在 resolve_future 里检查 fut.done()

修复 :用 try / finally 无条件清理(这正是第六节代码里的做法)。

python 复制代码
# ✅ 正确写法
try:
    return await asyncio.wait_for(fut, timeout=timeout)
except BaseException:
    self.corr.cleanup(cid)
    raise

并且暴露 a2a_pending_futures 指标做持续监控。

坑 6:把 Share Consumer 用在有状态会话上导致状态错乱

现象:多轮对话的 Agent 出现「用户的第二条补充消息被处理时,第一条消息还没执行完」,导致上下文丢失、重复调用 LLM、甚至任务状态被写坏。

根因 :为了提升吞吐,把有状态的 Task 会话 topic 也切到了 Share Group。而 Share Group 的核心设计就是「同一分区的消息可被并行处理、无顺序保证」。对于无状态任务这是优点,对于有状态的状态机这是致命的。

修复

  1. 有状态的多轮会话必须用传统 consumer group,按 contextId 分区,保证同一会话的消息被同一个消费者按顺序处理。
  2. Share Group 只用于无状态、可并行的任务(见 9.5 的选型对照表)。
  3. 如果确实既需要顺序又需要并行,正确做法是按 contextId 分区 + 每分区内串行,即传统 consumer group,而不是 Share Group。

这条坑的通用教训:任何提升吞吐的手段,都要先问一句「它破坏了什么保证」。Share Groups 破坏的是顺序保证,这在无状态场景下无所谓,在有状态场景下是灾难。

坑 7:消息体超 max.request.size 导致大 artifact 发送失败

现象 :小任务全部正常,但涉及长文档(比如 5MB 的 PDF 提取结果)的任务在发送 artifact 时抛 RecordTooLargeException

根因 :Kafka 的默认 max.request.size(producer)和 message.max.bytes(broker)都是 1MB 左右。A2A 的 Artifact 可能包含大文件内容,很容易超限。

修复

  1. 首选方案:不要把大内容放进消息体。 把 artifact 内容写到对象存储(S3 / OSS / MinIO),消息体里只放引用(URL + 校验和 + 大小)。这符合 Kafka 的设计意图------它优化的是「大量小消息」,不是「少量大消息」。
  2. 如果必须内联,需要同时调大三处:producer 的 max.request.size、broker 的 message.max.bytes、以及 topic 的 max.message.bytes(topic 级配置会覆盖 broker 级)。
  3. 注意大消息的真实代价 :Kafka 的消息是整批压缩和传输的,一条 10MB 消息会让整个批次变大,影响同批次其他消息的延迟,并且增加 broker 的内存压力(replica.fetch.max.bytes 也要跟着调)。调大参数容易,承担后果不容易。

坑 8:DLQ 里没有原始 header 导致无法重放

现象 :坏消息进了 DLQ,运维想修复后重放,但发现 DLQ 里的消息缺少 a2a-reply-topica2a-correlation-id 等 header,重放后客户端收不到任何响应。

根因 :写 DLQ 时只复制了 keyvalue,没有复制 headers。这在 Kafka 生产代码里非常常见,因为 ProducerRecord 有多个重载构造函数,很容易顺手用了不带 headers 的那个。

修复

java 复制代码
// ❌ 错误:丢了 headers
dlqProducer.send(new ProducerRecord<>(dlqTopic, record.key(), record.value()));

// ✅ 正确:原样带 headers,并追加诊断信息
var headers = record.headers();
headers.add(new RecordHeader("a2a-dlq-reason", reason.getBytes()));
headers.add(new RecordHeader("a2a-dlq-original-topic", record.topic().getBytes()));
headers.add(new RecordHeader("a2a-dlq-original-partition",
        String.valueOf(record.partition()).getBytes()));
headers.add(new RecordHeader("a2a-dlq-original-offset",
        String.valueOf(record.offset()).getBytes()));
headers.add(new RecordHeader("a2a-dlq-failed-at",
        Instant.now().toString().getBytes()));
dlqProducer.send(new ProducerRecord<>(dlqTopic, null, record.key(), record.value(), headers));

通用原则:DLQ 消息必须是「可重放的」,这意味着它必须包含重放所需的全部信息------原始 topic、原始分区、原始 offset(用于确认重放范围)、完整的 header、以及失败原因。少任何一项,重放都会变成手工考古。

下面这张流程图总结了从消费到 DLQ 的完整决策路径:
#mermaid-svg-O72JdTakBKiN77AX{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-O72JdTakBKiN77AX .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-O72JdTakBKiN77AX .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-O72JdTakBKiN77AX .error-icon{fill:#552222;}#mermaid-svg-O72JdTakBKiN77AX .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-O72JdTakBKiN77AX .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-O72JdTakBKiN77AX .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-O72JdTakBKiN77AX .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-O72JdTakBKiN77AX .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-O72JdTakBKiN77AX .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-O72JdTakBKiN77AX .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-O72JdTakBKiN77AX .marker{fill:#333333;stroke:#333333;}#mermaid-svg-O72JdTakBKiN77AX .marker.cross{stroke:#333333;}#mermaid-svg-O72JdTakBKiN77AX svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-O72JdTakBKiN77AX p{margin:0;}#mermaid-svg-O72JdTakBKiN77AX .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-O72JdTakBKiN77AX .cluster-label text{fill:#333;}#mermaid-svg-O72JdTakBKiN77AX .cluster-label span{color:#333;}#mermaid-svg-O72JdTakBKiN77AX .cluster-label span p{background-color:transparent;}#mermaid-svg-O72JdTakBKiN77AX .label text,#mermaid-svg-O72JdTakBKiN77AX span{fill:#333;color:#333;}#mermaid-svg-O72JdTakBKiN77AX .node rect,#mermaid-svg-O72JdTakBKiN77AX .node circle,#mermaid-svg-O72JdTakBKiN77AX .node ellipse,#mermaid-svg-O72JdTakBKiN77AX .node polygon,#mermaid-svg-O72JdTakBKiN77AX .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-O72JdTakBKiN77AX .rough-node .label text,#mermaid-svg-O72JdTakBKiN77AX .node .label text,#mermaid-svg-O72JdTakBKiN77AX .image-shape .label,#mermaid-svg-O72JdTakBKiN77AX .icon-shape .label{text-anchor:middle;}#mermaid-svg-O72JdTakBKiN77AX .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-O72JdTakBKiN77AX .rough-node .label,#mermaid-svg-O72JdTakBKiN77AX .node .label,#mermaid-svg-O72JdTakBKiN77AX .image-shape .label,#mermaid-svg-O72JdTakBKiN77AX .icon-shape .label{text-align:center;}#mermaid-svg-O72JdTakBKiN77AX .node.clickable{cursor:pointer;}#mermaid-svg-O72JdTakBKiN77AX .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-O72JdTakBKiN77AX .arrowheadPath{fill:#333333;}#mermaid-svg-O72JdTakBKiN77AX .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-O72JdTakBKiN77AX .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-O72JdTakBKiN77AX .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-O72JdTakBKiN77AX .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-O72JdTakBKiN77AX .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-O72JdTakBKiN77AX .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-O72JdTakBKiN77AX .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-O72JdTakBKiN77AX .cluster text{fill:#333;}#mermaid-svg-O72JdTakBKiN77AX .cluster span{color:#333;}#mermaid-svg-O72JdTakBKiN77AX div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-O72JdTakBKiN77AX .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-O72JdTakBKiN77AX rect.text{fill:none;stroke-width:0;}#mermaid-svg-O72JdTakBKiN77AX .icon-shape,#mermaid-svg-O72JdTakBKiN77AX .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-O72JdTakBKiN77AX .icon-shape p,#mermaid-svg-O72JdTakBKiN77AX .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-O72JdTakBKiN77AX .icon-shape .label rect,#mermaid-svg-O72JdTakBKiN77AX .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-O72JdTakBKiN77AX .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-O72JdTakBKiN77AX .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-O72JdTakBKiN77AX :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 缺失

不能
齐全
失败
成功
已处理过
首次处理
成功
可重试错误
未超过
已超过
不可恢复错误
消费者拉取消息
必需 header 是否齐全
记录错误日志
能否拿到 reply topic
回错误信封
直接投递 DLQ
结束
反序列化是否成功
投递 DLQ 并附加失败原因
幂等去重是否命中
提交 offset 跳过
执行业务逻辑
执行结果
提交 offset
是否超过重试上限
投递到 retry 分级 topic
投递 DLQ 保留原始 header
投递 DLQ 保留原始 header


十二、总结与展望

12.1 一句话总结

A2A 是语言,Kafka 是嗓门能覆盖全场的扩音系统。

A2A 定义了 Agent 之间「说什么」------Task 状态机、Message 结构、Artifact 增量语义,这套东西设计得相当克制和完整,是真正的标准化贡献。但它的默认传输(HTTP + JSON-RPC + SSE)是点对点的,在企业级规模下会撞上连接爆炸、强耦合、可观测性为零这堵墙。

Kafka 补的正是这一块。它不改变 A2A 的任何一个语义,只是换掉「怎么送达」。而 A2A v1.0 的三层分离设计(数据模型 / 抽象操作 / 协议绑定)恰好为这种替换预留了空间------这不是巧合,是分层设计的价值兑现。

12.2 三个需要记住的判断

回看全文,真正重要的不是代码,而是三个判断:

第一,模式是光谱,不是配方。 第三节的三种模式、第九节的 Share Groups、第十节的工具选型,每一条都给了「什么条件下选它 / 什么条件下不要选它」。1--3 个 Agent 的项目上 Kafka 是纯粹的过度设计;延迟敏感的交互式场景上 Kafka 是自找麻烦。先算清楚你的约束,再看架构图。

第二,PoC 与生产之间隔着 12 项工程能力。 第七节的差距清单不是恐吓,是清单。Google Codelabs 的 a2a-python-kafka 是理解映射关系的绝佳教材,但它用的是 InMemoryTaskStore、没有鉴权、没有幂等、没有背压。把 PoC 的代码直接复制进生产,是这篇文章里最容易犯、代价最大的错误。

第三,顺序保证是这套架构里最脆弱的假设。 从分区键设计(4.1)、到 Share Groups 的适用边界(9.5)、到坑 3 和坑 6,本质上都在讲同一件事:Kafka 的顺序保证是有条件的、局部的、可被误用的。任何提升吞吐的手段,都要先问「它破坏了什么保证」。

12.3 生态展望:接下来 12 个月会发生什么

Confluent 的 A2A Integration for Streaming Agents。 Confluent 在推进「Streaming Agents on Flink」,计划在 2026 Q1 开放 Open Preview。核心思路是把 Agent 的编排逻辑做成 Flink 作业,从而继承 Flink 的 checkpoint、状态管理和 exactly-once 语义。这个方向值得关注的原因:它可能一次性解决本文第九节里那些需要手写的可靠性工程(幂等、DLQ、状态恢复),因为这些恰好是 Flink 已经解决的问题域。

Apache Flink FLIP-531「Flink Agents」。 这个 FLIP 提出在 Flink 里原生支持 A2A 与 MCP 协议。如果落地,意味着 Agent 之间的通信可以直接跑在 Flink 的算子图上,天然获得 checkpoint 与 exactly-once。但要清醒:FLIP 从提出到可用通常要 1--2 年,短期内不要把架构押在上面。

W3C AI Agent Protocol Community Group。 这个社区组的目标是在 2026--2027 年产出标准化成果。这是长期最重要的一条,因为只有标准才能解决跨组织的互操作问题。在那之前,跨公司的 Agent 协作仍然会以点对点 HTTP + 私有约定为主。

一个务实的判断 :接下来 12 个月,企业内部 Agent 协作的主流形态会是「A2A over HTTP + Kafka 旁路扇出」(模式 B),而不是「A2A over Kafka」(模式 A)。原因不是模式 A 不好,而是模式 B 的改造成本低一个数量级,而它能解决企业最痛的那个问题------可观测性。

12.4 给读者的行动建议(分三档)

第一档:先别改架构。

适用:Agent 数量 ≤ 3,或者团队没有任何 Kafka 运维经验,或者业务对延迟敏感。

做这三件事:

  1. 把所有 Agent 的 HTTP 调用都打上 OpenTelemetry,包括 traceparent 透传。这一步零成本,收益巨大。
  2. 在 Agent 内部定义好事件结构(哪怕只是写成日志),为将来可能的 Kafka 化预留格式。
  3. 把 Agent Card 的 supportedInterfaces 写规范,至少为将来加 Kafka 绑定留好位置。

第二档:接旁路扇出(模式 B)。

适用:Agent 数量 4--10,有审计合规要求,或者「线上问题定位难」已经是当前的痛点。

做这三件事:

  1. 按 4.1 节的 topic 拓扑建最小集合(a2a.status.<task-id>a2a.artifacts.<task-id>a2a.audit.<domain>)。
  2. 在 Agent 里加事件上报,注意双写问题------先不要用 Outbox(成本偏高),但要把「写失败」记录成 metric 并告警,积累数据判断双写不一致的实际发生率。
  3. 把 OTel 的 traceparent 也注入到 Kafka header 里(10.1 节的代码直接可用),让 trace 贯通。

第三档:整体上 Kafka(模式 A)。

适用:Agent 数量 10+,或者跨团队/跨组织协作,或者任务量大且突发需要队列能力。

做这三件事,按顺序:

  1. 先把可靠性工程做齐:幂等消费(9.3)、Outbox(9.2)、DLQ 保留原始 header(坑 8)。这三项没做之前,不要切流量。
  2. 再明确分区键策略 :请求用 contextId、状态用 taskId、响应用 correlationId。把这三条写进代码断言,不靠人记。
  3. 最后才考虑 Share Groups :只用在无状态任务上(9.5),有状态会话继续用传统 consumer group。这条边界一旦搞错,debug 成本极高。

参考资料

  1. A2A 官方协议规范 v1.0(三层模型、Task 状态机、StreamResponse 定义)------ https://a2a-protocol.org/dev/specification/
  2. A2A 官方示例仓库(含 Python / JS 客户端与 Agent 参考实现)------ https://github.com/a2aproject/a2a-samples
  3. Google Codelabs:Way Back Home Level 5 ------ Event-Driven Architecture with ADK, A2A, and Kafka(本文第六、七节代码的参考来源,PoC 级别)------ https://codelabs.developers.google.cn/way-back-home-level-5/instructions
  4. a2a-python-kafka 社区实现的设计文档(kafka 自定义 AgentCard 字段、信封协议、CorrelationManager 的出处)------ https://github.com/weimeilin79/a2a-python-kafka/blob/main/A2A on Kafka.md
  5. Confluent 博客:Why Google's Agent2Agent Protocol Needs Apache Kafka(三种集成模式的原始论述)------ https://www.confluent.io/blog/google-agent2agent-protocol-needs-kafka/
  6. Confluent 博客:The Real-Time Backbone for Agentic Systems(MCP/A2A Proxy 与 Stream Governance)------ https://www.confluent.io/blog/real-time-agentic-ai-google-cloud/
  7. Apache Kafka 官方升级文档(4.0 移除 ZooKeeper、4.2 / 4.3 版本能力与破坏性变更)------ https://kafka.apache.org/43/getting-started/upgrade
  8. KIP-932:Queues for Kafka(Share Groups 设计文档)------ https://cwiki.apache.org/confluence/display/KAFKA/KIP-932+Queues+for+Kafka
  9. KIP-1222:Share Consumer RENEW 语义(延长 acquisition lock,4.2 新增)------ https://cwiki.apache.org/confluence/display/KAFKA/KIP-1222
  10. Kafka 4.2 新特性解读(Share Groups GA、Streams DLQ、KIP-1034/KIP-1226)------ https://blogs.jsbisht.com/blog/kafka-4-2-whats-new
  11. Karafka 文档:Consumer Groups vs Share Groups(选型对照与适用边界)------ https://karafka.io/docs/Basics-Consumer-Groups-vs-Share-Groups/
  12. Conduktor 术语页:Kafka Share Groups(启用方式、配置上限与限制)------ https://www.conduktor.io/glossary/kafka-share-groups
  13. DZone:Agentic AI Using Apache Kafka as the Event Broker with A2A and MCP ------ https://server.dzone.com/articles/agentic-ai-using-apache-kafka-as-event-broker-with-agent2agent-protocol
  14. Confluent 博客:Chain Services with Exactly-Once Guarantees(幂等与事务的边界)------ https://www.confluent.io/blog/chain-services-exactly-guarantees/
  15. W3C AI Agent Protocol Community Group(2026--2027 标准化路线)------ https://www.w3.org/community/
相关推荐
资讯综合1 小时前
2026企业AI平台选型指南:多维视角下的主流方案解析
人工智能
资讯综合1 小时前
向日葵 vs ToDesk 个人远控横评:PC/手机/弱网/远程开机实测(26年9月更新)
人工智能
布吉岛的石头1 小时前
Java 程序员第 48 阶段15:Transformer 架构总览与自注意力直觉,注意力权重可视化:用 Java 打印注意力矩阵理解模型在看什么
人工智能·深度学习·transformer
正经教主1 小时前
【FDE系列】阶段1Day 11:Prompt — 给模型立规矩
人工智能·fde
海上小飞龙2 小时前
LangChain 模型调用:invoke、stream、batch 到底该怎么选?
人工智能·语言模型·自然语言处理
美狐美颜sdk2 小时前
直播APP开发技术栈详解:视频美颜SDK、人脸识别与实时渲染
android·人工智能·音视频·美颜sdk·直播美颜sdk
jason_renyu2 小时前
Windows 环境下 Python 方式安装 Milvus 向量库与 Attu 避坑指南
人工智能·milvus·windows安装milvus·windows安转向量库
tianxuanjg2 小时前
机器人谐波减速器柔轮结构件采购指南:复杂薄壁精密件加工选型要点
人工智能·经验分享·机器人·无人机·制造