OpenHands 把「規則什麼時候進 context」拆成四種觸發,兩種主力各有各的壞法
補寫於 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.mdconcise 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"]。第三格換人了。
舊做法:永遠在場
trigger 是 None 的 legacy 格式檔案,會被整份塞進系統提示的 <REPO_CONTEXT> 區塊,每一輪都在。這就是 CLAUDE.md 那種角色。
OpenHands 在這裡做了一件我覺得很妙的事:它會直接把你 repo 裡的 .cursorrules、AGENTS.md、AGENT.md、CLAUDE.md、GEMINI.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 不會命中 github、issue 不會命中 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 theinvoke_skilltool by reading the file directly.
它防的是自己家的 agent 抄捷徑。給了路徑,模型就會直接 cat 那個檔案,然後這條路上所有的計量、權限、動態渲染全部繞過去。不給路徑,它就只能走大門。
這條路的成本結構跟關鍵字那條完全不同:初始 prompt 裡只有名字跟一句 description,全文要等模型開口才進來。硬上限只有兩個,description 最多 1024 字元、name 最多 64 字元;至於你可以放幾個 skill,skill.py、utils.py、agent_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 一旦出現,同一份檔案裡的 triggers 跟 inputs 會被直接丟掉,還會發一個 warning 提醒你有欄位被吃了。註解寫得很明白:
A skill is either path-triggered OR model-invocable, not both:
paths:wins and anytriggers:/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/OpenHands(
src/utils/skill-scope.ts的改名註解、AGENTS.md的 repo 分工) - OpenHands/software-agent-sdk(
skills/skill.py、skills/trigger.py、context/agent_context.py、tool/builtins/invoke_skill.py、Jinja 樣板skill_knowledge_info.j2) - OpenHands/docs(
overview/skills.mdx、skills/keyword.mdx、skills/repo.mdx、skills/path.mdx、skills/creating.mdx)










