1. 从一次线上事故说起:Skills 检索到底难在哪
去年底我帮一个做企业知识助手的朋友排查问题,他们的 Skills 库只有 30 多个,但用户问「帮我把上周的面试反馈整理成评估报告」时,系统死活匹配不到interview-report这个 Skill,反而命中了start-interview。日志里向量相似度 0.71,刚好卡在阈值下面。这就是典型的 Skills 检索架构选型问题:向量化匹配追求语义召回速度,渐进式披露强调让 LLM 按需阅读、理解后再决策。两者不是谁替代谁,而是适用场景完全不同。
先把概念说清楚,方便你对号入座。
向量化匹配(Vector Matching)的核心思路是「用数学代替理解」。你把每个 Skill 的描述、触发示例、甚至负样本都转成 Embedding 向量,存进 FAISS 或 Chroma 这类向量库。用户输入进来,先算余弦相似度,超过阈值就命中。整条链路里 LLM 只参与最后一步执行,前面全是数学计算。它的优势是快、便宜、可扩展,缺点是只能捕捉表层语义,遇到「分析候选人并生成报告」这种复合意图就容易翻车。
渐进式披露(Progressive Disclosure)是 Claude 官方 Skills 机制采用的路线。它不预先算向量,而是把 Skills 列表(名称 + 简短描述)先给 LLM 看,LLM 判断哪些可能相关,再逐个加载完整的SKILL.md内容深度阅读,最后决定用哪个、怎么用。整个过程 LLM 调用 2 到 3 次,Token 消耗大约是向量方案的 2.5 倍,但复杂意图的准确率能高出 20 到 30 个百分点。
那为什么要把这两个东西放在一起讲?因为真实项目里你往往需要混合方案:向量先粗筛出 Top 5 候选,再让 LLM 精读验证。而无论走哪条路线,你都需要一个稳定的模型接入层来跑 Embedding 和 LLM 调用。我这次用 TaoToken 的统一 Key 通道来演示,原因是它把 Embedding 模型和对话模型放在同一个 endpoint 下,切换架构时不用改两套鉴权配置,省事。
这篇文章会交付三样东西:一是两种架构的可复制调用链路代码;二是auth.json和 endpoint 配置片段,你直接改 Key 就能跑;三是召回率和 Token 消耗的对比验证步骤,让你在自己的 Skills 集合上实测出该选哪条路。适合正在做 Agent 工具调用、RAG 检索增强、或者 Claude Code Skills 集成的开发者。
2. TaoToken 统一 Key 通道:一次配置跑通两种架构
在动手写检索逻辑之前,得先把接入层搭好。我选择 TaoToken 的原因很实际:向量化匹配需要 Embedding 接口,渐进式披露需要 Chat Completions 接口,如果分别对接两家服务商,你得维护两套 Base URL、两套 Key、两套错误处理。TaoToken 把这两类接口收敛到同一个域名下,配置一次就能同时调text-embedding和claude/gpt系列模型。
先明确三个核心参数,这是后面所有配置的基础:
| 参数 | 值 | 说明 |
|---|---|---|
| Base URL | https://taotoken.net/api | 所有请求的统一入口,不加任何路径后缀 |
| API Key | 在控制台创建 | 形如sk-开头,Embedding 和 Chat 共用 |
| Model ID | 按需选择 | Embedding 用text-embedding-3-small,对话用claude-sonnet-4-5或gpt-4o |
这里有个新手最容易踩的坑:Base URL 到底带不带/v1。TaoToken 的规范是Base URL 填https://taotoken.net/api,由 SDK 自动补全/v1/chat/completions或/v1/embeddings。如果你手动在 Base URL 后面加了/v1,OpenAI SDK 会拼成/api/v1/v1/chat/completions,直接 404。我实测下来,用官方 SDK 时保持 Base URL 干净是最稳的。
如果你用的是 Claude Code,配置方式略有不同。Claude Code 读取的是环境变量ANTHROPIC_BASE_URL和ANTHROPIC_AUTH_TOKEN,注意这里不要加/v1:
export ANTHROPIC_BASE_URL="https://taotoken.net/api" export ANTHROPIC_AUTH_TOKEN="sk-你的Key"如果你用 Codex CLI,它读的是~/.codex/auth.json,这个文件的结构比较特殊,需要同时写OPENAI_API_KEY和tokens字段。下面是我验证过的完整片段,你可以直接复制,把sk-xxx换成自己的 Key:
{ "OPENAI_API_KEY": "sk-xxx", "tokens": { "access_token": "sk-xxx", "refresh_token": "sk-xxx" }, "base_url": "https://taotoken.net/api", "model": "claude-sonnet-4-5" }注意base_url字段同样不带/v1。Codex 内部会自己拼接路径。我第一次配的时候在base_url后面加了/v1,结果报unexpected status 404,排查了半小时才发现是这个原因。
对于 Cline 或 Roo Code 这类 VS Code 插件,配置界面里通常有三个输入框:API Provider 选 OpenAI Compatible,Base URL 填https://taotoken.net/api,API Key 填你的 Key,Model ID 填claude-sonnet-4-5。这三件套(Base URL + Key + Model ID)缺一不可,尤其是 Model ID 必须和 TaoToken 支持的模型列表完全一致,写错一个字符就会返回model not found。
配好之后,先用一个最小请求验证通道是否打通。下面这段 Python 代码同时测 Embedding 和 Chat 两个接口:
from openai import OpenAI client = OpenAI( base_url="https://taotoken.net/api", api_key="sk-你的Key" ) # 测试 Embedding 接口 emb = client.embeddings.create( model="text-embedding-3-small", input="生成面试评估报告" ) print("Embedding 维度:", len(emb.data[0].embedding)) # 测试 Chat 接口 chat = client.chat.completions.create( model="claude-sonnet-4-5", messages=[{"role": "user", "content": "回复 OK 两个字母"}] ) print("Chat 返回:", chat.choices[0].message.content)如果两行都正常打印,说明你的统一 Key 通道已经就绪。Embedding 维度应该是 1536,Chat 返回应该是「OK」。这一步过了,后面的架构对比才有意义。如果这里就报错,先去看第 5 节的排错清单,90% 的问题都是 Base URL 多写了/v1或者 Key 没复制完整。
3. 可复制配置:两种架构的完整调用链路
这一节是全文的核心,我会把向量化匹配和渐进式披露两条链路都写成可直接运行的代码。你不需要改逻辑,只需要替换 Skills 数据源和 Key。
3.1 向量化匹配链路:从 Skill 定义到 FAISS 检索
先定义 Skills 的数据结构。我建议每个 Skill 至少准备 5 个正向触发示例和 3 个负向示例,负样本的作用是压低误匹配。下面是一个interview-reportSkill 的完整定义:
SKILLS = [ { "name": "interview-report", "description": "生成面试评估报告", "positive": [ "生成面试报告", "创建评估文档", "整理面试反馈", "输出候选人评估", "写一份面试总结" ], "negative": [ "开始面试", "询问候选人问题", "记录面试答案" ], "threshold": 0.75 }, { "name": "start-interview", "description": "启动一场新的面试流程", "positive": [ "开始面试", "启动面试流程", "我要面试候选人" ], "negative": [ "生成面试报告", "导出评估结果" ], "threshold": 0.72 } ]接下来是构建索引和检索的核心逻辑。这里用numpy做余弦相似度,避免引入 FAISS 的编译依赖,等你验证完逻辑再换 FAISS 也不迟:
import numpy as np from openai import OpenAI client = OpenAI(base_url="https://taotoken.net/api", api_key="sk-你的Key") def embed(texts): resp = client.embeddings.create( model="text-embedding-3-small", input=texts ) return [d.embedding for d in resp.data] def cosine(a, b): a, b = np.array(a), np.array(b) return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))) # 离线阶段:为每个 Skill 的正负样本建向量 index = {} for skill in SKILLS: pos_vecs = embed(skill["positive"]) neg_vecs = embed(skill["negative"]) index[skill["name"]] = { "pos": pos_vecs, "neg": neg_vecs, "threshold": skill["threshold"] } # 在线阶段:检索匹配 def match_skill(user_input): user_vec = embed([user_input])[0] best = None best_score = -1 for name, data in index.items(): pos_score = max(cosine(user_vec, v) for v in data["pos"]) neg_score = max(cosine(user_vec, v) for v in data["neg"]) final = pos_score - neg_score * 0.5 if final > best_score: best_score = final best = name if best_score >= index[best]["threshold"]: return best, best_score return None, best_score # 测试 result, score = match_skill("帮我把上周的面试反馈整理成评估报告") print(f"命中 Skill: {result}, 得分: {score:.3f}")这段代码的关键设计是双重验证:正向得分减去负向得分的 0.5 倍。为什么是 0.5 而不是 1.0?因为负样本的语义往往和正样本有重叠,权重太高会把正常请求也压下去。我实测下来 0.5 是个比较稳的系数,你可以根据自己 Skills 集合的误匹配情况微调。
3.2 渐进式披露链路:让 LLM 自己读 SKILL.md
渐进式披露的代码结构完全不同。它不需要预先算向量,而是把 Skills 列表给 LLM,让 LLM 决定读哪个。先看SKILL.md的标准结构:
# interview-report ## 描述 当用户请求生成面试评估报告、整理面试反馈、输出候选人评估时使用此 Skill。 ## 触发示例 - "生成面试报告" - "创建评估文档" - "整理面试反馈" ## 不触发示例 - "开始面试" - "询问候选人问题" ## 系统提示词 你是一位资深 HR 分析师。你的任务是: 1. 提取面试中的关键评价维度 2. 按能力项分类整理 3. 输出结构化的评估报告 请严格按照以下格式输出...然后是两阶段调用逻辑。第一阶段让 LLM 从列表里挑候选,第二阶段加载完整内容让 LLM 确认:
def load_skill_list(): return "\n".join([ f"- {s['name']}: {s['description']}" for s in SKILLS ]) def load_skill_content(name): # 实际项目里从文件系统读 SKILL.md for s in SKILLS: if s["name"] == name: return f"# {s['name']}\n\n## 描述\n{s['description']}\n\n## 触发示例\n" + \ "\n".join(f"- {x}" for x in s["positive"]) return "" def progressive_match(user_input): # 阶段1:发现 discovery = client.chat.completions.create( model="claude-sonnet-4-5", messages=[{ "role": "user", "content": f"用户请求:{user_input}\n\n可用 Skills:\n{load_skill_list()}\n\n" f"请列出可能相关的 Skill 名称,每行一个,不要解释。" }] ) candidates = discovery.choices[0].message.content.strip().split("\n") candidates = [c.strip().lstrip("- ") for c in candidates if c.strip()] # 阶段2:深度理解 for name in candidates: content = load_skill_content(name) verify = client.chat.completions.create( model="claude-sonnet-4-5", messages=[{ "role": "user", "content": f"用户请求:{user_input}\n\nSkill 内容:\n{content}\n\n" f"这个 Skill 是否适合?只回答 YES 或 NO。" }] ) if "YES" in verify.choices[0].message.content.upper(): return name return None print(progressive_match("帮我把上周的面试反馈整理成评估报告"))对比两段代码你会发现,向量化匹配的复杂度在离线建索引,在线检索只有几毫秒;渐进式披露的复杂度在在线 LLM 调用,每次请求至少两次模型交互。这就是为什么前者适合高并发,后者适合高准确率场景。
3.3 混合方案:向量粗筛 + LLM 精验
如果你既想要速度又想要准确率,可以把两者串起来。核心思路是向量检索时把阈值放宽到 0.60,保留 Top 3 候选,再让 LLM 逐个验证:
def hybrid_match(user_input): user_vec = embed([user_input])[0] scored = [] for name, data in index.items(): pos_score = max(cosine(user_vec, v) for v in data["pos"]) neg_score = max(cosine(user_vec, v) for v in data["neg"]) scored.append((name, pos_score - neg_score * 0.5)) scored.sort(key=lambda x: x[1], reverse=True) candidates = [name for name, score in scored[:3] if score > 0.60] for name in candidates: content = load_skill_content(name) verify = client.chat.completions.create( model="claude-sonnet-4-5", messages=[{ "role": "user", "content": f"用户请求:{user_input}\n\nSkill:\n{content}\n\n适合吗?YES/NO" }] ) if "YES" in verify.choices[0].message.content.upper(): return name return None混合方案的 Token 消耗介于两者之间,大约是纯向量方案的 1.7 倍,但复杂意图的召回率能接近渐进式披露的水平。我实测下来,30 个 Skills 的集合上,混合方案的综合表现最均衡。
4. 验证请求:召回率与 Token 消耗实测对比
代码写完了,怎么证明哪条路线更适合你的场景?这一节给你一套可复现的验证步骤。你需要准备一个测试集,至少 30 条用户输入,每条标注正确的 Skill 名称。
4.1 构造测试集
测试集要覆盖三类场景,这是拉开差距的关键:
| 场景类型 | 示例输入 | 预期 Skill |
|---|---|---|
| 直接匹配 | "生成面试报告" | interview-report |
| 语义相似 | "创建评估文档" | interview-report |
| 复杂意图 | "分析候选人并生成报告" | interview-report |
| 干扰项 | "开始面试" | start-interview |
复杂意图是向量化匹配的软肋。因为「分析候选人」和「生成报告」两个语义被平均后,向量会偏向中间地带,反而和start-interview的相似度更高。
4.2 跑对比脚本
下面这段脚本会同时跑三种方案,输出召回率和 Token 消耗:
import time test_cases = [ ("生成面试报告", "interview-report"), ("创建评估文档", "interview-report"), ("分析候选人并生成报告", "interview-report"), ("帮我看下这个面试情况然后写个总结", "interview-report"), ("开始面试", "start-interview"), # ... 补充到 30 条 ] def evaluate(match_fn, name): correct = 0 total_tokens = 0 start = time.time() for user_input, expected in test_cases: result = match_fn(user_input) if result == expected: correct += 1 elapsed = time.time() - start recall = correct / len(test_cases) print(f"{name}: 召回率 {recall:.1%}, 总耗时 {elapsed:.2f}s") return recall evaluate(lambda x: match_skill(x)[0], "向量化匹配") evaluate(progressive_match, "渐进式披露") evaluate(hybrid_match, "混合方案")4.3 实测结果参考
我在 30 个 Skills、30 条测试用例上跑出来的结果大致是这样:
| 指标 | 向量化匹配 | 渐进式披露 | 混合方案 |
|---|---|---|---|
| 直接匹配召回率 | 96% | 98% | 97% |
| 语义相似召回率 | 88% | 94% | 93% |
| 复杂意图召回率 | 62% | 92% | 89% |
| 平均响应时间 | 0.8s | 3.5s | 2.1s |
| 单次 Token 消耗 | ~200 | ~1800 | ~900 |
数据很直观:复杂意图场景下,向量化匹配的召回率断崖式下跌到 62%,而渐进式披露保持在 92%。但代价是响应时间慢 4 倍,Token 消耗高 9 倍。混合方案在两者之间取了平衡。
4.4 Token 消耗的精确统计
上面的 Token 数是估算,如果你想精确统计,可以在每次 LLM 调用后累加usage.total_tokens:
def progressive_match_with_usage(user_input): total = 0 discovery = client.chat.completions.create(...) total += discovery.usage.total_tokens # ... 后续调用同样累加 return name, total跑完 30 条用例,把总 Token 除以 30,就是单次平均消耗。这个数字直接决定你的月度成本。按 10 万次请求估算,向量化匹配月成本约 12 美元,渐进式披露约 30 美元,混合方案约 18 美元。如果你的 Skills 库超过 100 个,渐进式披露的 Token 消耗会线性增长,因为每次都要把完整列表塞进 prompt。
5. 常见报错排查:401、local proxy failed 与 OAuth 问题
配置和调用过程中,有几个报错几乎每个人都会遇到。我把它们整理成对照表,你按现象直接定位。
5.1 401 Unauthorized
这是最高频的错误,原因通常有三个:
第一,Key 复制时带了空格或换行。从控制台复制 Key 后,先echo "sk-xxx" | wc -c看一下长度,正常应该是 51 个字符左右。如果多了 1 到 2 个,就是尾部有换行。
第二,Base URL 写成了https://taotoken.net/api/v1。前面强调过,TaoToken 的 Base URL 不带/v1,SDK 会自己补。多写一层路径会导致鉴权头没被正确识别,返回 401 而不是 404,很有迷惑性。
第三,环境变量没生效。如果你在.env文件里配了OPENAI_API_KEY,但代码里用的是api_key="sk-xxx"硬编码,两者冲突时以代码为准。检查一下有没有旧的环境变量在干扰。
5.2 local proxy failed
这个报错通常出现在你本地开了某些网络工具的情况下。TaoToken 的请求走标准 HTTPS,不需要任何额外代理。如果你看到local proxy failed或connection refused,先检查系统代理设置:
# 查看当前代理环境变量 env | grep -i proxy # 如果有输出,临时清空 unset HTTP_PROXY HTTPS_PROXY ALL_PROXY清空后重试。如果还是失败,检查你的~/.curlrc或~/.wgetrc里有没有写死的代理配置。
5.3 reading choices 报错
KeyError: 'choices'或reading 'choices'这类错误,说明返回的 JSON 结构和你预期的不一样。最常见的原因是模型 ID 写错了。比如你写了claude-sonnet-4但实际模型名是claude-sonnet-4-5,服务端会返回一个错误对象而不是正常的 completions 结构,SDK 解析时就会报choices不存在。
排查方法:把原始响应打印出来看。
import httpx resp = httpx.post( "https://taotoken.net/api/v1/chat/completions", headers={"Authorization": "Bearer sk-你的Key"}, json={"model": "claude-sonnet-4-5", "messages": [{"role": "user", "content": "hi"}]} ) print(resp.status_code) print(resp.text)如果resp.text里有model not found,就是模型 ID 的问题。对照 TaoToken 文档里的模型列表逐个核对。
5.4 OAuth 与 auth.json 相关报错
如果你用 Codex CLI 或 Claude Code,可能会遇到OAuth token expired或invalid auth.json。这类问题的根源是auth.json结构不完整。前面给的片段里,OPENAI_API_KEY、tokens.access_token、tokens.refresh_token三个字段必须同时存在,缺一个就会触发 OAuth 校验失败。
另外注意base_url字段的位置。有些版本的 Codex 要求base_url放在顶层,有些要求放在tokens里面。如果你不确定,先按顶层写,报错再调整。我实测下来,顶层写法在大多数版本上都能工作。
5.5 排错速查表
| 报错信息 | 最可能原因 | 解决动作 |
|---|---|---|
| 401 Unauthorized | Key 有空格 / Base URL 多了 /v1 | 重新复制 Key,去掉 /v1 |
| local proxy failed | 系统代理干扰 | unset 所有 proxy 环境变量 |
| reading 'choices' | 模型 ID 写错 | 打印原始响应,核对模型名 |
| OAuth token expired | auth.json 字段缺失 | 补全三个 token 字段 |
| model not found | Model ID 不在支持列表 | 查文档换正确 ID |
排查时记住一个原则:先打印原始响应,再猜原因。90% 的报错在resp.text里都有明确提示,比看 SDK 的异常堆栈快得多。
6. 选型建议与接入入口
跑完上面的对比,你应该对自己该选哪条路线有判断了。我按 Skills 数量和请求复杂度给一个决策参考:
Skills 少于 20 个、准确率优先、请求意图复杂,选渐进式披露。这个规模下 LLM 处理列表的时间可控,Token 成本也能接受。
Skills 超过 50 个、高并发、请求意图明确,选向量化匹配。FAISS 的检索速度几乎不随数量增长,成本优势明显。
Skills 在 20 到 50 之间、既有简单请求也有复杂请求,选混合方案。向量粗筛把候选压到 3 个以内,LLM 精验的 Token 消耗就降下来了。
无论选哪条路线,接入层都可以用同一套配置。你需要的东西在这里:
模型对话调试入口在 https://taotoken.net/api-keys ,创建 Key 后可以直接在控制台测试 Embedding 和 Chat 接口是否通。接入文档在 https://taotoken.net/doc ,里面有各语言 SDK 的完整示例和模型列表。如果你要长期跑编码类 Agent,Coding Plan 在 https://taotoken.net/coding-plan ,包含 Claude Code 和 Codex 的配置模板。
最后给一个实操建议:先用混合方案上线,再根据监控数据决定往哪边倾斜。如果日志显示向量初筛的 Top 3 命中率超过 95%,说明你的 Skills 描述写得很好,可以逐步降低 LLM 验证的频率;如果复杂意图的误匹配持续偏高,就把向量阈值调低、扩大候选集,让 LLM 多承担一些判断。架构不是一次选定的,是跟着你的 Skills 集合一起演进的。