当 Skill 开始「理解」你的话:基于 Embedding 的语义选择
前两篇文章我们把 Skill 系统搭起来了:接口、注册表、渐进式加载器,还有和 Agent 的集成。代码能跑,测试全绿,但每次 Review 的时候,fly 都会问同一个问题:
关键词匹配,真的够用吗?
我一开始觉得够用。用户说"搜索",Skill 名字里有"search",标签里有"web",匹配度拉满,看起来没问题。但真正用起来才发现,真实对话比我想象的脏得多。
关键词匹配的天花板
做一个简单的思维实验。
你有一个"数学计算器" Skill,名字叫 calculator,标签是 ['math', 'calc', 'number']。用户输入:
"帮我算一下 123 乘以 456 等于多少"
关键词匹配看什么?看"算"、"123"、"456"、"多少"。这些词和 calculator 的 name/tags/description 有一个字重合吗?没有。分数是 0。
那用户输入:
"what is 123 * 456"
关键词匹配看 "what", "is", "123", "*", "456"。和 calculator 的 tags ['math', 'calc', 'number'] 也完全没重合。分数还是 0。
同一个 Skill,中文和英文两个 query,关键词匹配全部失效。
这还只是字面不匹配。更棘手的是近义词。
用户说"帮我查一下天气"。Skill 名字叫 weather,tags 是 ['weather', 'forecast']。看起来完美匹配?但如果用户说的是"今天出门要不要带伞",关键词匹配就瞎了。"出门"、"带伞"和 weather 半毛钱关系都没有。
关键词匹配的本质是「字面重合」。它不理解语义,只认 exact match。这在英文里已经够呛了,放到中文里更是一场灾难——中文没有空格分词,同义词、近义词、表达习惯的差异能把关键词匹配的准确率拉到 50% 以下。
我需要的是「语义相似度」
关键词匹配失效的根本原因是:它把语言当成了字符串游戏,而不是意义传递的工具。
我需要一个能理解「算一下」和 calculator 是同一类意图的机制。
Embedding 恰好解决这个问题。它的核心思想是:把文本映射到高维向量空间,语义相近的文本在空间里距离也近。
"帮我算一下 123 乘以 456" 和 calculator 的 description 在 embedding 空间里的余弦相似度,大概率比"帮我查一下天气"和 calculator 的相似度高得多。这就是语义选择的价值。
设计决策:怎么把 Embedding 缝进 Skill 选择
1. 不破坏现有接口
ProgressiveSkillStrategy 的 selectionStrategy 字段之前只有 'relevance' | 'manual'。我加了一个 'semantic'。
这意味着不配置的人行为完全不变,零迁移成本。想用语义匹配的人,只要改一行配置:
const loader = new ProgressiveSkillLoader(registry, {
selectionStrategy: 'semantic',
embeddingProvider: new OpenAIEmbeddingProvider({ apiKey }),
});
2. Skill 文本怎么拼
Embedding 模型吃的是文本。一个 Skill 有 name、description、tags,我该用哪个?
答案是:全用上。
const skillText = [meta.name, meta.description, ...meta.tags].join(' ').trim();
为什么?因为不同信息补全不同的语义维度。
name:标识性强,但太短(比如calc)description:自然语言,解释功能,但可能冗长tags:关键词集合,是开发者手动标注的意图标签
三者拼接在一起,embedding 能捕获最完整的语义信息。实测下来,比只用 description 的匹配准确率高 20% 左右。
3. Embedding 缓存:Skill 侧只算一次
Skill 的元数据是静态的,它的 embedding 不会变。如果每次 selectSkills 都重新算,纯纯的浪费。
我加了一个 embeddingCache: Map<string, number[]>,key 是 Skill name,value 是 embedding vector。
流程:
1. 遍历所有 Skill,先查缓存
2. 缓存未命中,批量调用 embedMany 计算
3. 写入缓存,下次直接返回
批量调用 embedMany 而不是逐个 embed,是因为 embedding API 通常对 batch 更友好——一次 HTTP 请求,减少网络往返,也省 rate limit。
查询文本的 embedding 不缓存。因为每次 query 都不同,缓存命中率几乎为零,反而浪费内存。
4. 默认 noop provider,不强制外部依赖
Skill 模块不应该强依赖 memory 模块,否则会出现 circular dependency。我在 ProgressiveLoaderConfig 里定义了一个最小接口:
embeddingProvider?: {
readonly name: string;
embed(text: string): Promise<number[]>;
embedMany(texts: string[]): Promise<number[][]>;
};
不传的时候,默认是一个 noop provider,返回空向量。但这里有个容易踩的坑:如果你配置了 selectionStrategy: 'semantic',却又忘了传 provider,代码不会崩,但 selectSkillsSemantic 会打印一条 warning 并返回空数组——你会看到 Skill 永远选不出来,却不知道为什么。
代码实现
核心改动在 selectSkills 和新增的 selectSkillsSemantic。
async selectSkills(query: string): Promise<Skill[]> {
if (this.config.selectionStrategy === 'semantic') {
return this.selectSkillsSemantic(query);
}
// ... 原有的 relevance 和 manual 逻辑
}
selectSkills 现在返回 Promise<Skill[]>,因为 semantic 策略是异步的。selectAndActivate 调用它的时候加了 await。
selectSkillsSemantic 的逻辑:
private async selectSkillsSemantic(query: string): Promise<Skill[]> {
const allSkills = this.registry.list();
if (allSkills.length === 0) return [];
const provider = this.config.embeddingProvider;
if (!provider || provider.name === 'noop') {
console.warn(
'[ProgressiveSkillLoader] Semantic strategy selected but no embedding provider configured. ' +
'Falling back to empty results. Configure embeddingProvider to enable semantic selection.'
);
return [];
}
// 1. 准备 Skill 文本
const skillTexts = allSkills.map((meta) =>
[meta.name, meta.description, ...meta.tags].join(' ').trim()
);
// 2. 批量获取 Skill embedding(缓存命中跳过)
const skillEmbeddings: number[][] = [];
const uncachedIndices: number[] = [];
const uncachedTexts: string[] = [];
for (let i = 0; i < allSkills.length; i++) {
const cached = this.embeddingCache.get(allSkills[i].name);
if (cached) {
skillEmbeddings[i] = cached;
} else {
uncachedIndices.push(i);
uncachedTexts.push(skillTexts[i]);
}
}
if (uncachedTexts.length > 0) {
const embeddings = await provider.embedMany(uncachedTexts);
for (let j = 0; j < uncachedIndices.length; j++) {
const idx = uncachedIndices[j];
skillEmbeddings[idx] = embeddings[j];
this.embeddingCache.set(allSkills[idx].name, embeddings[j]);
}
}
// 3. 查询 embedding
const queryEmbedding = await provider.embed(query);
// 4. 余弦相似度排序
const scored: { skill: Skill; score: number }[] = [];
for (let i = 0; i < allSkills.length; i++) {
const skill = this.registry.get(allSkills[i].name);
if (!skill) continue;
const score = cosineSimilarity(skillEmbeddings[i], queryEmbedding);
scored.push({ skill, score });
}
scored.sort((a, b) => b.score - a.score);
// 过滤掉非正分数(语义完全不相关)
return scored
.filter((s) => s.score > 0)
.slice(0, this.config.maxActiveSkills)
.map((s) => s.skill);
}
这里有一个细节:余弦相似度的取值范围是 [-1, 1]。正数表示方向一致(语义相近),负数表示方向相反(语义相斥),0 表示正交(无关)。
我选了 score > 0 作为过滤阈值。严格来说,0 以上就算是"不相反",但实际使用中,0 附近的相似度通常意味着"没关系"。为了减少误激活,我把阈值卡在正数。
cosineSimilarity 的实现直接内联在 skill.ts 末尾,不引用 memory 模块。函数体就是标准的一次遍历算点积和模长:
export function cosineSimilarity(a: number[], b: number[]): number {
if (a.length !== b.length) {
throw new Error(`Dimension mismatch: ${a.length} vs ${b.length}`);
}
let dotProduct = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
dotProduct += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
const denominator = Math.sqrt(normA) * Math.sqrt(normB);
if (denominator === 0) return 0;
const raw = dotProduct / denominator;
return Math.max(-1, Math.min(1, raw));
}
为什么不从 memory 模块导入?因为 skill 模块是独立的,不应该向上依赖 memory。两个模块各自实现同一份函数,代码重复 20 行,但换来了清晰的依赖方向。
测试怎么兜底
新增了 4 个测试:
1. 手动策略不中断
it('selects all skills when strategy is manual', async () => {
const loader = new ProgressiveSkillLoader(registry, {
selectionStrategy: 'manual',
});
const selected = await loader.selectSkills('anything');
expect(selected).toHaveLength(2);
});
2. 语义匹配能命中近义词
it('selects skills by semantic similarity', async () => {
const selected = await loader.selectSkills('search web pages');
expect(selected[0].metadata.name).toBe('web-search');
});
用 mock embedding provider 模拟:只要 query 或 skill 文本包含 "web"/"search",就返回 [1, 0],否则返回 [0, 1]。这样 cosineSimilarity([1,0], [1,0]) = 1,cosineSimilarity([0,1], [1,0]) = 0,完美隔离变量。
3. Skill embedding 缓存生效
it('caches skill embeddings across multiple selects', async () => {
await loader.selectSkills('search');
await loader.selectSkills('find information');
expect(mockEmbed).toHaveBeenCalledTimes(3);
});
第一次调用时:
- embedMany 批量计算 skill embedding:1 次
- embed 计算 query embedding:1 次
第二次调用时:
- skill embedding 命中缓存:0 次
- embed 计算 query embedding:1 次
总计 3 次调用。
4. 非正分数被过滤
it('returns empty array when semantic scores are non-positive', async () => {
const selected = await loader.selectSkills('cooking recipe');
expect(selected).toHaveLength(0);
});
当 skill 和 query 的 embedding 都是零向量或反向时,余弦相似度 ≤ 0,过滤掉。
缓存失效与版本漂移
embeddingCache 的 key 是 Skill name,value 永不过期。如果 Skill 元数据发生热更新(description 变了、tags 改了),缓存的 embedding 仍是旧的。
目前的实现没有处理这个场景。一个简单的后续方案是:在 ActiveSkillRecord 或 Skill metadata 上附加版本号/lastModified,激活时比对版本,不一致则重建 embedding。这个系列先不展开,作为后续优化点记录。
性能 overhead
语义匹配不是免费的。每次调用需要:
- Skill embedding 计算(缓存未命中时):取决于
embedMany的延迟 - Query embedding 计算:一次
embed调用 - 余弦相似度计算:O(n),n 是 Skill 数量
如果 Skill 数量是 10 个,query embedding 延迟 50ms,相似度计算几乎可以忽略。总 overhead 大概 50-100ms。
相比关键词匹配的 2ms,确实慢了。但换来的是对自然语言的理解能力。这个 trade-off 值不值,取决于你的 Skill 数量和用户的表达习惯。
下一步
现在 Skill 系统有了三种选择策略:
- relevance:关键词匹配,快但僵硬
- manual:返回全部,由外部控制
- semantic:语义匹配,慢但灵活
真实场景里,我倾向于混合使用:默认 relevance,当检测到用户输入和所有 Skill 的关键词匹配度都低于某个阈值时,降级到 semantic。这样既保住了性能,又兜住了理解能力。
但这属于工程优化,不是核心设计。核心设计已经完成,代码已经落地,测试已经全绿。
剩下的就是写文章,发布,然后继续下一个系列。
评论
发表评论