跳到主要内容

技能检索与注入:从任务到 SKILL.md

30 秒导读: 一个任务进来,系统里可能躺着几十上百个技能(每个是一份 SKILL.md)。 全塞进 prompt 太贵、噪声也大。这一章讲 OpenSpace 怎么用两级检索——先用便宜的 BM25+向量把候选压到十几个,再让 LLM 精选出 1-2 个,最后只把选中的完整内容注入上下文。 核心一句话:贵的模型只在便宜方法筛过之后才登场。

本章只讲怎么挑技能、怎么注入。技能被选中后如何执行、如何进化,分别见 主循环自进化引擎


1. 这是什么(零基础也能懂)

一句话定义: 一套"从一大堆技能里,挑出跟当前任务相关的少数几个,再把它们的说明书塞进 模型上下文"的检索系统。

它解决什么问题? 想象你有一个能自己干活的 AI agent,你陆续给它攒了几十个"技能"—— 每个技能就是一份 Markdown 说明书(SKILL.md),写着"处理 PDF 该怎么做""调这个天气 API 要带哪些参数"。任务来了,比如"把这份报告转成 PDF"。

  • 天真做法: 把所有几十份说明书全贴进 prompt。→ 上下文爆炸、花钱、还把模型的注意力冲散。
  • OpenSpace 做法: 先廉价地估一下"哪些技能可能相关",筛掉大部分;再让 LLM 从少数候选里 精挑;只把最终选中的那 1-2 份完整贴进去。

一句话直觉/类比: 像招聘。简历初筛(BM25+向量,便宜、快、宁滥勿缺)先把 200 份简历 压到 15 份;面试(LLM 精选,贵、准)只面这 15 个,发 offer 给 1-2 个。你不会让 CEO 亲自 读完 200 份简历——那太贵了。

关键术语一句话点破:

术语白话
BM25一种经典的关键词打分算法:查询词在文档里出现得越多越集中,分越高。纯看字面,不懂语义,但极快。
embedding(向量)把文字压成一串数字,语义相近的文字向量也相近。能看懂"转成 PDF"和"生成 pdf 文档"是一回事。
渐进式披露先只给模型看"标题"(技能名+一句话描述),它想看细节再加载全文。省 token。
SKILL.md一个技能的说明书文件:--- 里是 name/description,正文是给 agent 的操作步骤。

2. 顶层全景(它大概怎么转)

一次技能检索,数据从"任务字符串"流到"注入上下文的技能全文",中间过四道关卡。从上到下是 流水顺序,越往下越贵、候选越少。

任务描述 task (一句话)

┌───────────────▼────────────────┐
│ ① 发现 discover() │ 启动时扫三级目录,把每个
│ 扫目录 → 建 registry │ SKILL.md 读成 SkillMeta
└───────────────┬────────────────┘ (只存 id/name/描述/路径)
│ N 个技能全在册
┌───────────────▼────────────────┐
│ ② 质量过滤 │ 丢掉"选了多次从没成功"
│ 按历史成功率剔除 │ 或"落地就退化"的技能
└───────────────┬────────────────┘
│ N' 个
┌───────────────▼────────────────┐
│ ③ 预筛 _prefilter_skills() │ 仅当 N' > 10 才触发
│ BM25 粗排 → embedding 精排 │ 便宜地压到 ≤15 个候选
└───────────────┬────────────────┘
│ ≤15 个候选(只有这步之后才动 LLM)
┌───────────────▼────────────────┐
│ ④ LLM 精选 │ 只看"标题级"目录:
│ 先写计划,再选 skill_id │ id + 描述 + 成功率
└───────────────┬────────────────┘
│ 1-2 个 skill_id
┌───────────────▼────────────────┐
│ ⑤ 注入 build_context_injection │ 这时才加载完整 SKILL.md
│ 拼成 system 消息 │ 正文,拼进 agent 上下文
└───────────────┬────────────────┘

