ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

OpenDeepWiki中的Agent Skills:模块化技能封装与工具调用实践

OpenDeepWiki中的Agent Skills:模块化技能封装与工具调用实践

1. 先说清楚:Agent Skills 到底是干什么的

我最早看到 "Agent Skills" 这个概念时,第一反应是:这不就是给大模型写 prompt 吗?后来在 OpenDeepWiki 这种知识库类项目里实际跑过一遍,才发现事情没那么简单。它解决的是一个很实际的问题——AI agent 在什么场景下、按什么规则、调用什么工具、拿到什么结果。如果没有一套机制来约束和管理,agent 面对一堆工具时会乱来:要么不知道该用哪个,要么把一个简单任务拆成十几次无效调用,要么在错误的路线上反复横跳。

OpenDeepWiki 这类项目的核心场景是"让 AI 自动整理、检索、生成和更新 wiki 内容"。你可以把整个系统想象成一个数字化团队:调度器是 LLM,文档库是 wiki 里的页面和知识块,工具箱是各种可执行脚本和 API。Agent Skills 机制就是给这个团队发的"岗位说明书"和"操作规程"。

传统的做法是把所有工具描述塞进 system prompt,让模型自行选择。但当工具数量超过十个、二十个,这套方法就会迅速失效,原因有两个:第一,描述太多会挤占上下文窗口,模型反而抓不住重点;第二,工具之间可能存在隐含依赖关系(比如"先查索引再搜正文"),模型从零推理这种依赖关系非常不可靠。Agent Skills 改变的是组织形式:它把工具、使用说明、约束条件和典型调用场景打包成一个整体,让模型先通过轻量级的"技能描述"做粗粒度决策,再在命中的技能内部寻找精确工具。这就好像你进一家大型图书馆,不是在一排排书架里漫无目的地乱逛,而是先看导览图找到对应楼层,再按索引号精确取书。

我在实际部署中也验证了一件事:引入 Skills 之后,tool call 的成功率和我手动 review 时的可接受度都明显提升。它不是魔法,只是把"模型自由发挥"变成了"模型做选择题",而选择题的答案范围是我们可控的。

2. OpenDeepWiki 的 Agent Skills 设计逻辑:以 SKILL.md 为单位的模块化封装

2.1 为什么是"一个技能一个目录"而不是"一个技能一个配置项"

OpenDeepWiki 沿用了业界比较成熟的做法:每个技能就是一个独立目录,目录内至少包含一个SKILL.md文件,此外可以根据需要附带脚本、参考文档、模板和测试用例。这种"目录即技能"的组织方式,最大的好处是自包含。

我见过很多项目把技能配置写进一个大 JSON 或 YAML 文件里,字段越来越长,最后根本没人敢动。OpenDeepWiki 的处理方式更接近 Unix 哲学:"做一件事,把它做好",并且每个技能拥有自己的依赖环境。比如一个"pdf 转 Markdown"技能,它的 Python 依赖只需要在自己的requirements.txt里声明,主程序加载时完全不用关心底层库是否冲突。你甚至可以给每个技能目录单独设一个虚拟环境或容器,做到严格的隔离。

从工程维护的角度来说,这种结构降低了认知负担。新增一个技能,就是新增一个目录;删除一个技能,就是删除整个目录。Git 的 diff 也干净得多,评审者一眼就能看出某个技能的变化影响范围。

2.2 SKILL.md 的文件结构与字段语义

一份标准的SKILL.md,通常由两部分组成:YAML frontmatter和Markdown 正文。frontmatter 管元信息,正文管操作指导。OpenDeepWiki 的加载器会先用 YAML 解析 frontmatter,然后用 Markdown 解析正文,分别走不同的处理流程。

frontmatter 里最常见的字段是name和description,但真正决定这个技能能不能被"正确触发"的,是description的写法质量。我见过有人写 "Use this skill for PDF processing",这种描述在语义匹配时基本等于没说。一个好的 description 应该具备三要素:适用场景、输入类型、输出类型。我自己在 OpenDeepWiki 里实践下来,一个比较可用的写法类似:

--- name: pdf-to-markdown description: 将 PDF 文件转换为结构清晰的 Markdown 文档,保留标题层级、表格和代码块。适用于需要把 PDF 稿件、论文或报告导入 wiki 知识库的场景。 ---

注意,我加入了"适用于把 PDF 导入 wiki 知识库"这个场景描述。模型在决定是否调用该技能时,实际是在做语义匹配,而不是关键词匹配。场景描述越具体,命中率越高,不过这也会带来我后面要讲的"误触发"问题,需要用一个参数来平衡。

正文部分同样很有讲究。它不是写给人看的,而是写给模型看的操作手册。我建议正文里明确写清楚:脚本入口、参数格式、退出码含义、典型用例、需要避免的错误做法。模型在执行技能时通常会把正文和脚本一并交给 LLM 读取,所以正文的语言要用命令式、无歧义,避免"或许""可能"这类模糊词汇。

2.3 技能目录里的"左膀右臂":scripts 与 reference

在 OpenDeepWiki 的实践中,SKILL.md只是起点,真正干活的是scripts目录下的可执行文件。我习惯把所有可执行逻辑放在scripts/下,按功能拆分,例如:

  • scripts/fetch_pdf.py:下载和规范化 PDF
  • scripts/parse_pdf.py:PDF 解析和 Markdown 转换
  • scripts/clean_markdown.py:清理无效字符、合并断行

有些技能还需要静态参考信息,比如企业内部 API 的字段规范、常见错误码表、正则模板等。这些不适合硬编码进正文,放在reference/目录里更合适。加载器可以把 reference 文件作为"按需读取"的资源,而不是全部塞给模型。这样能节省上下文窗口,尤其是当参考文档特别长的时候。

我踩过一个真坑:早期把所有的参考文档都内联进SKILL.md正文,结果一个技能加载后占用了将近 2 万 token,导致后续对话严重降速。后来我把reference/改成按需加载,只有模型判断需要时才用工具去读文件内容,问题立刻缓解。这个设计思路其实和操作系统的虚拟内存很像:先映射,再换页,而不是一次性全部加载进内存。

3. 完整链路拆解:从 SKILL.md 到 AI 工具调用,中间到底发生了什么

3.1 第一阶段:启动时的技能注册与索引构建

OpenDeepWiki 启动时,会扫描配置的 skills 根目录,对每个技能子目录执行加载流程。这一步我称之为注册阶段。核心动作有三个:解析SKILL.md的 frontmatter、校验字段完整性、把技能写入内存索引。

索引不是简单地把名字和描述存下来,它会做一层语义向量化。OpenDeepWiki 可以用本地 embedding 模型,也可以用远程 API,两者各有利弊。本地模型延迟低、数据不出域,但对中文长文本的语义理解通常弱一些;远程 API 效果更好,但是每次启动都要重新拉向量,冷启动会慢几秒。我自己的选择是:如果 wiki 知识库以中文为主,优先用效果更好的远程 embedding;如果是内部保密项目,就用本地模型加自定义词典。

注册阶段还有一个容易被忽略的细节:技能依赖检查。SKILL.md的 frontmatter 里可以声明requires字段,表示这个技能依赖另一个技能。比如"网页归档"技能可能依赖"URL 规范化"技能。依赖检查会在注册时构建一个有向无环图,如果检测到循环依赖,就直接报错并拒绝启动。我记得第一次看到这个设计时觉得有点过度设计,直到某次我为了图省事让两个技能互相引用对方的脚本,结果系统直接拒绝加载,我才理解这是必要的安全措施。

3.2 第二阶段:用户请求触发技能选择

当用户向 OpenDeepWiki 发起请求,比如"把这份 PDF 里的会议纪要整理成 wiki 页面",流程就进入触发阶段。系统先把用户请求和当前对话上下文拼接成一个"路由提示",和技能索引里的描述做匹配。匹配逻辑通常是两路并行:一路是 embedding 相似度,另一路是 LLM 的零样本判断。

