agent-skills:从Function Calling到智能体技能包编排的工程化实践
2026/9/20 9:25:38 网站建设 项目流程

最近在做智能体工程化落地的时候,我一直在反复琢磨一个问题:为什么同样的模型能力,有人做出来的Agent像老员工,手到擒来;有人做出来的就像刚毕业的实习生,明明模型知识都在,却总在具体任务上掉链子?后来我把代码从头翻了一遍,发现差距往往不在模型选型,也不在提示词工程,而是大家有没有把“Agent能干的事”当成一等公民来设计。这就是我这次想分享的主题:agent-skills,一套面向智能体的技能包设计与编排实践。

先说清楚它是什么。agent-skills,简单来说就是把Agent在某个垂直场景里需要执行的原子能力,抽成可复用、可组合、可观测的“技能包”。比如查天气是一个技能,查企业工商信息是一个技能,生成周报是一个技能。每个技能包含模型调用该功能时所需的完整上下文:函数定义、参数Schema、触发条件、使用用例、执行逻辑和兜底策略。它解决的核心痛点,是大模型在真实业务里“看不见手也够不着”的问题——模型有知识,但如果没有一套规范化的技能体系,它就无法稳定、可靠地调取外部工具和私有数据。

这篇文章适合谁看?正在做Agent应用、MCP服务、RAG系统,或者刚接触Function Calling但发现自己被参数描述、指令冲突、调用失败折腾到头疼的人。我会从设计思路、技能定义规范、装载机制、代码实现到踩坑记录,完整讲一遍。内容不吹概念,全部基于我自己跑通、写进生产环境的方案,你可以直接拿去改。

1. 项目整体设计:为什么是Skills,而不是一堆Function

在动手之前,我先把整个项目的设计思路讲清楚。因为如果你只是把十几个工具函数塞给Agent,那不叫架构,那叫临时工。agent-skills这套设计,本质上是一次“从函数列表到技能体系”的升级。

1.1 从Function Calling到Skill层:我在生产环境踩到的三道坎

最早我直接用Function Calling,把接口一个个定义成JSON Schema,然后告诉模型“你能用这些函数”。接两三个工具的时候,效果还行。但工具一多,问题就大面积爆发。

第一道坎是函数列表的膨胀问题。当Agent可用的函数超过20个,模型的选择准确率会明显下降。它开始分不清“查快递”和“查询物流轨迹”是不是同一个功能;两个参数接近的工具,比如“获取用户基本信息”和“获取用户权限信息”,模型经常点错。我把所有工具的Schema拼进Prompt,Token直接多花了将近40%,效果反而更差。

第二道坎是描述质量的不可控。团队每个人写的函数描述风格都不一样,有人写“获取用户信息”,有人写“根据ID查询用户基础资料并返回”,模型的解析效果天差地别。我们做过一次简单的效果回归,同一组测试问题,描述质量好的工具调用准确率能到88%,描述敷衍的直接掉到61%。

第三道坎是多步任务的组合困难。真实业务里没有那么多单次调用。用户说“帮我订明天杭州到北京的高铁,再订个终点站附近的酒店”,这需要拆分行程规划、列车查询、车票预订、酒店搜索、酒店预订五个动作,中间还有状态依赖。纯Function Calling只解决了“单个动作怎么调”,没解决“多个动作怎么组织”,每次都要在提示词里重新描述流程。

agent-skills的出发点,就是把这些临时性的、散落在Prompt里的逻辑,沉淀成结构化、可注册、可编排的“技能包”。它不是要替代Function Calling,而是在Function Calling上面加了一层工程化封装。

1.2 技能语义化设计的三个关键选择

做这套设计时,我定了三个基调和原则,后面所有代码都是围绕这三个原则展开的。

第一个原则是声明式优先。每个技能都用一份独立的Manifest文件描述,内容包括技能ID、名称、描述、参数Schema、可见条件、执行脚本、依赖技能、错误处理策略。这样技能本身脱离了代码而存在,新增一个技能不需要改主程序,只需要新增一个目录。产品运营也能通过配置文件维护技能描述,不用每次找开发改代码。

