做 Agent 开发的朋友,最近肯定绕不开“agent-skills”这个词。它跟我说的是同一件事:智能体不能只会“聊天”,得会“干活”,而这种“干活”的能力,需要一套结构化的技能体系来支撑。今天这篇文章,我打算把我自己从零搭技能库、跑通技能调用、再到上生产环境的完整过程拆开讲,包括设计思路、代码实现、评估方法,以及我踩过的几个印象深刻的坑。无论是刚接触智能体的小白,还是已经折腾过 LangChain、Claude 等框架的老手,读完应该都能直接抄作业。
1. Agent Skills 到底是什么:从“会聊天”到“会干活”
1.1 一个技能就是一个可复用的能力单元
先给个最朴素的定义:Agent Skill 是一个封装好的、可被大模型智能体按需调用和组合的特定能力模块。它不只是一段函数,而是“模型的调用方式 + 函数的输入输出规范 + 执行逻辑 + 失败处理 + 必要的上下文约束”的整体打包。你可以把它理解为给 AI 助手上的一份“岗位说明书”——比如“这个技能负责计算邮费,需要起点城市、终点城市、包裹重量,它返回运费金额和预计时效,超重会报一个特定的错误码”。
为什么要搞这套东西?因为大模型本身只是推理引擎,它擅长规划、生成文字、理解语义,但不擅长做精确计算、调外部 API、读写数据库、执行本地文件操作。技能层就是夹在模型和真实世界之间的那双手。一个典型的例子:你让智能体帮你查天气,模型其实不知道今天的气压数据,但如果它有一个“weather_lookup”技能,它会自动生成参数并调用后端接口,再把返回结果转述给你。这就是 Agent Skill 的最小价值闭环。
我在实际项目里给技能定了三个硬性要求:可复用、可测试、可观测。可复用意味着技能不绑定某一个具体对话;可测试意味着每个技能都能独立跑单元测试;可观测意味着每次调用都有日志,能看到模型想了什么、传了什么参数、技能返回了什么。这三条缺一条,技能库就会变成一个越滚越乱的大泥球。
1.2 为什么不能把技能写在系统提示词里
很多新手会问:我直接把工具的使用说明写进 system prompt 不行吗?短时间看行,但你一旦超过五个技能,系统提示词就会变成又臭又长的说明书。模型开始“遗忘”后面的技能,或者把所有技能都描述一遍导致 token 爆炸,更麻烦的是你每次升级技能逻辑都得改提示词,版本管理直接失控。
系统提示词适合放的是“全局行为规范”,比如“你是客服助手,注意礼貌,不要编造事实”。而 Agent Skills 适合放的是“能力清单和操作契约”,比如“当你需要查询订单时,调用订单查询技能”。这两者必须分开管理。技能存放在独立的注册表里,有自己的版本号、字段校验规则、测试用例、调用统计。模型需要哪一项,再通过函数调用(function calling)或工具选择机制去“唤起”对应的技能,而不是把所有细节都堆在上下文里。
这里还涉及一个关键的架构哲学:技能要有“边界感”。技能内部可以复杂,但对外暴露的接口必须简单。我见过有人把“生成客户周报”技能做成了一个庞大的多步骤工作流,内部要调 CRM、要算数据、要渲染 PDF,结果一旦中途出错,模型根本不知道错在哪一步,只能整个重来。好的做法是把大技能拆成多个原子技能:取客户数据、生成报表摘要、渲染 PDF,然后让模型像搭积木一样组合调用。这样既方便复用,也方便排查。
2. 设计一套 Agent Skills 的核心思路
2.1 技能接口:输入、输出、副作用
设计技能的第一步,是定义接口契约。每个技能必须有明确的输入参数、输出结构和副作用。输入参数要做严格的 JSON Schema 校验,不要指望模型一定能凭空生成完全正确的参数。我在生产环境里见过太多次模型把日期格式传成“2025年6月1日”,而技能要求的是“2025-06-01”。所以技能内部必须做参数归一化:接受多种常见格式,统一转换成标准格式,再执行逻辑。
输出结构同样要规范。我强烈建议技能统一返回一个 Result 对象,至少包含三个字段:
- status:success / error / partial(部分成功)
- data:实际数据
- message:给模型看的自然语言反馈,解释发生了什么
message 这部分特别关键。模型不是人,它不会从 data 里自动理解含义,要靠 message 来理解结果。比如“查询订单失败”可以返回“订单号 ORD-2024-001 不存在,请核对订单号后重试”,模型看到这句话就会主动跟用户道歉并索要正确订单号。这个反馈内容的质量,直接决定模型下一步决策的正确性。
副作用指的是技能调用对外部系统的影响,比如是否写库、是否发邮件、是否扣费。对于有副作用的技能,我要求必须加“确认”机制:模型需要先通过一个 dry-run 技能检查将要执行的操作,再由用户或审批流二次确认,最后才真正执行。这个习惯帮我避免了至少三次线上事故。
2.2 技能描述:给 LLM 看的说明书
技能描述是 Agent Skills 设计里最容易被低估的部分。模型不会读你的源码,它只能通过描述文字来判断“什么时候该用这个技能、怎么调用它”。写技能描述要遵循三个原则:简洁、具体、差异化。
简洁是指不要超过 150 个 token,模型对长描述的注意力会衰减。具体是指要包含触发条件、常用场景、调用示例;差异化是指要明确写出“这个技能不做哪些事”,避免模型把相似技能搞混。比如你有“发送邮件”和“发送营销邮件”两个技能,后者一定要注明“仅用于批量营销活动,需要先经过营销审核表”,否则模型很可能在普通业务邮件时选错。
我常用的描述模板是:
- 技能名称:动词开头,例如
create_calendar_event - 一句话功能概括:例如“创建日历日程,支持设置提醒”
- 适用场景:例如“当用户提出约会、会议、提醒需求时使用”
- 不适用场景:例如“不要用于取消或修改已有日程,那需要使用 update_calendar_event”
- 调用示例:给一个完整的参数 JSON 示例
一个测试技巧:把技能描述混在一起,拿一个包含多个技能调用的测试对话跑一遍,看看模型能不能正确选用。如果选错,就说明描述信息不足或者区分度不够,需要改描述而不是怪模型笨。
2.3 技能注册与发现机制
技能库不能是一堆散落的函数,需要有一个注册中心。注册中心的任务包括:技能元数据登记(名称、版本、接口描述、权限级别、调用频率统计)、技能依赖管理(一个技能可能依赖其他技能或共享工具)、技能启停控制(灰度发布时只让 10% 流量走新技能)。
我用的注册表是一个简单的关系表,字段大致是:
| 字段 | 含义 | 示例 |
|---|---|---|
| skill_id | 唯一 ID | calc_shipping_cost |
| version | 版本号 | 1.3.0 |
| description | 给模型看的描述 | 计算运费,支持国内及港澳台 |
| input_schema | JSON Schema | {...} |
| output_schema | JSON Schema | {...} |
| required_auth | 所需权限 | user_scope |
| enabled | 是否启用 | true |
在模型层面,每次对话开始时,系统会把所有启用的技能描述注入到上下文里。当技能数量超过 30 个时,注入成本会明显上升,这时候就要做技能路由:先用一个轻量级分类器(或者用模型做一次快速预筛选)从技能库里挑出可能相关的 5-10 个技能,再把这些技能的详细描述交给主模型。这个“粗筛 + 精调”的模式,能让响应速度和准确率同时提升一个台阶。
3. 从零实现一个技能库:环境与代码示例
3.1 最小化的技能封装格式
下面我用 Python 演示一个最基础的技能实现。我们没有复杂框架,只用标准库加一个简单的装饰器注册机制,这样能看清技能的本质。
# skill_base.py import json import inspect from typing import Callable, Any, Dict from dataclasses import dataclass @dataclass class SkillResult: status: str # "success" | "error" data: Any = None message: str = "" class Skill: def __init__(self, name: str, description: str, input_schema: dict, enabled: bool = True): self.name = name self.description = description self.input_schema = input_schema self.enabled = enabled self.func = None def __call__(self, *args, **kwargs): return self.run(*args, **kwargs) def run(self, **kwargs) -> SkillResult: try: # 参数校验 raw = self._validate_and_normalize(kwargs) result = self.func(**raw) if isinstance(result, SkillResult): return result return SkillResult(status="success", data=result, message="执行成功") except Exception as e: return SkillResult(status="error", data=None, message=f"执行失败: {str(e)}") def _validate_and_normalize(self, params: dict) -> dict: # 这里可以做字段补全 / 格式归一化 # 为演示简化,只做必填检查 required = [k for k, v in self.input_schema.get("properties", {}).items() if v.get("required")] for r in required: if r not in params or params[r] is None: raise ValueError(f"缺少必填参数: {r}") return params class SkillRegistry: def __init__(self): self.skills = {} def register(self, skill: Skill): if skill.name in self.skills: raise ValueError(f"技能 {skill.name} 已存在") self.skills[skill.name] = skill def get_descriptions(self, max_tokens: int = 2000) -> str: lines = [] for skill in self.skills.values(): if not skill.enabled: continue lines.append(f"技能名: {skill.name}\n说明: {skill.description}\n输入格式: {json.dumps(skill.input_schema, ensure_ascii=False)}") return "\n".join(lines) registry = SkillRegistry()接着定义一个具体的技能。这里我写一个“查邮费”技能,内部逻辑用一个简单规则表模拟。
# shipping_skill.py from skill_base import Skill, SkillResult, registry def calc_shipping_cost(from_city: str, to_city: str, weight_kg: float, express: bool = False) -> dict: # 模拟运费规则:首重1kg 10元,续重每kg 5元;如果跨省及超重,加8元;特快加价50% base = 10 if from_city != to_city: base += 8 if weight_kg > 1: base += (weight_kg - 1) * 5 if express: base *= 1.5 return { "cost": round(base, 2), "currency": "CNY", "estimated_days": 1 if express else (2 if from_city != to_city else 3) } shipping_skill = Skill( name="calc_shipping_cost", description="计算快递运费。当用户想知道从某城市寄到另一城市多少钱时使用。不适用于国际件。", input_schema={ "type": "object", "properties": { "from_city": {"type": "string", "description": "寄件城市名,例如北京"}, "to_city": {"type": "string", "description": "收件城市名,例如上海"}, "weight_kg": {"type": "number", "description": "包裹重量(kg)"}, "express": {"type": "boolean", "description": "是否特快,默认false"} }, "required": ["from_city", "to_city", "weight_kg"] } ) shipping_skill.func = calc_shipping_cost registry.register(shipping_skill)这段代码有意省略了框架,就是为了让你看到:技能其实就是一个带说明和数据契约的函数。真正使用的时候,你完全可以用 FastAPI 或者 langchain 的工具装饰器,但核心逻辑是一样的。
3.2 让 Agent 自主调用技能的调度逻辑
有了技能定义,接下来要解决“什么时候调用哪个技能”。最朴素且好用的方案是兼容 OpenAI function calling 的格式,把技能列表转换成工具列表,让模型自动生成调用请求。下面这段代码展示了如何用 openai 库实现。
# agent_core.py from openai import OpenAI import json from skill_base import registry client = OpenAI() # 假设环境变量里配置了 API Key def build_tool_schema(skill) -> dict: return { "type": "function", "function": { "name": skill.name, "description": skill.description, "parameters": skill.input_schema } } def run_conversation(user_msg: str, history: list = None): history = history or [] messages = [{"role": "system", "content": "你是个人助手,请根据需要调用可用技能,技能信息如下:"}] # 为了控制上下文长度,这里先直接注入所有技能描述 messages[0]["content"] += "\n" + registry.get_descriptions() messages.extend(history) messages.append({"role": "user", "content": user_msg}) tools = [build_tool_schema(s) for s in registry.skills.values() if s.enabled] if not tools: # 没有技能时,普通对话 resp = client.chat.completions.create( model="gpt-4o-mini", messages=messages ) return resp.choices[0].message.content while True: resp = client.chat.completions.create( model="gpt-4o-mini", messages=messages, tools=tools, tool_choice="auto" ) msg = resp.choices[0].message if not msg.tool_calls: return msg.content messages.append(msg) for call in msg.tool_calls: skill_name = call.function.name try: args = json.loads(call.function.arguments) skill = registry.skills[skill_name] result = skill.run(**args) messages.append({ "role": "tool", "tool_call_id": call.id, "content": json.dumps({ "status": result.status, "data": result.data, "message": result.message }, ensure_ascii=False) }) except Exception as e: messages.append({ "role": "tool", "tool_call_id": call.id, "content": json.dumps({"status": "error", "message": str(e)}) })这里有一个细节值得注意:我在把技能描述注入 system prompt 的同时,又把同样的技能作为 tools 传给了 OpenAI 接口。这看起来重复,但其实是双保险——描述注入是为了让模型理解全局能力,tools 则是为了让模型能按结构化格式发起函数调用。如果你的模型接口不支持 tools,那么可以只用描述注入,然后让模型输出一个特定格式的 JSON(比如{"action": "calc_shipping_cost", "args": {}}),再用解析器提取并执行。但那样稳定性会差一截,建议优先用原生 function calling。
3.3 带状态与记忆的技能设计
真实业务中,技能往往需要跨多次调用维持状态。比如一个“订机票”技能,用户可能先说“从北京到上海”,再说“明天早上”,最后说“选最便宜那班”。如果每次调用都是无状态的,模型就得靠对话历史里的原始文本去拼参数,很容易出错。
解决方法是给技能引入 session memory。我通常这么做:每个对话 session 维护一个session_statedict,技能可以读取和修改它。比如:
# stateful_skill_demo.py session_state = {} def flight_booking(origin: str = None, dest: str = None, date: str = None): # 合并已有状态 if origin: session_state["origin"] = origin if dest: session_state["dest"] = dest if date: session_state["date"] = date if not all([session_state.get("origin"), session_state.get("dest"), session_state.get("date")]): missing = [k for k in ["origin", "dest", "date"] if not session_state.get(k)] return SkillResult(status="partial", data=None, message=f"还缺少以下信息: {', '.join(missing)},请提醒用户补充") # 执行查询 result = query_flight(session_state["origin"], session_state["dest"], session_state["date"]) return SkillResult(status="success", data=result, message="航班查询完成,请根据结果让用户选择")这个设计的关键是返回partial状态,模型看到这个状态后会产生“还要继续追问”的意图,而不是傻傻地把缺参数错当成失败。我见过太多技能是“一缺参数就报错”,结果模型只能给用户抛一句“系统错误”,体验极差。
状态管理要小心一个问题:session_state 的存活期。对话一结束,状态应该清理;如果用户中途切换话题,状态也要及时重置。做法很简单:在 agent loop 里检测到新的意图领域(比如从“订机票”切到“查天气”)时,清空对应的 namespace。
4. 实测中的避坑指南与常见问题
4.1 技能描述写得不好,模型总会选错
我在早期开发时写了一个“get_current_time”技能,描述是“获取当前时间”。结果模型在用户问“今天星期几”时,居然不知道调用这个技能,而是直接回答“我不知道”。后来我把描述改成:“获取当前日期、时间和星期几。当用户询问当前时间、日期、今天星期几、现在几点、拍不了看时间等场景时使用。不要用于计算时区转换。”模型立刻就会用了。
这个案例说明:描述里要把用户可能用到的各种说法都写进去,尤其是口语化表达。我整理了一个技能描述自查清单:
- 是否包含至少 3 种用户问法的示例?
- 是否写明确不要调用的场景?
- 是否准确说明了每个参数的含义和格式?
- 是否包含一个具体的调用示例?
如果技能描述里出现“例如用户问:XXX 时”这种话,效果会非常好。因为对模型来说,示例比抽象规则更有指导性。
4.2 参数校验与错误处理
模型传参错误是常态,不是意外。常见错误包括:数值类型传成字符串、日期格式不符、枚举值没在范围内、把“补全的信息”当成“已定信息”。应对策略就是三层防护:
第一层,Schema 校验。用 JSON Schema 的type、enum、pattern等约束,在校验阶段就拦截明显错误。比如weight_kg要限制在 0.1 到 500 之间,不在范围内的直接返回错误。
第二层,归一化。有些错误不必返回错误,可以通过归一化自动修复。比如日期字段“6月1日”可以补全省份为“2025-06-01”;手机号字段“138 0000 0000”可以去掉空格。我在技能内部写了一套通用 normalize 工具,接收"today"返回今天的日期,接收"tomorrow"返回明天的日期,这样模型就不必纠结到底传哪种格式。
第三层,错误消息要面向模型优化。技能返回的 message 要能指导模型下一步动作。比如“订单不存在”不如写成“订单号 ORD-123 不存在,请确认是否输错,或者帮用户查询最近三天的订单”。这样模型就有下一步可以走。
另外,技能执行超时一定要设上限。我在一个技能里不小心调了一个网络请求,没有设置 timeout,导致整个 agent 卡了 3 分钟。后来所有技能都强制要求超时时间,内部优先用asyncio的wait_for,外部再兜底一层。
4.3 长上下文的性能陷阱
技能多了之后,你可能会发现 token 消耗涨得飞快。比如 30 个技能,每个描述 200 token,光技能描述就 6000 token,每次请求都带上,这部分成本是把小模型换成大模型的主要原因。
我实测过一个对比:同样完成“查快递单号”的任务,无技能路由时 prompt 约 7.5k token,加了路由预筛后降到 2.8k token,响应速度提升 40%。实现路由的方式有很多,最简单的可以用 embedding 检索:将技能描述向量化,用户消息向量化,计算余弦相似度,取 top-K。这种方法适合技能数量超过 20 个的团队。
另一个性能陷阱是模型把同一技能调用很多次。有一次用户说“要点三杯不同甜度的奶茶”,模型一次生成三个 tool_calls,这虽然合法,但技能内部可能重复加载大量数据。我的建议是在技能内部做“同参数结果缓存”(比如查运费,相同城市和重量的运费可以缓存 24 小时);对于模型层面,可以在 prompt 中提示“当同一操作会重复执行时,请合并为一次调用”。
4.4 并发与资源隔离
当你的 Agent 同时服务很多用户时,每个用户都会有独立的 session,技能调用时如果共享全局变量,就会出现串数据。比如上面我写的session_state,如果放在一个全局 dict 里,而 session_id 没有作为参数传入,那么用户 A 的航班信息可能会被用户 B 覆盖。
正确做法是:session_state 必须绑定在 session 对象内,技能调用时从上下文里取。如果你用 FastAPI,可以用contextvars或直接在消息对象里携带 session_id。我后来把 Agent 换成了异步架构,每个 session 一个独立的状态对象,技能函数签名里不直接访问全局,而是通过AgentContext来传递,这样并发就安全了。
还要注意技能之间的资源竞争。比如“导出报表”技能和“发邮件”技能可能同时用到 SMTP 连接池,如果没有做连接复用,系统会频繁握手。这种问题通常在压测时才会暴露。建议给所有外部 IO 加连接池和限流器,技能内部不要裸建连接。
5. 从技能到技能生态:评估与扩展
5.1 技能评估指标
技能开发和模型开发一样,需要评估才能迭代。我常用的指标分三层:
第一层,技能本身准确率。每个技能有独立的测试集,比如给calc_shipping_cost输入 50 组城市和重量,断言输出的费用是否符合规则。这一步用常规单元测试即可。
第二层,调用正确率。在模拟用户对话中,检查模型是否在正确场景下调用了正确技能、参数是否完整、调用顺序是否合理。我常用的是一个skill_usage_matrix,列出每个技能的触发条件,然后人工或半自动判断。
第三层,端到端任务成功率。给定一个完整任务(“帮我查到上海寄一个 3kg 包裹,走特快,多少钱”),看整个对话最终是否得到正确答案、用户是否需要重复追问。这个指标最能衡量体验。
我建议技能开发者至少保留一个回归测试集。每次改动技能描述或内部逻辑,跑一遍所有测试,防止“修好了 A 技能,弄坏了 B 技能”。
5.2 让用户用自己的数据扩展技能
比较进阶的话题是“用户自定义技能”。很多人希望自己的 Agent 能做一些私有的操作,比如“把我的邮件按标签自动归档”。如果每次都要让开发者写代码,就不够灵活。
目前可行的思路是“技能 DSL”。你可以定义一套简单的领域特定语言,让用户(或半技术用户)填写技能名称、输入参数、动作指令,系统自动生成对应的技能。比如:
name: archive_email description: 按标签将邮件归档到指定文件夹 trigger: 当用户发出归档邮件的请求时 steps: - action: gmail_search args: query: "label:{label} is:unread" - action: gmail_batch_move args: folder: "{destination}"这些 YAML 需要经过编译,转换成底层可执行技能。难点在于安全:不能让用户编写的技能执行任意代码。我的建议是只允许用户组合平台内置的受限动作,而不是开放 shell。这样既保留了灵活性,又不会把系统搞成执行任意命令的漏洞。
5.3 后续可以怎么延伸
技能库未来大概率会走向“技能市场”的模式。团队可以把自己积累的技能打包发布,其他团队直接安装使用。在这个方向上,需要补齐的还有:技能版本兼容、技能依赖锁文件、技能定价与计费、技能沙箱隔离。
我个人的感受是,Agent 能力的竞争正在从“模型参数”转向“技能生态”。模型就像操作系统内核,技能就像是 App。内核更新频率低,但 App 的丰富程度往往决定一个平台的价值。对个人开发者而言,现在正是积累技能资产的好时候,不必等模型变得无限强大,先把手头高频重复的任务固化成技能,就已经能提升一截效率。
最后再分享一个小技巧:给技能写“失败预案”。我在生产环境里有个保险丝机制——技能连续调用失败三次,会自动进入降级模式(比如查天气失败就返回一个“天气服务暂时不可用”的固定文案,而不是让模型反复重试制造垃圾请求)。这个方法看起来简单,但真的能救你于水火之中。