这里有个有趣的设计:OpenDeepWiki 并不是直接强行挑选一个技能,而是允许"技能不确定时由模型追问澄清"。比如用户说"这个文档你看一下",系统会向用户确认:"你希望做摘要、翻译,还是格式转换?"这看起来多了一步对话,实际反而减少了错误执行带来的返工。

不过,过度追问也会很烦人。我后来调了一个参数:skill_selection_threshold,当某个技能的匹配分数明显高于其他技能时(比如高出 0.15 以上),就直接选择,不再追问。当匹配分数都很低但用户意图又比较明确时,系统才会进入澄清模式。这个阈值不能拍脑袋定,我是拿历史日志里人工标注的"选择正确性"做了一次小规模回归测试才定下的数值。

3.3 第三阶段:技能上下文注入与工具体系绑定

技能被选中后,进入上下文注入阶段。OpenDeepWiki 会把该技能的SKILL.md正文加载进对话上下文,同时对该技能可用的一组工具进行"能力绑定"。注意,这一阶段并不是把系统里所有工具都暴露给模型,而是只暴露当前技能声明的工具白名单。这一点对安全性至关重要。

我举个具体例子。系统里有"文件系统管理"技能和"数据库查询"技能。如果模型在普通对话中就拥有所有工具,可能会出现"帮忙整理一下 B 站收藏夹"这类请求被模型误解为需要操作文件的诡异行为。OpenDeepWiki 的技能机制通过白名单隔离,确保"文件系统管理"技能只能发起文件读写类的工具调用,数据库相关的工具它连看都看不到。

工具绑定的底层实现,本质上是把工具的 JSON Schema 注入到模型输入中。OpenDeepWiki 在注入时会做一次最小化裁剪:如果某个工具在本次技能调用中不需要某个参数,就把它从 schema 里临时移除。这样做的原因是,参数越少,模型的选择难度越低,生成合法 tool call 的成功率越高。

3.4 第四阶段:模型生成工具调用与循环执行

注入完成后,模型根据用户请求和技能上下文生成结构化工具调用请求。这是整条链路里最考验工程细节的环节。OpenDeepWiki 的处理方式是:采用 OpenAI 兼容的 function calling 协议,模型返回一个tool_calls数组,每个元素包含工具名和参数 JSON。系统解析后,在本地进程中执行对应的 Python 函数或命令行脚本。

执行环节有几个关键点。第一,超时控制。长任务必须设置合理超时,比如 pdf 转换脚本我设成 120 秒,网络请求类工具设成 10 秒。超时后不能直接丢弃任务,而是把超时信息作为错误结果回传给模型,让模型决定重试还是换方案。第二,失败反馈。执行失败时返回退出码和 stderr 尾部内容,并附上一段"可能原因与建议动作"的固定文案,帮助模型快速修正。第三,循环上限。一次任务中允许模型多次工具调用,但需要设置最大迭代次数,我一般设成 8。超过上限就把当前中间结果返回给用户并附上"未完整完成"标记,而不是让模型继续空转。

下面是一个简化的工具调用执行伪代码,我把它贴在项目文档里也方便同事 review:

def execute_tool_call(tool_name, arguments, context): if tool_name not in available_tools: return ToolResult(success=False, error=f"tool {tool_name} not allowed") if time.time() - context.start_time > context.max_duration: return ToolResult(success=False, error="execution timeout") try: result = available_tools[tool_name].execute(**arguments) return ToolResult(success=True, data=result) except ToolExecutionError as e: return ToolResult(success=False, error=e.stderr_tail())

3.5 第五阶段:结果回收与知识库写入

工具调用返回结果之后,模型还会继续生成文本,对结果做摘要、格式化,甚至直接写好新的 wiki 页面草稿。这个阶段最容易被忽视,也最能体现 OpenDeepWiki 作为知识库工具的特点:它不只是"帮你调用一个工具然后把原始输出丢给你",而是会把工具的输出重新理解、结构化、去重之后再融入知识库。

