☰
Agent Skills实践:从工具调用混乱到可复用技能库
2026/10/21 10:02:47 网站建设 项目流程

这几年做AI应用,我最大的一个感受是:模型能力再强,不会用工具就是个摆设。所谓agent-skills,简单说就是给智能体准备的一套可复用、可组合、可观测的技能库,把网页检索、文件读写、数据清洗、定时提醒这些具体能力,做成一格一格标准接口,让Agent按需调用。这个项目是我在几个内部系统的智能化改造里反复打磨后沉淀下来的,目标是让技能的定义、注册、调度、调试有一套统一规范,而不是今天写一个函数、明天写一个脚本,最后全部堆在Prompt里没法维护。

如果你刚接触智能体开发,或者已经在做复杂工作流但总被“工具调用混乱”折磨,这篇文章应该能帮你省不少事。我会从技能抽象层的设计思路讲起,再到目录结构、核心代码、调度实现,最后整理我踩过的一些坑。整体偏实操,代码以Python为主,但核心思想不限制语言。

1. Agent Skills:把“会说话”变成“会干活”

1.1 为什么不能只靠Prompt

很多刚入门的朋友会以为,给大模型写一段详细的Prompt,它就能自己调用工具、完成复杂任务。现实中完全不是这么回事。模型确实能理解自然语言指令,但它不知道你系统里有哪些可用能力,更不知道每个能力需要什么参数、返回什么结构。如果你把工具函数一个个写在Prompt里,几轮对话后上下文就爆炸,而且模型很容易在工具选择上“想当然”。

我以前做过一个数据报表项目,当时把所有查询逻辑直接塞进系统提示词,让模型根据用户提问“猜测”应该执行什么SQL。结果就是:用户问“上个月华东区销售额”,模型给出的SQL字段名是错的,还一本正经地返回一个编造的数字。后来我意识到,模型需要的不只是“能力描述”,而是一套严格、可被调度的技能注册表。agent-skills这个项目就是在那个阶段开始做的。

所谓技能,不是“模型会什么”,而是“系统能做什么”。模型只负责理解用户意图、从技能列表里选一个合适的技能、按照技能的参数规范生成调用参数。真正执行动作的是技能背后的代码。这样职责就分开了:模型负责“决策”,代码负责“执行”。

1.2 从写死工具到技能库的三层演进

我把工具集成的演进分成三个阶段,基本对应大多数团队的成长路径。

第一阶段是“写死函数”。每个业务场景单独写一个函数,在代码里硬编译if-else,通过路由调用。优点是简单直接,缺点是每加一个能力就要改主流程,函数一多,Agent根本不知道该调哪个。

第二阶段是“工具列表”。把函数名、参数说明拼成JSON数组塞给模型。这种方式比写死函数灵活,但有两个问题:一是工具的语义描述全靠手写,质量参差不齐;二是没有统一返回结构,模型拿到结果后还得再做一层“阅读理解”。

第三阶段就是做Agent Skills。每个技能自带元信息、输入输出Schema、内部实现、状态管理,甚至还有日志和缓存。Agent从注册中心发现技能,而不是从Prompt里“碰运气”。这就像把一堆散落在工位上的螺丝刀、扳手、电钻,统一挂到一面工具墙上,并且每把工具都贴了标签:能做什么、怎么用、拧多大扭矩。模型只需要看着标签选工具。

1.3 Agent Skills的核心组成

从实现角度看,一个完整技能模块通常包含六个部分:

组成作用典型字段
技能描述让Agent理解该技能适合什么场景name, description, usage_scope
参数Schema声明调用参数的类型、必填项、取值范围JSON Schema
执行函数真正干活的部分run()
返回结构统一输出,便于Agent解析status, data, error
副作用标识标记是否写操作,是否可能耗时idempotent, timeout
元信息版本、作者、依赖、权限等version, tags

一个技能如果只写了执行函数,那它只能算工具;只有把上面六部分全部规范起来,才能叫技能。这也是agent-skills项目第一个版本花最多时间的地方:定义清楚“一个合格的技能长什么样”。没有这个抽象,后面做注册、调度、可观测性都是空话。

2. 设计一份可复用的技能规范

2.1 技能描述:让Agent知道“什么时候不该调我”

很多技能调用失败,根因不是实现有Bug,而是描述写得太泛。比如你写一个“文件搜索”技能,描述是“搜索文件”,模型看到任何和文档、资料、记录相关的请求都可能调用它。结果用户问“帮我查一下明天的会议记录”,技能搜出来一堆无关文件,任务失败。

