ARTICLE DETAIL

资讯详情

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

Agent Skills技能封装实战:从设计原理到代码实现

Agent Skills技能封装实战:从设计原理到代码实现 1. agent-skills 到底在解决什么问题我在过去几个月里密集折腾过 agent-skills 这个方向从最初只给智能体写一段提示词就跑到后来把技能做成可注册、可复用、可组合的模块整个过程的体验差距非常明显。说白了agent-skills 要解决的并不是让模型更聪明而是让模型更听话、更能干活——把一次性的对话式 AI变成一套真正能接进业务流程的执行系统。先解释一个容易混淆的点skills 不是插件也不是单纯的工具函数。插件通常是一段固定逻辑工具函数关注的是能被调用而一个 skill 的完整定义是一组自然语言指令 必要的代码/配置 明确的输入输出约定 触发条件告诉智能体在什么场景下、用什么步骤、产出什么结果。你可以把它理解成给实习生写的一份作业手册不是告诉他你会做报表而是告诉他报表要按这个模板、数据从这几个表取、异常情况这样处理、交稿前先自查三遍。这个方向最近热度很高核心原因是当一个基于大语言模型的应用从 Demo 走向真实业务时模型的随机性、窗口限制、知识截止日期会轮番折磨你。而技能化封装是目前被验证过最稳的一层缓冲。我们团队在客服助手、数据分析、内部知识库三个场景里实际跑了 agent-skills 的方案效果比纯提示词工程稳定得多——技能命中率、执行正确率、后期维护成本这三个指标都明显改善。说得直白一点如果你只是拿智能体聊天、写文案那 skills 对你可能有点杀鸡用牛刀但如果你要让它去操作浏览器、处理报表、调用内部 API、按流程审批或者做任何一件错了要担责任的事那就必须技能化。这篇文章我会从设计原理讲到代码实现再讲我踩过的坑最后给出一套可以直接抄作业的技能定义模板。适合看这篇文章的人正在做 Agent 应用开发的工程师、想给团队落地智能体工作流的负责人以及刚入门 LLM 应用开发、想知道 function calling 之外还有什么玩法的小伙伴。我会尽量避开空泛的架构图直接讲怎么落地。2. Skill 的结构设计与核心原理2.1 SKILL.md 与技能描述为什么自然语言比代码更重要一个 skill 最容易被低估的部分是它开头的自然语言描述。很多人写技能时喜欢一上来就写实现代码这是最大的误区。模型决定现在该不该用这个技能、怎么用靠的并不是代码逻辑而是你写在 SKILL.md 里的那几段话。我见过一份写得非常糟糕的技能描述原文大意是这是一个网页抓取工具输入 URL 输出 HTML。听起来没什么问题对吧实际跑起来模型根本不会主动调用它。原因就在于模型无法从这种描述里判断什么情况下应该用这个技能。它不知道网页抓取的典型场景是用户让我查一下某个网站的价格/新闻/公告也不知道已经拿到文本之后就不需要再调用。于是真正需要抓取时它忘了用不需要时它反而频繁调用。我的经验是技能描述至少要包含四层信息技能定位一句话说清楚这个技能负责哪一类任务。触发条件什么场景下必须用什么场景下不要用。这一条是最容易被忽略的但恰恰是它决定了模型会不会乱用技能。输入输出格式不要写输入一个 URL要写第一个参数必须是完整的 http(s) 链接如果用户只提供了域名需要先拼接 https://。注意事项例如抓取前先确认站点是否允许爬取输出前去掉脚本标签如果页面是动态渲染的改用无头浏览器。我把这些内容放在 SKILL.md 的头部后面的代码只负责执行。当模型读到 SKILL.md 时它实际上是在做一次阅读理解——描述写得越结构化、越接近模型的表达习惯技能被正确触发的概率就越高。这个结论不是我猜的是我们用同一套技能改了三版描述之后对比出来的第一版触发准确率只有 51%第二版加了触发条件后到了 73%第三版把输入输出格式具象化之后稳定在 88% 左右。2.2 技能调用机制Function Calling 与提示注入两条路线怎么选agent-skills 在执行层面有两条主流路线一条是 Function Calling工具调用另一条是提示注入把技能内容拼进上下文。这两条路我都在生产环境里用过各有各的适用场景。Function Calling 的原理是模型在生成最终回复之前先输出一个结构化的调用意图包含函数名和参数。然后由你的代码去执行真实逻辑再把执行结果拼回给模型让模型基于结果继续生成。Claude 的 tool use、GPT 的 function calling、以及各大开源模型的 tool call 能力走的都是这条路。它的优势是精准模型已经明确表示我要调用这个函数参数也被严格约束了不容易跑偏。劣势是每多一把工具推理的 attention 负担就会增加而且如果工具太多模型选错工具的概率也会上升。这就是为什么很多人发现工具列表超过 10 个之后准确率开始下滑。提示注入则是把整个技能的指令和示例直接拼到系统提示词里。这种做法的上限取决于上下文窗口但好处是模型对技能的理解更通透——它见过技能里所有的细节而不只是函数名加一句描述。我们团队在实际项目中用的混合策略是高频且确定的功能走 Function Calling低频但复杂、需要模型理解的技能走提示注入对于一批和核心业务强相关的技能两者同时上——先靠提示注入让模型理解全局再靠 Function Calling 兜底执行。这里有一个非常关键的实现细节无论走哪条路线技能的执行结果要格式化得足够干净。模型最怕的不是没有结果而是结果里有大量无关信息。比如抓取网页时如果直接把整个 HTML 扔回去模型的注意力会被导航栏、脚本、样式表淹没。正确的做法是先本地做一轮清洗把正文、标题、关键字段抽出来用紧凑的文本结构返回。很多 agent-skills 项目做到最后花在结果清洗上的时间比写技能本身还多。2.3 参数设计与上下文窗口的权衡技能不是越多越好关于参数设计我的原则是少参数、短描述、分层注册。很多人写技能时恨不得把十几个可选项全部暴露出来结果模型每次调用都只填了最前面两个参数后面的全部留空。这不是模型笨是你给的认知负担太大了。一个设计良好的技能参数尽量控制在 3 到 5 个以内并且要给每个参数一个默认行为。举一个实际问题我有一个数据筛选技能一开始定义了 8 个参数包括数据源、过滤条件、排序规则、聚合方式、时间范围、输出格式、是否去重、异常处理方式。跑起来之后发现模型经常漏传参数或者把一个参数的值填到另一个参数里。后来我把它拆成两个技能——基础筛选只管过滤和排序聚合统计专门处理分组和汇总每个技能只有 3 个参数准确率一下就上来了。上下文窗口的问题同样要重视。每个技能的描述、示例代码、指令规则都会占用 token。如果你注册了 30 个技能哪怕每个技能平均只有 300 token单是一次技能清单就要吃掉近一万 token——这个成本在长对话场景里会被反复放大。所以我在生产环境里做了两件事第一按业务场景分组只加载当前场景相关的技能而不是全部加载第二技能描述严格限制在 600 token 以内能压缩的尽量压缩。实践经验是一个场景下挂载 5 到 8 个技能是体验比较好、准确率也比较高的区间超过这个数量收益就开始递减了。提示如果你发现某个技能长期没有被触发大概率不是模型的问题而是它的描述太泛了。给它补上必须用的场景举例准确率会明显提升。3. 从零构建一个 Skill目录规范、注册机制与依赖管理3.1 技能目录的结构规范我参考了不少开源项目的习惯做法最终沉淀了一套自己的目录规范。一个完整的 skill 通常包含以下几个部分skills/ └── web-fetch/ ├── SKILL.md # 技能的身份证模型主要读它 ├── run.py # 主执行脚本 ├── requirements.txt # Python 依赖 ├── examples/ │ ├── basic.json # 一个最简单的调用示例 │ └── edge-case.json └── assets/ # 静态资源、模板文件SKILL.md 的头部有一个 YAML front-matter用来声明元信息。这个设计思路跟很多文档生成工具类似好处是便于程序化加载和索引--- name: web-fetch description: 抓取指定网页的正文内容提取标题和正文文本用于市场调研、信息查询等场景。 when_to_use: 当用户需要查看某个网页的信息、文章、公告、价格时。 when_not_to_use: 当用户已经提供了一段完整的文字不需要再次获取网页内容时。 parameters: - name: url type: string required: true description: 需要抓取的完整网页地址必须以 http:// 或 https:// 开头。 - name: output_format type: string enum: [text, markdown, json] default: text description: 输出格式text 为纯文本markdown 保留基本排版json 输出结构化字段。 ---这个名字和 description 会被模型直接读到所以我反复强调description 里要包含当用户需要……时这样的触发语义而不是干巴巴地写这个函数做什么。这套写法参考了 Anthropic 关于 skill 设计的最佳实践实测效果显著。3.2 技能注册与加载做一个轻量注册器有了目录结构接下来需要一套注册机制。我见过不少人把技能列表直接硬编码在系统提示词里这种做法在技能数量少的时候可以但一旦技能栈膨胀维护成本就不可控了。我的做法是写一个简单的注册器扫描 skills 目录下所有子文件夹读取 SKILL.md 的元信息生成统一的技能清单。注册器的核心逻辑并不复杂关键是要处理好两个问题一是技能的排序二是重复名称的冲突检测。# registry.py import os import yaml from pathlib import Path SKILLS_ROOT Path(__file__).parent / skills class SkillRegistry: def __init__(self, root: Path SKILLS_ROOT): self.root root self.skills {} self._load() def _load(self): for skill_dir in self.root.iterdir(): if not skill_dir.is_dir(): continue manifest_path skill_dir / SKILL.md if not manifest_path.exists(): continue with open(manifest_path, r, encodingutf-8) as f: content f.read() # 简单的 front-matter 解析生产环境建议用 python-frontmatter 库 meta self._parse_front_matter(content) if meta.get(name) in self.skills: raise ValueError(fduplicated skill name: {meta[name]}) self.skills[meta[name]] { meta: meta, dir: skill_dir, content: content, } def _parse_front_matter(self, content: str): # 解析 YAML front-matter略 pass def list_skills(self): return [s[meta][name] for s in self.skills.values()] def get_skill(self, name: str): return self.skills.get(name) registry SkillRegistry()这个注册器还有一种很实用的进阶玩法根据用户输入的问题先用一个轻量模型或者基于关键字规则选出候选技能只把候选技能的完整描述加载进上下文。这样就不需要把全部技能都塞给主模型了。这一步看似简单但对成本和准确率的提升非常明显。3.3 依赖隔离每个 skill 单独一套环境技能多了之后一个很现实的问题是依赖冲突。技能 A 需要 requests 的某个版本技能 B 需要 httpx 的另一个版本如果全部装在同一个 Python 环境里迟早要炸。我在真实项目里就遇到过装了技能 B 之后技能 A 的请求突然开始超时排查了半天发现是 urllib3 版本被顶掉了。后来我采用的方案是每个技能使用独立的虚拟环境或者至少用容器去跑技能脚本。具体到代码层面主进程通过 subprocess 调用技能脚本而不是直接 import 技能模块。这样虽然会有一定的进程间通信开销但换来了极强的隔离性。一个折中方案是把技能按域分组比如网络抓取类共享一个环境数据分析类共享另一个环境。实际运维下来两到三个隔离环境足够覆盖大多数场景也不需要为每个技能都开一个虚拟环境那么重。3.4 技能描述的质量检查先用模板再凝练在我把这些经验整理成文档之后团队里新来的同事最常问的一个问题是我到底应该怎么写 skill 里的自然语言部分才能让模型正确触发我给的回答是先写啰嗦版再浓缩。不要一上来就追求精炼——精炼的前提是把所有该有的信息都覆盖完整如果你本身就写不全那精炼只是把残缺变得更好看而已。我总结了一套最少内容清单这个技能解决什么问题以及它在业务流程里的位置。什么信号出现时模型必须主动调用它。什么信号出现时模型不应该调用它。每个参数的合法取值以及不传时的默认行为。一个最小可跑通的输入/输出示例。输出结果的格式要求以及返回给模型前应该做的清理。写完这六项之后再根据自己的经验把冗余的修饰词删掉把不必要的铺垫砍掉。等到模型实测触发准确率上去了再考虑压缩 token 的问题。记住准确率永远优先于 token 成本在准确率还没达标时谈优化是本末倒置。注意不要为了节省 token 把技能描述写得太诗意。模型不是人类读者它需要的是结构清晰、语义明确的指令。那些看似高深的抽象描述只会让模型不知所措。4. 实战让 Agent 学会三个高频技能4.1 技能一网页正文提取这个技能几乎是每个 agent 应用都会用到的。我需要它做的是给定一个 URL抓取网页并提取正文返回干净的文本信息。以下是 run.py 的核心逻辑# run.py import requests from bs4 import BeautifulSoup import sys def fetch_text(url: str, output_format: str text) - dict: headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, } resp requests.get(url, headersheaders, timeout15) resp.raise_for_status() soup BeautifulSoup(resp.text, html.parser) # 移除无关标签 for tag in soup([script, style, nav, footer, iframe]): tag.decompose() title soup.title.get_text(stripTrue) if soup.title else main soup.find(article) or soup.find(main) or soup.body text main.get_text(separator\n, stripTrue) # 清理过长的空白行 lines [line for line in text.split(\n) if line.strip()] if output_format markdown: # 这里可以做简单的 html-markdown 转换略 pass if output_format json: return {title: title, text: \n.join(lines)} return f# {title}\n\n \n.join(lines) if __name__ __main__: url sys.argv[1] fmt sys.argv[2] if len(sys.argv) 2 else text print(fetch_text(url, fmt))这里有一个我踩过很深的坑直接抓取 HTML 的时候如果目标网站做了 UA 校验requests 默认的 UA 会被 403。所以上面代码里我显式添加了一个浏览器 UA 头。但是不要过度伪装成真实浏览器——有些站的反爬策略会检测 TLS 指纹requests 在 TLS 指纹层面跟真实浏览器还有差异这种站就别硬刚了换无头浏览器或者官方 API 更靠谱。执行完抓取之后我会明确要求技能在返回结果前做一次是否为空正文的检查。很多新闻站的正文是动态渲染的静态抓取只能拿到空壳。这时候应该返回一个标志位empty_page: true而不是强行输出一堆无意义内容。模型看到这个标记就知道需要换策略比如提醒用户该页面是动态加载的。4.2 技能二CSV 数据分析与摘要生成数据分析技能是另一个高频刚需。当用户丢给智能体一个 CSV 文件要求做统计、分组、计算时模型如果直接读原始数据几乎必然被绕晕。我的做法是先让一个子程序对 CSV 做预处理生成摘要信息再把这些摘要信息交给模型做进一步判断。# run.py import pandas as pd import sys def summarize_csv(path: str, max_rows: int 100) - str: df pd.read_csv(path) desc df.describe(includeall).to_string() head df.head(max_rows).to_string() shape f总共有 {df.shape[0]} 行{df.shape[1]} 列\n columns 列名: , .join(df.columns) return f{shape}{columns}\n\n数据统计摘要:\n{desc}\n\n前{max_rows}行数据:\n{head} if __name__ __main__: # 参数: csv路径, 最大预览行数 print(summarize_csv(sys.argv[1], int(sys.argv[2]) if len(sys.argv) 2 else 100))这个技能的价值在于它不让模型直接面对原始数据而是先把数据压缩成模型能快速理解的格式。要知道一个几百兆的 CSV 全量塞进上下文代价非常高但如果你只需要做统计判断df.describe()生成的摘要信息已经覆盖了绝大多数情况。我在写这个技能时踩过一个认知坑一开始我以为用户需要的是模型直接读取 CSV 并回答问题但实际跑下来发现当数据超过 20 行时模型对数据中具体数值的记忆就开始模糊了。后来我把技能拆成两步——先让技能脚本生成摘要模型基于摘要回答宏观问题如果涉及具体行级过滤则通过技能脚本执行查询后把结果返回。这套摘要 按需查询的组合非常稳定。4.3 技能三本地文件批量重命名这个技能看起来简单但非常考验参数设计。用户的需求往往是模糊的比如把文件夹里所有图片改成日期格式。模型必须把这种模糊需求翻译成一组明确的操作指令然后交给技能脚本执行。# run.py import os import re import sys def rename_files(folder: str, pattern: str, dry_run: bool True) - list: changes [] for name in os.listdir(folder): source os.path.join(folder, name) if os.path.isfile(source): # pattern 中的 {name} 表示原文件名{ext} 表示扩展名 new_name pattern.replace({name}, os.path.splitext(name)[0]) new_name new_name.replace({ext}, os.path.splitext(name)[1].lstrip(.)) changes.append((source, os.path.join(folder, new_name))) if dry_run: return changes # 只返回变更计划不执行 return execute_changes(changes)我把dry_run参数设计成默认开启。这意味着模型会先给用户展示一份将要执行的操作清单等用户确认后再实际执行。这个设计极其重要——因为批量重命名是不可逆操作一旦改错找回来非常痛苦。通过 dry-run 机制把执行和预览分离是我从一次数据损坏事故里学到的教训。那次有同事让智能体直接批量改名结果某个正则写错几百个文件全部乱了最后靠备份才恢复。这个案例想说明的是在设计 skill 时凡是涉及不可逆操作的能力都要默认走预览—确认—执行三步。即便投入更多交互成本也比出事故后的维护成本低得多。4.4 技能组合让 Agent 学会编排而不是从头执行单一技能解决单一问题组合技能才是 agent-skills 真正的价值所在。我的意思是当你有了网页抓取、数据清洗、JSON 格式化这几个技能之后一个更复杂的任务——比如抓取三个竞品网站的价格并整理成表格——就不再需要写新的技能而是让模型编排现有技能依次执行。组合能力的实现依赖两个前提一是每个技能的输入输出格式足够规范能够直接对接二是模型对技能的边界有清晰理解。我在 SKILL.md 里会明确标注每个技能的上游依赖和下游输出。比如网页抓取技能的说明里会写输出适合作为数据清洗技能的输入数据清洗技能里则写接受网页抓取技能或 CSV 读取技能的输出。这个做法的好处是技能的复用率大幅提升新增需求时往往只需要调整编排逻辑而不是写新代码。我在实际项目中有一次需求变更只改了一段编排配置就上线了新的处理流程整个开发时间从预估的两天缩短到了半天。这就是技能化的杠杆效应。5. 常见问题与排查技巧实录5.1 技能假死模型不调用技能怎么办现象你写得明明白白用户也明确提出了需求但智能体就是不用技能而是自己凭记忆回答。这种情况在技能上线初期出现频率很高。排查步骤我一般按顺序走第一步检查技能描述里有没有触发条件。如果没有先补上。比如当用户需要查询实时数据、网页信息、最新公告时必须调用 web-fetch 技能。第二步检查技能的 description 与用户问题之间的语义距离。模型判断是否调用工具靠的是语义相似度不是纯规则匹配。如果描述写得过于专业、术语化而用户问题很口语化命中率就会下降。我的办法是在描述里主动加上若干同义表达比如查一下搜一下看看这个网站写着……。第三步检查是不是工具列表太长导致混淆。真正生产环境里一次会话同时加载超过 10 个技能时模型对每个技能的注意力会被稀释。解决办法是按场景动态加载。第四步把技能的示例examples写得更具体。一个输入 URL 得到正文的示例远比一段复杂的解释更有说服力。还有一个容易被忽略的细节模型的 system prompt 中如果写了你是一个助手可以使用工具这个表述太弱了。应该写成你具备以下技能……当任务涉及……时必须调用对应技能。指令的强制语气差异在实测中会造成 10% 到 20% 的调用率差距。5.2 上下文被撑爆技能加载了但没生效现象技能注册成功、描述也正确但调用时感觉模型忘记了技能的存在。这个问题的常见原因是上下文窗口被无关内容占据了。举个例子一个长对话进行了 20 轮之后前面 15 轮的聊天记录可能已经占了几千 token。如果技能清单是拼在系统提示词里的而系统提示词又排在最前端模型仍然应该能读到——但实测中过长的历史对话会分散模型注意力让它对系统提示词末尾的技能列表视而不见。我的解决方案使用消息级剪枝策略把技能清单的完整信息放在最近一次用户消息之后的位置而不是固定放在 system prompt 里。具体做法是在每次请求前将技能描述注入到最后一条用户消息的上方确保它在注意力窗口中的距离更近。如果使用的框架不支持这种灵活的注入方式退而求其次的做法是在用户新消息前插入一条系统消息内容为用户刚刚提到了……请优先考虑使用以下技能……。5.3 技能执行结果不可信这里是重灾区。技能执行返回了一堆数据但这些数据可能是错的。模型并不知道对错它只会拿这些数据继续加工最终给用户的答案就是错的。我最常遇到的两类问题一是源数据本身就不对。网页抓取技能把验证码页面当成正常页面抓了回来解析出的正文是一堆验证提示。这种问题很难靠模型自查发现需要在技能脚本里加更多校验。比如抓取结果如果长度异常短比如少于 200 字符或者标题包含 access deniedcaptcha 等关键词就返回错误标志。我在技能脚本里写了一个validate_result()方法专门做这一步实测能拦截掉大约 70% 的无效结果。二是模型把旧数据当新数据用。数据分析技能在第一次请求时生成了摘要并缓存了结果第二次、第三次对话时模型可能还记得这些数字但用户已经换了新数据源。这是非常隐蔽的错误模型上下文里还残留着旧数据信息新技能的结果反而被它当成补充材料优先采信了更熟悉的旧数据。我的对策每次技能执行结果返回时除了结果内容本身还附带一个时间戳和数据源 MD5 值。模型在生成回复前先判断当前数据源与上次是否一致不一致则明确提示该数据为旧数据不可使用。虽然这给技能增加了一点复杂度但换来的是回答可信度的大幅提升。5.4 技能互相干扰技能多了以后脚本之间会互相影响。我在生产环境遇到过两个技能都用了同一个临时文件路径导致数据被覆盖。一个技能修改了环境变量影响到另一个技能运行。两个技能返回的 JSON 结构里有相同的字段名导致模型混淆。解决手段有两个。第一个是命名空间隔离每个技能的所有临时文件都放在自己目录下的 tmp 文件夹里环境变量前缀带上技能名。第二个是输出契约统一我在技能注册器里加了一个校验每次执行返回结果必须是固定结构比如{success: bool, data: ..., meta: {...}}。如果技能脚本返回结构不符合这个契注册器直接拒绝结果并返回错误提示。这套约束看起来简单但它让模型在多技能协同的场景下始终知道每个结果是谁给的、格式是什么。5.5 技巧清单你应该提前知道的五件事最后整理一份我在多次项目里沉淀下来的操作技巧这些在官方文档里通常找不到技能描述中使用必须而非可以。模型对语气强度很敏感弱的语气会让它在犹豫时选择不调用工具。给每个技能至少写两个示例一个标准场景一个边界场景。边界场景能教会模型这种情况虽然看起来像但不要用这个技能。在开发阶段给技能脚本加上完整日志。别嫌麻烦生产环境里的诡异问题九成要靠日志定位。版本管理技能脚本。一个技能改了参数之后之前的老调用方会受影响最好在 SKILL.md 里标注版本号并在注册器里做兼容。优先让技能抛出确定性错误不要让模型猜。比如抓取失败原因是 HTTP 403比无法获取网页内容有用得多——模型可以根据具体错误调整策略而不是只能含糊地向用户道歉。我在实际项目里的体会是agent-skills 的技术门槛并不高真正难的是那些看起来不起眼但决定成败的细节。技能描述的一个措辞、返回值的一个字段、加载时机的一次调整都可能让整体效果产生质变。如果你正准备在项目里引入技能化设计不用一上来就追求大而全先挑两三个高频场景做深做透把这一套流程跑通之后再横向扩展。另外想提醒一句每个技能都要像对待产品功能一样对待它的生命周期。上线、监控、迭代、下线一个都不能少。没有监控的技能就像没有仪表盘的飞机——飞得起来但你不知道什么时候会出事。我自己会把技能的调用次数、成功率、平均耗时全部记录下来每周复盘一次。某个技能连续两周调用率低于 1%就考虑合并或下线某个技能成功率跌破 80%就要重点排查而不是等用户投诉。这套玩法跑顺了之后你会发现 agent 开发的核心矛盾已经不再是模型不够聪明而是你有没有把场景拆成足够清晰的技能单元。拆得越细边界越明确模型的表现就越稳定。
返回列表