从Function Calling到技能包:构建可复用的智能体技能系统
2026/9/23 6:33:49 网站建设 项目流程

这几年做大模型应用,我有一个特别深的感触:真正难的不是把模型接进来,而是让模型稳定地干杂活。你写一个 agent,要它查资料、算数据、调接口、整理报告,如果每个能力都临时写死在 prompt 里,一两个功能还行,到第五个、第八个的时候就彻底乱套了。这也是我最近一直在折腾一个叫agent-skills的项目的原因——它做的事情很简单,就是把智能体的各种能力,从“一段零散的提示词”升级成“一套可注册、可复用、可监控的技能包”。

这篇文章我就拿这个项目当例子,聊聊我踩过的坑、拆过的原理,以及一套能落地的技能系统到底该怎么搭。无论你是刚接触智能体开发,还是已经写过不少 function calling 的代码,这篇文章应该都能给你一些参考。

1. agent-skills 的核心设计思路:为什么“技能”比“工具”更好用

1.1 从能力碎片到技能包

早期做 agent,大家的习惯是把所有能力写成工具函数,然后一股脑塞给模型。比如你有查天气、发邮件、算汇率三个需求,就写三个函数,配上描述,模型自己选着调用。这套路刚开始没毛病,但功能一多问题就来了。

第一个问题是描述质量参差不齐。有的人写工具描述特别随意:“获取天气信息”,就完事了。模型根本分不清这个工具需要什么参数,什么情况下该用它,什么时候不该用它。结果就是模型一会儿乱传参数,一会儿明明该调工具却不调。

第二个问题是能力无法复用。做 A 项目时写的“网页正文提取”,到 B 项目还得再写一遍,复制粘贴改改 prompt,浪费时间不说,行为还不一致。

第三个问题更隐蔽:能力不可观测。模型到底调没调某个技能?调了几次?成功了没有?完全是一团黑盒。出了问题只能对着日志猜,非常痛苦。

agent-skills 想解决的,就是这三大痛点。它的思路很直接:把“能力”当作一类头等公民来管理,每个技能不仅有执行函数,还有完整的说明书、参数校验规则、执行记录和版本信息。它不是又一个工具调用封装,而是一套能力治理框架。

打个比方,普通 function calling 像是你把一堆工具扔进工具箱,模型自己翻。而技能系统像是给每个工具贴上标签、写清楚用途、画好适用范围,还配了一个管理员来登记谁借了、什么时候还的。后者看着重,但用起来才知道省心。

1.2 技能系统的三层抽象

我看了不少类似方案,也自己重构了两三版,最后留下的核心抽象就三层:

  • 技能描述层(Skill Manifest):这一段主要面向大模型,告诉它“这个技能叫什么、用来干什么、什么时候能用、参数长什么样、有没有什么注意事项”。对应到代码里,通常是一个 JSON 字典或 Pydantic 模型。
  • 技能执行层(Skill Executor):真正干活的函数。输入是经过校验的参数,输出是结构化的结果。这一层跟模型完全解耦,模型不关心你内部是怎么实现的,哪怕你技能里套了十层 API,它只管给参数拿结果。
  • 技能注册与调度层(Skill Registry & Dispatcher):管理人一堆技能的“台账”。负责技能注册、列表查询、参数校验、调用限流、日志埋点,以及决定“模型说想用 X 技能,当前环境允不允许”。

这三层各管一摊,边界清楚。你可以单独换掉执行逻辑,也可以单独优化描述文案,互不影响。后面我会详细讲每一层的落地细节。

1.3 和普通 function calling 的对比

我知道肯定有人问:这不就是 function calling 吗?搞这么多概念有必要吗?我用一个表格对比一下,大家心里就有数了:

维度常规 function calling技能包(agent-skills)
能力描述一句话描述,主要靠开发者临场发挥结构化清单,含用途、触发条件、参数 schema、示例、禁忌
参数校验多数靠模型自觉,错了就报错进入执行前先校验,类型、范围、必填项逐项检查
复用方式复制粘贴,或自己维护公共库统一注册中心,声明即用,天然支持热插拔
监控基本没有,出问题靠猜每次调用都有记录:耗时、成败、入参出参摘要
扩展成本每加一个功能都要改主 prompt往注册中心挂一个技能描述就行