我在agent-skills里把技能描述拆成三块:该技能“做什么”、在“什么场景下使用”、在“什么场景下绝对不要用”。绝不要用的边界,比做什么更重要。举个例子,我的技能库里有一个“网页正文提取”技能,描述里明确写了:输入必须是URL,不要用于搜索,搜索请调用另一个“网页搜索”技能。这样模型就很少会混淆。

描述不要追求华丽辞藻,用词要具体。比如用“从指定URL获取HTML并提取正文内容,去除导航和广告”比“采集网页信息”好得多。Agent本质上是在做文本匹配和信息检索,描述里的动词、宾语越具体,匹配准确率越高。

2.2 参数Schema:把自由文本变成结构化调用

既然模型要根据用户请求生成参数,参数格式就必须严格。我强烈建议用JSON Schema来定义每个技能的输入输出。不要嫌麻烦,这一步能省掉后面无数个“为什么参数又错了”的夜晚。

JSON Schema的好处是既能约束模型,又能做运行时校验。比如定义一个“定时提醒”技能的参数:

{ "type": "object", "properties": { "target_time": { "type": "string", "format": "date-time", "description": "提醒时间,ISO 8601格式,含时区" }, "message": { "type": "string", "minLength": 1, "maxLength": 500, "description": "提醒内容" } }, "required": ["target_time", "message"] }

这个Schema在模型生成参数时会被塞进系统提示词,模型知道target_time必须是ISO日期时间格式,message不能为空。调用前再用jsonschema库做一次校验,双保险。实际跑下来,参数格式错误率能从30%降到5%以内。

还有一种容易忽略的情况:模型偶尔会多传不在Schema里的字段。我的做法是默认开启“禁止附加属性”,在Schema里加"additionalProperties": false。宁可让调用失败,也不要让模型自由发挥。因为模型一自由发挥,后端的代码就得应付各种不可预测的入参,调试成本高到离谱。

2.3 原子技能与编排技能

技能不能全是一竿子插到底的大功能。我习惯把技能分成两层:原子技能和编排技能。

原子技能只做一件事,比如“读取文件内容”“发送HTTP请求”“把文本转成向量”。这类技能尽量不依赖其他技能,便于复用和测试。编排技能则是对多个原子技能的有序调用,比如“生成周报”:先读项目数据,再统计数据,然后生成标题,最后格式化为markdown。编排技能内部管理数据流转,对外仍然暴露一个统一的输入输出。

为什么要分两层?因为模型在执行复杂任务时,很难一次性生成正确冗长的步骤。如果把编排逻辑放在Agent侧,模型需要同时关注“怎么选工具”和“怎么串联数据”,上下文一长就糊。而把固定的编排逻辑变成“技能”后,模型只负责决定“要不要生成周报”,至于周报怎么生成,那是技能内部的事。

我目前经验是:原子技能占总技能数的70%左右,编排技能占30%。原子技能越多,组合的灵活性越高。但也不能太碎,比如“把A文件复制到B目录”这种一步操作就不需要拆成“读文件”和“写文件”两个技能,拆了反而增加调用次数。

2.4 技能注册与发现机制

技能设计的再好,Agent也得能找到。这就是注册与发现机制要解决的问题。在agent-skills里,我采用“装饰器注册 + 注册中心查询”的组合方式。

每个技能用Python装饰器声明,例如:

@skill( name="read_file", description="读取本地文本文件内容,返回纯文本", scope=["本地文件", "txt", "md"], exclude=["远程文件", "二进制文件"], params_schema=READ_FILE_SCHEMA, result_schema=READ_RESULT_SCHEMA ) def read_file(params: dict) -> dict: ...

装饰器负责把技能名、描述、Schema、执行函数绑定到一起,放入全局注册中心。注册中心对外提供两个接口:list_skills()返回所有技能的精简信息,get_skill(name)返回指定技能的完整定义。Agent启动时只需要调用list_skills(),然后把结果转成模型能读的JSON列表。

到了技能更多、更杂的阶段,光靠遍历列表不够。我在注册中心里加了一层向量检索:把每个技能的name、description、scope拼成一段文本,离线做embedding。当用户请求进来,先用相似度检索出Top K候选技能,再连同检索结果一起给模型。这样技能数量从几十个涨到几百个时,调用准确率不会明显下降。检索不过滤,只做“召回”,最终决定权始终在模型手里。

3. 从零实现agent-skills的核心环节

3.1 环境准备与目录结构

