技能推荐
技能越装越多,智能体选技能时手里的信息却越来越少:技能清单是以「索引」的形式塞进系统提示词的,一个技能一行,描述截断到几十个字符。用户要做一份路演 deck,「编辑 .pptx」和「生成 .pptx」两个技能在索引里几乎一模一样;用户只是问个概念,一长串技能名反而诱导智能体随手加载一个。
本篇不改清单,而是在智能体决定加载哪个技能之前,加两次象信请求做「渐进式披露」:
- 粗排:一个 Choice 读完全部 208 个技能的一行描述,给出排序;同一请求里三道 Noul 判断这条请求到底需不需要技能。
- 复核:只把前三名带进第二次请求,这次附上每个技能的完整描述和
SKILL.md正文开头,再问一次「选哪个」,并用每个候选各自的 Noul 判断「它真的能做这件事吗」——三个都不行就什么也不推荐。
最后得到的至多一个技能名,作为一行附加提示放在清单之后,清单本身每轮都不变,前缀缓存照样命中。
一句话结论(全部来自真实运行,2026-09-25,xiangxin-latest → xiangxin-1.0.0,共 262 次象信调用):
| 131 条请求 | 推荐正确 | 推荐错误 | 什么也没推荐 |
|---|---|---|---|
| 81 条有对应技能 | 72 | 2 | 7 |
| 50 条没有任何技能适用 | — | 1(误推) | 49 |
- 第一次请求的前三名里包含正确技能的比例为 79/81;第二次请求(不设阈值)选对 78/81。
- 两次请求合计平均约 5,600 个输入 token,按 ¥0.042/百万 token 计算,每轮 ¥0.000235,一万轮 ¥2.35。
- TypeSafe 原文的核心表格是「智能体加载错技能 / 不该加载却加载」的比例,需要真的让智能体(本文用 DeepSeek)跑三组对照。这一部分代码已写好,数字待大模型密钥就绪后补上,见下文「第 6 步」。
用户请求 ──► 请求 1:Choice 粗排 208 个技能 + 3 道「需不需要技能」Noul
│ 前三名 │ 门控分 < 0.30
▼ ▼
请求 2:Choice 复核 3 个候选 不推荐
+ 每个候选一道「真能做吗」Noul
│ 最高的「能做」< 0.30 ──────────► 不推荐
▼
推荐胜出者(一行 <skill_relevance>)环境准备
pip install xiangxin-sdk
export XIANGXIN_API_KEY="sk-xx-..."
# 可选:第 6 步的智能体对照(任何 OpenAI 兼容接口,本文用 DeepSeek)
pip install openai
export LLM_BASE_URL=... LLM_API_KEY=... LLM_MODEL=...脚本把每次调用的结果按输入缓存在 results/cache.json,重跑时直接回放,不再计费。
第 1 步:加载技能清单
技能清单来自 Nous Research 的开源智能体 hermes-agent(MIT 许可),固定在提交 fdec926(2026-09-25)。我们用 build_roster.py 读取仓库里 skills/ 和 optional-skills/ 下的全部 SKILL.md,得到 208 个技能、32 个类别;每条记录保存名称、类别、索引里显示的描述(按 Hermes 源码的规则截断到 60 个字符)、完整描述,以及 SKILL.md 正文的前 1,600 个字符。
智能体看到的系统提示词原样照搬 Hermes 的 agent/prompt_builder.py:
import json
from collections import defaultdict
from pathlib import Path
ROSTER = json.loads(Path("data/hermes_roster.json").read_text(encoding="utf-8"))["skills"]
BY_NAME = {s["name"]: s for s in ROSTER}
def render_index() -> str:
by_cat = defaultdict(list)
for s in ROSTER:
by_cat[s["category"]].append(s)
lines = []
for cat in sorted(by_cat):
lines.append(f" {cat}:")
for s in sorted(by_cat[cat], key=lambda s: s["name"]):
lines.append(f" - {s['name']}: {s['description']}")
return "\n".join(lines)208 skills in 32 categories
roster prompt: 17,580 characters
index description: 54 characters on average, 60 at most
apple:
- apple-notes: Manage Apple Notes via memo CLI: create, search, edit.
- apple-reminders: Apple Reminders via remindctl: add, list, complete.
- findmy: Track Apple devices/AirTags via FindMy.app on macOS.
- imessage: Send and receive iMessages/SMS via the imsg CLI on macOS.在这个提交里,Hermes 的维护者已经把每条描述都写到了 60 个字符以内,所以索引里的描述就是全文描述。第二次请求真正多带来的信息,是 SKILL.md 正文的开头:什么时候该用、什么时候该用别的技能。
第 2 步:请求集
data/requests.json 里有 131 条单轮中文请求。这些请求是本文构造的(由 Claude 对照各技能的 SKILL.md 撰写,不是真实用户日志),生成脚本 data/make_requests.py 一并放在仓库里:
- 81 条有对应技能,覆盖 81 个不同的技能,每条恰好由一个技能覆盖。刻意放进了成对的「近邻」:
powerpoint(通用 .pptx 读写)与pptx-author(数字可追溯到模型的路演 deck)、xlsx与dcf-model、chroma/qdrant/faiss、unsloth/trl-fine-tuning等。 - 50 条没有任何技能适用,专门用来惩罚「乱猜」:20 条日常请求(今晚做什么菜比较快?)、15 条没有技能能服务的技术问题(解释一下什么是单子(monad))、15 条要求做一件具体的事、但清单里恰好没有对应技能(把这条公告发到我的微博上——清单里只有 X/Twitter 的技能)。
因为是按 SKILL.md 写出来的,有技能的那部分请求比真实用户的请求更「好认」,下面的准确率应当看作偏乐观的上限。
第 3 步:粗排整个清单
一个请求里放两类问题:
which:一个 Choice,208 个技能名作为选项,选项说明就是索引里的那一行描述(与智能体看到的完全相同)。它的概率就是排序。- 三道门控 Noul:从三个角度问「这条请求是要做一件事,还是只要一段解释」。
prose_suffices方向相反,取 1 − p。三者的平均值是门控分,低于 0.30 就不推荐。
from xiangxin import Choice, Noul, XiangxinClient
client = XiangxinClient()
MODEL = "xiangxin-latest"
SHORTLIST, EXCERPT_CHARS = 3, 700
GATE_THRESHOLD, FITS_THRESHOLD = 0.30, 0.30
CHOICE_INSTRUCTIONS = "要帮助用户完成最新这条请求,应该加载下面哪个技能(如果有的话)?"
GATE_QUESTIONS = {
"acts_on_user_system": "助手是否被要求去操作用户的文件、账号、设备或在线服务,而不只是解释或给建议?",
"would_follow_documented_procedure": "一位认真的专家处理这条请求时,是否会去查阅某个具体的操作文档或命令手册,而不是凭常识直接回答?",
"prose_suffices": "一位知识面广的通才,是否不用任何工具、文档,也不用访问用户的文件或账号,只靠文字回答就能完全满足这条请求?",
}
INVERTED = {"prose_suffices"}
def build_state(request: str) -> dict:
return {"request": request, "recent_context": ""}
def rank_wide(request: str) -> dict:
questions = {"which": Choice(instructions=CHOICE_INSTRUCTIONS,
criteria={s["name"]: s["description"] for s in ROSTER})}
for key, text in GATE_QUESTIONS.items():
questions[f"gate::{key}"] = Noul(instructions=text)
resp = client.system_one(state=build_state(request), questions=questions, model=MODEL)
ranked = sorted(resp.answers["which"].probabilities.items(), key=lambda kv: -kv[1])
values = {k.removeprefix("gate::"): a.noul for k, a in resp.answers.items() if k.startswith("gate::")}
oriented = [(1 - v) if k in INVERTED else v for k, v in values.items()]
return {"ranked": ranked[:12], "gate": sum(oriented) / len(oriented), "values": values}三条示例请求的输出:
"把这份红烧肉菜谱存成备忘录里「菜谱」文件夹的一条新笔记,我要在手机上同步看。"
needs a skill 0.59 -> suggest values={'acts_on_user_system': 0.65, 'would_follow_documented_procedure': 0.23, 'prose_suffices': 0.1}
0.21 unsloth Unsloth: 2-5x faster LoRA/QLoRA fine-tuning, less VRAM.
0.04 obsidian Read, search, create, and edit notes in the Obsidian vault.
0.03 dspy DSPy: declarative LM programs, auto-optimize prompts, RAG.
"根据我们的估值模型 workbook 做一份融资路演 deck(.pptx),每个数字都要能追溯到模型里的单元格。"
needs a skill 0.54 -> suggest values={'acts_on_user_system': 0.37, 'would_follow_documented_procedure': 0.3, 'prose_suffices': 0.05}
0.32 pptx-author Build PowerPoint decks headless with python-pptx.
0.07 powerpoint Create, read, edit .pptx decks with python-pptx.
0.04 excel-author Build auditable financial workbooks headless via openpyxl.
"在我的 Mastodon 账号上发一条帖子。"
needs a skill 0.52 -> suggest values={'acts_on_user_system': 0.5, 'would_follow_documented_procedure': 0.27, 'prose_suffices': 0.22}
0.18 unsloth Unsloth: 2-5x faster LoRA/QLoRA fine-tuning, less VRAM.
0.04 axolotl Axolotl: YAML LLM fine-tuning (LoRA, DPO, GRPO).
0.03 dspy DSPy: declarative LM programs, auto-optimize prompts, RAG.路演 deck 这条,粗排就把「生成路演 deck」的 pptx-author 排在了通用的 powerpoint 前面。另外两条暴露了象信一号 1.0 在超长选项列表上的一个怪癖:当没有明显匹配的选项时,概率会集中到 unsloth 这个与请求毫不相干的选项上。在全部 131 条请求里,unsloth 是 55 条的第一名——50 条无技能请求全部在内,另外 5 条是有技能的请求(其中 1 条的正确答案本来就是 unsloth)。可以把它理解为模型在 208 个选项里的「弃权」落点;它不影响排序的其余部分,但意味着粗排的第一名本身不能直接拿来用,第二次复核是必要的。
备忘录这条是真正的失误:清单里的 apple-notes 描述是「via memo CLI」,而中文请求说的是「备忘录」,粗排没有把它排进前三。
第 4 步:复核前三名
三个选项留得下更多文字,所以第二次请求把完整描述 + SKILL.md 正文前 700 个字符作为每个选项的说明,再问一次同一个问题:
which:在 3 个候选里选一个。fits::{name}:每个候选一道 Noul——「这个技能能不能完成用户要做的那件具体的事?」它们各自独立作答,可以全部很低;最高的一个低于 0.30 时,整个候选名单作废。
RERANK_INSTRUCTIONS = "下面这几个技能里,恰好有一个最适合为用户最新这条请求加载。是哪一个?请看每个技能实际做什么,而不只是看名字。"
def rerank_questions(names, excerpt=EXCERPT_CHARS):
q = {"which": Choice(
instructions=RERANK_INSTRUCTIONS,
criteria={n: f"{BY_NAME[n]['description_full']} — {BY_NAME[n]['body'][:excerpt]}" for n in names})}
for n in names:
q[f"fits::{n}"] = Noul(
instructions=f"技能「{n}」是否能完成用户这条请求要做的那件具体的事?它的说明是:{BY_NAME[n]['description_full']}")
return q
def rerank(request: str, names: list) -> dict:
resp = client.system_one(state=build_state(request), questions=rerank_questions(names), model=MODEL)
return {"winner": resp.answers["which"].choice,
"probabilities": resp.answers["which"].probabilities,
"fits": {k.removeprefix("fits::"): a.noul for k, a in resp.answers.items() if k.startswith("fits::")}}"把这份红烧肉菜谱存成备忘录里「菜谱」文件夹的一条新笔记,我要在手机上同步看。"
was unsloth -> obsidian
fits 0.03 choice p=0.01 unsloth
fits 0.68 choice p=0.95 obsidian
fits 0.32 choice p=0.04 dspy
"根据我们的估值模型 workbook 做一份融资路演 deck(.pptx),每个数字都要能追溯到模型里的单元格。"
was pptx-author -> pptx-author
fits 0.56 choice p=0.55 pptx-author
fits 0.56 choice p=0.23 powerpoint
fits 0.43 choice p=0.22 excel-author
"在我的 Mastodon 账号上发一条帖子。"
was unsloth -> nothing fits
fits 0.03 choice p=0.45 unsloth
fits 0.03 choice p=0.15 axolotl
fits 0.23 choice p=0.40 dspy- 路演 deck:读到正文后 Choice 仍然选
pptx-author(p=0.55),但两个 .pptx 技能的fits都是 0.56。Choice 决定选哪个,fits决定要不要开口,两者回答的是不同的问题。 - Mastodon:三个候选的
fits都在 0.30 以下,整轮不推荐。这和 TypeSafe 原文不同——原文的粗排把 X/Twitter 技能排在第一,复核也没拦住;这里粗排「弃权」到了unsloth,复核反而很容易地把三个不相干的候选全部否掉。 - 备忘录:正确答案不在前三,复核只能在错的候选里挑一个「最像的」——
obsidian也是笔记软件,fits0.68,于是推荐错了。第二步只能拒绝或重排第一步交给它的东西。
整个方法就是下面这个函数:两次请求、两个阈值,至多返回一个技能名。
def suggest(request: str):
"""至多一个技能名;None 表示“没有适用的技能”。"""
wide = rank_wide(request)
if wide["gate"] < GATE_THRESHOLD:
return None
shortlist = [n for n, _ in wide["ranked"][:SHORTLIST]]
rr = rerank(request, shortlist)
return rr["winner"] if max(rr["fits"].values()) >= FITS_THRESHOLD else None
def suggestion_block(name) -> str:
body = (f"Relevant to the current request: {name}. Ignore this if it does not fit what the user actually asked for."
if name else "No skill in the roster appears relevant to this request.")
return f"\n\n<skill_relevance>\n{body}\n</skill_relevance>"<skill_relevance>
Relevant to the current request: pptx-author. Ignore this if it does not fit what the user actually asked for.
</skill_relevance>
<skill_relevance>
No skill in the roster appears relevant to this request.
</skill_relevance>提示语保留英文,是因为 Hermes 的系统提示词本身是英文;它明确告诉智能体「不合适就忽略」,而且在没有推荐时也要说一句「没有相关技能」——否则清单里那句「宁可多加载」就没人反驳了。
要换成你自己的技能清单,只需替换 hermes_roster.json:上面所有问题只读取 name、description、description_full 和 body 四个字段。
第 5 步:衡量推荐本身
先不看智能体,直接拿 suggest() 的输出和标注比。为了能在代码里扫阈值,第二次请求对全部 131 条请求都发了(线上只有过了门控的请求才需要发),共 262 次调用。
request 1 top-1 correct: 77/81 (95.1%)
request 1 top-3 contains gold: 79/81 (97.5%)
request 1 recall@k: @1 77/81, @3 79/81, @5 79/81, @10 79/81
most frequent top-1 picks over all 131 requests: [('unsloth', 55), ('findmy', 1), ('imessage', 1), ('claude-code', 1), ('codex', 1)]
request 2 winner correct (no thresholds): 78/81 (96.3%)
request 2 changed the top pick on 5 covered requests: 2 fixed, 1 broke
gate mean: covered 0.46; everyday 0.15; technical 0.17; missing 0.48
with gate 0.3 and fits 0.3:
covered (81): right 72, wrong 2, nothing 7
uncovered (50): suggested something on 1 {'missing': 1}- 粗排已经很强:81 条里 77 条第一名就对,前三名召回 79 条;把候选放宽到前 5、前 10 也找不回剩下两条(
apple-notes与hermes-agent,两者的粗排都「弃权」到了unsloth)。 - 复核改动了 5 条的第一名:修好 2 条(
apple-reminders、reddit-reading,都是粗排「弃权」、正确答案排在第二或第三的情况),改坏 1 条——请求里明确写了「用 Unsloth 做 QLoRA」,复核却选了更通用的peft(fits0.93)。 - 两次推错:备忘录 →
obsidian,以及上面这条 Unsloth →peft。 - 一次误推:「用高德打车帮我叫一辆车去机场」被推荐了
dspy(门控分 0.45,最高fits恰好 0.30,擦线通过)。
门控 Noul 的代价
7 条「什么也没推荐」的有技能请求里,6 条是门控分低于 0.30 被拦下的:写歌词给 Suno(0.16)、去 AI 味(0.21)、健身营养计划(0.20)、每周复盘(0.23)、开车路线(0.25)、p5.js 小作品(0.27)。这些请求在复核里的 fits 都在 0.82 以上,说明技能本身很明确——但它们看起来像是「写一段文字就能满足」的请求,三道门控题恰恰被设计成把这类请求拦下。
反过来,门控对最难的那一类负例几乎没用:「清单里恰好没有的具体操作」(发微博、钉钉通知……)的平均门控分是 0.48,比有技能的请求(0.46)还高——它们确实是「要做一件事」。这与 TypeSafe 原文的观察一致:门控题问的是「要不要行动」,而不是「清单里有没有」。真正把这类请求挡下的是复核里的 fits。
下面是在同一批数据上扫两个阈值的结果(右边四列:有技能请求的推荐正确 / 推荐错误 / 未推荐,无技能请求的误推数):
| 门控阈值 | fits 阈值 | 正确 | 错误 | 未推荐 | 误推(/50) |
|---|---|---|---|---|---|
| 0.0 | 0.0 | 78 | 3 | 0 | 50 |
| 0.0 | 0.3 | 78 | 2 | 1 | 6 |
| 0.0 | 0.5 | 78 | 2 | 1 | 1 |
| 0.2 | 0.3 | 76 | 2 | 3 | 2 |
| 0.3 | 0.3 | 72 | 2 | 7 | 1 |
| 0.3 | 0.4 | 72 | 2 | 7 | 0 |
| 0.4 | 0.3 | 56 | 1 | 24 | 1 |
| 0.5 | 0.3 | 33 | 1 | 47 | 0 |
在这批数据上,去掉门控、只把 fits 阈值提到 0.5 反而更好(正确 78、误推 1)。但请注意这是在评估集上挑出来的阈值,有过拟合之嫌;正文用的 0.30 / 0.30 是运行前定好的。如果你要改阈值,请在你自己的请求日志上重扫一遍(阈值怎么定见 置信度)。
成本与延迟
input tokens per request: wide 4,806, rerank 789
cost per turn (both requests): ¥0.000235; per 10,000 turns ¥2.35粗排请求的输入主要是 208 个选项的描述,约 4,800 token;复核只有约 790 token。本次运行时服务端同时在跑大量其他测试任务,实测模型耗时中位数为粗排 9.7 秒、复核 4.0 秒,其中包含排队;运行过程中也遇到过若干次 429 / 5xx,由 SDK 与脚本的重试兜住。这组延迟数字不代表空载时的表现,线上使用时请以你自己的压测为准。
第 6 步:让智能体真的跑一轮
TypeSafe 原文的主结论来自这一步:131 条请求各跑三组,每组一轮,只读智能体的第一次回复,看它调用 skill_view 加载了什么。
| 组 | 系统提示词里附加什么 |
|---|---|
| 智能体自己选 | 什么都不加 |
| 附象信推荐 | suggest() 的结果(包括「没有相关技能」) |
| 直接告诉答案 | 标注的正确技能,或「没有相关技能」 |
第三组做不到,它是另外两组的下限参照。两个指标都是错误率,越低越好:
- 加载错误:有技能的请求中,第一次
skill_view不是正确技能的比例(一个都没加载也算错); - 多余加载:无技能的请求中,调用了
skill_view的比例。
智能体用 DeepSeek(OpenAI 兼容接口),工具定义照搬 Hermes 的 skill_view,外加 terminal、read_file、web_search 三个最小工具。三组共 393 次大模型调用:
def run_turn(model, arm, request, suggestion):
resp = llm.chat.completions.create(
model=model, max_tokens=1024, tools=TOOLS,
messages=[{"role": "system", "content": CATALOG_PROMPT + suggestion},
{"role": "user", "content": request}])
msg = resp.choices[0].message
return [json.loads(c.function.arguments).get("name", "")
for c in (msg.tool_calls or []) if c.function.name == "skill_view"]数字待补
本文运行时大模型密钥尚未就绪,main.py 的这一段自动跳过(输出 LLM_API_KEY not set: agent comparison skipped)。三组的错误率、「推荐修好 / 改坏了多少条」以及大模型 token 用量,会在真实运行后补进这里,不做任何估计。大模型成本将按 usage 与 DeepSeek 官方价目计算:输入 token × 输入单价 + 输出 token × 输出单价(命中缓存的输入按缓存单价)。
讨论
为什么有效。 粗排只需要把正确答案送进前三,复核再用更多文字做最后决定,并且可以整体否决。两次请求分工明确:Choice 负责「哪一个」,逐候选的 Noul 负责「要不要」。本次数据里,真正挡住「清单里没有」这类请求的,是复核里的 fits,而不是门控。
局限。
- 象信一号 1.0 是 9B 模型(见 模型的长短板)。在 208 个选项上,没有合适选项时它会把概率集中到一个固定的无关选项(这里是
unsloth)上。这不影响「正确答案在不在前三」,但你不能把粗排第一名当作最终答案,也不能只凭粗排概率判断「有没有合适的」。 - 中英混杂有代价:清单描述是英文,请求是中文。「备忘录」没能对上「Apple Notes via memo CLI」,就是这种错位造成的。给技能补一行中文别名是最便宜的修法。
- 门控题的措辞决定了它拦什么。本文沿用了 TypeSafe 的「要不要行动」三问,结果把写歌词、改文风这类「产出文字但有专门技能」的请求也拦掉了。如果你的清单里有很多写作类技能,门控题要重写,或者直接去掉门控,只靠
fits。 - 请求是按
SKILL.md构造的,比真实用户请求更好认;上面的准确率偏乐观。
什么时候不该这么用。 清单只有十几个技能时,智能体直接读全文描述就够了,不值得多两次往返。清单再大几倍(超过 Choice 的 255 个选项上限)时,先把清单切块、每块粗排一次,再对各块的胜者做同样的复核。
完整代码
https://github.com/xiangxinai/xiangxin-cookbooks/tree/main/skill_suggestion
build_roster.py:从 hermes-agent 仓库生成data/hermes_roster.jsondata/make_requests.py、data/requests.json:131 条构造请求main.py:本文全部步骤(含待补的智能体对照)results/:run_output.txt(完整输出)、summary.json、per_request.csv(逐条结果)、cache.json(每次调用的原始结果)

