ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Cloudflare `agents/channels` 设计解析:为什么消息通道层选择彻底无状态

2026/9/18 14:17:53 拓冰建站 浏览量
Cloudflare `agents/channels` 设计解析:为什么消息通道层选择彻底无状态 Cloudflareagents/channels设计解析为什么消息通道层选择彻底无状态【免费下载链接】agentsBuild and deploy AI Agents on Cloudflare项目地址: https://gitcode.com/GitHub_Trending/agents1/agents本文基于agents在 Cloudflare Workers 上构建与部署 AI Agents 的 SDK仓库中的设计决策记录 design/channels.md 写成并以当前实现源码与配套 API 文档作为印证。它回答一个核心问题一套面向 Slack、Telegram、Email 等多平台的统一消息收发层在架构上应当把持久化、重试、去重、调度放在哪里。读完本文你将理解agents/channels的无状态核心 应用所有持久化设计哲学、每一条关键决策背后的取舍与驳回理由以及route、surface、identity、DeliveryResult、dispatchId等概念在源码中的真实形态与调用关系从而能在自己的 Agent 应用中正确地使用并扩展这套通道层。与本文配套的完整 API 使用指南见 docs/agents/channels.md设计文档本身刻意不描述 API只记录决策与理由避免随包演进而过时。需要指出的是agents/channels目前仍标记为实验性其接口在稳定发布前仍可能变化。一、核心决策Channels 是无状态的设计文档开门见山给出了整个包的基石Channels 无状态。它只负责三件事——认证并规范化normalize各提供方的输入、选择一条应用路由route、投递输出。它不持有任何存储、调度器、outbox发件箱、重试或去重逻辑所有与持久化相关的问题都属于应用层。为什么是所有权ownership而不是极简主义关键在于一个处理消息的应用本来就需要一个持久化的地方来存放消息——Agent、Durable Object、队列queue、Workflow 或数据库而应用自身的领域状态就住在那个存储里。如果消息库再私藏一份意图intents、尝试记录attempts、回执receipts、偏好preferences的第二存储就会和应用自己的存储形成竞争同一段对话出现两份记录出现两个关于什么已被送达的说法崩溃之后没有人能说清哪一份才是真的。更糟的是这还会固定死部署拓扑库内部的持久化意味着库需要 Durable Object 存储也就意味着一个简单集成无法运行在普通 Worker 上。因此这个包的工作不是提供持久化而是让应用自己拥有的持久化变得可能身份identities在重新投递和重新路由rerouting之间保持稳定结果outcomes诚实反映实际发生了什么目的地destinations是普通数据plain data应用可以持久化并在之后使用。设计文档强调这些对调用方的义务被写成了一份持久性契约durability contract放在包 README 中因为没有人读的保证就不是保证——契约全文可在 docs/agents/channels.md 看到后文详述。被否决的方案持久化的 Host最初的 Host 需要 Durable Object 存储和一个调度器并且自己拥有 outbox、重试退避retry backoff、入站回执、provider 引用索引、审批结算墓碑approval settlement tombstones和投递偏好。设计文档坦承它确实有用但它与已有持久化能力的调用方重复。更关键的是它从未实现过 exactly-once 的入站投递回调与回执写入之间发生崩溃会照样重放回调——所以它表面上提供的保证实际上是它无法兑现的。被否决的方案把持久化藏进每个 provider 适配器这会造成每 provider 的保证不一致、重复的存储逻辑并且让人无法说清重试和幂等到底属于哪一层。被推迟的方案无状态核心旁的持久化包装器注意这一条不是否决而是推迟。一个在无状态核心外层实现 outbox 和 inbox 的DurableChannelHost依然可能落地但在出现一个具体消费者验证接口之前不得发布——推测性地加入它会重新引入上述所有问题。这正是以需求驱动接口、而不是以臆想驱动接口的工程纪律。二、路由是应用策略每个 Channel 自带route设计文档的第一条派生决策是路由是应用策略并且附着在每个 Channel 上。每个 Channel 携带一个route函数它把一个规范化事件normalized event转成一个不透明的应用字符串例如 Durable Object 名称、队列键、数据库 id或者拒绝该事件。路由发生在认证和规范化之后因此适配器无需了解任何应用结构应用也无需了解 provider 的载荷结构。在源码 channel.ts 中ChannelRoute正是这样一个签名export type ChannelRouteTRaw unknown ( event: ChannelIngressEvent, raw: TRaw, context: ChannelRouteContext ) Awaitablestring | null;注意它返回string | null。设计文档专门指出拒绝必须写成显式的null而不是什么都不返回。原因有两个route 函数中的意外穿透fallthrough不能静默吞掉消息没有配置 route 必须能与故意忽略区分开。这一点在 Host 的实现里得到了严格执行host/index.ts 的#route方法在 route 返回undefined时直接抛错if (route undefined) { throw new Error( Channel route for ${channelKey} returned undefined; return null to ignore an event ); }那么谁来判断相关性设计文档给出了明确的边界适配器可以丢弃协议噪声——机器人回声bot echoes、编辑事件、入群/退群joins等 provider 装饰但绝不能因为认证过的人类消息看起来不相关就丢弃它私聊、提及、线程相关性都是应用策略所以有效消息必须连同用于判断所需的认证原始载荷一起到达路由层。内置路由策略源码在 routes.ts 提供了四个常用映射全部是确定性deterministic策略策略生成的路由适用场景routes.perEventevent:${eventId}每次入站都开一个新会话后续消息不再路由回原会话routes.perThreadthread:${thread.id}同一线程的所有事件进入同一会话routes.byIdentity(fallback?)identity:${identityKey}按发送者的通道身份分组不带 fallback 时无身份的事件被忽略routes.byUser(fallback)user:${user.id}优先解析已显式关联的用户再委托给后备策略byIdentity只把携带相同身份的事件分组从不推断两个不同身份属于同一个人——这种显式关联由应用完成再由byUser暴露给路由层。二者常组合使用例如routes.byUser(routes.byIdentity(routes.perEvent))。被否决的路由形态Host 级路由表把应用策略放进库配置里适配器自行评估路由会让应用在决策时刻拿不到原始载荷适配器级相关性过滤器在路由接缝routing seam之下静默丢弃输入。三、身份链接是显式的且路由时可见第二条派生决策Channels 从不推断两个身份属于同一个人。按地址匹配、按显示名匹配都不安全因此链接linking必须由应用自己显式执行。但一个已经记录了链接的应用仍应在关键时刻用得上它。为此Host 可以被赋予一个查询函数路由可以询问它这个 actor 是否是已知用户const host new ChannelHost({ channels, findUser: (identity) users.findUser(identity), onMessage, onApprovalResponse });在源码 host/index.ts 中findUser通过#routeContext暴露为路由上下文中的惰性findUser()——首次调用后才构造查询且查询结果被缓存避免同一事件多次解析。这正是个人 Agent 看到同一个人的 Slack 和 Email 落在同一会话而不靠猜的机制应用拥有存储Host只负责问。通道身份与用户身份ChannelIdentity在 identity.ts 中定义为{ channelKey, scope?, subject }——它必须命名观察它的已配置 Channel。scope是通道内的租户命名空间默认default例如一个已配置的 Slack 应用可以在多个工作区里观察到同一个用户 ID。identityKey()见 identity.ts用规范化后的三元组[channelKey, scope, subject]构造稳定键。如果应用还没有自己的用户身份存储包提供了开箱即用的 createUserIdentityStore它基于 Durable Object SQLite 建两张表cf_channels_users_v1与cf_channels_user_identity_links_v1提供link把通道身份挂到某个 user 下、findUser、linkChannelIdentities原子合并两个身份双方都已被链接时抛UserIdentityConflictError等操作。设计上接口 UserIdentityStore 的注释明确写着实现绝不从用户名、类邮箱值、显示名或消息 surface 推断链接每条链接都是显式的应用决策。被否决的方案自动身份解析automatic identity resolutionHost 自有的身份存储——那会原封不动地重新引入本设计想要移除的状态。四、Channel 自己选择入站工作第三条派生决策一个 Channel 被呈递offered一个输入如果它不感兴趣就返回 nothing。Host 按配置顺序逐个尝试每个 Channel第一个认领claims它的获胜。源码 host/index.ts 的handleRequest忠实地实现了这个模型async handleRequest(request: Request): PromiseResponse | undefined { for (const [channelKey, channel] of Object.entries(this.#channels)) { const ingress channel.ingress; if (!ingress) continue; try { const result await ingress.receive(request); if (!result) continue; // 不感兴趣换下一个 for (const envelope of result.events) { await this.#dispatch(channelKey, channel, envelope); } return result.response; // 认领成功 } catch { return new Response(Failed to handle Channel event, { status: 500 }); } } return undefined; }设计文档特别区分了declining婉拒和rejecting拒绝一个拥有该请求但发现签名错误的 Channel要返回自己的错误响应而不是返回 null 让下一个 Channel 接手——否则坏请求会被错误地转交给别的适配器。这个模型取代了旧的拆分模型过去 Host 自己按路径匹配 HTTP而 Email Channel 回答一个独立的谓词。新模型一条规则覆盖两者适配器可以匹配任何东西而不只是路径Host 侧不再存在可能与适配器自身校验不一致的匹配逻辑。选择机制的目的是区分可能认领同一输入的多个已配置 Channel——例如重叠的 webhook 路径或显式配置的多个邮箱——而不是在 Channel 认领之后决定应用是否关心这个事件。被接受的代价设计文档如实列出了两个代价配置顺序变得有意义先声明的优先Host无法再检测两个 Channel 挂载在同一路径上。这是单入口统一分发换来的可预测性与适配器自治。五、目的地是自描述的数据第四条派生决策一个目的地surface是自描述的数据。一个 surface 命名了能够到达它的已配置 Channel这样应用可以持久化一个 surface稍后再使用它而无需同时记住是哪个对象产生的。ChannelMessageSurface在 surface.ts 中定义为export type ChannelMessageSurface Readonly{ channelKey: TChannelKey; version: 1; address: TAddress; // 纯 JSON 值 label: string; // 创建时捕获的可读目的地文本 };关键设计点是Host 负责盖章channelKey适配器不知道自己被配置成了什么名字所以适配器产出的是没有channelKey的ChannelMessageSurfaceInputHost 通过stampSurface补齐见 host/index.ts。contactSurface同样是查配置→取 surface→盖章三步见 host/index.ts。复合目的地fallback 与 fanout先按顺序试这些、或者发给所有这些的复合目的地只是由 Host 安装的普通 Channel递归解析的普通 surfacefallback([a, b])生成channelKey: fallback、address: { surfaces: [...] }的 surfacefallback.tsfanout([a, b])同理生成channelKey: fanoutfanout.ts。在 Host 构造时fallback和fanout是保留键用户配置里出现这两个键会直接抛错POLICY_KEYS见 host/index.ts随后 Host 把fallbackChannel、fanoutChannel作为普通 Channel 安装进去。这也意味着你可以自己实现一个复合策略 Channel 注册在自己的键下再配一个写入该键的 surface 构造器——内置策略通过OutboundResolver见 channel.ts注入仅限出站解析的能力作为复刻样板。fallback()的语义是只在确认失败后才前进到下一个目的地因此它永远不会产生重复投递对流式发送它只在失败发生在目的地开始读取之前时才前进因为回放任意大的已消费前缀需要无界缓冲实现见 fallback.ts。fanout()发送到所有目的地部分成功或结果不确定时整体报告为uncertain合并逻辑见 fanout.ts。没有隐式默认目的地设计文档明确拒绝通道级默认目的地真实对话中有意义的目的地是当前恰好能触达对方的那个通道上的那个人这是通道级默认表达不了的而想要固定目的地的应用可以持久化一个属于自己的 surface。被否决的方案在调用点把 surface 与 Channel 配对——那不过是个缺字段的 surfaceHost 把 surface 依次呈递给每个 Channel 直到有人认领——因为 surface 之间太相似错投到错误的配置实例会是静默的而未知键会大声失败单独的 provider 标签——换来的是单一无歧义标识符代价是没有创建它的配置持久化数据将无法解读。后果配置键是持久标识符由此得出一个必须牢记的推论已配置的 channel 键是持久标识符。重命名一个 Channel 键会孤立orphan所有以旧名字持久化的 surface 和身份。这与surfaces 是可以持久化的普通 JSONsurface.ts 中的isChannelMessageSurface校验器即用于跨 Worker/DO 边界后的信封校验直接呼应——持久化引用的键必须稳定。六、出站尝试是单次的结果如实报告第五条派生决策一次deliver调用 一次 provider 尝试其结果必须区分三种情形见 channel.ts 的DeliveryResult状态含义delivered整条消息到达了读者注意不代表人读了failed一点都没到达retryable字段说明同一条路由能否再试uncertain未知比例的消息到达了读者再试一次或换路由都可能重复内容uncertain是最有趣的情形超时或发送中途崩溃之后没人能说清收件人是否收到。一个替你重试的库会把这种歧义变成重复消息。设计文档直白地指出只有调用方才知道重复和遗漏哪个更糟所以只有调用方才能决定是否重试。这是整个设计中最大的残余风险——而且它属于提供方没有幂等发送原语这一性质而非本包的性质。因此被否决的是自动重试以及在包内部按 delivery id 抑制重复发送后者需要持久化记录而这正是设计上刻意不保存的。源码层面ChannelDeliveryContext.deliveryIdchannel.ts被明确定义为调用方拥有的关联元数据而非幂等保证——适配器在 provider 存在幂等原语时可以把二者对应起来否则仅用于可观测性。流式发送的诚实降级Host 对不支持stream的 Channel 会在内部先收集全文再调用一次delivercollectAndDeliver见 host/index.ts生成中途失败也仍投递已产生的部分答案半截答案总比丢掉强但结果被降级为uncertain并携带CHANNEL_STREAM_INTERRUPTED错误码——这正是结果诚实反映实际发生什么的体现。七、交互标识符不携带信息审批链接只渲染不托管第六条派生决策审批请求的标识符interactionId是不透明的。把请求来源会话编码进标识符会让标识符成为一条隐蔽路由通道——一旦路由变化它就悄悄失效而且会诱使人把它当作授权凭证而它并不是。一旦决策与消息遵循同一套路由规则标识符就完全不需要携带路由信息在任何已链接通道上做出的决策都会到达发出请求的会话。应用负责结算settle第一个到达的终态决策获胜。因此被否决的是在标识符里嵌入路由以及 Host 自有的关联索引那又是状态。第七条派生决策是审批链接的处理方式provider 原生审批标识符在 provider 自己的控件里往返什么都不需要额外做provider 中立的审批需要一个公开链接而链接需要托管、签名密钥、过期时间和吊销机制——全是应用关注点且带有应用专属策略。所以 Channel 只渲染调用方提供的链接不替调用方验证任何东西。在源码 channel.ts 中ChannelApprovalLinks{ approve, reject }由getApprovalLinks?: () PromiseChannelApprovalLinks惰性提供且由调用方即 Host 的requestApproval流程提供并结算。一个被要求在没有链接的情况下请求审批的 Channel会诚实地报告失败而不是自己编造一个。设计文档为此留下一句犀利的总结一个裸露、可预测的交互标识符不能作为公开的审批 URL。八、设计如何落地dispatchId、入站分发与持久性契约稳定的dispatchId设计契约要求跨重投递稳定、不受路由影响的dispatchId。源码用配置键 事件 ID的元组哈希实现host/index.tsconst identity new TextEncoder().encode(JSON.stringify([channelKey, eventId])); const digest await crypto.subtle.digest(SHA-256, identity); return sha256:${hex(digest)};dispatchId只由已配置 Channel eventId推导因此与路由无关——即使 route 变化同一事件的去重键仍保持不变这正是身份在重新投递与重新路由之间保持稳定的实现基础。Host 的分发流程#dispatch见 host/index.ts先盖章 channelKey、再算 route、再算 dispatchId然后依次调用onRoute、onMessage/onApprovalResponse缺少对应回调时直接抛错把配置错误暴露在开发期。持久性契约Channels 保证什么、你的应用必须做什么设计文档承诺的契约被完整写入 API 文档 docs/agents/channels.md二者的义务划分如下Channels 保证你的应用必须dispatchId跨重投递稳定且不受路由影响在产生任何副作用之前用它去重Host 在 provider 确认前await你的回调在返回前持久化移交——DO RPC、队列发送或 Workflow 启动每次deliver()/stream()恰好一次出站尝试结果如实报告自行决定是否重试uncertain可能重复真实投递surface 是可以持久化的普通 JSON保持已配置 channel 键稳定决策以规范化事件到达携带你自己的interactionId自己结算interactionId不是授权凭证这条契约就是设计文档开篇那句话的兑现——保证没人读就不是保证。单入口收拢所有入站无状态设计还带来了一个可验证的实现红利所有配置 Channel 的 webhook 收在一个入口里Workers Email 走同样路径。示例见 docs/agents/channels.mdfetch里调host.handleRequest(request)email处理器里调host.handleEmail(message)返回null/false表示没有 Channel 认领回 404。Email 入站由ChannelEmailIngressingress.ts承载handleEmailhost/index.ts与 HTTP 走完全相同的按序尝试→认领→分发模型。九、结语agents/channels的设计文档是一份罕见的以否决记录取胜的架构文档。它的全部力量来自一个看似简单的前提消息库不该拥有第二个真相。由此推出的每一条决策——路由是附着于 Channel 的应用策略、身份链接显式化、入站由 Channel 自主认领、目的地是自描述数据、单次出站尝试并诚实报告、interactionId不透明、审批链接只渲染不托管——在packages/agents/src/channels/的源码里都有一一对应的实现点且每一步的被否决方案都指向同一个回归目标不重新引入状态。如果你正在构建跨平台消息 Agent最值得带走的三条经验是把dispatchId当作去重的第一道防线把 channel 键当作永久的持久化标识符把是否重试的决定权留在只有你才知道重复与遗漏孰轻孰重的调用方手里。想要深入可以继续阅读design/channels.md本文依据的设计决策记录、docs/agents/channels.md完整 API 指南含自定义 Channel 与 AI SDK 工具示例、以及源码 packages/agents/src/channels/实现与测试其中__tests__/目录覆盖了 host、routes、surface、fallback、fanout、identity 及各适配器的行为验证。【免费下载链接】agentsBuild and deploy AI Agents on Cloudflare项目地址: https://gitcode.com/GitHub_Trending/agents1/agents创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考