補寫於 2026 年 8 月 24 日,9 月才上線(8 月 23 日的排程沒跑起來,稿子隔天補上;部落格的發佈額度也在 8 月中用完了)。文中對 OpenHands 的描述以 8 月 24 日的官方文件與 repo 狀態為準,你讀到時可能已經改版。

寫給 agent 的規則,最早只有一種放法:整份塞進系統提示,從第一句話跟到最後一句。你的 CLAUDE.md 就是這樣運作的,OpenHands 那邊的對應物叫 AGENTS.md,檔名不同,行為一樣。它在對話開始前就進場,不管你今天要做的是改一行 CSS 還是追一支 log。

OpenHands 的官方文件對這件事講得毫不遮掩:

Always-on content occupies the conversation context from the beginning. Keep AGENTS.md concise and move lengthy or specialized instructions into on-demand skills and references.

「移到 on-demand」講起來一句話就完了。真正難的是那個 on-demand 的判斷由誰做。OpenHands 現在給了四個答案,其中兩個是主力,而它們壞掉的方式完全不一樣。

先講一件很多舊文章寫錯的事

那個機制以前叫 microagent。現在不叫了。

OpenHands/OpenHands 的原始碼裡有一行註解,講的是 .openhands/microagents/ 這個目錄:

.openhands/microagents/ is the pre-rename skills directory. The SDK still loads skills from it for backward compatibility…

pre-rename skills directory。官方自己的用詞就是「改名前的 skills 目錄」。所以「Claude Code 叫 skill、OpenHands 叫 microagent」這種對照是過期的,兩邊現在都叫 skill。改名發生在哪一天我查不到,素材裡也沒有,所以就不編一個日期給你。

現在的位置是專案的 .agents/skills/ 與使用者的 ~/.agents/skills/.openhands/skills/ 是舊的 OpenHands 位置,.openhands/microagents/ 在 docstring 裡直接被標成 deprecated,但為了向後相容仍然會載入。公開的那份放在 github.com/OpenHands/extensions

順帶一提,這個專案活得很好:GitHub 組織已經從 All-Hands-AI 改成 OpenHands,MIT 授權,最新 release 是 v1.15.0,發布於 2026 年 8 月 21 日,我查證的當天(8 月 24 日)還有新的 push 進去。

型別那邊也換過。網路上 v0.x 時代的文章會告訴你有 repo / knowledge / task 三種,但 v1 的 get_skill_type() 型別標註寫的是 Literal["repo", "knowledge", "agentskills"]。第三格換人了。

舊做法:永遠在場

triggerNone 的 legacy 格式檔案,會被整份塞進系統提示的 <REPO_CONTEXT> 區塊,每一輪都在。這就是 CLAUDE.md 那種角色。

OpenHands 在這裡做了一件我覺得很妙的事:它會直接把你 repo 裡的 .cursorrulesAGENTS.mdAGENT.mdCLAUDE.mdGEMINI.md 通通讀進來當常駐 skill。你不用做任何事,你為別家工具寫的那些規則,它照樣吃。

更好玩的是 CLAUDE.md 有一道 vendor gating:只有在你用 Anthropic 家族的模型時,那份檔案才會進 prompt。換一家模型,同一個 repo,同一份檔案,它就不在了。

這個做法的代價很直白:內容從第零輪就佔位置,而且你為了那 5% 的情境寫的那 500 字,其他 95% 的對話也要一起付。這件事我在 CLAUDE.md 裡寫「請講重點」為什麼常常沒用 那篇算過同一筆帳:常駐的東西便宜的是心安,不是 token。

新做法一:關鍵字說了算

第二種是在 frontmatter 寫 triggers:,一個字串陣列。使用者訊息裡出現那些字,這份 skill 才會被注入。

注入的位置有點反直覺。系統提示那邊不動,它掛在使用者訊息的後綴,包在 <EXTRA_INFO> 區塊裡,內容是整份 markdown 正文,不是摘要也不是片段。渲染那段的 Jinja 樣板寫得非常誠實:

The following information has been included based on a keyword match for ““.
It may or may not be relevant to the user’s request.

它可能跟你的需求有關,也可能無關。這句話不是免責聲明寫太保守,是這個機制的真實狀態:它不知道相不相關,它只知道字串對上了。

那個字串對得比你想的死

關鍵字比對聽起來像是多少會做點模糊匹配。實際上沒有,就是一行 regex:

1
pattern = rf"(?<![a-z0-9]){re.escape(keyword_lower)}(?![a-z0-9])"

大小寫不敏感,兩邊都先 .lower()。整詞比對,而且它刻意不用 \b,改用「前後不可以是英數字」的 lookaround。官方註解舉的例子是 git 不會命中 githubissue 不會命中 tissue,但 /linear 這種斜線開頭的指令仍然打得中。

不用 \b 換來的就是最後那半句。斜線在 \b 的世界裡本身就是邊界,/linear 會被切成 linear,於是你打 linear 也會誤觸發。改成只排除英數字之後,斜線指令才真的能當指令用。

一行 regex 換掉一整類誤觸發。這種取捨我很喜歡,因為它便宜到不像有在解問題。

中文那邊我讀 regex 的推測是可以命中的,因為那個 lookaround 只排除 [a-z0-9],中文字元不在排除範圍內,前後是中文仍然算邊界。這是我讀出來的,官方文件沒提,我也沒實際跑過,你要用中文關鍵字請自己先測一次。

官方對關鍵字數量的建議是 2 到 5 個。

新做法二:讓模型自己敲門

第三種走的是完全不同的邏輯。檔名叫 SKILL.md 的走 AgentSkills 標準格式,它們不會被自動注入,而是列在 <available_skills> 區塊裡,只給名字跟 description。模型看完那份清單,自己決定要不要讀,要讀就呼叫 invoke_skill 這個工具把全文取回來。