第二个原则是自校验。技能必须声明自己“什么时候该被调用”,也要声明“什么时候不该被调用”。这一点容易被忽略,但非常重要。我在Manifest里专门设计了一个trigger区,里面写了技能适用的任务类型、必须满足的前置条件、以及典型误用场景。模型在决策时,决策质量明显更稳定,因为它有了正向信号和负向信号,而不是只靠一段干巴巴的“函数说明”。

第三个原则是可组合。技能之间通过依赖声明建立关系。“订酒店”这个技能依赖“获取用户偏好”和“地址解析”。执行器的编排层会先解析依赖DAG,然后逐个执行。这样技能不是孤岛,而是可以像积木一样组装,一个复杂的业务任务,其实就是多个技能按特定顺序的编排。

提示:这三个原则里,自校验最容易被人忽略,但它恰恰是生产环境稳定性的关键。我后面会专门讲,怎样的描述模型才认。

1.3 与主流方案打一个配合,而不是打一场擂台

我在搭建这套东西时,也调研过MCP(Model Context Protocol)等方案。这里我说一下自己的看法:agent-skills和MCP并不是二选一的关系,它们处于不同层级。

对比维度Function Calling原生方式agent-skills技能包MCP Server
最小单元单个函数一个完整的任务技能一组相关工具集合
描述规范JSON Schema散落代码中统一Manifest声明文件遵循MCP协议规范
编排能力靠Prompt硬编码依赖声明+DAG编排由Host端自行实现
可观测性弱,靠日志文本强,技能级埋点与状态跟踪中等,协议层有追踪
扩展成本改代码发版新增目录即生效独立服务/配置

所以我的实际姿势是:底层工具服务用MCP对外暴露,上层业务能力用agent-skills做编排,二者叠着用。技能包里的Executor可以是一个MCP client调用远端工具,也可以是一个本地Python函数,甚至是一个调用别的Agent的子任务。对外统一暴露的是“技能”这个大颗粒,而不是几十个细碎的函数。

2. 核心细节:技能Manifest与装载机制解析

决定这套体系好不好用,最重要的就是Manifest写得好不好。我见过很多人一上来就写代码写Executor,结果卡在描述上,模型就是不知道该不该调。所以我把Manifest的设计单独拿出来讲,它是整个skill体系的“用户界面”。

2.1 一份可运行的Skill Manifest长什么样

我选YAML作为Manifest的格式,原因很简单:可读性好,写注释方便,团队里非开发角色也能看懂。下面是我线上在用的一个简化版示例,用来定义“查询企业工商信息”这个技能。

id: enterprise_basic_info_query name: 查询企业工商信息 version: 1.2.0 description: >- 当用户需要了解某家企业的注册资本、法定代表人、成立日期、 经营状态、统一社会信用代码、经营范围等信息时,使用本技能。 支持按企业全称或统一社会信用代码查询。 trigger: positive: - 企业基本信息查询 - 查一下XX公司的注册资本是多少 - 这家公司的法人是谁 - 企业经营状态是否正常 negative: - 用户想查询企业对外投资关系,请用 enterprise_investment_query - 用户想查询企业司法诉讼记录,请用 enterprise_lawsuit_query required_context: - 必须能提取到企业名称或统一社会信用代码,否则主动向用户追问 parameters: type: object properties: keyword: type: string description: 企业全称或统一社会信用代码 fuzzy: type: boolean description: 是否允许模糊匹配,默认false required: - keyword dependencies: [] executor: type: rest_api endpoint: "https://internal-api.example.com/enterprise/basic" method: GET headers: Authorization: "Bearer ${ENV_ECI_API_TOKEN}" params_mapping: searchKey: keyword fuzzyMatch: fuzzy response_mapping: matched: data.matched records: data.records retry: max_attempts: 2 backoff_seconds: 1.5 timeout: 8 fallback: - message: "企业信息查询服务暂时不可用,已经记录你的请求,稍后可以再试一次。" - action: "notify_admin"

