Agent Gateway 设计笔记:统一外部接入与投递

0 阅读12分钟

26-cover

你做了一个 Agent,先接 CLI,一切很清楚:用户输入一句话,系统推理、调工具、返回结果。

后来前端希望用 HTTP 调一次,最好直接拿到最终回复;移动端希望用 WebSocket 收实时进度;业务系统希望把任务交给 Agent,完成后再投递到指定通道。

这时问题已经不是“再写一个 Channel”。

本篇只讲一个点:Gateway 的价值不是多一个 Web API,而是把外部系统接入 Agent 的身份、会话、等待和投递语义统一起来。

问题入口

普通 channel 通常绑定一个平台。TelegramChannel 处理 Telegram,QQBotChannel 处理 QQ,CLIChannel 处理本地命令行。它们的主要工作是适配平台消息格式和交互能力。

Gateway 面对的是另一类入口:外部应用不想实现某个平台适配器,只想通过 HTTP 或 WebSocket 调用 Agent。它可能是网页、移动端、Webhook、企业系统,也可能是另一个 Agent。

如果直接把这些请求塞进 Agent Loop,会出现三个问题。

第一,身份边界不清。API token、平台 user_id、WebSocket 连接、配对码和管理员权限混在一起,核心循环被迫处理网络鉴权。

第二,时间模型不匹配。HTTP 请求希望短时间返回,Agent 任务可能经历模型推理、工具调用、审批等待和后台投递。

第三,投递语义不稳定。同一条最终回答,可能要同步返回给 HTTP 客户端,也可能要推送给 WebSocket,还可能被路由到另一个 channel/chat。

举个具体例子:前端发来“帮我分析这份日志,并把结论同步到运维群”。HTTP 请求本身只表达一次调用,但 Agent 内部会产生会话、媒体缓存、模型推理、文件解析、最终回复和跨通道投递。若没有 Gateway,这些语义要么被塞进业务接口,要么被下沉到 Agent Loop,最后核心循环会越来越像一团网络适配代码。

Gateway 不是 Agent 外面套一层 HTTP 服务,而是同步协议和异步智能体之间的协议桥。

Gateway 边界

通道系统解决的是“不同平台如何接入 Agent”。Gateway 解决的是“外部系统如何通过统一网络入口接入 Agent”。

维度普通 ChannelGateway
面向对象具体聊天平台或本地入口外部应用、前端、自动化服务、A2A
输入来源平台原生消息HTTP / WebSocket 请求
输出方式调平台 API 发送HTTP wait 回复、WebSocket 推送、跨通道投递
核心治理平台适配、体验能力鉴权、限流、会话、超时、路由、健康检查
产品语义平台交互入口可编程 API 契约

为了不停留在抽象层面,下面以 echo-agent 的实现为例。它把 Gateway 设计成通道系统之上的 API 层:外部请求先转换成标准 InboundEvent,Agent 输出再转换成 HTTP 返回、WebSocket 消息或 DeliveryRouter 投递。

Gateway 并不替代所有通道。它让外部系统共享一个入口,同时仍保留内部事件模型:

channel = gateway:{platform}
session_key = gateway:{platform}:{chat_id}

这一步很重要。统一入口不等于统一上下文。不同 platform、不同 chat_id 仍要形成不同 session,否则 Web、移动端和内部系统可能串到同一段对话历史里。

这个边界也解释了 Gateway 与 Channel 的取舍。Channel 更关心“某个平台怎么说话”:图片如何解析、消息如何编辑、reaction 怎么发。Gateway 更关心“外部系统怎样稳定调用”:谁能访问、属于哪个 session、等待多久、失败怎么表达、最终结果投递到哪里。前者偏适配,后者偏契约。

入口治理

Gateway 首先是信任边界。

它暴露 HTTP、WebSocket、Playground、A2A 和会话接口。一旦部署到团队网络或公网,请求主体就不再只是本机用户,而可能是浏览器、脚本、Webhook、移动端、内部系统甚至恶意请求。

echo-agent 的 GatewayConfig 不只是 host/port。它包含平台开关、reply mode、rate limit rpm、认证模式、API token、token header、管理员用户、配对码 TTL、session reset policy、媒体缓存目录和缓存大小。

26-信任边界

认证分两层。

第一层是 API token,用来保护消息入口和管理接口。token 可以来自配置 header,也可以来自 Authorization bearer。比较时使用 hmac.compare_digest(),避免普通字符串比较的时序问题。

第二层是用户授权。

模式含义适用场景
open所有用户通过本地开发、受控实验
allowlistuser_idplatform:user_id 必须在名单内固定客户端、内部系统
pairingallowlist 直接通过,其他用户用配对码授权临时授权、用户自助接入

配对不是简单发一个 code。管理端调用 /pair 为平台生成配对码,用户调用 /pair/verify 提交 platformuser_id 和 code。配对码默认 300 秒过期;验证失败会计数并触发短期 lockout;认证行为写入 audit.jsonl

