资讯动态

Agent Skills 技能体系设计实战:从零构建可维护、可测试的智能体

发布时间:2026/10/14 9:37:52 来源:尧图企业网站定制
最近几周我一直在折腾一个叫agent-skills的项目准确说不是某个开源仓库而是我自己的一套 Agent 技能体系建设思路。折腾完之后最大的感受是很多人把 Agent 开发想得太玄其实真正决定它好用不好用的往往不是底层大模型有多强而是你给它的“技能”有没有设计好。早先我写 Agent 的时候习惯把所有指令、工具、流程全塞进一段 prompt 里结果只有两种要么它只会做我给你表演过的那几个动作换个场景就废要么它自由发挥跑偏了你还不知道在哪偏的。后来我把整条链路上能拆出来的能力全部“技能化”——也就是封装成一个个带描述、带输入输出约定、带容错策略的独立模块Agent 只负责调度技能只负责执行。这套重构之后项目的可维护性、可测试性、可扩展性提升了一个量级。这篇文我打算把从零设计一套 agent-skills 体系的完整过程写下来。包括 Skills 到底是什么、接口怎么定、文件怎么组织、代码怎么落地还有我在实际项目中踩过的坑和排查方法。适合正在做 Agent 应用开发、想把自己那堆“函数 prompt”升级成正式技能体系的开发者参考。如果你只是写 demo 玩可能体会不深但一旦你要维护一个复杂的多智能体系统这套东西迟早用得上。1. Skills 到底在解决什么问题1.1 一个 Skill 的本质先说我的理解Skill 不是“把功能函数再包一层”的概念而是把一个 Agent 应该具备的能力封装成一个自包含的执行单元。这个单元里至少要有四样东西元信息这个技能是干什么的、什么时候该用、什么时候不该用。入口约定接收什么参数、输出什么结构。执行逻辑具体怎么做可能调一个外部 API也可能组合多个步骤。异常与边界处理失败了怎么办、超时怎么办、数据格式不对怎么办。打个比方如果把 Agent 比作一个餐厅后厨那 Skills 就是后厨里一个个熟练掌握的菜品技能。厨师Agent不需要知道“糖醋排骨”的每一克调料的配比细节它只需要知道这道菜的名字、口感方向、大概需要哪些备料然后喊一声“做糖醋排骨”后厨就把活儿干完了。干完端上桌还能告诉你“今天的排骨偏瘦建议下次换五花”。这个类比里最关键的一点是Agent 不关心技能的内部实现但它必须能从外部准确判断该不该点这个技能。这就决定了 Skills 体系设计的核心工作其实都集中在“元信息”和“接口约定”上而不是内部逻辑。1.2 为什么碎片化能力会让 Agent 变“傻”我见过很多半成品项目代码结构大概是这样的一个大模型调接口、一个 2000 行的工具函数文件、一个写满规则和例子的 prompt。这种“三座大山”架构在 demo 阶段完全没问题但一旦任务变复杂问题立刻暴露。第一个问题是Agent 不知道自己“会什么”。你把一堆函数堆给它它只知道函数名和短描述遇到一个需要综合判断的任务它根本不知道应该把哪几个能力组合起来。比如你要做“把上周的所有销售周报整理成一份分析 PPT”这背后可能需要数据查询、摘要提取、图表生成、幻灯片排版四个能力。如果你没有把这些能力显式地声明成“技能”大模型大概率只会用第一直觉选其中一个函数然后硬编出一份非常粗糙的结果。第二个问题是技能内部一多prompt 上下文就炸。我在一个项目里曾经把所有工具的说明全塞进 system prompt当时有 40 多个工具描述加起来超过 8000 token。结果发现模型越来越“犹豫”每个工具它都看过但每个都不敢用更可怕的是核心任务的上下文被压缩了。把技能独立出来后prompt 里只有当前任务需要的技能描述上下文干净很多决策速度也上来了。第三个问题也是要命的无法测试、无法排查。所有逻辑都写在 prompt 里的时候你根本没法给单步逻辑写单测。我把技能抽出来之后每个技能都可以单独跑、单独测、单独加日志哪里挂了查哪里。这一步改造带来的收益很快就在一次第三方 API 连续报错的问题排查中体现出来——我只看技能执行日志就定位到了是哪一步的参数填充错了而不是对着大模型的返回内容猜。1.3 Skills 与工具集、插件、Workflow 的区别这里想特意说一下几个概念的边界因为很多人在设计时把混在一起。概念侧重点例子工具Tool原子能力无状态不关心调用时机发送 HTTP 请求、读文件、计算 MD5技能Skill原子能力 意图判断 执行流程 容错自带边界从网页抽取正文并生成摘要、根据数据自动画图表工作流Workflow多个技能按固定顺序编排有向无环图先查数据再生成图表最后套 PPT 模板插件Plugin通常是第三方生态里的功能包粒度可大可小一个能接入某电商平台数据的能力包在 agent-skills 的体系里我认为 Skill 是承上启下的核心单元它向上可以被 Workflow 编排向下可以调用多个 Tool。我甚至会把一个复杂 Skill 的内部实现再拆成几个内部子技能这样每个子技能都有独立的描述和失败策略组合起来却像一次“业务操作”。2. 设计一套 Skills 体系地基怎么打2.1 目录结构与文件组织设计 Skills 体系第一件事就是确定代码组织方式。我这里分享一个在模拟项目 X 里反复调整后稳定的目录结构skills/ common/ toolkit_http/ skill.yaml __init__.py text_utils/ skill.yaml __init__.py content/ webpage_summarizer/ skill.yaml __init__.py requirements.txt document_parser/ skill.yaml __init__.py report_generator/ skill.yaml __init__.py data/ sql_queryer/ skill.yaml __init__.py chart_renderer/ skill.yaml __init__.py每个技能目录里skill.yaml是元信息文件__init__.py暴露执行入口requirements.txt声明依赖。目录分类按照能力域分common放通用能力content放内容处理data放数据能力。这样 Agent 在做意图路由的时候可以只浏览分类目录缩小技能匹配范围而不是在所有技能里全量搜索。这套结构看起来简单但它的价值是把“技能元数据”和“技能实现”彻底解耦。Agent 加载技能时先读skill.yaml通过元信息判断该不该用真要调用时才去 import 具体实现。这样加载阶段非常轻量几十个技能的扫描时间基本可以忽略。2.2 接口规范输入输出、异常处理技能接口是整个体系里最容易改来改去、也是越改越乱的部分。我的经验是尽早定死情愿一开始多思考也绝不在写了 20 个技能之后再统一重构。接口规范分两层第一层是技能的入口定义。标准做法是每个技能暴露一个统一方法比如def execute(context: SkillContext, params: dict) - SkillResult: ...context里带着共享状态、调用链信息、全局配置。params是本次调用的具体参数。SkillResult是标准化返回至少包含 status成功/失败/部分成功、data、message、trace_id、消耗指标。第二层是参数定义。我不建议只靠params字典传参会导致技能内部到处params.get(xxx, default)丑且难维护。更好的做法是让每个 skill 声明自己的输入参数结构然后在执行入口做一次参数校验校验失败直接返回结构化的错误而不是让技能内部代码去处理脏数据。关于异常处理我习惯强制每个技能捕获所有可能的外部异常并返回统一的SkillResult(statusfailed, message...)同时把异常堆栈写入日志。为什么这么做因为在 Agent 链路上技能失败是常态Agent 需要根据失败原因决定重试、换策略还是直接放弃。如果技能抛一个没有任何结构化信息的异常Agent 只能一脸茫然地瞎猜容易原地打转。2.3 元数据声明Agent 靠什么“看见”技能skill.yaml是技能的门面Agent 对大模型侧的“技能可见性”完全依赖它。一个合格的元信息文件至少应该包含这些字段name技能名全局唯一尽量用全局视野来命名别太口语化。description一段面向大模型的自然语言描述回答“这个技能做什么、什么场景用、什么场景禁用”。parameters入参结构包含每个参数的类型、必填性、说明。tags分类标签辅助路由。timeout执行超时时间。permissions权限声明比如是否需要网络、是否需要文件写入。allowed_tools技能内部允许调用的工具白名单。我当时在description上踩过坑早期写得特别简短比如“生成图片的 URL”模型经常在不需要图片的时候调用它。后来我按“正向 反向 边界”三个维度重写描述先说适合什么再说不适合什么最后说必须满足什么条件才可以用。例如description: | 用于从远程 URL 抓取网页正文并生成中文摘要。 适用场景用户提供链接要求总结文章内容。 不适用场景用户没有给链接、需要搜索资料、或要求英文摘要。 边界条件仅支持静态 HTML 页面需要联网抓取超时上限 5 秒。这样模型判断时就会非常明确误调用率下降特别明显。2.4 命名规范与分类策略命名这件事看起来是个小事但实际影响很大。因为技能名会出现两种地方一是代码 import 路径二是大模型在“思考过程”里看到的文本。命名一旦不规范模型就经常出现幻觉技能名——它可能参考几个相似技能的名字组合出一个根本不存在的新名字。我的命名规范是三元组域_动作_对象。比如data_query_sales_orders数据域查询销售订单。content_summarize_webpage内容域摘要网页。doc_generate_ppt_from_markdown文档域从 Markdown 生成 PPT。这个命名风格的好处是天然支持“按前缀做意图聚类”。我给 Agent 的调度层写过一个匹配逻辑先从用户指令里抽出的关键词映射到域再映射到动作最后锁定对象这样匹配速度比全量语义检索快得多而且可解释。分类策略上我建议分类不要超过两层。我见过有项目把技能分成“数据处理-文本处理-中文文本处理-繁体中文处理”这种四级目录结果模型根本分不清该用哪一级调度准确率反而不如平铺结构。保持目录浅、描述精准比追求分类严谨更实际。2.5 版本与依赖管理技能一定会迭代。今天一个技能还只支持表格数据明天你给它加了图表能力如果版本管理不好就会出大乱子——工作流里调的是旧版本测试环境调的是新版本生产环境是含 bug 的上一版本。我目前在项目里采用两套机制技能级版本号每次修改skill.yaml或实现文件就更新version字段。调度层在日志里记录“哪个版本被执行”线上问题一复查就知道是哪个版本引起的。依赖锁定每个技能目录里的requirements.txt明确锁版本。特别注意技能之间也可能互相依赖这种依赖关系我会在skill.yaml里用depends_on声明。加载器会做依赖拓扑排序先加载依赖技能再加载当前技能避免import时缺东西。3. 从零实现一个 Skill实操全流程3.1 场景规划先画边界再写代码直接撸代码是大忌。我现在的流程是先选择一个有语义闭环的场景给这个技能画边界。我用“网页内容归纳”作为示例场景。需求是用户丢一个 URLAgent 抓取页面、去噪、提取正文、生成 200 字以内的中文摘要并返回来源链接。边界画下来是这样输入url、目标语言默认中文、最大摘要字数默认 200。输出结构化结果包含 title、content_preview、summary、source_url、fetch_status。不做的事不做情绪分析、不做关键词抽取、不处理需要登录的页面。失败条件URL 非法、页面非 HTML、抓取超时、正文为空。这些边界定义完整后我才开始建目录、写skill.yaml。3.2 核心代码落地一个内容归纳技能目录建好后实现核心逻辑。这里我贴一个简化版本重点不是实现本身而是展示“技能内部按层级组织”的思路# skills/content/webpage_summarizer/__init__.py import time from html.parser import HTMLParser from urllib.request import Request, urlopen class SkillError(Exception): pass def _validate(params: dict) - dict: url params.get(url, ) if not url.startswith((http://, https://)): raise SkillError(finvalid url: {url}) lang params.get(lang, zh) max_len int(params.get(max_len, 200)) return {url: url, lang: lang, max_len: max_len} def _fetch_html(url: str) - str: request Request(url, headers{User-Agent: agent-skills/1.0}) with urlopen(request, timeout5) as response: content_type response.headers.get(Content-Type, ) if text/html not in content_type: raise SkillError(fnot a html page: {content_type}) return response.read().decode(utf-8, errorsignore) class _TextExtractor(HTMLParser): def __init__(self): super().__init__() self._text [] self._skip 0 def handle_starttag(self, tag, attrs): if tag in (script, style): self._skip 1 def handle_endtag(self, tag): if tag in (script, style) and self._skip 0: self._skip - 1 def handle_data(self, data): if self._skip 0: self._text.append(data.strip()) def get_text(self): return \n.join(t for t in self._text if t) def _summarize(text: str, lang: str, max_len: int) - str: # 实际项目里这里会调用大模型做摘要这里简化为截取前 max_len 字符 if lang zh: return text[:max_len] return text[:max_len] def execute(context, params: dict) - dict: start time.time() try: clean _validate(params) html _fetch_html(clean[url]) parser _TextExtractor() parser.feed(html) text parser.get_text() if not text: raise SkillError(empty page text) summary _summarize(text, clean[lang], clean[max_len]) return { status: success, data: { title: parser.get_title() or , summary: summary, source_url: clean[url], fetch_ms: int((time.time() - start) * 1000), }, message: , trace_id: context.get(trace_id, ), } except SkillError as exc: return { status: failed, data: {}, message: str(exc), trace_id: context.get(trace_id, ), }这个实现里值得注意的几点_validate是独立函数所有参数在入口就洗干净后面代码不需要再半路判断参数。_fetch_html带了超时和 content-type 检查这两个检查是我在实际项目里被坑之后加的——很多 URL 返回的是重定向页或纯 JSON不带检查的话技能会拿到一堆垃圾内容。异常处理在技能内部完成绝对不裸抛异常给上层调度器。上层只需要看status success还是failed不用关心技能内部怎么失败的。3.3 注册与动态加载机制光有实现还不够关键是要让 Agent 能“看到”并“加载”这些技能。我实现过一个很轻量的加载器思路是启动时扫描skills/目录读取所有skill.yaml构建一个技能索引字典。只有等到实际调用时才去importlib动态导入对应技能模块。每次导入之后把模块缓存起来避免重复导入的耗时和内存开销。加载器的核心逻辑大致是这样import importlib import yaml from pathlib import Path class SkillRegistry: def __init__(self, root: str): self.root Path(root) self.index {} self._modules {} def scan(self): for skill_file in self.root.rglob(skill.yaml): with open(skill_file, r, encodingutf-8) as fp: meta yaml.safe_load(fp) skill_name meta[name] pkg_path skill_file.parent.as_posix().replace(/, .) self.index[skill_name] { meta: meta, module: f{pkg_path}.__init__, } def get(self, skill_name: str): if skill_name not in self._modules: entry self.index.get(skill_name) if not entry: raise KeyError(skill_name) self._modules[skill_name] importlib.import_module(entry[module]) return self._modules[skill_name]动态加载的意义不只是省资源更重要的是允许热更新。改了一个技能的实现只要重启加载器扫描一遍不需要把整个 Agent 进程重启。我自己在迭代的时候经常用这个机制改完一个技能跑一下单测马上就能在联调环境里看到效果效率非常高。3.4 测试与调试技巧每个技能都要有独立的测试入口。我强烈建议就算项目再急也要给每个技能写至少 3 条用例正常路径、边界参数、异常路径。拿网页摘要这个技能来说测试用例大概是def test_summarize_success(): result skill.execute({trace_id: t1}, {url: https://example.com}) assert result[status] success assert result[data][summary] def test_summarize_invalid_url(): result skill.execute({trace_id: t2}, {url: ftp://invalid}) assert result[status] failed assert invalid url in result[message] def test_summarize_empty_page(): result skill.execute({trace_id: t3}, {url: https://example.com/empty}) assert result[status] failed assert empty page in result[message]调试时有一个小技巧在技能执行日志里加上trace_id把“技能入参、返回结果、耗时”三样东西打出来。这个 trace_id 从 Agent 任务开始就一路透传排查问题的时候拿着一个任务 ID能串出整条技能调用链。这一步基础设施看着不起眼但等技能数量过二十个、任务链路超过五跳的时候没有它就等于摸黑干活。4. 常见问题与排查技巧实录4.1 技能“抢活”导致任务漂移实际运营过一个读数据、画图的 Agent 以后你会发现一个很魔幻的情况用户说“统计这个月的销量趋势”本来应该走data_query_sales_orderschart_renderer两个技能结果模型突然调用了content_summarize_webpage然后开始胡编一个网页摘要。这个问题的根因一般是两个技能的 description 边界重叠比如data_query_sales_orders里写了“支持将结果生成表格”chart_renderer里也写了“可以根据数据生成表格”模型就迷糊了。我的排查方法把技能索引里的 description 全部导出人工按场景分组看哪几个技能对同一个场景都有“适得其反”的描述然后做三件事——收敛一个主入口、把其他技能描述改成“仅限 xxx 场景”、在元数据里加上conflict_with字段主动提示调度层。用这个方法我把某次调度准确率从 76% 拉到了 91%代价只是改了十几个描述文本。4.2 上下文爆炸与技能描述侵占Agent 会把“当前有哪些可用技能”的描述都攒在上下文里技能一多起来光描述就有几千 token。更糟的是有些技能内部还要把大段工具文档也塞进上下文结果用户的实际数据反而没地方放了。我在这块的经验是三层隔离全局只放索引prompt 里只放技能名 一句话描述完整 description 在意图路由命中后再注入。子技能描述按需注入当一个技能内部需要模型判断调用哪个子技能时才加载子技能的 description。硬性上下文预算给每个技能调用后的返回数据设 token 上限超出就截断或者落盘引用。这套机制做完之后任务上下文稳定在 4000 token 以内大模型的指令遵循度明显回升。4.3 外部工具调用失败与重试策略技能内部去调第三方接口失败是常态。常见的有超时、限流、接口返回格式变更、认证过期。我整理了一套重试规则直接写在技能内部网络超时类错误做 2 次重试退避时间 0.5 秒 1 秒。限流类错误HTTP 429退避 5 秒后重试 1 次仍失败就放弃并返回限流提示。接口格式错误的不重试直接失败。因为重试同样会失败浪费时间和 token。认证过期顺手刷新 token然后重试一次这是很多项目最容易漏掉的一类场景。这里有个很容易踩的坑重试会把不太稳定的外部接口成功概率提高但也导致了“技能整体耗时翻倍”。所以重试必须同时携带一个总超时上限比如整个技能最多 15 秒超过后无论重试多少次都直接返回失败。否则一个宕机的第三方接口能把整个 Agent 任务拖死这是线上事故级别的坑。4.4 安全边界与权限控制技能体系一旦搭起来安全隐患会被放大。因为每个技能都相当于一个可以被模型按需调用的“遥控器”不加权限控制等于把大门钥匙交给了模型。我的安全控制经验是每个技能在skill.yaml里声明所需权限比如network: yes/low_timeout、filesystem: read_tmp_only、exec: no。权限声明不是摆设调度层会做一次校验。技能要请求网络但权限声明里没有直接拒绝调用并记录异常。涉及文件读取的技能统一强制让路径落在沙箱目录里面。我在某次排查中发现一个“读取文档预览”的技能竟然能接受任意文件路径当时吓出一身冷汗马上加了路径白名单校验。对于技能内部会执行代码的场景比如数据科学类技能额外加一层执行环境隔离不让它在 Agent 主进程内运行。4.5 冷启动与性能优化技能多了之后冷启动扫描也会成为问题。虽然我只在启动时读 YAML、不 import 模块但如果技能目录在网络盘上I/O 也会拖累启动速度。我的优化方法有三个扫描结果缓存为一个索引文件只有skill.yaml的 mtime 变化时才重建。技能实现模块使用懒加载绝不冷启动全量 import。高频技能可以预热在启动脚本里主动 import 前 5 个最常用的技能避免任务首次调用时等待 import 时间。我实测过一个小项目30 个技能的冷启动扫描时间从接近 200ms 降到了 20ms 左右代价就是一份 30 行的索引缓存文件。5. 进阶多技能协同与编排5.1 三种协同模式串行、并行、条件路由单一技能再完美也只是积木。真正体现 agent-skills 设计水平的是技能之间的协同方式。我做过的项目里最常见的是三种模式串行流水线A 技能的输出作为 B 技能的输入。比如document_parser解析出 PDF 文本再交给content_summarize_text生成摘要。这种模式的坑在于上游给下游传参时字段名和结构一定要配对否则下游容易产生类型错乱。并行分治一个任务拆成多个互不依赖的子任务同时跑多个技能。比如分析一份年报可以同时让三个技能分别处理财务数据、管理层讨论、风险提示。并行时要特别注意共享资源的写冲突我给每个技能分配独立的结果缓冲区最后统一聚合。条件路由根据第一步的结果决定下一步走哪个分支。这一步最好别让模型自由发挥而是用一个明确的规则函数去路由。比如摘要技能返回status failed且原因是“页面非 HTML”那下一个技能应该走search_webpage_text而不是再重试一次。5.2 共享状态与跨技能记忆很多需要多技能协作的任务中间状态必须能被各个技能看到。比如 Agent 要先确认用户身份后续所有技能都需要带上用户 ID。这个问题看起来简单但实现时容易变脏。我采用的方式是维护一个SkillContext对象里面是一个可读写的字典每个技能执行时拿到同一个引用。某技能往context.state[user_id]写值后续技能直接从里面读。注意这里面有两条纪律只能写自己负责的关键字段。技能 A 不能顺手改掉技能 B 写的字段。大对象不要存 context存引用或者落盘路径。我没少吃这个亏有次在 context 里塞了个 20MB 的 DataFrame后面每个技能都慢半拍排查了很久才发现是共享状态导致的内存瓶颈。5.3 可观测性让技能调用链路可追踪技能数量上来后最让我头疼的其实是“整体不可见”。一个任务在多个技能之间跳来跳去出问题了很难定位。后来我给每个技能的执行统一加了三条链路信息trace_id任务级唯一 ID贯穿所有技能调用。span_id技能级唯一 ID标识一次具体执行。parent_span_id记录调用来源用于构建调用树。这组信息打到结构化日志里再配合简单的聚合查询就能还原出“哪一次任务、按什么顺序、调了哪些技能、各自耗时多少、哪个环节失败”。有一次线上问题排查我发现一个任务流程明明只需要 4 个技能实际却调了 8 次原来是有个技能在失败后被模型反复重试了 4 次。看到调用树之后我马上去给那个技能的重试策略加了总次数上限问题当场解决。最后再分享几个我踩过的坑技能体系越用越顺的时候最容易掉以轻心的地方反而是最初级的依赖管理。我的某个技能从“只做摘要”升级成“摘要 主题分类”时直接改了skill.yaml里的 description但却忘了更新示例输出结果模型照着旧的示例结构解析新输出总是解析失败。自那以后我固定了一条铁律改技能必改三件套——元信息、示例输出、测试用例三者必须同步提交。还有一个建议给刚上手的朋友不要一开始就追求技能数量多。先把 5 个核心场景打磨成 5 个高质量技能跑通“技能索引 - 意图路由 - 技能执行 - 结果回填”的完整闭环再慢慢扩。技能化这件事质量和边界远比数量重要。我见过太多项目是技能写了几十个真正被模型稳定调用的只有五六个其余全是吞 token 的摆设。这套 agent-skills 的思路我还会继续迭代。下一步我打算把技能的“自评估”机制加进去——让每个技能执行完顺手给自己打个置信度分这样调度层就能对低置信度的结果做二次校验。开发过程中如果再有值得分享的进展我再回来补充。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