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,再把结果包回协议层的事件流。三个细节有取舍含义:
- 同步非流式 。
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 包装成本地 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),有问题评论区见,欢迎交流~