入口统一,权限不能统一放大。

限流也在 Gateway 完成。RateLimiter 使用 token bucket,key 通常是 platform:chat_id,没有 chat_id 时按 platform 限流。HTTP 被限流返回 429,WebSocket 被限流返回 error。

这不是普通 API 的装饰功能。一次 Agent 请求背后可能触发模型预算、工具执行和真实副作用。没有限流,错误集成也可能把系统打满。

所以 Gateway 的默认姿态应该保守。open 模式适合本地开发,不适合公网暴露;allowlist 适合固定内部客户端;pairing 适合需要临时授权的用户入口。若还启用了高风险工具、外部网络或无人值守审批,Gateway 层更要先把主体身份和请求来源固定下来。

请求归一

Gateway 注册的入口包括 /api/v1/message/ws/api/v1/health/api/v1/sessions/api/v1/pair/api/v1/stats。如果 A2A 配置启用且传入 agent loop,还会注册 /.well-known/agent.json/a2a

最核心的路径是 POST /api/v1/message

async def handle_message(request):
    verify_api_token(request)
    body = await parse_json(request)
​
    platform = body["platform"]
    user_id = body["user_id"]
    chat_id = body["chat_id"]
    text = body.get("text")
    media_urls = body.get("media_urls", [])
​
    require(text or media_urls)
    authorize_user(platform, user_id)
    rate_limit(platform, chat_id)
​
    session_key = f"gateway:{platform}:{chat_id}"
    maybe_reset_session(session_key)
​
    content = build_text_blocks(text)
    for url in media_urls:
        path = await media_cache.download(url, platform)
        content.append(ContentBlock(type=ContentType.FILE, url=str(path)))
​
    event = InboundEvent(
        channel=f"gateway:{platform}",
        sender_id=user_id,
        chat_id=chat_id,
        content=content,
        session_key_override=session_key,
        metadata={"gateway": True, "platform": platform, "user_id": user_id},
    )
    return await publish_or_wait(event, body)

这里有两处容易被低估。

第一,媒体不是直接把远程 URL 丢给 Agent。Gateway 会下载并缓存 media_urls,文件名使用 URL sha256 前 16 位,并按 platform 分目录存储;超过缓存上限时按 mtime 删除旧文件。下载后的媒体进入 ContentBlock(type=ContentType.FILE, url=str(path)),Agent 后续处理的是本地路径。

第二,会话重置不应散落在 Agent Loop 里。SessionResetPolicy 支持 noneidledailyboth。客服场景可以长时间不活跃后清空上下文,个人助理场景可以选择不自动重置。重置会记录 last_reset_atreset_count

这套归一化还有一个隐含好处:Agent Loop 看到的是干净事件,而不是外部 payload。它不需要知道 token header 来自哪里,也不需要判断媒体 URL 是否还能访问,更不需要理解 HTTP 请求体里哪些字段应该被忽略。入口越公共,越应该在 Gateway 压缩攻击面,把内部系统暴露给模型前先变成稳定结构。

协议桥

异步 API 返回 accepted 就够了,但很多外部应用希望一次 HTTP 请求得到最终回复。Gateway 的 wait=true 解决的就是这个问题。

关键是:Agent Loop 不应该因此变成同步函数。Gateway 只是在入口层创建 pending future,把标准 InboundEvent 发布到 MessageBus,然后等待对应的最终 OutboundEvent

26-协议桥

核心机制很短:

future = asyncio.get_event_loop().create_future()
self._pending_http[event.event_id] = future
​
await self._bus.publish_inbound(event)
payload = await asyncio.wait_for(future, timeout=timeout_seconds)

GatewayServer 初始化时订阅 outbound global handler:

self._bus.subscribe_outbound_global(self._handle_outbound)

当 Agent 输出到来时,Gateway 只处理 channelgateway: 开头的事件。它用 metadata 中的 _inbound_event_idreply_to_id 找到 correlation id。只有 is_final=True 的事件会完成 HTTP wait,进度消息不会误触发完成。

timeout_seconds 被限制在 1 到 600 秒之间,默认 180 秒。pending 请求最多 500 个,超过返回 503。超时返回 504,并带上 event_idsession_key。任务可能仍在后台继续,只是 HTTP 请求不再等待。

这个取舍很清楚:短任务可以 wait;长任务不应强行占住 HTTP 连接,而应返回 accepted,再通过 WebSocket、轮询或投递获得后续状态。

如果没有这层协议桥,系统通常会落入两个坏状态。要么强行同步,把复杂 Agent 任务压进一个请求响应周期,最后被代理超时、客户端断连和长尾延迟拖垮;要么粗暴异步,只返回 accepted,却不给客户端稳定的状态和最终结果路径。Gateway 的职责就是把 accepted、running、completed、timeout 这些时间状态产品化。

实时与投递

WebSocket 解决的是会话级实时通道。

