1. Gateway 与 Channel 是什么

1.1 背景

我们一直是这样直接调用 agent 的:

agent.streamEvents(new UserMessage(message), ctx);   // ctx 里手动塞 userId / sessionId

这在写 demo、跑测试时没问题。但一旦要把它变成一个真实的线上服务,立刻会冒出一堆"和 agent 本身无关、却每个项目都要重写一遍的工程问题:

  • 会话从哪来? :每个用户、每段对话都要一个稳定的 sessionId,否则记忆会串或丢。这个映射谁来维护?
  • 同一会话并发了怎么办? :用户手快连发两条,两次推理同时跑同一段记忆,会互相踩。需要串行化
  • 多个 agent 怎么分发?: 有 salessupport 两个 agent,一条消息该给谁?
  • 对接平台怎么办? :钉钉是持久 WebSocket、飞书是事件回调、GitHubwebhook……每个平台的"收消息 / 回消息"形态都不一样。

如果没有一层统一抽象,这些都得在每个 controller 里手写。Gateway / Channel 就是 Harness 为这层"接入与会话治理"提供的标准件:把上面四件事收敛成可复用的基础设施,让你的业务代码只关心"发什么、收什么"。

1.2 Gateway:会话与路由的中枢

Gateway 是位于"传输层"和 agent 之间的中枢:它不关心消息从哪个平台来,只负责把消息正确地、有序地、带着一致记忆地送到对的 agent,再把结果送回去。

类比:它很像 Web 框架里的 DispatcherServletHTTP 请求进来,由它做会话识别、并发控制、路由到对应 ControllerGatewayagent 也是这个角色。

它具体管三件事:

  • 会话管理:把每个用户对话映射到稳定的 sessionId,·agent· 跨轮次看到一致的记忆(底层有 SessionIdUtils 等负责生成/规整)。
  • Per-session 并发控制:同一 session 的并发消息公平排队、串行执行(底层是 SessionTurnGate,本质是每个 session 一把公平信号量 Semaphore(1, true)),agent 不会和自己抢同一段记忆。
  • Agent 路由:多 agent 场景下,按规则把每条消息路由到正确的 agent(产出一个 RouteResult,里面有目标 agentId、会话上下文 MsgContext、回信地址 OutboundAddress)。

Gateway 本身是个接口,核心方法就是 run(MsgContext, messages) / runStream(...)在某个会话上下文里把这批消息跑一遍

1.3 Channel:消息平台的适配器

Channel 是某个具体消息平台的"适配器/驱动":把平台的原生消息形态,翻译成 Gateway 能理解的统一路由模型,再把 agent 的回复翻译回平台能投递的形态。

类比:它很像 JDBC 驱动,上层用统一的 Connection/Statement,每种数据库用各自的驱动适配底层协议。

Channel 之于消息平台同理:上层是统一的 Gateway 路由模型,钉钉/飞书/Slack 各用自己的 Channel 适配各自的协议。

一个 Channel 要做的事就三步(对应接口里的 dispatch(InboundMessage)):

  1. 解析入站消息:把平台消息封成框架的 InboundMessage
  2. 解析路由router.resolveRoute(config, message)RouteResult(选定目标 agent + 会话上下文 + 回信地址);
  3. 交给 Gateway 执行并投递回复gateway.run(route.context(), message.messages(), route.outboundAddress())

内置的 ChatUiChannel 就是一个"面向 HTTP/SSE 前端"的 Channel;钉钉、飞书等则是面向各 IM 平台的 Channel(第 8 节)。

1.4 架构位置

把三者串起来看,数据流是这样的:

 用户 / 消息平台 (HTTP、WebSocket、钉钉、飞书…)
        │  平台原生消息
        ▼
 ┌─────────────┐   把平台消息适配成统一模型
 │  Channel    │   dispatch(InboundMessage) → resolveRoute → RouteResult
 └─────────────┘
        │  (MsgContext, messages, OutboundAddress)
        ▼
 ┌─────────────┐   会话映射 / per-session 串行 / 多 agent 路由
 │  Gateway    │   run / runStream
 └─────────────┘
        │  在对的会话上下文里调用
        ▼
 ┌─────────────┐
 │ HarnessAgent│  (可能再委派子 agent,见【36】【37】)
 └─────────────┘

