在搞Agent相关项目时,我最大的感受是:模型能力再强,如果每次任务都靠"现场自由发挥",结果就是时好时坏、难以复用。真正让Agent从"玩具"变成"生产力工具"的,恰恰是它背后能不能沉淀一套稳定、可插拔、可验证的能力集合。这个"agent-skills"项目,本质上就是给Agent装配一套技能系统——把高频、确定性的操作封装成"技能",让模型通过调用技能去完成任务,而不是每次都从零推理。
这篇文章我会从设计思路、核心机制、完整实现到踩坑排查,把怎么搭一套可用的Agent技能系统讲透。适合正在做Agent应用开发、想提升模型任务执行稳定性的工程师,也适合刚接触这一块但对"工具调用"「函数调用」已有基本概念的读者——只要你清楚Agent本质是"LLM + 工具 + 循环",后面的内容都能跟上。
1. 为什么Agent需要一套"技能系统"
1.1 从"会说话"到"会干活":Agent能力封装的必然选择
早期的Agent应用很简单,给模型扔一段Prompt,让它直接输出结果。但一碰到需要操作外部系统、查文件、调API、处理结构化数据的情况,这种裸奔式写法立刻露馅:模型记不住对话外的状态、输出格式飘忽不定、同样的任务换个说法可能就执行失败。
后来大家开始给Agent挂"工具",用Function Calling让模型选择调用哪个函数。这一步确实解决了不少问题,但也暴露了新的麻烦——工具的粒度怎么定?如果把"查数据库"做成一个工具,那"查用户表"和"查订单表"是分开还是合并?如果每个细粒度操作都做成工具,函数列表会膨胀到模型难以选择;如果做大而全的工具,参数设计又变得极其复杂,模型经常传错参数。
agent-skills想解决的就是这个中间层问题:在模型和具体工具之间,加一层"技能"抽象。一个技能不只是单个函数,它可能包含触发条件、前置校验、执行流程、结果格式化、失败降级策略——它是一整套可复用的行为封装。模型不需要知道技能内部怎么实现,只需要理解"这个技能是干什么的、什么时候该用、需要什么参数"。
1.2 技能、工具、工作流:概念边界与项目定位
聊技能系统之前,得先把这个概念和目标对齐。在agent-skills的语境里:
- **工具(Tool)**是最底层的能力单元,对应一个具体的函数或API调用,比如
read_file、execute_sql。 - **技能(Skill)**是面向任务的能力封装,内部可以编排多个工具调用,包含自己的参数校验、中间逻辑、异常处理,比如"代码仓库检索"技能,内部会依次调用
list_files、read_file、grep_search。 - **工作流(Workflow)**是跨技能的流程编排,比如"从issue到PR"的完整链路。
agent-skills聚焦在第二层。它不替代工具层,也不强行做工作流引擎,而是把那些"经常组合使用、步骤相对稳定"的操作固化下来,让模型用一个语义化的技能名就能触发一整段逻辑。
这套设计最直接的价值是:降低了模型的选择成本。模型面对的是十个技能而不是一百个工具,选择精度高得多。同时技能的复用性也上来了——同一个技能可以在不同任务里反复调用,不需要针对每个场景重写Prompt。
1.3 agent-skills的核心能力清单
从落地角度看,这套技能系统需要具备六个基础能力:
- 技能注册:把技能元信息集中管理,包括名称、描述、参数Schema、版本号。
- 技能发现:让LLM能够根据任务描述自动匹配合适的技能,靠的是技能描述的质量和参数约束的清晰度。
- 技能执行:按定义好的流程调用底层工具,处理中间状态和结果格式化。
- 技能组合:支持一个技能内部调用其他技能,形成层级化的能力结构。
- 技能验证:离线跑测试用例,确保技能在给定输入下行为符合预期,避免上线后出乱子。
- 技能热更新:在不重启服务的情况下新增或调整技能。
这六个能力对齐了,Agent的技能体系才算真正立住了。下文我会逐个讲清楚实现要点。
2. 整体架构与核心设计思路
2.1 技能的"三段式"结构:清单、描述、实现
在agent-skills里,一个完整的技能由三部分构成:
技能清单(Registry):全局维护一份技能元数据列表,相当于技能的"通讯录"。每条记录包含技能名、简介、参数JSON Schema、入口函数名、版本号、依赖关系。这份清单既给LLM做选择用,也给执行引擎做路由用。
技能描述(Descriptor):每个技能都有自己的描述文件,通常用Markdown或YAML维护。描述文件是对"何时该用、何时不该用、参数怎么填、返回什么"的详细说明。描述写得好不好,直接决定模型会不会选对技能——后面第四章会专门讲这个坑。
技能实现(Impl):实际执行的代码逻辑。可以是单个Python函数,也可以是一组函数加配置文件。实现层不直接暴露给LLM,LLM只跟描述和参数打交道。
三段式的核心用意是解耦:描述可以随时调整而不动代码;实现可以单独测试而不依赖LLM;清单则提供一个统一视图,方便调试时查看"模型到底有哪些技能可选"。
2.2 技能注册与发现:让模型知道"你会什么"
技能注册发生在服务启动阶段。系统会扫描配置好的技能目录,解析每个技能的描述文件,校验参数Schema合法性,然后构建Registry对象。这个Registry会被注入到两处:一是LLM的函数列表,用来生成调用候选;二是执行引擎的路由表,用来把技能名映射到实际函数。
技能发现的核心挑战是候选太多时模型容易蒙。实践下来,技能数量控制在8到12个以内时,模型的选择准确率明显更高;超过20个,误选率就会上升。如果你确实有几十个技能,得加一层"分组发现":先按领域分几大类,第一轮让模型选类,第二轮在类内选具体技能。
技能描写的格式也直接影响发现准确率。我的建议是每个技能至少包含四段信息:
- 名称与别名:名称简短达意,别名覆盖常见说法。
- 用途描述:一两句话说清楚"干什么用",避免含糊的表达。
- 适用场景:什么时候该用这个技能,最好带正反例。
- 参数说明:每个参数的用途、类型、必填性、取值范围。
这块内容看起来简单,但对效果的影响远超想象。描述仔细打磨过的技能,选择准确率能提高两成以上。
2.3 技能编排引擎:从单技能到复合技能
复杂任务往往不能靠单个技能解决,比如"帮我总结一下这个仓库的代码结构并生成架构文档",至少涉及文件遍历、代码阅读、文档模板三个环节。如果硬把整个过程塞进一个技能里,技能会变得臃肿难维护。更好的做法是把拆出来的小能力做成原子技能,再通过编排层组合调用。
agent-skills里做了一个轻量的编排机制:技能实现内部可以通过self.call_skill("skill_name", params)来调用其他技能。每个技能可以声明依赖哪些子技能,执行引擎在调用前会先校验依赖是否可用。这个设计借鉴了函数调用的思路——技能之间是树状调用关系,而不是平铺的互相调用。
需要特别注意的是循环依赖问题。技能A依赖B、B依赖A这种Case必须静态检测,在注册阶段就报错,否则运行时就直接爆栈了。校验算法很简单:解析依赖图,做拓扑排序,发现环就抛异常。
2.4 为什么用JSON Schema描述技能约束
技能参数如果只写"描述"而不做结构约束,模型传参时就会出现各种"自由发挥":参数名对不上、类型传错、必填项遗漏。agent-skills在技能定义里强制要求提供JSON Schema,这有几点实质好处:
第一,结构化约束让模型更容易生成合法参数。大模型对JSON Schema的理解能力已经相当不错,只要把type、required、properties、enum这些字段写清楚,模型生成的参数基本能通过基础校验。
第二,Schema可以复用去做校验和重试。模型第一次传参不符合Schema时,执行引擎可以直接把校验错误返回给模型,让它根据错误信息重新生成。这比让模型瞎猜参数要高效得多。
第三,Schema能生成文档和测试用例。基于Schema自动生成Mock参数,可以批量跑技能的单测和回归测试,省了很多手写测试的功夫。
这里有个小技巧:描述里别用"可以传入任意字符串"这种话,尽量给每个参数加上明确的description,必要时候用enum限定取值范围。模型对边界清晰的参数理解得远比开放参数更准。
3. 实操:从零搭建一个技能系统
3.1 项目结构与依赖准备
我直接用一个最小可复现的工程来演示。项目结构如下:
agent-skills/ ├── skills/ │ ├── registry.py │ ├── base.py │ └── builtin/ │ ├── file_reader/ │ │ ├── SKILL.md │ │ └── impl.py │ └── repo_searcher/ │ ├── SKILL.md │ └── impl.py ├── engine.py ├── llm.py └── main.py依赖方面只需要两个核心库:openai(或其他模型SDK)用于LLM调用,jsonschema用于参数校验。为了方便演示,我没有引入重型框架,实际的技能调用分发逻辑全部自己实现,这样你能看清底层原理,换成LangChain这类框架时也更容易对应上。
3.2 定义技能清单:SKILL.md与技能目录规范
每个技能目录下放一个SKILL.md,作为技能的描述文件。以file_reader为例:
--- name: file_reader version: 1.0.0 description: 读取指定文本文件的内容,支持按行范围截取。 when_to_use: 当需要查看文件内容、提取文件片段、确认代码实现细节时使用。 when_not_to_use: 需要搜索文件时请使用repo_searcher,不要使用本技能。 params: path: type: string description: 文件绝对路径或相对项目根路径。 required: true start_line: type: integer description: 起始行号,从1开始,缺省表示从文件开头。 required: false end_line: type: integer description: 结束行号,包含该行,缺省表示读到文件末尾。 required: false returns: type: object properties: content: type: string description: 读取到的文件内容。 total_lines: type: integer description: 文件总行数。这个Markdown头部实际是一份可解析的元数据。注册程序在扫描目录时会读取---之间的YAML块,转成技能描述对象。正文部分不会被解析进元数据,主要给开发者自己看,方便维护。
when_to_use和when_not_to_use这两项是我强烈建议保留的。它们相当于在告诉模型"边界在哪里",比单纯描述功能更管用,能显著降低误调用率。
3.3 技能实现层:以文件检索技能为例
技能实现就一个普通Python类,继承BaseSkill即可。以下是repo_searcher的核心实现,注意它内部组合了file_reader技能:
# skills/builtin/repo_searcher/impl.py import os from skills.base import BaseSkill class RepoSearcherSkill(BaseSkill): name = "repo_searcher" version = "1.0.0" def run(self, params: dict, context: dict): keyword = params.get("keyword") path = params.get("path", ".") file_patterns = params.get("file_patterns", ["*.py"]) max_results = params.get("max_results", 20) results = [] matched_files = self._find_files(path, file_patterns) for file_path in matched_files[:50]: content = self.call_skill("file_reader", { "path": file_path, })["content"] if keyword in content: lines = content.splitlines() for idx, line in enumerate(lines, 1): if keyword in line: results.append({ "file": file_path, "line": idx, "text": line.strip(), }) return {"results": results[:max_results]} def _find_files(self, root, patterns): matched = [] for dirpath, _, filenames in os.walk(root): # 跳过隐藏目录和依赖目录 if any(part.startswith(".") for part in dirpath.split(os.sep)): continue if "node_modules" in dirpath or "venv" in dirpath: continue for fname in filenames: if any(fname.endswith(p.replace("*", "")) for p in patterns): matched.append(os.path.join(dirpath, fname)) return matched关键技术点是self.call_skill这个方法,它由基类提供,执行引擎注入一个技能分发器后,技能之间就能互相调用了。这样做的好处是:技能实现里不需要关心调用来源是LLM还是其他技能,统一走同一套分发逻辑,行为一致也方便追踪。
3.4 技能执行引擎:注册、分发、校验一条龙
引擎是整个系统的承重墙。它的职责有三块:注册技能、接收LLM的技能调用请求、执行技能并返回结构化结果。
# engine.py import inspect import jsonschema from skills.registry import SkillRegistry class SkillEngine: def __init__(self, registry: SkillRegistry): self.registry = registry def register_skill(self, skill_instance): descriptor = self.registry.get_descriptor(skill_instance.name) schema = descriptor["params"] # 预检参数Schema是否合法 jsonschema.Draft7Validator.check_schema(schema) self.registry.add(skill_instance) def execute(self, skill_name: str, params: dict, context: dict): skill = self.registry.get(skill_name) if skill is None: raise ValueError(f"unknown skill: {skill_name}") descriptor = self.registry.get_descriptor(skill_name) schema = descriptor["params"] # 校验参数 try: jsonschema.validate(instance=params, schema=schema) except jsonschema.ValidationError as e: return { "status": "error", "error_type": "invalid_params", "message": str(e), } # 执行 try: # 注入技能分发器 skill.set_dispatcher(self.execute) result = skill.run(params, context) return {"status": "success", "data": result} except Exception as e: return { "status": "error", "error_type": "runtime_error", "message": f"{type(e).__name__}: {str(e)}", }这里有一个设计细节值得注意:参数校验失败并不直接抛异常,而是返回一个结构化错误对象。原因在于,当调用方是LLM时,抛异常会导致整个Agent链路中断;而返回结构化错误,可以让上层把message回传给模型,让模型自行修正参数后重新发起调用。这比"一错就挂"的体验好太多。
3.5 接入LLM调用层:函数调用与结果回填
有了技能注册表和引擎,剩下的就是把技能列表暴露给LLM,处理模型发来的函数调用请求。我用工具调用(Function Calling)的方式实现,这也是目前最主流的方式。
# llm.py import json from openai import OpenAI client = OpenAI() def build_tools(registry): """把技能列表转换成OpenAI Function Calling格式""" tools = [] for descriptor in registry.list_descriptors(): tools.append({ "type": "function", "function": { "name": descriptor["name"], "description": descriptor["description"], "parameters": descriptor["params"], } }) return tools def run_agent(user_query): registry = build_registry() # 注册扫描 engine = SkillEngine(registry) tools = build_tools(registry) messages = [{"role": "user", "content": user_query}] for step in range(5): resp = client.chat.completions.create( model="gpt-4o", messages=messages, tools=tools, ) msg = resp.choices[0].message if not msg.tool_calls: return msg.content # 执行技能调用 for tool_call in msg.tool_calls: skill_name = tool_call.function.name params = json.loads(tool_call.function.arguments) result = engine.execute(skill_name, params, context={}) # 把结果回填到对话,让模型继续推理 messages.append({ "role": "assistant", "content": None, "tool_calls": [tool_call.model_dump()], }) messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": json.dumps(result, ensure_ascii=False), }) return "reach max steps"核心逻辑不难理解:循环里把技能的返回值拼到对话消息里,模型根据这些结果决定下一步动作,直到它认为任务完成、不再发起技能调用为止。这个循环就是Agent最基本的执行形态,agent-skills只负责让"技能选择"和"执行质量"更可控,而循环本身不设限。
实际项目里我会把循环轮数调高到10到15轮,并加上任务终止条件判断,避免模型在某些失败场景下无限兜圈子。
3.6 技能验证链路:离线测试与回归
Agent赛道最大的痛点是回归问题——昨天能跑通的流程,今天换了个模型版本或调整了提示词,结果就变了。技能系统能缓解这个问题,前提是对技能做离线验证。
我在项目里为每个技能配了一个tests.yaml,记录典型的输入输出对:
cases: - name: "读取文件头部" params: path: "README.md" end_line: 5 expect: status: "success" data.total_lines: 10 - name: "文件不存在" params: path: "not_exist.md" expect: status: "error" error_type: "runtime_error"验证跑起来很简单:加载一个技能,遍历测试用例,执行并对比期望结果。这一步做不了全自动的智能判断,但能防住大部分低级错误。更重要的用途是配合"效果评估":把技能描述调整后,跑一遍全量用例,看看哪些用例从success变成error,或者参数Schema变更后哪些调用会挂掉。这套机制保证你在快速迭代的时候,不至于把之前的成果改没了。
4. 实战中的常见问题与排查技巧
4.1 技能描述太笼统,模型总是选错技能
这是我被问得最多的一个问题。症状表现是:任务明明应该走A技能,模型却选了B技能,或者干脆编造一个技能名。
排查思路先看注册表,确认模型实际能看到哪些技能描述。然后逐条审视描述,看是否存在"语义重叠"。比如"file_reader"描述成"读取文件内容"和"repo_searcher"描述成"搜索代码内容",模型在遇到"看一下这个文件里的某段代码"时就可能摇摆不定。
解决方法是把描述改成"决策导向"而不仅是"功能导向"。例如:
- file_reader:仅用于读取文件内容,适用于已知具体文件路径的场景。如果不知道路径、需要搜索包含某个关键字的文件,请使用repo_searcher。
- repo_searcher:在项目内搜索包含指定关键字的文件和行号,适用于需要"找到文件"但不清楚文件位置的场景。
这种"该用我"和"别用我"并存的写法,模型踩坑的概率会大幅下降。
4.2 技能执行失败后,模型"嘴硬"不承认
技能调用返回了错误码,但模型在下一轮回复里直接说"已完成",完全不引用错误信息。这种情况在复杂任务里很常见。根因多半是工具调用结果里携带的信息不够"扎眼",模型在一堆消息里没能有效识别出错误。
我的做法是统一错误响应格式,并且把status字段放在最前面,同时在错误信息里加上error_type和message这样的结构化字段。像这个例子:
{ "status": "error", "error_type": "invalid_params", "message": "参数path不能为空,请检查后重试" }一旦执行引擎返回status: error,上层Agent循环里要强制要求模型重新规划。可以加一句系统提示:只有当所有工具调用的结果都是success时,才允许输出最终回复。这一步能显著减少"假装成功"的情况。
4.3 多技能组合时的上下文污染
技能编排时容易踩一个隐形坑:A技能返回的结果里带了大段无关信息,模型在后续推理时被这些冗余内容带偏,生成结果变得奇怪。尤其是把大文件全文返回给LLM的情况,费token还容易超上下文窗口。
对策有两个方向。一是"按需裁剪",技能返回结果尽量精简,只保留与任务直接相关的片段。比如file_reader默认只返回前200行,或根据参数截取指定行区间,而不是无脑全量返回。二是"分段消费",如果必须读大文件,让技能只返回"每个段落的摘要"或"命中关键字的位置",等模型明确需要看某段原文了再回过头来读取。
4.4 技能热更新的版本管理
开发调试阶段经常要改技能描述或者实现,又不想每次重启服务。我在引擎里加了一个reload_skill(skill_name)方法,内部会重新扫描技能目录、校验Schema、替换注册表里的实现和描述。但这里有个隐患:如果在线请求刚好在reload的间隙命中这个技能,可能拿到新旧混搭的配置。
稳妥的做法是把版本带上:技能元信息里维护version字段,reload时会把它写入注册表,并打印一条操作日志。排查线上问题时,看到日志就知道当前生效的是哪个版本,不至于出现"我改了代码但线上行为不对"这种灵异事件。另外,所有技能变更都走git记录,发布到生产环境之前先跑一遍第三章说的离线回归用例。
4.5 排查技巧速查表
我把常见问题整理成了一张速查表,遇到问题可以直接对照着看:
| 现象 | 可能原因 | 排查与解决办法 |
|---|---|---|
| 模型选错技能 | 技能描述语义重叠、边界不清 | 增加when_not_to_use字段,重写描述 |
| 参数频繁校验失败 | JSON Schema约束过宽松或过严 | 精简参数数量,用enum限定取值 |
| 技能执行慢 | 技能内部同步调用了太多子技能 | 检查是否有重复读取文件、重复遍历目录 |
| 返回结果被截断 | 结果超出模型上下文窗口 | 技能层做裁剪,只返回关键信息 |
| 技能改了没生效 | 缓存问题或reload失败 | 查看reload日志,确认version已经变更 |
| 模型陷入死循环 | 技能反复返回同一错误 | 在循环层加最大轮数,超过就强制终止 |
| 嵌套技能调用爆栈 | 存在循环依赖 | 注册阶段做依赖图拓扑排序,发现环直接报错 |
这张表不是标准答案,但它覆盖了我在实际项目里遇到频率最高的几类问题。你在使用技能系统的过程中大概率也会碰到,到时候可以对照着排查。
5. 后续还能怎样扩展
技能系统一旦跑通,后续扩展空间是很大的。我现在在尝试的方向主要有三个,分享出来给你参考。
第一个方向:技能自动编排。目前技能组合靠的是LLM在运行时的动态选择,但有些固定流程(比如"先检索再总结再输出")完全可以沉淀成预置的编排模板。我在agent-skills里加了一个skill_chain配置,允许把多个技能按顺序串起来,类似于一个迷你版工作流。遇到"每次都要先查文件再调API再格式化"这种常规操作,直接用编排模板跑,速度和稳定性都更好。
第二个方向:技能效果评估。前面提到的离线用例是基础,但真正有价值的评估是端到端的效果评估——给定一个有明确标准的任务,看Agent最终能不能完成。比如"从项目里找出所有调用过某API的地方并统计次数",跑完Agent后和真实结果对比,得到一个分数。这套评估跑得越勤,技能描述和参数约束的优化就越有依据。
第三个方向:技能共享与复用。不同项目之间其实有很多技能是相通的,比如文件检索、网页抓取、数据库查询。我正打算把内置技能做成一个独立包发布出去,让其他项目直接依赖,而不是每个项目都重新写一遍。这个思路如果走通,Angent开发的重心就会转向"怎么组合技能"而不是"怎么实现技能"。
关于"agent-skills"这个项目,我最终的一个体会是:Agent工程质量的关键,不在模型选得有多新,也不在提示词写得有多花哨,而在你给模型搭的这套"脚手架"够不够稳。技能系统就是脚手架的重要一环——它能让你沉淀经验、提升稳定、减少重复劳动。如果这篇文章能帮你在这儿少走几步弯路,我就觉得值了。