我用Python 3.10以上版本,依赖库尽量少。核心只需要jsonschema做参数校验,pydantic做数据模型,fastapi开一个轻量的调试服务,其余全部用标准库。如果你要加向量检索,再引入向量库相关的client就行,不需要一开始就把生态铺得很大。

项目目录我建议这样组织:

agent-skills/ ├── skills/ │ ├── __init__.py │ ├── registry.py # 注册中心 │ ├── base.py # 技能基类 │ ├── decorator.py # @skill 装饰器 │ └── builtin/ │ ├── file_ops.py │ ├── web_ops.py │ └── notify.py ├── runtime/ │ ├── executor.py # 技能执行器 │ ├── validator.py # 参数校验 │ └── trace.py # 日志与追踪 ├── server.py # 调试服务 └── tests/

把技能实现和运行时分开放,是我后来重构才想明白的。一开始我把所有代码堆在一起,导致技能里到处都是调度逻辑。现在技能目录里只放能力实现,运行时的校验、执行、日志全部走公共模块,新加一个技能就是往builtin/加一个文件,不需要动其他代码。

3.2 实现一个基础技能模块

这里以一个“网页正文提取”技能为例,看完整的技能模块怎么写。首先定义参数Schema:

WEB_EXTRACT_SCHEMA = { "type": "object", "properties": { "url": {"type": "string", "format": "uri", "description": "目标网页URL"}, "max_chars": {"type": "integer", "minimum": 100, "maximum": 20000, "default": 5000} }, "required": ["url"], "additionalProperties": False }

然后写技能实现:

@skill( name="extract_web_content", description="从指定URL提取网页正文,自动去除导航、广告、页脚", scope=["网页", "URL", "文章", "正文"], exclude=["不要用于搜索网页", "不要用于获取重定向链接"], params_schema=WEB_EXTRACT_SCHEMA, result_schema=RESULT_SCHEMA, timeout=15 ) def extract_web_content(params: dict) -> dict: url = params["url"] max_chars = params.get("max_chars", 5000) html = http_get(url) text = parse_main_content(html) return {"status": "ok", "data": {"text": text[:max_chars]}}

注意timeout=15这个字段。Agent技能执行是有时间预算的,一个网页提取超过15秒,基本是网络问题或者目标站屏蔽。我在执行器里强制限时,超时就返回错误,不能让Agent卡死在那等。技能失败也要快速失败,否则整个工作流都被拖垮。

3.3 技能调度的实现思路

执行器是整个agent-skills最核心的模块。Agent模型侧调用工具时,通常会给出一个“工具名”和“参数”,执行器要完成四件事:查注册表、校验参数、执行技能、返回结构化结果。

我实现了一个极简的execute函数:

def execute(skill_name: str, params: dict, context: dict | None = None): skill = registry.get_skill(skill_name) if skill is None: return error_result(f"unknown skill: {skill_name}") errors = validate(params, skill.params_schema) if errors: return error_result(f"invalid params: {errors}") trace_id = start_trace(skill_name, params) try: result = skill.run(params, context) return normalize_result(result, skill.result_schema) except Exception as e: log_trace(trace_id, "error", str(e)) return error_result(str(e)) finally: end_trace(trace_id)

这段代码看上去简单,但有几个容易被忽略的设计点。第一,context参数是可选的,它承载跨技能共享的数据,比如用户ID、会话ID、临时目录路径,不需要写进技能的输入Schema。第二,返回值必须经过normalize_result,这样不管技能内部返回什么,对外一定是{status: ok, data: ...}或者{status: error, message: ...}的格式。模型看到统一格式,解析成功率会高很多。

我还在执行器里做了技能白名单和黑名单。不同业务场景可以只暴露部分技能,比如对外API服务不暴露“执行系统命令”技能。这不是功能问题,是安全问题,必须从调度层做隔离。

3.4 对接主流Agent框架的踩坑记录

把agent-skills接到Agent框架时,我的第一个教训是:不要直接传全部技能。主流Agent框架一般会自动把“可用工具”全部塞到模型请求里。技能少没事,一旦技能描述加起来超过几千token,模型的调用准确率会明显下降,费用也上去了。我后来做了一层过滤器,按照用户请求先做向量召回,只传Top 10候选技能给模型。

第二个教训是:技能返回值里不要放大量原始文本。比如“读取文件”技能,如果用户要读一个20万字的日志文件,直接返回全文,模型根本处理不了。正确的做法是返回摘要、前N行或者文件路径,让模型决定要不要进一步读取。我把“日志分析”场景改成先返回文件大小、行数、前50行,模型觉得需要再调用“按行读取日志”技能,这样上下文立刻可控。