这份Manifest里我最看重两个字段:trigger.negative和required_context。negative这个字段是真的救过我命。之前没有它的时候,用户问“某公司和另一家公司是什么关系”,模型会去调基础信息查询,返回一堆工商信息,却没有回答“关系”;加了negative之后,模型会转而选择“企业关联关系查询”技能,准确率提升了大概25个百分点。

required_context则是兜底。它告诉模型,如果用户提供的线索不完整,不要硬调接口,而是主动追问。这个字段让Agent从“闷头干活”变成了“有脑子的助手”,交互体验提升非常明显。

2.2 描述怎么写,模型才愿意“认领”这个技能

很多人都以为技能的description就是复制粘贴一下接口文档,其实完全不是一回事。模型选择技能的过程,本质上是一个语义匹配过程。你要给它的是“触发信号”和“排除信号”,而不是干巴巴的功能罗列。

我的经验是,一份好的技能描述,必须回答四个问题:

  1. 这个技能解决什么任务?描述里直接写“当用户需要...时使用”,比写“提供XX接口”效果好得多。
  2. 它最擅长处理哪些典型说法?把用户真实会说的句子放进来。注意,不是放示例对话,而是放语义范式。
  3. 什么情况下绝对不能用它?明确把相似技能区分开,这个真的能减少误调用。
  4. 执行前需要哪些信息?写清楚前置条件。比如查天气必须要有城市名,没有就追问。

另外,参数描述同样会影响决策。模型会依据参数描述判断自己手上的信息够不够。参数描述写得模糊,模型就容易凭空编参数。所以我在每个参数的description里都会加“若用户未提供该信息,必须主动询问”这类约束。

注意:description不要写底层技术细节,比如“调用HTTP接口”“返回JSON”。模型不关心技术实现,它只关心任务目标。写技术细节反而会分散模型的决策注意力。

2.3 装载与热更新:让技能库像一个插件市场

技能库的装载机制,我设计成三层结构。

第一层是扫描层。服务启动时,扫描配置的skills目录,读取所有Manifest文件,做格式校验。格式不对的不会直接拒绝,而是进到warning列表,不让其注册,但也不会拖垮整个服务。第二层是注册层。校验通过后,Manifest被转成内存里的Skill对象,Executor按类型加载。代码型技能执行Python函数,REST型技能绑定HTTP终端,MRCP型技能建立客户端连接。第三层是索引层。把所有技能的ID、名称、描述、参数信息合并成一个轻量索引,用于后续驱动模型做技能选择。

热更新的实现也不复杂。我写了一个简单的目录监听器,Manifest文件一旦发生变更,就触发重新加载。发布新技能,不需要重启服务,只要把文件夹往目录里一丢,等两秒,技能库就更新了。我做了一个小的版本号校验,模型在做多轮对话时,如果技能在某轮被更新,上下文里记录的版本和当前版本不一致,会让模型重新决策一次,防止拿着旧方案硬跑。

这里有一个小细节:技能的注册顺序会影响模型的调用偏好。在实践里我发现,同一批测试任务,把某些被认为更通用的技能排在前面,模型的调用命中率会高几个点。我猜测是因为模型的注意力在技能列表开头更集中。所以我会把高频技能放到目录前面,或者专门设一个boot_priority字段来控制排序。

3. 实操过程:从零搭一个Agent Skills服务

这一章我直接上代码,让你能跑起来。我的实现是Python写的,核心依赖很少,FastAPI加PyYAML就够了。不需要复杂框架,核心价值都在设计逻辑里。

3.1 项目目录结构

我的项目结构比较简洁,建议你直接照着建:

agent-skills/ ├── skills/ │ ├── enterprise_basic_info_query/ │ │ ├── manifest.yaml │ │ └── executor.py │ ├── calendar_event_create/ │ │ ├── manifest.yaml │ │ └── executor.py │ └── ... ├── core/ │ ├── loader.py # 技能加载与校验 │ ├── registry.py # 技能注册表 │ ├── selector.py # 技能选择与路由 │ └── executor.py # 技能执行器 ├── server.py # FastAPI入口 └── config.yaml # 总配置