举例来说,pdf 转换技能返回的可能是几百行冗长的 Markdown,模型会先识别里面的标题层级,清理无效换行,删掉页眉页脚,然后判断是否与现有 wiki 页面重复。如果重复,可以选择追加而不是新建。这个"认知后处理"是由一个不同于工具执行的模型回合来完成的。从工程角度讲,OpenDeepWiki 采用了tool_call -> tool_result -> model_final_response的循环结构,每个 tool call 和对应的 tool result 在消息队列里保持严格的先后顺序,确保模型不会看到混乱的执行上下文。

4. 在 OpenDeepWiki 里从零配置一个 Agent 技能:以"网页内容抓取与归档"为例

4.1 目录结构与最小实现

理论讲完了,来看看具体操作。假设我现在要在 OpenDeepWiki 里实现一个"网页内容抓取与归档"技能,目标是:用户给出一个 URL,技能抓取网页正文,转成干净的 Markdown,存储到 wiki 的指定目录,并生成一张索引条目。

第一步,创建技能目录:

skills/ └── web-archiver/ ├── SKILL.md ├── requirements.txt └── scripts/ ├── fetch_url.py └── parse_to_markdown.py

这个结构很简洁,但也足够跑通链路。fetch_url.py负责下载网页并处理重定向、编码识别;parse_to_markdown.py负责剥离标签、提取正文、输出标准 Markdown。

4.2 编写有效的 SKILL.md:不只给模型看,也是给系统看的

SKILL.md的内容决定了模型的执行质量和系统的路由效率。我贴一个自己调过几版后的写法:

--- name: web-archiver description: 抓取指定网页内容并转换为干净的 Markdown 格式,存入 wiki 资料库。适用于需要保存外部文章、博客、文档页面作为知识库素材的场景。 --- # Web Archiver 该技能用于抓取并归档网页内容。执行时请严格遵循以下步骤: 1. 使用 scripts/fetch_url.py 下载目标网页,参数 url 必须包含协议头(https://)。 2. 调用 scripts/parse_to_markdown.py 将 HTML 转换为 Markdown。 3. 检查输出的 Markdown 是否包含正文核心段落。若只有导航或版权信息,视为失败,返回错误信息。 4. 将 Markdown 内容保存到 wiki 的 `archive/` 目录,文件名格式为 `YYYYMMDD-slug.md`。 5. 在 `archive/index.md` 中追加一条引用链接,并附带一行摘要。 注意: - 不要抓取需要登录的网页。 - 遇到 404 或 403 时直接返回状态码,不要重试超过一次。 - 所有输出必须为中文或保留原文,不进行翻译。

这里有一个容易忽略的点:正文中的步骤是给模型看的执行秩序,模型在生成工具调用参数时会严格参考它。比如我写"参数 url 必须包含协议头",就是为了防止模型在工具调用时漏掉https://,导致脚本直接报错。这种"提前在文档中声明约束"的做法,比在代码里做参数校验更早地拦截错误,因为模型能在生成调用前就自我纠正。

4.3 脚本实现的一些细节

我来看fetch_url.py的一个核心片段。这个脚本要处理的是:请求头伪装、超时、以及编码探测。

import argparse import requests def fetch(url): headers = { "User-Agent": "Mozilla/5.0 (compatible; OpenDeepWikiBot/1.0)" } resp = requests.get(url, headers=headers, timeout=10) resp.raise_for_status() if not resp.encoding or resp.encoding == "ISO-8859-1": resp.encoding = resp.apparent_encoding return resp.text if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--url", required=True) args = parser.parse_args() print(fetch(args.url))

这里的apparent_encoding是 requests 库从字节流推测编码的能力,对中文页面尤其重要。很多网页不声明 charset 或声明错误,没有这行的话,正文会出现中文乱码,而模型拿到乱码后整个链路就废了。另外要注意:脚本输出要精简,只输出 HTML 正文,不要把访问日志、调试信息也打到 stdout。因为 stdout 会作为工具结果回传给模型,多余信息会干扰模型的判断,浪费 token。

4.4 配置技能白名单与执行权限

技能目录创建好后,需要在 OpenDeepWiki 的配置文件里注册它。配置的关键是白名单机制。比如我的配置如下:

{ "skills": { "enabled": ["web-archiver", "pdf-to-markdown"], "tool_policies": { "web-archiver": { "allow_network": true, "allow_file_write": true, "allowed_paths": ["archive/"], "max_execution_seconds": 30 } } } }

这里allowed_paths是一个很有效的防护措施:该技能只能往archive/目录里写文件,就算模型被恶意 prompt 诱导尝试写入其他路径,系统在文件系统层面也会拦截。我强烈建议你不会图省事就把这个字段设成["/"],一旦设成全路径,技能失控时影响范围就不可控了。

4.5 验证链路:从日志到端到端闭环

配置完之后的验证过程,我分三步走。

第一步是加载验证。启动 OpenDeepWiki 后,看日志里是否出现类似skill web-archiver registered, 2 tools bound的信息。如果显示注册失败,多半是 frontmatter 格式问题或依赖包缺失。

第二步是手动触发验证。直接在测试环境发一条消息:"打开这个链接 https://example.com/article,保存到 wiki 资料库。"观察日志中的 intent 路由结果,确认系统正确选中了web-archiver技能,而不是错误地落到其他技能上。然后观察 tool call 的参数:URL 是否带了协议头、超时是否生效。

第三步是结果质量验证。打开生成的 Markdown 文件,检查标题、段落、链接是否完好;再打开archive/index.md,确认索引条目是否追加成功。这一项是对"认知后处理"环节的检验:模型有没有把原始网页内容变成适合 wiki 的格式。

我还习惯在验证阶段把模型概率参数调低一点(比如 temperature 调成 0.2),减少随机性,确保两次验证结果一致。确认链路稳定后,再调回正常战斗参数。

5. 最容易翻车的五个点与我的规避经验

5.1 description 写得太宽泛,技能变成"万能工具"

这个问题最常见的表现是:技能描述写了"帮助用户处理各种文档",结果模型遇到任何文档类请求都优先选它,哪怕用户只是想找一个简单的文本替换工具。我早期在 OpenDeepWiki 里配过"文档工具"技能,description 里写了"处理 PDF、Word、Markdown 等各类文档",后来在日志里发现大量误路由,全部是模型把简单任务抛给了这个重技能。

规避办法是描述里加"非适用场景"。比如:

description: 抓取网页并归档为 wiki 页面。不适用于本地文件转换、PDF 解析或数据库查询。当用户只是询问网页内容摘要时,优先使用通用摘要能力。

这段"不适用于"的负向描述能显著降低误触发率。我做过一个小实验:在 100 条测试请求里,加了负向描述后,误触发从 14 次降到了 3 次。负向描述的成本几乎为零,收益却非常明显。

5.2 引用上下文无限膨胀:技能正文+脚本+参考文件全部塞给模型

有些技能的脚本很多,每个脚本还有各自的说明文档,如果全部塞进上下文,token 消耗会非常夸张。更麻烦的是,模型会因为上下文噪声太大而"迷失焦点",在工具调用时参考了不相关的脚本说明。

我的经验是:SKILL.md 正文只写执行逻辑和约束,脚本的详细参数说明按需获取。比如在正文里写"关于参数细节,请通过 read_tool_docs 读取 scripts/fetch_url.py 的头部注释",这样模型只在需要时才加载脚本说明。这个方法虽然多了一次工具调用,但整体 token 消耗往往更低,因为不是每次任务都要读所有脚本文档。

5.3 工具白名单设得太宽,模型开始"自己发明需求"

有一段时间我在配置里给某个技能开放了所有文件读写权限,结果在测试中,模型为了完成一个简单的文本摘要任务,居然先读取了一大堆与任务无关的 wiki 页面,又创建了三个临时文件。这不是模型"不聪明",而是工具的搜索空间太大,模型倾向于尝试多样化路径。

规避办法是基于功能最小化原则配置白名单:一次工具调用只暴露完成任务所必需的 2 到 3 个工具。如果需要更多工具,要么拆分技能,要么在流程中分阶段暴露。我在 OpenDeepWiki 里把"网页抓取"和"内容清洗"拆成了两个技能,前者只管抓,后者只管洗,中间通过一个临时文件传递数据。这样每个技能的上下文干净了很多,一次任务耗时反而更短。

