半年前我开始做一个小项目,姑且叫它 agent-skills。起因很简单:我手上有个基于大模型开发的智能体,能聊天、能查资料、能操作内部系统,但用起来总有一种“什么都懂一点,什么都干不精”的别扭感。你问它技术问题,它给你长篇大论;你让它查一份具体合同,它在工具列表里翻半天,最后告诉你“我找了一下,好像没有”。问题不在模型能力,而在任务的拆解方式太粗放了。后来我重新设计了这套技能体系,把智能体的能力从“一堆工具”升级成“一组带使用说明书的技能包”,情况才彻底改观。
如果你也在做大模型应用,尤其是带工具调用或自主决策的 Agent,这篇文章值得看完。我会把 agent-skills 从设计思路、技能定义格式、注册调度机制,到实际踩过的坑,完整梳理一遍。不是讲概念,是讲我反复调过的真实工程方案,代码也是可以直接拿去改的。
1. 为什么需要一套 Agent 技能库
1.1 从一次失败的问答机器人改造说起
我最早做的是一个内部知识库问答机器人。设计很常规:把文档切段、向量化、存数据库,用户提问时先召回相似片段,再把片段拼进 Prompt 交给大模型生成回答。上线后跑了两周,发现一个特别尴尬的情况:用户问“帮我写一份关于新项目的周报”,机器人会检索出一堆文档,然后生成一份“看起来像模像样”的周报。但你仔细一看,里面引用的项目编号是半年前的,参与人员名字是错的,甚至连项目当前状态都过时了。
问题出在哪?当时我的 Agent 确实挂着几个工具,比如“文档检索”“项目信息查询”“人员信息查询”,但工具和任务之间没有建立起可靠的路由。模型遇到复杂任务时,经常随机挑一个工具用,或者干脆绕过工具直接靠训练数据里的记忆去编。你可以把这个阶段理解为:你把一堆专业设备扔给一个实习生,但不告诉他什么场景该用哪台设备,结果就是要么不用,要么乱用。
agent-skills 的出发点,就是要解决这种“设备有了,使用说明书没有”的尴尬。它不关心你底层接了多少 API、数据库、内部系统,它关心的是:把这些能力组织成一段段可以被模型理解的“技能单元”,每个单元都写清楚自己负责什么、在什么条件下被触发、需要什么参数、怎么执行、输出长什么样。模型不再面对一个扁平的工具清单,而是面对一本结构清晰的“能力菜单”。
1.2 技能化的核心价值:把 Prompt 工程沉淀成可复用资产
很多人都知道,调大模型应用,核心其实是在调 Prompt。于是你会遇到一个典型场景:项目需求变了,你在某个系统提示词里加了一句话;发现另一个场景也要这个能力,你又复制一版,改几个字;再后来,多处 Prompt 里的逻辑互相矛盾,A 场景改了,B 场景忘了同步。最后整个项目变成一座随时会塌的纸牌屋。
技能化解决的就是这个“混乱复制”问题。它把一段能力从“写在 Prompt 里的话”变成“一个独立封装的模块”,模块自带描述、参数、执行逻辑和调用约束。当多个场景都需要同一能力时,大家引用同一个技能模块,而不是各自复制一段内容。这样有三个直接好处:
第一,能力可维护。想调整搜索逻辑,只需要改一个技能模块,所有用到它的场景自动生效。
第二,行为可预期。每个技能的执行边界是清晰的,模型通过描述来判断“这个任务该不该用此技能”,而不是靠感觉。
第三,增量更轻松。新增能力时,不需要改动整体系统提示词,只需要新增一个技能包并告诉模型它的存在。
我在这个阶段还深刻体会到一件事:技能化的本质,是把人类的“岗位说明书”思想搬到了智能体身上。招人时你不会只告诉新人“你是工程师”,你会列岗位职责、协作边界、工作产出。Agent 也是一样,给它一个模糊的岗位,它就只能给你一个模糊的结果。
2. 技能体系的设计思路
2.1 技能长什么样:四段式结构
设计 agent-skills 时,我把每个技能定义成了四段式结构,这也是整个体系的骨架:
@dataclass class SkillDefinition: name: str description: str parameters: dict execute: callable instruction: str safety_tags: listname 是技能的唯一标识,必须简短、语义清晰。description 是写给模型看的一句话说明,核心作用是帮助模型做路由决策。parameters 定义调用该技能需要传入的参数,包含类型、含义、限制条件。execute 是真正执行技能的函数,可以是同步函数,也可以是异步任务,甚至可以只是一个描述性的“要让另一个子 Agent 完成”的元操作。
instruction 字段容易被忽略,却非常关键。它不是给模型看的,而是给调用方看的。比如技能内部要调用某个外部 API,但有鉴权流程、有超时限制,这些实现细节不适合塞进模型的上下文里,放进 instruction 字段就行。这样一段技能既能被大模型自主调用,也能被普通代码直接调用,做到了“人机同用”。
还要单独说明 safety_tags。它标注这个技能是否会操作外部系统、是否会发送消息、是否会修改数据,属于权限控制字段。模型可以在 Prompt 里看到技能描述,但真正执行前,框架会检查当前对话的安全级别是否满足技能要求。比如“发送邮件”这个技能,在测试环境中可以被自由调用,在正式环境中就要求用户二次确认。
2.2 技能描述怎么写才容易被调用
我发现很多人做技能库,最后死在“描述”上。常见写法是“执行文件搜索”。这种描述对模型来说基本无效,因为它没有告诉模型:什么时候该用?搜索的逻辑范围是什么?输入什么?输出什么?描述写得太笼统,模型就会在多技能场景下陷入选择困难。
我的建议是,把 description 写成一段“选择条件 + 能力边界 + 输出格式”的完整说明,篇幅控制在三到五行。以文件搜索技能为例,我实际用的是这样一段:
当用户需要根据文件名、路径关键词或者文件内容关键词查找本地文档时使用。支持按扩展名过滤,返回结果为文件路径列表,每条路径包含文件名、所在目录、修改时间和大小。如果用户想搜索的不是文档,而是在线网页或外部数据库,不要使用本技能。
这段描述解决了三件事:它的适用场景、它返回什么格式、它不负责什么。特别是“不要使用本技能”这种负面约束,能大幅降低模型误调用概率。我在实测中发现,加了负面约束后,误调用率下降了接近六成。
parameters 的写法同样有讲究。除了基本的类型定义,我强烈建议加一个枚举或示例值。模型在生成参数时,如果看到示例,会大幅度减少“编造参数”的情况。比如:
parameters: query: type: string description: 搜索关键词 example: 项目周报 file_type: type: string enum: [pdf, docx, xlsx, md] default: ""有了 enum 和 example,模型生成的参数就会收敛在一个安全范围内。我在早期版本里没做这个限制,模型经常传一个根本不存在的文件类型进来,结果就是技能抛异常,整个链路中断。
2.3 技能注册与上下文管理
技能库不能是“一筐技能扔进去”,必须有一个注册中心和清晰的上下文管理机制。我采用的是“手动注册 + 自动装载”策略。
在框架启动阶段,开发者把所有技能定义写入注册表,注册表会做三件事:检查重名、校验参数 schema、检查 execute 函数是否可调用。任何一个环节不通过,注册失败并抛出明确错误。这段注册逻辑虽然简单,但在团队协作时价值极大——多人同时往技能库添加能力,重名、错误配置是最常见的问题,在注册阶段拦掉,比运行阶段报怪错好太多。
上下文管理则要解决“模型到底该看到哪些技能”的问题。假设系统里有五十个技能,你不能把五十个技能描述全部塞进系统提示词,那样既占用上下文窗口,又会干扰模型的决策。我的方案是给技能打标签,当前对话基于意图识别结果,动态选择十个以内的技能进入模型视野。这就像图书馆有几十万册书,但读者面前只摆着相关书架上的那几本。
3. 从零搭建 agent-skills 技能框架
3.1 基础框架与技能载体
接下来进入实操环节。我会用一个最小可运行的 Python 项目,演示 agent-skills 的完整闭环。不要被代码量吓到,这个框架的核心逻辑其实只有不到两百行。
先创建项目结构:
agent-skills/ ├── skills/ │ ├── __init__.py │ ├── registry.py │ ├── base.py │ ├── file_search.py │ ├── schedule_notice.py │ ├── web_summary.py │ └── loader.py ├── main.py └── skills.yamlbase.py 里放的是技能基类,也就是上面见到的 SkillDefinition。为了更好用,我给它加了一个 from_dict 方法,方便从 YAML 配置创建技能声明:
# skills/base.py from dataclasses import dataclass, field from typing import Callable, Optional, Any @dataclass class SkillDefinition: name: str description: str parameters: dict execute: Callable instruction: str = "" safety_tags: list = field(default_factory=list) @classmethod def from_dict(cls, data: dict, execute: Callable): return cls( name=data["name"], description=data["description"], parameters=data.get("parameters", {}), execute=execute, instruction=data.get("instruction", ""), safety_tags=data.get("safety_tags", []), )这里我刻意把 execute 作为参数传入,而不是从配置里读取,因为代码函数不能被 YAML 序列化。配置文件只负责描述技能,真正的执行逻辑还是要回归到 Python 函数中。
3.2 核心调度逻辑:让模型学会选技能
技能调度是整个系统的神经中枢。它要完成两个任务:第一,把可用的技能描述组合成一段结构化的“系统提示”;第二,解析模型返回的调用意图,执行技能并组织最终回复。
我把调度逻辑写成一个简洁的循环:
# main.py 核心调度逻辑 # 伪代码,用于说明核心流程 def run_agent(user_message, available_skills): # 1. 组装系统提示 skills_block = [] for skill in available_skills: skills_block.append(format_skill_spec(skill)) system_prompt = build_system_prompt(skills_block) # 2. 让模型决策:直接回答还是调用技能 response = llm.chat( system=system_prompt, messages=[{"role": "user", "content": user_message}] ) # 3. 如果模型决定调用技能,解析参数并执行 if response.action == "call_skill": result = execute_skill(response.skill_name, response.skill_params) final_answer = llm.chat( system=system_prompt, messages=[ {"role": "user", "content": user_message}, {"role": "assistant", "content": response.original_text}, {"role": "tool", "name": response.skill_name, "content": result} ] ) return final_answer return response.textformat_skill_spec 函数的作用,是把技能定义转成模型友好的文本。我实际使用的格式类似这样:
[技能名] file_search [适用场景] 当用户需要根据文件名、路径关键词或者文件内容关键词查找本地文档时使用 [参数说明] query: 搜索关键词; file_type: 可选,取值 pdf/docx/xlsx/md [输出说明] 返回文件路径列表,包含文件名、目录、修改时间和大小 [限制] 仅限本地文档,不适用于在线搜索我在反复调试后发现一个重要原则:这个技能列表的格式必须稳定,每次请求都完全相同,不能时有时无。大模型对格式一致性的依赖超乎想象,你哪怕只多了一个空行,它都可能在解析技能参数时出现异常。
3.3 示例:实现三个实用的基础技能
第一个技能是文件搜索。它的执行函数如下:
# skills/file_search.py import pathlib from typing import Dict, Any, List def search_files(query: str, file_type: str = "", base_path: str = ".") -> List[Dict[str, str]]: results = [] root = pathlib.Path(base_path).resolve() for path in root.rglob("*"): if path.is_file(): if file_type and path.suffix.lstrip(".") != file_type: continue if query.lower() not in path.stem.lower() and query.lower() not in str(path): continue stat = path.stat() results.append({ "filename": path.name, "path": str(path), "size": stat.st_size, "modified": stat.st_mtime, }) if len(results) >= 20: break return results注意我做了两个工程上的保护:限制最大返回条数为 20,避免一次性输出海量路径导致上下文被撑爆;用 rglob 时指定 root.resolve(),避免路径穿越问题。技能执行函数必须自己处理异常和资源限制,不能指望调用方帮你兜底。
第二个技能是日程提醒。这个技能看起来简单,但踩坑价值很高。我发现纯函数无法“主动”提醒用户,于是把技能定义成一个大模型驱动的“伪执行”。
# skills/schedule_notice.py def add_schedule_notice(event: str, time: str, remind_before: int = 10) -> str: # 实际项目中这里会调用日历服务的 API return f"已为【{event}】创建日程,时间 {time},提前 {remind_before} 分钟提醒"为什么说是“伪执行”?因为真实环境中这个技能背后挂着一个任务调度队列。模型调用它时,实际上只是向队列写入一条消息,真正的提醒动作由后台定时任务完成。这里我要强调一个观点:不要把技能执行过程理解成“立即得结果”,有些技能是“发指令、后生效”,这种异步设计在复杂 Agent 中很常见。
第三个技能是网页摘要。它的作用是抓取指定 URL 的文本内容,生成适量摘要。这个技能我特别注意了限制条件——只允许抓取特定域名,防止 Agent 被恶意引导到不可信页面:
# skills/web_summary.py allowed_domains = ["example.org", "docs.example.com"] def fetch_web_summary(url: str) -> str: from urllib.parse import urlparse domain = urlparse(url).netloc if domain not in allowed_domains: return "该域名不在访问白名单中,无法获取内容" # 实际项目中使用 HttpClient 获取页面并提取正文 return "页面摘要内容"三个技能放在一起,系统就有了一个多元能力的小生态。下一步,要把它们全部注册进技能库。
# main.py from skills.registry import SkillRegistry from skills.file_search import search_files from skills.schedule_notice import add_schedule_notice from skills.web_summary import fetch_web_summary registry = SkillRegistry() registry.register_from_yaml("skills.yaml", execute_map={ "file_search": search_files, "schedule_notice": add_schedule_notice, "web_summary": fetch_web_summary, })注册完成后,同一套技能即可以被大模型场景通过调度器调用,也可以被普通 API 场景直接调用。这个“双通道”设计让我在后期扩展时省了大力气。
4. 踩过的坑与问题排查
4.1 技能永远不被模型调用:先检查描述,再检查参数
这是我遇到最多的问题,而且排查方向经常被搞反。团队里有同事说“技能库加了好几个技能,但模型死活不用”。跑到日志里一看,模型输出里根本没有调用意图。第一反应是模型不聪明,换了大参数模型还是不行,最后才发现是技能描述写得过于笼统。
我举个例子。最初我给文件检索技能写的描述是“在本地文档中搜索相关信息”。这个描述放在技能列表里,模型根本搞不清它跟“网页搜索”有什么区别,更搞不清什么时候该触发。后来我改成完整的三段式描述,并加了“不适用于在线搜索”的限制,问题迅速解决。
如果你也遇到技能不被调用,按这个顺序排查:
- 描述里有没有说清楚“什么情况下用”?
- 描述里有没有说清楚“什么情况下不用”?
- 参数说明里的 example 和 enum 是否齐全?
- 技能是否进入了当前模型的可见列表?
大多数时候,问题不在模型,在于你把一个“没有使用说明书”的工具硬塞给了它。
4.2 技能参数格式总出错:用 Schema 校验而非期望模型自觉
另一个高频坑是参数格式。模型生成的 JSON 参数经常缺字段、类型错误、甚至把枚举值写错。我在早期版本里直接把模型生成的参数透传给 execute 函数,结果就是每逢大版本模型升级,参数错误率就会波动。
后来我引入了 Pydantic 风格的严格校验,所有参数在进入执行函数之前先做合法性检查。类型不对就拒绝执行并返回错误说明,而不是硬着头皮用默认值。有一次模型把 file_type 传成“word”,明显是它的拼写习惯,被校验拦住后返回了“仅支持 pdf/docx/xlsx/md 格式”,模型看到错误提示后重新生成了正确参数,整个体验反而更顺畅了。
这个经验的核心是:永远不要把参数正确性建立在模型的“自觉”上,要通过框架层做强制约束。模型是概率系统,它大概率会输对,但小概率的输错足以毁掉用户体验。
4.3 技能之间互相“打架”:用命名空间隔离状态
当技能数量超过十五个之后,我开始遇到另一个问题:技能调用完以后,其中的变量或资源没有清理干净,导致下一个技能被影响。最典型的是文件句柄未释放,以及某个技能修改了全局配置却没有恢复。这种 Bug 极难排查,因为它们不是每次必现,而是看运气。
我的解决方案是给每个技能一个独立的执行上下文。具体来说,execute 函数被包裹在一个隔离环境里运行,技能内部的临时变量、打开的连接、写入的文件,都在调用结束后统一清理。如果你用的是 Python,最简单的做法是用 contextmanager 包一层,或者把技能拆成独立服务。强烈建议技能内部不要修改任何全局状态,哪怕只是为了“临时方便”也别这么干。我正在 debug 一个诡异问题:某次对话中,模型调用完“日程提醒”技能后,紧接着调用“文件搜索”,结果文件搜索的路径参数被悄悄替换成了一个日程字符串。排查到最后,发现是一个全局变量被第二个技能误读了。加隔离后,这个问题彻底消失。
4.4 异步执行与超时控制
技能系统上线后,我很快发现一个现实约束:不是所有技能都能在五秒内返回。网页抓取可能慢,数据库查询可能慢,调用外部 API 也可能慢。如果调度循环是同步等待,用户的体验就是机器人“卡死”了。
我后来把所有可能耗时的技能统一改成异步模式,并且为每个技能配置超时阈值。一旦超时,调度器返回“该技能执行超时,请重试或换一种问法”。另外,同一时间里最多只允许两个技能并行执行,防止模型在复杂任务中一口气触发五个技能,把下游系统打爆。
| 技能名 | 超时阈值 | 是否异步 | 安全级别 |
|---|---|---|---|
| file_search | 3 秒 | 否 | 只读 |
| schedule_notice | 2 秒 | 是 | 需确认 |
| web_summary | 10 秒 | 是 | 白名单域名 |
这张表就是我在项目里实际使用的技能配置,注册技能时可以直接参考。超时、异步、安全级别这三件事,必须从第一天就想清楚,不然后期改造的成本会很高。
5. 这套体系还能怎么扩展
agent-skills 目前已经能支撑我一个中等复杂度业务场景的日常运行,但它还只是骨架。我认为最值得继续投入的方向有三个:
第一是技能评估自动化。现在每个技能靠人工描述、凭经验调试,但如果技能数量过百,就必须建立一套自动评估标准。比如给每个技能准备一组典型测试用例,每次改动后自动跑一遍,看调用率、参数正确率、执行成功率是否回退。
第二是技能组合与工作流。目前模型是基于当前任务选择单个技能,但真实需求往往需要多步操作。比如“整理某项目周报并发送给团队”这个任务,涉及信息检索、文档生成、消息发送三个技能。下一步我计划引入技能链的概念,让技能之间可以声明依赖关系,模型负责编排,框架负责保证顺序和上下文传递。
第三是冷启动技能推荐。新场景接入时,如何从已有技能库中挑选合适技能,减少人工配置成本。我打算基于历史对话日志做意图聚类,自动为场景推荐技能组合。
但这些都是后话。如果你现在正要为自己的 Agent 构建技能体系,我的建议很简单:先别追求技能数量,把手头最频繁使用的三五个能力做成高质量技能,把描述写清楚、参数校验做严、执行函数做健壮,然后把一个个场景往里接入。只有被真实业务反复打磨过的技能,才算是真正合格的能力模块。