每个技能一个目录,Manifest和Executor分开。这样做好处很明显:技能之间的边界清晰,谁改了什么一目了然,也方便后面做权限控制和灰度发布。

3.2 核心代码:加载器、注册表与执行器

先看加载器。它负责把YAML转成对象,并且做基础校验。

# core/loader.py import yaml from pathlib import Path from dataclasses import dataclass, field from typing import Any, Optional @dataclass class Skill: id: str name: str version: str description: str trigger: dict parameters: dict dependencies: list executor: dict retry: dict timeout: int fallback: list source_path: Optional[Path] = None @classmethod def from_manifest(cls, path: Path) -> "Skill": raw = yaml.safe_load(path.read_text(encoding="utf-8")) # 必填字段校验 required = ["id", "name", "description", "parameters", "executor"] missing = [k for k in required if k not in raw] if missing: raise ValueError(f"Skill manifest缺少必填字段: {missing}") raw["source_path"] = path return cls(**raw)

注册表维护所有技能的内存索引,并负责按ID查找和依赖解析。

# core/registry.py from typing import Dict, List, Optional class SkillRegistry: def __init__(self): self._skills: Dict[str, Skill] = {} self._order: List[str] = [] def register(self, skill: Skill) -> None: if skill.id in self._skills: # 版本高则覆盖,否则跳过 current = self._skills[skill.id] if self._compare_version(skill.version, current.version) <= 0: return self._skills[skill.id] = skill if skill.id not in self._order: self._order.append(skill.id) def unregister(self, skill_id: str) -> None: self._skills.pop(skill_id, None) self._order = [sid for sid in self._order if sid != skill_id] def get(self, skill_id: str) -> Optional[Skill]: return self._skills.get(skill_id) def list_for_model(self) -> List[Skill]: return [self._skills[sid] for sid in self._order if sid in self._skills] def resolve_dependencies(self, skill_id: str) -> List[Skill]: result = [] visited = set() def visit(current_id: str) -> None: if current_id in visited: return visited.add(current_id) skill = self._skills.get(current_id) if not skill: return for dep_id in skill.dependencies: visit(dep_id) result.append(skill) visit(skill_id) return result @staticmethod def _compare_version(v1: str, v2: str) -> int: import re p1 = [int(x) for x in re.findall(r"\d+", v1)] p2 = [int(x) for x in re.findall(r"\d+", v2)] for a, b in zip(p1, p2): if a != b: return 1 if a > b else -1 return len(p1) - len(p2)

执行器这里,我支持三种类型:local(本地函数)、rest_api(HTTP调用)、command(命令行),实际项目中按需扩展。

# core/executor.py import httpx import asyncio from typing import Any, Dict class SkillExecutor: def __init__(self, registry: SkillRegistry): self.registry = registry self._http = httpx.AsyncClient(timeout=10) async def execute(self, skill_id: str, params: Dict[str, Any]) -> Dict[str, Any]: skill = self.registry.get(skill_id) if not skill: raise KeyError(f"技能未注册: {skill_id}") # 依赖预执行,提取上下文作为额外参数 context = {} for dep_id in skill.dependencies: dep_result = await self.execute(dep_id, params) context[dep_id] = dep_result exec_type = skill.executor.get("type", "local") if exec_type == "rest_api": result = await self._run_rest(skill, params) elif exec_type == "local": result = await self._run_local(skill, params, context) else: raise ValueError(f"Unsupported executor type: {exec_type}") return { "skill_id": skill.id, "version": skill.version, "result": result, "used_params": params, } async def _run_rest(self, skill: Skill, params: Dict[str, Any]) -> Any: ep = skill.executor["endpoint"] method = skill.executor.get("method", "GET").upper() mapping = skill.executor.get("params_mapping", {}) headers = skill.executor.get("headers", {}) # 按映射关系转换参数名 mapped = { mapping.get(k, k): v for k, v in params.items() if v is not None } async with self._http as client: if method == "GET": resp = await client.get(ep, params=mapped, headers=headers) else: resp = await client.post(ep, json=mapped, headers=headers) resp.raise_for_status() return resp.json() async def _run_local(self, skill: Skill, params: Dict[str, Any], context: Dict[str, Any]) -> Any: module_name = skill.executor.get("module") if not module_name: raise ValueError(f"local executor缺少module配置: {skill.id}") import importlib module = importlib.import_module(module_name) func_name = skill.executor.get("function", "run") func = getattr(module, func_name) if asyncio.iscoroutinefunction(func): return await func(params, context) return func(params, context)