第三个教训是注意流式输出。如果技能执行时间比较长,框架默认会在工具调用期间卡住,用户端看不到任何反馈,体验非常差。我的处理是在执行前先给前端推一个“正在调用技能extract_web_content”的状态,执行完毕再推结果。这一步不复杂,但很影响产品体验。

4. 实战:用agent-skills搭一个多技能工作流

4.1 场景拆解

空谈设计没有说服力,我用一个实际跑通的例子说明。某次我接了个内部需求:自动生成竞品分析简报。用户输入一个行业关键词,系统要完成:搜索近期相关资讯、抓取前几篇文章的正文、提取核心要点、汇总成结构化简报、推送到协作群里。

这个任务如果写成一个单体函数,代码会非常长,而且每一步都可能失败,不好定位。按agent-skills的思路拆开后,变成了五个独立的技能:

  • search_news:接收关键词和数量,返回新闻标题、来源、链接
  • extract_web_content:接收URL,返回正文纯文本
  • summarize_text:接收长文本,返回三条核心要点
  • generate_report:接收结构化数据,返回markdown简报
  • send_group_message:接收消息文本和webhook,推送通知

这里generate_report是编排技能,它内部会调用summarize_text,但对外不需要暴露这一点。Agent不需要知道简报怎么生成,只需要知道“任务完成后会得到一份markdown格式报告”。

4.2 技能组合与数据流

实现这个工作流的关键,是定义好技能之间的数据流。每个技能的输出必须能作为下一个技能的输入。比如search_news返回的数据结构是:

{ "status": "ok", "data": { "items": [ {"title": "标题", "url": "链接", "source": "来源", "date": "日期"} ] } }

那么extract_web_content的输入参数url就从items[0].url取。为了让模型正确取字段,我不会让模型自己“翻译”字段名,而是在技能描述里写清楚:这个技能的输入参数url通常对应上一步结果中的url字段。实践中模型对字段名的记忆很强,反而对“第几个列表项”的理解较弱。因此我尽可能把列表减少,或者把单个结果先抽出来,再喂给下一个技能。

为了处理多篇文章的场景,我在编排技能generate_report内部做循环。它对每一条搜索结果调用extract_web_content和summarize_text,最后把所有摘要聚合成一张表格。这样做的好处是:Agent只需要发起一次generate_report,内部循环多少次完全不影响模型的上下文。

4.3 效果对比与传统方式

同样的需求,我用两种方式做过对比。传统方式是写一个长脚本,手动把五个能力串起来,每一步失败就在日志里找。问题很明显:每换一个检索源,或者每换一种简报格式,脚本就要改逻辑;检索源偶尔返回空结果,脚本直接崩,没有任何“重试”或“换个来源再查”的智能。

换成agent-skills之后,我给模型下了一个“生成竞品分析简报”的指令,模型自主完成了选技能、传参数、检查结果、必要时重试的整条链路。用户侧的输入只需要一句自然语言,输出就是一份可用的markdown简报。链路中任何一步失败,模型会根据错误信息决定是重试当前技能,还是换一个技能。比如extract_web_content超时,模型会跳过这个URL,用下一条新闻继续。

这个对比不是我夸大的“智能涌现”,而是技能抽象带来的容错能力。单个技能可以稳定失败,但组合逻辑允许“重新选择”,整体任务成功率就大大提升了。

5. 常见问题与排查技巧实录

5.1 技能不被调用或调用错技能

这是最常见的坑。模型明明看到了技能列表,却始终不调用,或者调用了另一个技能。我排查时先看技能描述是不是太短。比如描述只有“搜索新闻”四个字,模型无法判断“搜索”和“抓取网页”的区别。我要求每个技能的描述不少于一句话,至少包含:动作动词、操作对象、典型场景、不适用场景。

第二个原因是技能粒度不对。某个技能描述里写了“获取新闻列表并解析正文”,但实现时只做了列表抓取,正文解析需要另外调用别的技能。模型根据描述以为一步能完成,结果拿到一个不完整的结果,后续就乱了。这种情况我建议“描述与实现严格一致”,做不到的功能千万别写进描述里。

第三个原因很隐蔽:向量召回阶段把技能过滤掉了。如果启用了相似度检索,一定要检查召回阈值。我把阈值设置在0.2左右,宁可多召回几个不相关技能,也不要漏掉正确技能。因为最终选择权在模型,多候选强过无候选。

