做Agent开发的人,多半都被同一个问题折磨过:功能逻辑都写好了,模型却总是“不听使唤”。该调工具的时候不调,调了又传不对参数,一个简单的任务翻来覆去地出错。我去年从零搭过一套多智能体应用,前前后后把几十个工具函数塞进prompt里,结果维护成本直接爆炸,后来把整套逻辑重构成agent-skills体系,也就是把能力封装成独立、自描述、可复用的技能模块,问题才算真正解决。
这篇文章我就把 agent-skills 这套玩法的核心思路、设计方法和落地细节完整拆一遍。内容包括:为什么工具调用会失效、技能体系如何重新组织Agent能力、SKILL.md该怎么写、调度器和技能注册表怎么实现、多技能协同怎么处理,以及我踩过的坑和排查技巧。适合正在做Agent应用开发、被工具管理和模型调用稳定性困扰的朋友参考。
我一直觉得,写Agent最难的不是模型能力,而是“工程化”。模型本身是聪明的,但你得用它能理解的方式,把你的能力边界讲清楚。agent-skills 就是干这件事的。
1. Agent Skills到底是什么:从工具堆砌到技能体系
1.1 散装工具调用的四个典型痛点
先聊聊最传统的实现方式。很多人在第一版Agent里,都是把所有工具函数写在一个list里,每个工具用一份JSON Schema描述参数,然后通过函数调用机制一起丢给模型。我在项目早期就是这么做的,当时维护了23个工具,看起来井井有条。
但工具一多,问题就接踵而至。
第一,选择准确率断崖式下跌。模型要在几十个工具里挑一个最合适的,本质上是做一次多分类。当工具描述含糊、边界重叠时,模型会频繁选错工具。我遇到过最离谱的一次,用户问“帮我查一下今天的天气”,模型去调了“日程管理”工具,因为两个工具的描述里都有“今天”这个词。
第二,重复逻辑没法复用。搜索、总结、格式化这些基础能力散落在不同工具里,两个任务要用到同一段逻辑,只能复制粘贴。改了一个地方忘了另一个,Bug就悄悄埋下了。
第三,状态管理一团乱麻。很多工具依赖前置步骤。比如“先搜索、再总结、最后保存到记忆”,用散装工具时,这些依赖关系只能用全局变量加if-else硬编码,代码丑得没法看。
第四,测试几乎没法做。散装工具没有统一结构,你只能一个个手写测试用例,回归测试更是奢望。
1.2 技能体系的本质:给工具套上工程化外壳
Agent Skills解决的就是这些问题。它不是什么玄乎的框架,本质就是给“模型可调用的一段能力”套上一个标准化的工程外壳。每个技能至少包含四样东西:一个唯一的名字、一份自描述文档、一段可执行的代码逻辑、一组可验证的测试用例。
我用一个表格对比一下两种方式的差异。
| 维度 | 散装工具列表 | 技能体系 |
|---|---|---|
| 组织单元 | 函数 | 技能目录(代码+描述+测试) |
| 模型理解依据 | JSON Schema片段 | 结构化自描述文档(SKILL.md) |
| 逻辑复用 | 复制粘贴 | 按技能目录引用 |
| 状态管理 | 全局变量硬编码 | 技能内聚+上下文对象传递 |
| 测试方式 | 零散手写 | 每个技能自带测试用例 |
| 扩展成本 | 改主流程 | 新增目录+注册即可 |
这套体系最大的聪明之处,是把“模型该理解什么”和“开发者该维护什么”这两件事彻底分开了。模型不需要看代码,它看的是结构化的技能描述;而开发者不用把每个工具的细节塞进prompt上下文,只需要维护好自己的技能目录。两边各管各的,配合反而更顺。
1.3 一个Skill的标准目录长什么样
我实际用下来的技能目录结构如下(以Python为例):
skills/ ├── web_search/ │ ├── SKILL.md │ ├── search.py │ └── test_search.py ├── code_interpreter/ │ ├── SKILL.md │ ├── execute.py │ └── test_execute.py └── memory_manager/ ├── SKILL.md ├── memory.py └── test_memory.py每个技能目录下,最核心的文件是SKILL.md,因为这就是技能与模型之间的“接口文档”。模型在读你的技能目录时,主要就是看这份文档。
一份合格的SKILL.md大概长这样:
--- name: web_search description: 当用户需要查询实时信息、最新资讯、论文资料或事实验证时使用。 input: query: 用户的搜索关键词,尽量保留用户原意 max_results: 返回结果条数,默认5,最大10 output: 搜索结果列表,包含标题、链接和摘要 --- # web_search 技能说明 ## 触发条件 - 用户提问涉及实时新闻、时效性信息 - 用户要求查找资料、验证事实 - 模型已有知识无法覆盖的最新内容 ## 使用注意 - query 不要做模糊化改写,保留用户核心意图 - max_results 不得超过10 ## 不适用场景 - 用户只需要基于已有知识的推理,不需要外部信息 - 搜索历史对话记录我习惯把SKILL.md类比成一份招聘JD:岗位名称是技能名,岗位职责是触发条件,任职要求是使用注意。JD写得越清楚,来投简历的候选人(模型)就越靠谱。
2. 模型与技能的协作逻辑:描述怎么写才能被可靠调用
2.1 模型视角:模型看到的技能清单其实是一份菜单
很多人以为模型调用技能时“看懂”了你的代码,其实不是。模型看到的是一个高度抽象后的技能清单元信息,通常就是每个技能的名字和一段描述。
举个实际的prompt片段:
你有以下技能可供调用: 1. web_search:当用户需要查询实时信息、最新资讯、论文资料或事实验证时使用。输入为用户的搜索关键词。 2. execute_code:当用户需要运行Python代码、计算数学问题或验证算法逻辑时使用。输入为一段完整的Python代码。 3. save_memory:当用户明确要求记住某条偏好或事实,且该信息需要长期保存时使用。 4. query_memory:当需要回顾该用户的长期偏好、历史事实时使用。模型看到这个菜单后,会根据用户请求做“意图匹配”,选择一个技能并填充参数。所以它的本质是一个文本匹配加参数抽取的任务。你写的描述,就是模型做决策的全部依据。
这也解释了为什么很多人的工具调用老是失败:你以为模型看懂了你的字段约束,其实它只是基于描述做了一次语义匹配,任何模糊、歧义、过度抽象的描述,都会让它做出错误决策。
2.2 技能描述的四条黄金准则
关于SKILL.md怎么写,我总结了四条经验,每条都是真金白银换来的。
第一条:触发场景要写具体。不要写“此技能用于搜索”,要写“当用户需要查询实时信息、最新资讯、论文资料或事实验证时使用”。让模型能把用户的表达和你的技能直接对上。描述里多放几个同义触发词,比如“查一下”“搜一搜”“最新情况”,都能提高命中率。
第二条:输入参数要给范围和示例。模型填充参数时,如果没有参考只能瞎猜。我在web_search技能里写明“query: 用户的搜索关键词,尽量保留用户原意,长度不超过200字”,模型的参数生成质量明显提升。
第三条:明确写出“不适用场景”。这是最容易被人忽略的一点。技能描述里的反面案例,能有效减少误调用。我曾经有一个“save_memory”技能,没写反例,模型每轮对话都把上下文存一遍,浪费大量token。加了“不适用于临时性猜测、不适用于模型可以在上下文直接访问的信息”之后,误调用少了一半以上。
第四条:参数名要符合模型直觉。字段命名不要用缩写和内部术语。之前我把搜索关键词字段命名为kw,模型经常不传或乱传。改成query之后,准确率直接上了一个台阶。模型的直觉来自训练数据,越贴近自然语言的字段名,它越容易理解。
2.3 兜底策略:别全指望模型自觉
就算描述写好了,模型依然可能选错或者不选。我建议在调度层加两重兜底。
第一重,置信度兜底。在模型返回工具调用结果时,别急着执行。可以做个简单判断:如果返回的技能名不在注册表内,或者参数校验失败,直接回退到“重新提问,让模型修正”。我曾遇到模型编造技能名的情况,比如输出一个“search_internet”,而注册表里只有“web_search”。这个时候如果直接执行,必然报错。
第二重,用户澄清兜底。当模型返回tool_choice: auto且没有选择任何工具,但用户请求明显需要外部能力时,不要硬跑,而是让Agent说一句“我需要确认一下你的意图”,引导用户把需求说清楚。
这两重兜底加起来,系统的容错性会好很多。
3. 从零搭一套技能体系:注册、调度与执行
3.1 第一步:梳理你的能力清单,别拍脑袋
很多人设计技能是“我想做什么就写什么”,我不推荐这么干。我建议从用户真实任务日志去反推。把你过去一周的用户对话拉出来,逐条看用户到底让你做了什么,归类成能力项。
我当初梳理完,发现所有需求归纳下来只有6类能力:搜索、代码执行、记忆读写、文本总结、内容翻译、格式化输出。原来23个工具,合并压缩成12个技能,一下子清爽了很多。
经验之谈:不要为了设计而设计,技能一定是给真实需求服务的。
3.2 第二步:实现技能基类与注册机制
技能要统一管理,先得定义抽象基类。我的基础实现长这样:
# base_skill.py from abc import ABC, abstractmethod class BaseSkill(ABC): @property @abstractmethod def name(self) -> str: """技能唯一标识,必须与SKILL.md中的name一致""" @property @abstractmethod def description(self) -> str: """技能描述,供模型决策使用""" @abstractmethod def execute(self, context: dict, **kwargs) -> dict: """执行技能并返回标准化结构:{success, result, error}"""然后是注册表。注册表的核心功能是登记技能、查询技能、列出所有技能:
# skill_registry.py class SkillRegistry: def __init__(self): self._skills = {} def register(self, skill: BaseSkill): if skill.name in self._skills: raise ValueError(f"技能重复注册: {skill.name}") self._skills[skill.name] = skill def get(self, name: str) -> BaseSkill: return self._skills.get(name) def list_catalog(self) -> list[dict]: return [ {"name": s.name, "description": s.description} for s in self._skills.values() ]为什么一定要搞注册机制?因为它把“新增技能”变成了一件低风险的事。新技能只需写个类,实例化后调一次registry.register(),主流程完全不用改。我后来扩展技能时,基本不动调度代码,只需要关心技能自身逻辑。
3.3 第三步:实现技能调度器核心逻辑
调度器是整个技能体系的中枢。它负责:送技能目录给模型、解析模型的调用意图、校验参数、执行技能、把结果回填给模型继续推理。
# skill_agent.py def run_agent_with_skills(user_request: str, registry: SkillRegistry, llm, max_rounds: int = 5): catalog = registry.list_catalog() messages = [ {"role": "system", "content": build_system_prompt(catalog)}, {"role": "user", "content": user_request} ] for round_idx in range(max_rounds): response = llm.chat.completions.create( model="your-model", messages=messages, ) assistant_msg = response.choices[0].message # 模型决定调用技能 if assistant_msg.tool_calls: for tool_call in assistant_msg.tool_calls: skill_name = tool_call.function.name arguments = json.loads(tool_call.function.arguments) skill = registry.get(skill_name) if not skill: # 兜底:技能不存在,让模型重新选择 messages.append({ "role": "function", "name": skill_name, "content": json.dumps({"error": f"技能不存在: {skill_name},请从技能目录中选择。"}) }) continue # 执行技能 result = skill.execute(context, **arguments) messages.append({ "role": "function", "name": skill_name, "content": json.dumps(result, ensure_ascii=False) }) # 把执行结果放回模型,继续生成最终回复 messages.append({"role": "system", "content": "请基于技能执行结果继续回答用户问题。"}) continue # 模型不再调用工具,生成最终回复 return assistant_msg.content return "已达到最大轮次,任务未完成。"这段代码是简化版,但已经能展示完整的调度闭环。有个细节需要注意:messages里的role: function消息,是给模型“看”的执行结果。结果里一定要包含状态字段(success还是fail),因为模型要根据这个判断下一步该怎么做。
3.4 技能代码的防御性设计:别让模型拿到异常栈
技能执行层的防御性设计,很多人不重视,结果就是模型用不了技能。我踩过一个很深坑:技能内部报错后直接抛Python traceback,模型看到一串英文堆栈,完全不知道发生了什么,于是开始胡编乱造,说“搜索已经完成”。
正确的做法是:所有技能执行结果都统一封装成结构化的返回,错误信息要面向模型编写。
# web_search.py class WebSearchSkill(BaseSkill): name = "web_search" description = "当用户需要查询实时信息、最新资讯、论文资料或事实验证时使用。" def execute(self, context: dict, query: str, max_results: int = 5) -> dict: try: # 参数校验 if not query or len(query) > 200: return {"success": False, "result": None, "error": "query参数不能为空且不得超过200字,请重新提供查询词。"} if max_results < 1 or max_results > 10: return {"success": False, "result": None, "error": "max_results必须在1到10之间,请调整参数。"} results = self._do_search(query, max_results) # 空结果也要告诉模型,而不是抛异常 if not results: return {"success": True, "result": [], "error": None, "tips": "搜索无结果,可能需要更宽松的关键词"} return {"success": True, "result": results[:max_results], "error": None, "tips": None} except Exception as e: # 吞掉底层异常,返回模型能理解的错误描述 return {"success": False, "result": None, "error": f"搜索服务暂时不可用:{str(e)},请稍后重试或换一个查询。"}注意看:返回结构固定是{success, result, error, tips}。error写给模型看,不要写技术堆栈;tips可以给模型额外的修正建议。模型读了这份结果,知道哪里错了、该怎么改,才能做出正确补救。
4. 多技能协同与状态管理实战
4.1 技能拆分的颗粒度:别太粗也别太细
技能拆分多少合适?我的经验是:单技能负责的“任务动作”要清晰,边界要单一。
太粗的典型例子:把“搜索并生成总结”做成了一个技能。模型用这个技能时,通通把搜索关键词传进去,但却说不清楚总结的侧重点,最后生成的内容用户根本不满意。因为一个技能里塞了“搜索”和“总结”两个动作,而总结需要依赖搜索结果实时判断。
太细的典型例子:把“打开网页”“提取正文”“裁剪内容”“保存链接”分成四个独立技能。技能列表变臃肿,模型每次光看目录就要花大量token,选择也容易乱。
我现在的原则是:一个技能对应一个可独立验证的能力动作。“搜索”是一个动作,“总结”是另一个动作。但“搜索并保存到记忆”就不行,因为保存到记忆应该由另一个技能负责,或者由调度层组合。
4.2 技能间协作:让模型编排,还是内置工作流?
实际任务经常要多个技能配合。比如用户说“帮我查一下最新的AI Agent论文,然后把核心观点整理出来”。这需要web_search先搜索,然后调用文本总结技能,可能还要结合记忆技能判断用户的历史偏好。
我是怎么处理的?分两种场景。
简单串联场景:让模型自己编排。模型只要会按顺序发技能调用请求就行,调度器天然支持多轮工具调用。第一轮搜索,第二轮总结,每一轮都把结果回填给模型。这个方式灵活,容忍度高,适用于大多数日常任务。
固定流程场景:内置“复合技能”。如果某个流程特别稳定,比如“每日早报生成”固定是搜索三条新闻加翻译加格式化,我会封装一个daily_report复合技能,内部编排多个基础技能。这个时候模型只需要调用一个技能,省心也省token。
# composite_skill.py class DailyReportSkill(BaseSkill): name = "daily_report" description = "生成每日技术早报,包含搜索、翻译、格式化三个步骤。" def execute(self, context: dict, topics: list[str]) -> dict: results = [] for topic in topics: search_out = self._search.execute(context, query=topic) if not search_out["success"]: continue translate_out = self._translate.execute( context, text=search_out["result"][0]["summary"]) results.append(translate_out["result"]) return {"success": True, "result": format_report(results), "error": None, "tips": None}这里有个权衡:复合技能灵活度低,但稳定性高;模型自由编排灵活度高,但偶尔会出错。我建议对“保底能力”用复合技能,对“探索型任务”用自由编排。
4.3 记忆类技能的特殊性:读写分离是关键
记忆是Agent技能体系里最特殊的一类,因为它的处理时机很微妙。我在做记忆技能时踩过不少坑,最后摸索出一个稳定的模式:记忆读写要分开,且记忆读取不是被动的“工具调用”,而是每轮对话开始前的“前置动作”。
具体做法是:在用户每轮提问前,Agent首先从记忆库里查询相关资料,作为上下文的一部分注入到消息里。而不是等模型觉得“需要回忆历史”时才去调query_memory。因为模型经常意识不到自己需要记忆。
def build_messages_with_memory(user_id, user_request, registry): memory_skill = registry.get("query_memory") mem_result = memory_skill.execute( {"user_id": user_id}, query=user_request[:100]) memory_context = mem_result["result"] system_prompt = f"用户长期偏好如下:{memory_context}\n请结合以上信息回答用户问题。" return [{"role": "system", "content": system_prompt}, {"role": "user", "content": user_request}]至于写入记忆,则应该在用户明确表达偏好、或者Agent完成一次高价值交互后触发。我给save_memory技能设了一条铁律:用户说“记住”才写入,Agent自己不能主动把每轮对话都塞进长期记忆。要不然记忆库很快会变成垃圾场,查询质量直线下降。
4.4 上下文对象:技能间传递状态的桥梁
技能执行时要共享很多状态。比如当前用户ID、会话ID、页面内容、时间等。我设计了一个context字典,沿两条规则传递:
- 全局上下文:所有技能可读,存用户ID、会话ID等基础信息;
- 技能本地上下文:单个技能内部使用,执行完释放。
这种设计让技能保持“无状态”特性,技能之间不互相依赖全局变量,只依赖传入的context。排查问题的时候特别舒服,不会出现“哪个技能改了全局状态导致另一个技能行为怪异”的灵异事件。
5. 常见问题与排查技巧实录
这一节把我实战中遇到的问题整理成速查表,再挑几个典型的展开说说。
| 现象 | 常见原因 | 排查思路 | 解决办法 |
|---|---|---|---|
| 技能存在但模型不调用 | 描述过于抽象/与用户表达匹配度低 | 把技能目录单独发给模型,问它“这个请求你会调哪个技能” | 在描述中增加触发词和同义表达 |
| 模型调用了错误的技能 | 多个技能边界重叠描述不清 | 查看模型实际看到的技能描述 | 明确“不适用场景”字段 |
| 模型传参错误 | 参数名不规范/缺少示例 | 打印模型返回的arguments | 参数名贴近自然语言,加上范围和默认值 |
| 技能报错后模型编造结果 | 模型读不懂技术错误信息 | 检查技能返回的error字段 | 错误信息面向模型编写,增加tips修正建议 |
| 技能调用进入死循环 | 执行失败后模型反复重试相同调用 | 统计每轮tool_calls | 增加max_rounds上限和重试规则 |
5.1 模型就是不调用技能怎么办
这是被问得最多的问题。技能明明写得很好,模型就是“徒手回答”。我的排查套路很固定:
第一步,把系统提示词里的技能目录单独拿出来,发到你用的模型对话框里,配上一条真实用户请求,直接问:“你会调用哪个技能吗?”如果模型说不调,说明描述本身有问题,需要重写。
第二步,检查是不是上下文太长了。上下文越长,模型对工具调用的“注意力”越容易被稀释。如果对话历史有一大堆无关内容,模型会倾向直接回答而非调用工具。这时要把历史消息压缩成摘要。
第三步,降低技能选择成本。很多框架的function calling机制是默认auto,模型会权衡是否值得调用。你可以把调用阈值调低,或者在系统提示里加上一句:“只要用户请求涉及实时信息、计算、历史偏好,就必须调用对应技能。”
5.2 技能返回的错误,模型看不懂才是真问题
我遇到过最诡异的一个Bug:搜索技能因为网络超时报错,模型看了错误信息后,竟然告诉用户“搜索已完成,结果是某某”。用户当然不信,追问了几个细节,模型就开始编。
后来我意识到,问题的根源不是模型说谎,而是它没读懂我的报错。Python的traceback对模型来说只是一堆无意义符号,它只能基于自己的“语义猜测”继续作答。
从那以后,我要求所有技能的错误返回必须遵循一个模板:
{ "success": false, "error": "搜索服务超时,请稍后重试或换个说法描述搜索需求。" }错误文案要用模型能理解的口吻描述,最好再给一句修正建议。这个改动上线后,模型在技能失败后的胡言乱语基本绝迹。
5.3 多个技能部分重叠,模型经常选错怎么办
当你的技能里同时有“web_search”和“query_memory”,用户说“帮我查一下上周和A总聊了什么”,模型可能去调web_search而不是query_memory。因为“查一下”这个词让模型觉得需要搜索。
这种场景我的解决办法是:在描述里明确技能之间的边界。
query_memory的描述改成:“当用户需要回顾本系统内已保存的历史对话、总结、偏好时使用。是系统内部记忆检索,不是互联网搜索。如果用户需要外部实时信息,请用web_search。”
看起来只是加了几句“不是”和“如果”,但模型做决策时的匹配准确率提升非常明显。这本质上是在帮模型划分类别边界。
5.4 技能调用卡死和token爆炸的防御
多技能编排时,最容易出现的问题就是轮回转:模型不断调用技能,但始终得不到最终结论。除了调度层加max_rounds之外,我还会加一个“无进展检测”:如果连续两轮模型都在调同一个技能、传相似的参数、拿到相似的结果,就直接打断,让模型改用其他方式回答或者向用户澄清。
还有一个常见的token浪费点:技能返回结果太长。比如web_search返回了十个网页的全文摘要,模型读这些内容要花大量token。建议在技能结果里做预裁剪,只保留关键字段,并把超长文本用“标题+首段+链接”的结构压缩。
6. 给Agent技能体系长期维护的几条建议
6.1 技能新增大战:别急着加,先看调用日志
我一开始的冲动是,遇到一个新需求就给系统加一个新技能。结果不到两周,技能数从12涨到25,但调用质量反而下降了。后来我给自己立了个规矩:一个新能力需求出现三次以上,再考虑加技能。而且加之前先翻历史对话,确认这几条需求能归纳成同一个技能逻辑。
技能越来越多不是好事,技能的“平均选择准确率”会随着数量增加而下降。保持精简,是技能体系长期稳定的关键。
6.2 SKILL.md也要版本化
技能代码可以入库版本管理,但SKILL.md经常被人忽略。其实描述文档变了,模型的调用行为就可能完全改变。我现在把SKILL.md和技能代码放在同一个仓库,任何改动都走pull request。如果线上调用准确率有波动,第一步就是回退最近的SKILL.md改动。
6.3 用真实对话做技能调用回归测试
每个技能目录下的test文件,我不只是测代码逻辑对不对,还测“模型视角下的调用正确率”。具体做法是:准备一批标准用户问答对,跑一遍完整Agent流程,统计技能调用命中率,作为技能体系的基准指标。每次改动后跑一遍,低于基准就回滚。这个方法听着笨,却是最让我放心的护城河。
最后再分享一点个人经验:构建技能体系是一个持续迭代的过程,不要追求一步到位。先按一个典型场景搭出最小可用的技能闭环,跑通之后再慢慢扩充。技能体系的收益是长线的,前期的设计和维护投入,会在后面每一次新增能力时加倍还回来。