注入后的上下文 → 交给 GroundingAgent 执行

怎么读这张图: 每往下一层,候选数变少、单位成本变高。①②③ 都很便宜(纯本地/一次向量 API),只有 ④ 真正调用生成式 LLM,⑤ 才第一次读取技能全文。这就是"省钱"的骨架。

部件一句话职责:

部件干什么在哪
SkillRegistry技能的总管:发现、缓存、预筛、精选、注入,全在这一个类里skill_engine/registry.py:102
SkillMeta一个技能的轻量元数据(id/name/描述/路径),不含正文skill_engine/registry.py:88
SkillRankerBM25+embedding 混合排序器,预筛阶段的引擎skill_engine/skill_ranker.py:75
ToolLayer编排层:初始化 registry、触发选择、把结果注入 GroundingAgenttool_layer.py:634 / :689
RetrieveSkillTool一个内部工具,让 agent 在执行中途再次检索技能skill_engine/retrieve_tool.py:26

3. 核心原理(逐个机制,由浅入深)

3.1 发现:扫三级目录,把 SKILL.md 变成可检索的元数据

要解决的小问题: 检索之前,得先知道"有哪些技能"。它们散落在几个目录里,还可能重名。

思路: 启动时扫一遍所有技能目录,每个含 SKILL.md 的子目录就是一个技能。只解析 frontmatter 里的 name/description,连同一个持久化的 skill_id 存成 SkillMeta—— 正文先不读进内存做元数据,但会把原始内容缓存起来备用(预筛/注入时才用)。

三级目录的发现顺序很讲究,靠前的优先级更高,同名时前面的遮蔽后面的:

优先级高 ┌─ ① OPENSPACE_HOST_SKILL_DIRS 环境变量 (宿主 agent 带来的技能)
├─ ② config_grounding.json → skills.skill_dirs (用户自配)
优先级低 └─ ③ openspace/skills/ (内置技能,永远存在)

这个顺序在 tool_layer.py:634 _init_skill_registry 里按序 append 到 skill_paths, 再交给 SkillRegistry(skill_dirs=...)。真正扫目录的是 registry.py:120 discover:它遍历 每个目录下的子目录,读 SKILL.md,先过一道安全检查再入册。

# skill_engine/registry.py:137 discover() 的核心循环(节选,已简化)
for entry in sorted(skill_dir.iterdir()):
skill_file = entry / "SKILL.md"
if not skill_file.exists():
continue
content = skill_file.read_text(encoding="utf-8")
if not is_skill_safe(check_skill_safety(content)): # 安全闸门
continue
meta = self._parse_skill(entry.name, entry, skill_file, content)
self._skills[meta.skill_id] = meta # id -> SkillMeta
self._content_cache[meta.skill_id] = content # id -> 原始全文(备用)

几个关键细节:

  • 键是 skill_id,不是 name 两个目录里同名的技能会拿到不同的 skill_id,可以共存 (registry.py:110 注释明确说 "All internal maps are keyed by skill_id")。skill_id 存在 技能目录的 .skill_id 边车文件里,首次发现时生成、之后只读——所以它能扛住目录搬迁 (registry.py:44 _read_or_create_skill_id)。
  • 安全闸门在入口。 check_skill_safety(skill_utils.py:36)用正则扫内容,命中 blocked.malware 之类的阻断标志就直接拒收(skill_utils.py:44 is_skill_safe)。 suspicious.* 只记日志不阻断。外部技能的热加载(register_skill_dir,registry.py:293) 走同一道安全检查。

为什么只存元数据、缓存全文? 这是渐进式披露的第一步——注册表随时能报出"我有哪些技能、 各自一句话是啥"(便宜),而完整正文只在预筛的 BM25 语料和最终注入时才被翻出来。


3.2 为什么要"两级检索"——先便宜筛,再贵精选

这是本章的核心思想,值得单独讲透。

