拓冰建站拓冰建站
首页 / 资讯中心 / 正文

自己动手写Agent Harness【skills rules】:skills 与 rules 机制实现

写在前面系列是为了帮助大家更好的去理解Agent Harness基础设施并不是想重复造轮子真实开发建议选择一个成熟的SDK或Harness框架才是最合适的选择~1. 引言子代理会分工了还不会「积累」上一篇《自己动手实现一个 agent子代理机制》那篇我让 harness 会分工——子代理把大任务拆出去、把上下文隔离。这篇换一个词让 harness 会「积累」。先抛我的总论点skill 管「能干什么」rule 管「不能碰什么」它们在做同一件事——让 harness 不用每次重新教。你的最小 harness 已经会转、会调工具、会划边界、会记会话系列前四篇但每开一次会话模型都不记得你上个月定的 git 提交规范、代码 review 清单、发布检查表。你把它们再讲一遍下次它还是忘。skill 和 rule 就是把你讲过的东西固化下来按需塞回去。2. 先看 Claude Code 怎么做Skills 与 Rules两条轴动手前先看一眼成熟产品把这两个词放在哪。Claude Code 的扩展入口我在《拆三家回答一个问题》那篇里拆过五件套其中有两个是我们这期的主角。它们被两条轴分开加载时机常驻还是按需强制程度建议还是强制skill 是「按需加载的多步流程 / 知识封装」。官方文档的说法skill 是一个目录里面的SKILL.md带 YAML frontmattername、description和正文启动时 Claude Code 只读 frontmatter 知道有哪些技能正文要等 description 命中用户任务才加载进上下文。description 决定「何时被选」——这和我在第二篇《给它装手和眼睛》里建工具注册表时说的完全同构工具靠 description 决定「何时被调」技能靠 description 决定「何时被加载」。rule 是「带路径的约束」。官方文档里 rule 住在.claude/rules/*.md项目级或~/.claude/rules/*.md用户级关键在 frontmatter 的paths字段——glob 匹配只有你读取或编辑的文件落在 paths 里这条 rule 才加载。没有 paths 的 rule 每次启动都加载常驻、费 token带 paths 的 rule 按路径命中才加载官方把这叫「保持常驻上下文小的主要工具」。两个点要记住它们都是触发时才加载skill 按任务触发、rule 按路径触发它们都不是强制层——写进 system 的指令模型可以忽略官方文档对这类约束的定性就是「soft constraints」。不能妥协的底线比如「绝不许动生产库」不能靠 skill 或 rule要靠 hooks——那是下一篇。3. Demo 先行技能注册表 路径规则匹配工程在examples/first-agent/step6-skills-rules/。先给代码再跑给你看。技能注册表。三条技能每一条是name description keywords prompt// 真实工程技能注册表硬编码在 JS 里是教学简化——加一个技能就得改代码。// 真实做法是「目录驱动」每个技能一个目录目录内 frontmattername/description 常驻// 正文 prompt启动时扫描加载。Claude Code 的 .claude/skills/ 就是这个形态。exportconstskills[{name:git-commit,description:生成符合规范的 git 提交信息。当用户提到「提交信息」「commit」「git 提交」时选用。,keywords:[git 提交,commit,提交信息],prompt:GIT_COMMIT_PROMPT,},{name:code-review,description:按清单审查代码。当用户提到「review」「代码审查」「审查这段代码」时选用。,keywords:[review,代码审查,审查],prompt:CODE_REVIEW_PROMPT,},{name:release-check,description:发布上线前检查。当用户提到「发布」「上线」「release」「发版」时选用。,keywords:[发布,上线,release,发版],prompt:RELEASE_CHECK_PROMPT,},]exportclassSkillRegistry{constructor(skills){this.skillsskills}// catalog常驻的「技能目录」。只含 name description绝不带 prompt。// 这就是省上下文的机制——目录小一行一个正文大几段话// 目录常驻、正文懒加载。读者看到这里就明白为什么要拆 name/description 和 prompt。catalog(){returnthis.skills.map(({name,description})({name,description}))}// select用户输入 → 命中一个技能。命中只看 keywords description 的索引化// 不看 prompt 正文。返回整个技能对象含 prompt选中了才需要它。select(userText){// 真实工程keywords 字符串 includes 匹配是教学简化确定性、可解释但不聪明。// 真实做法是让模型读 description 自己判断「何时选」——Claude Code 的技能目录正是// namedescription 常驻模型读到描述决定加载哪个也可用 embedding 相似度做语义检索索引。// 真实工程find() 只取第一个命中没处理「多技能同时命中」的冲突。// 真实做法要定义优先级或让模型裁决命中多个时合并上下文或取最高优先。returnthis.skills.find((s)s.keywords.some((k)userText.includes(k)))??null}// getPrompt按名字取正文。这是「按需加载」的唯一入口——// 不调用它prompt 就永远不进入上下文。getPrompt(name){returnthis.skills.find((s)s.namename)?.prompt??null}}skill 正文长什么样拿 git 提交规范当例子它就是一段真实工程规范几段话单独拎出来exportconstGIT_COMMIT_PROMPT【技能git 提交规范】 用户让你写 git 提交信息时必须按以下格式 1. 第一行\type(scope): 中文摘要\ - type 取 feat / fix / docs / refactor / test 之一 - scope 是改动模块如 cart、hello - 摘要不超过 50 字说「做了什么」不说「怎么做的」 2. 空一行 3. 正文 1-3 行说「为什么改」说清动机 示例 feat(cart): 支持购物车合并 同店商品自动合并为一个订单降低拆单率。路径规则匹配器。两条规则每一条是paths content// 真实工程段级子序列匹配是最朴素的路径匹配只支持「连续整段相等」这一种语义。// 真实做法换 glob / minimatch 这类成熟匹配库原生支持通配符* / **与多路径语义。exportconstrules[{paths:[scripts/],content:SCRIPTS_RULE},{paths:[package.json],content:PACKAGE_JSON_RULE},]exportclassRuleMatcher{constructor(rules){this.rulesrules}// match当前路径 → 命中的规则列表可能为空。Windows 兼容统一转成 / 再比。match(filePath){constsegsfilePath.replaceAll(\\,/).split(/)returnthis.rules.filter((r)r.paths.some((pat){constpatSegspat.replaceAll(\\,/).split(/).filter(Boolean)// 连续段子序列匹配模式段组在路径任意位置整段出现即命中for(leti0;ipatSegs.lengthsegs.length;i){if(patSegs.every((p,j)segs[ij]p))returntrue}returnfalse}),)}}拼装。常驻的基座 技能目录按需的技能正文 命中的规则拼成一次请求的 systemexportfunctionbuildSystem(registry,matcher,userText,currentPath){constparts[BASE_SYSTEM]constcatalogregistry.catalog()constskillregistry.select(userText)constactiveRulesmatcher.match(currentPath)// 1. 技能目录常驻——它是「有哪些能力」的索引永远在上下文里parts.push(【可用技能目录常驻仅 namedescription正文按需加载】\ncatalog.map((s)-${s.name}${s.description}).join(\n),)// 2. 选中技能的正文按需注入——只有这一次请求带它其它请求不带if(skill)parts.push(skill.prompt)// 3. 命中的路径规则约束按需注入for(constrofactiveRules)parts.push(r.content)return{system:parts.join(\n\n——————\n\n),activeSkill:skill,activeRules}}一个设计说明本篇自带一个演示模型SkillMockLLM它不调远程 API只做一件事——读出 system 里被注入了哪条技能正文 / 哪条规则按注入的规范回话。所以输出本身就能证明「注入真的发生了」。之所以不用llm/mock.js那是为 step1-4 的工具场景写死的读文件/列目录/执行命令本篇演示的是「注入技能与规则后模型行为跟着变」需要一个能看见注入内容的模型。跑通它。零依赖自测node step6-skills-rules/test.js输出逐字取自PRACTICE.md$ node step6-skills-rules/test.js ✅ ① 按 description 命中正确技能 ✅ ② 未选中技能不加载其 prompt懒加载 ✅ ③ 路径规则命中注入、不命中不注入 ✅ ④ 技能与规则独立叠加 全部通过skill 管能干什么按需加载rule 管不能碰什么路径命中才注入都不是强制层。四条断言每条盯一件事① 按 descriptionkeywords命中正确技能② 没选中的技能不加载正文——这是「懒加载」成立的关键③ 规则命中才注入、不命中不注入④ 技能与规则能同时命中、独立叠加。完整演示node step6-skills-rules/index.js$ node step6-skills-rules/index.js step6 —— skills 与 rules 机制 skill 管「能干什么」按需加载的多步流程rule 管「不能碰什么」带路径的约束。 两者都是触发时才加载都不是强制层——不能妥协的底线靠 hooks下一篇。 [user] 帮我写个 git 提交信息我改了 hello.js 当前路径src/feature.js · 技能目录常驻仅 namedescription - git-commit生成符合规范的 git 提交信息。当用户提到「提交信息」「commit」「git 提交」时选用。 - code-review按清单审查代码。当用户提到「review」「代码审查」「审查这段代码」时选用。 - release-check发布上线前检查。当用户提到「发布」「上线」「release」「发版」时选用。 · 技能选择按用户输入命中命中「git-commit」→ 注入正文 | 【技能git 提交规范】 | 用户让你写 git 提交信息时必须按以下格式 | 1. 第一行type(scope): 中文摘要 | - type 取 feat / fix / docs / refactor / test 之一 | - scope 是改动模块如 cart、hello | - 摘要不超过 50 字说「做了什么」不说「怎么做的」 | 2. 空一行 | 3. 正文 1-3 行说「为什么改」说清动机 | 示例 | feat(cart): 支持购物车合并 | | 同店商品自动合并为一个订单降低拆单率。 · 路径规则按当前路径匹配无命中 · 以上全部拼进 system交给模型 [assistant] feat(hello): 添加 hello 世界示例 新增 Hello World 输出作为最小可运行示例方便新手跑通。 [user] 准备发布 v1.2.0帮我检查一遍发布清单 当前路径scripts/deploy.js · 技能目录常驻仅 namedescription - git-commit生成符合规范的 git 提交信息。当用户提到「提交信息」「commit」「git 提交」时选用。 - code-review按清单审查代码。当用户提到「review」「代码审查」「审查这段代码」时选用。 - release-check发布上线前检查。当用户提到「发布」「上线」「release」「发版」时选用。 · 技能选择按用户输入命中命中「release-check」→ 注入正文 | 【技能发布检查清单】 | 发布前逐项确认任何一项未勾就明确说「不建议发布」 | - [ ] 版本号已 bumppackage.json 变更日志 | - [ ] 迁移 / 脚本已在小环境验证向下兼容 | - [ ] 回滚方案已写清楚失败时退到哪个版本 | - [ ] 监控告警已配置错误率、延迟、磁盘 · 路径规则按当前路径匹配命中 | 【路径规则scripts/】scripts/ 目录下的脚本 | - 禁止直接对生产环境执行写操作只允许 --dry-run 预览 | - 禁止改动 package-lock.json / yarn.lock锁文件只在主目录维护 · 以上全部拼进 system交给模型 [assistant] 发布检查版本号已 bumpv1.2.0✅迁移脚本已验证 ✅回滚方案已写 ✅监控告警已配 ✅。4 项全勾可以发布。 [user] 这个目录里有什么 当前路径package.json · 技能目录常驻仅 namedescription - git-commit生成符合规范的 git 提交信息。当用户提到「提交信息」「commit」「git 提交」时选用。 - code-review按清单审查代码。当用户提到「review」「代码审查」「审查这段代码」时选用。 - release-check发布上线前检查。当用户提到「发布」「上线」「release」「发版」时选用。 · 技能选择按用户输入命中无命中 · 路径规则按当前路径匹配命中 | 【路径规则package.json】改动 package.json 的 dependencies / devDependencies 时 | - 必须同步更新 README.md 的「安装依赖」一节 | - 不得直接删除他人仍在使用的依赖先确认引用 · 以上全部拼进 system交给模型 [assistant] 收到。当前路径 package.json 命中规则改依赖必须同步更新 README 安装说明。我会在改动里一起带上。三段演示三个证据逐个拆第 1 段src/feature.js技能命中 git-commit路径规则无命中——证明「技能选择只看用户输入与路径无关」。模型回话feat(hello): ...严格按注入的 git 规范走。第 2 段scripts/deploy.js技能 release-check 和路径规则 scripts/ 同时命中、同时注入——证明两个机制独立叠加、互不排斥。模型回话同时带发布清单的「4 项全勾」。第 3 段package.json技能不命中、仅路径规则命中——证明规则是「带路径的约束」即使没有技能、只要文件在规则路径内也会注入。模型明确引用了规则内容。4. 两个核心设计设计一技能注册表 —— namedescription 常驻当索引正文按需加载。这是省上下文的全部秘密拆开就三行逻辑catalog()常驻、select()命中、getPrompt()按需取正文。目录小一行一个正文大几段话目录永远在上下文里正文只在被选中那一次进上下文。description 是这套设计的命门。它决定「何时被选」——我在第二篇《给它装手和眼睛》里说工具靠 description 决定「何时被调」这里同一句话技能靠 description 决定「何时被加载」。我甚至把 description 里的关键词抽出来做成了keywords数组选择器只比 keywords 不比正文——这就是「正文懒加载」能成立的前提选择器永远不碰大块头。懒加载用测试第二条验最直接test.js原文constctxbuildSystem(registry,matcher,帮我写个 git 提交信息,src/feature.js)assert.ok(ctx.system.includes(GIT_COMMIT_PROMPT),选中的 git-commit 正文应注入)assert.ok(!ctx.system.includes(CODE_REVIEW_PROMPT),未选中的 code-review 正文不应注入)assert.ok(!ctx.system.includes(RELEASE_CHECK_PROMPT),未选中的 release-check 正文不应注入)用户输入只命中 git-commitsystem 里就只多了 git-commit 的正文code-review、release-check 的正文一个字都没进。三条技能全塞进去也能跑但上下文会膨胀三份——技能越多这个「只加载选中的」就越值钱。设计二路径规则匹配 —— paths 是开关命中即注入约束。规则和技能的区别在「开关」技能靠用户输入里的关键词触发规则靠当前工作路径触发。match(filePath)是全部逻辑——当前路径落在某条规则的paths里就把它的content注入 system。这里有一个真实踩过的坑值得单独说。我最初想用字符串includes做路径匹配模式/scripts/vs 路径scripts/deploy.js——结果不命中因为相对路径不带前导/。更糟的是反过来includes会把myscripts/x误判成命中scripts。这两个毛病同根同源路径是结构不是字符串。正确的做法是把路径按/切段规则模式也切段模式段组作为连续一段出现在路径任意位置才算命中。这样scripts/命中scripts/deploy.js、不命中myscripts/而且D:/repo/package.json这类带盘符的绝对路径也能命中package.json段。真实系统可以换 glob / minimatch思想不变路径是规则的开关但匹配要按「路径段」不是「字符串子串」。我最后把这套 demo 的简化点挨个摊开讲真实工程会怎么补。select()的 keywords 字符串includes是「技能命中」的教学简化——确定、可解释但不聪明真实工程让模型读 description 自己判断「何时选」Claude Code 的技能目录正是 namedescription 常驻、模型读到描述决定加载哪个嫌贵也可以用 embedding 相似度做语义检索索引。技能来源那头demo 把技能硬编码在 JS 数组里加一个技能就得改代码真实做法是「目录驱动」——每个技能一个目录frontmatter 的 name/description 常驻、正文 prompt 放里面启动时扫描加载.claude/skills/就是这个形态。冲突处理上select()的find()只取第一个命中多技能同时命中时后面的被静默丢下真实工程得定义优先级或让模型裁决命中多个就合并上下文或取最高优先。演示模型那边SkillMockLLM只读 system 证明注入是教学演示设计真实 harness 换llm/real.js那个同签名complete(messages, tools)把装配好的 system 原样发给真实模型——注入的技能正文和规则就真正约束行为。两者都不是强制层。这点我压在最后说因为它最容易误解。skill 注入的规范、rule 注入的约束本质都是写进 system 的文本——模型可以遵循也可以忽略。SkillMockLLM里我是写死「读到注入就照做」但真实模型没有这条保证。我在第三篇《给它划安全边界》里立过「先定安全哲学」这里补一句不能妥协的底线不能写在 skill 或 rule 里。「绝不许动生产库」「写完必须跑测试」这类要求靠注入文本是赌模型自觉要变成确定性执行得靠 hooks——脚本在工具执行前后被 harness 强制调用不由模型自觉。这是下一篇。5. 对照 dsh你的技能注册表是 ctx.skills 的最小投影老规矩手写的东西翻 dsh 源码对照。我上一期《自己动手实现一个 agent子代理机制》说你的 spawn/fork 是 SubagentProvider 的最小投影这篇同样一句你的技能注册表是 dsh 的ctx.skills的最小投影。dsh 有一个独立的 skill 包packages/skill/skill它的注册表叫SkillRegistry挂在ctx.skills上。拆三个点每个都能对上你写的代码你写的step6-skills-rulesdsh 的 ctx.skills对应catalog()只含 namedescription 的目录list()/snapshot()返回的SkillSummaryname description whenToUse不加载正文常驻摘要都是「有哪些能力」的索引getPrompt(name)按名取正文get(name)每次向提供方请求正文正文按需加载不进目录缓存keywords数组description 的索引化SkillSummary.description是「路由描述」都决定「何时被选」dsh 的 README 写得很直白「定义仍采用渐进式加载。get()每次调用都会向胜出提供方请求正文而不是在此注册表中缓存正文。」——这句话翻译过来就是你代码里的catalog()和getPrompt()的分工目录常驻、正文懒加载。dsh 那版比你的多一层技能可以有invocation策略能不能被模型调、能不能被用户调、有提供方抽象技能来自本地文件、URL 还是远端注册表按 scope 分层、重名最近层胜出——这些是完整形态你的极简版只取了「摘要常驻 正文按需」这个核。描述写得好不好是技能系统的命门这点有论文撑着。我在《拆三家回答一个问题》里引过一篇 Skill-Use 研究arXiv 2608.04828拿八个 LLM 在两个 harness 上测技能使用最强的组合也只有 0.613 分——模型「该用技能时用不对」是常态而不是意外。原因往往不在模型在 description写得含糊模型就不知道什么时候该选它。所以你的每一条技能description 都要写成「当用户提到 X 时选用」——把触发场景说清楚而不是复述正文内容。rules 和 dsh 的 profile 层配置是同一件事的两面。dsh 没有一条叫 rule 的机制但它有一个「写一次就一直在」的配置叠加层发行版 bundle → profile 层cordis.patch.yml→ home 级 →--patch后层覆盖前层。规则的本质就是这种持久配置——你把它写进.claude/rules/它就一直躺在那里按路径命中才生效不用每次重新教。区别只在生效维度dsh 的 profile 层按「层」生效发行版/用户/项目Claude Code 的 rules 按「路径」生效。都是把约束从「提示词里的临时叮嘱」搬进「常驻的持久配置」——这正是我开头说的「让 harness 会积累」在配置侧的那一半。6. 结论skill一次学会rule一劳永逸的边界今天这套 harness 会「积累」了。压成一句话skill 管「能干什么」rule 管「不能碰什么」一个让你教一次就会一个让你写一次就一直在——但都不是强制层。技能是「按需加载的多步流程」规则是「带路径的约束」触发时才加载命中才注入。真要说清楚它俩的差别一个问「这次任务要什么流程」一个问「这个文件/目录有什么规矩」。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门