坦白讲,如果项目里只有两三个工具调用,用 function calling 完全够了,没必要上技能系统。但一旦能力数量超过十个,或者你希望能力能在多个项目间漂移复用,技能系统的收益就会非常明显。我见过好几个团队,前期图省事全靠 function calling,到后期每个 agent 的 prompt 跟裹脚布一样,改一个功能半天不敢动,就是因为工具描述和主 prompt 完全耦合了。技能方案逼着你把“能力说明”当作独立配置来维护,这个约束其实是帮你省心的。

2. 关键环节拆解:技能描述、注册、校验到底怎么做

2.1 技能描述就是给模型看的说明书

我一直认为,技能描述是整套系统里最值得花时间抠的部分。同样一个函数,描述写得好不好,模型调用的准确率能差出几个档次。

一份合格的技能描述,我一般会包含五块内容:

  • 基础信息:技能名(必须唯一,建议全部小写加下划线)、简述、版本号。
  • 触发时机:明确说“什么情况下用”,最好带正例和反例。比如“当用户询问某地的当前天气时使用”,反例是“用户问气候趋势时不要使用”。
  • 参数说明:每个参数的用途、类型、取值范围、默认值,如果参数之间有依赖关系也写清楚。
  • 返回值说明:技能返回的是什么结构,方便模型理解后续怎么处理。
  • 注意事项与边界:比如“本技能仅支持国内城市”“接口会限流,同一城市请求间隔至少 1 秒”。

看起来有点啰嗦,但模型真的吃这一套。有一次我的技能描述里忘记写“仅支持人民币计价”,结果模型在遇到美元金额时也往上套,算出来的结果全错了。后来在描述里加了一句“币种为 CNY,外币请先转换为 CNY”,错误率直接归零。

实际落地时,这个描述最终是要序列化成模型能看到的文本。我习惯把描述转成一个紧凑的 JSON,再拼进 system prompt 里。伪代码大概是这样的:

{ "skill_name": "web_search", "description": "搜索网页并返回前N条结果的标题与摘要。当用户需要实时信息、最新资讯、或本地知识时使用。", "version": "1.2.0", "parameters": { "query": { "type": "string", "description": "搜索关键词,尽量使用简洁的核心词,避免长句。", "required": true }, "max_results": { "type": "integer", "description": "返回结果数量,默认5,最大10。", "default": 5 } }, "returns": { "type": "list", "items": { "title": "string", "url": "string", "snippet": "string" } }, "triggers": "当问题涉及最新消息、时效性内容、用户要求联网查询时。", "do_not_use_when": "用户问题可以通过内部知识库或常识回答时。" }

这个 JSON 我再包装一下,生成成模型友好的文本段:每个技能用“技能名 + 参数列表 + 触发条件 + 注意事项”的方式输出。实验下来,结构化描述比纯自然语言描述好使,因为模型能更快地在参数名和描述之间建立关联。

2.2 注册中心:技能的统一台账

注册中心我在技术选型上纠结过一阵,一开始就是 Python 里的一个大字典,后来慢慢加了动态加载、热度统计、权限标记,才变成一个小服务。核心设计不复杂,主要有四个要点。

第一,技能注册表要以“技能名”为唯一键。你可以用装饰器注册,也可以用 YAML 配置文件注册。我更推荐 YAML 配置驱动加装饰器实现二选一:配置驱动适合运维同事维护,装饰器适合开发者快速接入。现在项目里是两套并存的,线上用配置驱动,实验性技能用装饰器。

第二,要支持动态启停。线上出过一个问题:某个技能对应的上游 API 挂了,但是技能还留在注册表里,模型反复选它,反复报错,体验非常差。后来我加了一个“健康状态”字段,定时探测,不健康的技能自动从模型可见列表里摘掉,只保留注册信息供排查。这个改动对稳定性提升非常明显。

