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 层不直接 importio.agentscope的类。
服务端入口是ScopeA2aServer(dream-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 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,优先级更高 |
// 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" 则直接抛异常终止启动。问题在启动时暴露,而不是第一次对话时。
// 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) | 与本地 workspaceskills/并存 |
一个容易混的点:Prompt 的 key 是 Prompt 资源名,不是配置中心里 Group=agent的 Card 数据,两套数据在 Nacos 控制台里也是不同的管理入口。
四、Prompt 热更新:理想接线与现实限制
这是全篇最值得如实写的部分。先看理想接线长什么样。
HarnessAgent的sysPrompt在build()时就固化了,PortsConfig组装时如果把 Nacos 拉到的提示词直接写进build(),那它就变成启动时的一次性快照。所以正确的挂法是中间件——ChatNacosPromptMiddleware实现MiddlewareBase,在onSystemPrompt回调里每轮推理时拉最新 Prompt:
// 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 用的是 Nacosv3.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),有问题评论区见,欢迎交流~