notebooklm-py 架构决策实录:Capability Protocol 模式从「胖联合体」到「可组合能力」的演进
2026/9/13 17:57:11 网站建设 项目流程

notebooklm-py 架构决策实录:Capability Protocol 模式从「胖联合体」到「可组合能力」的演进

【免费下载链接】notebooklm-pyUnofficial Python API and agentic skill for Google Gemini Notebook. Full programmatic access to NotebookLM's features—including capabilities the web UI doesn't expose—via Python, CLI, and AI agents like Claude Code, Codex, and OpenClaw.项目地址: https://gitcode.com/GitHub_Trending/no/notebooklm-py

本文以 docs/adr/0002-capability-protocol-pattern.md 为骨架,还原 notebooklm-py(Unofficial Google Gemini Notebook Python API)在解耦Session协作对象时采用的typing.Protocol能力模式,并沿 ADR-0013 / ADR-0014 的演进脉络,结合当前源码(src/notebooklm/_runtime/contracts.pysrc/notebooklm/_web/contracts.pysrc/notebooklm/_client_assembly.py)验证其最终形态。读完你会掌握:为什么 Python 里"我不能 import 的东西"要用 Protocol 来 type、什么是能力联合(fat union)反模式、以及一个真实开源项目如何用两条结构性规则(≥2 消费者才提升共享协议、单一消费者直接注入协作对象)完成自我修正。

一、问题背景:八个功能子客户端与一个Session的两难