第三,技能执行要有超时和熔断。模型调用技能是同步等结果的,如果某个技能卡了 30 秒,整个对话就卡住了。我的做法是:每个技能声明自己的预期耗时和超时上限,执行器统一用 asyncio.wait_for 包一层。超时后不仅仅抛异常,还要把这次超时计入技能的错误率,连续出错超过阈值就自动摘除。

第四,权限控制要前置。不是所有技能都适合让模型无限制调用。我在注册表里给每个技能加了 two 个属性:visibility(模型能不能看到)和require_approval(某些高危操作调之前需要用户确认)。比如“发送邮件”这个技能,我一定设置 require_approval=True,模型只管生成草稿,发出前必须用户点头。

这里给一个简化的注册器代码骨架,大家可以直接参考:

# registry.py from dataclasses import dataclass, field from typing import Callable, Any, Dict, Optional import asyncio import time @dataclass class Skill: name: str manifest: dict handler: Callable[..., Any] timeout: float = 10.0 require_approval: bool = False enabled: bool = True health: bool = True metrics: Dict[str, float] = field(default_factory=lambda: { "call_count": 0, "error_count": 0, "total_time": 0.0 }) class SkillRegistry: def __init__(self): self._skills: Dict[str, Skill] = {} def register(self, name: str, manifest: dict, timeout: float = 10.0, require_approval: bool = False): def decorator(func): skill = Skill( name=name, manifest=manifest, handler=func, timeout=timeout, require_approval=require_approval ) self._skills[name] = skill return func return decorator def visible_skills(self) -> list[Skill]: """返回模型可见的技能列表:已启用 + 健康 """ return [ s for s in self._skills.values() if s.enabled and s.health ] def get(self, name: str) -> Skill: return self._skills.get(name) async def invoke(self, name: str, **params) -> dict: skill = self.get(name) if not skill or not skill.enabled or not skill.health: raise RuntimeError(f"skill {name} not available") if skill.require_approval: # 这里接入人工确认逻辑 raise PermissionError(f"skill {name} requires user approval") start = time.perf_counter() skill.metrics["call_count"] += 1 try: result = await asyncio.wait_for( skill.handler(**params), timeout=skill.timeout ) return {"status": "ok", "result": result} except asyncio.TimeoutError: skill.metrics["error_count"] += 1 raise RuntimeError(f"skill {name} timeout after {skill.timeout}s") except Exception as e: skill.metrics["error_count"] += 1 raise RuntimeError(f"skill {name} failed: {e}") finally: skill.metrics["total_time"] += time.perf_counter() - start

这里有个小细节:invoke是 async 的,我默认所有技能都是异步实现。如果你的技能是同步函数(比如普通的 requests 调用),也建议用asyncio.to_thread包一层,避免阻塞事件循环。在真实对话场景里,agent 可能会连续串行调用多个技能,同步阻塞会直接影响用户体验。

2.3 参数校验:别让模型的“灵机一动”炸掉你的代码

参数校验这件事,一开始我是不重视的,觉得模型也不至于传个 string 给 int 参数。直到有一次,模型把一个日期参数传成了“明天早上”,下游接口直接崩了。从那以后我把参数校验定为强制环节,invoke里第一步就是校验。

我推荐用 JSON Schema 做参数校验,因为它已经是事实标准,而且可以和模型的函数定义直接复用。实现可以手写,也可以用现成库。

# validate.py from jsonschema import validate, ValidationError def validate_params(skill_name: str, params: dict, schema: dict): """在调用技能前校验参数,失败时抛出带详细信息的异常""" try: validate(instance=params, schema=schema) except ValidationError as e: raise ValueError( f"skill {skill_name} got invalid params: {e.message}" )

除了类型和必填项,我还会在 schema 里加enumminimummaximumpattern这些约束。特别是枚举参数,模型偶尔会造出不在列表里的值,用enum约束后,至少不会把脏数据传进核心逻辑。

