从三套各自为政的联网脚本,到一份 SKILL.md 同时喂给 Codex、Claude Code、Hermes,这条路我走了大概三周。期间最大的感受不是"写工具难",而是"让三个 Agent 用同一个工具"这件事的坑,比工具本身多得多。这篇把设计思路和踩坑记录都摊开讲,适合正在折腾 Agent Skill 化、想统一多端工具链的开发者参考。
1. 痛点复盘:三家 Agent 的联网能力为什么不能直接复用
先交代一下我自己的使用场景。我平时大量依赖 Agent 做信息检索和资料整理,流程通常是:搜索关键词 -> 打开几个候选页面 -> 抓正文 -> 清洗成 Markdown -> 再交给大模型总结。这个链路听起来很基础,但拆到 Agent 层面就会发现问题:Codex、Claude Code、Hermes 三家的联网能力完全不是一回事。
Codex 的搜索能力偏"云侧",它把 web search 当成模型能力的一部分,返回结果受控,你很难从 Codex CLI 侧直接拿到原始抓取内容,更别说自定义抓取规则。Claude Code 可以写插件和自定义工具,也支持加载 SKILL.md,但官方推荐的做法更偏向独立子代理 + MCP Server,配置链略重,而且每换一个项目就要重新检查配置。Hermes 这边轻快很多,但搜索弱、抓取能力基本等于裸写 requests,稍微复杂一点的页面就抓不干净。
最开始我的方案是维护三套脚本:一套给 Codex 用,一套给 Claude Code 用,还有一套是 Hermes 专用的 Shell 脚本。结果就是规则快速发散,同一个搜索接口,三个脚本里参数风格不一样;同一个页面清洗逻辑,一个用 BeautifulSoup 写,一个用正则硬抠,另一个干脆抓原文。维护成本高不说,最难受的是每次改抓取逻辑要改三份,改完还得分别测试,时间全耗在"对齐行为"上了。
于是我开始认真考虑把三套收敛成一套,用 SKILL.md 作为统一入口。这个思路的核心是:Agent 与外部脚本之间只隔一层"技能描述",只要三个运行时都能理解这层描述,底层脚本完全可以共享。换句话说,搜索和抓取逻辑只写一遍,SKILL.md 负责告诉不同的 Agent"这个工具怎么用、什么时候用、用什么姿势调用"。
但这里有个很现实的问题:SKILL.md 不是严格标准。OpenAI、Anthropic、社区各家对它的解析规则存在差异,想要一行代码不改跑通三家,几乎不可能。真正的做法是先找出三家解析器的公约数,再围绕公约数设计格式。这部分见下一节。
2. SKILL.md 跨运行时兼容的设计基础:三种解析器的差异与公约数
2.1 三个运行时加载 SKILL.md 的方式差异
先说 Claude Code,它对 SKILL.md 的支持相对成熟。官方约定是把技能放在~/.claude/skills/<skill-name>/SKILL.md或项目.claude/skills/<skill-name>/SKILL.md,文件头部用 YAML frontmatter 写name和description,正文写使用说明。Claude Code 的加载逻辑会解析 frontmatter,根据 description 判断什么时候该激活这个技能,然后把整个 SKILL.md 正文塞进上下文。
Codex 的场景不太一样。Codex CLI 本身更强调 AGENTS.md 这类项目级指令,对 SKILL.md 没有像 Claude Code 那样严格的目录约定。社区里比较常见的做法是把 SKILL.md 当作一种"可引用手册",在 AGENTS.md 里用一两句话告诉 Codex:遇到搜索或抓取任务时去读skills/search-scrape/SKILL.md。所以 Codex 侧真正生效的是正文里的命令描述和步骤,frontmatter 对它来说更多是装饰。
Hermes 这类轻量运行时更随意。有的版本只扫描文件名,有的版本只看正文里有没有## 使用步骤这样的固定标题,frontmatter 解析常常被跳过。这对我们的设计反而是个好消息:它意味着我们不能依赖 frontmatter 里的私有字段传参,所有关键信息必须能在纯文本正文里表达清楚。
我把三个运行时对 SKILL.md 的解析差异整理成一个表,后面所有设计决策都围绕这张表展开:
| 运行时 | frontmatter 解析 | 正文结构要求 | 命令执行方式 | 环境变量传递 |
|---|---|---|---|---|
| Claude Code | 严格解析 name/description | 相对宽松 | 通过工具调用脚本 | 支持,但需显式声明 |
| Codex CLI | 基本忽略 | 需要清晰步骤 | 从正文代码块抽取命令 | 继承 shell 环境 |
| Hermes | 部分版本跳过 | 需要## 使用步骤标题 | 从代码块读取并执行 | 继承 shell 环境 |
2.2 公约数设计:四个必须遵守的规则
基于这张差异表,我给自己定了四条"公约数"规则,一条都不让步。
第一,frontmatter 只写name和description。任何扩展字段,比如allowed-tools、model-hints、x-skill-version,有的解析器不认识就直接忽略,有的甚至可能因为 YAML 解析失败导致整个技能加载不出来。你辛辛苦苦加的"增强字段",反而成为兼容性炸弹。
第二,正文用固定的## 使用步骤作为第一层小标题,步骤用有序列表写清楚。不要用###里再嵌套####这种层级,因为 Hermes 这类解析器只会扫描二级标题,你把关键步骤埋在四级标题里它根本看不见。
第三,所有调用命令统一写成 bash 代码块,命令里只依赖python3和标准库、或者极少数 pip 包。不要让 Agent 去猜用什么解释器,更不要让 SKILL.md 里出现uv run、bunx这种需要额外运行时前置的命令。三台机器上不一定都装了 uv,但基本都有 python3。
第四,脚本与 Agent 的交互必须严格遵循"stdout 出结果、stderr 出诊断、退出码表状态"这个 Unix 惯例。很多 Agent 在调用外部工具时不会去读 stdout 以外的内容,如果你把错误信息打到 stdout,结果就是 Agent 把一堆堆栈当成正常结果拿去用,越错越离谱。
后来我实际写 SKILL.md 的时候,这个"公约数"原则帮我省掉了大量调试时间。任何一眼看起来"某个运行时特有"的写法,我都直接放弃。宁可少一点花哨能力,也要保证三个工具都能稳定触发同一个脚本。
3. 搜索+抓取工具的能力拆解与实现:从 query 到 markdown 的完整链路
3.1 目录结构:一个 skill 就是一个自包含小项目
SKILL.md 不是孤零零一个文件,它通常伴随一个资源目录。我最终落地的目录结构是这样的:
skills/search-scrape/ ├── SKILL.md ├── scripts/ │ ├── search.py │ ├── scrape.py │ └── clean.py ├── cache/ │ └── (运行时自动生成) └── config/ └── engines.yaml目录设计的原则是"自包含 + 可复制"。自包含指的是克隆这个目录到任何一台机器上,只要装了requirements.txt里那几个依赖就能跑;可复制指的是它不绑定任何 Agent 的私有目录。cache/是运行时自动生成的,刻意保留在 skill 目录内,方便跨会话复用搜索结果;config/engines.yaml放搜索引擎配置,这样改配置不用改代码。
3.2 search.py:多引擎搜索与结果归一化
search.py的核心功能是接收一个 query,返回结构化的搜索结果列表。我没有用传统 requests + 解析 HTML 的方式去做搜索,因为各家搜索引擎反爬策略不同,写起来又臭又长。最终方案是封装了几个可插拔的搜索源,通过--engine参数切换。
来看关键代码片段:
# scripts/search.py import argparse import asyncio import json import sys import httpx async def search_duckduckgo(client, query, limit): # 使用 DuckDuckGo 的即时答案接口,免费且无需 key url = "https://api.duckduckgo.com/" params = {"q": query, "format": "json", "no_html": 1} resp = await client.get(url, params=params) resp.raise_for_status() data = resp.json() results = [] for topic in data.get("RelatedTopics", []): if "Topics" in topic: for sub in topic["Topics"]: results.append({ "title": sub.get("Text", ""), "url": sub.get("FirstURL", ""), "snippet": sub.get("Text", ""), }) else: results.append({ "title": topic.get("Text", ""), "url": topic.get("FirstURL", ""), "snippet": topic.get("Text", ""), }) return results[:limit] async def main(): parser = argparse.ArgumentParser() parser.add_argument("query") parser.add_argument("--engine", default="duckduckgo") parser.add_argument("--limit", type=int, default=6) parser.add_argument("--format", choices=["json", "text"], default="json") args = parser.parse_args() async with httpx.AsyncClient(timeout=15) as client: if args.engine == "duckduckgo": items = await search_duckduckgo(client, args.query, args.limit) else: # 其他引擎类似... items = [] if args.format == "json": print(json.dumps(items, ensure_ascii=False, indent=2)) else: for it in items: print(f"- {it['title']}\n {it['url']}\n {it['snippet']}") if __name__ == "__main__": try: asyncio.run(main()) except Exception as e: print(str(e), file=sys.stderr) sys.exit(1)这段代码的思路是:一次请求,把结果转成统一 JSON 结构,输出到 stdout。Agent 拿到 JSON 后可以直接遍历 URL 列表,调用下一步的抓取脚本。注意我在捕获异常时把错误打到了 stderr,exit code 置为 1,这是前面公约数规则里要求的。
3.3 scrape.py 与 clean.py:抓取、清洗、转 Markdown 三步走
抓取脚本比搜索更麻烦。搜索接口至少是标准 JSON,页面 HTML 则是千奇百怪。scrape.py只做一件事:拿到 URL,把 HTML 抓下来,再转成干净的 Markdown 文本。
我用了 httpx 抓页面,BeautifulSoup 解析 DOM,然后用一个自定义规则提取 main 区域。不要依赖某一个阅读模式的库去做全部事情,因为那些库对中文网站、论坛页面、代码文档的适配差异很大。我自己维护了一套简单的优先级提取规则:
- 如果有
<article>标签,直接提取 article 内部文本; - 否则找
class或id包含content、main、post的元素; - 都没有就回退到
<body>,但会把<script>、<style>、<nav>、<footer>全部移除。
提取后的 HTML 片段传给clean.py,由它用 html2text 转 Markdown,同时执行几个固定清理任务:去掉超长无意义空行、去掉重复的标题层级、把图片链接单独摘出来放到文章末尾。这样 Agent 拿到的 Markdown 体积小、信息密度高,不会因为页面里塞了五十个图片 URL 把上下文窗口撑爆。
clean.py接收 stdin 输入、stdout 输出,整个设计保持了"管道式"Unix 风格,这样 Agent 可以灵活组合:
python scripts/scrape.py "https://example.com/post" | python scripts/clean.py --max-chars 8000--max-chars这个参数是专门为不同上下文窗口的 Agent 准备的。Claude Code 上下文大可以放开,Hermes 上下文小就限制 5000 字以内,用同一个脚本、不同参数即可。
3.4 为什么最终输出选 Markdown 而不是原样 HTML
这是个很重要的取舍。最初版本我图省事,让 scrape.py 直接把清洗后的 HTML 输出,以为 Agent 能看懂就行。后来发现两个问题:一是 HTML 标签大量占用 token,一个简单页面能膨胀到原始文本的三倍体积;二是很多 Agent 在读取 HTML 时容易产生幻觉,会把标签属性里的内容误认为正文。
换成 Markdown 以后,token 占用直接下降 50% 以上,Agent 对内容的理解准确率也明显提升。搜索+抓取这个场景,目标是为了让大模型拿到"人可读的文本",而不是保留网页的排版结构,所以 Markdown 是比 HTML 更合适的中间格式。
4. 一份 SKILL.md 的格式取舍:五处关键妥协点
既然决定让三套运行时共用同一份 SKILL.md,就不可能保留各家最舒服的写法,必须有一堆妥协。下面是我在实际编写中做出的五个关键取舍,每条都对应一个具体的兼容性问题。
4.1 妥协一:frontmatter 永远只用 name + description
这是最先定下来的规则。Claude Code 能完整解析 frontmatter,但 Codex 和部分 Hermes 版本基本忽略它。为了"一份走天下",我不能寄希望于私有字段能传到脚本里。所有可变参数全部通过命令行参数传,而不是塞进 frontmatter。否则你写个query: "..."在 frontmatter 里,Claude Code 可能用得很开心,Hermes 却根本不知道你在说什么。
4.2 妥协二:不用复杂的 shell 包装,只保留最外层可执行命令
写 SKILL.md 时你会倾向于把一些常用组合写成 Shell 脚本,比如./search-and-scrape.sh "keyword",让 Agent 一次调用完成搜索加抓取。但组合脚本一旦出错,排查链路会拉得很长。我更倾向于在 SKILL.md 里只暴露scripts/search.py和scripts/scrape.py两个原子命令,让 Agent 自己决定要不要串联。Agent 擅长编排,它不需要你替它把流程写死;你替它写死流程,反而降低了它在遇到边界情况时的灵活性。
4.3 妥协三:支持--max-chars和--limit这类"上下文预算"参数
在不同运行时切换时,最明显的差异就是上下文窗口大小。Codex 在复杂任务里可能一个会话吃掉几十万 token,Hermes 轻量模式的上下文可能只有几万。同一个搜索结果列表,完整输出 20 条可能没问题,但抓取正文时 20 个页面全部展开就会爆炸。
所以两个脚本都支持--limit(限制搜索条数)和--max-chars(限制抓取正文最大字符数)。默认值按最小公共分母设:搜索默认 6 条,抓取默认 5000 字符。这样在上下文最小的 Hermes 上跑不会爆,在 Codex 上也可以用参数放开。
4.4 妥协四:配置外置,不放硬编码密钥
搜索源里面有一些是免 key 的,但难免会有需要 API key 的场景。key 绝不能写在 SKILL.md 里,也不能写在脚本的默认参数里。我把所有敏感配置放到用户主目录下的~/.config/search-scrape/config.yaml,脚本启动时按"环境变量 > 配置文件 > 默认值"的优先级读取。这样同一个 skill 目录可以随便复制给其他项目,不用每次复制完还要进去改 key。
4.5 妥协五:错误信息必须进 stderr,并且给出可操作建议
这是我从踩坑里总结出来的教训。第一次写 search.py 的时候,打印错误我用的是print("error: timeout", file=sys.stderr),Agent 拿到的 stderr 是空的,只看到 stdout 里什么都没有,还以为搜索结果是空,直接告诉用户"没有找到相关信息"。
后来我把所有异常处理改成既往 stderr 写详细错误,又往某些异常分支的 stdout 写一句人话提示:
[search.py] error: timed out after 15s. hint: try again with a shorter query, or check your network connection.Agent 看到 stdout 里有hint:关键词,就会把它当成诊断信息而不是搜索结果,这对引导 Agent 正确决策很有帮助。
下面这个表汇总了五处妥协点的本质和风险:
| 妥协点 | 原因 | 不这么做的风险 |
|---|---|---|
| frontmatter 只用 name/description | 部分运行时忽略私有字段 | 技能在个别工具中无法加载 |
| 暴露原子命令而非组合脚本 | Agent 更擅长编排 | 组合脚本出错难排查 |
| 支持上下文预算参数 | 各运行时上下文差异大 | 小上下文工具直接爆窗 |
| 配置外置 | 防止密钥和路径泄露 | skill 无法安全复用 |
| 错误进 stderr + hint | 避免 Agent 错误处理错误 | 误报"无结果" |
5. 实战迁移记录:cc switch、本地接口转发与 tool schema 冲突的排查链路
工具和 SKILL.md 都写完,不等于万事大吉。真正让我意识到"跨运行时兼容"有多考验耐心的是在迁移和联调阶段,这里记录两条完整的排查链路,都是实际遇到的问题。
5.1 “cc switch” 切换 Codex endpoint 时 local proxy failed
我在本地用 cc switch 管理 Codex 和 Claude Code 的运行时切换,这本来是为了测试同一份 SKILL.md 在不同模型供应商下的表现。结果第一次切换后就遇到这样的错误:
cc switch local proxy failed while handling codex endpoint /responses排查链路我按下面几步走通:
第一步,确认 cc switch 里的 endpoint 配置。Codex CLI 新版走的是/responses接口,而不是老版的/v1/chat/completions。如果你配置的后端只实现了/v1/chat/completions,而没有针对/responses做兼容,就会报这个错。我当时检查了 cc switch 的配置目录,发现后端 base_url 指向http://127.0.0.1:某个端口,端口是对了,但接口路径挂在/v1/chat/completions上。
第二步,验证后端服务是否真的在监听这个端口。我用 curl 直接请求了一下:
curl -X POST http://127.0.0.1:端口/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{"model":"test","messages":[]}'能通,说明服务没问题。问题纯粹是 Codex 需要的路径和后端提供的路径不匹配。
第三步,查看 Codex CLI 配置里是否能调整 API 路径。新版 Codex CLI 支持在配置里指定responses还是chat风格。最后我把 cc switch 的映射规则改成同时映射/responses到后端的兼容地址,或者干脆把 Codex 的接口风格切回 chat completions(如果配置项允许),问题才消失。
这个坑的核心在于:跨工具切换时,你切换的不只是模型,还有 API 语义层。SKILL.md 本身不涉及这个层面,但如果你要测试"同一个 skill 在不同运行时下效果如何",就必须先把底层 API 连通性解决掉。不然你会误以为 skill 写错了,实际上请求压根没到模型那里。
5.2 三家运行时对“工具调用”的 schema 理解不一致
第二个坑更隐蔽,也更贴近 SKILL.md 设计本身。我在 Claude Code 里把脚本封装成了 function calling 工具,声明了一个 JSON schema。Claude Code 会用很标准的 OpenAI 风格 schema 传参,Codex 也基本兼容,但 Hermes 传参完全随缘——有一次它直接把整个目标 URL 当成一个 JSON 字符串塞进--url参数里,导致命令行解析直接失败。
排查时我反复看 Hermes 的日志,发现它对 function calling 的原生支持不完整,很多情况下它更擅长"从文本里找命令、拼命令行去执行"。于是我换了个思路:不再依赖任何运行时的原生 function calling,而是在 SKILL.md 正文里用非常明确的模板告诉 Agent:
当你需要搜索时,执行: python scripts/search.py "<query>" --limit 6 --format text然后给几个具体的 few-shot 示例。这样 Hermes 即使原生 schema 支持不好,也能通过文本解析把命令拼出来。Claude Code 和 Codex 对这种"从文本中识别命令"的路径同样能走通,只是略有冗余。
这个取舍让我损失了一部分"结构化传参"的严谨性,换来了三个运行时都能成功触发脚本的结果。正如标题所说,一份 SKILL.md 走天下,本质就是要在"各工具的个性化能力"和"共享兼容性"之间做取舍。对我来说,能稳定跑通比严格 schema 重要得多。
5.3 另一个需要注意的坑:订阅权限提示
迁移过程中还遇到过 Claude Code 提示your organization has disabled claude subscription access for claude code。这个跟 SKILL.md 没关系,纯粹是账号侧权限问题。如果你在同一个组织里既用 Claude Code 又用 CODE CLI,一定要确认订阅策略是否允许当前的认证方式。该提示出现时,我的建议是先查组织设置里的 Access 控制,而不是去折腾 skill 配置。这类问题往往比技术问题更隐蔽,因为你以为是代码和配置的问题,最后发现是账号策略。
6. 测试策略与维护习惯:三端各跑一轮,才算真的可用
写到这里,你可能觉得 SKILL.md 本身的设计已经结束了,但实际上"一份走天下"最难的是后续每次改动后的回归测试。
我最终形成的习惯是:每次修改抓取逻辑或搜索逻辑后,用同一组 query 在三个运行时各跑一遍冒烟用例。
测试用例固定就三条:
- 搜索一个热门技术关键词(比如 "codex cli"),确认返回结果数量符合 limit;
- 抓取一个技术博客页面,确认标题、正文、代码块都出现在 Markdown 里;
- 故意给一个不存在的 URL,确认脚本把错误写进 stderr 且退出码非 0。
不要觉得三条就够。搜索+抓取工具的特殊性在于它被 Agent 使用,Agent 的行为不完全受你控制,你必须保证任何输入情况下脚本都不会把无效内容当作正常结果输出。尤其是抓取环节,一个页面 5 秒超时还是 15 秒超时,直接影响 Agent 的整体体验。我最后把超时时间统一设成 15 秒,超过之后就快速失败并给出 hint,而不是默默挂起消耗 Agent 的耐心。
6.1 用幂等和缓存降低 Agent 的重复消耗
搜索和抓取都是"外部 I/O 密集"操作,同一个查询在短时间内被多个 Agent 触发是很常见的事。我给 search.py 和 scrape.py 都加了缓存逻辑:搜索按 query+engine 做 key,抓取按 URL+时间窗做 key,结果存在cache/目录下,有效期默认 10 分钟。
这个设计和 SKILL.md 没有直接关系,但它保证了"三个运行时同时在线"时,不会因为大家抢同一个外部接口而被限流。
6.2 SKILL.md 的版本管理
SKILL.md 本身的版本管理也是一个容易忽视的点。我前面说 frontmatter 里不要加私有字段,所以版本信息我也没放在 frontmatter 里,而是放在正文最底部的一个## Changelog区。Agent 通常不会读这个区域,但对人来说,每次改动后能在文件里快速看到历史记录,极大方便协作。配合 Git 管理整个 skill 目录,每次改动都能回溯到是哪个取舍点引发的行为变化。
实际经历了几次"Claude Code 突然不用搜索工具了"的排查,最后发现都是我改了脚本行为但没有更新 SKILL.md 里的示例命令,导致 Agent 按照旧文本去拼命令,新脚本已经不认那个参数了。所以现在我给自己定了一条铁律:脚本改动必须同步更新 SKILL.md 正文中的命令示例,二者以 Git 提交为单位绑定,不允许单独提交。
7. 最后再分享两个让我少走弯路的小技巧
第一个小技巧:在 SKILL.md 里给 Agent 写"输入输出说明"时,宁可写得多一点,也不要留白。很多 SKILL.md 只写"你可以使用 search.py 进行搜索",但没告诉 Agent 输出长什么样。后来我在每个命令后面补了一小段"返回结果示例",像这样:
搜索命令返回一个 JSON 数组,每个元素包含 title/url/snippet 三个字段。 示例:[{"title": "Codex CLI", "url": "https://...", "snippet": "..."}]这个动作看起来微不足道,但对 Agent 的核心决策影响非常大——它知道拿到结果后下一步可以做什么,而不用自己盲猜。
第二个小技巧:始终用一个不带 API key、完全本地可跑的搜索源做默认配置。最开始我默认配置里挂了一个需要 key 的搜索服务,结果换机器测试时忘记配 key,三端全部静默失败。后来我把默认引擎换成无 key 的 DuckDuckGo 即时答案接口,虽然返回量不算丰富,但保证开箱即用。真正需要更强搜索能力时,通过--engine显式切换。
这两个技巧帮我省下的调试时间,比整个 SKILL.md 的设计时间还多。毕竟工具写得再好,如果 Agent 不知道怎么正常使用,一切等于零。