客户端连接 /ws 后,先发送 auth 消息,包含 platformuser_idchat_id 和 token。Gateway 校验通过后构造同样的 session_key,并把连接保存到 _ws_clients[session_key]。之后客户端发送 message,Gateway 构造 InboundEvent.text_message() 发布到 bus。

未认证前发送 message 会收到错误。WebSocket 还支持 ping/pong。

这里要避免一个误解:WebSocket 连接不是长期权限凭证。连接建立只说明当时认证通过,关键操作仍要能回到用户、会话和权限语义上。

输出侧,Gateway 同时做两件事:如果存在 pending HTTP,就在 final event 时 resolve future;如果同一 session 有 WebSocket 客户端,就把 payload 广播出去。

DeliveryRouter 负责另一类投递:把某些 outbound event 路由到另一个 channel/chat。

26-投递路由

它支持通用 rule,也提供 cron route 和 mirror route。cron route 可以根据 source_session_key 把定时任务输出投递到目标通道;mirror route 可以把某个 source channel 的输出镜像到另一个目标。

为避免循环转发,路由后的 event metadata 会加 _routed=True。DeliveryRouter 遇到已 routed 的事件会跳过。路由时保留 contentreply_to_idis_finalmessage_kind,只修改 channelchat_id

这说明跨平台投递不应该重新生成回答。Agent 已经产出语义结果,Gateway 只负责把结果送到合适位置。

投递路由也要有边界。镜像输出可以提升可见性,但不应绕过权限;cron 结果可以投递到群聊,但应该保留来源 session;已经 routed 的事件必须跳过,避免循环投递。真正生产可用的投递系统,关注的不只是“发出去了”,还要能解释发给了谁、为什么发、是否重复、失败后如何处理。

生产可用性

Gateway 一旦对外开放,API 就变成产品契约。

外部系统会依赖请求格式、响应格式、错误码、超时语义、认证方式、会话规则和 WebSocket 事件。内部 Agent Loop 可以快速演进,但 Gateway 的对外语义不能随着实现细节随意变化。

echo-agent 还在 Gateway 中放入 HookRegistry、GatewayHealthProvider、ProgressiveEditor 和 A2A 集成。Hook 可以在启动、停止、认证成功、认证失败、消息接收、会话重置等事件上触发扩展逻辑。HealthProvider 汇总运行状态、WebSocket 客户端数量、媒体缓存大小、delivery rules 数量和 Gateway sessions;/health 根据状态返回 200 或 503。

判断一个 Gateway 是否生产可用,不看 endpoint 数量,而看这些工程项是否成立。

检查项合格标准
输入校验invalid JSON、空内容、非法媒体都有明确错误
身份治理API token、allowlist、pairing、audit 可追踪
限流保护HTTP 与 WebSocket 都按 platform/session 限流
wait 语义final event 才完成 future,超时和 pending 上限明确
会话隔离gateway:{platform}:{chat_id} 稳定生成,reset 有策略
媒体处理远程 URL 转本地缓存,大小和清理策略可控
投递治理HTTP、WebSocket、router 各自有清晰投递语义
可观测性health、stats、hooks、错误码和关联 ID 可用于排障

会提供 /api/v1/message 只说明能被调用;能否生产可用,要看 Gateway 是否把身份、状态、时间和投递都变成稳定契约。

测试也应该围绕这些契约写,而不是只测“接口能通”。HTTP message 至少要覆盖 invalid JSON、空内容、未授权、限流、bus 满载、wait=false accepted、wait=true completed、wait 超时和 pending 超限。Outbound resolution 要覆盖非 gateway channel 被忽略、非 final 不完成 future、无 correlation id 不报错,以及 WebSocket session 能收到输出。认证要覆盖 open、allowlist、bearer token、pairing 成功、过期 code 和错误 code。

小结

Gateway 表面上是外部接入层,深层上是产品化边界和信任边界。

echo-agent 的做法是:外部 HTTP 和 WebSocket 请求先经过认证、限流、会话、媒体和 reset policy 治理,再转换成标准 InboundEvent;Agent 输出再根据场景转换成 HTTP wait 回复、WebSocket 推送或 DeliveryRouter 跨平台投递。

这样 Agent Loop 仍然保持事件驱动,不必理解每一种外部协议。外部系统也获得了稳定接入契约:可以同步等待,可以实时订阅,可以异步投递,也可以通过健康检查判断服务是否可用。

Gateway 的核心判断很简单:统一入口只是第一步,真正重要的是统一入口之后,风险没有被放大,状态没有被串扰,投递没有失控,外部 API 没有变成内部实现的泄漏口。

这也是 Gateway 从“接口层”走向“平台边界”的根本原因。

(全篇完)


本文为 echo-agent 设计笔记系列第 26 篇。项目源码已开源至 GitHub。如果你对工业级 Agent 的工程落地感兴趣,欢迎加入技术交流群(QQ群号:47572014)参与日常讨论。下一篇我们将探讨 《给 Agent 加一套可观测性:trace、健康检查与遥测》,敬请期待。