Channel与多端接入:TransportChannel抽象

一个agent同时服务多个客户端,是aptbot 0.2.x的核心场景:你在电脑上打开WebUI跟agent对话,出门切换到手机继续,回家在另一台电脑上接续。这要求 agent的状态独立于客户端连接——客户端可以断开重连,agent不应该重启,也不应该丢失对话上下文。

这个需求看起来简单,但实现起来有几个关键问题:如果两个客户端同时接入同一个agent,事件该推给谁?客户端断线重连后,如何拿到断线期间错过的历史?一个用户在电脑上发了消息,手机端怎么同步看到回复?

Channel抽象就是为了解决这些问题而诞生的。它把”如何与客户端通信”和”agent如何运行”拆成两个独立的问题。这篇文章从多端接入的根本挑战讲起,对比三种方案的设计取舍,最后深入看aptbot如何通过类型化事件总线、最小化Channel接口和bindSession机制来实现多端同步。

一、概念:什么是多端接入,为什么需要Channel抽象

1.1多端接入的核心问题

多端接入最朴素的实现是”每个客户端一个agent实例”——用户A连上来,创建一个agent实例服务A;用户B连上来,再创建一个agent实例服务B。但这在”同一用户多端切换”场景下会出问题:

  1. 状态分裂:用户在电脑上让agent改了文件,然后切换到手机。手机连接的是另一个agent实例,对刚才的修改一无所知。两个agent实例各自维护自己的上下文,彼此不通信,用户看到的对话历史不一致。
  2. 资源浪费:每个客户端一个agent实例意味着每个客户端都要维护一个完整的LLM上下文窗口。如果10个客户端接入,就是10个上下文窗口在内存里——即使其中9个客户端没有活跃对话。
  3. 无法多端同步:用户在电脑上发出指令,agent执行完毕后回复只在电脑上显示。用户拿起手机看不到刚才的对话——因为手机连接的是另一个agent实例。

正确的做法是:一个agent实例 + 多个客户端接入。客户端只是agent的”显示器”和”输入设备”,agent的状态不绑定于任何一个客户端。这样无论用户从哪个端接入,看到的是同一个agent会话。

1.2 Channel抽象的角色

要实现”一个agent + 多个客户端”,关键是把”消息怎么传”和”agent怎么运行”解耦。

Channel抽象就是这个解耦的桥梁:

  • Agent侧:只关心”产生事件”——LLM的流式输出、工具的调用结果、状态的变化,都作为事件发出。agent不关心这些事件最终怎么到达客户端。
  • Channel侧:只关心”消费事件”——把agent产生的事件转成客户端能理解的格式推送出去。Channel不关心agent怎么决策、怎么执行工具。

这种解耦带来的灵活性:你可以同时用WebSocket Channel(接WebUI)、CLI Channel(接命令行终端)、Telegram Channel(接手机IM),所有Channel接收的是同一套事件流。如果你要加一个新的接入方式(比如Slack),只需要写一个新的Channel实现,agent循环一行都不用改。

1.3事件生产和事件消费的解耦

Channel抽象背后的核心设计模式是事件生产与事件消费的解耦

传统客户端-服务端模式中,服务端直接向客户端推送消息。这隐含了一个假设:一个消息只有一个消费者。但在多端接入场景中,同一个消息可能有多个消费者(电脑WebUI + 手机WebApp + 日志记录器)。

解耦的方式是引入一个中间层——事件总线。agent把事件发到总线,总线负责把事件分发给所有感兴趣的人。agent不需要知道”谁在听”,消费者不需要知道”谁在说”。

这个模式在大型系统中非常常见(如Kafka、RabbitMQ),但aptbot是单进程应用,不需要分布式消息队列。aptbot在进程内实现了一个轻量级的事件总线,专门用于agent事件的分发。

二、通用设计方案:多端接入的常见架构模式

在agent中实现多端接入,有几个维度的设计选择。理解这些维度是分析具体方案的基础。

2.1 session与connection的关系

多端接入最核心的设计决策是:session(会话)和connection(连接)是绑定还是解耦?

  • 绑定模型:连接即session。用户打开WebSocket连接,系统创建一个session;断开连接,session销毁。这种方式简单直接,但无法支持”断开重连后保留会话”。
  • 解耦模型:session独立于连接存在。用户登录后系统分配(或用户创建)一个session,之后多个连接可以绑定到同一个session。连接断了session还在,下次重连后绑定回原来的session。

