☰
手把手搭建Agent技能库:从Function Calling到工具调用工程落地
2026/10/7 17:14:45 网站建设 项目流程

做 Agent 开发的朋友应该都体会过那种感觉:模型动不动就能引经据典、侃侃而谈,可真让它干点正事——查个天气、算笔账、改个文件名——它就原地傻眼。原因很简单:LLM 没有手,既摸不到外部世界的实时数据,也没法主动改变任何系统状态。agent-skills 这个项目概念,核心就是解决这个问题——把“模型会说话”变成“模型会干活”。这篇文章我会从技能库的架构设计讲到具体落地,给出一套既能跑通、又能扛住真实业务的技能化方案,适合正在做 Agent 原型验证、或者已经卡在工具调用稳定性上的开发者参考。

先说我自己的背景。过去一年多,我一直在做企业级 Agent 平台,前后给客服、运维、数据分析几个场景搭过技能系统,踩过不少文档里不会写的坑。agent-skills 这个方向看起来简单,实际做起来涉及技能建模、参数约束、调用链路、错误恢复、动态扩展一堆细节。这篇就当是阶段性的经验复盘,想到哪写到哪。

1. agent-skills 到底是什么:从“会说话”到“会干活”

1.1 核心概念拆解

agent-skills 说白了,就是给大模型配一套可以随时调用的“技能库”。这里的技能不是指 prompt 里写几句提示词,而是指有完整定义、有参数约束、有对应执行逻辑的工具模块。每个技能包含三样东西:技能名称、技能描述、参数 Schema。模型在对话过程中,根据用户请求的意图,主动挑选一个或多个技能,生成结构化的调用参数,然后由运行时环境真正执行这些技能。

我习惯用一个生活化的类比来解释:大模型像一个新入职的实习生,脑子聪明,知识面广,但什么实际业务都不会。agent-skills 相当于给他一本《岗位操作手册》,上面写着“打印文件时,使用打印机技能,参数为文件路径、打印份数、单双面”。实习生不需要知道打印机驱动怎么写,只需要按手册勾选参数,剩下的活由技能实现者搞定。

这个机制的技术基础叫 function calling,也叫 tool calling。主流大模型对外提供的 API 里都有对应接口:你把技能列表以 JSON 格式传给模型,模型在需要时返回一个结构化的调用指令,而不是直接输出自然语言。运行时解析这个指令,执行代码,再把结果喂回给模型,模型继续后续的推理。

1.2 为什么技能化设计比“让模型自由发挥”更靠谱

我在早期做 Agent 的时候,习惯把所有能力写进系统提示词里:你可以用 python 执行,你可以访问数据库,你可以读取文件……结果就是模型经常“想当然地执行”,它以为自己执行了,实际上什么都没发生,或者干脆生成一段永远跑不起来的伪代码。教训很明显:你越给模型抽象的权力,它越容易发挥想象力。

技能化设计把抽象能力变成确定性的接口。每个技能背后都是经过测试的代码,它的输入有约束,输出有格式,异常有兜底。模型只能在这些边界内做选择,不能自由发挥。这样一来,系统的可测试性、可观测性、安全性都上了一个台阶。

另外技能是可复用的资产。一个“发送邮件”技能,今天在客服 Agent 里用,明天在审批 Agent 里也能用。技能库越攒越厚,新项目启动就越快。我们内部现在有个原则:凡是抽象逻辑出现第二次,就封装成技能;出现第三次,就重构成公共技能库。这跟代码重构里“三次法则”是同一种思路。

2. 技术架构与技能注册机制

2.1 三种主流实现架构对比

技能库设计最核心的一个问题是:技能怎么组织、怎么被模型发现。我见过三类主流做法,各有优劣。

第一种是集中式注册表。所有技能启动时在一个全局 Registry 里完成注册,生成技能清单,请求模型前统一注入。优点:实现简单,技能可见性高,排查问题时直观。缺点:所有技能无论是否相关都会被注入,当技能数量很大时,token 消耗会剧增。

第二种是目录式自动发现。把每个技能写成一个独立文件,放在固定的 skills 目录下,运行时自动扫描、加载、注册。这种方案可扩展性很好,新增技能不用改主代码,适合团队并行开发。缺点:需要处理依赖加载顺序和命名冲突,调试起来要查文件系统。

第三种是能力域分组。按场景或者领域把技能分组,再通过路由策略只注入与当前对话相关的技能组。这种方案在技能数量达到几百个之后几乎是必选项,否则模型面对几百个候选技能,选择准确率会肉眼可见地下降。代价是你要额外设计和维护一套分组与路由逻辑。

