跳到主要内容

数据截至 (上游 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
SkillRankerBM25+embedding 混合排序器,预筛阶段的引擎skill_engine/skill_ranker.py:67
SkillListingService生成回合头的轻量目录附件skill_engine/protocol.py:247
SkillDiscoveryServiceDiscoverSkills 的后端:级联检索skill_engine/protocol.py:358
SkillToolSkill 工具:加载单个技能全文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 分跳过粗筛,全量交给 embeddingskill_ranker.py:98 hybrid_rank
没装 rank_bm25BM25 退化成"词集合重叠比例"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-1350protocol.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:247SkillListingService.get_listing_delta
目录预算拼装openspace/skill_engine/protocol.py:141format_skills_within_budget
大目录优先级裁剪openspace/skill_engine/protocol.py:206_prioritize_large_skill_listing
发现级联openspace/skill_engine/protocol.py:538SkillDiscoveryService.asearch
直接命中openspace/skill_engine/protocol.py:376SkillDiscoveryService._direct_hits
全文加载工具openspace/skill_engine/protocol.py:735SkillTool(SKILL_TOOL_NAME)
工具名常量openspace/skill_engine/protocol.py:55SKILL_TOOL_NAMEDISCOVER_SKILLS_TOOL_NAME
禁用技能屏蔽openspace/skill_engine/protocol.py:77_disabled_skill_ids
来源过滤openspace/skill_engine/protocol.py:191filter_to_bundled_and_mcp
技能总管(发现/预筛/精选)openspace/skill_engine/registry.py:586SkillRegistry
技能元数据(轻量,无正文)openspace/skill_engine/registry.py:540SkillMeta
扫目录建注册表openspace/skill_engine/registry.py:622discover
热加载单个技能目录openspace/skill_engine/registry.py:905register_skill_dir
质量过滤 + 预筛 + LLM 精选openspace/skill_engine/registry.py:1274select_skills_with_llm
BM25+embedding 预筛封装openspace/skill_engine/registry.py:1463_prefilter_skills
读技能正文openspace/skill_engine/registry.py:1504load_skill_content
计划-then-选 promptopenspace/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:67SkillRanker.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:29discover_skill_registry_roots
运行时建注册表openspace/runtime/app.py:1135OpenSpaceRuntime.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)
安全检查 / frontmatteropenspace/skill_engine/skill_utils.py:36check_skill_safety / is_skill_safe
进化/分析类 promptopenspace/prompts/skill_engine_prompts.pySkillEnginePrompts

给需要复核的读者: 以上引用 as-of sourceCommit 3827781。旧符号对照:_select_and_inject_skillsRetrieveSkillTool 已移除;build_context_injection(registry.py:1575)仍在但主路径不再用它注入 system 消息,仅 doctor 体检(core/doctor.py:1478)等处复用。