当然,校验也不是万能的。日期、金额这类参数光靠 schema 不够,我还会在技能内部做一层归一化处理。比如日期参数,技能内部统一转成YYYY-MM-DD格式,模型传“明天”这种自然语言就先做一次时间解析。校验是防守第一关,内部防御是第二关,两层都别省。

3. 实操:从零搭建一套可复用的 agent-skills 技能系统

3.1 最小骨架与项目结构

我在本地搭了一套可以跑通的最小系统,代码量不大,但足以展示 agent-skills 的完整链路。目录结构大概是这样的:

agent-skills-demo/ ├── main.py # 入口,启动对话循环 ├── registry.py # 注册中心,管理技能生命周期 ├── skills/ │ ├── __init__.py # 自动导入所有技能模块 │ ├── web_search.py # 搜索技能 │ ├── page_fetch.py # 网页正文提取技能 │ └── summarize.py # 文本摘要技能 ├── manifests/ │ ├── web_search.yaml # 各技能的描述配置 │ ├── page_fetch.yaml │ └── summarize.yaml └── requirements.txt

用 YAML 放描述,用 Python 放实现,这样分工的好处是:调 prompt 的人不用改代码,改代码的人不用反复动 prompt。团队协作时这个解耦特别重要。

一个技能模块的写法很简单,以page_fetch.py为例:

# skills/page_fetch.py import httpx from registry import SkillRegistry registry = SkillRegistry() # 实际应用里会用全局单例 @registry.register( name="page_fetch", manifest="manifests/page_fetch.yaml", timeout=15.0 ) async def fetch_page(url: str, max_chars: int = 8000): """获取网页正文并截断到max_chars字符""" headers = { "User-Agent": "Mozilla/5.0 (compatible; AgentSkillBot/1.0)" } async with httpx.AsyncClient(headers=headers, timeout=10.0) as client: resp = await client.get(url) resp.raise_for_status() # 真实项目里这里会用 readability 之类的库提取正文 # 这里简化处理,直接用 HTML 去标签 text = resp.text # 去掉 script/style 块 import re text = re.sub(r'<(script|style)[^>]*>.*?</\1>', '', text, flags=re.S) text = re.sub(r'<[^>]+>', ' ', text) text = re.sub(r'\s+', ' ', text).strip() return text[:max_chars]

这个小函数干了一个很典型的活:接收参数、抓网页、清理 HTML、返回截断后的正文。技能内部跟随便是这样的——它不关心模型,只关心输入输出。

3.2 一个真实技能:搜索 + 抓取 + 摘要的编排

单个技能好写,怎么把多个技能串起来才是核心。我的做法是再写一个“编排技能”,它是技能系统里的“高阶技能”,内部会复用其他技能。

比如“查一下最近的 AI 新闻,并总结三个要点”这个指令,单个技能解决不了,需要走一遍 搜索 → 抓取 → 生成摘要 的流程。在 agent-skills 的框架里,我实现了一个research_brief技能,它的执行逻辑就是调用其他技能:

# skills/research_brief.py from registry import SkillRegistry from skills import web_search, page_fetch, summarize @registry.register( name="research_brief", manifest="manifests/research_brief.yaml", timeout=30.0, ) async def research_brief(topic: str, items: int = 3): search_results = await web_search.search(query=f"{topic} 最新进展", max_results=items) pages = [] for item in search_results["items"]: try: content = await page_fetch.fetch_page(url=item["url"], max_chars=3000) pages.append({"url": item["url"], "title": item["title"], "content": content}) except Exception as e: # 单页失败不影响整体 pages.append({"url": item["url"], "title": item["title"], "content": f"抓取失败: {e}"}) summary = await summarize.summarize( text="\n\n".join([p["title"] + "\n" + p["content"] for p in pages]), max_points=items ) return {"summary": summary, "sources": [p["url"] for p in pages]}

这里我特意让research_brief不直接写抓取逻辑,而是调用page_fetch技能,目的就是复用底层的超时、监控、参数校验。组合优于继承,这条原则放到技能系统里一样适用。底层技能尽量原子化,业务技能负责编排,调试的时候能很快定位问题到底出在哪一层。