5.2 参数总是生成错误

参数问题大多是Schema解释不清楚。比如时间字段,单纯写“string”类型,模型可能返回“明天上午10点”,而不是机器可读的时间戳。我处理方式是在description里写明:“必须是ISO 8601格式,例如2024-12-01T10:00:00+08:00,禁止使用相对时间”。模型对大段描述中的例子非常敏感,一个具体例子比十行规则有用。

参数错误还有一个原因:模型把用户原始文案直接当参数传。比如技能要求message是纯文本,用户说的是“提醒我喝水”,模型直接传了“提醒我喝水”,但这其实已经是完整功能,没问题。但另一个场景,技能要求keyword是待搜索的关键词,模型传了“帮我搜索一下AI行业最新动态”,结果搜索接口把“帮我搜索一下”当关键词,结果自然不对。我的做法是在参数Schema里每次生成前,先对参数做“指令剥离”:keyword只保留实体词,去掉指令前缀。这一步可以用一个小的预处理技能实现。

5.3 返回值太长导致上下文溢出

上下文溢出是我在实战中遇到最多的问题。解决思路不是限制所有返回值,而是让技能按需返回。我给所有“可能返回大量数据”的技能都加了max_items或max_chars参数,默认值保守一点。比如网页提取默认5000字,搜索结果默认10条。模型如果发现信息不够,会主动再调一次技能取更多数据,这比一次性加载到上下文好得多。

另外,我给所有技能返回值都做了“摘要可选项”。比如文件读取技能可以只返回行数、编码、前20行,后续是否需要完整内容由模型判断。这个模式可以和Agent的“追问”机制结合起来,用户问“日志里有什么异常”,模型先读前50行,发现不够再继续读,而不是一次读200万行。

5.4 多技能并发冲突与脏数据

多个技能并发执行时,容易出现共享状态被覆盖的问题。比如两个技能同时往同一个临时目录写文件,或者一个技能在写同一个全局变量。我的教训是:技能之间不要共享可变状态。所有需要传递的数据必须显式通过参数或返回值传递,不能靠“全局上下文”隐式共享。上下文对象只读,如果确实需要写,就复制一份再写。

还有幂等性问题。网络请求类技能在重试时可能会重复提交数据。我要求所有“写操作”技能支持幂等键。比如发送消息技能可以接收request_id,发送前检查这个ID是否已经处理过,处理过就返回原结果,不再重复发送。这套机制在Agent自主重试时非常关键,不然用户可能会收到好几条重复通知。

5.5 问题排查速查表

现象可能原因优先排查方向
技能完全没被调用技能列表未传入模型,或描述不清晰检查注册中心输出、技能描述长度与边界
调用技能但参数缺失Schema缺少示例,description不够具体补充default值和enum值
调用技能报错参数校验失败,或执行函数异常看trace日志,确认入参和Schema是否匹配
返回结果无法解析返回结构不统一检查normalize_result是否生效
Agent在链路上卡住技能超时时间过长设置合理的timeout,增加失败快速返回
上下文快速膨胀返回原始大文本增加max_chars和摘要选项
重复执行副作用重试未做幂等写操作技能增加request_id幂等键

最后的经验体会

我自己的项目从“工具函数堆”演进到“agent-skills”之后,最大的改变不是功能变多,而是维护成本显著下降。以前模型行为不可控,出了问题只能改Prompt重新试,现在所有行为边界都能在技能层收敛。算下来,同等复杂度业务,调试时间大概降了40%。我个人的一个习惯是:每新增一个技能,一定写三个用例,一个正常请求,一个边界请求,一个错误请求,全部跑通才允许注册。测试覆盖比代码文档更可靠,因为文档会过时,测试永远反映当前行为。

还有一个值得说的扩展方向:技能描述和参数校验规则,完全可以由Chat自动生成。我给大部分新技能写Schema时,先用自然语言描述功能,再用现有技能作为示例,让模型生成初版Schema,最后人工确认。这样效率很高,而且Schema的风格能和旧技能保持一致。但要注意,自动生成的Schema务必做人工审查,尤其是description字段,模型偶尔会编造不存在的取值。

agent-skills这套思路不绑定任何具体大模型厂商,只要模型支持工具调用,都可以接入。未来我计划把技能库拆成核心运行时和应用技能包两部分,让不同团队只维护自己的业务技能,公共技能统一由运行时提供。如果你手头也在做智能体应用,不妨从最小的两个技能开始试,把规范和调度骨架搭起来,后面加技能会越来越顺手。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询