问题的本质: 让 LLM 从技能里挑,准确度最高——它能理解任务意图、读懂技能描述的语义。 但 LLM 调用又慢又贵,而且如果把几十上百个技能的目录全塞进选择 prompt,不但烧 token, 过长的清单还会稀释模型判断力。

解法:分级,让成本和候选数匹配。 把检索拆成"漏斗",越贵的手段处理越少的候选:

级别手段成本处理量作用
粗筛BM25(纯本地关键词)几乎为零全部 N 个快速砍掉字面完全不沾边的
精排embedding 余弦相似度一次向量 APIBM25 的 top(≈45)语义重排,救回"换了说法"的相关技能
精选LLM 计划-then-选一次 LLM 调用≤15 个候选真正理解意图,定夺 1-2 个

关键的门槛设计: 预筛不是无条件跑的。只有当过滤后技能数 超过 PREFILTER_THRESHOLD (=10) 才启动 BM25+embedding;技能少于等于 10 个时,直接把全部丢给 LLM——因为候选本来 就不多,预筛省不下几个 token,反而多一次向量 API 不划算。

# skill_engine/registry.py:412 select_skills_with_llm 里的分流(节选)
prefilter_used = False
if len(available) > PREFILTER_THRESHOLD: # 只有技能够多才预筛
available = self._prefilter_skills(task_description, available, max_skills)
prefilter_used = True

PREFILTER_THRESHOLD 定义在 skill_ranker.py:45。这个"够多才预筛"的判断,把 "省 LLM token" 和 "别为省小钱多花向量 API 钱" 两个成本平衡了起来。

一句话记住: 便宜的方法负责把量压下来,贵的方法负责把质提上去;门槛决定"到底 值不值得动用便宜筛子"。


3.3 预筛:BM25 粗排 → embedding 精排

要解决的小问题: 在不调用生成式 LLM 的前提下,把 N 个技能快速压到十几个高相关候选。

思路(两阶段接力):

query ─┬─► [BM25 粗排] ──► 取 top_k×3 ≈ 45 个 ──┬─► [embedding 精排] ──► top_k(≤15)
│ 纯词面、极快 │ 语义余弦、需向量 API
│ │
└── 若 BM25 全 0 分(没匹配) └── 若无 API key → 退回 BM25 结果
→ 把全部候选交给 embedding 判

为什么 BM25 在前、embedding 在后? BM25 免费又快,先用它把明显不相关的砍掉一大半; embedding 要花一次向量 API 钱,只对 BM25 活下来的少数候选算,省了大量向量计算。这是 "两级"思想在预筛内部的又一次递归应用。

真正的编排在 skill_ranker.py:107 hybrid_rank,它的降级链写得很稳:

情况行为代码位置
正常BM25 top×3 → embedding 重排 → top_kskill_ranker.py:124-131
BM25 全 0 分跳过粗筛,全量交给 embeddingskill_ranker.py:125-128
没装 rank_bm25BM25 退化成"词集合重叠比例"skill_ranker.py:294-302
没有 embedding API key只返回 BM25 结果skill_ranker.py:135-137
两者都不可用返回前 top_k 个原样候选skill_ranker.py:120

_prefilter_skills(registry.py:505)负责把 SkillMeta 包装成排序用的 SkillCandidate(带上从 _content_cache 取出、去掉 frontmatter 的正文 body), 调用 hybrid_rank,再按 skill_id 映射回 SkillMeta。保留上限是 max(15, max_skills*5)(registry.py:516)。

几个精华细节:

  • BM25 的语料不止 name+description,还截取正文前 2000 字(skill_ranker.py:283-284), 让词面匹配更丰富;但正文被截断以控成本。
  • embedding 有内容寻址缓存。 缓存键是 base64(skill_id):sha256(text)[:16] (skill_ranker.py:164 _content_key),只要 name/描述/正文有任何改动,key 就变,旧缓存 自动失效——不需要外部触发重算。技能进化后调用 invalidate_cache(skill_ranker.py:234) 还能主动清。
  • embedding 文本口径统一:name + description + body,和 MCP 的 search_skills、云端 clawhub 平台一致(skill_ranker.py:322 _build_embedding_text),保证向量空间可比。