解耦模型显然是多端接入的正确选择,但它引入了额外的复杂度:session需要持久化(否则进程重启session就丢了)、session需要与连接建立映射关系、session事件需要广播给所有绑定它的连接。

2.2事件分发的策略

一个agent事件产生后,如何分发给客户端?三种常见策略:

广播(broadcast):每个事件推送给所有连接的客户端。最简单,但问题很明显——用户A的私密对话会被推送给用户B。广播只在单用户多端场景下可用。

单播(unicast):每个事件只推送给”发起请求的那个客户端”。这是传统请求-响应模式。但问题是:用户在电脑上发消息、手机上想看到回复——单播模式下手机看不到。

组播(multicast):每个事件推送给”绑定到当前session的所有客户端”。这是多端同步的正确策略——同一个session的所有客户端都收到事件。组播的分组依据是session。

2.3传输协议的选择

传输协议决定了事件怎么从服务端到达客户端。三种常见的实时传输协议:

WebSocket:全双工、低延迟,浏览器原生支持。适合需要流式推送的场景(agent的流式输出天然适合WebSocket)。缺点是自建协议的复杂度(心跳、重连、序列化)。

SSE(Server-Sent Events):服务端到客户端的单向流,基于HTTP长连接。比WebSocket简单(原生HTTP协议),浏览器支持好。但SSE是单向的——客户端到服务端还需要额外的HTTP请求。

长轮询(Long Polling):客户端定期发起HTTP请求,服务端在有事件时返回。实现最简单(只需要HTTP),但延迟较高,资源消耗大。适合事件频率低的场景。

三、市面其他多端接入方案对比

不同agent项目对”如何让多个客户端接入同一个agent”这个问题的回答差异很大。以下是三种有代表性的设计路线。

3.1方案A:agent + channel强绑定

这条路线的做法是:每个连接对应一个agent实例,连接即session,断开连接session即销毁。channel只是agent的”附属品”——agent创建时就决定了它使用什么channel。

设计特点:

  • 连接即session:用户连上来时创建一个session,断开时销毁。没有”断线重连保留上下文”的概念。
  • agent持有channel:agent实例内部持有channel引用,直接调用channel.send() 推送消息。不需要事件总线。
  • 一对一关系:一个agent实例只服务一个客户端。不存在多端共享agent的问题——因为压根不支持多端接入。
  • 实现极简:没有channel抽象层、没有事件总线、没有session管理器。agent循环直接向channel写数据。

优势:

  • 实现最简单——代码量最少,不需要任何基础设施
  • 逻辑最直白——读代码就能理解”agent怎么把消息发给客户端的”
  • 零额外延迟——没有总线、没有分发,agent直接推送给客户端

劣势:

  • 无法多端同步:用户不能在手机和电脑之间切换——第二个连接上来时,第一个连接已经销毁了对应的agent实例。用户在电脑上的对话不会同步到手机。
  • 断线即丢失:网络不稳定断开WebSocket时,agent正在执行的任务可能中途取消。用户即使立即重连,也回到了一个全新的session,刚才的对话历史没有了。
  • 资源浪费:每个连接一个agent实例意味着每个连接有独立的LLM上下文窗口。用户只是刷新页面就要重建一个agent实例——CPU和内存的浪费很大。

适用场景: 简单的demo项目、一次性对话场景(用户用完即走,不需要保留历史)。

3.2方案B:独立session层 + channel透传

这条路线的进步是引入了独立的session层:session不再绑定到连接,而是持久化的实体。channel则透穿在session和客户端之间——session通过channel收发消息。

设计特点:

  • session持久化:session有独立于连接的生命周期。连接断开后session保留,重连后恢复。
  • channel透传:session直接持有channel引用,事件通过channel推送给客户端。没有事件总线,session自己管理分发。
  • session与channel一一对应:一个session仍然只能绑定一个channel。不支持多端同时接入。
  • 连接恢复机制:客户端断线后可以带上sessionId重连,重新绑定到同一个session。