在项目早期基线中,NotebookLMClient对外暴露了八个命名空间化的功能 API:notebookssourcesartifactschatresearchnotessettingssharing(此后客户端陆续又加入了mind_mapslabels等命名空间)。每个功能 API 都在独立模块中实现(_notebooks.py_sources.py_chat.py等),并且都需要结构化访问Session的协作对象能力:

  • RPC 分发(rpc_call
  • 认证路由(auth routing)
  • 请求 ID 分配(request-id allocation)
  • 轮询注册表(polling registry)
  • 传输簿记(transport bookkeeping)
  • 上传并发控制(upload concurrency)

设计当时有两个不可谈判的约束决定了架构走向:

  1. 子客户端(sub-client)绝不能直接 importSession。因为Session需要 import 子客户端才能把它们挂到NotebookLMClient.notebooks等属性上,若子客户端反向 importSession,就会形成循环依赖。时至今日,Mypy 仍通过TYPE_CHECKING门控来强制这一边界。
  2. 子客户端必须是可类型化的。当子客户端调用executor.rpc_call(...)时,Mypy 需要校验签名;如果参数类型写成Any,恰好会在"方法 ID 漂移会静默破坏"的地方让类型系统失效。

二、原始设计:Capability Protocol 模式与SessionCapabilities适配器

代码库用一套capability Protocol模式同时解决了上述两个约束:十个窄Protocol各自描述一个独立的协作对象表面:

CoreRPCProvider · SourceListProvider · CoreReqIdProvider ChatStreamingProvider · PollRegistryProvider · AuthRouteProvider CookieJarProvider · TransportOperationProvider UploadConcurrencyProvider · LoopAffinityProvider

每个 Protocol 描述的是当时审计能识别出的最小协作对象表面。随后一个具体的适配器类SessionCapabilities多重继承全部十个 Protocol,并把每个方法转发给底层的Session实例(历史实现位置为src/notebooklm/_capabilities.py:149-160)。子客户端在构造函数中接收SessionCapabilities参数,所有协作交互都经由它完成。

2.1 当时的决策(Decision 四项原则)

按 ADR-0002 的记录,tier-10 基线时期确立的模式是:

  1. src/notebooklm/_capabilities.py中按"一个协作对象表面一个 Protocol"定义窄能力Protocol类;
  2. 定义单个具体适配器SessionCapabilities,多重继承所有 Protocol 并转发给Session
  3. 子客户端构造函数接收SessionCapabilities实例而非Session实例;
  4. NotebookLMClient在 open 时构造一个SessionCapabilities适配器,并注入到每个子客户端。

当时该模式被Accepted,理由有三:

  • 子客户端只有一条导入路径(from ._capabilities import SessionCapabilities),避免了"每个子客户端各自罗列 Protocol 拼盘"的样板代码;
  • 保证每个 Protocol 至少有一个结构化实现者(Session经由适配器),Mypy 能端到端验证契约;
  • 该表面经历了 tier-7 线程安全改造和 tier-8 RPC/VCR 改造而未发生变动,经验上足够稳定。

2.2 想要与不想要的后果

想要的后果:

  • 在子客户端与Session之间建立了一条单一、经 Mypy 验证的接缝;
  • 能力 Protocol 在一个地方记录了协作对象图,便于对照(当时的实时图已迁移到 docs/architecture.md)。

不想要的后果(也是日落条款的起因):

  • 每个子客户端依赖的是"联合",而非它真正需要的子集NotebooksAPISettingsAPI根本不需要UploadConcurrencyProviderChatStreamingProvider,但当时它们两者都对外声明了。
  • Session在联合被钉住的情况下无法收缩到约 1,300 行以下。任何 Protocol 中出现的每个方法都必须保留在Session上(或保留在委托给Session的适配器上)。
  • _core.py:450-774的 property-bridge 动物园部分原因正是联合强制Session暴露那些已被物理迁移进接缝的属性——这一点详见 ADR-0001。
  • 适配器开始泄漏私有内部_capabilities.py:230转发了_core._begin_transport_post这个带下划线前缀的方法,窄协议契约已经开始滑向私有领地。
  • ChatStreamingProvider的 docstring 公开自述为过渡态:"Chat-aware error mapping still lives onSession.query_postuntil that is extracted into a chat-owned transport."——即胖联合被文档明确标注为"尚未完成的工作"。

(以上_capabilities.py_core.py及精确行号均为 ADR-0002 写作时期的历史引用,_capabilities.py在 D2 cutover 时已被删除,当前仓库中不存在该文件。)

三、审计结论:胖联合是"披着 Protocol 外衣的上帝接口"

一次内部架构审计(代号 disease D2)将上述结果归类为fat-union god-interface wearing a Protocol mask:十个 Protocol 单独看都很窄,但每个子客户端都拿到的是联合,因此子客户端实际依赖的有效契约是完整的十 Protocol 表面。设计之初期望的"收窄"从未发生,因为适配器提前把它们合并了。

审计给出的建议是:让每个子客户端按它实际用到的能力子集来标注类型,并删除SessionCapabilities适配器Session将结构化地满足每个窄 Protocol;由于 Python 的 structural sub-typing(结构化子类型)天然成立,运行时适配器根本不需要。这项工作被编排为 D2 cutover(架构疾病治理弧线的 Wave 3)。

3.1 当时考虑过的替代方案

ADR-0002 记录了五条被拒绝或采纳的替代路径,是理解模式边界的最佳教材:

方案结论理由
每个子客户端各自的窄Protocol(D2 cutover 的最终选择)✅ 采纳为替代方案每个子客户端只声明自己实际用到的表面;Session无需修改,结构化子类型自动满足;效果是NotebooksAPI只依赖CoreRPC + AuthRoute。代价是约新增 8 个 Protocol 类。当时未选是因为优先"单一路径"胜过"最小耦合",审计在观察到长期耦合成本后重新排序
构造函数注入独立协作对象 dataclass❌ 拒绝会迫使每个子客户端构造函数接收 4–7 个类型化参数,每个测试都要构造那么多 fake。Protocol 模式严格来说更符合人体工学,错的只是"联合的形状",而非结构化类型方法本身
直接以Session作为子客户端类型❌ 拒绝制造循环导入问题并破坏分层。Protocol 模式正是 Python 对"请把我 type 成我不能 import 的东西"的标准答案
typing.Protocol+runtime_checkable=True+isinstance守卫(不用适配器类)❌ 拒绝runtime_checkableProtocol 做isinstance检查既慢也不更安全(不检查方法签名),当时判断是"无收益的成本"
在本 ADR 中直接删除SessionCapabilities❌ 拒绝删除必须与按子客户端引入 Protocol 配对进行;先删会让迁移窗口内每个子客户端的core参数退化成Any,丢失模式本要交付的类型安全收益。D2 cutover 以原子方式编排这次交换

四、第一代修正:ADR-0013 可组合能力模型(Shared vs Feature-local)

ADR-0002 的审计建议在 ADR-0013 中落地为可组合能力模型。核心是把能力分成两类,并据此制定结构性的提升规则:

  • SHARED(共享):被 ≥2 个功能使用的能力,才允许提升为_runtime/contracts.py中的模块级 Protocol。例如逻辑 RPC 分发rpc_call被每个功能 API 使用;循环亲和性断言被 chat 和 artifact polling 使用。
  • FEATURE-LOCAL(功能本地):只被恰好一个功能使用的能力,留在所属功能模块内。例如transport_post+ chat 手动next_reqid簿记(只有 chat 需要);drain-hook 注册(只有 artifact polling 注册关闭钩子)。

ADR-0013 明确解释了为什么需要这条"≥2 消费者"的硬规则:ADR-0010 原本把Session: Protocol钉在恰好五个成员(rpc_calltransport_postnext_reqidassert_bound_loopoperation_scope),但在_session_contracts.py中它已膨胀到八个成员——authkernel是为了上传流程便利而提升的,register_drain_hook与独立的DrainHookRegistrationProtocol 重复。"先提升再说"(promote it just in case)正是漂移的温床,必须用结构性规则而非劝说来阻止。

此外 ADR-0013 还规定:功能构造函数按能力命名依赖,而非按宽泛的Session。纯 RPC 功能(NotebooksAPIResearchAPISettingsAPISharingAPI)只取rpc: RpcCaller;多能力功能(ChatAPIArtifactsAPISourceUploadPipeline)则直接以关键字参数接收各协作对象。

五、第二代修正:ADR-0014 把接口模型补全为实现模型

ADR-0013 解决了编译期(interface 层面),但运行时仍然把Session实例传给每个功能 API——Session仍是所有 Protocol 的通用满足者。这带来四个可观测后果(详见 ADR-0014):

  1. Session必须满足所有功能 Protocol 的联合:给ChatRuntime加一个方法,Session就必须暴露(或转发)该方法,方法数随功能数增长。
  2. 转发是结构性强制而非偶然Session.transport_post之所以存在,是因为ChatRuntime要求它。
  3. 测试 monkeypatch 的是Session而非协作对象:这直接喂养了 ADR-0007 的禁止 monkeypatch 白名单。
  4. RpcOwnerProtocol 携带下划线前缀的Session私有内部_kernel_perform_authed_post_await_refresh_increment_metrics),这是"私有的Session表面被结构化地类型化"。

