01 先跑一个最小 Agent
01 先跑一个最小 Agent
这篇只做一件事:
搭一个不依赖业务知识的 A2A 最小服务,让前端能收流式事件。
本文内容对照提交:
data-agent-backend/src/main/kotlin/io/github/qifan777/server/a2a/A2AConfiguration.ktdata-agent-backend/src/main/kotlin/io/github/qifan777/server/a2a/A2AController.ktdata-agent-backend/src/main/kotlin/io/github/qifan777/server/a2a/ToyAgentExecutor.kt
1. 本文目标
完成后你应该具备这 3 个能力:
- 知道 A2A 服务最小需要哪些端点与配置
- 能发起一次流式请求并收到
artifact-update - 能把这套骨架迁移到任意业务场景
2. 最小链路
Frontend -> /api/.well-known/agent-card.json -> /api/a2a/jsonrpc -> 后端 A2AController -> JSONRPCHandler -> ToyAgentExecutor -> SSE
只保留协议,不引入 SQL、RAG、知识库。
3. 后端最小必备
3.1 依赖
implementation("io.github.a2asdk:a2a-java-sdk-transport-jsonrpc:0.3.2.Final")这个依赖会把 A2A Java SDK 的 JSON-RPC 传输层一起带进来,后面 JSONRPCHandler 就来自这里。
3.2 AgentCard + JSONRPCHandler
提交里的 A2AConfiguration 做了 4 件关键事情:
capabilities.streaming(true)url(...)指向前端可访问的http://localhost:3500/api/a2a/jsonrpcadditionalInterfaces(...)补充 JSON-RPC 接口信息- 用
DefaultRequestHandler.create(...)创建JSONRPCHandler
对应代码可以直接理解成:
@Bean
open fun agentCard(): AgentCard {
return AgentCard.Builder()
.name("sqlAgent")
.capabilities(
AgentCapabilities.Builder()
.streaming(true)
.pushNotifications(true)
.stateTransitionHistory(true)
.build()
)
.url("http://localhost:3500/api/a2a/jsonrpc")
.additionalInterfaces(
listOf(AgentInterface("JSONRPC", "http://localhost:3500/api/a2a/jsonrpc"))
)
.build()
}这里最容易忽略的一点是:AgentCard.url 不是随便填的,它必须是前端真的能访问到的地址。所以教程里写成 3500/api/...,而不是后端自己的 9933/a2a/...。
3.3 两个 HTTP 入口
提交里不是 GraphController,而是 A2AController 提供:
GET /.well-known/agent-card.jsonPOST /a2a/jsonrpc
其中:
GET直接返回jsonRpcHandler.agentCardPOST同时声明application/json和text/event-stream- 控制器会先解析 JSON-RPC 请求体,再判断这是不是流式方法
关键代码如下:
@GetMapping(value = ["/.well-known/agent-card.json"], produces = [MediaType.APPLICATION_JSON_VALUE])
fun agentJson(): AgentCard {
return jsonRpcHandler.agentCard
}
@PostMapping(
value = ["/a2a/jsonrpc"],
produces = [MediaType.TEXT_EVENT_STREAM_VALUE, MediaType.APPLICATION_JSON_VALUE]
)
fun handleRequest(@RequestBody body: String): Any {
...
}这个 handleRequest 的核心判断是:
streaming = method != null && (
SendStreamingMessageRequest.METHOD == method.asText() ||
TaskResubscriptionRequest.METHOD == method.asText()
)也就是说,只有流式消息发送和任务重订阅两种方法会进入 SSE 分支。
4. 一个最小 Executor 思路
先做“玩具执行器”:收到文本就回一段固定结果,不做业务处理。
提交里的 ToyAgentExecutor 就是这个最小版本:
override fun execute(context: RequestContext, eventQueue: EventQueue) {
val taskUpdater = TaskUpdater(context, eventQueue)
val text = ((context.message?.parts?.first { it is TextPart }) as TextPart).text
taskUpdater.addArtifact(
listOf(TextPart("hello from a2a: $text")),
"1",
"TOY_HELLO_NODE",
mapOf("outputType" to "GRAPH_NODE_STREAMING")
)
taskUpdater.complete()
}这段代码有 3 个关键信号:
- 从
context.message.parts里读用户输入 - 用
taskUpdater.addArtifact(...)发出一个名字为TOY_HELLO_NODE的 artifact - 用
taskUpdater.complete()结束任务
前端真正依赖的是下面这组事件顺序:
taskartifact-updatestatus-update(completed)
task 事件由 A2A SDK 侧的任务生命周期管理产生;我们在业务代码里最直接控制的是 artifact-update 和最终完成态。
这里还有一个很实用的细节:
mapOf("outputType" to "GRAPH_NODE_STREAMING")虽然这是 toy demo,但提前把 outputType 放进 metadata,后面接 Graph 节点渲染时可以直接复用这套约定。
5. 本地启动
# backend
rtk ./gradlew bootRun
# frontend
rtk pnpm install
rtk pnpm dev默认:
- 前端:
http://localhost:3500 - 后端:
http://localhost:9933
这里有一个隐含前提:
前端需要把 /api 代理到后端,所以浏览器访问的是 3500/api/...,真正处理请求的是 9933 上的 Spring Boot。
6. 第一轮验证
先验证服务发现:
rtk curl http://localhost:3500/api/.well-known/agent-card.json你应该能看到这些关键字段:
namecapabilities.streaming = trueurl = http://localhost:3500/api/a2a/jsonrpc
再验证 JSON-RPC 入口。当前提交里前端没有额外封装调试页,最简单的方式就是跑起前端页面后发送一句话:
- 页面发消息
- 网络出现
/api/a2a/jsonrpc - 响应类型是
text/event-stream - 后端进入
ToyAgentExecutor.execute(...)
7. 常见问题
1) agent card 404
- 后端没启动
- 前端代理
/api -> 9933配置错误
2) 只有一次性返回,没有流式
capabilities.streaming不是true- 控制器没有命中
SendStreamingMessageRequest - controller 没按 SSE 返回
SseEmitter
3) 前端收不到 artifact
- executor 没有
addArtifact(...) taskUpdater.complete()未调用artifact.name和前端约定的节点名对不上
8. 本章完成标准
满足以下两条即可进入下一篇:
- 前端能稳定收到
artifact-update - 你可以解释最小 A2A 链路中的每一跳
如果你要把这篇记成一句话,可以记成:
AgentCard 负责发现,A2AController 负责接协议,JSONRPCHandler 负责分发,ToyAgentExecutor 负责产出 artifact。