优势:

  • session持久化解决了”断线丢历史”的问题——客户端重连后能继续之前的对话
  • session层独立后,可以增加session管理功能(如session列表、session标签、session搜索)
  • 实现相对简单——只需要在方案A的基础上加一个session管理器

劣势:

  • 仍不支持多端同步:一个session只能绑定一个channel。用户不能在电脑发完消息、在手机上看回复——因为手机连接会”抢走”session的channel绑定。
  • 事件消费逻辑分散:session直接管channel的收发,意味着session代码里混合了”业务逻辑”(管理对话状态)和”传输逻辑”(怎么把消息发出去)。后续每加一种传输方式(从WebSocket到Telegram),session代码都需要修改。
  • 连接切换时状态丢失:用户从电脑切换到手机时,电脑的channel断开,手机的channel绑定上来。但电脑channel断开到手机channel绑定之间有一个时间窗口——期间agent产生的任何事件都丢失了(因为没有channel可以推送)。

适用场景: 单用户多设备轮换使用(一次只在一个设备上登录),需要保留对话历史但不需要实时多端同步。

3.3方案C:类型化事件总线 + Channel抽象 + 多对一共享

这条路线做了三个关键设计:

  1. 类型化事件总线:agent的所有输出都作为类型化事件发到总线上。总线不关心谁在消费这些事件。
  2. Channel抽象:每个接入方式实现Channel接口,从总线订阅事件并转发给客户端。agent和session都不知道Channel的存在。
  3. 多对一共享:一个session可以同时绑定多个Channel。总线把事件分发给所有绑定了该session的Channel。

设计特点:

  • 事件三层分离:agent只负责产生事件,总线负责分发事件,Channel负责传输事件。三层各司其职,互不干扰。
  • Channel接口最小化:实现一个新接入方式只需要实现几个简单的方法(如send、close、isAlive)。不需要理解agent的内部逻辑。
  • 类型安全的事件格式:所有事件有统一的类型定义(envelope),消费方按类型精确处理。不存在”收到一个JSON字符串,还要自己解析判断是什么事件”的情况。
  • 自动生命周期管理:Channel死了自动解绑,新Channel接入自动订阅,不需要手动管理映射关系。

优势:

  • 真正的多端同步——一个session绑定多个Channel,所有Channel收到同一份事件流
  • 加新接入方式成本低——实现Channel接口的4个方法即可,不碰agent循环
  • 事件格式标准化——所有接入方式消费同一种事件格式,不存在”WebSocket用一种格式、Telegram用另一种”的适配问题
  • 健壮性好——Channel死了不影响agent,agent死了所有Channel收到关闭事件

劣势:

  • 架构复杂——需要事件总线、Channel管理器、session-Channel映射表三套基础设施
  • 总线可能成为瓶颈——所有事件经过总线分发,如果总线实现效率低会影响整体性能
  • 调试困难——事件从agent到客户端经过三层(agent → bus → Channel → client),追踪问题时需要跨三层排查

适用场景: 需要多端实时同步的项目、需要接入多个不同传输协议的项目、架构清晰度要求高的项目。

3.4三种方案对比

维度 方案A(强绑定) 方案B(session + 透传) 方案C(总线 + 抽象 + 多对一)
核心哲学 连接即session,一对一 session持久化,channel透传 三层分离,多对一共享
session生命周期 绑定连接 独立于连接 独立于连接
多端同步 不支持 不支持(一一对应) 支持(多对一)
事件分发 agent直接推送 session直接推送 总线组播
传输层与业务层 不分离 部分分离(session管传输) 完全分离
加新接入方式 改agent循环 改session层 仅加Channel实现
断线恢复 丢失session 保留session,丢失中间事件 保留session,缓冲事件
实现复杂度
架构清晰度 低(混杂) 高(职责分明)

三条路线从简到繁,从紧耦合到松耦合。方案A适合最小可行产品,方案B适合需要session管理的单用户场景,方案C适合需要真正多端同步的产品。aptbot选择了方案C,因为它的定位就是”一个agent服务多端”。

四、aptbot的设计特点

aptbot选择了方案C——类型化事件总线 + Channel抽象 + 多对一共享。这套设计在aptbot中实际落地为以下几个具体组件。

