ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

跨模型Agent Skill适配:协议层与实现层分离实战

跨模型Agent Skill适配:协议层与实现层分离实战 先说个我自己的经历。上个月我把一个基于SKILL.md的代码审查 skill 从 Claude 迁移到 Qwen2.5 上结果同一个目录、同一份提示词Claude 能给出结构化审查结论Qwen 却一上来就漏掉输出 JSON的要求偶尔还给我编一个不存在的工具参数。折腾了整整两天最后发现问题根本不在 prompt 写得好不好而在 skill 的结构设计从一开始就没考虑模型无关这件事。如果你也在做 agent skill 开发——不管用的是 Claude Agent Skills、Codex skill 还是 OpenCode 这类框架只要你的技能目录打算在多款大模型之间复用这篇就是写给你看的。我会先拆解 skill 跨模型时的差异来源再给出一套协议层与实现层分离的设计方法最后用一个完整的网页抓取结构化总结skill 案例带你走一遍从翻车到修通的完整链路。整个过程不涉及高深理论全是能直接抄的实操方案。1. 别急着改 prompt先搞清楚 skill 跨模型时到底丢在哪一层很多人拿到同个 skill 适配不同大模型这个问题第一反应是去调 prompt把语气调硬一点、把格式要求重复三遍、把示例加几个。这些招数有用但都是治标。真正的问题在于skill 在跨模型时丢失的内容分属三个不同的层次你得先定位到底是哪一层出了问题。1.1 skill 的真实构成它从来不只是 prompt先统一一下认知。在 Anthropic 提出的 Agent Skills 规范里一个 skill 是这样一个目录review-skill/ ├── SKILL.md ├── scripts/ │ └── run_review.py └── resources/ └── review_template.mdSKILL.md 是核心它由 YAML frontmattername、description 等元信息和正文指令、示例组成。scripts 里放可执行脚本resources 里放参考文档、模板。Agent 启动时会把 SKILL.md 的 description 注入系统提示词等模型判定需要用到这个 skill 时再把完整的 SKILL.md 内容塞进上下文同时允许模型调用脚本来拿外部数据。Codex skill、OpenCode 的 skill 机制大同小异差别主要在于脚本如何被调用、description 如何注入、以及工具调用协议是走 Anthropic 格式还是 OpenAI 兼容格式。这就在根上埋了个雷你以为你在写一份给模型看的使用说明书实际上你同时写了一份给 agent 框架看的工具注册表。前者是纯文本问题后者是协议问题。1.2 三个真正的差异层指令遵循、工具协议、上下文容量第一个差异层是指令遵循能力。不同模型的服从度差别极大。Claude 对必须输出 JSON不要包含任何其他内容这种约束理解得很干净但有些模型尤其是量化后的本地模型对此的响应是先输出一段解释性文字再把 JSON 塞进 markdown 代码块里。不是它笨是它在训练时见到的指令风格和你的写法不匹配。第二个差异层是工具调用协议。Anthropic 的 tool use API 和 OpenAI 的 function calling API在请求和返回的字段名、参数结构上是两套东西维度Anthropic Tool UseOpenAI Function Calling请求参数结构input_schemaparameters工具名写法name支持大小写name建议只含字母数字下划线返回内容位置tool_use块内tool_calls数组内模型传参错误率较低视模型而定如果你直接把写给 Claude 的工具 schema 原样搬到 OpenAI 兼容网关返回的参数很可能直接对不上。这跟 skill 写得好不好没关系是序列化层的映射问题。第三个差异层是上下文窗口和容量。同一个 skill 的 SKILL.mdClaude 能完整读完并遵循末尾的步骤但上下文小一些的模型可能会漏读后半段指令——尤其当 skill 里塞了很长的 examples 时。这个层的问题最阴险因为它不会报错只会让模型表现出好像没看到那条规则的诡异行为。所以动手改之前先回答自己三个问题模型是不是没按格式输出指令层模型调用工具时参数是不是错了协议层模型是不是在长上下文下表现明显变差容量层。判断清楚了再决定改 SKILL.md、改适配器、还是压缩示例。2. 把 skill 拆成两层协议层和实现层才是模型无关的基础我见过很多团队维护 skill 的方式是给每个模型复制一份 SKILL.md比如SKILL-claude.md、SKILL-qwen.md。短期可行长期必崩——你改一个格式规则要同步三四个文件漏掉一个就是行为不一致。正确做法是把 skill 拆成两层协议层定义这个 skill 做什么、输入输出契约是什么实现层负责把契约翻译成某个模型能理解的表达。2.1 协议层用中立 schema 定义输入输出契约协议层不关心模型是谁它只定义两件事这个 skill 接受什么参数返回什么结构。我在实际项目中习惯用一段 JSON Schema 来定义契约放在resources/contract.json里{ name: fetch_and_summarize, description: 抓取网页内容并输出结构化摘要必须返回 JSON 对象, input: { url: { type: string, required: true }, max_length: { type: integer, default: 300 } }, output: { title: { type: string }, summary: { type: string }, keywords: { type: array, items: { type: string } } } }这份契约用在一个中立的、不偏向任何厂商的格式上。写 SKILL.md 时我只引用这份契约不在正文里出现任何调用 search_tool(参数1, 参数2)这种跟具体工具绑定的写法。因为模型看到search_tool这个名字时它会在自己的工具列表里找同名工具找不到就编一个——这是很多工具调用乱掉的根源。2.2 实现层适配器负责把中立 schema 翻译成各家 API实现层是一个薄薄的适配器它读contract.json然后动态生成目标模型的工具 schema。比如我在 Python 里写了一个极简的翻译函数def to_openai_tool(contract): return { type: function, function: { name: contract[name], description: contract[description], parameters: { type: object, properties: { k: {type: v[type]} for k, v in contract[input].items() }, required: [ k for k, v in contract[input].items() if v.get(required) ] } } } def to_anthropic_tool(contract): return { name: contract[name], description: contract[description], input_schema: { type: object, properties: { k: {type: v[type]} for k, v in contract[input].items() }, required: [ k for k, v in contract[input].items() if v.get(required) ] } }这样 SKILL.md 里只写使用 contract.json 中定义的 fetch_and_summarize 工具剩下的交给适配器。模型看到的是经过适配的、符合自家 API 格式的工具说明而不是一份四不像的混合 schema。2.3 SKILL.md 正文的写法也要降敏感协议层的另一个重点是 SKILL.md 正文的措辞。我总结了几条经验都是实测后管用的不要用绝不一定不要这类绝对化否定。负面指令是跨模型失败率最高的写法。改成正面引导输出内容应仅包含 JSON 对象比不要输出任何解释文字稳定得多。示例宁缺毋滥。few-shot 对能力强的模型帮助有限对能力弱的模型反而会造成格式模仿上的偏差。我一般只保留一个最短的完整示例并且明确标注这是一个示例不是真实输出。把关键约束放在 SKILL.md 的前 30%。上下文短或注意力弱的模型对靠后的指令遗忘率明显升高。步骤说明按重要程度排序而不是按执行顺序排序。需要模型理解的东西写文字需要模型照着做的东西给模板。描述性的请输出包含标题、摘要、关键词的 JSON远不如直接给一个空模板{ title: , summary: , keywords: [] }模型对模板的模仿能力远强于对描述的推理能力。这一点在几乎所有模型上都成立。3. 实操一个网页抓取结构化总结skill 从 Claude 迁到 Qwen 的两轮改造理论讲再多不如跑一个真实案例。这个 skill 的功能很简单给一个 URL脚本抓取网页正文模型生成结构化摘要。我最初是在 Claude 上把流程跑通的然后切到 Qwen经历了两次明显翻车最终改造成模型无关版本。3.1 第一版为 Claude 量身定制的写法最初的 SKILL.md 长这样--- name: fetch_and_summarize description: 抓取指定网页并输出结构化摘要。当用户提供 URL 并希望获取内容摘要时使用。 --- # 任务目标 抓取用户提供的 URL 内容输出包含 title、summary、keywords 的 JSON 对象。 # 执行步骤 1. 调用 fetch_and_summarize 工具传入 url 参数。 2. 等待工具返回网页文本。 3. 根据网页文本生成摘要以 JSON 格式输出。 4. 输出必须严格为 JSON不得包含其他内容。 # 输出格式 { title: 网页标题, summary: 200字以内的中文摘要, keywords: [关键词1, 关键词2, 关键词3] }在 Claude 上这个版本一次通过输出干净利落。问题出在切换到 Qwen 之后。3.2 切到 Qwen 后的三个翻车现场第一个现象模型输出了一坨带前言后语的 JSON。真实返回长这样好的我来为您生成摘要 json { title: 某某文章, summary: ..., keywords: [...] }需要其他帮助可以继续问我。第二个现象工具调用参数被传错类型。max_length 是个 integer模型传了个字符串 三百 进去脚本直接报错。第三个现象更诡异在网页文本较长时模型输出的 JSON 里丢了 keywords 字段而且不报错就默默少给一个字段。 这三个现象分别对应前面说的指令层、协议层、容量层的问题。我当时的处理方式不是去调 prompt 语调而是直接改了 skill 的结构。 ### 3.3 四步改造从Claude 专用到模型无关 第一步**把输出 schema 降维**。原来 keywords 是数组现在我把契约改成只用 string 和 array of string 两种类型并且把嵌套的 object 全部拍平。这一步是为了规避弱模型在构造嵌套 JSON 时容易出错的问题。 第二步**在指令里直接给 JSON 模板**而不是描述性说明。上面那个模板保留但我在模板后面加了一行提示直接填写这些字段不要添加任何字段。实测下来这句话对 Qwen 的约束力比必须严格输出强得多。 第三步**加一个后处理解析层**。因为无法保证所有模型都输出裸 JSON我在脚本里做了一个容忍 markdown 代码块的解析函数 python import json, re def extract_json(text: str) - dict: text text.strip() if text.startswith(): # 去掉首尾的代码块标记 text re.sub(r^(?:json)?\s*, , text) text re.sub(r\s*$, , text) try: return json.loads(text) except json.JSONDecodeError: # 兜底提取第一个 { 到最后一个 } 之间的内容 start text.find({) end text.rfind(}) if start ! -1 and end ! -1 and end start: return json.loads(text[start:end1]) raise第四步给工具调用加参数校验和失败重试。契约里声明max_length是 integer适配器就在调用前强制转换转不了就丢默认值工具返回空结果时生成一段固定提示要求模型重新调用一次。这四步做完SKILL.md 基本已经看不出是为哪个模型写的了。3.4 改造后的效果改造后的 skill 在我本地的测试矩阵里跑了一遍Claude Sonnet、Qwen2.5、DeepSeek-V3、以及一个量化版的 Llama 3.1 8B。结果前三家都能稳定输出符合契约的 JSON量化版 Llama 偶尔还会漏字段但后处理解析器至少能保证程序不崩漏掉的字段会用空值补齐并记录 warning。对本地小模型我会在 SKILL.md 的 description 里加一句如果输出内容超过 300 字会截断主动降低它的任务复杂度。跨模型适配追求的不是每个模型表现一模一样而是每个模型都能完成任务、输出能被程序消费。4. 一次工具参数乱掉的完整排查链路从现象到根因讲一个我印象特别深的排查过程。那是在 skill 的第二个版本我加入了按关键词过滤功能契约里多了一个filters参数类型是 array of object。在 Claude 上跑得好好的切到 Qwen 后工具调用参数直接变成空对象{}脚本拿不到任何过滤条件结果全量返回了。这个 bug 花了我一个下午才定位。4.1 现象描述用户的查询是抓取某网页只要提到 Python 的部分。模型调用工具时日志里显示的参数是{ url: https://example.com/article, filters: {} }filters应该是[{field: topic, value: Python}]结果变成了空对象。更奇怪的是模型没有报错也没有尝试二次调用就像它认为自己已经做对了。4.2 排查链路完整还原我复盘时把排查过程记成了清单方便以后复用。第一步先看是不是我的解析代码吞了参数。我检查了适配器里to_openai_tool的转换逻辑用contract.json生成parameters.schema然后用一段测试代码直接调 OpenAI 兼容接口。结果发现即使我手动把正确的filters传给接口解析层也能正常读出来。这说明问题不在我的代码而在模型生成的参数内容本身。第二步看框架有没有对工具做额外转换。我用的是 OpenCode 作为 agent 框架它内部会对工具 schema 做一次归一化。我查了框架的源码和日志发现filters被转换后 schema 长这样{ type: object, properties: { field: { type: string }, value: { type: string } } }问题出现了我在协议层定义的是array of object但框架在转换过程中把嵌套的 object 结构丢了只保留了 object 本身的属性定义。模型看到的 schema 是filters 是一个对象包含 field 和 value 两个字段它自然就构造了一个{}出来。第三步直接抓原始 API 返回绕开框架日志。这一步最关键。我写了一个最小复现脚本把 SKILL.md 注入 system prompt 后直接请求 Qwen 的 API不经过 OpenCode。结果在原始返回里模型确实输出的是filters: {}。这证实了我的猜测不是框架的 bug是模型对数组里套对象这种复杂嵌套 schema 的遵循能力不足。第四步定位根因。Qwen 面对array of object时最常见的失败模式就是返回空对象或空数组而不是构造出符合内部结构的元素。这跟模型的训练数据里少见这类复杂 tool schema 有关。Claude 遇到同样的情况能自己推断出正确的结构其他模型就未必。4.3 修复方案把嵌套 schema 降成扁平的字符串参数我没有去跟模型较劲而是直接把契约改了。filters从array of object改成一个字符串参数比如filter_string格式是field:value;field:value由脚本内部去解析{ input: { url: { type: string, required: true }, filter_string: { type: string, default: } } }脚本侧def parse_filters(filter_string: str): if not filter_string: return [] result [] for item in filter_string.split(;): if : in item: field, value item.split(:, 1) result.append({field: field.strip(), value: value.strip()}) return result改完之后Qwen 面对的就只是一个普通字符串参数它处理得很好。代价是参数的表达变丑了但换来了跨模型的稳定性。这条经验成了我后来设计契约的铁律能用 string 表达的不用 object能拍平的嵌套一定拍平。模型在工具调用上对复杂结构的理解能力远没有你想象的那么强。4.4 从这次坑里总结的排查顺序以后再遇到工具参数乱掉的问题直接按这个顺序查能省掉一半时间先看原始 API 返回不要只依赖 agent 框架的日志。框架会做转换会掩盖真相。用最小复现脚本直接调模型 API确认是不是模型本身的问题。检查适配器对 schema 的转换逻辑尤其是嵌套类型的转换。如果模型确实对复杂 schema 无能为力不要硬撑改契约比调 prompt 更有效。5. 长期维护把适配从一次性修补变成可持续机制一个 skill 适配了三四个模型之后最怕的不是改不动而是改一个地方全盘崩掉。我在项目里养成了几套维护机制虽然看起来繁琐但长期省下的时间远超投入。5.1 建立模型能力矩阵我会给每个用到这个 skill 的模型建一行档案记录它对 skill 各环节的表现模型指令遵循复杂工具参数JSON 输出干净度长上下文稳定性Claude Sonnet优优优优Qwen2.5 72B良中良良DeepSeek-V3良良良中Llama 3.1 8B量化中差中差这张表不是摆设它会直接指导 SKILL.md 的分支写法。比如容量层差的模型我在 SKILL.md 里用一个条件块控制示例长度如果当前上下文余量不足 2000 token可跳过示例部分。虽然模型不一定每次都会读这个条件但在实测中确实能降低长上下文下的遗忘率。5.2 回归测试清单每次改动 SKILL.md 或契约文件我都跑一遍固定的回归测试。清单如下格式测试用三个不同复杂度的输入一个短网页、一个长网页、一个空页面断言输出能通过extract_json解析且必填字段齐全。工具测试每个模型调用一次工具检查返回参数是否符合契约的类型。指令测试故意写一个意图模糊的指令比如帮我看看这个页面看模型是否会错误地跳过 skill。降级测试模拟工具调用失败看模型是否会触发重试还是直接编造结果。这四项测试花不了几分钟但能在你调整措辞、加功能之后第一时间暴露问题。5.3 版本管理把协议变更和实现变更分开我强烈建议把 skill 的版本号拆成两段语义化版本直接抄 npm 那套主版本号变更意味着契约变了比如输出格式从 JSON 改成了 YAML次版本号变更意味着实现变了比如新增了一个解析器、调整了示例。这样当某个模型表现异常时你能快速定位是协议变了还是实现变了不用回滚整个目录。如果团队协作记得在 CHANGELOG 里写清楚每次改动的动机。我自己踩过最大的坑是某次为了迁就一个本地小模型把 输出 JSON 改成了更宽松的 输出纯文本结果小模型是稳定了Claude 反而开始输出自然语言。最后查 CHANGELOG 才发现是那次为了修复一个根本不存在的 bug 引入的回归。契约的每一项改动都必须写清影响范围不然就是在给自己埋雷。6. 写在最后我在这个项目里的几点真实体会把同一个 skill 适配到不同大模型这件事做久了会发现一个反直觉的结论适配的瓶颈往往不在大模型而在你自己对 skill 的结构设计。你越是把 skill 设计成依赖某个模型的隐式能力适配成本就越高你越是把 skill 设计成显式的、自包含的、有清晰契约的它就越容易在不同的模型上表现一致。我个人的项目里现在的流程已经固定下来了先在协议层把逻辑想清楚——这个 skill 到底要做什么、输入输出边界是什么、哪些部分可以依赖模型的理解力、哪些部分必须靠脚本兜底然后才开始写 SKILL.md最后再考虑适配具体的模型。顺序反了就会陷入调完 Claude 调 Qwen调完 Qwen 发现 Claude 又不稳定的死循环。另外给刚入坑的朋友一个建议不要一上来就追求一个 SKILL.md 通吃所有模型。先把主力模型通常是你最常用的那个跑通然后用固定的一批回归用例去测其他模型把差异记下来再逐项修。跨模型适配是个随着模型迭代会不断变化的过程——今天适配好的 skill下个版本的模型 API 一变可能又要重来。与其追求一次性完美不如把这套适配方法内化成你的日常开发习惯。这样哪怕明天又冒出一个新模型你也知道从哪下手。
返回列表