Skill 包深度检查与反向优化
对第三方或历史积累的 Skill 包做体检。目标不是"读一遍提建议",而是量化缺陷、修好、并用真实产出证明没改坏。
铁律
- 先备份。
cp -R <技能目录> <技能目录>.bak-$(date +%Y%m%d%H%M)。改完要能一条命令回退。 - 不删用户文件。废弃脚本原地保留,用
scripts/README.md标注"流程内 / 已废弃"。删除是不可逆的,标注是可逆的。 - 修工具,不要改规则去迁就工具的 bug。审计脚本误判,就修脚本的判定逻辑;不要教写稿人"避开某个字"。
- 同一文件必须串行编辑。并行发多个 Edit 到同一文件会互相覆盖,症状是
import丢了、常量未定义、字段缺失。这是最容易反复踩的坑,一次只改一处。 - 改动要有可解释的数字。改完能说出"以前 A 报 1012、B 报 929,现在都是 1012",而不是"我优化了一下"。
执行步骤
一、勘察
find <技能目录> -type f | sort
wc -l $(find <技能目录> -name "*.py") | sort -n | tail -30
读 SKILL.md + 全部 references/。重点看:字数/比例这类量化阈值写在哪、由哪个脚本执行。
二、脚本可达性(找死代码,也找"重复实现")
逐个脚本问"谁引用它":
for f in scripts/*.py scripts/*.js; do
b=$(basename "$f")
hits=$(grep -rln -- "$b" SKILL.md references scripts tests 2>/dev/null | grep -v "^$f$")
[ -z "$hits" ] && echo "[无人引用] $b" || echo "[被引用] $b <- $hits"
done
注意三点坑:
- 子进程调用写的是
ROOT / "xxx.py"字符串,grep 文件名能覆盖;但from xxx import不含.py,所以反过来查 import 时要搜不带扩展名的名字。只搜一种会误判。 - shell 循环里
grep ... scripts/ *.py遇到没有匹配文件时 zsh 会报no matches found,整个命令替换结果为空 → 误报"无人引用"。要么setopt null_glob,要么分开写。 - 上面这套 grep 只是初筛,不能当结论——「文本匹配」会把注释、docstring、SKILL.md 里的口头提及也算成"有人用",于是「只写在文档里」这个正是要抓的病,反而被豁免了。 这条是实测出来的,不是想出来的:给一条白名单条目做移除注入,两次都没变红——第一次因为
SKILL.md的变更段里写了那个名字,第二次因为测试文件自己的 docstring 里也写了同一个名字。两次都是"提一句就豁免"。
所以查到孤儿定义后,用 AST 复核一遍再下结论:
import ast, pathlib
def top_level(path): # 模块级:函数 + 全大写常量
t = ast.parse(pathlib.Path(path).read_text(encoding="utf-8"))
return [n.name for n in t.body if isinstance(n, ast.FunctionDef)] + [
tg.id for n in t.body if isinstance(n, ast.Assign)
for tg in n.targets if isinstance(tg, ast.Name) and tg.id.isupper()]
def loaded(paths): # 真实读取过的标识符
used = set()
for p in paths:
t = ast.parse(pathlib.Path(p).read_text(encoding="utf-8", errors="replace"))
for n in ast.walk(t):
if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Load): used.add(n.id)
elif isinstance(n, ast.Attribute): used.add(n.attr)
elif isinstance(n, ast.alias): used.add(n.asname or n.name.split(".")[-1])
return used
Attribute 与 alias 两处都要收:前者管 模块.名字,后者管 from m import x——漏掉 alias 会把"只被 import、由别的模块调用"的公开接口误报成死代码。
别把 vendored 第三方子目录(带 LICENSE/VERSION 的)算进扫描范围:那里面本来就有我们既不该改、也不该背锅的死代码(实测某个 vendored 包里有函数定义后零调用)。它与本包的契约另用一条测试钉住(见第六节"字段世代漂移"与下一条)。
这一步最好直接落成回归测试,而不是审一次就算完——因为"新写一条规则却忘了接消费者"是每次改动都可能重新发生的病,不是一次性的历史遗留。本项目实测:前两轮审计各查出一处(字数上限、占比阈值),说明它会复发。测试判据同上(只看 AST、不扫 vendored、白名单每条写明理由),再加一条"白名单过期即报"——过期的白名单比没有白名单更糟,它会把一条真死代码静默豁免掉。
再查重复实现:
grep -rn "^def " scripts/*.py | sed 's/.*def \([a-z_]*\).*/\1/' | sort | uniq -d
同名函数出现在多个文件里,且实现有细微差别,就是隐患:两个脚本会对同一份产出给出不同结论。
三、静态检查
python -m pyflakes scripts/*.py tests/*.py
未使用局部变量可能是漏功能(算了没用的变量),也可能是 vestigial 遗留。要打开上下文看,别机械删。
四、跑自带测试
python -m unittest discover -s tests -t .(若 ImportError: Start directory is not importable,补一个空的 tests/__init__.py,或先 python tests/test_x.py 直接跑)。
测试失败就是实锤缺陷,优先修。常见根因:硬门禁加了新条件,fixture 没跟着更新。
五、用真实产出做回归基准(这一步价值最高)
不要凭空造测试数据。收集用户真实跑过的产出(历史工作目录、已发布内容),固定成基准集,然后:
- 对同一份产出,用每个相关脚本各跑一遍,对比它们报的数字。口径不一致会立刻暴露。
- 把结果存成基线 JSON,改动后逐项对比,差异必须能解释。
- 反向校准:造一批"必须被拦"和"必须放行"的样例,两侧都要测。只测一侧等于没测。
判定标准:
- "必须放行"的样例(尤其是用户已经交付/发布过的内容)误伤必须为 0。这是硬要求,不能"大概没问题"。
- 组合规则要单独测边界:某个词单独出现该放行、与后果词同时出现才拦。把语义上合理的词放进硬拦截名单,会误杀正经内容。
再找一组对照组。 只拿自己的历史产出当基准,只能发现"前后不一致",发现不了"整体偏离"。要判断某个阈值定得对不对,得找同类的高质量真人成品当参照——同赛道的爆款原文、用户认可的范文、其他工具产出的公认好稿。步骤:
- 逐一量同一个指标(句长分布、连接词密度、段落长度、用词层级…),做成"真人 vs 本包产出"对照表。
- 差值本身就是结论。典型形态是"两端都差"或"缺一头":某包曾出现过真人的长句占 31%、自身只有 8%,短句数量却持平——问题因此被精确定位成"缺长句",而不是笼统的"句子太短"。
- 阈值优先取对照组实测的极值(如真人最低值),这样每个数字都能追溯到证据,而不是拍脑袋。对照组只有 5 篇也能用,但要说明样本量。
- 对照组同时用来验证"守卫线是不是假安全":自家 0 命中的规则,如果真人也 0 命中,说明是文体特性(记进文档即可);如果真人常常命中,说明是自家产出跑偏了,那才是真发现。
新判据上马前,先做「区分度检验」——一个全场恒定的指标不能用来区分高低。 想到一条听起来合理的量化门禁时,先别写进脚本:拿历史产出把它算一遍,按表现分成高、低两组比一比。三种结果对应三种处置:①两组没差别 → 无区分度,别加;②方向反了(低表现组反而更高)→ 更别加,加了等于惩罚对的做法;③只有极少数样本命中 → 样本不足,记成待观察、别当规则。实测四例全否:开头钩子词(三组均值 0.23/0.22/0.19,无区分度)、开头含数字/引语/书名(低表现组 60% vs 高表现组 40%,方向是反的)、开头空泛词(90 字内只 2 篇命中)、段长上限(中位 83 字、p95 137,本来就达标)。根因最值得记住:该包早就要求"开头先写具体人物动作场景",全部历史产出都执行到位——一个全场恒定的特征,在数学上就不可能用来区分高低。所以想加门禁时先问一句:"这个特征在我的产出里是不是已经全都满足(或全都不满足)?"
改动前先把漏检复现成存档。 要修的是「该拦没拦住」的规则时,先用现脚本把漏检跑出来,把原始判定值原样记下来(level、writable、以及"正文取到多少字"——最后这个用来排除"其实根本没读到正文"这个假因)。存档有两个用处:一是证明问题真实存在,而不是读代码猜出来的;二是改动后能逐条对照。典型证据长这样:某来源判定 level: ok / writable: true / body_characters: 1362——正文全在、词表却一个词都没命中,问题就被定位成"词表覆盖不足"而不是"没读到内容"。
「多拦了几条」必须逐条归因。 只报"误伤为 0"是不够的——那只说明老的正常内容没被碰,说明不了新规则的打击面。做法是拿当时的产物文件(上一次跑出来的检查结果 JSON)当对照基准:新脚本多拦下的每一条,都要落到"它以前是什么判定"。实测例子:36 份历史来源里多拦 1 条(原来 ok)、另外 4 条未变(原来就已是 block)——这才叫"没扩散打击面"。反过来,如果多拦的那几条里有一条原来也是 ok、且属于正经选题,就必须回头收窄规则,而不是把那一条当"本来就该拦"。
分级规则(比密度阈值更细的一档):当一个类别没法按"密度"判、又不能用单词硬拦时,用「主体词 × 后果词」组合,并把后果词分两级——一级同现即拦;二级再叠加一类限定词(群体量词、被动句式、事件词)才拦。分级的价值全在二级上:它专门用来放掉"看起来像事件、其实是日常话题"的写法。设计时要注意三件事:①两级词表不许有重叠词(重叠会让分级形同虚设,写个测试守住);②主体词表要刻意排除泛化词,只收带明确身份/年龄信息的词——泛化词会被比喻和书名带出来("绑架孩子的人生""少年感"都是实测过的误判);③群体事件用带量词的短语承接(如「多名学生」),带上量词以后语义只剩事件一种。
六、找"静默失效"的规则
最隐蔽的一类 bug:规则写在那里,但永远不会触发。
- 先列「文档承诺」,再去找它的执行者——这一条放在最前面,因为它能发现读代码发现不了的那一类。读代码只能看出"写了但触发不了";而"规则只在文档里、根本没有任何脚本承担它",代码里一点痕迹都没有,只能反向查。做法:把
SKILL.md与各references/*.md里写死的硬约束逐条摘出来(尤其抓「禁止」「必须」「一律」「不得」「不能」开头的句子),每一条追问"它的执行者是哪个函数、在哪一行报出来"。答不上来的就是缺口。本轮实测:一条「一篇文章禁止出现第二本书」的规则在两处文档里各自写着,四个检查脚本没有一处读它——而这个缺口的姊妹条款(叙述者地名泛化)早就有人兜底了,说明它是补漏时的遗漏,不是有意的设计取舍。有姊妹条款的规则最容易漏一半:补的时候记得对称着查。 - 词表里的关键词带全角字符,而被检查的文本已经被归一化成半角 → 匹配永远失败。
- 正则要求长度 ≥N,而合法输入恰好都短于 N。
- 组合规则需要 A 和 B 同时出现,但 A 的判定分支永远先返回。
- 假值陷阱(Python 里最常见):
if score and score < limit这样的写法,会让score = 0/0.0/""被判成"不成立"而通过。症状是"最极端的情况反而放行"——例如句长变异系数为 0.0(每句一模一样)反而过了节奏检查。一律改成is not None或显式len(x) > 0。查法:把所有阈值判定里的if 变量:过一遍,逐个问"这个量取 0 时应该拦还是放"。 - 检查器覆盖了不允许修改的内容:被检查文本里有一部分是高优先级规则明令逐字保留的(原标题、引用的原文、固定话术、用户指定不得改动的字段)。检查器照样对它报警,就会源源不断产出"改不掉的报警";更糟的是噪音会把同一条里混着的真问题一起埋掉。修法是让检查器默认屏蔽这一段(留开关便于单独审),不是去改规则允许改标题。查法:拿真实产出跑检查器,看报警的行号有没有集中在标题行、引用块这类位置;一条报警里若同时含"标题行 + 正文行"多个位置,屏蔽后再跑一次,真问题通常会自己露出来。
- 门禁撞上改不动的输入 = 死锁(比误报严重一档)。上一条讲的是噪音,这一条讲的是流程物理上走不下去:判定落在写稿人改不了的输入上(逐字保留的标题、用户指定的固定字段、平台返回的原始数据),一旦命中就既交付不了也修不了。判据是问一句"这条红线命中后,执行者有没有动作能让它消失?"没有,就是死锁。修法不是放宽判定,而是把判定挪到还能改的那一步(写稿前判来源能不能选、排版前判可改的正文),原处只留提示。构造标题类输入正反各测一遍:旧口径几个中招、新口径几个放行、正文里的同类词是否照旧拦。
-
字段世代漂移:同一个数据源的字段名被改过,多个消费者各只认一代。这是静默失效里最贵的一种,因为两个消费者会同时空转、而且各自只对一半历史数据空转——一份退化"只剩标题可判",另一份退化成"无原文可比",两边都不报错,报告照样输出
"valid": true。查法(可量化,先做这个):
bash # 把所有历史输入按"它实际有哪个字段"分组,数一遍 for f in $(find <产出目录> -name "*.json"); do python -c "import json,sys;d=json.load(open('$f'));print([k for k in ('字段A','字段B') if d.get(k)])" done | sort | uniq -c
一旦某个字段只覆盖一部分文件,就去 grep 谁读它、谁读另一个,看有没有消费者读到了空值还继续往下走。修法是建一个取值模块(source_body.py那类:按优先级逐个字段取、取不到返回空串并在报告里写明"没取到"),所有消费者都调它,并在文档里禁掉"自己写payload['某字段']"。 -
跨包读字段的契约漂移:门禁读别人的输出,键名一漂移读到的就是
None,而类型判断会把它判成"没超标"从而放行。 与上一条的区别:上一条是同一个数据源的字段被消费者各认一代,这一条是门禁依赖另一个模块的输出格式,而那个模块会被整体替换(vendored 子包升级、上游换实现)。症状是"门禁静默死亡,而两个行为测试仍然全绿"——因为你测的是"偏了会报/不偏不报",改完之后它对任何输入都不报了,只有"会报"那一半变红、"不报"那一半照样绿,于是测试全绿。查法:把该门禁读的每一个键都列出来,写一条只问"键在不在、类型对不对"的契约测试;再改一次键名做注入,确认它真会红。实测一例:门禁读prose["rhythm"]["long_ratio"],而那个返回结构由 vendored 包生成——注入改成long_ratio_pct后isinstance(None, (int, float))为假、门禁静默放行;补上契约测试后,报错直指"会读到 None 并静默不报"。凡跨包读字段,都要有一条测契约的测试,而不是只测行为。 同一个包的其它消费者读的failures/red_lines/warnings同属这一类,一起钉住。
验证方法:为每条规则造一个必然命中的输入,跑一遍看它是否真的报出来。报不出来就是失效。
新增交叉检查时,先别怀疑它误伤——老夹具里可能一直藏着矛盾。 一个用了很久、一直绿的测试夹具,可能内部本来就不自洽(实测:三篇正文共用一个模板、书名都写同一本,却声明三本不同的中心书)。这种矛盾平时不显形,一旦加了新的交叉检查就立刻暴露。查法:看失败是不是落在"两个字段互相矛盾"上,是的话修夹具而不是放宽检查——夹具帮你发现了真问题。
别用「出现次数/频率」去判一条语义规则。 曾考虑把「中心书必须贯穿全文」做成门禁,做法是数书名号出现次数;实测真实成稿里中心书用书名号往往只出现 1 次(多数时候写"这本书"),次数与是否贯穿毫无关系。数出来的次数会给出错误的确定性,比不查更糟。 语义判断要么上一套真语义检查,要么明确留给人眼,不要用代理指标冒充。
七、跨宿主与跨入口兼容
一个包往往不只被"自己"运行。查它有没有第二个宿主(本地网页、浏览器插件、别的 agent、CLI 包装),有的话加载方式可能不一样,而这个差异在包内测不出来:
- 常见坑:宿主用
importlib.util.spec_from_file_location按文件路径直接执行某个脚本,刻意不把 scripts 目录放进sys.path(因为那目录里几十个模块名会撞上宿主自己的模块)。这种宿主里,脚本内新加的普通import(from source_body import x)会直接ModuleNotFoundError——而在包内跑什么都正常。 - 先查宿主怎么加载:
grep -rn "spec_from_file_location\|sys.path\|importlib" <宿主目录>;再查包内有没有"按路径加载同目录模块"的现成写法可复用。 - 修法是按文件路径加载(
_load_sibling:spec_from_file_location(f"pkg_{name}", Path(__file__).parent/f"{name}.py")),加载后不往sys.path里塞东西。最省事的替代方案是让被依赖的模块只用标准库。 - 守门必须写在干净子进程里:在测试进程内改过
sys.path就测不出真问题。用例做三件事——①临时决定sys.path里不含 scripts 目录时能加载;②能正常工作;③加载后sys.path没被污染。 - 同理,任何"改脚本顺手加一个 import"的动作,都要在宿主里也过一遍。只验包内 = 只验了一半。
八、修复顺序
- 建立唯一口径的公共模块(如
text_metrics.py),让所有脚本从它取值。 - 修判定逻辑本身,而不是调整文档去适应 bug。
- 新增缺失的检查,并接成硬门禁(在构建/交付前执行,不过就中止且不产出交付物)。
- 统一重复实现,选语义更完整的那一版。
- 对齐版本号(
SKILL.md顶部 vsruntime-version.json之类的元数据文件)。 - 合并
SKILL.md里互相重复的规则段,补入新增检查的条目。
九、锁住修复
为新修的逻辑补回归测试,每条测试对应一个真实踩过的坑。测试名写清"什么不能被算成什么"。
十、回归验证(缺一不可)
- 自带测试全绿
- 静态检查干净
- 基线集全部通过且误伤为 0
- 完整流程端到端跑一遍,成功
- 负向测试:注入一条必然违规的内容,确认流程中止且交付物为 0 个
- 守门注入矩阵(不只测工作流,还要测"守门测试自身"):把本轮每一处新加的守门判据列成一张表,逐条注入"它本该拦住的破坏",跑对应那条测试,要求全部如期变红。输出一行一条(✓ 如期变红/✗ 未变红),末尾报 N/M。价值在于它会当场抓住"看着在守、其实早就无效"的那种守门——实测某次 10 条注入只红 9 条,查下去才发现那条判据用的是文本匹配、文档里提一句就放过了(见第二节第三条坑)。没有被注入验证过的守门测试不算守门;而"注入"这个动作本身也应该有测试守着,否则哪天有人把守门改成空转也没人知道。
- 重构必须证明产物不变(改了"统一重复实现""拆公共模块"这类不动逻辑的改动时):用审计前的备份快照与新版脚本对同一份输入各跑一次完整构建,然后
bash diff -r out_old/ out_new/ # 期望:退出码 0、0 行差异 md5 -q out_old/主产物 ; md5 -q out_new/主产物
两边 md5 一致才叫"没动产物"。口头说"逻辑等价"不算验证——这次统一了 5 个函数的 2–3 套实现,靠这一步才发现其中一处第一人称判定其实不等价(if "我" not in xvs 统一口径)。 - 报告口径位移:任何"以前能过、现在过不了"的案例都要列出来,说明是内容问题还是口径位移,不要擅自放宽阈值
十一、收尾
- 更新技能内的
scripts/README.md,标明流程内 / 已废弃脚本 - 在技能目录内写清"改脚本前请注意"的口径约定
- 改了 vendored 的第三方子目录(带 LICENSE / VERSION 的那种),要把它标成本地修改。写清改了哪几个文件、改了哪几处、为什么,以及"以后整体升级上游时必须重做这几处"。否则下次升级会安静地把修复全部冲掉,问题重新出现而没人知道原因。
- 不要动用户的其他资产。发现另一处存在同类问题(比如别的应用里有同一份词库的拷贝),在报告里点出来并说明分叉风险,让用户决定是否一起改
报告写法
用"问题 → 实测数字 → 修法 → 验证结果"的结构。用户要看的是证据,不是形容词。至少给出:
- 修复前后同一指标的具体数值对比
- 真实产出回归的误伤数(必须是 0)
- 反向测试的拦截情况
- 明确列出遗留问题和口径位移,不藏
- 副作用与越界披露:审计过程中若重跑过用户的历史派生报告(如把旧目录里的检查结果 JSON 重新生成一遍)、改动过任何非本包文件,必须逐条写明"哪个文件、什么时候、由 ok 变 block 之类"并说明为什么这么做。审完才发现自己动过用户资产是最糟的收尾。
- 本轮要是有"按密度判"的新规则,把上下边界都写进报告:单用一个喻体词(多次)放行、出现真正的事件词才拦。这类规则最容易被下一个人误解成"阈值可以随便调"。
- 本轮要是有"组合分级判"的新规则,把每一级都写进报告:一级后果与主体词同现即拦、二级后果还要叠加哪类词(群体量词/被动句式/事件词)才拦。同时列明被刻意排除的常用词与理由——例如不收「孩子」「学生」「少年」,因为它们会被比喻和书名带出来("绑架孩子的人生""少年感"都是实测过的误判)。这类规则会被下一个人误解成"多收几个词更保险",而后者正是误杀整片赛道的起点。
如果用户要求"两轮",建议这样分:第一轮 = 勘察 + 量化 + 修复(把问题找全、改完、锁测试);第二轮 = 专门做反向验证与专项——静默失效/假值陷阱逐条造必然命中的输入、端到端 A/B、负向注入、跨宿主加载。第二轮不要重复第一轮的方法,它的价值在于用另一套手段去证伪第一轮的自信。
反面清单
- ❌ 只读代码提建议,不量化
- ❌ 改文档去迁就脚本 bug("注意不要出现某某字")
- ❌ 阈值只拿自家历史产出当基准,不找真人对照组(那样只能发现前后不一致,发现不了整体跑偏)
- ❌ 并行编辑同一文件
- ❌ 删除用户的废弃脚本
- ❌ 为了让旧内容通过而放宽阈值
- ❌ 只在"该拦的"样例上测,不测"该放的"
- ❌ 统一重复实现后不做产物对比,只凭"逻辑等价"就宣布改完
- ❌ 只在自己包内验证脚本能跑(包外的宿主可能用完全不同的加载方式,
sys.path里都没有这个目录) - ❌ 用 grep/文本匹配的"有人提到过"当作"有人在使用"(注释、docstring、文档里提一句就豁免了——而那恰恰是要抓的病)
- ❌ 新加一条量化门禁之前不做区分度检验(全场恒定的特征在数学上区分不了高低;方向反了的判据更会惩罚对的做法)
- ❌ 门禁跨包读字段,却只测行为不测契约(上游一改键名,门禁静默死亡而行为测试照样全绿)
- ❌ 查完死代码就收工,不把它落成回归("新写规则忘接消费者"是每次改动都可能复发的病,不是一次性遗留)