一个 Gateway 可以挂多个 agent多个 ChannelChannel 是"入口适配器",Gateway 是"会话与路由中枢",agent` 是"真正干活的"。

好消息:大多数场景你不需要直接碰 Gateway 或 Channel——agent.channel(...) 会在后台把它们全部接好(懒加载 gateway + 注册 agent + 注入 channel)。只有当你要对接新平台、或要多 agent 路由时,才需要显式打交道(第 6、7 节)。


2. 快速开始

单个 agent 想对外服务,一行绑定一个 ChatUiChannel 即可:

HarnessAgent agent = HarnessAgent.builder()
        .name("assistant")
        .sysPrompt("你是一个有用的助手。")
        .model("dashscope:qwen-plus")
        .build();

// 绑定一个 ChatUI channel
ChatUiChannel chat = agent.channel(ChatUiChannel.create());

// 发送消息。每个 userId 自动获得独立的 session。
Msg reply = chat.send(SendOptions.userId("user-1"), "你好!").block();

// 同一个用户、同一个 session —— 对话自然延续
Msg followUp = chat.send(SendOptions.userId("user-1"), "再多说一些。").block();

// 不同用户 → 不同 session,互不干扰
Msg otherUser = chat.send(SendOptions.userId("user-2"), "你好").block();

agent.channel(...) 做的事:懒加载创建内部 gateway → 注册当前 agent → 把 gateway 注入 channel。调用返回后,chat 就能直接用了。这也是【37】里 expose_to_user 能"零配置生效"的原因,bridge 在这一步顺带接好了。


3. SendOptions:会话模型

SendOptions 告诉 channel 谁在说话、这属于哪个对话、要不要指定 agent

工厂方法 行为
SendOptions.userId("user-1") 每个用户一个 session(最常用)
SendOptions.of("user-1", "session-a") 指定 session——同一用户开多个独立对话
SendOptions.userId("user-1").withAgentId("support") 多 agent 场景下路由到指定 agent
// 同一用户,两个互不影响的对话
chat.send(SendOptions.of("user-1", "session-a"), "话题 A").block();
chat.send(SendOptions.of("user-1", "session-b"), "话题 B").block();

记住:session 是记忆的边界。同 (userId, sessionId) 就是同一段记忆;换 sessionId 就是另起炉灶。


4. 流式事件 + SSE

sendStream() 返回 Flux<AgentEvent>——和 agent.streamEvents() 一样细粒度的事件流,但经过 gateway 路由、带会话管理

chat.sendStream(SendOptions.userId("user-1"), "北京今天天气怎么样?")
    .doOnNext(event -> {
        if (event instanceof TextBlockDeltaEvent delta) {
            System.out.print(delta.getDelta());                 // 正文增量
        } else if (event instanceof ToolCallStartEvent tc) {
            System.out.println("\n[tool] " + tc.getToolCallName());
        }
    })
    .blockLast();

接成 Spring Boot SSE Controller

@GetMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> chat(@RequestParam String message,
                                          @RequestParam String userId,
                                          @RequestParam(required = false) String sessionId) {
    SendOptions options = (sessionId != null)
            ? SendOptions.of(userId, sessionId)
            : SendOptions.userId(userId);

    return chat.sendStream(options, message)
            .map(event -> {
                Map<String, Object> payload = new LinkedHashMap<>();
                payload.put("type", event.getType().name());
                payload.put("id",   event.getId());
                if (event instanceof TextBlockDeltaEvent delta) {
                    payload.put("delta", delta.getDelta());
                } else if (event instanceof SubagentExposedEvent se) {     // 子 agent 暴露事件
                    payload.put("subagentId", se.getSubagentId());
                    payload.put("agentId",    se.getAgentId());
                    payload.put("label",      se.getLabel());
                }
                return ServerSentEvent.<String>builder()
                        .data(objectMapper.writeValueAsString(payload))
                        .build();
            });
}

5. 与暴露的子 Agent 对话

agentexpose_to_user=true spawnagent 时,gateway 会把它暴露为用户可直接寻址的入口,并在 sendStream() 流里发出一个 SubagentExposedEvent

5.1 发现暴露的子 Agent

AtomicReference<String> subagentId = new AtomicReference<>();

chat.sendStream(SendOptions.userId("user-1"), "找一个研究员帮我调查 AI 趋势")
    .doOnNext(event -> {
        if (event instanceof SubagentExposedEvent se) {
            subagentId.set(se.getSubagentId());                  // 记下句柄
            System.out.printf("子 Agent 已暴露: id=%s agent=%s label=%s%n",
                    se.getSubagentId(), se.getAgentId(), se.getLabel());
        }
        if (event instanceof TextBlockDeltaEvent delta) {
            System.out.print(delta.getDelta());
        }
    })
    .blockLast();

SubagentExposedEvent 的四个字段(均已在 RC4 核实):

字段 说明
subagentId 用于向该子 agent 发消息的句柄
agentId 子 agent 类型(如 "researcher"
sessionId 子 agent 的 session id
label 可选的人类可读名称

5.2 向子 Agent 发消息(绕过父 agent)

// 非流式
Msg reply = chat.sendToSubagent(subagentId, "重点关注 LLM agent").block();

// 流式
chat.sendToSubagentStream(subagentId, "重点关注 LLM agent")
    .doOnNext(event -> {
        if (event instanceof TextBlockDeltaEvent delta) {
            System.out.print(delta.getDelta());
        }
    })
    .blockLast();

5.3 同时支持主 agent 与子 agent 的 SSE

一个典型 controller:带 subagentId 就发给子 agent,否则发给主 agent

@GetMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> chat(@RequestParam String userId,
                                          @RequestParam String message,
                                          @RequestParam(required = false) String subagentId) {
    Flux<AgentEvent> events = (subagentId != null)
            ? chat.sendToSubagentStream(subagentId, message)        // 直连子 agent
            : chat.sendStream(SendOptions.userId(userId), message); // 走主 agent
    return events.map(event -> toSSE(event));
}

前端监听 SUBAGENT_EXPOSED 事件 → 渲染一个新的"对话标签页",后续该标签页的请求把 subagentId 传回来即可。这就是"分支对话"的完整闭环。


6. 多 HarnessAgent 路由(GatewayBootstrap)

agent.channel(...) 适合单个 agent。如果你有多个 HarnessAgent 实例要共用一个入口,用 GatewayBootstrap

HarnessAgent salesAgent = HarnessAgent.builder()
        .name("sales").sysPrompt("你是一个销售助手。")
        .model("dashscope:qwen-plus").build();

HarnessAgent supportAgent = HarnessAgent.builder()
        .name("support").sysPrompt("你是一个客服 agent。")
        .model("dashscope:qwen-plus").build();

GatewayBootstrap gw = GatewayBootstrap.builder()
        .agent("sales", salesAgent)
        .agent("support", supportAgent)
        .mainAgent("sales")          // 没有显式指定 agent 时的默认
        .build();

ChatUiChannel chat = gw.chatUiChannel();

6.1 按 agentId 路由

SendOptions.withAgentId(...) 指定目标 agent;不指定就走 mainAgent

// 路由到 sales(默认 main agent)
chat.send(SendOptions.userId("user-1"), "有什么产品?").block();

// 显式路由到 support
chat.send(SendOptions.userId("user-1").withAgentId("support"), "账单问题").block();

6.2 GatewayBootstrap 模式下暴露子 Agent

agent.channel(...) 会自动接好 bridge;但 GatewayBootstrap 模式下,要启用 expose_to_user,需要手动把 gateway bridge 接到每个 agent 的子 agent 中间件上

GatewayBootstrap gw = GatewayBootstrap.builder()
        .agent("main", mainAgent)
        .build();

// 取出 bridge,让 agent_spawn(expose_to_user=true) 生效
SubagentGatewayBridge bridge = gw.gatewayBridge();
// 通过 SubagentsMiddleware.setGatewayBridge(bridge) 传给 agent 的子 agent 中间件

agent.channel(...) 时这步会自动完成;只有手搭 GatewayBootstrap 时才需要显式 setGatewayBridge(...)


7. 自定义 Channel

要对接一个新平台(Slack、自研 IM……),实现 Channel 接口即可。它的核心是把平台消息转成 InboundMessage,再交给 gateway 路由:

public class MySlackChannel implements Channel {

    private Gateway gateway;
    private final ChannelRouter router = ...;   // 路由器

    @Override public String channelId() { return "slack"; }
    @Override public ChannelConfig config() { return myConfig; }

    // init/start/stop 在接口里是 default 方法,按需 override
    @Override public void init(Gateway gateway) { this.gateway = gateway; }
    @Override public void start() { /* 连接 Slack */ }
    @Override public void stop()  { /* 断开连接 */ }

    // 非流式分发:解析路由 → 交给 gateway 跑 → 拿到回复
    @Override
    public Mono<Msg> dispatch(InboundMessage message) {
        RouteResult route = router.resolveRoute(config(), message);
        return gateway.run(route.context(), message.messages(), route.outboundAddress());
    }

    // 可选:流式分发
    @Override
    public Flux<AgentEvent> dispatchStream(InboundMessage message) {
        RouteResult route = router.resolveRoute(config(), message);
        return gateway.runStream(route.context(), message.messages(), route.outboundAddress());
    }
}

通过 GatewayBootstrap 注册并启停:

GatewayBootstrap gw = GatewayBootstrap.builder()
        .agent("main", agent)
        .channel(new MySlackChannel())
        .build();

gw.start();   // 依次调用所有 channel 的 init() + start()
// ... 运行中 ...
gw.stop();    // 依次调用所有 channel 的 stop()

8. 内置 Channel 适配器

AgentScope 还提供了多个开箱即用的 Channel 适配器,作为独立扩展模块

  • 钉钉Stream 协议(持久 WebSocket
  • 飞书 / Lark — 事件订阅回调
  • GitHubIssue / PR 评论 webhook
  • GitLabNote hook
  • 企业微信 — 加密回调

⚠️ 说明:这些适配器是独立扩展模块,并不包含在本系列 demo 的依赖里(本工程只引入了 agentscope-coreagentscope-harness)。它们的 Maven 坐标与各平台的接入细节(协议、回调地址、加密方式)请以官方"Channel 适配器集成总览"为准。原理上它们都和第 7 节的自定义 Channel 一样——实现 Channel 接口、把平台消息转成 InboundMessage 交给 gateway。


Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