摘要
dsh 很火,但更值得研究的是它的实现方式。顺着工具注册链路往下看,会发现它和 Java 开发者熟悉的 Spring IoC 有异曲同工之处:两者都把能力的装配交给容器,让使用方不再直接依赖具体实现。
一切皆插件是如何实现的
dsh 底层使用了 Cordis 框架。定位到 packages/core 就能看到,Session、Agent、Tools、System Prompt 等核心能力,都以 Cordis 服务或插件的形式挂载到共享上下文中。
也就是说,dsh 没有把所有能力硬编码进 Agent Loop,而是让各个插件通过注册机制,把自己的能力贡献到运行时。Agent Loop 不需要自己创建每一种工具,也不需要知道工具由哪个插件实现;它只面向容器已经装配好的能力工作。

1. 插件怎么登记:register()
先看工具注册入口,packages/core/tools/src/index.ts 里的 ToolRegistry.register():

重点看最后的返回值:
ts
return this.layers.effect(
this.ctx,
layer => layer.tools.insert(name, definition),
{ label: 'tools.register()' },
)
把这段代码抽象一下,它主要完成三件事:
- 校验这份工具"说明书"是否合法,例如输出结构、超时参数以及保留名称。
- 找到当前作用域对应的工具层,把工具定义插入进去。
- 返回一个撤销函数。插件卸载时调用它,就能移除这次注册留下的工具定义。
为什么要返回撤销函数?因为"一切皆插件"不只是能把零件装上去,还要能把它干净地拆下来。注册与撤销是一对操作:插件挂载时贡献能力,插件卸载时收回能力,避免已经失效的工具继续留在运行时。
2. tools 真正保存的是什么:Map<string, V>
继续点击 layer.tools,可以看到它的类型:
ts
readonly tools: NamedEntries<ToolDefinition>
NamedEntries 位于 packages/core/scope/src/store.ts,核心实现可以简化为:
ts
private data = new Map<string, V>()
insert(name: string, value: V): () => void {
const data = this.data
if (data.has(name)) throw this.duplicateError(name)
data.set(name, value)
let active = true
return () => {
if (!active) return
active = false
data.delete(name)
}
}
本质上,它就是一张 Map:
- key:工具名 ,例如
bash。 - value:工具定义,里面包含工具名称、参数、执行逻辑和输出描述。
从这套实现看,选 Map 有三个原因:
- 可以按名字直接查找:核心需求就是"根据工具名找到工具定义"。
- 保留插入顺序:JavaScript 的 Map 会维护插入顺序,使工具投影后的排列保持稳定。
- 同一层内禁止重名:如果一张登记表里出现两个同名工具,模型发起调用后就无法得到唯一解释。
还有一个容易忽略的细节:这里保存的是工具能力定义,不是插件对象本身。
插件负责把能力注册进来,并在自身生命周期结束时撤销注册;真正进入登记表的,是工具名和 ToolDefinition。因此,使用方不需要知道这个工具由哪个插件提供,只需要按照名字查找并调用。
这正是解耦发生的地方。
3. 完整流水线:模型怎么使用这张登记表
把工具从注册到执行的过程串起来,大致是这样一条链路:

