最近两周我都在捣鼓一个内部 Agent 项目,越调越觉得"技能"这件事值得单独拿出来聊聊。之前我们把所有能力都塞进系统提示词里,结果上下文一长,模型行为就开始飘;后来把能力拆成一个个函数,还是不够,因为模型根本不知道什么场景下该调哪个函数。折腾到最后,我们才意识到:Agent 真正需要的不是一堆散装函数,而是一套完整的"技能系统"。这也是我今天想展开写的核心——agent-skills。
如果你正准备做 Agent 应用、或者你的 Agent 已经出现"提示词膨胀、工具调用混乱"的问题,这篇文章应该能帮你省掉不少试错成本。我会从设计思路讲起,再给一套可以直接落地的最小实现,最后把我踩过的坑和排查方法一并列出来。
1. 先想清楚:Agent 到底需不需要一套"技能系统"
很多刚接触 Agent 的人会有一个直觉:模型本来就会调用工具,我给它注册几个 function,它自己会选,为什么还要再包一层技能系统?这个直觉没有错,但只适用于玩具项目。当你把 Agent 放到真实业务里,面对几十个工具、多轮任务、以及各种边界情况时,你会发现缺的恰恰是那层"封装和治理"。
1.1 技能、工具、插件和函数调用,到底差在哪
我见过不少团队把"工具(Tool)"和"技能(Skill)"混着用,导致沟通成本很高。先统一一下概念。函数调用是最基础的层,它解决的是"模型输出结构化指令、程序去执行"的问题。工具层在此基础上做了描述封装,让模型知道这个函数是干什么的。到插件层,通常意味着一个完整的、可插拔的功能集合,会涉及资源、配置甚至 UI。
技能层和它们最大的区别,在于技能是"带状态管理、带校验、带编排上下文的能力单元"。它不只是告诉模型"我能干什么",而是告诉模型"什么情况下该用我、用我之前需要准备什么、用完我会给你什么"。我把这四层放一起对比过:
| 层级 | 核心问题 | 典型代表 | 需要模型理解的内容 |
|---|---|---|---|
| 函数调用 | 怎么调? | OpenAI function calling | 参数、返回结构 |
| 工具 | 能干什么? | 各类 Agent 框架的 Tool 基类 | 功能描述、触发条件 |
| 插件 | 怎么集成? | 浏览器插件、IDE 插件 | 安装方式、资源依赖 |
| 技能 | 什么时候用?怎么编排? | Claude Skills、各类 Agent 技能库 | 适用场景、限制条件、编排位置 |
这个对比不是抠字眼,而是为了让你在设计时知道自己在做哪一层的事。如果你只是想让模型偶尔算个数学题,函数调用就够了;但如果你想让 Agent 完成"查资料、写总结、发邮件"这样连续的复杂任务,你需要的是一套能编排、能校验、能复用的技能系统。
1.2 我在项目里遇到的两个真实痛点
这个项目一开始我也偷懒了。第一个痛点是提示词爆炸。我们当时把十几个功能的说明都写进系统提示词里,每条都带例子和边界说明,结果 prompt 快五千字。模型每轮都要处理这些信息,响应变慢不说,关键时候还会"忘记"某些工具存在。我印象最深的一次,模型放着现成的 PDF 转文本技能不用,自己尝试用 Python 代码去解析 PDF,结果跑得稀烂。
第二个痛点是技能间无法组合。很多任务是分步骤的,比如"先搜索关键词,再总结内容,最后生成日报"。如果每个工具都是独立的、彼此没有状态关联,Agent 就只能自己脑补步骤,无法形成稳定的执行流程。后来我们发现,必须有一个技能编排层,把"按顺序执行""按条件分支""失败重试"这些逻辑固化成模板,Agent 的任务才真的稳定下来。
这两个痛点直接推动我去搭了一套轻量技能系统:注册机制负责统一管理,校验机制负责守住参数边界,编排机制负责把技能串成流程。下面就是我落地的完整思路。
2. 核心细节解析:一个技能该有的"标准长相"
技能系统听起来玄乎,落到代码层面其实就是四个问题:技能长什么样、怎么注册、怎么执行、怎么被模型选中。这一节我逐个拆。
2.1 技能定义的四要素:名称、描述、参数、执行体
一个技能必须有四个核心部分,缺一不可。第一是名称,它必须语义化。我踩过最大的坑就是把技能命名为func_1、process_data这种,模型根本理解不了。推荐用小写加连字符的命名方式,比如fetch_web_page、summarize_text、parse_pdf,一眼就知道是干什么的。
第二是描述。这可能是整个技能系统里最容易被低估的字段。描述写得好不好,直接决定了模型会不会在正确的时机调用它。我总结了一套写法:描述要包含"使用场景(When)+ 输入来源(What it needs)+ 输出结果(What it returns)"。比如一个天气查询技能,我不会写"查询天气",而是写:
当你需要获取某个城市当前或未来的天气情况时使用此技能。 需要提供城市名称,可选提供日期。返回该城市的天气状况、温度和湿度。不要小看这几行描述。实际测试中,同样的技能,描述从"查询天气"改成上面这种写法后,模型在正确场景下的调用率从不到一半提升到了九成以上。原因很简单——大模型选工具的本质是在做语义匹配,你给的信息越接近用户的真实表达,匹配越准。
第三是参数。参数必须有 JSON Schema 约束,不能靠函数签名去猜。第四是执行体,也就是真正干活的那段函数。我建议执行体写成纯函数:输入参数、返回结果,不在内部偷偷改全局状态。这样技能才好测试、好维护、好回溯。
2.2 JSON Schema 参数约定的作用与选择
为什么参数非要单独拎出来说?因为模型返回的参数经常"不合规"。我给你看几个真实案例:模型该传整数的时候传了字符串;该传数组的时候传了逗号分隔的文本;最离谱的是有次它给我传了一个不存在的字段,说要"智能补充"。
JSON Schema 就是用来挡住这些问题的一道闸门。它规定了每个参数的类型、是否必填、取值范围,甚至字段之间的依赖关系。当模型返回的参数通过不了校验时,我们直接把校验错误返回给模型让它修正,会比让执行体收到垃圾参数后崩溃,整体的成功率高出很多。
我自己常用的 Schema 写法是这样的:
get_weather_schema = { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,如 北京、上海", }, "date": { "type": "string", "description": "可选,日期,格式 YYYY-MM-DD", }, }, "required": ["city"], "additionalProperties": False, }注意那个additionalProperties: False,它的作用是禁止模型传额外的字段。我遇到过不写这个字段时,模型会把city写成location,看起来差不多,但程序里取不到值就会报错。把这条加上,模型的自由发挥空间被压缩,反而让系统更稳定。
2.3 技能编排:顺序执行、条件路由与结果传递
单个技能做好之后,编排就是下一步。编排的目的,是把多个技能按照业务逻辑串起来。我这里的经验是,不要指望模型自己画流程图,你要在系统里预置一套可运行的编排规则。
最基础的编排是顺序执行:上一个技能的输出,作为下一个技能的输入。比如我做会议纪要 Agent,原始音频转文字的技能执行完,输出一大段文本;紧接着调用摘要技能,把这段文本压缩成要点;最后调用提炼行动项技能,从要点里找出负责人和截止时间。每一步的输出结构是固定的,下一步才能稳稳接住。
稍微复杂一点的是条件路由。比如客服 Agent 在识别到用户情绪激动时,应该优先走安抚话术技能;识别到是退货咨询时,走退货流程技能。这个"识别"动作本身也可以是一个技能,输出是一个分类标签,系统再根据标签路由到不同的技能链上。
执行完编排后,结果传递也是个容易被忽视的点。我的建议是给每个技能的输出加上一个统一包装,至少包含status、data、error三个字段。这样不管是顺序执行还是分支路由,上一层的结果都能被下一层安全解析,不会出现"技能 A 返回了文本、技能 B 期望字典"这种错位。
2.4 为什么说版本管理是技能系统的隐藏需求
技能会演进。你改了技能 A 的逻辑,但技能 B、技能 C 可能还在用旧规则依赖它的输出。如果不做版本管理,你的 Agent 会在某个时刻突然变笨,而且你根本不知道是哪个技能变了导致的。
我给每个技能都加了一个version字段,并在描述里向模型声明当前版本号。每次修改技能逻辑,版本号跟着升。这样在调试 Agent 时,只要看调用日志里的版本信息,就能快速定位"这个行为是 v1.2 产生的,还是 v1.3 引入的"。
还有一个实际好处:版本管理能支持灰度。比如我把summarize_text从 v1.0 升级到 v2.0,可以先让 10% 的流量走新版本,观察摘要质量和错误率,没有问题再全量切换。这在生产环境里尤其重要,因为技能行为的变化会直接影响用户体感,不能儿戏。
3. 实操过程:从零搭一套最小可用的 Agent 技能系统
理论讲再多,不如直接跑起来。这一节我带你用一个 Python 脚本搭出技能系统的骨架,包含技能定义、注册、校验、执行和与模型交互五部分。代码不复杂,但是可以作为一个最小的可扩展底座,往里面加技能非常方便。
3.1 整体架构与代码结构
我先说清楚整体由哪些模块组成,避免你被代码绕晕。
# 目录结构 skill_system/ ├── skill.py # Skill 类定义,含参数校验和技能执行封装 ├── registry.py # 技能注册表,用于注册和获取技能 ├── executor.py # 技能执行引擎,处理调用、错误回收、最大轮次 ├── skills/ # 具体技能实现目录 │ ├── calculator.py # 数学计算技能 │ ├── time_skill.py # 时间查询技能 │ └── memory_skill.py # 简单的知识检索技能 └── main.py # 演示入口,模拟模型选择技能并调用实际项目里这个结构可以很自然地扩展:多一个技能就是往skills/里加一个文件再注册一下;要接入数据库、外部 API,也只需要在技能执行体里引入对应客户端。
3.2 技能注册与仓库实现
先写最底层的Skill类和注册表。这里我把每个技能的参数 Schema 和实际执行函数放在一起,通过装饰器的方式让注册更简洁。
# skill.py import json import jsonschema from typing import Any, Callable, Dict class Skill: def __init__( self, name: str, description: str, parameters_schema: Dict[str, Any], handler: Callable[..., Any], version: str = "1.0.0", ): self.name = name self.description = description self.parameters_schema = parameters_schema self.handler = handler self.version = version def execute(self, params: Dict[str, Any]) -> Dict[str, Any]: # 参数校验:不合格直接返回错误,不让脏数据进入执行体 try: jsonschema.validate(instance=params, schema=self.parameters_schema) except jsonschema.ValidationError as e: return { "ok": False, "error": f"参数校验失败: {e.message}", } try: result = self.handler(**params) return {"ok": True, "result": result} except Exception as e: # 捕获执行体内部异常,避免技能崩溃导致整个 Agent 挂掉 return {"ok": False, "error": f"执行异常: {e}"} def to_llm_schema(self) -> Dict[str, Any]: # 把技能转换成 OpenAI function calling 认识的格式 return { "type": "function", "function": { "name": self.name, "description": self.description, "parameters": self.parameters_schema, }, }# registry.py from typing import Dict, Optional from skill import Skill class SkillRegistry: def __init__(self): self._skills: Dict[str, Skill] = {} def register(self, skill: Skill) -> None: if skill.name in self._skills: raise ValueError(f"技能 {skill.name} 已经注册,请勿重复注册") self._skills[skill.name] = skill def get(self, name: str) -> Optional[Skill]: return self._skills.get(name) def list_skills(self) -> list[Dict]: return [skill.to_llm_schema() for skill in self._skills.values()]这个版本我用了jsonschema库做参数校验。你没用过的话,执行pip install jsonschema装一下就行。注册表的设计刻意保持了简单,没有引入数据库连接、权限控制这些复杂概念,因为第一阶段最需要的是让"注册-获取-列出"这条链路跑通。
3.3 技能执行引擎与错误重试
有了技能和注册表之后,需要一个执行引擎统一调度技能调用。它的职责包括:解析模型返回的技能名和参数、从注册表取出技能、执行、把错误信息整理成模型能理解的形式返回给模型。
# executor.py from typing import Dict, Any, Optional from registry import SkillRegistry class SkillExecutor: def __init__(self, registry: SkillRegistry, max_retries: int = 2): self.registry = registry self.max_retries = max_retries def execute_call( self, call: Dict[str, Any], retry_count: int = 0 ) -> Dict[str, Any]: name = call.get("name") arguments = call.get("arguments", {}) skill = self.registry.get(name) if skill is None: return { "ok": False, "error": f"未找到技能 {name},请从可用技能列表中选择", } result = skill.execute(arguments) # 如果执行失败且还有重试次数,把错误信息返回给模型让它重新补参 if not result["ok"] and retry_count < self.max_retries: return { "ok": False, "need_retry": True, "feedback": f"技能 {name} 执行失败,原因:{result['error']}。请根据反馈修正参数后重试。", } return result这里的need_retry字段是一个很实用的设计。真实场景里,模型经常传错参数,比如把城市名写成北京市而不是北京。把错误信息原样丢给模型,它通常能自行修正。重试次数我会控制在 2 次以内,超过就放弃并向用户报错,防止模型在一个错误上反复打转消耗 token。
3.4 接入 LLM 完成技能选择
现在到最关键的一步:如何让大模型选择技能。真实项目中你会调 Anthropic 或 OpenAI 的 API,但核心逻辑是一样的:把技能列表转换成模型能理解的 schema,和用户问题一起发给模型,模型返回技能调用指令。
我这里写了一个简化版的交互函数,用requests模拟对模型 API 的调用。真正的 API 调整部分你可以根据自己的模型服务替换。
# main.py import json from registry import SkillRegistry from executor import SkillExecutor from skills.calculator import calculator_skill from skills.time_skill import time_skill from skills.memory_skill import memory_skill def build_registry() -> SkillRegistry: registry = SkillRegistry() registry.register(calculator_skill) registry.register(time_skill) registry.register(memory_skill) return registry def call_llm(messages: list, skills_schema: list) -> dict: """ 模拟一个 LLM 调用。 真实场景里,这里会去请求模型服务,并把 skills_schema 传入工具/技能列表。 这里用一个本地规则模拟模型返回结果,方便你直接跑通流程。 """ user_input = messages[-1]["content"] if "计算" in user_input or "多少" in user_input: match = re.search(r"[\d+\-*/().\s]+$$", user_input) expr = match.group(0) if match else "1+1" return {"name": "calculator", "arguments": {"expression": expr}} elif "时间" in user_input or "几点" in user_input: return {"name": "get_current_time", "arguments": {}} elif "记忆" in user_input or "知识" in user_input: return {"name": "query_memory", "arguments": {"query": user_input}} return {"name": "get_current_time", "arguments": {}} def main(): registry = build_registry() executor = SkillExecutor(registry) skills_schema = registry.list_skills() messages = [ {"role": "system", "content": "你是一个能调用技能的助手,请根据用户问题选择合适的技能。"}, {"role": "user", "content": "你好,请帮我算一下 (12+8)*3 等于多少?"}, ] # 1. 获取模型选择的技能调用 llm_call = call_llm(messages, skills_schema) # 2. 交给执行引擎 result = executor.execute_call(llm_call) # 3. 把执行结果返回给模型生成自然语言回答 if result["ok"]: messages.append({ "role": "function", "name": llm_call["name"], "content": json.dumps(result["result"], ensure_ascii=False), }) # 真实场景里这里要再调用一次 LLM,让它基于技能结果组织回答 final_answer = f"计算结果是 {result['result']}" print("Agent:", final_answer) else: print("Agent 调用失败:", result) if __name__ == "__main__": main()这个call_llm函数用正则模拟了模型选择技能的行为,方便你理解调用流程。换成真实模型时,你只需要把skills_schema传进 API 的tools参数,然后解析它返回的tool_calls即可。
我实际测试这套代码的链路情况是:注册三个技能后,模型能根据不同的用户请求选到对应的技能;一旦参数错误,执行引擎的错误回收机制能让模型重新修正参数;整个流程跑通后,后续最常做的就是往skills/目录里加新技能了。
3.5 完整调用链演示与效果分析
几个直接可以放进skills/目录的技能实现,你可以直接复用:
# skills/calculator.py from skill import Skill def calculate(expression: str) -> str: # 注意:生产环境不要用 eval,这里只是演示。 # 更安全的做法是用 ast.literal_eval 或者专门的表达式解析库。 return str(eval(expression, {"__builtins__": {}}, {})) calculator_skill = Skill( name="calculator", description="当用户需要进行数学运算(如加减乘除、括号表达式)时使用这个技能。输入一个数学表达式,返回计算结果。", parameters_schema={ "type": "object", "properties": { "expression": { "type": "string", "description": "数学表达式,例如 (12+8)*3", } }, "required": ["expression"], }, handler=calculate, version="1.0.0", )# skills/time_skill.py from datetime import datetime from skill import Skill def get_current_time(): return {"time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")} time_skill = Skill( name="get_current_time", description="当用户询问当前时间、日期、星期几时使用这个技能。无需参数,直接返回当前系统时间与日期。", parameters_schema={ "type": "object", "properties": {}, "required": [], }, handler=get_current_time, version="1.0.0", )# skills/memory_skill.py from skill import Skill MEMORY_STORE = { "项目": "Agent 技能系统开发项目,周期 6 周,核心成员 4 人", "会议": "每周一上午十点开项目周会", "目标": "本月完成技能系统 v1.0 并接入生产环境", } def query_memory(query: str) -> str: for key, value in MEMORY_STORE.items(): if key in query: return f"{key}: {value}" return "未找到相关知识" memory_skill = Skill( name="query_memory", description="当用户询问项目信息、成员信息、会议安排等内部知识时使用。输入查询内容,返回匹配的记忆条目。", parameters_schema={ "type": "object", "properties": { "query": { "type": "string", "description": "要查询的问题内容", } }, "required": ["query"], }, handler=query_memory, version="1.0.0", )跑起来的效果:用户问"帮我算一下 (12+8)*3",模型选中calculator技能,参数是{"expression": "(12+8)*3"},执行后返回60,再组织成自然语言回答。整个链路清晰、可控,出问题时你知道该查哪个环节。
4. 常见问题与排查技巧实录
技能系统跑起来之后,真正的挑战才开始。这一节我把我实际遇到的高频问题和排查方法整理成清单,你可以直接对照处理。
4.1 模型死活不调用的技能,问题多半出在描述上
这是我和团队花费时间最多的一类问题。明明技能已经注册了,参数也写得没问题,但模型就是不用它。有一次,我们做了一个网页抓取技能,描述写的是"抓取指定 URL 的网页内容"。用户问题是"帮我看下某某网站最近有什么新闻",模型死活不调用抓取技能,反而自己在回答里编了一段新闻。
查了很久才明白,问题出在用户表达的是"看新闻",不会说"抓取 URL"。模型做语义匹配时,它其实不知道"看新闻"等价于"抓取网页再总结"。
这个问题的排查思路是:把用户的问法和技能描述并列,看它们之间是否存在语义鸿沟。常见的解法是重写技能描述,把用户角度的"用户话术"写进去。比如上面那个技能,我后来改成了:
当用户想了解某个网站的内容、新闻、文章或公告时,使用这个技能。你可以先抓取网页正文,再结合其他技能做总结或提取关键信息。这样模型在遇到"看新闻"这类请求时,就能把用户的表达和技能描述关联起来。还有一个很实用的补充技巧:如果你发现模型倾向于用其他技能替代,可以在被替代的技能描述里明确写一句"如果你需要 X,应该使用 Y 技能而不是本技能",减少误选。
4.2 技能结果被截断、参数类型对不上这类问题怎么查
技能系统最常见的报错不是逻辑错误,而是数据类型不匹配。我印象最深的一次,技能要求传入user_ids是一个字符串数组,模型传成了"user1, user2, user3"这样一个字符串。校验层直接拦截,错误信息反馈给模型后,模型仍然固执地传字符串,重试两次依然失败。
后来我找到的解法是:在 Schema 的描述里更明确地写清格式要求。比如:
"user_ids": { "type": "array", "items": {"type": "string"}, "description": "用户 ID 列表,注意必须传数组格式,例如 [\"user1\", \"user2\"],不要传逗号分隔的字符串。" }另外一个高频问题是返回结果太长,被模型 API 端的 max_tokens 截断。比如摘要技能一次性返回了 2000 字,模型生成回答时只取到了前面 300 字,内容像没写完一样。解决思路不外乎两个:一是技能内部先做长度控制,把返回结果压缩到合理的范围;二是调整模型调用的max_tokens参数,给最终回答预留足够的空间。
4.3 经典问题速查表
我把这些问题汇总成了下表,你在排查时可以直接对照:
| 现象 | 可能原因 | 定位方式 | 处理建议 |
|---|---|---|---|
| 模型不调用任何技能 | 技能描述与用户表达不匹配 | 把用户问题和所有技能描述放在一起阅读 | 重写描述,加入用户视角的表达 |
| 模型总调用同一个技能 | 某个技能描述过于宽泛,覆盖了其他技能的场景 | 查看模型实际传给该技能的参数 | 收窄技能职责,区分使用场景 |
| 技能执行报参数错误 | JSON Schema 约束太松或太严 | 查看报错信息里的校验失败字段 | 逐步调整 Schema,加上 additionalProperties: False |
| 技能返回内容被截断 | 返回结果太长、max_tokens 不够 | 检查模型返回的 finish_reason | 压缩技能返回内、调大 max_tokens |
| 技能执行很慢 | 执行体里有同步阻塞调用 | 用日志记录每次执行耗时 | 检查外部 API 调用,必要时加超时或缓存 |
| 某个技能新版本行为异常 | 版本管理缺失,无法回滚 | 查看调用日志中的技能版本 | 改造为带版本号的技能定义和灰度发布 |
4.4 关于技能系统和 Agent 框架关系的看法
文章写到这里,我知道很多人心里还有一个疑问:市面上的 Agent 框架不是已经提供了 Tool、Plugin 机制吗,为什么还要自己实现一套技能系统?
我的看法是,框架解决的是"通用能力",技能系统解决的是"你业务里的稳定流程"。框架的工具机制通常是平铺的——它们把每一个调用展示给模型,让模型自由选择。这在实验阶段没问题,但到了生产环境,你会希望把"搜索→过滤→总结→输出"这类已经验证过的流程固化下来,让模型只需要选择流程,而不是每一步都重新规划。
技能系统的本质,是把你对业务的理解、对流程的沉淀,变成 Agent 的默认能力。它不是和框架冲突的,恰恰相反,它是在框架之上补齐"治理层"的空缺。你完全可以基于 LangChain、CrewAI 或其他框架来构建,但技能层要独立出来,至少保证:技能的元数据、校验规则、版本信息是可追踪的。
根据我个人的实操经验,一个技能系统从搭建到落地,真正花费时间的不是写代码,而是调试技能描述和参数 Schema——让模型"每次都选对"、"参数每次都传对"。这个打磨过程没有捷径,只能在真实场景里反复试、反复调。这也是为什么我前面花了大篇幅讲描述写法、参数校验这些细枝末节,因为它们才是决定 Agent 体验的核心。最后再分享一个小技巧:每次调技能描述之前,先把旧描述保存下来,记下当时的调用成功率,改完再对比一遍。这样你的每一次调优,都是在往正确的方向前进,而不是凭感觉乱试。