关于"模糊匹配"的诚实说明: skill_engine/fuzzy_match.py 里的 fuzzy_find_match / REPLACER_CHAIN 是一条六级降级的 SEARCH/REPLACE 文本匹配链(精确 → 去行首空白 → 块锚点 Levenshtein → …),它服务的是编辑技能文件(进化时打 patch),不参与技能检索。检索这一路 的"模糊"来自 BM25 的词面容错和 embedding 的语义容错。fuzzy_match 的细节见 自进化引擎


3.4 LLM 精选:只看"标题",先计划再选

要解决的小问题: 从 ≤15 个候选里,选出真正对本任务有用的 1-2 个(可能一个都不选)。

思路(渐进式披露 + 计划先行): 送进 LLM 的不是技能全文,而是一份"标题级目录"—— 每行是 skill_id + 一句话描述 (+ 历史成功率)。全文只在选中后(3.5)才加载。这样即便有 15 个 候选,选择 prompt 也很短。

目录的组装在 registry.py:419-441:有质量数据时,每行还附上 (success 3/4 = 75%)(selected 5x, never succeeded) 这类信号。

选择 prompt 用"计划-then-选"套路(registry.py:666 _build_skill_selection_prompt), 逼模型先想清楚再挑,四步走:

步骤让 LLM 做什么
Step 1 Plan先想"这任务怎么干、要产出什么格式(PDF/DOCX…)、用什么工具"
Step 2 Match只挑为计划核心步骤提供实测流程的技能;仅"话题沾边"的要排除
Step 3 Quality优先高成功率的;躲开"从没成功"或成功率极低的
Step 4 Decide至多选 max_skills 个;没有贴切的就必须返回空——选错比不选更糟

它要求返回一个 JSON:{"brief_plan": "...", "skills": ["skill_id_1", ...]}。为什么强调"选错 比不选更糟"?因为一个不相关技能会把 agent 带上错误路径、白烧整轮迭代预算(prompt 里原话 就是这个意思,registry.py:696)。

解析很宽容(registry.py:707 _parse_skill_selection_response):先尝试从 Markdown 代码块里抠 JSON,再退而找裸 {...},json.loads 失败就返回空——宁可这一轮不注入 技能,也不让脏输出把流程搞崩。解析出的 skill_id 还要回注册表校验、并截到 max_skills 上限 (registry.py:476-483)。

质量前置过滤(在预筛之前就发生): select_skills_with_llm(registry.py:340)开头会用 SkillStore 的历史指标,直接剔除两类烂技能:

  • 选了 ≥2 次却从没完成过(total_selections>=2 and completions==0,registry.py:394)。
  • 落地 ≥2 次但退化率 >50%(fallbacks/applied > 0.5,registry.py:398)。

也就是说,烂技能连进 LLM 候选池的资格都没有——又省了一层。


3.5 注入:选中后才加载全文,拼成一条 system 消息

要解决的小问题: 把最终选中的 1-2 个技能的完整 SKILL.md 正文送到模型眼前,而且要 放在用户指令之前,让模型先读到技能指导。

思路: build_context_injection(registry.py:554)对每个选中技能,这时才调 load_skill_content 从缓存取出正文、去掉 frontmatter,拼成带小标题的块;最后套一个统一的 # Active Skills 头部,用 \n\n---\n\n 拼接。