3.3 主循环里怎么把技能列表交给模型

技能写好了,还得把它们“安利”给模型。主循环的核心逻辑大概是:

  1. 从注册中心拿到可见技能列表;
  2. 把技能列表转成模型能理解的结构化描述,插入 system prompt;
  3. 模型回复里如果带技能调用指令,就解析出技能名和参数;
  4. 调用注册中心的invoke,把结果作为新的一轮消息再交给模型;
  5. 循环,直到模型给出最终回答。

这里的第 2 步有讲究。同一个技能列表,转成 JSON 传给 OpenAI 的tools参数是种用法,直接在 system prompt 里写成文本是另一种用法。前者的函数调用更稳定,后者更灵活,可以写更复杂的触发条件和约束。我目前的经验是:如果技能数量少、参数规整,用tools参数就好;如果技能描述里包含大量上下文约束、注意事项,写在 system prompt 里更不会丢信息。很多框架两种都支持,你可以按混用的方式调优。

一个简化的主循环长这样:

# main.py import json from registry import SkillRegistry async def run_agent(user_request: str, registry: SkillRegistry, llm): system_prompt = build_system_prompt(registry.visible_skills()) messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_request} ] for step in range(5): # 最多5轮工具调用,防止死循环 response = await llm.chat(messages) if response.tool_calls: for call in response.tool_calls: skill_name = call.function.name params = json.loads(call.function.arguments) try: result = await registry.invoke(skill_name, **params) messages.append({ "role": "tool", "tool_call_id": call.id, "content": json.dumps(result, ensure_ascii=False) }) except Exception as e: messages.append({ "role": "tool", "tool_call_id": call.id, "content": json.dumps({"error": str(e)}, ensure_ascii=False) }) continue return response.content return "Agent 已执行较多轮次仍未完成,请简化需求或检查技能配置。"

注意第 4 步的错误处理:技能抛异常不能直接让整个对话崩掉,要把异常作为工具结果回传给模型,让模型自行决定怎么应对。这是 agent 容错的关键习惯。

3.4 权限与沙箱:哪些技能需要“收着点”

技能系统权限这块,我用四个级别来管控:

  • 完全公开:比如天气查询、通用计算,任何对话都可以调用。
  • 需用户确认:发邮件、发布内容、删除数据、花钱操作,模型最多先生成草稿,真正执行前必须走确认。
  • 环境隔离:所有涉及文件操作的技能,统一运行在一个临时工作目录里,技能无法访问目录之外的路径,这个对本地文件读写类技能尤其重要。
  • 外部调用白名单:技能内部请求的 URL,只允许走配置过的域名白名单。我踩过一次坑,某个抓网页技能不小心被诱导访问了内部服务地址,从那以后所有外呼请求强制过白名单。

权限的最核心原则是最小可用:技能能拿到的最小范围的数据,能访问的最少网络资源。不要图省事给所有技能配一个完整的内网权限或全目录读写权限。

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

4.1 模型就是不调用技能,怎么排查?

这是频率最高的问题。通常我按这个顺序排查:

先看技能描述是否太长或太绕。有一次我把一个技能描述写了 600 多个字,模型直接“看漏”了。后来精简到 150 字左右,调用率明显上升。技能描述要像电梯演讲,第一句就得说清楚:做什么,什么时候用。

再看参数名是否符合直觉。模型对queryurltext这类常见参数名非常敏感,对input_dataparam_a这种泛化命名就反应迟钝。我试过把一个搜索技能的参数从q改成query,调用准确率直接提升了 10 多个百分点。

最后看触发条件是否写清了“不要用”的场景。模型不敢调技能,很多是因为你不告诉它什么时候别用,它怕用错了。描述里加上“不要用于 XX 场景”,反而能让它在正确场景更大胆地调用。

4.2 技能返回结果太大,把上下文塞爆了怎么办?

