`skills/builtin`:内置技能的加载与注册
「技能」这个词在不同项目里差别很大:有的是提示词片段,有的是插件,有的是一整套可执行脚本。要判断某个项目的技能到底是哪一种,最快的办法是看两件事——技能包在磁盘上长什么样,以及它是整篇塞进系统提示词还是按需被拉取。DeepTutor 这两件事都写在一个文件里:deeptutor/services/skill/service.py。
下面所有行号与数字都对应我们采集的仓库快照:HEAD 456f9c2,采集日 2026-08-10,deeptutor/__version__.py 里的版本号是 1.5.11。行号会随版本漂,但文件名与字段名是稳的。
一、这个目录实际上装了什么
先把规模钉死。deeptutor/skills/ 下只有 builtin/ 一个子目录,里面是 5 个技能目录,每个目录只有一个 SKILL.md,没有 references/,也没有 scripts/。整个 deeptutor/skills/ 的 Python 行数是 0——这一层不含任何代码,全是 Markdown。
五个内置技能与各自的行数(wc -l):
| 技能名 | 行数 | frontmatter 里有什么 |
|---|---|---|
docx | 206 | tags: [tool, office]、requires: sandbox: shell |
pdf | 269 | 同上 |
pptx | 220 | 同上 |
xlsx | 189 | 同上 |
skill-creator | 77 | 只有 name 与 description |
四个 office 技能的 frontmatter 结构一致(skills/builtin/docx/SKILL.md:1-12、pdf/SKILL.md:1-11、pptx/SKILL.md:1-12、xlsx/SKILL.md:1-12),skill-creator 是唯一一个既没有 tags 也没有 requires 的(skills/builtin/skill-creator/SKILL.md:1-4)。这个差别不是随手写的,下面第五节会看到它直接决定了这条技能在可用性检查里走哪条分支。
二、frontmatter 的字段表与两条正则
技能定义格式写在 services/skill/service.py:27-38:--- 包起来的 YAML frontmatter,加正文。字段一共这几项:
name:必填description:必填,进清单的就是这一行tags:可选always:可选,为true时整篇正文预注入每一轮requires:可选,下含bins、env、sandbox三个门
约束比看上去严。服务里为名称与标签各定了一条正则:名称正则是 ^[a-z0-9][a-z0-9-]{0,63}$,标签正则是 ^[a-z0-9][a-z0-9\- _]{0,31}$(service.py:64-65)。名称那条限定小写字母或数字开头、只可带连字符、最长 64 个字符;标签那条稍宽,除连字符外还放行空格与下划线,长度上限 32。至于名称或标签不匹配时服务具体怎么处理,不在我们核过的范围内,这里不下结论。
解析函数 _parse_frontmatter 用正则 ^---\s*\n(.*?)\n---\s*\n? 切出头部再交给 yaml.safe_load,YAML 解析失败时静默退回空 dict(service.py:346-359)。这一条值得记住:你的 SKILL.md 缩进写错了,不会抛异常,只会表现为「这个技能的 description 是空的、requires 没生效」。
三、两层结构,user 遮蔽 builtin
技能有两层(service.py:16-25):
- builtin 层在
deeptutor/skills/builtin/,运行时只读; - user 层在
data/user/workspace/skills/。
builtin 层的根路径不是配置项,是算出来的:BUILTIN_SKILLS_ROOT = Path(__file__).resolve().parents[2] / "skills" / "builtin"(service.py:72)。它从 service.py 自身位置往上数三层再往下拼——所以这个目录随包走,跟工作区在哪没关系。
发现顺序在 list_skills()(service.py:408-431):先扫 user 层,给每条标 source="user";再扫 builtin 层,遇到同名的跳过。这就是「user 遮蔽 builtin」的全部实现——不是合并、不是字段覆盖,是整条记录被顶掉。你想改内置的 pdf 技能,做法是在 user 层建一个同名 pdf 目录,而不是去改包里那份。
写操作那侧是对称的:所有写入强制打到 user 层,命中 builtin 同名就抛 SkillReadOnlyError(service.py:543-548)。
四、反直觉的那一处:技能默认根本不进提示词
看到「五个内置技能,最长的 269 行」,很容易默认它们会被拼进系统提示词。实际不是。运行时装载分成两条完全不同的路(service.py:488-538):
summary_entries():为每个可见技能出一行摘要。进系统提示词的就是这份清单——不是正文,是清单。load_always_for_context():只挑出always: true且 available 的技能,把它们的正文整篇渲染进去,块标题是## Active Skills。
不在第 2 条里的技能,正文一个字都不会自动进上下文,得靠 read_skill 工具按需取(service.py:7-14)。read_skill 的入参是 (name, file),出参是 ToolResult(content=文件正文, metadata={skill, file, char_count}),找不到时 success=False 并返回一句 (skill not found: ...)(tools/builtin/__init__.py:1392-1404)。
这个设计的直接后果是:技能数量增长对每轮 token 的影响主要落在清单行上,而不是正文长度上——除非你给某个技能标了 always: true,那它每轮都会整篇进去。所以第二节那张字段表里,always 是唯一一个会显著改变上下文体积的开关。至于加了某个技能之后模型的产出会变成什么样,那是运行时的事,我们没有运行过,不做任何判断。
顺带记一条源码明确划出的边界(service.py:44-46):行为与语气预设(“teacher”、“peer” 之类)不是 skill,它们在 deeptutor.services.persona 里,理由写得很直白——人格必须从第一个 token 就生效,所以走预注入。别把这两套东西混在一起找。
五、requires 是一道会去查你本机的门
_availability 的实现在 service.py:366-390,三个门各查各的:
bins:用shutil.which查宿主机 PATH 里有没有这个可执行文件,缺了记CLI: xenv:查环境变量是否设置,缺了记ENV: xsandbox:查对应沙箱是否可用,缺了记SANDBOX: x
三项都不缺才算 available。这里必须如实说明一件事:四个 office 技能的 frontmatter 都写着 requires: sandbox: shell,也就是说它们的定位是要在本机通过 shell 沙箱执行外部程序的技能,不是纯文本提示词。凡是这类能力,是否启用、在什么环境里启用,需要你按自己的机器和数据敏感度自行评估——本文只陈述源码里的门怎么判,不给安全方案。
另外注意 available 与「装载」是两回事。可用性检查真正卡住的是 load_always_for_context() 那条整篇注入的通道——它要求 always 与 available 同时成立(service.py:488-538)。也就是说,一个技能哪怕标了 always: true,只要 requires 里有一项在你本机没满足,整篇预注入这条路就走不通。至于清单侧对不可用技能是怎么呈现的,我们没有核到这一层,不做描述。
六、file 入参、路径校验与导入配额,与内置的五个单文件
把前面几处摆到一起,会看到两组并列的事实。一组是读取与导入这一侧的机制:
read_skill的入参里有file(tools/builtin/__init__.py:1392-1404);read_skill_file拒绝绝对路径与含..的路径,并用is_relative_to做二次校验,单次读取上限_MAX_READ_CHARS = 100_000,超出追加[... truncated ...](service.py:76、service.py:460-472);- 外部导入的上限是单文件 1000000 字节、总量 20000000 字节、最多 500 个文件,后缀走白名单,只放文本与脚本类(
service.py:86-102)。
另一组是磁盘上的实况:我们实读的 deeptutor/skills/builtin/ 下,5 个目录里各只有一个 SKILL.md,没有 references/,也没有 scripts/(find deeptutor/skills -type f -o -type d)。一侧是 file 入参、路径校验、单次截断与三档导入配额,另一侧是五个单文件目录。两边就是这个状态,我们只陈述,不推断原因。
技能的外部来源也在这一层:DEFAULT_HUB = "eduhub",内置两个 hub —— eduhub(https://eduhub.deeptutor.info/api/v1)与 clawhub(https://clawhub.ai/api/v1),都按 type: clawhub 协议(services/skill/hub.py:67-77)。这里要把话说清楚:services/skill/hub.py 全文 1005 行,我们只核了这几个默认常量,安装、发布与 lock 流程一行都没读,所以外部技能是怎么下载、怎么落盘的,本文不下结论。能说的只有第三节那条——写操作强制打到 user 层,命中 builtin 同名就抛 SkillReadOnlyError(service.py:543-548),用户侧的技能落点只有 user 层这一处。再加上第五节说的,技能可以声明 requires: sandbox: shell 这类需要执行外部程序的门——所以「从哪个 hub 装、装了什么」这件事,本质上属于你本机的执行边界问题。
七、你可以自己跑一遍的核查动作
find deeptutor/skills -type f -o -type d:确认只有builtin/、5 个目录、每个目录一份SKILL.md,没有references/和scripts/。wc -l deeptutor/skills/builtin/*/SKILL.md:核对 206 / 269 / 220 / 189 / 77 这五个数。- 打开
service.py:27-38,把 frontmatter 字段表抄下来;再打开service.py:64-65抄两条正则,拿你自己的技能目录名去比对。 - 打开
service.py:408-431,确认扫描顺序是「user 在前、builtin 同名跳过」;再看service.py:543-548,确认写操作命中 builtin 会抛SkillReadOnlyError。 - 打开
service.py:488-538,对照summary_entries()与load_always_for_context()两个函数,确认整篇注入的条件是always且available,块标题是## Active Skills。 - 打开
service.py:366-390,看清bins走的是shutil.which(查的是宿主机 PATH),再回头看四个 office 技能的requires: sandbox: shell。 - 打开
service.py:346-359,确认 YAML 解析失败是静默回退——这决定了你的技能写错缩进时会看到什么现象(不是报错,是字段变空)。
以上是我们在只读源码时用到的统计与定位动作,未经实测运行;命令的实际输出以你本地 clone 的仓库状态为准。
边界说明
本文只读了 deeptutor/services/skill/service.py 的常量段、frontmatter 解析、可用性检查、发现与装载这几段,以及 5 份 SKILL.md 的 frontmatter;hub.py 的安装与发布流程、user 层技能在 API/CLI 侧的增删改路径都没有逐行核实。我们没有安装、部署或运行过该项目,SKILL.md 正文进入提示词之后模型会怎么用它、产出会有什么变化,本文一律不下结论。文中出现的上限、正则与默认值都是源码中的默认配置,不是运行结果的保证;这些参数取值是该项目的实现选择,不构成任何经过验证的方法论结论。
本文依据 DeepTutor 官方仓库(github.com/HKUDS/DeepTutor)的 README、AGENTS.md、
pyproject.toml 与 deeptutor/ 下的源码整理,核对日 2026-08-10,对应仓库快照 456f9c2(版本 1.5.11)。
本文内容为仓库源码与文档口径,我们没有安装、部署或运行过该项目,也没有调用过其中任何一个模型 API,
因此不涉及生成质量、响应速度与教学效果的任何描述。
参数与默认值随版本变动,请以仓库最新代码与 --help 的实际输出为准。
安全相关做法请结合自身环境评估,本文不构成安全方案建议。