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

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 负责把运行状态暴露出去。全部是可选装配------不开 Nacos,8091 的 chat 服务照常跑。

一、A2A Server:把 chat 暴露成 A2A 服务

A2A(Agent-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 层不直接 import io.agentscope 的类。

服务端入口是 ScopeA2aServer(dream-scope-adapter),它组装官方的 AgentScopeA2aServer 加一条 JSON-RPC transport:

bash 复制代码
// 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:

bash 复制代码
// dream-scope-adapter/.../a2a/ChatA2aRunner.java(节选)
final class ChatA2aRunner implements AgentRunner {

    @Override
    public Flux<AgentEvent> streamEvents(List<Msg> 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,再把结果包回协议层的事件流。三个细节有取舍含义:

  1. 同步非流式 。chat.handle() 跑完才把整个输出包成一个 TextBlockDeltaEvent 发出去,Agent Card 里 capabilities.streaming 也是 false。远端调用方拿到的是一次性的完整回答,不是逐 token 的流。对一个对外暴露的服务来说,同步返回比流式好治理------超时、重试、幂等都是在"一次请求一次响应"的模型下才好设计。
  2. sessionId / userId 透传。A2A 请求里的会话信息被原样传给 domain 层,远端调用者的多轮对话状态和本地调用者一样落在 Redis 里,A2A 不会变成第二套状态存储。
  3. stop() 留空是诚实的 。message/send 同步跑完,没有后台任务可停,注释直说,不假装支持取消。

手写的 A2aSupport 是单测对照

adapter 里还有个 A2aSupport,手写了 Agent Card 的 Map 结构和 JSON-RPC 请求/应答的编解码。类注释写得很清楚:产品路径走官方 a2a-server,这些手写代码是给单测当对照用的。这个做法值得留意------协议编解码最容易在细节上出错(比如 parts 里混入非 text 类型时的处理),测试里有一份手写的期望结构,官方封装的行为变化立刻能测出来。

二、A2A Client:两种方式接远端 Agent

调用方向反过来,ScopeA2aClientAgent 用官方 a2a-client 的 A2aAgent 把远端 Agent 包装成本地 AgentHandler,agentId 固定为 a2a。对上层调用方来说,调它和调 chat 没有区别------同一个 AgentInvokeRequest 进、AgentInvokeResult 出,HTTP 层无感。

远端地址有两个来源,组合根里是两个互斥的 Bean:

来源 触发条件 发现方式
well-known 直连 配了 dream-scope.a2a.remote-url 拉远端 /.well-known/agent-card.json
Nacos 发现 dream-scope.nacos.a2a.discovery-enabled=true NacosAgentCardResolver 按 Agent 名拉 Card,优先级更高
bash 复制代码
// 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_ENABLED Spring Cloud 配置中心,启动时用 dream-scope.yaml 覆盖本地配置 false
DREAM_SCOPE_NACOS_DISCOVERY_ENABLED Spring Cloud 服务发现 false
dream-scope.nacos.enabled AgentScope AI 通道(Prompt / A2A / Skill) false

第三套是本篇的重点。它不是走 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" 则直接抛异常终止启动。问题在启动时暴露,而不是第一次对话时。

bash 复制代码
// 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 的用法
Prompt Prompt 资源名(如 dream-scope-chat) NacosPromptListener 拉正文,喂给中间件
Agent Card 按 Agent 名精确 getAgentCard A2A 注册 / 发现
Skill ZIP 包(NacosSkillRepository) 与本地 workspace skills/ 并存

一个容易混的点:Prompt 的 key 是 Prompt 资源名,不是配置中心里 Group=agent 的 Card 数据,两套数据在 Nacos 控制台里也是不同的管理入口。

四、Prompt 热更新:理想接线与现实限制

这是全篇最值得如实写的部分。先看理想接线长什么样。

HarnessAgent 的 sysPrompt 在 build() 时就固化了,PortsConfig 组装时如果把 Nacos 拉到的提示词直接写进 build(),那它就变成启动时的一次性快照。所以正确的挂法是中间件------ChatNacosPromptMiddleware 实现 MiddlewareBase,在 onSystemPrompt 回调里每轮推理时拉最新 Prompt:

bash 复制代码
// dream-scope-adapter/.../nacos/ChatNacosPromptMiddleware.java(节选)
@Override
public Mono<String> 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 用的是 Nacos v3.1.0 镜像,而 AgentScope 2.0.3 带的 Nacos AI 客户端发的 QueryPromptRequest,3.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、prometheus,management.prometheus.metrics.export.enabled=true 打开 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 或 AgentRunner,HarnessAgent 走后者。
  • 适配层的价值在翻译 :ChatA2aRunner 90 行,把协议事件流翻译成 domain 的 AgentInvokeRequest;同步非流式是有意的取舍,sessionId / userId 透传保证状态不分裂。
  • 客户端两来源一出口 :well-known 直连和 Nacos 发现产出同一个 agentId=a2a 的 Handler,上层无感;Nacos 发现优先。
  • Nacos AI 通道是 3.x gRPC:8848 是传统配置中心,9848 才是 Prompt / Card / Skill 的通道;启动探活把连接问题前置到部署时。
  • 热更新要诚实:onSystemPrompt 每轮拉是正确接线,但 3.1 服务端对不上 3.2 客户端协议,实测未打通;写清楚限制比包装成特性更有价值。

主干四篇到此收尾:01 六边形内核、02 状态与治理、03 工具与检索、04 互通与协作,项目完整代码在 GitHub(logosssss/dream-scope),有问题评论区见,欢迎交流~

相关推荐
马剑威(威哥爱编程)1 小时前
【AI全栈后端12-02】Spring Boot 跑通第一个 AI 对话接口:HR 政策问答机器人实战
java·人工智能·spring boot·机器人
果霸大叔1 小时前
RAG 数据导入与解析全攻略(二):图文与 PDF 解析——OCR、多模态大模型与九种 PDF 工具选型
人工智能
IT枫斗者枫哥1 小时前
AI返回合法JSON,字段就可信吗?给抽取结果补一道业务校验
java·人工智能·后端
旋生万物1 小时前
素数螺旋映射 $z_n=n^{1+i}$ 的角分布统计检验与零模型对比
大数据·前端·人工智能·算法·云原生·螺旋生成论·螺旋相位
天天被压力1 小时前
【别再到处找免费股票数据API了:官方204个接口,32篇一次讲透 #06】Python实时行情总报错?五档盘口+逐笔一次跑通
java·人工智能·python
智能RPA1 小时前
农业与矿业行业智能体自动化平台对比评测(计量与巡检场景)
运维·人工智能·python·自动化·agent·rpa
easyeye1231 小时前
用开源的Toonflow和MiniMax H3一步步复刻万妖
人工智能
byte轻骑兵1 小时前
VCP核心缩写概览
人工智能·音视频·le audio·低功耗蓝牙音频
user4465117917911 小时前
AgentScope 2.0 框架技术解析
agent
茶杯6751 小时前
AI重构电商视觉生产 极睿科技AGI Ecpro助力行业数字化升级
人工智能·ai重构电商·极睿科技·agi ecpro·极睿科技—agi ecpro