to_prompt() 的 docstring 特地解釋了為什麼清單裡不給檔案路徑:

The <location> field is intentionally omitted so the agent cannot bypass the invoke_skill tool by reading the file directly.

它防的是自己家的 agent 抄捷徑。給了路徑,模型就會直接 cat 那個檔案,然後這條路上所有的計量、權限、動態渲染全部繞過去。不給路徑,它就只能走大門。

這條路的成本結構跟關鍵字那條完全不同:初始 prompt 裡只有名字跟一句 description,全文要等模型開口才進來。硬上限只有兩個,description 最多 1024 字元、name 最多 64 字元;至於你可以放幾個 skill,skill.pyutils.pyagent_context.py 這三個檔案裡沒有任何數量或 token 總量的檢查,官方等於沒設上限。官方文件那邊只有軟建議:SKILL.md 理想是 1,500 到 2,000 字,最好不要超過 3,000 字,長的東西放到 references/ 去。

第四種:碰到檔案才出現

還有一種是 paths:,寫 glob。agent 動到符合的檔案時才注入,官方叫它 rules。文件對這種的說法是:

They add zero baseline cost to the context window: nothing is loaded until a matching file is actually touched, and each rule is injected only once per conversation.

這種的觸發條件是最硬的,因為「你有沒有碰到那個檔案」這件事完全不需要判斷,是事實。

這四種不能疊加。優先序是原始碼寫死的 if/elif:paths 最大,再來 inputs,再來 triggers,都沒有就是常駐。paths 一旦出現,同一份檔案裡的 triggersinputs 會被直接丟掉,還會發一個 warning 提醒你有欄位被吃了。註解寫得很明白:

A skill is either path-triggered OR model-invocable, not both: paths: wins and any triggers:/inputs: are ignored.

寫了 paths 的 skill 還會被強制設定成不對模型宣傳,模型硬要呼叫它會拿到一句:Skill '<name>' cannot be invoked directly. It can only be activated by trigger matching.

兩種主力各自會怎麼壞

關鍵字硬觸發的失效模式,是它綁在「你有沒有講那個詞」上,而不是「這件事是不是那種事」上。你的 skill 寫了 triggers: [git],然後你打「幫我把改動推上去」。沒有命中。這份 skill 從頭到尾沒進來,而你不會收到任何訊號,因為沒命中就是什麼事都不發生。反過來也一樣糟:你只是隨口提了一句那個詞,一份三千字的正文就整包掛到你的訊息後面。

模型自選的失效模式在另一個地方。它把整個判斷責任壓在 description 那 1024 字元上。你的 skill 有沒有被叫,取決於模型讀那句話的當下覺得跟眼前的事有沒有關係。這件事有它的好處,語意判斷本來就該交給模型做。壞處是它同樣是靜默的,而且更難修:關鍵字沒命中,你至少可以 grep 一下自己打了什麼字;description 沒說服模型,你只能改文案再試。

再加上一件官方沒說、但我用 Claude Code 寫 skill 的經驗告訴我一定會發生的事:既然數量沒有上限,那份 <available_skills> 清單遲早會長。清單一長,它本身就變成一場注意力競賽——二十個 skill 擺在那裡,模型憑一句話挑一個,選錯的機率跟你寫 description 的功力直接掛勾。這段是我的推論,不是 OpenHands 文件的說法。

我會怎麼選

判準我覺得只有一條:觸發條件本身是機械可判定的,就別讓模型判斷;要靠語意才判得出來的,就別交給字串比對。

碰到 src/**/*.ts 這種檔案要套的規範,用 paths。使用者打 /release 這種明確指令要載的流程,用 triggers。這兩件事都有客觀答案,讓 regex 去比對,快、免費、而且結果可複現。

反過來,「這次的任務算不算在做資料庫遷移」這種問題,regex 永遠答不好,因為使用者可能講「改 schema」、可能講「加一個欄位」、也可能只是貼了一段 SQL 過來。這種就該寫進 description,讓模型自己認。

OpenHands 那條「paths 出現就吃掉 triggers」的規則,其實就是這個判準被人寫進程式碼裡的樣子。它不讓你在同一份檔案上同時要兩種觸發,因為那代表你自己也還沒想清楚這條規則到底該什麼時候出現。

這件事真正改變的

變的是寫規則這件事多了一個欄位。以前你只要想「這條規則要寫什麼」,現在你得多回答一句「它憑什麼在這一輪出現」。這個問題沒辦法含糊帶過,因為四個選項互斥,你總得挑一個。

我沒驗證的部分

這篇所有的行為描述都來自 2026 年 8 月 24 日當天的 repo 原始碼與官方文件 repo(OpenHands/docs),上面引的英文全部是逐字照抄。文件站的網址現況我沒有實際連過,官方 API 回傳的 homepage 是 openhands.dev

有一處文件跟程式碼對不上,你如果照舊教學做會踩到:overview/skills/repo.mdx 的欄位表裡還列著 agent,預設值 CodeActAgent。但 v1 的 loader 完全沒讀這個 key,skill.py 全檔 grep metadata_dict.get 的十三個命中裡沒有它。我推測那是 v0.x 的殘留,寫了不會報錯,只是沒有任何效果。

來源

  • OpenHands/OpenHandssrc/utils/skill-scope.ts 的改名註解、AGENTS.md 的 repo 分工)
  • OpenHands/software-agent-sdkskills/skill.pyskills/trigger.pycontext/agent_context.pytool/builtins/invoke_skill.py、Jinja 樣板 skill_knowledge_info.j2
  • OpenHands/docsoverview/skills.mdxskills/keyword.mdxskills/repo.mdxskills/path.mdxskills/creating.mdx