4.1方案E类型化bus + AgentEventEnvelope设计动机

aptbot在0.2.x中实现的事件总线被称为”方案E”(Event-driven Engine),其核心设计围绕两个概念展开:

类型化事件总线(Bus):bus是aptbot内部的一个类型化EventEmitter。Agent产生的所有输出——LLM流式token、工具调用请求、工具执行结果、错误信息、状态变化——都作为类型化事件发到bus。

1
2
3
4
5
6
type AgentEvent =
| { type: 'llm_token'; content: string }
| { type: 'tool_call'; name: string; args: unknown }
| { type: 'tool_result'; name: string; result: unknown }
| { type: 'error'; message: string }
| { type: 'done'; summary: string };

AgentEventEnvelope(事件信封):每个事件在总线上传输时被包裹在一个”信封”中,包含事件的元信息:

1
2
3
4
5
6
7
interface AgentEventEnvelope {
id: string; // 事件唯一 ID
sessionId: string; // 所属 session
timestamp: number; // 事件发生时间
type: string; // 事件类型
payload: unknown; // 事件内容
}

为什么需要envelope?两个原因:

  1. 多路复用:bus上同时传输多个session的事件。envelope里的sessionId让Channel能过滤出”属于我的session”的事件。如果没有sessionId,每个Channel都得接收所有session的事件,然后自己过滤——浪费CPU也泄露隐私。
  2. 消费方信息充足:Channel收到一个envelope后,不需要额外查询就知道这个事件属于哪个session、什么时候发生的。这让Channel可以实现”事件缓冲”(客户端离线时缓存事件,重连后补发)而不需要额外维护映射表。

用bus而不是直接”agent → 客户端”推送的根本原因是:bus解耦了事件生产与事件消费。agent只往bus发事件,不管谁在收、收多少、收不收得到。Channel从bus订阅事件,不管谁在发、发多少、为什么发。这种解耦让agent和Channel可以独立演变。

4.2接口最小化:type / send / close / isAlive

Channel是aptbot中接入方式的标准接口。它只要求4个方法:

1
2
3
4
5
6
interface Channel {
type: string; // channel 类型标识
send(event: AgentEventEnvelope): void; // 推送事件给客户端
close(): void; // 关闭 channel
isAlive(): boolean; // channel 是否还活着
}

4个方法,这是有意的最小化设计:

  • type:标识Channel的类型(如 "websocket""telegram")。用于日志和监控,不参与业务逻辑。
  • send:核心方法。bus把事件分发给Channel时调用send。Channel的实现决定怎么把这个事件发送给客户端——WebSocket Channel序列化成JSON通过ws.send推送,Telegram Channel通过Bot API发送消息。
  • close:关闭Channel,释放资源。由bus在检测到Channel不存活时调用,或由上层系统关闭。
  • isAlive:健康检查。bus定期调用isAlive检查Channel是否还活着。如果返回false,bus会触发Channel死亡处理流程(自动解绑、清理资源)。

为什么接口这么小?

因为Channel的责任范围被严格限定在”传输“——它只负责”把已经封装好的事件从服务端送到客户端”。它不需要理解事件的含义、不需要关心session的状态、不需要处理用户输入。

接口最小化的另一面是客户端能力假设最小化——aptbot假设客户端只能”收事件”和”被关闭”,不假设客户端能查询、能恢复、能确认。这些更高的能力通过协议层(如WebSocket重连后的resync协议)实现,不进入Channel接口本身。这保证了即使是最简单的客户端(如一个只读的事件显示器),也能实现Channel接口。

4.3 wrapTransportChannel适配器

实际开发中,往往已经有成熟的传输层实现——Node.js的 ws 库、Telegram Bot SDK、Slack SDK。这些库已经有自己的连接管理、心跳、重连、消息序列化等机制。如果要求每个Channel实现从头写这些,代价太大。

wrapTransportChannel 是一个适配器函数,它把”已有的传输层实现”包装成Channel接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
function wrapTransportChannel(options: {
type: string;
send: (data: string) => void;
close: () => void;
isAlive: () => boolean;
}): Channel {
return {
type: options.type,
send: (event) => options.send(JSON.stringify(event)),
close: options.close,
isAlive: options.isAlive,
};
}