ADR-0014 的六条实现规则(当前仍生效的核心部分)是:

  • Rule 1 — 单协作对象 Protocol 直接由协作对象满足(方法下推):RpcCallerRpcExecutor直接满足;LoopGuardClientLifecycle直接满足;OperationScopeProvider与 drain-hook 注册由CallSupervisor直接满足。
  • Rule 2 — 复合 Protocol 仅在"值得"时用功能本地适配器满足:判断标准是意图式的三条(有下游模块把整个复合作为单一依赖、或委托改变了调用形状、或多个消费者共享)。否则直接构造注入底层协作对象,不设适配器中间人。
  • Rule 3 —NotebookLMClient.__init__是装配根(composition root):每个功能与它的满足者显式接线,构造代码读起来就是一张接线图。
  • Rule 5 — 协作对象直接接收其真实依赖RpcExecutor从持有owner引用改为kerneltransportauth_refreshmetrics四个关键字参数,RpcOwnerProtocol 随之消失。

六、当前源码验证:终态是什么样

对照当前仓库,ADR 中描述的演进确实已全部落地:

  1. src/notebooklm/_capabilities.py已不存在(find 结果为空),SessionCapabilities适配器如 ADR-0002 的 Status 所述在 D2 cutover 时被删除。
  2. src/notebooklm/_runtime/contracts.py现在只保留LoopGuard(第 31–34 行),一个assert_bound_loop单方法 Protocol。该模块 docstring 明确写着:按 ADR-0013,"只有被 ≥2 个功能共享的 Protocol 才能住在这里;单一消费者能力留在所属功能模块(例如AuthMetadata住在_web/sources/upload.py)"。曾与Session一并存在的复合 ProtocolArtifactsRuntimeUploadRuntime及其适配器 dataclass 均已退役,AsyncWorkRuntime复合协议也因不足两个消费者被删除(issue #1327)。
  3. KernelRpcCaller按后端拆分修订移到了src/notebooklm/_web/contracts.pyKernel(第 13–32 行)描述纯传输表面——post(url, headers, body, ...)get_http_client(...)cookies属性、aclose()RpcCaller(第 35–50 行)描述窄 RPC 分发表面——rpc_call(method, params, source_path, allow_null, ...),参数签名的完整性一目了然,这正是"子客户端调用rpc_call时 Mypy 能校验签名"的实现基础。
  4. 装配根是src/notebooklm/_client_assembly.py_assemble_client(...)(第 132 行起):负责归一化根级输入、选择后端、冻结生命周期,并把各协作对象注入功能 API。这与 ADR-0014 Rule 3"__init__是 composition root"完全吻合。

从代码结构看,当前客户端还引入了 ADR-0002 之后才出现的命名空间(mind_mapslabels、后端偏好backend_preference等),它们遵循同样的模式:单消费者能力本地化,多消费者能力提升为共享 Protocol,功能构造函数以关键字参数直接接收窄协作对象

七、留给读者的设计教训

把 ADR-0002 → ADR-0013 → ADR-0014 连起来读,是一条完整且罕见的"自我修正"弧线:

  1. Protocol 是 Python 处理"不能 import 的对象"的类型标准答案,但窄 Protocol 的联合也会退化成上帝接口——判断标准不是"每个 Protocol 多窄",而是"每个消费者实际依赖多窄"。
  2. 需要一条结构性提升规则:共享协议必须等第二个真实消费者出现才能提升(≥2 consumers ⇒ shared),否则"先提升再说"会一路漂移。
  3. 接口模型与实现模型必须匹配:编译期类型收窄了,运行时却仍把统一对象传进去,类型系统的好处就打了折扣;方法应下推到真正拥有它们的协作对象上。
  4. 适配器只在该"值得"时才存在:单一消费者 + 1:1 委托时,直接注入底层协作对象比适配器中间层更清晰。
  5. 架构决策记录(ADR)的价值在此刻显现:docs/adr/README.md 中的编号是只追加的,ADR-0002 被标记为Superseded而非删除,历史上下文得以完整保留;这正是 ADR-0002 自己示范的"记录决策为何存在,让后来者不必重新争论或静默地重蹈覆辙"。

如果你想继续深挖,推荐按此路径阅读:先看 docs/architecture.md 的实时架构图,再对照_runtime/contracts.py_web/contracts.py的协议表面,最后在_client_assembly.py_assemble_client中观察接线方式,你会得到与这篇 ADR 完全互证的全貌。

【免费下载链接】notebooklm-pyUnofficial Python API and agentic skill for Google Gemini Notebook. Full programmatic access to NotebookLM's features—including capabilities the web UI doesn't expose—via Python, CLI, and AI agents like Claude Code, Codex, and OpenClaw.项目地址: https://gitcode.com/GitHub_Trending/no/notebooklm-py

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询