大模型上下文窗口是有限的,技能返回一个 8000 字的网页正文,再转两轮,基本就快到上限了。我的三层策略:

  • 第一层:源头截断。技能内部就限制返回长度,比如抓网页默认只取前 2000 字,而不是抓完再让模型处理。
  • 第二层:结构化摘要。有些任务不需要完整正文,只需要几个要点。技能内部先做一轮摘要,只返回摘要结果。不要怕多花一次模型调用,它省下的 token 往往更多。
  • 第三层:工具结果压缩入库。如果确实需要保存大段内容,就把完整内容写进本地缓存文件或对象存储,返回给模型的只是一个引用 ID。后续模型如果需要细节,再用“读取详情”技能去取。这种模式适合特别重的检索类任务。

4.3 技能越来越多,怎么避免互相打架?

技能多了之后,模型可能会把 A 技能用在 B 技能的场景上。解决思路有两个方向。

第一个是改描述,把边界划清楚。比如“查天气”和“查空气质量”很容易混,那就在描述里互相提醒:查空气质量时不要调用天气技能,它们虽然都是气象类,但数据口径不同。

第二个是技能分组/命名空间。我在注册中心里给技能加了前缀分组,比如search.websearch.videoanalyze.sentiment。模型在看到技能名时就能自动归类,减少了歧义。命名空间的另一个好处是,我在日志里能一眼看出是哪一类技能被高频调用。

4.4 技能内部异常:错误信息要“对模型友好”

技能内部出异常是常态,但怎么把异常信息返回给模型是门学问。直接返回 Python 堆栈,模型虽然能读,但容易带偏。我在技能内部统一做了一层异常翻译,把错误转成对模型有指导意义的内容。

比如“网页抓取失败”这个异常,简单的信息是“timeout”,模型可能下一轮也不知道该怎么办。好一点的信息是:“目标网站响应超时,可能是网站暂时不可达。建议换一个 URL,或者提示用户稍后再试。”模型看到这种提示,下一步行动就明确多了。

问题症状常见原因处理办法
模型频繁选择错误的技能技能描述边界不清,或触发条件太模糊增加“不要使用”场景,细化触发条件
所有技能都正常,但模型回答质量差技能返回的结果没有经过提炼增加摘要类后处理技能,先压缩再拼接
对话进行几轮后明显变慢技能调用链太长,或单技能耗时过大检查每次调用的 metrics,优化超时和重试策略
技能调用成功率低上游 API 不稳定,或参数校验过于严格增加自动重试(幂等技能),或放宽非关键参数约束
新技能上线后老技能“失灵”技能描述太多,模型注意力被稀释给技能增加路由助手技能,或按对话场景动态裁剪可见列表

4.5 动态裁剪技能列表,降低模型选择负担

最后分享一个很实用的技巧:不要把几十个技能一次性全甩给模型。模型注意力有限,技能列表越长,选择准确率越低。我现在的做法是:先给模型一个“路由技能列表”,只有三五个一级分类,比如“搜索工具”“数据处理工具”“文本生成工具”“系统操作工具”。模型先选一级分类,再由分类路由到具体技能。

实现上很简单,就是两个层级的注册表,外层是分类索引,内层是具体技能。虽然多了一步调用,但整体准确率提升不少。要是模型已经足够强、技能数量不大,也可以跳过这层路由,直接用一套扁平列表。这个取舍要看你的具体情况。

写在最后

我在这套技能系统上反复折腾了几个月,最深的感受是:给智能体做技能管理,本质上是在给混乱建秩序。大模型本身是个充满不确定性的东西,你没法保证它每次都能做出正确的工具选择,但你可以通过把技能的说明书写得足够清楚、把注册和校验流程做得足够严密,把不确定性压缩到可控的范围里。

如果你正准备给自己的 agent 项目加技能系统,我的建议是先别急着实现一堆高级功能,从一个最小的技能注册 + 描述 + 校验闭环开始,跑通一次调用,再慢慢加动态启停和监控。等你的技能数量真正超过十个,你会明显感受到这套体系带来的从容感——新功能只是往注册表里挂一个描述加一个函数,而不是再改一遍主 prompt。这个转折点,值得你亲自体验一次。

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

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

立即咨询