我做了两年多 AI Agent 相关的开发,踩过最大的坑,就是把 Agent 的能力全堆在提示词里。一开始觉得挺爽,prompt 里写清楚“你可以调用搜索、计算、写代码”,模型好像真的听话。等任务稍微复杂点就原形毕露:上下文被占满、工具调用格式飘忽不定、改一个功能的提示词还容易影响其他功能的表现。后来我把项目重构,单独拆出来一套技能系统,也就是现在的 agent-skills 项目。这个项目说白了就是给 Agent 做一套模块化的“能力插槽”:每个技能独立定义、独立注册、独立执行,由调度器根据任务描述自动选择要激活的技能。它适合正在做 Agent 应用、被“工具调用不稳定”和“提示词越写越乱”折磨的开发者和产品团队参考。
先说明一下,这篇文章不涉及某个现成框架的源码解读,而是把我自己做 agent-skills 时的完整思路、踩坑记录和可复现的代码骨架分享出来。无论你是用 LangChain、CrewAI,还是完全自己写编排逻辑,这套设计思想都能直接落地。
1. 项目概述:agent-skills 是什么,解决什么问题
1.1 从一次真实翻车说起
去年我做过一个内部知识库问答 Agent,需求不复杂:用户问问题,Agent 先检索知识库,再结合检索结果回答。第一版实现极其天真,把检索逻辑、总结逻辑、判断逻辑全写进了 system prompt,用类似“当你需要查找资料时,请使用 search_docs(query) 这个函数”的句式。
结果上线第一周就出问题。业务方反馈,同一个问题隔一天问,答案风格能变一个样。更麻烦的是,某些问题模型会“自作聪明”跳过检索,直接凭训练数据里的记忆编答案。我反复调提示词,这个 bug 修好了,另一个问题又冒出来。后来又加了几个新工具,prompt 长度从 800 字涨到 3000 字,每次请求光提示词就要吃掉一大截 token,响应延迟肉眼可见地上升。
这就是我决定做 agent-skills 的直接导火索:把 Agent 的能力从“写在提示词里的约定”变成“代码里的实体”。
1.2 agent-skills 的定位与边界
agent-skills 这个项目,核心定位是“Agent 能力的模块化管理与自动调度层”。它解决三件事:
- 能力注册:每个技能有唯一的名称、描述、参数 Schema,集中登记在技能注册表里。
- 能力发现:Agent 在运行时根据用户目标和上下文,从技能注册表里选出候选技能。
- 能力执行:技能被选中后,由统一执行器完成参数校验、调用、异常处理、结果归一化。
这里需要划清一个边界:agent-skills 不是 Agent 框架本身,它不负责规划、不负责记忆、不负责多轮对话管理,只专注在“技能”这一层。你可以把它理解为 Agent 的“工具箱管理员”,而不是“大脑”。
我见过不少团队试图自己做这套东西,最后都掉进同一个泥潭:把技能逻辑和 Agent 的规划逻辑耦合在一起。技能内部不该感知“我是被谁调用的”“现在在第几轮对话”,它只需要暴露“我给你这些参数,你返回这个结构的结果”。这个原则在后面实现时非常重要。
2. 整体设计与思路拆解:为什么技能必须模块化
2.1 拆掉提示词里的“瑞士军刀”
很多人觉得,把工具说明写进提示词不也一样吗?区别很大。提示词是文本,模型对它只是“读取”,不具备强约束力。模型可能漏看、可能理解偏、可能输出格式不对。而技能系统是代码,它有类型检查、参数校验、错误分支,模型只要选对了技能,剩下的事情由程序兜底。
我做一个类比:把工具塞进提示词,就像口头交代同事“有空帮我把数据整理一下”,信息全靠默契;用技能系统,相当于提交一份标准工单,字段齐全、流程固定、结果有验收标准。后者出错的概率天然低一个量级。
2.2 技能、工具、插件:概念边界怎么划
做 agent-skills 之前,我先把概念理清楚,不然代码写起来会很乱:
| 概念 | 定位 | 粒度 | 示例 |
|---|---|---|---|
| 工具(Tool) | 单一原子操作 | 最细 | 执行一次 HTTP GET、读一个文件 |
| 技能(Skill) | 围绕某个能力的完整逻辑 | 中等 | 查天气、做竞品分析、生成周报 |
| 插件(Plugin) | 一组相关技能的打包分发 | 最粗 | “数据分析插件”包含数据清洗、统计、可视化 3 个技能 |
agent-skills 管理的是“技能”这一层。一个技能内部可以用多个工具,技能对外是黑盒。这层抽象非常关键:业务方只关心“这个 Agent 会不会查天气”,不关心天气 API 用的是哪家服务商、返回格式是 JSON 还是 XML。
2.3 技能注册表 + 执行器的双段式架构
我最终选的是“注册表 + 执行器”的双段式架构,整体流程分两步走:
- 选择阶段:把技能列表(名称 + 描述 + 参数 Schema)交给 LLM,由模型决定调用哪个技能、传入什么参数。这一步产出的是“调用意图”。
- 执行阶段:执行器根据意图,从注册表取出对应技能,做参数校验后真正执行。执行结果再回传给模型,由模型组织最终回复。
为什么分成两段,不直接让模型执行?因为模型不可靠,直接让模型输出“最终答案”很难验证对错,但让模型只输出“我要调什么、参数是什么”就简单多了。调用意图是结构化数据,可以用 JSON Schema 严格校验;执行结果来自真实代码,可信度远高于模型的凭空生成。
3. 核心细节解析与实操要点
3.1 技能描述怎么写,模型才容易命中
技能系统里,最容易被低估的是“描述(description)”字段。它不直接参与执行,但决定了模型能不能在正确的时候选到正确的技能。
我踩过的一个坑是描述写得太抽象。比如“weather”技能,我一开始写的描述是“Get weather information”,结果模型经常在该用的时候不用。后来改成“Get current weather and forecast for a city. Use this when the user asks about temperature, rain, wind, or weather conditions for a specific location.",命中率明显提升。
描述的关键是给出使用时机,而且是带场景和使用条件的描述,不是单纯的功能陈述。写描述的时候,我总结了四条经验:
- 写清楚技能的触发场景,用“当用户提到……时使用”的句式
- 包含典型问法示例,比如“用户可能问‘上海明天热吗’”
- 说明不适用的边界,比如“只支持中国城市,不支持国外城市查询”
- 控制长度在 50~150 字之间,太短信息不够,太长模型容易忽略
3.2 参数 Schema 的合理设计:别小看这一步
参数 Schema 是技能与模型交互的“契约”。我遇到过最气人的场景:技能写好了,模型也选中了,但传进来的参数完全没法用。比如查询天气需要“城市名”,模型传了一堆“用户想去上海玩,上海这几天热不热”这种自然语言,而不是规范的城市名。
这个问题靠一个技巧解决了大半:在 Schema 的 description 里明确参数格式和示例值。比如城市名参数,描述写成“City name in Chinese, e.g. '上海', '北京'. Do not include extra words like '天气预报'.”模型看到这样的约束,输出规范程度会高非常多。
再补充一个实操中很实用的设计:给参数加默认值和容错归一化。我习惯在技能内部做一层输入清洗,比如城市名先做去空格和别名映射(“魔都”映射到“上海”),这样就不会因为用户说法口语化而直接报错。
3.3 技能执行的沙箱与安全边界
Agent 技能一旦开放给用户调用,安全就必须考虑。这里说的安全不只是防止恶意的提示注入,也包括防止用户误操作触发副作用。
我的原则是:有副作用的技能必须带确认机制。比如“发送邮件”“删除文件”“下单支付”这类技能,不能模型一判断就执行。markdown 里写了一个通知提示,但代码层面更要强制确认。所以我在技能定义里加了一个confirm_required字段,这类技能在执行前必须经过用户二次确认。
另外,执行环境也要尽可能隔离。涉及外部 API 的技能统一走网关,涉及文件操作的技能限制在指定目录内,涉及代码执行的技能强制放进沙箱容器。这些成本不高,但能避免 99% 的“Agent 闯祸”事故。
4. 实操过程与核心环节实现
4.1 环境准备与基础骨架
这个项目的语言我选了 Python,主要因为生态成熟,而且后续要接入不同的 LLM 都方便。依赖很少,核心就两个:Pydantic 做数据校验,OpenAI SDK 做模型调用(其他厂商的 SDK 也兼容,后面会讲)。
先把技能的数据结构定义出来,这是整个系统的地基:
from pydantic import BaseModel, Field from typing import Callable, Any, Optional class SkillDefinition(BaseModel): """技能元信息定义""" name: str = Field(description="技能唯一名称,如 weather_query") description: str = Field(description="技能描述,包含使用时机、典型场景、边界说明") parameters_schema: dict = Field( description="参数 JSON Schema,遵循 JSON Schema 规范", default_factory=dict ) confirm_required: bool = Field( description="执行前是否需要用户二次确认", default=False ) class Skill(BaseModel): """技能实体:元信息 + 执行函数""" definition: SkillDefinition handler: Callable[..., Any]这里 Pydantic 只是辅助,核心思想是:一个技能 = 元信息(给模型看的契约)+ 处理器(给程序执行的函数)。两者分开,模型只接触元信息,程序只执行 handler。
4.2 技能注册表与执行器
接着是注册表,它负责维护“系统里有哪些技能”:
class SkillRegistry: """技能注册表:登记、查找、列出技能""" def __init__(self): self._skills: dict[str, Skill] = {} def register(self, skill: Skill) -> None: if skill.definition.name in self._skills: raise ValueError(f"Skill '{skill.definition.name}' already registered") self._skills[skill.definition.name] = skill def get(self, name: str) -> Skill: return self._skills[name] def list_skills_for_llm(self) -> list[dict]: """转成 LLM 友好的工具定义格式""" return [ { "type": "function", "function": { "name": skill.definition.name, "description": skill.definition.description, "parameters": skill.definition.parameters_schema, } } for skill in self._skills.values() ]执行器我单独写了一个类,职责很单一:接收模型返回的工具调用意图,找到技能,校验参数,执行,兜底异常:
import json class SkillExecutor: """技能执行器:负责参数校验与函数调用""" def __init__(self, registry: SkillRegistry): self.registry = registry def execute(self, skill_name: str, arguments: dict) -> dict: skill = self.registry.get(skill_name) # 参数校验:这里可以基于 parameters_schema 做严格校验 # 我这里用 pydantic 的 TypeAdapter 动态校验,失败时返回清晰错误 try: result = skill.handler(**arguments) except TypeError as e: return { "status": "error", "error_message": f"Invalid arguments for skill '{skill_name}': {e}" } except Exception as e: return { "status": "error", "error_message": f"Skill execution failed: {e}" } return {"status": "success", "result": result}4.3 动手实现一个真实技能:天气查询
下面的示例是完整的技能创建过程。我以“天气查询”为例,参数 Schema 写得比较讲究,描述部分重点突出触发场景:
def get_current_weather(city: str, unit: str = "celsius") -> dict: """模拟天气查询,实际项目替换为真实 API 调用""" # 这里假设调用了某个天气服务 mock_data = { "上海": {"temp": 28, "condition": "晴", "humidity": 65}, "北京": {"temp": 24, "condition": "多云", "humidity": 50}, "广州": {"temp": 31, "condition": "雷阵雨", "humidity": 88}, } data = mock_data.get(city, {"temp": None, "condition": "未知", "humidity": None}) if unit == "fahrenheit" and data["temp"] is not None: data["temp"] = round(data["temp"] * 9 / 5 + 32, 1) return {"city": city, **data} weather_skill = Skill( definition=SkillDefinition( name="get_current_weather", description=( "查询中国主要城市的当前天气和温度。" "当用户询问温度、是否下雨、风力、天气状况时使用。" "支持城市示例:上海、北京、广州。" "如果城市不在支持列表中,明确告知用户暂不支持。" ), parameters_schema={ "type": "object", "properties": { "city": { "type": "string", "description": "城市名,用中文,例如'上海'。不要携带语气词或多余文字。" }, "unit": { "type": "string", "enum": ["celsius", "fahrenheit"], "default": "celsius", "description": "温度单位,默认摄氏。" } }, "required": ["city"] } ), handler=get_current_weather ) registry = SkillRegistry() registry.register(weather_skill)4.4 接入 LLM 完成自动调度
技能注册好了,怎么让模型自动选择呢?这里利用 OpenAI 的 function calling 机制,把技能列表作为 tools 传入,模型返回的 tool_calls 就是“选中 + 参数”的意图:
from openai import OpenAI client = OpenAI() def run_agent_with_skills(user_input: str, registry: SkillRegistry, executor: SkillExecutor): messages = [{"role": "user", "content": user_input}] response = client.chat.completions.create( model="gpt-4o-mini", messages=messages, tools=registry.list_skills_for_llm(), tool_choice="auto", ) response_message = response.choices[0].message # 如果模型决定调用技能 if response_message.tool_calls: for tool_call in response_message.tool_calls: skill_name = tool_call.function.name arguments = json.loads(tool_call.function.arguments) # 对需要确认的技能,先返回提示信息 skill_def = registry.get(skill_name).definition if skill_def.confirm_required: return {"type": "confirmation_needed", "skill": skill_name, "arguments": arguments} # 执行技能 execution_result = executor.execute(skill_name, arguments) # 把技能结果返回给模型,让模型组织最终答案 messages.append({ "role": "assistant", "content": response_message.content, "tool_calls": response_message.tool_calls }) messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": json.dumps(execution_result, ensure_ascii=False) }) final_response = client.chat.completions.create( model="gpt-4o-mini", messages=messages, ) return final_response.choices[0].message.content return response_message.content到这里,一个最小的 agent-skills 闭环就跑通了。用户问“上海今天热吗”,模型会选择 get_current_weather,传入 {"city": "上海"},执行器返回真实天气数据,模型再根据数据组织回答。
4.5 多技能协同与任务编排
当技能数量多起来,你还会遇到“一个任务需要多个技能顺序执行”的情况。比如用户问“上海今天适合跑步吗”,需要先查天气,再参考空气质量,也许还要查限行信息。
我的做法是给模型加一个轻量的“计划器”:在 system prompt 里提示模型“如果需要多个技能,请先规划调用顺序,分步调用”,然后循环执行 tool_calls,直到模型不再请求调用为止。每次循环把上一轮的工具结果附加到 messages 里,模型就能看到之前的执行结果并决定下一步。这本质上是一个简单的 ReAct 循环,但对多数任务已经足够了,不需要上重型的规划图编排。
5. 常见问题与排查技巧实录
5.1 模型选了技能但参数一塌糊涂
这是出现频率最高的问题。表现是:技能选对了,但传入的参数带着前后缀,比如city字段是“帮我查一下上海的天气”,而不是规范的“上海”。
我的排查路径是三步:
- 先看parameters_schema 的 description是否写清楚格式要求,没写清楚就让模型自由发挥,必然飘
- 再看模型返回的原始 arguments,如果原始输出就是乱的,那就是 Schema 约束不够
- 如果原始输出是规范的,那就是执行器侧解析的问题,检查是不是用了错误的反序列化方式
最佳实践是双保险:Schema 层面尽量约束 + 执行器内部做归一化清洗。比如 city 字段,解析后先做正则去杂质,再匹配已知城市列表,都不行才报错。
5.2 多个技能互相“抢活”
技能多了以后,会出现 A、B 两个技能都能处理同一类请求,模型时而选 A、时而选 B,结果不稳定。比如“查天气”和“查穿衣建议”,用户的“上海穿什么”就可能被选到天气技能。
解决办法是明确技能的边界描述,在描述里写“本技能只负责 X,不处理 Y”。同时可以在技能描述里写明优先级:“如果用户询问穿着建议,请优先调用穿衣建议技能;穿衣建议技能内部会自动查询天气。”这样模型在选择时就有了明确的优先级指引。
5.3 技能调用超时与资源泄露
技能是真实代码,真实代码就会超时、就会挂起。之前我有个技能调外部 API,对方服务抖动,结果整个 Agent 卡了 40 多秒才响应。用户早跑了。
后来给执行器加了统一的超时控制,Python 里用concurrent.futures包一层就行:
from concurrent.futures import ThreadPoolExecutor, TimeoutError def execute_with_timeout(handler, timeout_seconds: int = 10, **kwargs): with ThreadPoolExecutor(max_workers=1) as pool: future = pool.submit(handler, **kwargs) try: return future.result(timeout=timeout_seconds) except TimeoutError: pool.shutdown(wait=False, cancel_futures=True) return {"status": "error", "error_message": f"Skill execution timed out after {timeout_seconds}s"}另一个隐藏问题是外部 API 的 token 配额和并发限制。全局统一在技能外层加限流器(rate limiter),按技能分级控制调用频率,避免一个技能把整个系统的配额打爆。
5.4 技能更新后模型还在用旧行为
我经历过一次很诡异的故障:技能逻辑已经改了,模型还是按照旧的行为方式调用。排查半天发现是缓存问题——LLM 调用层面没问题,但技能注册表被加载成了旧版本,进程没重启。
这里要养成一个习惯:技能注册表启动时做一次版本校验,打印当前加载的技能清单和版本号。上线新技能后,先跑一遍自检命令确认注册表内容,再放开流量。
6. 一点后续扩展的想法
agent-skills 目前已经帮我把内部几个 Agent 项目的提示词体量压缩了 60% 以上,工具调用的稳定性也有肉眼可见的提升。下一步我打算做两件事:一是给技能加自动测试,每个技能注册时附带几个 mock 用例,冒烟测试通过才允许上线;二是把技能的调用日志结构化存储,用来分析哪些技能经常被模型误选、哪些描述还需要调优。
如果你也在做 Agent 项目,我的建议是从一个小场景开始,别一上来就设计几十个技能。先做 3 个核心技能,跑通注册、调度、执行、回传这个闭环,再逐步扩展。等你把第一个技能系统的坑都踩完,后面加技能就是纯粹的堆量了。