我追这套源码追到第五篇,前面几篇把消息主循环、工具注册、上下文裁剪都啃完了,本来以为 Skills 系统会是个轻量附录,翻进去才发现这是整个框架里最“像人”的一块。说得直白点,模型本身是个知识面极广但毫无职业训练的应届生,你让它聊常识它很行,你让它按某个行业的规矩出一份对上口径的东西,它就开始自由发挥。Skills 系统干的事情,就是把“某个岗位的老手是怎么干活的”这段隐性经验,做成可插拔、可按需加载、可版本管理的结构化文件,在恰当的时刻塞进上下文,让通用模型在特定任务上表现得像个专家。
这篇文章面向两类人:一类是正在读这个框架源码、想知道 Skills 目录下那些 Markdown 文件到底被谁读、什么时候读、读进去之后怎么拼进 prompt 的;另一类是自己搭 agent、被“提示词越写越长、效果越写越飘”折磨过,想找一套可维护方案的人。我会从设计取舍讲到文件格式,从加载器实现讲到触发匹配,最后给一份我自己踩坑攒出来的排查对照表。代码是基于常见实践补的示意实现,不是逐行复刻,但逻辑链路和真实源码是对得上的。
1. 内容整体设计与思路拆解
1.1 Skills 解决的其实是“能力落差”而不是“知识缺失”
很多人对 Skills 的第一反应是“这不就是知识库吗”,方向就偏了。知识库解决的是“模型不知道这件事”,比如某个零件的公差标准、某个接口的返回字段;Skills 解决的是“模型知道但不会按规矩用”。这两者的差别在实际项目里非常致命。
举个我印象很深的例子。让模型读一份周期性的经营数据表并给出结论,它知识上完全没问题,加减乘除都会,但它会犯三类错:第一类是口径错,把“含税”当“不含税”算;第二类是流程错,先下结论再找数据,导致结论和数据对不上;第三类是格式错,你想看一张对比表,它给你三段散文。这三类错你喂多少知识文档都救不回来,因为它缺的是“先做什么、后做什么、什么情况必须停下来问人”的操作规程。
所以 Skills 的本质是操作规程的载体,它的内容重心应该落在顺序、判定条件、边界和验收标准上,而不是概念解释。我在给自己团队写 Skill 的时候有个土办法:先假装带一个刚入职的新人,把“他要犯哪些错你才会放心让他独立干活”列出来,列完那张纸基本就是 Skill 的正文骨架。这个视角转换比什么都管用,因为它强制你去想异常分支,而不是只写 happy path。
从框架设计角度看,Skills 需要同时满足四个约束:可热更新(不改代码就能调)、可组合(一次任务能挂多个)、可裁剪(不能无脑全量注入)、可观测(出问题能查到是哪个 Skill 干的)。这四个约束基本决定了后面的所有技术选型。
1.2 四种实现路线的取舍:为什么最后都走向声明式文件
在 Skills 这套东西成型之前,业内的做法大致有四类,我把它们的优缺点摊开对比一下,你就明白为什么这个框架选了现在这条路。
| 方案 | 落地方式 | 优势 | 硬伤 |
|---|---|---|---|
| 微调 | 拿领域语料做 SFT | 效果上限高,推理零额外开销 | 迭代周期以天计,无法按任务切换,忘性大 |
| 超长 system prompt | 把所有规矩塞进系统提示 | 实现最简单,改一行就生效 | 上下文预算被吃光,注意力被稀释,互相干扰 |
| 工具函数硬编码 | 把流程写成代码分支 | 确定性最强,可测试 | 灵活性差,任何规则微调都要发版 |
| 声明式 Skill 文件 | 元数据 + 正文,按需加载 | 可热更新、可组合、可版本化 | 依赖匹配质量,需要额外治理 |
框架选的是第四条。理由不难理解:agent 场景下需求的变更频率远高于传统软件,今天加个“退款审批口径”,明天改个“合同风险等级划分”,如果每次都走发版流程,这套东西根本活不下来。声明式文件的好处是把“内容”和“执行”解耦了,业务侧改 Markdown,工程侧只维护加载和匹配逻辑。
但声明式方案有个绕不开的代价——它把复杂度从代码转移到了匹配。代码分支是确定的,写了 if 就一定走 if;而 Skill 是要被“选中”的,选错了比没有更糟。所以你会看到这个框架在 Skill 的元数据上下了很重的功夫:description 写得像一段给模型看的说明书,triggers 里塞关键词和文件类型,priority 用来处理冲突。这些字段不是装饰,每一个都对应匹配链路上的一次判断。
还有个容易忽略的点:为什么用 Markdown 而不是 JSON 或 YAML 存正文。我一开始也觉得奇怪,后来想通了——正文最终是要进 prompt 的,Markdown 的层级、列表、加粗本身就是模型最熟悉的“指令排版语言”,用 JSON 存正文等于每次加载都要做一次序列化,纯属给自己找麻烦。而 frontmatter 用 YAML 是因为元数据需要结构化解析,两者各取所需,这个混合格式在静态站点领域已经验证过很多年了。
1.3 Skill、Tool、Prompt 三者的边界到底在哪
新手最容易犯的错是把 Skill 写成 Tool 的说明书,或者把 Tool 的逻辑塞进 Skill。我习惯用一个人体类比来划分:Tool 是手,Skill 是脑,Prompt 是嘴。
手负责“能不能做到”,比如读文件、跑脚本、发起一次计算,它必须是确定性的、可枚举的、有明确入参出参的。脑负责“该怎么想”,包括判断顺序、异常处理、什么情况下该调哪只手。嘴负责“说成什么样”,包括语气、格式、长度、要不要给表格。
落到实操上,判断标准很清晰:如果一段内容需要被程序严格执行、且有明确返回值,它是 Tool;如果一段内容是在教模型“遇到 X 情况先做 A 再做 B,如果 B 失败就回退到 C”,它是 Skill;如果一段内容只是调整输出风格,放 system prompt 里就行,没必要单开 Skill。
我在实际项目里见过最典型的错位,是把一整套校验规则写成了 Tool,然后在 Skill 里写“请调用 validate 工具并处理返回结果”。问题在于 validate 返回的是布尔值和错误列表,而“处理返回结果”这件事本身需要判断逻辑,模型拿着一个 false 根本不知道该重试还是该报错。正确的拆法是:Tool 只负责机械校验并返回结构化错误码,Skill 里明确写“错误码 E03 表示口径不一致,此时不要修改数据,直接在结论里标注存疑并列出差异项”。这才是把脑和手分开。
还有一个隐含边界是可测试性。Tool 是可以写单测的,输入什么输出什么是固定的;Skill 的效果只能靠端到端评估来验证。所以凡是能写成 Tool 的确定性逻辑,尽量下沉到 Tool,Skill 里只保留判断和编排。这条原则能显著降低你后续调优的工作量,因为确定性部分不会漂。
2. 核心细节解析与实操要点
2.1 一个 Skill 的描述文件究竟由哪些部分构成
目录结构上,这个框架采用的是“一个 Skill 一个文件夹”的约定,入口固定为SKILL.md,同级可以挂references/(参考资料)、scripts/(可执行脚本)、assets/(模板)。这个约定很关键,因为它让 Skill 从“一段文本”升级成“一个自包含的能力包”,参考资料可以按需二次加载,脚本可以被 Tool 层直接调用,而不用把几千字全塞进一个文件里。
入口文件的骨架分两段:YAML frontmatter 放元数据,正文放操作规程。下面是我按源码字段还原出来的一个示例:
--- name: report-audit description: 按内部口径核对周期性经营报表的数据一致性;当用户要求核对、对账、比对、审计报表数字时使用 version: 1.2.0 priority: 60 triggers: keywords: ["核对", "对账", "比对", "报表", "口径"] file_globs: ["*.xlsx", "*.csv"] tools: ["read_file", "run_script", "calc"] max_tokens: 1800 references: - references/glossary.md ---字段里最值得琢磨的是description。它不是写给人看的简介,而是写给模型看的路由依据。源码里这段描述会被拼进一个“可用技能清单”注入到系统提示中,模型自己也会参与选择。所以好的 description 一定要包含“做什么 + 什么时候用”,最好带上用户可能说的原话。我见过有人把 description 写成“报表审计技能”五个字,结果模型十次有八次想不起来用它,改成上面那种带触发语境的写法之后命中率立刻不一样。
priority是用来解冲突的。同一个 query 命中多个 Skill 时,优先级高的先注入,低的不一定被丢弃,但会被预算挤掉。这个值的设定我建议拉开梯度,别都用默认 50,否则冲突处理就变成了随机。
max_tokens是单 Skill 的注入上限,超过会被截断。这个字段存在的意义是防止有人往 Skill 里贴一篇万字长文,把整个上下文吃干净。截断策略通常是保留头部、丢弃尾部,所以写 Skill 的时候要把最关键的判定规则放在前面,参考资料类的细节放后面或者干脆挪到references/里做二次加载。
references是个延迟加载清单,只有在 Skill 被激活且模型主动请求时才会读入。这个设计很省,但也容易踩坑——如果正文里没有明确写“遇到不确定的术语请先读 glossary”,模型大概率不会主动去读,那这个字段就白配了。
2.2 触发与路由:Skill 是怎么被“叫醒”的
匹配链路上,这个框架用的是三段式的混合策略,而不是单一方案。这个选择是有原因的:纯关键词匹配太脆,用户换个说法就漏;纯向量检索成本高且不稳定,短 query 的语义向量经常抖;纯靠模型自选则完全不可控,遇到小模型直接摆烂。混合策略是把三者的短板互补。
第一段是显式触发,说白了就是硬匹配。扫描triggers.keywords里的词,query 里出现就加分,加分权重通常和关键词长度正相关——长词更具体,短词容易误伤。源码里对中文关键词做了特殊处理,因为中文没有空格分词,短词(两个字)误命中率极高,像“核对”这种还好,“报表”在闲聊里也可能出现。所以实际实现里一般会给短关键词设一个阈值,比如命中的短词数量必须达到两个以上才计入。
第二段是语义兜底,把 Skill 的 description 做向量化存进索引,query 进来先算相似度,超过阈值就进候选池。这一段的作用是接住那些关键词没覆盖到但语义确实相关的请求。代价是需要维护一个向量索引,并且要处理索引和文件不同步的问题——我在实际使用中就遇到过改了 description 但忘了重建索引,导致新写的触发词完全不生效,排查了半天。
第三段是模型自选。系统提示里会常驻一份“技能清单”,格式大概是“name: 一句话描述”,模型在规划阶段可以主动声明要用哪个 Skill。这一段兜的是最难的场景:用户表述极其模糊,比如“帮我看看这个文件有没有问题”,关键词和语义都不一定命中,但模型结合上下文能判断出该用审计类技能。
三段的结果最后会进一个合并与排序环节,按分数加权、按 priority 微调,然后进预算裁剪。裁剪逻辑值得单独说,因为它决定了“多个 Skill 命中时到底给谁”。
def select_skills(query, registry, budget=4000): scored = [] for skill in registry: score = 0.0 kw_hits = 0 for kw in skill.triggers.get("keywords", []): if kw in query: score += 8.0 * len(kw) kw_hits += 1 if kw_hits < 2 and max((len(k) for k in skill.triggers.get("keywords", [])), default=0) <= 2: score = 0.0 score += skill.semantic_sim * 20.0 score += skill.priority * 0.1 if score > 0: scored.append((score, skill)) scored.sort(key=lambda x: -x[0]) picked, used = [], 0 for _, skill in scored: cost = skill.token_estimate if used + cost > budget: continue picked.append(skill) used += cost return picked, used这段逻辑里有三个我自己很在意的细节。第一,continue而不是break——预算不够时跳过当前这个,但继续尝试更小的 Skill,这个细节能让预算利用率提升不少,否则一个大 Skill 排前面就把后面的全堵死了。第二,token_estimate必须在加载时就预计算并缓存,不能每次现算,因为裁剪是热路径。第三,排序键里priority的系数只有 0.1,意味着它只能做微调,不能逆转语义相关性的巨大差距,这个权重比例是刻意设计的,防止有人用 priority 硬抢。
2.3 上下文拼装的顺序:为什么位置比内容更重要
Skill 被选中只是第一步,真正决定效果的是它被放在 prompt 的什么位置。这个框架的拼装顺序大致是:基础系统提示 → 技能清单(常驻)→ 激活的 Skill 正文 → 工具 schema → 历史消息 → 当前用户输入。这个顺序不是随手排的,每一段的位置都有明确理由。
基础系统提示放最前面,因为它最稳定,几乎所有请求都一样,放在头部能最大化前缀缓存的命中率。这一点在长会话里差距巨大,前缀缓存的定价通常只有常规输入的几分之一,一个 3000 token 的稳定前缀,在几百轮对话里省下的成本是实打实的。所以我在写系统提示时有个硬规矩:只放永远不变的内容,任何带时间戳、带用户名的东西一律挪到后面,否则缓存全废。
技能清单紧跟在系统提示后面,也是常驻的,因为它需要让模型在任何时刻都知道自己有哪些能力可用。这份清单必须短,通常每个技能一行,控制在几十个 token。技能数量超过二三十个之后,清单本身就会造成干扰,这时候要做的是分层——顶层只列技能分类,模型选中分类后再展开具体技能。
激活的 Skill 正文放在工具 schema 之前,这个顺序我一开始觉得反了,后来想明白了:Skill 正文里会引用工具名,比如“调用 run_script 执行校验”,如果 schema 在正文前面,模型读到正文时工具定义已经过去了,注意力上是“倒着找”。正文在前、schema 在后,读起来是“先知道要干什么,再看到手边有哪些工具”,符合指令式阅读习惯。
历史消息之后才是当前输入,这个没什么好说的,是标准做法。但有个细节值得提:当 Skill 正文比较长(比如 1500 token)时,它和历史消息之间会形成一段较长的“非对话内容”,部分模型在这种结构下会出现指令遵循衰减。缓解办法是在 Skill 正文结尾加一句显式的收束语,比如“以上规则仅适用于本次任务,完成后回到正常对话”,实测能明显减少规则泄漏到后续无关轮次的问题。
2.4 写 Skill 正文的几条硬规矩
正文写得好不好,直接决定这套系统是“专家”还是“复读机”。我踩了几个月坑,总结出几条自己一直在用的规矩,按重要性排序。
第一条,用祈使句,不用描述句。“你应该先检查数据完整性”比“数据完整性检查是很重要的”有效得多。前者是指令,后者是陈述,模型对指令的遵循度显著高于对陈述的推理。我甚至会避免任何“需要注意的是”这种软化表达,直接写“禁止 X”。
第二条,给反例,而且反例要比正例具体。只写“输出要简洁”没用,模型对简洁的理解和你差着十万八千里。写成“结论部分不超过三句话,不出现‘综上所述’这类总结词,不重复表格里已经出现过的数字”,约束立刻变得可执行。反例的价值在于它划定了边界,而正例只能说明方向。
第三条,把判定条件写成可枚举的分支。不要写“根据情况判断风险等级”,要写成“满足以下任一条件即为高风险:金额超过阈值、参与方数量大于三、存在跨期调整”。模型不是不能做模糊判断,但模糊判断不可复现,同一个输入两次跑出两个结果,这在生产环境是灾难。
第四条,控制长度。我的经验值是单个 Skill 正文控制在 800 到 1500 字之间。低于 800 字通常讲不清楚流程,高于 1500 字则开始出现“读到后面忘了前面”的现象。超过这个长度就该拆成两个 Skill,或者把细节挪进references/。
注意:不要把示例数据写进 Skill 正文。模型有很强的模仿倾向,你放一段假的报表示例,它很可能把示例里的数字直接搬到输出里。示例数据要么放
assets/里明确标注为模板,要么用占位符。
还有一条算不上规矩但很实用的经验:写完之后自己当模型读一遍。把 Skill 正文单独拿出来,不看任何上下文,读完问自己“我知道该先干什么吗、遇到异常我知道该怎么办吗、我知道什么情况下该停下来问人吗”。三个问题有一个答不上来,就说明这段还得改。这个自检方法比任何评估指标都来得快。
3. 实操过程与核心环节实现
3.1 从零写一个最小可用的 Skill
理论说完了,走一遍完整流程。假设场景是某公司的内部数据核对需求,团队每周要人工比对两份来源不同的汇总表,找出差异项并给出判断。这个活有明确口径、有固定流程、有异常分支,非常适合做成 Skill。
先建目录:
skills/ report-audit/ SKILL.md references/ glossary.md scripts/ diff_check.pySKILL.md的正文我一般按五段来写:角色、输入确认、执行流程、判定规则、输出格式。这个结构是长期迭代出来的,因为它对应了模型干活的完整链路——先知道自己是谁,再确认手上有什么,然后按步骤做,遇到分支按规则判,最后按格式交付。
## 角色 你是数据核对助手,只负责比对与标注差异,不负责解释业务原因, 不负责给出经营建议。任何超出核对范围的请求,明确说明不在职责内。 ## 输入确认 开始前必须确认三件事,缺任何一件都要先问,不要猜: 1. 两份数据的统计周期是否一致 2. 金额单位是否一致(元/万元) 3. 口径是含税还是不含税 ## 执行流程 1. 读取两份数据,先做行数、列名的一致性检查,不一致直接报告并停止 2. 按主键对齐,找出仅存在于单侧的记录,标记为「单边」 3. 对两侧都存在的记录做数值比对,差异绝对值超过 0.01 的标记为「差异」 4. 对差异项按差额绝对值降序排列,取前 20 条 5. 输出结果表 ## 判定规则 - 差异率 = |A - B| / max(|A|, |B|),分母为 0 时差异率记为 1 - 差异率 <= 1% 标记「可接受」,1% ~ 5% 标记「需复核」,> 5% 标记「异常」 - 出现「单边」记录时,无论数量多少,整体结论必须标注「数据不完整」 ## 输出格式 先给一段不超过三句话的结论,再给差异明细表,列为: 主键 | A值 | B值 | 差额 | 差异率 | 标记 表格之后不再追加任何解释性文字。这份正文大约 500 字,覆盖了角色边界、前置确认、四步流程、可枚举的判定阈值和输出格式。你可以注意到它几乎没有“知识”,全是“规矩”。这就是 Skill 该有的样子。
配套的glossary.md放术语定义,比如“单边”“跨期调整”在公司内部的具体含义。正文里在第一次出现这些词的地方加一句“术语含义见 glossary”,模型在遇到不确定时就会去读。
diff_check.py是纯机械的比对脚本,输入两个文件路径,输出 JSON 格式的差异列表。它被注册成 Tool,Skill 正文里直接说“调用 diff_check 完成第 2、3 步”,把确定性计算彻底下沉。
3.2 加载器源码走读:从目录扫描到注册表
Skill 从磁盘到内存要经过解析、校验、预处理、索引四步。解析这一步的核心是切分 frontmatter 和正文,实现不复杂但边界要处理干净。
import re from pathlib import Path import yaml FM_PATTERN = re.compile(r"\A---\s*\n(.*?)\n---\s*\n", re.S) REQUIRED = ("name", "description") class Skill: def __init__(self, meta, body, root): self.meta = meta self.body = body self.root = root self.name = meta["name"] self.priority = int(meta.get("priority", 50)) self.triggers = meta.get("triggers", {}) self.max_tokens = int(meta.get("max_tokens", 2000)) self.semantic_sim = 0.0 self.token_estimate = self._estimate_tokens(body) def _estimate_tokens(self, text): cjk = sum(1 for c in text if "\u4e00" <= c <= "\u9fff") other = len(text) - cjk return int(cjk * 0.9 + other / 3.6) + 64 def load_skill(path: Path) -> Skill: raw = path.read_text(encoding="utf-8") m = FM_PATTERN.match(raw) if not m: raise ValueError(f"{path}: 缺少 frontmatter 分隔符") try: meta = yaml.safe_load(m.group(1)) or {} except yaml.YAMLError as e: raise ValueError(f"{path}: frontmatter 解析失败 {e}") for key in REQUIRED: if key not in meta: raise ValueError(f"{path}: 缺少必填字段 {key}") body = raw[m.end():].strip() if not body: raise ValueError(f"{path}: 正文为空") return Skill(meta, body, path.parent) def scan(root: Path): registry = {} for entry in root.iterdir(): md = entry / "SKILL.md" if not entry.is_dir() or not md.exists(): continue try: skill = load_skill(md) except ValueError as e: print(f"[skip] {e}") continue if skill.name in registry: raise ValueError(f"技能名重复: {skill.name}") registry[skill.name] = skill return registry几个实现细节值得说。FM_PATTERN用了\A而不是^,目的是强制 frontmatter 必须在文件最开头,避免正文里出现的分隔线被误识别成元数据块——这个 bug 我在别的项目里真遇到过,正文中间三横线一出现,整个解析就错位了。_estimate_tokens用中文字符数和其余字符数分别估算,因为中英文的 token 密度差异很大,统一按字符数除以 4 会严重低估中文。校验失败时的策略是跳过并打日志,而不是整体抛异常,这个选择很关键:一个坏文件不应该让整个框架起不来,但要留下痕迹让人能查到。
预处理这一步是在加载后立即做的:把正文按max_tokens做一次预截断(保留头部),把 description 送去向量化,把关键词建一个倒排索引方便快速匹配。这些都在启动阶段完成,热路径上只读不算,这是保证响应速度的关键。
索引同步是个经常被忽略的运维问题。文件改了,向量和倒排索引不会自动更新。可靠的做法是监听文件变更事件重建单个技能的索引,而不是全量重建;简单粗暴的做法是每次启动全量重建,代价是启动慢几秒但绝对一致。我倾向后者,除非技能数量真的到了几十上百个。
3.3 注入环节:预算计算和拼装实现
这里我想把预算计算的过程完整写一遍,因为它是我见过最多人凭感觉拍脑袋的地方。
假设模型上下文窗口是 32k token。要做三步预留:输出预留通常取 4k(要足够模型把长表格和结论写完,留少了会被硬截断);历史消息按最近 8k 估算(一般保留最近若干轮,超过就摘要压缩);工具 schema 如果挂了十几个工具,实测能到 3k 左右。三项加完剩 32 - 4 - 8 - 3 = 17k,但这个数字不能全给 Skill。
原因是注意力衰减。经验值是单次激活的 Skill 总正文控制在 3000 到 5000 token 之内,超过这个量级,模型对规则的遵循度会明显下降,尤其是排在后面的 Skill 会被前面的淹没。我做过一次对比,同样两个技能,一个挂 4000 token 一个挂 8000 token,后者在需要同时遵守两条规则的任务上错得更多。这不是模型上下文不够,是注意力被摊薄了。
所以最终配置大概是全局预算 5000,单个技能上限 1800,这样一次最多激活两到三个强相关技能。剩下的空间留给临时读取的参考资料,那部分是模型主动请求时才加载的,属于按需消耗。
拼装代码大致长这样:
def build_prompt(query, registry, history, tools_schema, budget=5000): picked, used = select_skills(query, registry, budget) parts = [BASE_SYSTEM] if registry: catalog = "\n".join(f"- {s.name}: {s.meta['description']}" for s in registry.values()) parts.append("## 可用技能\n" + catalog) for s in picked: body = s.body if estimate(body) > s.max_tokens: body = truncate_head(body, s.max_tokens) parts.append(f"## 当前激活技能: {s.name}\n{body}") parts.append(render_tools(tools_schema)) parts.append(render_history(history)) parts.append(f"## 当前请求\n{query}") return "\n\n".join(parts), [s.name for s in picked][s.name for s in picked]这个返回值别省,它是后续排查的命门。日志里记下每次请求激活了哪些技能、各自多少 token、多个技能时排序结果是什么,出问题的时候看一眼就能定位是“没匹配上”还是“匹配上了但被预算挤掉”。
3.4 怎么验证 Skill 真的生效了
Skill 写完之后千万别直接用,先做三组对照。第一组是不挂 Skill 的基线,看原始表现,这组能告诉你这个任务到底难在哪。第二组只挂当前这个 Skill,看提升幅度。第三组挂多个 Skill,看有没有相互干扰。每组跑二十个以上样本,别拿三五个样本下结论,方差大得离谱。
观察指标不能只看“对不对”,要拆开看:触发率(该用的场景有没有用上)、遵循率(用了技能之后有没有按流程走)、正确率(最终结果对不对)、稳定性(同一个输入多次跑结果是否一致)。这四个指标经常不同步,我遇到最多的情形是触发率上去了正确率没变,说明技能被激活了但模型没照着做,问题出在正文写法或位置,而不是匹配逻辑。
一个很实用的调试手段是把实际拼装好的 prompt dump 出来,人工读一遍。我打赌你第一次读的时候会发现问题——要么是某个 Skill 被莫名其妙激活了,要么是两个技能的规则在某个点上直接冲突。这种冲突靠看代码看不出来,只能靠看拼装结果。
4. 常见问题与排查技巧实录
4.1 症状对照速查表
下面这张表是我自己整理的问题速查,按症状查比按原因查快得多。
| 症状 | 最可能原因 | 排查动作 | 处理方式 |
|---|---|---|---|
| 该用技能时没激活 | 关键词覆盖不足 / 索引未重建 | 看激活日志里的候选列表 | 补触发词,重建索引 |
| 不该用时被激活 | 短关键词过宽 | 统计误触发 query 的共性 | 加长度阈值,抬 priority 门槛 |
| 激活了但没遵守 | 正文过长 / 位置靠后 | dump 拼装后的 prompt | 拆短正文,前移位置 |
| 多个技能规则打架 | 同域技能边界不清 | 对比两个技能的同名规则 | 合并或加互斥条件 |
| 首轮对,后续轮跑偏 | 规则泄漏到历史 | 检查后续轮次的 prompt | Skill 结尾加收束语 |
| 响应变慢明显 | 索引/向量化在热路径 | 看耗时分布 | 移到启动阶段预计算 |
| 改了文件不生效 | 缓存未失效 | 重启后是否恢复 | 补文件监听或定时重载 |
4.2 几个我踩过的坑
坑一:把 Skill 当知识库用。早期我在 Skill 正文里塞了大量的行业背景、标准条文、术语解释,结果模型读完这些“很有道理”的内容,输出反而更发散——因为它接收到的是一堆陈述性知识,不是操作指令。后来我把所有解释性内容挪进references/,正文只留流程和规则,效果立刻收敛。这个经验我后来又验证过几次,结论很稳定:正文回答问题“怎么做”,参考资料回答“是什么”,两者混在一起会稀释指令密度。
坑二:给 Skill 起了个语义太宽的名字。有个技能叫analysis,本意是做数据异常分析,结果任何带“分析”二字的请求都可能命中,包括让它分析一段文字的情感倾向。名字和 description 的语义边界必须收窄,宁可起得土一点、具体一点,也别贪图通用。改完之后叫metric-anomaly-scan,误触发率直接降下来。
坑三:忘了处理跨技能的数据格式约定。当一个任务连续触发两个技能时,前一个技能的输出会作为后一个技能的输入。如果前一个技能约定的输出格式是 Markdown 表格,后一个技能期望的却是 JSON,中间就会打架。解决办法是在 Skill 里显式声明输入输出契约,比如正文里写“输入:包含key、value两列的表格;输出:JSON 数组,元素含key、delta、flag”。这条写上去之后,跨技能串联的失败率降了一大截。
坑四:预算裁剪把关键技能饿死了。早期排序逻辑里 priority 权重给得太高,导致一个高优先级的巨型技能长期霸占预算,几个小而关键的技能永远排不进去。改法有两个:一是限制单个技能的预算占比不超过 50%,二是排序后做一次“保底”,保证分数最高的那个一定入选。后者更简单也更不容易出错。
坑五:正文里的示例数据被照抄。这个前面提过一次,但它值得单独说,因为太常见了。只要正文里出现形如“例如 2024 年 Q1 的金额是 128.5 万元”,模型就有很高概率在输出里出现 128.5 这个数。彻底的做法是示例里只用占位符,比如{amount}、{period},明确指出这些是待填充的槽位。
4.3 多技能共存时的治理思路
技能数量上去之后,治理成本会指数级上升。我目前的经验是分三层管。第一层是域划分,每个域的技能放一个子目录,元数据里加domain字段,匹配时先做域收缩再在域内排序,这样能大幅减少无关技能的干扰。第二层是互斥声明,明确写出“本技能与 X 技能不应同时激活”,匹配阶段直接做过滤,比指望模型自己调和要靠谱得多。第三层是版本与灰度,技能文件里带版本号,加载时按版本切换,新版本先只对特定条件生效,观察几天再全量。
还有一条容易被忽略的:定期清理。技能是有生命周期的,业务规则变了、工具下线了、某些技能被更大的技能吸收了,这些残留如果不清理,会持续占用清单位置和匹配算力,还会增加误触发概率。我现在的习惯是每个季度过一遍清单,把近三个月零调用的技能归档到skills/_archive/下,加载器跳过下划线开头的目录。这个动作简单,但收益比想象中大。
最后分享一个我在实际使用中的体会。这套 Skills 系统真正难的地方从来不是代码,而是你怎么把一个人脑子里的隐性经验,写成模型能照做的显性规则。我们团队写第一个技能的时候,写完互相一读,发现连“什么算差异”这种最基本的问题,两个人理解都不一样——一个人觉得超过 1 分钱就算,另一个觉得要看具体业务。这种分歧在只有人的时候可以靠沟通临时解决,写成 Skill 就必须一次性说清楚。
所以我现在养成一个习惯:每次写新技能之前,先找实际干这个活的人聊二十分钟,只问三个问题——你第一步做什么、什么情况下会停下来、做完了怎么判断自己没做错。三个问题的答案凑起来,基本就是一个技能正文的骨架,剩下的只是措辞和边界补充。这个过程急不来,但做一次就能沉淀一次,比反复调提示词划算得多。