Agent Scope Java 2.x 系列【38】Harness:Gateway 与 Channel
文章目录
1. Gateway 与 Channel 是什么
1.1 背景
我们一直是这样直接调用 agent 的:
agent.streamEvents(new UserMessage(message), ctx); // ctx 里手动塞 userId / sessionId
这在写 demo、跑测试时没问题。但一旦要把它变成一个真实的线上服务,立刻会冒出一堆"和 agent 本身无关、却每个项目都要重写一遍的工程问题:
- 会话从哪来? :每个用户、每段对话都要一个稳定的
sessionId,否则记忆会串或丢。这个映射谁来维护? - 同一会话并发了怎么办? :用户手快连发两条,两次推理同时跑同一段记忆,会互相踩。需要串行化。
- 多个 agent 怎么分发?: 有
sales、support两个agent,一条消息该给谁? - 对接平台怎么办? :钉钉是持久
WebSocket、飞书是事件回调、GitHub是webhook……每个平台的"收消息 / 回消息"形态都不一样。
如果没有一层统一抽象,这些都得在每个 controller 里手写。Gateway / Channel 就是 Harness 为这层"接入与会话治理"提供的标准件:把上面四件事收敛成可复用的基础设施,让你的业务代码只关心"发什么、收什么"。
1.2 Gateway:会话与路由的中枢
Gateway是位于"传输层"和agent之间的中枢:它不关心消息从哪个平台来,只负责把消息正确地、有序地、带着一致记忆地送到对的agent,再把结果送回去。
类比:它很像 Web 框架里的 DispatcherServlet :HTTP 请求进来,由它做会话识别、并发控制、路由到对应 Controller。Gateway 对 agent 也是这个角色。
它具体管三件事:
- 会话管理:把每个用户对话映射到稳定的
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)):
- 解析入站消息:把平台消息封成框架的
InboundMessage; - 解析路由:
router.resolveRoute(config, message)→RouteResult(选定目标agent+ 会话上下文 + 回信地址); - 交给 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 和多个 Channel;Channel 是"入口适配器",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 对话
当 agent 用 expose_to_user=true spawn 子 agent 时,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 — 事件订阅回调
- GitHub —
Issue/PR评论webhook - GitLab —
Note hook - 企业微信 — 加密回调
⚠️ 说明:这些适配器是独立扩展模块,并不包含在本系列 demo 的依赖里(本工程只引入了
agentscope-core与agentscope-harness)。它们的 Maven 坐标与各平台的接入细节(协议、回调地址、加密方式)请以官方"Channel 适配器集成总览"为准。原理上它们都和第 7 节的自定义 Channel 一样——实现Channel接口、把平台消息转成InboundMessage交给 gateway。
更多推荐


所有评论(0)