1. 技能系统:让 Agent 从“会聊天”变成“能干活”
如果你接触过 Agent 开发,大概率会遇到一个尴尬的现状:模型本身很聪明,能写诗、能总结文档、能跟你聊人生,但真要让它去执行一个具体操作——比如抓取网页数据、操作某个工具、完成一次支付流程——它就露馅了。这背后的原因很简单:大模型是“大脑”,但它没有“手”。而 agent-skills 这一类方案,解决的就是“给大脑装上手”的问题。
我第一次意识到这件事,是在做一个内部自动化场景的模拟项目。当时我用模型去处理一组业务表格数据,模型理解需求、拆解任务都没问题,但真要让它去读取文件、调用接口、写回到数据库,每一步都要靠外部代码硬编码进去。代码越写越重,逻辑越叠越乱,关系全靠各种 if-else 维持,最后整个系统变成一团纠结的线。后来我接触到了技能(Skills)这个概念,才找到了一条相对清爽的路径:把 Agent 可以执行的一类特定行为,封装成规范、可复用、机制统一的技能模块,由模型本身来决定“此刻该调用哪个技能”以及“怎么调用”。这也是 agent-skills 这类项目最核心的价值所在。
这篇内容适合正在做 Agent 应用、大模型工具调用的开发者,也适合对 Agent 落地方式感兴趣的初学者。我会从设计思路、核心细节、代码实现、踩坑复盘四个纬度,围绕技能系统本身把整个链路讲透。你不需要看过任何特定开源项目,只要带着“我想给 Agent 装上可用能力”这个需求,就能把这套思路带走。
2. 为什么 Agent 需要一套技能系统
2.1 能力扩展与知识扩展的本质区别
在搭建 Agent 时,很多人会混淆两个概念:知识扩展和能力扩展。
知识扩展,是往模型里塞信息。比如把产品文档切块后做向量索引,让模型在回答前能检索到相关资料。这条路的本质是让模型“学到”原本不知道的事情,但它改变不了模型的行为边界。
能力扩展,是让模型“能做”原本做不到的事情。比如模型没有真实世界里的操作权限,你给它接上浏览器控制能力,它就能打开网页;接上命令行执行能力,它就能运行脚本、管理文件。而技能系统,本质上就是在做能力扩展这件事的标准化框架。
你可以拿人来做类比。一个人刚毕业进入公司,知识储备没问题,但没法直接上手干活,因为缺的是“岗位技能”——怎么用公司内部的系统、怎么走审批流程、遇到问题该找哪个部门。这些技能被总结成标准作业流程后,新员工能快速上手,老员工也不会漏步骤。
2.2 直接硬编码工具调用,到底卡在哪里
没有技能系统的 Agent 也能干活,最常见的做法就是硬编码。在代码里写好每个函数,模型需要时通过函数调用机制去触发。问题在于,当功能数量超过十几个之后,这套方案会逐渐失控。
第一个是共性问题。很多工具之间存在相似逻辑,比如“读取文件”和“读取网页”,底层都要做内容获取和清洗。硬编码时,同样的逻辑在多个函数里重复出现,改一处忘一处,维护成本直线上升。
第二个是参数复杂度问题。真实世界的工具往往有复杂的输入参数。以“发送邮件”为例,需要收件人、主题、正文、附件路径、是否抄送等。如果你把这些参数直接摊开给模型自由发挥,它大概率会漏传或者传错格式。
第三个是容错与升级问题。硬编码的函数通常缺少统一的重试机制、超时控制、异常上报。某个工具挂了,连带整个 Agent 流程中断,排查起来极其痛苦。
技能系统正是为了解决这三类问题而设计的。它把“一个能力的完整定义”作为最小单元,包含能力描述、参数说明、执行逻辑、异常处理,然后统一注册到一个管理中心,让 Agent 的主循环按标准方式去发现和调用它们。
2.3 技能与普通函数、插件到底有什么不同
要理解技能系统,需要先厘清一个边界:技能到底是什么?它和普通函数、插件有什么区别?
普通函数是代码层面的最小调用单元。它能做一件事,但它是给程序员用的,不是给模型用的。函数名可以叫process_data(),参数可以是dict,没有自然语言描述,模型根本不知道该在什么场景下用它。
插件是一个更粗粒度的软件包。一个插件往往捆绑了多个能力,比如一个“浏览器插件”同时包含页面导航、元素点击、表单填写、内容提取等。插件解决的是“能力范围”问题,但它没有定义清楚“模型应该如何精准地选择其中某一个动作”。
技能介于两者之间。它是一个面向模型设计的、语义清晰的行为单元,具备三个核心特征。
第一,技能是自描述的。每个技能都有明确的名字和用途说明,让模型能“读懂”它是干什么的。第二,技能是参数规范化的。它通过 JSON Schema 约束输入结构,让模型生成符合格式的参数。第三,技能是可组合的。一个复杂任务可以被拆解为多个技能的有序组合,每个技能只负责自己那一小段。
3. 核心设计:技能系统应该由哪几部分组成
3.1 技能的三层架构:执行层、注册层、编排层
我在多次重构 Agent 项目的过程中,逐渐沉淀出一套自己比较顺手的技能系统结构,大致可以拆成三层:执行层、注册层、编排层。每一层解决一类独立的问题,层与层之间通过清晰的接口衔接。
执行层是技能的本体。它负责真正干活,比如发起 HTTP 请求、执行本地脚本、读写文件等。一个执行函数只做一件事,保证职责单一。执行层不应该关心模型怎么选它、参数怎么来的,它只负责“拿到参数,执行动作,返回结果”。
注册层是技能的索引。它维护一份技能清单,记录每一个技能的标识、描述、参数格式、执行入口。注册层承担着两个职责:一是给模型提供可查询的技能目录,让模型知道“我有这些技能可用”;二是给运行时提供技能解析能力,把模型的选择快速映射到具体的执行函数上。
编排层则负责更高维度的逻辑。当任务很复杂、需要多个技能协同完成时,编排层决定技能之间的先后顺序、依赖关系和衔接规则。这部分可以交由模型自主完成,也可以在特定场景里用预设流程来约束,后者更稳。
3.2 技能定义的数据结构,如何组织才算合理
技能是面向模型的,因此它的描述方式决定了模型使用的准确率。在我反复调过的坑里,发现一个好的技能定义应该包含以下核心字段。
标识(name)要短且唯一,比如web_fetch、file_write。尽量避免模糊词汇,比如do_something,这类名字在模型眼里没有任何区分度。
描述(description)要写清楚“这个技能解决什么问题”“在什么场景下使用”,最好是包含触发条件的完整句子,比如“当用户需要从网页中提取信息时使用此技能”。
参数声明(parameters)必须使用 JSON Schema 或等效结构,明确每个参数的名称、类型、是否必需、取值范围。这一步非常重要,它是模型生成结构化参数的唯一依据。
执行入口(endpoint或function)指技能的代码实现引用。在 Python 里可能是可调用对象,在服务化架构里可能是一个 HTTP 接口地址。
返回值规范(output_schema)容易被忽略,但对复杂系统很关键。它告诉你调用技能之后得到的结果是什么结构,方便编排层做下一步决策。
下面我给出一个偏通用的技能定义格式,用 JSON 表达往往比较直观。
{ "name": "web_fetch", "description": "根据给定URL获取网页正文内容,适合用于信息检索和内容提取场景。当用户提到某个网址、请求查询网页信息时使用。", "parameters": { "type": "object", "properties": { "url": {"type": "string", "description": "要访问的网页完整地址"}, "timeout": {"type": "integer", "description": "请求超时时间(秒),默认10"} }, "required": ["url"] }, "executor": "handlers.web.fetch_page", "timeout": 15 }3.3 技能与模型之间,靠什么机制真正打通
数据定义是静态的,真正让技能系统活起来的,是模型与技能之间的交互机制。目前主流的打通方式有两种。
第一种是函数调用(Function Calling)。模型在推理时会输出一个结构化指令,指明要调用的技能名和参数。你的代码解析这个指令,找到注册表中对应的执行函数,跑完后把结果回传给模型,模型再根据结果生成下一步回复或继续调用其他技能。这种方式适合大模型平台原生支持函数调用的场景。
第二种是自然语言路由。当你用的模型不支持函数调用时,可以将技能目录注入到系统提示词里,让模型以自然语言输出“我要调用某个技能,参数如下”。代码端再用一个轻量解析器去提取技能名和参数。这种方式兼容性好,但稳定性稍差,输出格式容易被模型的各种发挥带跑。
我在生产环境里的做法是:优先走函数调用机制,同时保留自然语言路由作为降级方案。两边指向同一个注册中心,路由层只负责解析,不包含业务逻辑,这样即使切换方案也不会影响技能的注册与执行。
4. 实操:从零到一,搭建一个可用的技能框架
4.1 定义技能基类,规范所有技能的“标准长相”
动手搭建技能框架时,我建议先做一个基类,用它来统一技能的接口。这样后续每增加一个新技能,只需要继承基类并实现核心方法,框架层面的调度逻辑完全不需要改动。
这里给出一个简洁的 Python 基类设计。
import json from typing import Any, Dict, Optional class BaseSkill: name: str = "base_skill" description: str = "Base skill class" parameters_schema: Dict[str, Any] = {} timeout: int = 10 def execute(self, params: Dict[str, Any]) -> Dict[str, Any]: raise NotImplementedError def to_manifest(self) -> Dict[str, Any]: return { "name": self.name, "description": self.description, "parameters": self.parameters_schema, "timeout": self.timeout, } def validate_params(self, params: Dict[str, Any]) -> Dict[str, Any]: # 此处可接入 jsonschema 库做严格校验 return params def run(self, params: Dict[str, Any]) -> Dict[str, Any]: valid_params = self.validate_params(params) try: result = self.execute(valid_params) return {"status": "success", "result": result, "skill": self.name} except Exception as exc: return {"status": "error", "error": str(exc), "skill": self.name}这个基类做了几件事:定义子类必须实现的execute方法;提供to_manifest生成对外暴露的技能描述;提供一个统一的run入口,负责参数校验、异常捕获和结果封装。注意,所有技能执行后都返回一个统一格式的结果,这对后续的编排和日志分析特别重要。
4.2 注册中心:所有技能的统一仓库
有了基类之后,下一步是写注册中心。注册中心维护一份技能清单,提供注册、查询、调用的核心接口。
class SkillRegistry: def __init__(self): self._skills: Dict[str, BaseSkill] = {} def register(self, skill: BaseSkill) -> None: if skill.name in self._skills: raise ValueError(f"Skill with name '{skill.name}' already registered") self._skills[skill.name] = skill def unregister(self, skill_name: str) -> None: self._skills.pop(skill_name, None) def get_skill(self, skill_name: str) -> Optional[BaseSkill]: return self._skills.get(skill_name) def list_skills(self) -> list[Dict[str, Any]]: return [skill.to_manifest() for skill in self._skills.values()] def execute(self, skill_name: str, params: Dict[str, Any]) -> Dict[str, Any]: skill = self.get_skill(skill_name) if skill is None: return {"status": "error", "error": f"Skill '{skill_name}' not found"} return skill.run(params)注册中心实现里有一个细节值得注意:注册时做了重名检查。这个看起来简单的约束,在实际项目中帮我避免了很多次隐性Bug。因为技能多了以后,最容易发生的问题就是两个人各自实现了一个“读取配置”的技能,后注册的覆盖先注册的,导致线上表现时灵时不灵。
4.3 写一个真实技能:文件读取
基类和注册中心到位后,就可以往里填具体技能了。我拿“文件读取”和“HTTP请求”两个技能举例,因为它们几乎在任何 Agent 项目里都会用到。
import os class FileReadSkill(BaseSkill): name = "file_read" description = "读取指定路径的文本文件内容,适合本地文件查看和内容分析场景。" parameters_schema = { "type": "object", "properties": { "file_path": {"type": "string", "description": "要读取的文件路径"}, "encoding": {"type": "string", "description": "文件编码,默认utf-8"} }, "required": ["file_path"] } def execute(self, params): file_path = params["file_path"] encoding = params.get("encoding", "utf-8") if not os.path.exists(file_path): raise FileNotFoundError(f"{file_path} 不存在") with open(file_path, "r", encoding=encoding) as f: return f.read()实现 HTTP 请求的技能类似,注意添加超时控制即可。
import requests class HttpRequestSkill(BaseSkill): name = "http_request" description = "发起HTTP GET请求并返回响应内容,适合访问API接口或抓取网页内容。" parameters_schema = { "type": "object", "properties": { "url": {"type": "string", "description": "请求的URL地址"}, "headers": {"type": "object", "description": "附加请求头,可选"}, "timeout": {"type": "integer", "description": "超时时间(秒),默认10"} }, "required": ["url"] } def execute(self, params): url = params["url"] headers = params.get("headers", {}) timeout = params.get("timeout", 10) resp = requests.get(url, headers=headers, timeout=timeout) resp.raise_for_status() return resp.text这两个技能非常简单,但已经覆盖了技能系统的最小闭环:定义、注册、校验、执行、返回。生产环境中你还可以在execute内部增加日志记录、调用计数、结果缓存,但基础骨架就是这些。
4.4 把技能接入 Agent 主循环
技能系统本身不直接产生价值,它必须被 Agent 主循环使用才能发挥效果。以下是一个最小可跑的接入示例。
# 初始化注册中心并注册技能 registry = SkillRegistry() registry.register(FileReadSkill()) registry.register(HttpRequestSkill()) # 模拟模型输出的技能调用指令 fake_model_output = { "skill": "http_request", "params": {"url": "https://example.com", "timeout": 5} } # 执行技能 response = registry.execute( fake_model_output["skill"], fake_model_output["params"] ) print(response)在实际项目中,fake_model_output来自模型的函数调用结果。你只需要把模型的输出解析后透传给注册中心即可。整个 Agent 主循环的核心切换逻辑可以抽象成:接收用户目标,让模型决定调用哪个技能,执行技能并返回结果,让模型根据结果生成下一步动作。这个循环反复执行,直到模型判定目标已完成。
5. 进阶:技能编排,让多个技能协同完成复杂任务
5.1 复杂任务为什么会卡在单技能上
单技能能解决的问题是有限的。比如用户提了这样一个需求:“访问某个网页,把里面的标题和正文提取出来,整理成 Markdown 格式,保存到本地文件。”这个请求涉及网页访问、内容提取、格式转换、文件写入四个环节。如果用单技能硬扛,要么技能做得很重、包含大量内部逻辑分支,要么干脆做不到。
技能编排就是为解决这类问题而生的。编排层负责把一个大任务拆解为技能调用序列,然后按顺序执行。难点在于,拆解和排序这件事该由模型自主完成,还是由代码预设。
我的建议是分层处理。简单、稳定的流程用预设编排,复杂、动态的流程交给模型自主规划。前者可控,后者灵活。两者结合,效果最好。
5.2 预设编排:把流程写死在代码里
预设编排适合那些步骤相对固定、很少场景变化的流程。以“网页内容转存”这个需求为例,可以直接写一个编排函数。
def compose_web_to_markdown(registry, url, output_path): http_result = registry.execute("http_request", {"url": url}) if http_result["status"] != "success": return http_result content = http_result["result"] md_content = "# 网页内容\n\n" + content write_result = registry.execute("file_write", { "file_path": output_path, "content": md_content }) return write_result这个编排函数的好处是执行路径清晰,结果可控,不会让模型在半路发挥。坏处是只能针对固定场景,没法覆盖用户千奇百怪的个性化需求。因此,它适合做业务层封装,而不适合做通用能力。
5.3 模型自主规划:只提供技能目录,让大模型决定执行顺序
更通用的方案是模型自主规划。将注册中心里的技能清单整体注入提示词,然后让模型输出一个技能调用计划,代码再依次执行。
system_prompt = """ 你是一个任务规划器。请根据用户需求,从以下可用技能中挑选合适的技能并规划执行顺序。 技能列表: {skills} 请严格按以下JSON格式输出规划: {{"steps": [{{"skill": "技能名", "params": {{...}}}}, ...]}} """这一招的通用性好,但你得承受一个代价:模型的规划不一定最优,甚至偶尔会出错。因此在执行层面,我通常会加一个“步骤结果反馈”机制,即每执行完一步,都把结果拼进上下文再交给模型,让它在下一步规划时能看到上一步的执行效果。这比一次性生成完整计划要稳定得多。你可以把这种模式想象成让员工分步汇报工作进展,每完成一步就确认一次,远比让他一次性做完一周的事情后再汇报要可控。
6. 真实项目里的坑,和排查技巧
6.1 技能描述写得太虚,模型选错技能
技能描述是模型选择技能的唯一依据,但很多人会把描述写得含糊不清。比如“该技能用于处理文件操作”,这句话对模型来说基本没什么区分价值。文件操作具体是读还是写?是只读还是删除?模型不懂。
我踩过最深的坑是一个内部项目里的“数据查询”技能描述与“日志分析”技能描述高度相似,结果模型经常把日志分析的任务路由到数据查询上,导致执行结果千奇百怪。后来我强制规定了描述写法:谁、在什么场景、输入什么、输出什么。比如“当用户希望以结构化方式查询订单数据时使用此技能,输入查询条件,输出匹配的订单列表。”这种带触发场景的写法,准确率明显提升。
6.2 参数校验不严格,执行错误难定位
技能参数出问题的概率极高,尤其是模型输出的参数经常出现类型不对、字段缺失、枚举值非法等问题。如果不做严格校验,错误往往要到执行函数内部才爆发,排查链路长、定位慢。
解决办法是在技能基类的run入口统一接一个 JSON Schema 校验器。我推荐直接用jsonschema库。校验不通过时,直接返回格式化的错误信息,并把这个错误信息拼接到提示词中,让模型自己修正参数。
import jsonschema def validate_params(self, params): schema = self.parameters_schema if not schema: return params jsonschema.validate(instance=params, schema=schema) return params加了这段逻辑后,参数错误会第一时间被拦截,并且模型在下一次推理时能看到报错说明,能自动调整参数格式。这个正反馈机制是让整个系统稳定下来的关键,实测下来,连续三轮参数校验通过率能超过九成。
6.3 不同技能职责重叠,注册时互相覆盖
技能多起来以后,容易出现两个技能功能重叠的情况。比如“获取天气”和“查询天气”本质上是一个能力,但如果不同的人各写了一个并都注册进去,模型就会面临选择困难,甚至出现随机选中一个的情况。
我的处理策略是:注册时强制查重,名字相同的直接拒绝注册。同时每次会话开始前,把技能清单过一遍,超过一定数量的重复语义技能会被合并。另外,注册日志要做全量留存,方便排查“技能被谁覆盖”这类问题。
6.4 技能执行超时与失败重试
Agent 技能在执行真实操作时,经常遇到外部依赖超时或暂时性故障。如果技能本身不支持重试,一次网络抖动就会终结整个任务。
我在技能基类里加了一个简易的重试机制:对系统异常和网络类异常做自动重试,对参数校验错误则不重试,直接返回让模型修正。重试策略采用指数退避,初始等待 1 秒,最多重试 3 次。这样既不会无限等待,也不会把资源消耗在无效重试上。
6.5 排查技能链路问题的三个入口
排查技能系统问题时,最常关注的切入点是日志、技能清单校验和人工测试。
每次调用技能时,建议统一记录技能名、参数、耗时、结果状态。出现问题时,先通过日志判断问题出现在路由阶段、校验阶段还是执行阶段。技能清单需要定期导出来人工检查一遍,看描述是否精准、参数是否合理。新增技能后,先通过命令行工具直接调用一次,排除掉外部依赖问题,再接入 Agent 流程,这样能隔离开大部分表层故障。
7. 技能系统后续可以怎么扩展
技能系统搭完基础框架后,后续的扩展方向其实很清晰。我目前正在尝试的方向是把技能元数据单独存储到外部索引里,这样技能不用全部塞进提示词,只检索与当前任务相关的几个候选技能,再动态加载到上下文中。这样既能降低提示词长度,又能支撑成百上千的技能规模。
另一个方向是给技能增加调用统计与效果评分。每次模型调用技能时,记录执行结果和下游任务是否顺利完成。时间长了,这些数据能为技能自动排序提供参考,把高频高胜率的技能排在候选列表前面,让模型优先选择更可靠的能力。
还有一点,技能的原子性很重要。技能不要写得太重,宁可多拆几个小技能,通过编排组合出复杂能力,也不要在一个技能里塞满所有逻辑。前者维护方便,模型选择也更精准;后者虽然看起来一步到位,但逻辑复杂度会把错误率拖到不可接受的程度。
我在实际维护技能系统的过程中最深的体会是:技能的数量不是越多越好,而是越“清爽”越好。每一个技能都应该有明确的边界、清晰的语言描述、规范的参数结构,并且能被模型一眼看懂。好的技能像瑞士军刀里的一个小工具,功能单一,但每个都可靠顺手。把这件事做到位了,Agent 的能力边界才算真正打开。