这条链路上有两个关键分工。
第一,模型只看"菜单",不看"后厨" 。schemas() 会把可见工具投影为模型需要的字段,模型知道工具叫什么、能做什么、需要哪些参数,但看不到工具内部的执行函数和展示逻辑。
第二,真正执行时,仍然要回到登记表查找定义 。模型生成 tool_call 后,Agent Loop 负责调度,ToolRegistry 再根据名字解析对应工具,进入工具执行流水线,最后调用真正的 execute(...)。
所以,schema 解决的是"让模型知道有哪些工具",Map 解决的是"模型选中之后,运行时去哪里找到它"。
4. 本质上,它装的是"工具能力"
一句话概括:
dsh 装进去的是工具能力,大模型根据 schema 决定调用哪一个。
这张 Map 从头到尾只承担一个核心职责:维护"名字 → 能力"的映射,把"谁实现能力"和"谁使用能力"拆开。
不过,真正重要的并不是"用了 Map",而是谁掌握装配权。工具的使用方不再亲自创建、寻找和拼装具体实现,这些工作被交给了容器和注册机制。
5. 对比 Spring:共同点是把装配权交给容器
对于 Java 开发者,这套设计很容易让人联想到 Spring IoC。两者最值得抓住的共同点是:
对象不由自己装配,而由容器注入。
在 Spring 中,一个 Bean 不需要在内部到处 new 自己依赖的对象。它声明依赖,容器负责查找合适的 Bean、创建实例并完成注入。
在 dsh 中,Agent Loop 同样不需要直接依赖每个具体工具插件。插件把 ToolDefinition 注册到 Cordis 管理的作用域工具层,运行时再从容器已经汇集的能力中生成 schema、查找工具并执行。
所以,两者的共同点不只是"底层都有 Map"。Map 只是保存登记结果的数据结构,把装配权从对象自身转移给容器,才是 IoC 的核心。
Spring 的 DefaultListableBeanFactory 负责管理 BeanDefinition;DefaultSingletonBeanRegistry 则管理已经创建的共享单例。概念上可以简化为两类登记:
java
// Bean 名称 → Bean 定义
Map<String, BeanDefinition> beanDefinitionMap;
// Bean 名称 → 已创建的共享单例
Map<String, Object> singletonObjects;
这两套登记机制都通过"名字 → 对象或定义"的映射,把能力提供者与使用者隔开。不过,它们的实现目标并不相同。
5.1 一张核心工具表 vs 定义与实例分开管理
dsh 的 ToolDefinition 自带执行逻辑,注册完成后就具备可调用条件。Spring 则区分 BeanDefinition 与 Bean 实例:定义描述 Bean 应该如何创建,实例则由容器按作用域和初始化策略进行管理。
这里不能简单理解成"Spring 一定等到 getBean() 才创建"。在常见的 ApplicationContext 启动流程中,非懒加载单例通常会被预实例化;懒加载 Bean 或其他作用域,才可能在实际获取时创建。
5.2 Agent 作用域分层 vs Spring 容器和作用域体系
dsh 的工具注册存在全局层和 Agent 作用域层。Agent 层可以看到自身作用域下的工具,并按规则与全局工具组合;同一层内的重名会被拒绝,而作用域工具可以遮蔽全局工具。
Spring 也支持父子容器以及 singleton、prototype、request、session 等作用域,但它解决的是 Bean 查找与实例生命周期问题,不能简单概括为"单层"。两者相似的是分层查找思路,具体语义并不相同。
5.3 可逆注册 vs 以容器生命周期为主
dsh 把注册视为一种可撤销的 effect。插件卸载时,对应工具能够从当前层中移除,这是热插拔设计的重要基础。
Spring 的典型使用方式则是先注册 Bean 定义,再由容器统一创建和管理 Bean。Bean 定义能否覆盖也不是固定的"重名必报错",而取决于注册表和应用的覆盖策略;在常规业务开发中,Bean 的创建和销毁仍主要跟随容器生命周期。
所以,二者不是同一套实现,却共享一个非常接近的 IoC 思路:调用方只声明自己需要什么,能力的查找、组合和生命周期管理交给容器。
一句话收尾
这张 Map,是 dsh 把"一切皆插件"落到工具系统中的关键账本:插件向里面登记能力,模型根据 schema 点名,运行时再按名字取出真实定义并执行。
它和 Spring 的 Bean 注册机制有相似的 IoC 思想,但 dsh 更强调 Agent 作用域和可逆注册;Spring 则拥有更完整的 Bean 定义、实例创建和作用域管理体系。
但 Map 只是表面,容器装配才是核心。理解这一点之后,再回头看 dsh 的工具系统,就不再是一堆零散插件,而是一条清晰的链路:注册、投影、选择、查找、执行、撤销。
而这条链路与 Spring IoC 的相似之处,最终都落在同一句话上:
对象不由自己装配,而由容器注入。