适配器做的事情很简单:

  1. 把AgentEventEnvelope序列化成传输层能消费的格式(如JSON字符串)
  2. 调用传输层的send方法发送出去
  3. 把传输层的close事件映射到Channel的close
  4. 把传输层的存活状态映射到Channel的isAlive

这个适配器的价值在于:开发者不需要重写传输层。如果你有一个WebSocket连接,你只需要调用 wrapTransportChannel({ type: 'websocket', send: ws.send, close: () => ws.close(), isAlive: () => ws.readyState === ws.OPEN }),就得到了一个标准的Channel实例。

适配器模式让aptbot的Channel系统保持了”零依赖”的优雅——核心接口不依赖任何第三方库,而第三方库通过适配器接入。这是”不重新发明轮子”的具体体现。

4.4 bindSession(sessionKey, channel) 多对一共享

Channel创建好了,接下来是把Channel绑定到session。核心API是 bindSession(sessionKey, channel)

多对一是最关键的设计:一个session可以同时绑定多个Channel。

具体场景:

  • 用户在电脑WebUI打开session X(Channel A绑定session X)
  • 用户在手机也打开session X(Channel B绑定session X)
  • agent处理了一条消息,发回的事件通过bus分发,同时推送给Channel A和Channel B
  • 电脑和手机看到的是同一份agent输出

这个过程agent完全不知道——它只往bus发事件,不关心谁在收。bus负责根据sessionId过滤事件,只推送给绑定了该session的Channel。

多对一共享带来的核心能力是”真正的多端同步“——不是”用户主动刷新才能看到最新消息”,而是”agent每产生一个事件,所有端实时收到”。用户在电脑上看到agent逐个输出token的同时,手机上也实时看到同样的token流。

4.5 WebSocket作为Channel实现

aptbot 0.2.x的主要Channel实现是WebSocket。WebUI是浏览器,自然用WebSocket与服务端双向通信。CLI通过WebSocket连服务端(同一台机器或远程VPS),也能接入同一个agent。

WebSocket Channel的工作流程:

  1. 连接建立:客户端通过HTTP升级协议建立WebSocket连接
  2. 身份认证:客户端在连接建立后发送认证消息,包含Bearer token和要绑定的sessionKey
  3. 绑定session:服务端验证token后,调用 bindSession(sessionKey, channel) 把WebSocket连接绑定到对应session
  4. 事件推送:agent产生事件 → bus分发 → Channel.send → ws.send(JSON.stringify(envelope))
  5. 客户端输入:客户端通过WebSocket发送用户消息 → 服务端解析后注入agent loop
  6. 连接断开:WebSocket触发close事件 → bus检测到Channel不存活 → 自动解绑

Channel多端接入架构

这个流程中最关键的是第3步——客户端决定绑定到哪个session,而不是服务端分配。这使得”断线重连保留session上下文”变得简单:客户端断线后记住sessionId,重连时带上同一个sessionId重新绑定,历史都在。

WebSocket Channel的实现细节中,一个值得注意的设计是事件缓冲。客户端断线后,bus会缓存最近N条事件(N可配置)。当客户端重连并绑定到同一个session后,bus把缓存的事件重新推送给新Channel,客户端补上断线期间错过的内容。这让”断线重连”的体验接近无缝——用户不会看到中间有一段空白。

4.6 dead-channel自动unbind

Channel可能”死掉”——网络断开、客户端崩溃、超时无响应。如果死掉的Channel不被清理,会出现几个问题:

  1. 事件丢失但自以为成功:bus调用dead channel的send方法,调用成功了(实际上数据丢进了虚空),bus以为客户端收到了,实际上没有。用户可能以为agent回复了但”没收到”。
  2. 资源泄漏:每个Channel在bus中占用一个订阅位置。dead channel不清理,订阅位置被占满后新的Channel无法订阅。Channel内部的缓冲区也可能持续增长。
  3. 幽灵连麦:dead channel不清理,bus以为客户端还在线。但实际上客户端已经重连建立了新的Channel,dead channel变成了”幽灵”。如果幽灵channel还占着session的绑定槽位,新的Channel无法绑定。