架构选型本质上取决于技能规模。如果你只做五六个工具的 Demo,第一种最省事;如果你在做正式产品,直接从第二种起步,预留第三种的扩展位。我见过不少团队一上来就想做动态加载和语义路由,结果项目还没跑通,先被架构复杂度拖垮了。

2.2 技能描述与参数 Schema:决定成功率的关键

很多人在 function calling 上翻车,不是模型不够聪明,而是技能的描述和参数写得太烂。模型判断“该不该调用这个技能、参数怎么填”,完全依赖你提供的描述文本和字段定义。描述写得含糊,模型就会犹豫,犹豫就会编一个参数给你。

我先给一个反例:

{ "name": "send_message", "description": "发送消息", "parameters": { "type": "object", "properties": { "content": {"type": "string"} }, "required": ["content"] } }

这个描述等于没写。发送什么消息?发送到哪?通过什么渠道?模型不知道,只能猜。再来一个正例:

{ "name": "send_wecom_message", "description": "向指定的企业微信群机器人发送文本消息。适用于向某个工作群推送告警、通知、报告等场景。群机器人的 webhook 地址在技能配置中预先绑定。", "parameters": { "type": "object", "properties": { "content": { "type": "string", "description": "要发送的文本内容,最长 4000 字" }, "mentioned_list": { "type": "array", "items": {"type": "string"}, "description": "需要 @ 的成员手机号列表,可为空数组" } }, "required": ["content"] } }

一眼就看得出差距。正例里模型知道这个技能是做什么的、适用于什么场景、参数边界在哪。我把描述准则总结成一句话:假设使用者完全不了解你的业务,你要用一段话让他决定何时使用、何时不用。

参数 Schema 方面,我的建议是尽量精细化。能写 enum 就写 enum,能给 default 就给 default,能在描述里注明格式约束就注明。模型不是人,它不会主动追问,你给的信息越完整,它编错的概率越低。

3. 实操:手把手搭建一套 agent-skills 技能库

3.1 基础工程结构与技能注册器实现

我直接给出一套我在项目里实际用过的轻量实现。用 Python 写,核心依赖是 Pydantic 做参数校验。工程目录大概是这个形态:

agent_skills/ ├── core/ │ ├── registry.py # 技能注册器,全局唯一 │ ├── schema.py # 技能定义与参数模型 │ └── executor.py # 技能执行器,负责调用与结果包装 ├── skills/ │ ├── weather.py # 天气查询技能 │ ├── calculator.py # 计算器技能 │ └── git_tools.py # Git 操作技能 ├── agent/ │ └── runner.py # 接入 LLM API 的运行链路 └── main.py # 入口,启动加载

registry.py 的核心逻辑是维护一个名称到技能对象的映射,同时提供注册和获取两个接口。我选了最简单的注册表模式,因为起步期你最大的敌人是过度设计,而不是扩展性。

# core/registry.py from typing import Dict, Type from core.schema import BaseSkill class SkillRegistry: """技能注册表,保存所有已注册的技能定义。""" _skills: Dict[str, Type[BaseSkill]] = {} @classmethod def register(cls, skill_cls: Type[BaseSkill]) -> Type[BaseSkill]: """将技能类注册到全局注册表。""" if skill_cls.name in cls._skills: raise ValueError(f"技能名称冲突: {skill_cls.name}") cls._skills[skill_cls.name] = skill_cls return skill_cls @classmethod def get(cls, name: str) -> Type[BaseSkill]: if name not in cls._skills: raise KeyError(f"技能未注册: {name}") return cls._skills[name] @classmethod def all_skills(cls) -> list: """返回所有技能定义,用于注入到 LLM 上下文中。""" return [skill_cls.to_definition() for skill_cls in cls._skills.values()]

这里有一个容易踩的坑:技能名称冲突。团队并行开发时,两个人很可能都写了search技能,一个搜数据库,一个搜文件系统。所以在注册时一定要做重名校验,宁可启动时报错,也不要在运行时悄悄覆盖。

3.2 用装饰器技能定义与参数校验

按惯例,我定义一个抽象基类BaseSkill,每个技能只需要实现execute方法。参数校验放在基类里,通过 Pydantic 自动完成。