两个体贴的工程细节:

  • {baseDir} 占位符替换(registry.py:582):把技能正文里的 {baseDir} 换成该技能目录 的真实绝对路径,这样 SKILL.md 里写的 scripts/foo.shreferences/… 相对引用能被 agent 正确解析。注入块里也会显式标出 **Skill directory**: <path>
  • backend 感知的提示词(registry.py:594-628):头部会根据当前实际可用的后端 (shell/mcp/gui…)动态生成——比如只有 shell 可用时才提 shell_agent,避免让模型去用 一个根本不存在的工具。

注入完成后回到编排层:tool_layer.py:689 _select_and_inject_skillscontext_text 交给 GroundingAgent.set_skill_context(...);没选中任何技能时则 clear_skill_context() 并返回 False。GroundingAgent 拿到这段上下文后如何两阶段执行,见 主循环


3.6 编排与"选谁来选":ToolLayer 的三处接线

前面各机制都住在 SkillRegistry 里;真正把它们串起来触发的是 ToolLayer。三个接线点:

① 初始化注册表 _init_skill_registry(tool_layer.py:634):按 3.1 的三级顺序拼 skill_paths,建 SkillRegistrydiscover()

② 选择并注入 _select_and_inject_skills(tool_layer.py:689):取 max_select(默认 2),从 SkillStore 拉质量指标,调 select_skills_with_llm,再把结果注入。 整条流程还会通过 RecordingManager.record_skill_selection 落盘,便于审计与后续进化。

③ 用哪个 LLM 来选 _get_skill_selection_llm(tool_layer.py:766):选择器和主力干活模型 可以是两个模型。优先级:

config.skill_registry_model (专用的技能选择模型,通常挑个便宜快的)
▼ 没配就用
GroundingAgent._tool_retrieval_llm (工具检索模型)
▼ 再没有
self._llm_client (主 LLM)

这又是省钱思路:技能选择是个"分类"小活,不必用最贵的主力模型,可以指定一个便宜模型专门 干,而且超时压到 30s、重试 2 次(tool_layer.py:773-777)。


3.7 执行中途再检索:RetrieveSkillTool

要解决的小问题: 开工时选的技能可能不够用——干到一半发现"这活得懂 Docker",但初选没给。

思路: 把"检索技能"本身做成一个 agent 能主动调用的内部工具 RetrieveSkillTool(retrieve_tool.py:26,backend_type = SYSTEM)。它复用同一条管线—— _arun 里直接调 select_skills_with_llm(query, max_skills=1)(retrieve_tool.py:76), 所以质量过滤、BM25+embedding 预筛、计划-then-选一个都不少;命中后同样用 build_context_injection 返回技能全文。

没有 LLM client 时它退化成"BM25+embedding only"路径,走 cloud.search.hybrid_search_skills(retrieve_tool.py:88)。这个降级保证了即便没有生成式 模型,中途检索也还能靠便宜手段工作。


4. 巧妙之处(可借鉴的技术)

  • 成本分级漏斗。 免费手段处理全量、贵手段只碰少量;PREFILTER_THRESHOLD 还让"要不要 动用便宜筛子"本身也成本可控。这套"BM25→embedding→LLM"是可直接照搬的检索省钱范式。 (registry.py:412skill_ranker.py:45)
  • 渐进式披露贯穿始终。 注册表只记元数据 → LLM 只看标题级目录 → 选中才加载全文。每一层都 在"够用就好"地暴露信息。(registry.py:358 的 docstring 就是这么写的)
  • 内容寻址的 embedding 缓存。 缓存键把文本 hash 编进去,内容一变缓存自动失效,免去一切 手动"记得重算"的心智负担。(skill_ranker.py:164 _content_key)
  • 质量数据反哺检索。 历史"选了不成功/落地就退化"的技能被前置剔除,并把成功率写进 LLM 目录——让检索随使用越用越准。(registry.py:382-407)
  • 一条管线,多处复用。 初选、中途 retrieve_skill、MCP search_skills 共享同一套 BM25/embedding 与选择逻辑,口径统一、行为一致。(retrieve_tool.py:29 注释、 skill_ranker.py:14)
  • 处处降级不崩。 BM25 无库退化成词重叠、无 API key 退回 BM25、LLM 输出脏就返回空、 选择失败就无技能继续——没有任何一环会因为依赖缺失而让整个任务失败