aptbot通过自动unbind 解决这些问题:

  • 定期健康检查:bus定期(如每30秒)调用所有Channel的 isAlive() 方法
  • 死亡判定的阈值:连续3次isAlive() 返回false,判定Channel死亡
  • 自动解绑和清理:死亡的Channel从session的绑定列表中移除,调用close() 释放资源

当Channel死亡后,如果客户端立即重连,会创建新的Channel并重新绑定到同一个session。dead channel的自动清理和新Channel的绑定是两个独立的过程——清理由健康检查触发,绑定由客户端发起。二者可能同时发生,但因为它们的操作对象不同(老channel的清理vs新channel的绑定),不会产生冲突。

五、发展方向

5.1 Telegram作为首条IM Channel

尽管aptbot的架构可以支持任意多个Channel实现,0.2.x只做了WebSocket。远期的第一条IM Channel规划是Telegram。

选择Telegram的原因:

  • Bot API成熟:Telegram的Bot API是IM平台中文档最完善、限制最宽松的。它支持webhook和polling两种模式,消息类型丰富(文本、图片、文件、按钮、内联查询),且更新频率稳定。
  • 多端天然支持:Telegram自己就是多端的(手机、桌面、Web)。aptbot接入Telegram后,用户可以在手机Telegram里与agent对话,在电脑Telegram里查看历史——Telegram自己负责多端同步,aptbot不需要额外工作。
  • 公开可达:Telegram Bot通过webhook接收消息。用户不需要在家庭网络开端口、不需要动态DNS、不需要反向代理。在VPS上跑一个aptbot实例,Telegram Bot把消息通过api.telegram.org转发过来。

接入Telegram的核心难点是消息模型差异:IM平台是”消息”模型——一条消息一次发送,内容固定;aptbot是”流式事件”模型——LLM的token逐个产生、工具调用和结果是独立的事件。把流式事件”折叠”成IM消息是一个适配问题。

一种可能的方案是消息聚合:在Telegram Channel中维护一个”当前正在发送的消息”缓冲区。agent产生的llm_token事件不断追加到缓冲区,直到产生完整的句子或达到最大消息长度时,一次性通过Bot API发送。工具调用事件则作为独立的后续消息发送。这样用户看到的是”agent逐句输出”,体验接近在Telegram里和一个真人聊天。

5.2 IM Channel接入的泛化

Telegram之后,适配更多IM平台(Discord、Slack、飞书、企业微信)是一个自然的方向。每个平台的差异主要在于:

  • 消息格式:Markdown、HTML、自定义消息卡片
  • 交互能力:按钮、下拉菜单、模态框
  • 文件传输:图片、文档、代码片段
  • 速率限制:每个平台不同的限频策略

但核心的Channel抽象不需要变化——所有IM Channel都实现同样的4个方法。差异通过配置参数和适配器内部的转换逻辑处理。这是Channel抽象的价值所在:接入1个IM和接入20个IM的工作量是线性增长的,不会因为架构不支持而爆炸。

小结

Channel与多端接入是agent “状态独立于客户端”的基础设施。

  1. 概念层面:多端接入的核心矛盾是”一个agent服务多个客户端”。朴素的”每个客户端一个agent实例”方案会导致状态分裂和资源浪费。正确的做法是通过Channel抽象把事件生产与事件消费解耦。

  2. 方案对比:方案A(agent + channel强绑定)连接即session,架构简单但体验差——断连丢失、无法多端同步。方案B(独立session层 + channel透传)解决了session持久化,但仍不支持多端同时接入。方案C(类型化事件总线 + Channel抽象 + 多对一共享)架构最清晰,但需要额外的事件总线基础设施。

  3. aptbot的设计:类型化事件总线(bus)+ AgentEventEnvelope解耦事件生产与消费;Channel接口最小化(type/send/close/isAlive)让接新端成本降到最低;wrapTransportChannel适配器复用成熟传输层;bindSession多对一共享实现真正的多端同步;dead-channel自动unbind避免幽灵连接和资源泄漏。目前WebSocket是主要实现,Telegram等IM Channel在远期规划中。

下一篇文章在Channel之上看Session系统:session的持久化、多用户隔离、多端同步的最终一致性模型,以及CLI的命令如何让session成为可组织的工作单元。