可扩展性接缝:注册表驱动的供应商与工具
30 秒导读: 一个平台要活得久,难点不在「今天支持 Twilio」,而在「明天要接 Plivo、后天要接一个客户自建的 Asterisk,而这不能把核心代码改成一堆
if provider == "twilio"」。Dograh 的做法是把三处天然会变的东西——电话供应商、对话图的节点类型、LLM 能调的工具——都做成注册表插槽:每样新东西只写自己的文件夹、加一行 import 就接入,核心的编排/管线/路由代码永远只对着一个抽象基类和一张注册表说话。本章讲清这套「接缝」怎么设计、为什么这么设计。
本章是 Dograh 系列的收尾章。前面几章讲的是「系统怎么跑」: 对话即图是数据模型,实时语音管线是帧的流动, PipecatEngine是把图变成工具调用的状态机, 一次通话的编排是端到端串起来。本章讲的是「系统怎么长大」—— 新供应商、新节点、新工具从哪个缝里塞进去,而不用动上面这些核心。
1. 这是什么(零基础也能懂)
先说要解决的痛
假设你在做一个语音 AI 平台。第一版只接了 Twilio 打电话。很快你会遇到:
- 有客户在印度,要用 Plivo;有客户要 Telnyx 的呼叫控制;有客户干脆自建 Asterisk。
- 每家供应商的凭证字段不一样(Twilio 是
account_sid+auth_token,Vonage 是 JWT), 音频采样率不一样(Twilio 8kHz,Vonage 16kHz),回话格式不一样(TwiML vs NCCO JSON), 连配置表单都得为每家单独画。
最容易写坏的写法是让核心到处长出分支:
# 反面教材,非源码:每加一家供应商,这些 if 都要改一遍
def create_provider(name, config):
if name == "twilio":
return TwilioProvider(...)
elif name == "plivo":
return PlivoProvider(...)
elif name == "vonage": # 又要来改这里
return VonageProvider(...)
这种代码的病根:一个变化点(新供应商)散落在 N 个文件里(工厂、音频配置、schema、路由、前端表单), 加一家漏改一处就出 bug。
「接缝」的思路:让核心只认抽象和注册表
Dograh 的解法可以一句话概括:
把「会变的东西」收进一个不可变的描述对象(spec),丢进一张全局注册表;核心代码只查注册表、 只调抽象基类,永远不认识任何一家具体供应商的名字。
加一家新供应商 = 在 providers/<名字>/ 下写自己那份 spec + 实现类,并在一个 import 列表里加一行。
核心的工厂、管线、路由一个字都不用改——它们遍历注册表就自动看见了新成员。
三处接缝
Dograh 里用了同一套模式的地方有三个,面向三类「想加东西」的读者:
| 你想加的东西 | 接缝在哪 | 面向谁 |
|---|---|---|
| 新电话供应商(接个新运营商) | services/telephony/ provider 注册表 | 要接 Twilio/Plivo 之外的运营商 |
| 新对话节点类型(图里的新积木) | services/workflow/node_specs/ + services/integrations/ | 要给工作流加自定义节点 |
| 新工具(LLM 能调用的能力) | services/workflow/tools/ + MCP 会话 | 要给 agent 加计算器/知识库/外部 API/MCP |
三处长得几乎一样,学会一处就懂三处。本章以最完整的电话供应商为主线讲透模式, 再用节点和工具说明「同一套模式在不同场景怎么变形」。
2. 顶层全景(这套接缝大概怎么转)
一张图:注册表插槽的通用形状
三处接缝都是这个形状。以电话供应商为例,把抽象名字换成节点/工具也成立:
┌─────────────────────────────────────────────┐
加东西的人 ──▶ │ providers/<名字>/ (只碰这个文件夹) │
│ __init__.py ── 造一个 ProviderSpec 并 │
│ register(SPEC) ①自注册 │
│ provider.py ── 实现抽象基类的方法 │
│ config.py ── Pydantic 请求/响应模型 │
│ transport.py ── 造 pipecat transport │
└───────────────────┬─────────────────────────┘
│ import 触发 register()
▼
┌─────────────────────────────────────────────┐
│ registry._REGISTRY : {name → ProviderSpec} │ ②全局注册表
└───────────────────┬─────────────────────────┘
│ get(name) / all_specs()
┌────────────────────────────┼────────────────────────────┐
▼ ▼ ▼
factory.py run_pipeline.py routes/telephony.py
查 provider_cls 查 transport_factory 遍历 all_specs()
造实例 起 transport 按名字挂载路由
└──────────── 核心代码:只认抽象基类 + 注册表,不认名字 ──────────┘
│
▼ ③从 spec 生成
UI 表单 (ProviderUIField) · 校验 schema · 掩码规则 · 音频配置
怎么读这张图: 上半是「加东西的人」的活,全在自己文件夹里;中间是那张注册表;
下半是核心代码——它们只通过注册表接口(get/all_specs) 拿到成员,从不写供应商名字。
最下面一行是「白拿的红利」:UI、校验、掩码、音频参数都从同一个 spec 派生,不用另写。
三个不变量(整套模式的骨架)
| 编号 | 名字 | 是什么 | 电话供应商里的体现 |
|---|---|---|---|
| ① | 自注册 | 成员在 import 时把自己登记进注册表 | 每个 providers/<名字>/__init__.py 调 register(SPEC) |
| ② | 全局注册表 | 一个 {名字 → spec} 字典 + 查询函数 | registry._REGISTRY、get()、all_specs() |
| ③ | 从 spec 生成 | UI/schema/校验/参数都从 spec 派生,不重复写 | ui_metadata 生成表单、config_request_cls 校验、transport_sample_rate 定音频 |
记住这三点,后面每一节都是它们的具体化。
3. 接缝一:电话供应商(最完整的样板)
这节讲最全的一处。看懂它,节点和工具就是「同一套模式的简化版」。
3.1 抽象基类:核心眼里「一家供应商」长什么样
核心代码不认识 Twilio,只认识一个抽象类 TelephonyProvider——它规定了「任何一家供应商必须能做的事」。
真实实现见 api/services/telephony/base.py:68 的 TelephonyProvider(ABC)。它用 @abstractmethod
钉死了一组必须实现的方法(下面挑几个有代表性的):
| 抽象方法 | 干什么 | 为什么必须抽象 |
|---|---|---|
initiate_call | 发起一通外呼 | 各家 REST API 完全不同 |
parse_inbound_webhook | 把入站 webhook 解析成标准结构 | 各家 payload 字段名不同 |
verify_inbound_signature | 验签保安全 | 各家签名方案不同(HMAC/JWT/body 签名) |
start_inbound_stream | 接起入站呼叫、 起媒体流 | 有的回 TwiML,有的发 REST(见下) |
can_handle_webhook(classmethod) | 「这条 webhook 是我的吗?」 | 入站分发时用来认领 |
关键设计:标准化 DTO(数据传输对象)。 各家 API 五花八门,但核心不想处理这种差异, 于是基类定义了一组「归一化」的 dataclass,让所有供应商的输出都长成同一个样子:
CallInitiationResult(base.py:16)——外呼结果,统一成call_id/status/caller_number…NormalizedInboundData(base.py:44)——入站数据,统一成from_number/to_number/account_id…ProviderSyncResult(base.py:31)——把「DB 写成功但供应商 API 拒绝了」表达成一个非致命警告 (ok=False, message=...),而不是抛异常炸掉流程。
有了这些 DTO,核心的编排代码(见 04)处理的永远是标准形状, 「哪家供应商」的差异被挡在了基类实现里。
一个体现「抽象要贴合现实」的细节:
start_inbound_stream(base.py:321)的 docstring 明确区分两类供应商—— 标记响应型(Twilio/Plivo,直接返回 TwiML/XML)和呼叫控制型(Telnyx,发 REST 调用去接起并起流)。 抽象方法的返回值被设计成「可以是 Response 对象,也可以是 JSON」,正是为了同时容纳这两种截然不同的交互。