# core/schema.py from abc import ABC, abstractmethod from typing import Dict, Any from pydantic import BaseModel, Field, ValidationError class BaseSkill(ABC): name: str = "" description: str = "" parameters: Dict[str, Any] = {} @classmethod def to_definition(cls) -> dict: """将技能转换为 LLM 工具接口格式。""" return { "type": "function", "function": { "name": cls.name, "description": cls.description, "parameters": { "type": "object", "properties": cls.parameters, "required": cls.required_fields } } } @abstractmethod def execute(self, params: dict) -> str: """执行技能,返回结果字符串。 参数 params 是模型生成的 JSON 对象,执行前需要校验。 """ def run(self, params: dict) -> dict: """统一的执行入口,封装异常与校验。""" try: validated = self._validate(params) result = self.execute(validated) return {"success": True, "result": result} except ValidationError as e: return {"success": False, "error": f"参数校验失败: {e.errors()}"} except Exception as e: return {"success": False, "error": str(e)} def _validate(self, params: dict) -> dict: # 这里用 Pydantic 动态创建校验模型,略去具体实现 return params

然后你写技能的时候就很简单了。以天气查询为例:

# skills/weather.py from core.registry import SkillRegistry from core.schema import BaseSkill @SkillRegistry.register class WeatherSkill(BaseSkill): name = "get_weather" description = "获取指定城市当天和未来 3 天的天气预报。当用户询问天气、气温、降雨概率时使用。" parameters = { "city": { "type": "string", "description": "城市中文名,如:杭州、上海" }, "days": { "type": "integer", "description": "查询天数,1 表示今天,3 表示未来 3 天", "default": 1 } } required_fields = ["city"] def execute(self, params: dict) -> str: city = params["city"] days = params.get("days", 1) # 这里换成真实天气 API 调用 return f"{city}未来{days}天天气:晴转多云,气温 22-28℃"

装饰器注册的方式新手友好,能很直观地看到技能注册的过程。注意name、description、parameters是类属性,必须定义完整,缺一个后面生成工具列表时就会出问题。

3.3 接入模型调用链路与核心执行循环

现在到了关键环节:怎么让模型在对话中自动调用这些技能。核心执行循环通常是四步:

  1. 将注册表里的所有技能定义传给模型接口;
  2. 模型返回自然语言回复或工具调用请求;
  3. 如果有工具请求,执行对应技能,把结果附加到对话消息列表;
  4. 再次把完整的对话历史发给模型,直到模型不再请求工具。

这一轮我贴一段伪代码,能跑通主流程:

# agent/runner.py from core.registry import SkillRegistry def run_agent(user_input: str, messages: list, llm_func): messages.append({"role": "user", "content": user_input}) for _ in range(5): # 限制循环次数,防止模型无限调用工具 response = llm_func(messages, tools=SkillRegistry.all_skills()) if response.tool_calls: for tool_call in response.tool_calls: skill_name = tool_call.function.name args = json.loads(tool_call.function.arguments) skill_cls = SkillRegistry.get(skill_name) result = skill_cls().run(args) messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": json.dumps(result, ensure_ascii=False) }) else: return response.content return "达到最大工具调用轮次限制"

这里有两个经验值得说一说。

第一,循环次数一定要设上限。我见过模型在某个工具结果不符合预期时,反复调用同一个工具十几遍,最后把自己绕晕。设个 3 到 5 次的上限,配合结果中的错误信息,让模型有机会调整策略,但别让它无限重试。

第二,工具结果一定要是可以被模型理解的文本。不要直接返回一个 Python 对象或者一个裸的 JSON 堆栈。给模型的字符串越规整,它后续的总结能力就越好。我在执行器里会做一个统一处理:成功时返回结果摘要,失败时返回错误信息加简短排查提示。

def execute_with_context(skill, params): result = skill.run(params) if result["success"]: return f"[技能执行成功] {result['result']}" else: return f"[技能执行失败] {result['error']},请检查参数或联系管理员"

3.4 技能清单的动态裁剪与按需注入

如果技能库规模很小,每次都把全部技能注入到上下文里没什么问题。但一旦技能超过二三十个,模型的选择准确率会明显下滑,同时 token 消耗也在持续飙升。

我的做法是引入一个简单的路由层:把技能打上标签,根据用户当前对话的分类来裁剪候选集。比如有finance、it、hr三个标签,用户问的是“报销流程怎么走”,就只注入finance标签下的技能,其他的不注入。

路由层不需要做得很复杂。先用一个分类模型或者简单关键词规则判断意图域,然后从注册表里挑选对应技能注入。我实际测试过一个不错的指标:候选技能从 50 个降到 8 个之后,工具选择的准确率从 82% 提升到 96%,代价是 token 消耗减少了大半。