3.3 接入大模型:技能选择的三种姿势

技能库建好了,怎么让模型用起来?我在项目里实际跑了三种接入方式,各有优劣。

第一种是Prompt注入式。把所有技能的Manifest精简后塞进System Prompt,让模型自动决定调用哪个技能。这种方式实现最简单,但Token消耗高,技能多了效果差。我建议不超过25个技能时才这么用。

第二种是两步式决策。第一步,用一个轻量分类模型或者关键字规则,先从技能库里候选出5个可能的技能;第二步,把这5个技能的完整Manifest发给大模型,让它在里面选。这种方式既控制了Token,又保留了语义判断的灵活性。我在生产环境用的就是这种方式,效果非常稳。

第三种是Agent循环式。把技能选择做成一个独立的小Agent,它自己决定调用顺序和参数。适合超复杂任务,但响应延迟会明显增加,调试也麻烦一些。

顺便说一句,不管哪种方式,都要给模型一个明确的信号:技能调用结果必须反馈给用户,不能默默失败。所以我在技能输出格式里统一增加了一个status字段,模型看到status=success才继续,看到status=failed就换策略。这个细节让整体稳定性提升了一个台阶。

我准备了一份把技能Manifest转成模型友好格式的工具函数,方便你接入Prompt或者两步式决策。

# core/selector.py import json def build_skill_prompt(skills) -> str: lines = ["# Available Skills", ""] for s in skills: lines.append(f"## {s.id} (v{s.version})") lines.append(f"- Name: {s.name}") lines.append(f"- Description: {s.description}") lines.append(f"- Parameters: {json.dumps(s.parameters, ensure_ascii=False)}") if s.trigger.get("negative"): lines.append(f"- Do NOT use when: {'; '.join(s.trigger['negative'])}") lines.append("") return "\n".join(lines)

3.4 测试与评估:不能只靠“感觉还行”

技能库上线前,我建议建立一套回归测试集。不要等到上了生产再让用户帮你测。我的做法是,每月固定收集真实用户问题100条,打上标签,标注应该命中哪个技能,跑一次全量回归,得出三个指标:技能命中率、参数完整率、任务完成率。

下面是我自己项目最近一次回归的样例数据:

技能名称测试题数命中率参数完整率任务完成率误用率
企业工商信息查询20096.5%94.0%91.5%2.5%
日程创建15098.0%100.0%96.0%0.7%
天气查询12099.2%99.2%97.5%0.8%
企业关联关系查询8091.3%88.8%85.0%6.3%

误用率偏高的技能,我会重点看它的negative描述是不是写清楚了,是不是和相近技能的边界没有划清。这个表跑出来以后,优化方向会非常清楚,而不是靠猜。

4. 常见问题与排查技巧实录

这里我整理了自己在搭建和运行agent-skills过程中踩过的坑。每一个都真实发生在我项目里,不是网上抄来的。

4.1 模型死活不调用某个技能

这大概是出现频率最高的问题。排查思路从这几个方向来:

  1. 看是不是技能压根没注册成功。日志里搜一下技能ID,确认registry里有。
  2. 看描述和用户意图的语义距离。如果描述写的是“提供企业信息查询接口”,而用户说的是“帮我看看这家公司靠不靠谱”,模型真的很难把两者关联起来。改成“评估企业是否可信、是否正常经营”之类的说法,情况立刻不一样。
  3. 看是不是有多个相似技能抢了名字。用户要的是“查法人”,你的技能描述里没有“法人”两个字,而另一个技能描述里刚好有,那模型就会跑偏。优化方式是给每个技能都列出一组同义词和典型问法。
  4. 看描述长度。有些技能描述写太长,模型一扫描直接略过了。我通常把description控制在80到150个字的区间,太长的拆成基础说明加触发条件。

