尧图精选

AgentScope Java 实战 04:互通层——A2A 协作与 Nacos 接线

🕒 发布时间:2026/10/1 17:08:28 📁 来源:尧图网络
03 篇结尾留了一句话主干还剩最后一篇。这篇补上互通层——Agent 对外暴露成 A2A 服务、按需调用远端 Agent以及 Nacos 的 Prompt / Card / Skill 三条 AI 通道和它们的现实限制。这也是本系列主干01-04的收尾篇。前三篇把单个 Agent 做完整了01 装配出有六边形边界的运行时02 让它有状态、可治理03 给它装上工具和书架。但一个 Agent 再完整也只是进程里的一个对象。互通层要回答的问题是这个 Agent 能不能被发现、被别的服务调用能不能反过来调用别人的 Agent以及运行时的提示词能不能不重启就换。dream-scope 的答案是三条通道A2A 协议负责 Agent 与 Agent 之间的调用Nacos 负责 Prompt / Card / Skill 三份数据的注册与下发Actuator / Prometheus / OTel 负责把运行状态暴露出去。全部是可选装配——不开 Nacos8091 的 chat 服务照常跑。一、A2A Server把 chat 暴露成 A2A 服务A2AAgent-to-Agent是一个开放协议服务方发布一张 Agent Card 描述自己调用方用 JSON-RPC 的message/send发消息拿回带 artifact 的结果。AgentScope Java 2.0.3 在agentscope-extensions-protocol下提供了 a2a-client 和 a2a-server 两个子模块dream-scope 两个都用上了但都包在自己 adapter 模块里web 层不直接 importio.agentscope的类。服务端入口是ScopeA2aServerdream-scope-adapter它组装官方的AgentScopeA2aServer加一条 JSON-RPC transport// dream-scope-adapter/.../a2a/ScopeA2aServer.java节选 ConfigurableAgentCard card new ConfigurableAgentCard.Builder() .name(dream-scope-chat) .description(dream-scope 内置 chat Agent) .url(uri /a2a) // 对外根地址 /a2a 端点 .version(0.1.0) .preferredTransport(jsonRpc) .defaultInputModes(List.of(text)) .defaultOutputModes(List.of(text)) .build(); var builder AgentScopeA2aServer.builder(new ChatA2aRunner(chat)) .agentCard(card) .withTransport(TransportProperties.builder(jsonRpc) .host(host).port(port).path(/a2a).build());Card 里的字段就是 A2A 协议的自我介绍名字、描述、服务地址、传输方式、输入输出类型。组合根A2aPortsConfig里还有一个小动作值得注意端点注册完不等于服务就绪要等 Spring 的ApplicationReadyEvent之后再调一次server.postEndpointReady()这一步才算真正把服务挂出去。这里有个版本差异的坑可以直接写进注释手册文档里提到的JsonRpcTransportProperties在 2.0.3 里不存在实际要用TransportProperties.builder(String)。写文章时对着最新文档抄 API编译期就会撞上。为什么是 ChatA2aRunner官方 a2a-server 对被暴露的 Agent 有两种接受方式ReActAgent.Builder或者实现AgentRunner接口。dream-scope 的主角 chat 是HarnessAgent——它不是ReActAgent第一条路走不通。于是有了ChatA2aRunner// dream-scope-adapter/.../a2a/ChatA2aRunner.java节选 final class ChatA2aRunner implements AgentRunner { Override public FluxAgentEvent streamEvents(ListMsg messages, AgentRequestOptions options) { String text lastText(messages); String sessionId options null ? null : options.getSessionId(); String userId options null ? null : options.getUserId(); AgentInvokeResult result chat.handle(new AgentInvokeRequest(AgentIds.CHAT, sessionId, userId, text)); String output result null || result.output() null ? : result.output(); return Flux.just( new TextBlockDeltaEvent(a2a, a2a, output), new AgentResultEvent(new AssistantMessage(output))); } Override public void stop(String taskId) { // message/send 同步跑完无后台任务可停 } }这个类总共 90 行做的事情只有一件把 A2A 协议层的请求翻译成 domain 层AgentHandler的AgentInvokeRequest再把结果包回协议层的事件流。三个细节有取舍含义同步非流式。chat.handle()跑完才把整个输出包成一个TextBlockDeltaEvent发出去Agent Card 里capabilities.streaming也是false。远端调用方拿到的是一次性的完整回答不是逐 token 的流。对一个对外暴露的服务来说同步返回比流式好治理——超时、重试、幂等都是在一次请求一次响应的模型下才好设计。sessionId / userId 透传。A2A 请求里的会话信息被原样传给 domain 层远端调用者的多轮对话状态和本地调用者一样落在 Redis 里A2A 不会变成第二套状态存储。stop() 留空是诚实的。message/send同步跑完没有后台任务可停注释直说不假装支持取消。手写的 A2aSupport 是单测对照adapter 里还有个A2aSupport手写了 Agent Card 的 Map 结构和 JSON-RPC 请求/应答的编解码。类注释写得很清楚产品路径走官方 a2a-server这些手写代码是给单测当对照用的。这个做法值得留意——协议编解码最容易在细节上出错比如 parts 里混入非 text 类型时的处理测试里有一份手写的期望结构官方封装的行为变化立刻能测出来。二、A2A Client两种方式接远端 Agent调用方向反过来ScopeA2aClientAgent用官方 a2a-client 的A2aAgent把远端 Agent 包装成本地AgentHandleragentId固定为a2a。对上层调用方来说调它和调 chat 没有区别——同一个AgentInvokeRequest进、AgentInvokeResult出HTTP 层无感。远端地址有两个来源组合根里是两个互斥的 Bean来源触发条件发现方式well-known 直连配了dream-scope.a2a.remote-url拉远端/.well-known/agent-card.jsonNacos 发现dream-scope.nacos.a2a.discovery-enabledtrueNacosAgentCardResolver按 Agent 名拉 Card优先级更高// dream-scope-adapter/.../ScopeA2aClientAgent.java节选 static A2aAgent buildRemote(String remoteUrl) { String base normalizeBase(remoteUrl); // 去掉尾部 /a2a WellKnownAgentCardResolver resolver WellKnownAgentCardResolver.builder() .baseUrl(base) .relativeCardPath(/.well-known/agent-card.json) .build(); A2aAgentConfig config A2aAgentConfig.builder() .clientConfig(ClientConfig.builder().setStreaming(false).build()) .build(); return A2aAgent.builder() .name(AgentIds.A2A) .agentCardResolver(resolver) .a2aAgentConfig(config) .build(); }调用侧统一remote.call(input).block(Duration.ofSeconds(30))30 秒超时失败包成AgentProviderException抛给上层不静默吞。setStreaming(false)和服务端的选择对称——两端都是同步语义行为可预期。三、Nacos一条 gRPC 通道三份数据Nacos 部分容易写偏先把通道说清楚。dream-scope 里 Nacos 相关的开关有三套互不绑死开关作用默认DREAM_SCOPE_NACOS_CONFIG_ENABLEDSpring Cloud 配置中心启动时用dream-scope.yaml覆盖本地配置falseDREAM_SCOPE_NACOS_DISCOVERY_ENABLEDSpring Cloud 服务发现falsedream-scope.nacos.enabledAgentScope AI 通道Prompt / A2A / Skillfalse第三套是本篇的重点。它不是走 8848 的传统配置中心而是 Nacos 3.x 的 AI gRPC 通道默认 9848。server-addr仍然填host:8848客户端自己换算 gRPC 端口——但服务端必须真是 3.x 且暴露了 9848只开 8848 的 2.x 配置中心会连失败。ChatNacosClient.open()在启动期做了一次探活这是很实用的防御用一个不存在的 Agent 名去getAgentCard返回 NOT_FOUND 说明 gRPC 通道已通返回 501、connection refused 或 version too low 则直接抛异常终止启动。问题在启动时暴露而不是第一次对话时。// dream-scope-adapter/.../nacos/ChatNacosClient.java节选 static void probeAiChannel(AiService ai, String serverAddr) { try { ai.getAgentCard(AI_PROBE_AGENT); } catch (NacosException ex) { if (isFatalAiProbe(ex)) { throw new IllegalStateException( nacos ai grpc not ready (need Nacos 3.x with port 9848): serverAddr, ex); } // 未知 Agent 的 NOT_FOUND 表示 gRPC 已通 } }通道之上跑三份数据各有各的形态数据载体dream-scope 的用法PromptPrompt 资源名如dream-scope-chatNacosPromptListener拉正文喂给中间件Agent Card按 Agent 名精确getAgentCardA2A 注册 / 发现SkillZIP 包NacosSkillRepository与本地 workspaceskills/并存一个容易混的点Prompt 的 key 是 Prompt 资源名不是配置中心里 Groupagent的 Card 数据两套数据在 Nacos 控制台里也是不同的管理入口。四、Prompt 热更新理想接线与现实限制这是全篇最值得如实写的部分。先看理想接线长什么样。HarnessAgent的sysPrompt在build()时就固化了PortsConfig组装时如果把 Nacos 拉到的提示词直接写进build()那它就变成启动时的一次性快照。所以正确的挂法是中间件——ChatNacosPromptMiddleware实现MiddlewareBase在onSystemPrompt回调里每轮推理时拉最新 Prompt// dream-scope-adapter/.../nacos/ChatNacosPromptMiddleware.java节选 Override public MonoString onSystemPrompt(Agent agent, RuntimeContext ctx, String current) { String base current null ? : current; String loaded nacos.sysPrompt(null); // 每轮拉最新 if (loaded null || loaded.isBlank()) { return Mono.just(base); // 拉失败或空白原样返回不打断调用 } if (base.contains(loaded)) { return Mono.just(base); // 防重复拼接 } return base.isBlank() ? Mono.just(loaded) : Mono.just(base \n loaded); }设计上有三个防御点拉失败或内容空白时原样返回当前提示词配置中心的故障不传导到对话链路contains检查避免同一份内容被反复拼接拼接策略是追加在内置 SYS_PROMPT 之后基础人设与运营文案分层。然后是现实。这套接线的每一环——中间件、NacosPromptListener、Nacos 侧的 Prompt 管理——代码都在、编译通过、单测覆盖但实测没有跑通热更新本地 docker compose 用的是 Nacosv3.1.0镜像而 AgentScope 2.0.3 带的 Nacos AI 客户端发的QueryPromptRequest3.1 的服务端不认识——客户端是 3.2 协议服务端是 3.1版本对不上。提示词热更新在这套环境里没有打通。这不是代码问题是时间线问题框架迭代到 3.2 协议稳定镜像还停在 3.1。工程上能做的三件事都做了启动探活把通道问题前置Prompt 拉失败不影响对话模型名、超时这类必须重启才生效的配置明确写进docs/nacos的注释里——dream-scope.yaml覆盖的是启动配置chat Harness 在PortsConfig里 build 一次改完要重启。文章把它写成接线已就绪、热更新待服务端版本跟上比假装它是已验证的特性诚实得多。五、可观测与开源运营观测这层 02 篇提过一半chat 链路上挂着OtelTracingMiddleware配了 OTLP endpoint 才导出 trace。HTTP 侧是标准 Actuator 四件套——health、info、metrics、prometheusmanagement.prometheus.metrics.export.enabledtrue打开 Prometheus 抓取端点metrics.tags.application给所有指标打上应用名。没有自建监控面板暴露标准端点让 Prometheus 接是对小项目最省力的选择。开源运营方面 dream-scope 做了三件事README 里每条链路都给可直接复制的 curl 命令.env.example列全所有环境变量且真实 Key 不入库dream-scope-ui提供 Vue 页面开发模式下 Vite 把/api、/a2a、/.well-known代理到 8091。同样如实的是没做的事没有演示脚本没有 CI——GitHub Actions 仓库里一条都没有。对一个 10 月才开源的个人项目先把 README 的 curl 写对比补一套没人看的 CI 流水线优先级高。六、本篇学到什么A2A 的本质是 Card JSON-RPC服务方发布 Agent Card调用方发现后按message/send通信AgentScope 的 a2a-server 接受ReActAgent.Builder或AgentRunnerHarnessAgent 走后者。适配层的价值在翻译ChatA2aRunner 90 行把协议事件流翻译成 domain 的AgentInvokeRequest同步非流式是有意的取舍sessionId / userId 透传保证状态不分裂。客户端两来源一出口well-known 直连和 Nacos 发现产出同一个agentIda2a的 Handler上层无感Nacos 发现优先。Nacos AI 通道是 3.x gRPC8848 是传统配置中心9848 才是 Prompt / Card / Skill 的通道启动探活把连接问题前置到部署时。热更新要诚实onSystemPrompt 每轮拉是正确接线但 3.1 服务端对不上 3.2 客户端协议实测未打通写清楚限制比包装成特性更有价值。主干四篇到此收尾01 六边形内核、02 状态与治理、03 工具与检索、04 互通与协作项目完整代码在 GitHublogosssss/dream-scope有问题评论区见欢迎交流~
上一篇/下一篇内容由系统自动关联 返回资讯列表 →