如果你不想引入额外的分类模型,也可以用另一种更轻的方案:把技能分成“通用技能”和“领域技能”。通用技能(如计算器、查日历)始终注入,领域技能按场景分组、按需激活。通用技能数量控制在十个以内,领域技能组之间再做一个互斥规则。这种方式实现成本低,效果也不错。考虑到很多团队的 Agent 一开始就奔着“什么都干”去的,反而应该从“分组限定”开始,再逐步开放规模,这是我踩坑之后反推出来的结论。

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

4.1 模型返回的参数永远对不上 Schema

最典型的报错是ValidationError,模型生成了{ "user": "张三" },但技能期望的是{ "name": "张三" }。排查思路是回看注入的 Schema 里字段名、类型、必填项与模型实际输出的差距。

我处理这类问题有一套固定步骤:

  1. 把模型实际返回的原始 arguments 抓出来,打印到日志里;
  2. 对照着你定义的 Schema 检查,是不是字段名跟描述文案不一致;
  3. 检查是不是required把太多字段设为必填。模型一旦判定期望字段缺失,就会编造一个值。能设默认值的就别设为必填;
  4. 看描述里是否给出了示例值。模型对示例值非常敏感,一个好例子能显著提升参数命中率。

我举一个真实案例。早期我的“创建工单”技能要求参数里有priority,描述写的是“优先级”,没有给枚举值。模型有时候传"high",有时候传"High",有时候传"紧急",导致下游解析失败。后来我把参数改成enum: ["低", "中", "高"],并在描述里注明“必须是三者之一”,这个问题再没出现过。

4.2 技能描述含糊导致模型该用不用

这个问题比参数错误更隐蔽。你有个数据库查询技能,描述写“执行 SQL 查询”,结果用户问“上个月订单总量”,模型死活不肯调用它,而是自己编了个数字。

原因是模型不知道这个技能能回答这类问题。描述里只写了“什么是这个技能”,没有写“什么场景下使用这个技能”。我后来会在每个技能描述里固定加一句“当用户询问 X 类型信息时,应使用此技能”,相当于给技能画了一个清晰的触发范围。

写描述还要避免过于宽泛。我有个同事写过一个技能描述叫“执行日常操作”,模型把所有操作都往它身上套,结果日常操作接口里又没有相应逻辑,整个 Agent 行为直接乱了。技能描述要表达的是“我是专才,不是通才”,把适用场景边界说得越窄,模型调用反而越准确。

4.3 多技能冲突与命名空间设计

当技能库逐渐变大,你会遇到名称冲突和职责重叠的问题。刚才提到的search就是典型的冲突重灾区。我建议做两件事:一是命名上加入领域前缀,比如db_query_order、file_search_report,而不是裸的search;二是在技能描述里明确写“本技能只负责 XX,不处理 YY 情况”,把边界画出来。

职责重叠更麻烦。你有“get_user_info”和“get_user_orders”,用户问“帮我查一下张三的账户情况”,模型可能两个都调,也可能一个都不调。我的建议是定义复合技能,把相关操作聚合到一个技能里,技能内部自己做分支。这样模型面对的技能粒度更粗,决策负担更小。技能也不是越小越好,合理的粒度取决于你希望模型做多少步推理来决定调用哪个工具。

4.4 上下文膨胀与执行超时

每轮工具调用都会把工具结果追加到消息列表里,多轮下来上下文很容易爆炸。我在实际运行中碰到过某次对话累计达到 30 万 token,调一次模型二十几秒,用户体验直接崩盘。

缓解手段有三个:

  1. 控制最大工具调用轮数,别让模型无限追问;
  2. 给工具结果做摘要。返回给模型的不是完整查询结果,而是“本次查询共返回 87 条记录,前 5 条为:……”;
  3. 定期对早期对话做压缩或者滑动窗口裁剪,只保留最近几轮关键消息。

这三个手段都不复杂,但收益非常明显。尤其是工具结果摘要,很多人会忽略。模型不需要看 80 条原始数据,它只需要基于总结继续往下推理就行。

4.5 常见问题速查表

