数据截至 (上游 commit 38277815ed44)
技能协议与发现:skill_listing / DiscoverSkills / Skill
30 秒导读: 一个任务进来,系统里可能躺着上百个技能(每个是一份
SKILL.md)。 全塞进 prompt 太贵、噪声也大。新版 OpenSpace 用 Skill Protocol 三件套渐进披露: 回合开始注入一条轻量目录(只有技能名+一句话描述,按上下文预算截断);模型想找技能就调DiscoverSkills(一条"直接命中 → 词面重叠 → 混合检索 → LLM 精选"的降级级联); 真正要用某个技能时才用Skill工具加载完整正文。核心一句话: 全文只在模型主动要时才进上下文,贵的模型 只在便宜方法筛过之后才登场。
上游重构提示: 旧版"执行前
_select_and_inject_skills一次性选 ≤2 个技能并把全文注入 system 提示"与中途的RetrieveSkillTool(skill_engine/retrieve_tool.py)已被移除, 改为上述模型可见协议;但SkillRegistry的发现/安全检查/两级检索/LLM 精选内核全部保留, 被重新用作发现级联的末端。本章按新版源码重写。
本章只讲技能怎么被披露、被发现、怎么加载。技能如何进化见 自进化引擎;主循环见 主循环。
1. 这是什么(零基础也能懂)
一句话定义: 一套"从一大堆技能里,把跟当前任务相关的少数几个渐进地送进模型上下文"的 协议 + 检索系统。
它解决什么问题? 想象你有一个能自己干活的 AI agent,你陆续给它攒了几十个"技能"——
每个技能就是一份 Markdown 说明书(SKILL.md),写着"处理 PDF 该怎么做""调这个天气 API
要带哪些参数"。任务来了,比如"把这份报告转成 PDF"。
- 天真做法: 把所有几十份说明书全文全贴进 prompt。→ 上下文爆炸、花钱、还把模型的注意力冲散。
- OpenSpace 做法: 先只给一份"目录卡片"(名字+一句话);模型觉得需 要,就搜一下
(
DiscoverSkills);确定要用了,才把那一份的全文读进来(Skill工具)。
一句话直觉/类比: 像图书馆。门口先发你一张索引卡(skill_listing);
你用检索机查书(DiscoverSkills);真要读了才去架上把书取下来(Skill)。
没人进门就把全部藏书搬给你。
关键术语一句话点破:
| 术语 | 白话 |
|---|---|
| Skill Protocol | 模型可见的三个面:轻量目录、发现工具、加载工具(模块契约见 skill_engine/protocol.py:1-9) |
| skill_listing | 一条只含技能名+描述的附件,按上下文预算截断(protocol.py:57 SKILL_BUDGET_CONTEXT_PERCENT) |
| BM25 | 经典关键词打分算法:查询词在文档里出现得越多越集中,分越高。纯字面、极快。 |
| embedding(向量) | 把文字压成一串数字,语义相近的向量也相近。能看懂"转成 PDF"≈"生成 pdf 文档"。 |
| 渐进式披露 | 先给"标题",它想看细节再加载全文。省 token。 |
2. 顶层全景(它大概怎么转)
一次任务的技能面,数据从"技能目录"流到"进入上下文的技能全文",分四层。从上到下 渐进披露,越往下越贵、内容越多。
① 发现 discover() [registry.py:622] 启动时扫技能根目录,
五级根:Host env → 配置 → Project → User → 内置 每个 SKILL.md → SkillMeta
(runtime/skill_registry.py:29) (只存 id/名/描述/路径)
│ N 个技能在册(元数据级)
② 回合头注入 skill_listing [protocol.py:247] 名字+描述卡片,
按上下文预算 1% 截断;超 100 个先做优先级裁剪 不含正文
│ 模型看到目录
③ 按需发现 DiscoverSkills [protocol.py:358] 级联降级:
直接命中(select:名字) 直接 → 词面 → 混合 → LLM
→ 词面重叠 → BM25+embedding 混合 → LLM 精选
(精选复用 select_skills_with_llm [registry.py:1274])
│ 命中 1-N 个技能(仍只有元数据)
④ 按需加载 Skill 工具 [protocol.py:735] 唯一默认加载完整
校验安全后把 SKILL.md 正文带进对话 SKILL.md 正文的路径
▼
正文进入上下文 → 模型照着做(主循环见第 1 章)
怎么读这张图: ①是一次性的,②每回合一次,③④由模型按需触发。只有 ③ 的最后一级 真正调用生成式 LLM,④ 才第一次把技能正文带进上下文。这就是"省钱"的骨架。
部件一句话职责:
| 部件 | 干什么 | 在哪 |
|---|---|---|
SkillRegistry | 技能总管:发现、缓存、预筛、精选,全在这一个类里 | skill_engine/registry.py:586 |
SkillMeta | 一个技能的轻量元数据(id/名/描述/路径),不含正文 | skill_engine/registry.py:540 |
SkillRanker | BM25+embedding 混合排序器,预筛阶段的引擎 | skill_engine/skill_ranker.py:67 |
SkillListingService | 生成回合头的轻量目录附件 | skill_engine/protocol.py:247 |
SkillDiscoveryService | DiscoverSkills 的后端:级联检索 | skill_engine/protocol.py:358 |
SkillTool | Skill 工具:加载单个技能全文 | skill_engine/protocol.py:735 |
build_skill_registry | 运行时按五级根目录建注册表 | runtime/skill_registry.py:81 |
3. 核心原理(逐个机制,由浅入深)
3.1 发现:扫五级根目录,把 SKILL.md 变成可检索的元数据
要解决的小问题: 检索之前,得先知道"有哪些技能"。它们散落在几个目录里,还可能重名。
思路: 启动时 OpenSpaceRuntime.init_skill_registry(runtime/app.py:1135)调用
build_skill_registry(runtime/skill_registry.py:81),其内部 discover_skill_registry_roots
(runtime/skill_registry.py:29)按固定优先级收集技能根,靠前的遮蔽后面同名的:
优先级高 ┌─ ① OPENSPACE_HOST_SKILL_DIRS 环境变量 (宿主 agent 带来的技能)
├─ ② config_grounding.json → skills.skill_dirs (用户自配)
├─ ③ 项目根目录默认位置 (workspace 内的 project skills)
├─ ④ 用户级默认位置 (跨项目共享的 user skills)
优先级低 └─ ⑤ openspace/skills/ (随包内置,永远存在)
真正扫目录的是 SkillRegistry.discover(skill_engine/registry.py:622):它遍历每个根下的
子目录,读 SKILL.md,先过格式体检(记 warning 诊断)再入册。
几个关键细节:
- 键是
skill_id,不是name。 两个目录里同名的技能会拿到不同的skill_id,可以共存 (docstring 明确说明,registry.py:625-630)。skill_id存在技能目录的.skill_id边车文件里, 首次发现时生成、之后只读(registry.py:207_read_or_create_skill_id)——所以它能扛住目录搬迁。 - 元数据级发现模式。
_metadata_only_discovery开启时只读 frontmatter、不读正文 (registry.py:648-651);全文级安全检查推迟到Skill工具加载时做 (注释见registry.py:659-660)。启动更快,安全不缺位。 - 安全闸门。
check_skill_safety(skill_utils.py:36)用正则扫内容,is_skill_safe(skill_utils.py:44)命中blocked.malware类阻断标志即拒收;suspicious.*只记日志。热加载register_skill_dir(registry.py:905)走同一道检查。
为什么只存元数据、缓存全文? 这是渐进式披露的第一步——注册表随时能报出
"我有哪些技能、各自一句话是啥"(便宜),而完整正文只在预筛语料和 Skill 工具加载时才被翻出来。
3.2 skill_listing:每回合一张按预算截断的目录卡
要解决的小问题: 模型得先知道有哪些技能才会去用;但目录本身也不能无限长。
思路: SkillListingService.get_listing_delta(protocol.py:271)在每回合开头
(loop 里的调用点见 agents/turns/loop.py:622)生成一条 skill_listing 附件:
- 只列名字+描述,描述截到
MAX_LISTING_DESC_CHARS(默认 250 字符,protocol.py:57附近的常量组)。 - 按上下文预算截断:
format_skills_within_budget(protocol.py:141)以SKILL_BUDGET_CONTEXT_PERCENT = 0.01(上下文的 1%,protocol.py:57)为上限拼目录。 - 技能太多时先裁剪:超过
LARGE_SKILL_LIST_THRESHOLD = 100(protocol.py:63) 会走_prioritize_large_skill_listing(protocol.py:206)做优先级排序, 被省略的部分在目录尾部提示"还有 N 个,用 DiscoverSkills 查或select:<名字>直选"。 - 不重复发:按 agent 记录"已发送过的技能名"(
sent_skill_names_by_agent), 只发增量;跨回合由restore_skill_state_from_messages(loop.py:616-621)恢复这份状态。 - 每次列目录都记事件:
record_skill_event_now(skill_id, "listed", ...)(protocol.py:325-337), 给第 3 章的进化统计攒数据。
3.3 DiscoverSkills:一条"便宜优先"的降级级联
要解决的小问题: 模型拿着目录找不到合适的(目录被截断/任务描述模糊),要能搜。
思路: SkillDiscoveryService.asearch(protocol.py:538)是 DiscoverSkills 工具的后端,
按成本从低到高逐级尝试,高置信命中即停:
query(模型的检索词)
│
① 直接命中 _direct_hits select:名字 精确匹配 / 精确名命中 → 立即返回
│
② 词面重叠 _token_overlap_search 分词后对 名/描述/when_to_use/正文 打分
│ 高置信(_high_confidence_keyword_hits)→ 返回
│
③ 混合检索 _hybrid_search BM25+embedding,质量计数微调加分/扣分
│ 高置信(_high_confidence_hybrid_hits)→ 返回
│
④ LLM 精选 select_skills_with_llm 复用注册表的两级检索+计划-then-选
(registry.py:1274);失败或 llm_failed → 退回 ③ 的结果
要点:
- 级联末端就是旧内核。
select_skills_with_llm(registry.py:1274)原封保留 "质量前置过滤 → BM25+embedding 预筛 → 计划-then-选"的完整流程(见 §3.4)。 - 质量计数参与打分。 ②③ 的打分里,
total_completions加分、total_fallbacks扣分 (protocol.py:671-675)——历史表现反哺检索。 - 处处降级不崩。 LLM 不可用、调用抛错、
record.method == "llm_failed"都退回混合检索结果 (protocol.py:596-608)。
3.4 LLM 精选内核:质量过滤 + 两级预筛 + 计划-then-选(保留自旧版)
select_skills_with_llm(registry.py:1274)是整条链里唯一调生成式 LLM 的地方,三段式:
① 质量前置过滤(registry.py:1327-1350):用 SkillStore 的历史指标直接剔除烂技能——
- 观测 ≥2 次(
applied+fallbacks)却从没完成过(completions == 0); - 选中 ≥2 次且退化率 >50%(
fallbacks / selections > 0.5)。
② 两级预筛(仅当候选数 > PREFILTER_THRESHOLD = 10,openspace/skill_engine/skill_ranker.py:40):
_prefilter_skills(registry.py:1463)把 SkillMeta 包装成带正文的 SkillCandidate,
交给 SkillRanker.hybrid_rank(skill_ranker.py:98)——先 BM25 粗排(免费、纯词面),
再对 BM25 的 top 集合做 embedding 余弦精排(一次向量 API),压到十几个候选。
| 情况 | 行为 | 代码位置 |
|---|---|---|
| BM25 全 0 分 | 跳过粗筛,全量交给 embedding | skill_ranker.py:98 hybrid_rank |
没装 rank_bm25 库 | BM25 退化成"词集合重叠比例" | skill_ranker.py:190 _bm25_rank |
| 没有 embedding API key | 只返回 BM25 结果 | hybrid_rank 内部降级 |
embedding 缓存仍是内容寻址的:缓存键编入文本 hash,内容一变旧缓存自动失效;
进化后可 invalidate_cache(skill_ranker.py:174)主动清。
③ 计划-then-选(_build_skill_selection_prompt,registry.py:1839):送进 LLM 的是
"标题级目录"(id+描述+成功率/信任态,registry.py:1375-1388 拼目录),prompt 逼模型先想
"这任务怎么干"(Plan)再挑(Match/Quality/Decide),返回 {"brief_plan": ..., "skills": [...]}。
解析宽容(_parse_skill_selection_response,registry.py:1880):先抠代码块 JSON、再找裸
{...}、失败返回空——宁可这轮不选,也不让脏输出搞崩流程。
3.5 Skill 工具:全文的唯一默认入口
要解决的小问题: 目录和发现都只给元数据;正文什么时候进上下文、怎么防恶意内容?
思路: SkillTool(protocol.py:735,工具名 Skill,SKILL_TOOL_NAME 定义于
protocol.py:55)是唯一默认加载完整 SKILL.md 的路径:
- 模型调
Skill(skill="名字", args=...)→ 从注册表取元数据 → 安全检查后把正文带进对话 (load_skill_content,registry.py:1504)。 - 它是
META后端、非并发安全的本地工具(protocol.py:738-743),并带权限规则 (Skill <名字>粒度的 allow/ask/deny,protocol.py:913-942)——技能调用可被权限系统管住。 - 命中已加载技能会激活"技能作用域",回合结束统一
deactivate_all_skill_scopes(loop.py:993-996)。
这样,任何 SKILL.md 全文要进上下文,都必须过"模型主动请求 + 安全检查"两道门—— 这是渐进披露的安全面。
3.6 技能的"禁用"与"隐藏"
disable_model_invocation:元数据带此标志的技能不进目录、不可发现(protocol.py:283-289)。_disabled_skill_ids(protocol.py:77):从SkillStore拉被禁用(含信任态被吊销)的技能, 目录与发现同时屏蔽——进化系统吊销的技能,检索层立刻失明(信任生命周期见第 3 章)。filter_to_bundled_and_mcp(protocol.py:191):某些部署面(如 MCP 检索)只披露 bundled 与 MCP 来源的技能,按来源过滤。
4. 巧妙之处(可借鉴的技术)
- 披露协议化,全文加载只有一条路。 目录/发现/加载三面分离,
Skill是唯一默认正文入口 (protocol.py:1-9模块契约),token 成本与安全检查都收口在一点。 - 成本分级漏斗依旧。 免费词面 → 一次向量 API → 一次 LLM;
PREFILTER_THRESHOLD(=10,openspace/skill_engine/skill_ranker.py:40)还让"要不要动用便宜筛子"本身也成本可控。 - 级联高置信即停。
DiscoverSkills前两级命中就不花钱调 LLM(protocol.py:538-612), 大多数查询止步于词面/混合检索。 - 质量数据反哺检索。 目录里带成功率与信任态、打分里 completions 加分 fallbacks 扣分、
烂技能进不了候选池——检索随使用越用越准(
registry.py:1327-1350、protocol.py:671-675)。 - 一条管线,多处复用。 发现级联末端、进化行为评估(
evolution/behavior_eval.py:564)、 doctor 体检共用同一套select_skills_with_llm/hybrid_rank,口径统一。 - 处处降级不崩。 BM25 无库退化成词重叠、无 API key 退回 BM25、LLM 脏输出返回空、 发现失败退回混合结果——没有任何一环会因为依赖缺失而让整个任务失败。
5. 边界与局限(诚实)
- 目录默认只占上下文 1%。 技能很多时目录必然被截断(
SKILL_BUDGET_CONTEXT_PERCENT,protocol.py:57),漏掉的可能性依赖DiscoverSkills的检索质量补救。 - 精选依赖生成式 LLM。 级联最后一级要调 LLM;无 LLM client 时
DiscoverSkills只能 用混合检索结果(protocol.py:596-599)。 - embedding 要联网。 精排依赖向量 API;没有 key 时整条预筛降级为纯 BM25,语义召回下降。
- 安全检查是正则,非语义。
check_skill_safety(skill_utils.py:36)靠关键词正则, 能挡明显恶意串,绕过正则的隐蔽注入挡不住;suspicious.*只记日志不阻断。 - 本章不覆盖的部分: 技能被加载后如何随任务执行被记为"已用"见 主循环与第 3 章的技能事件;技能如何进化/版本化见 自进化引擎;工具侧的预选见 工具接地层; MCP 检索与云端社区见 对外集成。
6. 代码地图(导航索引)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 技能协议模块契约 | openspace/skill_engine/protocol.py:1 | (模块 docstring:三件套分工) |
| 轻量目录服务 | openspace/skill_engine/protocol.py:247 | SkillListingService.get_listing_delta |
| 目录预算拼装 | openspace/skill_engine/protocol.py:141 | format_skills_within_budget |
| 大目录优先级裁剪 | openspace/skill_engine/protocol.py:206 | _prioritize_large_skill_listing |
| 发现级联 | openspace/skill_engine/protocol.py:538 | SkillDiscoveryService.asearch |
| 直接命中 | openspace/skill_engine/protocol.py:376 | SkillDiscoveryService._direct_hits |
| 全文加载工具 | openspace/skill_engine/protocol.py:735 | SkillTool(SKILL_TOOL_NAME) |
| 工具名常量 | openspace/skill_engine/protocol.py:55 | SKILL_TOOL_NAME、DISCOVER_SKILLS_TOOL_NAME |
| 禁用技能屏蔽 | openspace/skill_engine/protocol.py:77 | _disabled_skill_ids |
| 来源过滤 | openspace/skill_engine/protocol.py:191 | filter_to_bundled_and_mcp |
| 技能总管(发现/预筛/精选) | openspace/skill_engine/registry.py:586 | SkillRegistry |
| 技能元数据(轻量,无正文) | openspace/skill_engine/registry.py:540 | SkillMeta |
| 扫目录建注册表 | openspace/skill_engine/registry.py:622 | discover |
| 热加载单个技能目录 | openspace/skill_engine/registry.py:905 | register_skill_dir |
| 质量过滤 + 预筛 + LLM 精选 | openspace/skill_engine/registry.py:1274 | select_skills_with_llm |
| BM25+embedding 预筛封装 | openspace/skill_engine/registry.py:1463 | _prefilter_skills |
| 读技能正文 | openspace/skill_engine/registry.py:1504 | load_skill_content |
| 计划-then-选 prompt | openspace/skill_engine/registry.py:1839 | _build_skill_selection_prompt |
| 宽容解析 | openspace/skill_engine/registry.py:1880 | _parse_skill_selection_response |
| 持久化 skill_id 边车 | openspace/skill_engine/registry.py:207 | _read_or_create_skill_id |
| 混合排序器 | openspace/skill_engine/skill_ranker.py:67 | SkillRanker.hybrid_rank |
| BM25 打分(含无库降级) | openspace/skill_engine/skill_ranker.py:190 | _bm25_rank |
| 预筛触发门槛(=10) | openspace/skill_engine/skill_ranker.py(PREFILTER_THRESHOLD = 10 定义行) | PREFILTER_THRESHOLD |
| 五级根目录发现 | openspace/runtime/skill_registry.py:29 | discover_skill_registry_roots |
| 运行时建注册表 | openspace/runtime/app.py:1135 | OpenSpaceRuntime.init_skill_registry |
| 回合头注入点 | openspace/agents/turns/loop.py:622 | (_append_skill_listing_delta 调用) |
| turn0 发现预取 | openspace/agents/turns/loop.py:624 | (_append_skill_discovery_delta_async) |
| 安全检查 / frontmatter | openspace/skill_engine/skill_utils.py:36 | check_skill_safety / is_skill_safe |
| 进化/分析类 prompt | openspace/prompts/skill_engine_prompts.py | SkillEnginePrompts |
给需要复核的读者: 以上引用 as-of
sourceCommit 3827781。旧符号对照:_select_and_inject_skills、RetrieveSkillTool已移除;build_context_injection(registry.py:1575)仍在但主路径不再用它注入 system 消息,仅 doctor 体检(core/doctor.py:1478)等处复用。