4.2 Prompt Token暴涨,模型还没选对

每加一个技能,Token就涨一截。我在初期曾经塞了40多个技能,一次请求光技能描述就花了近五千个Token。后来我开始精简描述,每个技能只保留description、parameters、negative三块,其他字段只在执行阶段使用,不进入Prompt。这样Token降了60%,命中率反而高了。

再进一步,就是上一步说的两步式决策。先召回再选择。我用一个初筛规则:每个技能配置了一组关键词权重,用户问题的分词结果命中权重足够高,才进入候选集合。候选集合控制在5个以内,让大模型做最终决策。实测下来,相关技能的候选命中率能做到96%以上,说明初筛环节不会把正确技能漏掉。

4.3 相同输入,结果时好时坏

这个问题的根子通常不在技能库,而在模型决策的随机性。我的做法是,在System Prompt里加一句“严格根据技能描述中的触发条件判断,不确定时优先选择ID排序靠前的技能”。这看起来有点笨,但确实能把结果稳定性拉高不少。

另外,技能执行的超时和重试策略也要想清楚。早期我把超时设置成5秒,内网接口偶尔抖动超过5秒就直接报错,用户体感就是“Agent突然傻了”。后来统一做了两轮重试、指数退避,超时调整到8到10秒,任务完成率从82%升到了93%。重试的逻辑我已经写在上面的Executor里了,你直接用就行。

4.4 我强烈建议保留的一份运行日志

生产环境调试像抓瞎一样痛苦时,我才意识到日志结构化有多重要。给每个技能调用都打一份结构化日志,格式如下:

ts=2024-11-20T10:23:11Z event=skill_executed skill_id=enterprise_basic_info_query version=1.2.0 status=success latency_ms=423 params={"keyword":"某某科技有限公司"} result_preview="found 1 record" session_id=7f91a2b4

有了这份日志,定位很多问题都变成了SQL查询,而不是翻服务器上的print输出。比如查某个技能的P99延迟,按skill_id聚合一下;查模型误用,找status=fallback的记录;查参数缺失,直接看params_json里哪个字段频繁为空。这套日志体系是我项目上线后做的最有价值的一次基建投入。

注意:参数、结果、用户问题都算业务数据,日志打全量要脱敏。特别是涉及账号、手机号、地址等字段时,用掩码函数处理后再落盘,不要图省事直接把原始JSON打出来。

4.5 常见问题速查表

我最后整理一个速查表,你遇到类似问题可以快速对照。

现象可能原因验证方式解决方案
模型不调用目标技能描述语义距离太远把用户原话和描述放到向量里算相似度重写描述,加入真实用户表述
两个技能频繁选错边界描述不清看误用日志集中哪个技能对补全双方的negative字段
接口调用参数错误参数映射配置错误对比请求日志和原始输入检查params_mapping
Token消耗过高技能描述全量注入Prompt统计Prompt中技能占比改成两步式决策
技能更新后还是旧行为版本比较逻辑有Bug查看注册表版本号检查版本号解析正则
执行失败但用户无感知缺少fallback处理看fallback日志触发量配置兜底文案和告警

最后分享一点自己的体会

做这套agent-skills项目,我最深的感受是:Agent工程的复杂度,不在模型里,而在模型和现实世界的接缝处。模型再聪明,也需要一套表达清晰、边界明确、可观测的“手和脚”。你给Agent的不是几十个函数,而是一套它真正“理解”的工作方式。我从最早几十个函数塞Prompt,到现在四十多个技能稳定运行,最大的变化不是代码多了多少,而是我开始把Agent当成一个需要入职培训的新员工来对待——每一个技能,都像一个SOP手册,写清楚适用范围、操作步骤、前置条件和禁忌事项。按这个思路做下去,你也会发现,Agent并不是那么不可控。以上,就是我在agent-skills项目里的完整实践记录,里面的代码和思路,你都可以直接拿去用,也希望你能踩出更少的坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询