最近在折腾一个项目,代号就叫“agent-skills”,核心是给AI智能体设计一套可复用的技能体系。搞了大半个月,踩了不少坑,也总结出一些可复用的思路,今天就把这套东西完整拆开讲讲。
我见过太多人做Agent,上来就往Prompt里堆工具描述:五个工具还可以靠提示词硬撑,工具一多,模型就开始瞎选、串场、反复调用失败。我自己的项目里,最开始接了十几个API,然后在system prompt里用一整页描述每个工具是干嘛的,结果模型经常把两个相似工具的调用参数搞混,还有大量无效请求。后来我才意识到,问题不是模型不够聪明,而是我把“工具”直接暴露给了模型,但缺少一个中间层,把这个工具在什么场景下用、怎么用、前置检查是什么、结果怎么处理,打包成一个完整的“技能”交出去。
“agent-skills”这个项目做的东西,本质上就是这样一套技能体系:把散落的工具调用升级成语义完整、可编排、可观测的Agent技能模块,让模型只面对少量高聚合度的能力入口,而不是一堆毫无逻辑的API函数。今天这篇文章,我会把这套体系的设计思路、目录规范、实操代码、调试技巧全部展开,希望能给正在做Agent工程的同行一些参考。
1. 为什么Agent需要“技能”,而不是一堆“工具”
1.1 从工具到技能,概念上差在哪里
我在项目里对“工具”和“技能”做了明确区分。工具是单一的函数调用,比如get_user_by_id(user_id: int);技能则是一组围绕某个业务目标聚合的能力包,它内部可以有多个工具调用、有前置校验、有异常分支、有标准化的输出格式。
举个例子。如果你的Agent需要帮用户查订单、退换货、开发票,直接在工具列表里堆query_order、apply_refund、create_invoice这三个工具,模型确实能调,但很容易在参数上犯错:查询订单时传了退款单号,或者发票金额算错。但如果把这三个操作封装成一个“订单售后处理技能”,技能内部自己去识别输入是订单号还是售后单号,自己校验金额,再决定走哪个流程,模型只需要说“用户要退款”,技能就接管了后续所有细节。
这就是两者最大的区别:工具是“你告诉模型每一步操作”,技能是“你告诉模型目标,操作过程交给封装好的模块”。对模型来说,面对的接口数量变少了,每个接口的语义边界更清晰了,决策准确率自然就上来了。
1.2 技能体系解决的核心问题
仔细复盘一下,我这套技能体系主要解决了三个问题:
第一是上下文污染。工具描述写得越详细,占用的上下文窗口越大;写得越简略,模型越容易误用。技能体系把冗长的工具说明拆成两层,模型在决策层只看到精简的触发条件和使用场景,完整的执行细节放在技能内部,调用时才加载。这相当于给Prompt做了瘦身。
第二是错误处理与容错。过去model → tool这种直连模式下,工具报错了,模型要自己理解错误信息再重新尝试,经常陷入死循环。现在技能内部就做了重试、降级、参数修正,模型拿到的永远是技能消化过的结果,而不是一堆乱七八糟的原始异常。
第三是组合与编排。有些复杂任务需要多步工具协作,比如先查库存、再锁库存、然后生成订单。如果这三步暴露给模型去编排,不确定性太大了。封装成“下单技能”后,编排逻辑是代码写死的,模型只需要决定“现在该调用下单技能了”,内部怎么跑,它不用管。
我把工具和技能的区别整理成了一张表,方便对照理解。
| 维度 | 工具(Tool) | 技能(Skill) |
|---|---|---|
| 粒度 | 单一函数,一步操作 | 多步骤、带逻辑的完整能力 |
| 模型感知度 | 暴露全部细节,参数模型自己填 | 只暴露触发条件与目标 |
| 错误处理 | 模型现场判断,容易出错 | 技能内部预置重试与容错 |
| 上下文开销 | 每个工具都要完整描述 | 只有触发层占上下文,执行细节后加载 |
| 可复用性 | 跨场景反复声明,易冲突 | 一次封装,全局可复用 |
1.3 什么时候该上技能体系
不是所有项目都需要技能体系。如果你的Agent只有两三个工具,模型也不可能选错,那直接平铺就好。但当你在代码里开始写if tool_name == "xxx"的特殊处理,或者发现两个工具的description越来越长、越来越像,甚至开始出现模型把A工具的参数传给B工具的情况,那就说明该引入技能层了。
我这个项目触发改造的临界点,是工具数量超过15个、Prompt中工具描述超过3000字符。顺着这个思路重构之后,工具的决策准确率从改造前的82%左右提升到了94%,无效调用次数下降了接近60%,这个提升幅度足以说明问题。
2. “agent-skills”技能体系的设计与目录规范
2.1 技能目录怎么组织最顺手
项目里我采用的目录结构,参考了市面上几种主流Agent技能仓库的约定,最终形成了一套比较稳定的模板。每个技能独立一个目录,目录名就是技能名,内部包含声明文件、描述文档和代码脚本三大部分。
agent-skills/ ├── skills/ │ ├── order_after_sales/ │ │ ├── manifest.yaml │ │ ├── SKILL.md │ │ └── scripts/ │ │ ├── query_order.py │ │ ├── apply_refund.py │ │ └── utils.py │ ├── database_query/ │ │ ├── manifest.yaml │ │ ├── SKILL.md │ │ └── scripts/ │ │ └── query_mysql.py │ └── ... ├── runtime/ │ ├── loader.py │ ├── session.py │ └── logger.py └── config.yaml这套结构的核心原则是“一个技能一个家”。manifest.yaml负责声明技能元数据和参数Schema,SKILL.md写给模型看,scripts里才是真正的执行代码。三者职责分离,谁负责什么一眼就能看明白,不会出现技能一多就乱成一锅粥的情况。
2.2 manifest.yaml:给解析器看的技术说明
manifest.yaml是技能的“身份证”,主要给运行时加载器读,里面记录了技能的名称、描述、参数定义、依赖关系和超时设置。我通常这样写:
name: order_after_sales description: 订单售后处理技能,支持查询订单状态、提交退款/退货申请、开具发票。 version: "1.2.0" timeout: 30 depends_on: - database_query parameters: type: object properties: intent: type: string enum: ["query", "refund", "invoice"] description: "用户售后意图类型" order_id: type: string description: "订单号,支持数字与字母组合" reason: type: string description: "退款或退货原因" required: - intent allowed_tools: - query_order - apply_refund - create_invoice有一点要注意:manifest里的description要短,最好一句话讲清楚这个技能是干嘛的,因为这段描述会直接拼接进模型的System Prompt,写得越长,污染越严重。详细的使用说明放进SKILL.md,模型决定调用技能后才会去读,两边的信息密度要做差异化设计。
参数Schema的定义也别偷懒。模型在做函数调用时,会严格按照这个Schema生成参数,所以枚举值、必填项、格式约束写得越明确,模型生成越准确。我把所有参数都加上了description,实测下来参数生成准确率能涨不少。
2.3 SKILL.md:写给模型看的操作手册
SKILL.md是技能体系里最有价值的东西。它相当于给模型的一份“岗位说明书”,里面提供了这个技能在什么场景下触发、内部怎么处理、输出什么格式。
我整理了一个技能描述写作的模板,实际用下来效果不错:
# 订单售后处理技能 ## 触发条件 - 用户提到“我的订单怎么样了”“我要退款”“订单有问题”“开发票” - 用户提供了订单号,或询问最近订单 ## 技能行为 1. 先调用查询接口确认订单状态 2. 如果订单已发货且超过售后期,引导用户走售后单流程 3. 退款金额不能超过订单实付金额,否则拒绝操作 4. 开发票需要企业抬头,个人用户只开电子普通发票 ## 输出格式 - 成功:返回订单状态+操作结果 - 失败:返回错误码+可读的失败原因,并给出下一步建议 ## 注意事项 - 不要直接修改数据库订单状态 - 如果订单号不存在,提示用户核对后再试 - 所有操作必须记录操作日志别小看这份文档,我强烈建议在“触发条件”里写足典型问法,在“注意事项”里把容易踩雷的规则写清楚。模型在技能内部做多步决策时,读到的上下文越结构化,行为越稳定。
另外说个规律,SKILL.md的总长度控制在800~1500词比较合适。太短了模型信息不够,太长了会拖慢调用时的上下文加载。我测试过,超过2000词的技能描述,模型在关键信息召回上反而会变差。
2.4 技能之间的依赖与编排设计
当技能多起来之后,技能之间还会产生依赖关系。比如售后技能需要查订单,查询逻辑已经封装在数据库查询技能里了,就没必要重复写一套,直接在depends_on字段里声明即可。
运行时加载器读取depends_on后,会自动把依赖技能的执行函数挂到当前技能的执行环境里,内部可以互相调用。这种设计让技能不只是一个孤立的模块,而是一张能力网络,复用性非常高。
不过我也踩过依赖过深的坑。早期我把技能依赖链拉到三层以上,结果出了bug之后排查链路特别痛苦。后来立了个规矩:技能依赖深度不超过两层,超过两层就引入独立的服务层接口,直接暴露给技能调用。
3. 从零实现一个可复用的Agent技能
3.1 宿主框架怎么选
实现技能的时候,先得选一个宿主框架。我当时对比了直接用LangChain工具类、基于Anthropic的Claude Skills机制、以及纯自研三种方案,最终选择了以自研为主、参考Claude Skills设计思路的方式。
选自研的核心原因是灵活性。LangChain的工具抽象虽然生态好,但它的核心抽象还是偏向“工具”而非“技能”,错误处理、参数校验、技能编排都要自己额外写很多胶水代码。Claude Skills的思路很超前,尤其它的“轻描述、重文档”设计,非常适合做技能体系;但如果项目的交互链路比较特殊,或者需要兼容不同的模型厂商接口,还是需要自己封装一层。
对于大多数项目,我的建议是:没有特殊需求,先用现成框架,把技能描述写作和目录规范跑通;等真正遇到框架瓶颈了,再考虑自研。我之所以自研,是因为这个项目的需求确实比较复杂,定制化场景多。
3.2 实操案例:写一个“数据库查询技能”
下面就按照我之前实战的过程,手把手带你写一个最常用的“数据库查询技能”。这个技能的需求很简单:Agent接到一个自然语言问题后,技能负责把问题转成SQL、查询数据库、把结果整理成模型可读的格式。
第一步,创建技能目录:
mkdir -p agent-skills/skills/database_query/scripts第二步,写manifest.yaml:
name: database_query description: 将自然语言问题转换为SQL并查询数据库,返回结构化的查询结果。 version: "1.0.0" timeout: 15 parameters: type: object properties: query_question: type: string description: "用户希望查询的原始自然语言问题" table_name: type: string description: "需要查询的数据表名称,可选,缺省时自动识别" required: - query_question第三步,写SKILL.md:
# 数据库查询技能 ## 触发条件 - 用户询问数据情况、报表指标、订单数量、用户统计等 - 用户问题中包含“统计”“多少个”“平均”“占比”等字眼 ## 技能行为 1. 根据问题理解表结构,选择合适的表和字段 2. 生成SQL查询语句,先通过EXPLAIN检查索引使用情况 3. 禁止在查询中使用DELETE、UPDATE、DROP等写操作 4. 如果查询结果为空,返回空结果并解释可能的业务原因 ## 输出格式 - 返回markdown格式的表格 - 每列附带字段说明 - 附上SQL语句,方便人工复核第四步,实现scripts/query_mysql.py:
import os import mysql.connector from typing import Any, Dict, List def query_mysql(query_sql: str, params: tuple = ()) -> Dict[str, Any]: """ 执行MySQL查询并返回结构化结果。 只支持SELECT语句,禁止写操作。 """ try: conn = mysql.connector.connect( host=os.getenv("MYSQL_HOST"), port=int(os.getenv("MYSQL_PORT", "3306")), user=os.getenv("MYSQL_USER"), password=os.getenv("MYSQL_PASSWORD"), database=os.getenv("MYSQL_DATABASE"), connection_timeout=3, ) cursor = conn.cursor(dictionary=True) cursor.execute(f"EXPLAIN {query_sql}") explain_result = cursor.fetchall() for row in explain_result: if row.get("type") == "ALL" and "where" in row.get("Extra", ""): return { "success": False, "error": "查询未命中索引,已终止执行,请添加索引或修改条件" } cursor.execute(query_sql, params) rows = cursor.fetchall() columns = [desc[0] for desc in cursor.description] if cursor.description else [] cursor.close() conn.close() return {"success": True, "columns": columns, "rows": rows} except Exception as e: return {"success": False, "error": str(e)}这里有个小细节,就是查询前先执行EXPLAIN,这个设计是为了防止模型生成的SQL是全表扫描。模型对数据量没概念,一个三千万行的表,它敢写不带WHERE的查询,有了索引检查,能拦住绝大多数低质量SQL。
第五步,实现技能主入口文件:
from typing import Any, Dict from .scripts.query_mysql import query_mysql import json def execute_skill(parameters: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]: """ 技能统一入口,负责接收模型意图参数,完成查询并格式化输出。 """ if "query_question" not in parameters: return {"success": False, "error": "缺少query_question参数"} question = parameters["query_question"] # 生成SQL的逻辑通常交给模型,这里通过session把问题传出去 # 更稳妥的方式是:在技能内部再调用一次模型做text2sql sql = context.get("sql_generator", lambda q: "")(question) if not sql.strip(): return {"success": False, "error": "无法从问题中生成SQL"} result = query_mysql(sql) if not result["success"]: return {"success": False, "error": result["error"]} rows = result["rows"][:50] # 限制最多返回50行,避免上下文爆炸 if len(result["rows"]) > 50: rows.append({"warning": "结果过多,仅显示前50行"}) return { "success": True, "data": rows, "sql": sql, "row_count": len(result["rows"]), }技能里用了一个context参数,这是我在设计里坚持的一个点。它专门用来传递运行时信息,比如模型生成SQL的回调函数、当前用户身份、会话上下文等,这样技能就不需要自己去猜测外部环境,只依赖显式传入的上下文,可测试性会好很多。
3.3 技能注册与调用链路解析
技能写好了,接下来是运行时加载。我的加载器是这样工作的:
import yaml from pathlib import Path from typing import Dict, Any SKILLS_ROOT = Path("skills") def load_skill(skill_name: str) -> Dict[str, Any]: skill_dir = SKILLS_ROOT / skill_name manifest_path = skill_dir / "manifest.yaml" with open(manifest_path, "r", encoding="utf-8") as f: manifest = yaml.safe_load(f) skill_md_path = skill_dir / "SKILL.md" skill_doc = skill_md_path.read_text(encoding="utf-8") # 动态导入技能入口模块 module_path = f"skills.{skill_name}.entry" entry = __import__(module_path, fromlist=["execute_skill"]) return { "name": skill_name, "manifest": manifest, "documentation": skill_doc, "execute": entry.execute_skill, }加载进内存后,runtime会维护一个技能注册表。每次会话开始时,根据用户当前任务,把注册表里可能相关的技能注入到System Prompt里,而不是一次性全注入。这个动态加载机制,是控制上下文开销的关键。
调用链路的完整流程大概是这样的:
- 用户提问进入Agent主循环
- 主模型根据当前Prompt和技能触发描述,决策调用哪个技能
- 加载器从注册表中取出技能对象,并把参数Schema给模型,让模型生成结构化参数
- 模型生成参数后,runtime做参数校验,校验通过后调用
execute_skill - 技能内部执行完,返回标准化结果
- 主模型拿到结果,组织成自然语言回复用户
3.4 技能的观测与评估
这里说一个我特别想强调的点,技能体系做得好不好,一定要有可观测性。我在每个技能执行时都会记录这样几条信息:
- 技能名、技能版本号
- 触发时的完整参数
- 执行耗时
- 内部每一步的中间结果(尤其是SQL、API响应等)
- 最终输出结果的摘要
这些日志的价值很大。有一次模型在订单查询技能上反复失败,回看日志才发现,模型经常把一个比较长的字母数字混合订单号截断成短数字,根源是参数描述里没有注明订单号长度范围。把参数描述改成“订单号格式为13位字母数字组合”之后,问题立刻消失了。
我自己还会在开发环境跑一组固定的回归测试集,包含50个不同类型的查询问题,每次改版都跑一遍,观察技能调用准确率的变化。这个习惯帮我挡住了不少回归bug。
4. 常见问题与排查技巧实录
4.1 模型就是不调用技能,怎么办
这是最常见的一类问题。技能明明写得没问题,手动调接口也能跑通,但模型聊天时就是不触发,总是用泛泛的方式回答。
排查顺序一般是这样:
| 排查点 | 检查方式 | 常见根因 |
|---|---|---|
| 技能描述是否进入Prompt | 看一次实际请求的完整system message | 动态加载逻辑有bug,描述没拼进去 |
| 技能描述是否足够短 | 检查描述字数 | 描述过长,被其他内容挤占或截断 |
| 触发条件是否明确 | 对比典型用户问题与描述用词 | 描述太抽象,模型无法对齐到具体语义 |
| 技能名是否容易误解 | 检查技能名与描述的一致性 | 名字暗示了错误的能力范围 |
| 是否存在竞争技能 | 检查其他技能的触发词 | 多个技能描述相似,模型选择了错误那个 |
我碰到最多的就是触发词写得太抽象。一开始写的是“当用户有信息查询需求时”,结果模型觉得什么都能触发,反而什么都不触发。改成“当用户询问订单状态、物流轨迹、退款进度时触发”之后,命中率立竿见影地上升。
另外,如果主模型是支持function calling的,需要确认一下触发的优先级。我见过有些场景里,模型宁可自己有一个模糊答案,也不调用技能去拿准确数据。这种情况可以通过在System Prompt里加一句强约束来缓解,比如“当你需要准确数据时,必须先调用对应技能,禁止猜测”。
4.2 技能执行成功,但模型无视结果
这个问题的表现是,技能返回了正确的数据,但主模型回复时完全没用,或者还在自己编数据。
大部分时候是因为结果格式对模型不友好。我早期是把技能返回的JSON直接丢给主模型,模型读是能读,但字段一多,它就抓不住重点了。后来我规定所有技能返回的结果必须带上三层结构:
summary: 一段话总结执行结果,直接给模型读data: 原始结构化数据meta: 执行时间、耗时、告警信息等元数据
主模型优先读summary,这样即使数据再复杂,它也能快速把准确信息组织进回复里。
还有一种情况是返回数据太长,模型读到后面把前面的关键信息忘了。所以我规定技能返回结果默认不超过50行或2000个token,超过部分在summary里做聚合统计,这样既保住了关键信息,又不会超限。
4.3 技能数量膨胀之后,互相干扰怎么破
技能做多了之后,会面临一个新问题:技能之间描述相似,模型不知道该选谁。比如我做了“订单查询”和“物流查询”两个技能,在某个边界场景上,模型经常选错。
我的解法有两个。第一个是给每个技能在manifest里加一个trigger_keywords字段,把高频触发词和同义词全部列出来,注册的时候生成一份关键词索引表,主模型决策前,先用轻量规则匹配缩小候选技能范围。这相当于在模型之前加了一道规则路由,能明显降低选错概率。
第二个是给相似技能增加“排他性说明”。比如在订单查询技能的SKILL.md里写明“物流状态查询请使用物流查询技能”,在物流查询技能里也反向注明。这种交叉说明看起来有点笨,但对模型判断边界非常有效,实测冲突场景的错误率能降低一半以上。
4.4 技能内部调用外部API时的超时与重试
有段时间技能频繁出现超时,但单独测API明明是好的。后来发现是并发场景下,技能同时发起了大量外部请求,把下游服务打挂了。
后面我在技能层做了一套统一的重试策略:
- 首次请求超时时间:5秒
- 重试次数:最多2次,采用指数退避(1秒、2秒)
- 请求间加入并发限制,技能粒度统一走信号量控制
- 连续失败超过5次,技能主动熔断,直接返回降级提示
采用这套策略后,外部接口抖动对会话的影响小了很多。重点在于,技能封装的不只是正常流程,还要封装好异常降级,否则它和裸调API就没有本质区别了。
5. 写在最后的一点体会
项目做到现在,我最大的感受是:Agent技能体系的难点不在代码,而在对模型行为的理解,你设计的每一个字段、每一段描述,都是在引导模型的概率分布走向你想要的那一侧。技能描述写得清晰,模型的行为就稳定;技能描述含糊,模型就给你展示各种让人血压升高的操作。
如果你也是正在做Agent相关项目,我的建议是先别着急写一堆复杂框架,从一两个真正高频使用的技能开始,把目录规范、描述写作、日志观测这三点跑通,再逐步扩展。这比一开始就搭一个庞大的技能编排引擎要实际得多。
对了,最后再分享一个小技巧:技能目录里记得加一个examples/文件夹,把每次测试中比较好的“用户问题-技能参数-执行结果”三元组存下来。时间长了,这会变成你优化技能描述最好的数据资产,比任何文档都有说服力。