能力三角色:Definition/Provider/Consumer

约 20 分钟

解释能力分层中 Definition、Provider、Consumer 三角色如何把接口与实现解耦并支持配置切换。

上一课你通过订阅工具执行前事件,在审批钩子中用 allowlist 判断、以 bail 阻断未授权调用并记录日志,再从授权与未授权两条路径验证了阻断与放行。你已用过插件、service、inject、ctx.effect()。第 1-3 课也说明了:模型适配器、工具、会话、Agent 循环都是插件。现在看这些插件之间的接口纪律:一个能力由谁定义、由谁实现、由谁使用?

dsh 把答案分成三个角色:Definition、Provider、Consumer。文档把这条边界叫作 capability seam(能力接缝),并单列了 capability layering(能力分层)主题;教程目录页名为“能力三角色:Definition / Provider / Consumer”。这三个词在源码目录、文档页和配置里反复出现,先认角色再读代码。

记住一句话:接口与实现分离,实现由配置选择。先把三角色讲清,下一课你就要亲手实现并热替换一个 Provider。

核心概念

能力接缝:Definition / Provider / Consumer

先看生活类比。墙上的电源插座只约定一种标准:孔位、电压、频率。发电厂、充电宝、太阳能板都按这个标准供电,谁发的电并不重要。手机也只认插座标准,从不关心电来自哪家电厂。换电源很容易:拔掉旧插头,插上新插头,手机照常工作。

技术定义:能力接缝(capability seam)是一条可替换的边界,由三个角色组成。

  • Definition:声明能力的接口。它只规定“提供什么、长什么样”,不写怎么实现。接口不变,是所有实现都要遵守的契约。在 dsh 里,接口以服务(service)的形式存在。
  • Provider:实现这个接口,并把实现注册进共享上下文 ctx。Provider 是插件,可以有多个,彼此可替换。
  • Consumer:通过 inject 声明依赖,拿到的是接口本身,而不是某个具体实现。Consumer 只认识 Definition。

选择哪个 Provider 发生在配置层,不在代码里。Consumer 只依赖接口,所以替换实现时,Consumer 一行都不用改。配置里哪一行生效,谁就是当前 Provider;其余实现仍可保留,随时切回——这是第 2-1 课 patch 行替换的用武之地。

三角色缺一个就会退化:没有 Definition,Consumer 只能抓具体实现,耦合随之而来;没有 Provider,Definition 只是空壳;没有 Consumer,接口无人消费。接缝的意义,就是把“接口稳定”和“实现可换”两件事分开。画图时记住三条:Consumer 指向 Definition 表示依赖,Provider 指向 Definition 表示实现,Consumer 与 Provider 之间没有连线。

dsh 自己的内核就是这套三角色。architecture 文档给出了两张表:

  • core/agent 拥有 ctx.agents,定义 Agent 接口、维护实时注册表、发射 agent/* 事件——这是 Definition。
  • core/agent-loop 实现该接口,是默认驱动——这是 Provider。
  • 会话、headless 运行器、Web UI 通过 ctx.agents 使用 Agent,不直接依赖 core/agent-loop——它们是 Consumer。

注意 core/agent 与 core/agent-loop 是两个包,而不是合成一个——接口与实现的分离在目录结构上就看得见。

同一模式出现在模型侧:llm/llm 拥有 ctx.llm,文档称它为 adapter seam(适配器接缝)。你配置过的模型提供方,就是挂在这条接缝上的 Provider。

同一张表还列了 core/session(ctx.sessions)、core/tools(ctx.tools)、core/system-prompt(ctx.systemPrompt)。它们都是 Definition 的所在地,实现则由各 bundle 里的插件提供。看到带 ctx 键的包,先问一句:它定义了什么接口?

文档还强调:dsh 没有特权内核需要打补丁(no privileged core to patch)。扩展 dsh 的方式,是在其他插件旁边挂载一个新插件;注册是 effect,插件卸载时自动撤销——这是 ctx.effect() 的用武之地。

三角色的收益在切换那一刻显现。要换 Provider,改动范围只有一处:配置。

换实现时,你在配置层做两件事:让新的 Provider 插件被加载,让原来指向旧实现的那一行指向新实现。第 2-1 课的 profile、bundle 与 cordis.patch.yml 就是落点——patch 按行 id 替换整行配置或插入新行。

叠加顺序决定谁生效:profile 里列出的 bundle、profile 的 cordis.patch.yml、home 级补丁,最后是 --patch 覆盖。你在哪一层换,就由哪一层的行接管。

为什么配置行是唯一改点?因为 Consumer 通过 inject 拿到的引用由容器解析,解析目标由配置决定——Consumer 的代码路径里根本没有第二个实现的名字。对比没有分层的写法:Consumer 直接 new 一个具体实现,换实现就得改 Consumer、改测试、重新发布。有了接缝,改动的边界被收拢到配置这一行:Definition 不动,Consumer 不动。反过来,如果切换需要改 Consumer,说明接缝选错了位置。

什么时候值得开一条接缝?三个信号:同一个能力有多个实现候选;你预见到半年内要替换实现;调用方不止一个。模型提供方、存储、审批策略都是典型候选。

反过来,只有一个实现、没有替换预期时,先别急着抽象。接缝是投资,不是装饰。docs 把 capability layering 放在 Practice 分类下,正是提醒你:分层是动手前的设计决策,不是写代码后的补丁。

画出三角色关系图

  1. 1

    在纸上画三层:最上方写 Consumer,中间写 Definition,最下方写 Provider。

  2. 2

    从 Consumer 向下画一条箭头指向 Definition,标注“inject 依赖”。从 Provider 向上画一条箭头指向 Definition,标注“实现并注册”。

  3. 3

    加注释:Definition 旁写“接口不变”,Provider 旁写“可替换”,Consumer 旁写“不改代码”。

  4. 4

    检查:Consumer 与 Provider 之间不应有直接连线。若画出来了,说明依赖泄漏,删掉它。

  5. 5

    自问:换成另一个 Provider,图上哪几处会变?答案应只有 Provider 与配置,Consumer 不变。

你应该看到

一张三层关系图:Consumer 只指向 Definition,Provider 只指向 Definition,二者之间没有直接连线;你能圈出切换 Provider 时的改动范围——Provider 与配置两处,Consumer 与 Definition 不动。

自检:你要把文本存储从本地文件换成远程服务,两者实现同一个 Definition。改动范围包括哪些?Consumer 需要改吗?

两处:挂载并启用新的 Provider,在配置里把实现指向它。Definition 接口不变,Consumer 不改一行——它只依赖接口,不依赖实现。