现象常见原因解决建议
模型不调用技能描述不够明确,无法判断适用场景补充“当用户询问……时使用”句型,收窄技能职责
参数校验报错字段名、类型、枚举值不匹配参数描述加示例值,必填字段设默认值
多个技能同时触发技能职责重叠合并成复合技能,或加领域前缀区分
工具结果模型看不懂返回内容太复杂、无结构用固定格式摘要,成功/失败标记清晰
上下文爆炸、响应慢工具结果过大,历史消息过多裁剪结果、压缩早期消息、设置最大轮次
技能升级后模型表现下降行为变化导致模型推理路径改变技能版本号纳入日志,做 A/B 回归测试

排查这类问题,我的一大心得是:先看日志,升级到固定格式日志(时间戳、技能名、入参、出参、耗时、错误信息)后,以前靠猜的事故全都变成了可复现的定位。

5. 从技能库到技能编排:扩展思路与实测心得

5.1 技能编排:让多个技能协作完成复杂任务

单个技能解决的是“一件事”,但实际业务往往是“一串事”。比如“帮我把最新的销售周报整理一下发给 leader”,这个需求可能涉及读取报表、生成摘要、查找收件人、发送邮件四个环节。如果你把四个技能丢给模型让它自己想编排,模型很容易出错,因为它不知道这四个动作之间的依赖关系。

我在这方面的实践是引入工作流模板,把技能编排的路径预先定义好。工作流模板本质上是一个有向无环图,节点是技能,边是依赖关系。Agent 接收用户请求后,先匹配工作流模板,再按模板顺序调度技能。这样模型不需要想“下一步该干嘛”,只需要按流程执行。

这种方式带来的好处是稳定性大幅提升。自由编排的准确率可能只有 60%,配上固定工作流之后能到 90% 以上。缺点是灵活性下降了,处理不了模板之外的请求。我的策略是两层结构:先尝试匹配模板,模板匹配不上,再退化为模型自由选择技能。这种“先规矩、后自由”的路线在业务中表现比较稳定。

5.2 技能库维护的版本管理与灰度控制

技能代码本身会迭代,而模型对技能“行为变化”非常敏感。你刚把某个技能从同步调用改成异步调用,模型可能还是按旧逻辑等待结果,行为就乱了。这是技能库维护里最容易被忽视的风险。

我现在强制要求所有技能接口保持语义稳定,只允许内部实现变化,不允许对外输入输出格式变化。如果确有必要调整参数结构或返回格式,就在技能名称后面加版本号,比如send_message_v2,并且在下游工作流里做切换,而不是直接覆盖旧版。这样即使用户的旧会话还在跑,也不会因为技能行为突变导致失败。

日志层面我也加了一层技能级监控:每个技能执行的耗时、成功率、错误分布都单独看。哪段时间某个技能成功率掉下去了,马上能定位到代码变更或者提示词调整。这个监控在技能规模还小的时候看不出价值,等技能数量上到几十个,它就变成排查问题的主力工具。建议不要把监控想得太复杂,简单记录入参、出参、错误信息、耗时四项就够。

5.3 我在项目实战中积累的几条独家经验

写到这里,分享几个我在实战里反复印证过的体会。

技能描述是一个要反复打磨的文本工程。我见过很多人把时间花在算法选型和架构设计上,却不愿意花半小时打磨技能描述。实际上,在模型能力固定的前提下,描述质量决定了上限。同样的技能,描述差一个量级,调用准确率差二十个百分点都很正常。

另一个容易被忽视的点是,技能结果给模型的反馈信息要“结构化”。除了成功的结果内容,还要有足够的错误上下文。比如数据库技能执行失败时,返回的信息里应该包含“表不存在”还是“连接超时”,模型才能给出正确的补救方案。我见过模型因为不知道具体错误原因,在同一个错误上反复重试的情况,那就是给它的反馈太单薄了。

不要一上来就贪多求全。技能库是慢慢长起来的,不是一步到位的。先从三五个高频技能开始跑通链路,再逐步往库里加。很多人一开始就仿照 OpenAI 的官方示例塞了十几个技能进去,结果模型面对一长串候选,选择困难,行为飘忽不定。技能数量要跟模型的上下文窗口和推理能力匹配,这个平衡点只能用自己的数据测出来。我的经验是宁可让技能少而精,少让模型做无谓的“选哪个好”,多把每个技能的准确率打磨到极致。

最后想强调的是,无论你的 Agent 应用在哪个行业,agent-skills 这个思路的核心价值都一样:把不可控的模型推理变成可控的工具调用,把模型的“行为随机性”驯化在一个个明确的边界里。用户问一个模糊问题的时候,模型负责理解意图;模型确定要做什么的时候,技能负责把事做对。各司其职,Agent 才真正可靠。

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

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

立即咨询