5. 边界与局限(诚实)

  • 首选依赖生成式 LLM。 最准的精选那步要调 LLM;没有 LLM client 时初选直接跳过技能选择 (tool_layer.py:736-745),只有中途的 retrieve_skill 会走 BM25+embedding 兜底。
  • embedding 要联网。 精排走 text-embedding-3-small 的 HTTP 调用(skill_ranker.py:40:414),没有 API key 时整条预筛降级为纯 BM25,语义召回能力下降。
  • 安全检查是正则,非语义。 check_skill_safety(skill_utils.py:23)靠关键词正则,能挡住 明显的恶意串,但绕过正则的隐蔽注入挡不住;且 suspicious.* 只记日志不阻断。
  • 选择上限很小。 默认最多注入 2 个技能(tool_layer.py:709)。需要多技能协作的复杂任务, 得靠中途 retrieve_skill 补,而不是一次注入一大堆。
  • 本章不覆盖的部分: 技能被选中后如何执行主循环;技能如何 进化/版本化自进化引擎;工具侧的 RAG 见 工具接地层;MCP search_skills 与云端社区见 对外集成

6. 代码地图(导航索引)

主题文件路径符号名
技能总管(发现/预筛/精选/注入)openspace/skill_engine/registry.pySkillRegistry
技能元数据(轻量,无正文)openspace/skill_engine/registry.py:88SkillMeta
扫三级目录、建注册表openspace/skill_engine/registry.py:120discover
热加载单个技能目录openspace/skill_engine/registry.py:293register_skill_dir
质量过滤 + 预筛分流 + LLM 精选主流程openspace/skill_engine/registry.py:340select_skills_with_llm
BM25+embedding 预筛封装openspace/skill_engine/registry.py:505_prefilter_skills
拼装注入上下文(加载全文)openspace/skill_engine/registry.py:554build_context_injection
计划-then-选 选择 promptopenspace/skill_engine/registry.py:666_build_skill_selection_prompt
宽容解析 LLM 选择结果openspace/skill_engine/registry.py:707_parse_skill_selection_response
持久化 skill_id 边车openspace/skill_engine/registry.py:44_read_or_create_skill_id
混合排序器(两阶段接力)openspace/skill_engine/skill_ranker.py:75SkillRanker
BM25 粗排→embedding 精排编排openspace/skill_engine/skill_ranker.py:107hybrid_rank
BM25 打分(含无库降级)openspace/skill_engine/skill_ranker.py:264_bm25_rank
内容寻址 embedding 缓存键openspace/skill_engine/skill_ranker.py:164_content_key
预筛触发门槛(=10)openspace/skill_engine/skill_ranker.py:45PREFILTER_THRESHOLD
三级目录发现顺序openspace/tool_layer.py:634_init_skill_registry
触发选择并注入 GroundingAgentopenspace/tool_layer.py:689_select_and_inject_skills
选"用哪个模型来选技能"openspace/tool_layer.py:766_get_skill_selection_llm
中途再检索的内部工具openspace/skill_engine/retrieve_tool.py:26RetrieveSkillTool
安全检查 / frontmatter 解析openspace/skill_engine/skill_utils.py:36check_skill_safety
进化/分析类 prompt(非选择 prompt)openspace/prompts/skill_engine_prompts.py:3SkillEnginePrompts

给需要复核的读者: 以上引用 as-of sourceCommit 228f8f7。注意一处易误解:技能选择的 prompt 不在 prompts/skill_engine_prompts.py,而是内联在 registry.py:666 _build_skill_selection_prompt;skill_engine_prompts.py 装的是进化(FIX/DERIVED/CAPTURED) 与执行分析的 prompt。