5.4 循环上限设置不合理,长任务被粗暴截断

有次我处理一个特别大的 PDF,整整有三百多页。技能跑完前 7 次工具调用还没完成,第 8 次被循环上限截断,生成的 wiki 页面只有前半部分。用户收到页面时没有看到任何错误提示,因为模型在最终回复里只写了"已完成"。

这就是隐式失败。解决方法是三管齐下:第一,max_iterations增大到 12 到 15,但要配合超时;第二,在 SKILL.md 正文里要求模型在长任务中分阶段汇报进度,比如"已完成前 50 页转换,继续剩余部分";第三,在最终回复格式里增加一个字段completion_status,可以取值completed或partial,这样即使被截断,用户至少能看出内容不完整。这个字段对后续人工接管也很有价值。

5.5 技能之间出现隐性依赖,改一个技能连带炸另一个

OpenDeepWiki 的技能设计强调自包含,但实际中它们不可能完全独立。比如"网页抓取"技能产出的临时文件放在data/tmp/,"内容清洗"技能也读取这个目录,两件事本身没问题,但如果哪天我改了临时文件命名规则,就会导致第二个技能悄悄失败。

我的规避习惯是:跨技能共享的任何资源,都在各自 SKILL.md 里显式声明,并加一个独立的标记文件。比如在data/tmp/下写一个.metadata.json,记录生成时间、源技能、文件格式要求。下游技能读取时先校验这个 metadata,发现格式不匹配就直接报错,而不是硬着头皮解析。这样的设计让技能的失败模式从"悄悄出错"变成"显式报错",排查时间能省一大半。

6. 一些我自己觉得值得收藏的实用经验

很多人把 Agent Skills 当成一个"配置技巧",其实它是一个系统工程。在 OpenDeepWiki 里实践下来,我总结出几个值得长期坚持的习惯:

第一条,每个技能都要有自己的回归测试。我会为重要技能写一组模拟请求,跑完对比输出结果和 expected 文件。这样每次改动SKILL.md或脚本之后,都能快速确认没有破坏已有能力。测试框架不复杂,用 pytest 加几个 fixture 就行,重点是把技能上下文注入和工具调用 mock 掉,只测逻辑部分。

第二条,日志里一定要能追踪每次工具调用的链路 ID。OpenDeepWiki 的日志如果只有"角色+消息",排错会非常痛苦。我习惯把一次完整任务分配一个trace_id,从用户请求、技能选择、每次工具调用到最终写入,全部打上同一个 ID。这样无论哪一环出问题,都能在日志里拉出一整条链路来复盘。

第三条,定期检查技能命中率分布。我每个月会拉一次日志,统计哪些技能被频繁触发、哪些技能从创建后就没被用过。那些长期没被触发的技能,很可能存在两个问题:描述里包含了过时的场景,或者它的功能已经被其他技能覆盖。不管哪种情况,都应该尽快调整,而不是任它占着资源。

关于"这个机制能走多远"我想多说一句。未来 OpenDeepWiki 这类系统大概率会支持更复杂的技能编排——比如技能 A 执行完自动触发技能 B,形成流水线。这很诱人,但前提是单技能的可靠性先达标。如果每个技能单独执行时都有 90% 的正确率,串联三个技能后整体正确率就只有 72.9%,这是很吓人的衰减速度。所以我的建议是:先把单技能正确率做到 98% 以上,再考虑编排。我自己在 OpenDeepWiki 中的优化重心也一直放在单技能内:让描述更精准、脚本更健壮、错误反馈更清晰。

最后分享一个小技巧。SKILL.md正文不只是给模型看的,OpenDeepWiki 的界面会把它渲染成可读的文档,用户也能看到。这意味着你可以让技能文档一物两用:既充当模型的操作指南,又充当用户的"这是什么、能做什么、限制是什么"的说明页。所以写作时尽量用简洁的标题、明确的步骤和必要的代码示例,不要写那种只有机器才能读懂的字段堆砌。毕竟,一个连同事都看不懂的技能定义,模型大概率也执行不好。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进