尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

从Function Calling到技能包:构建可复用的智能体技能系统

从Function Calling到技能包:构建可复用的智能体技能系统 这几年做大模型应用我有一个特别深的感触真正难的不是把模型接进来而是让模型稳定地干杂活。你写一个 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_approvalTrue模型只管生成草稿发出前必须用户点头。这里给一个简化的注册器代码骨架大家可以直接参考# 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_factorylambda: { 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( namename, manifestmanifest, handlerfunc, timeouttimeout, require_approvalrequire_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(fskill {name} not available) if skill.require_approval: # 这里接入人工确认逻辑 raise PermissionError(fskill {name} requires user approval) start time.perf_counter() skill.metrics[call_count] 1 try: result await asyncio.wait_for( skill.handler(**params), timeoutskill.timeout ) return {status: ok, result: result} except asyncio.TimeoutError: skill.metrics[error_count] 1 raise RuntimeError(fskill {name} timeout after {skill.timeout}s) except Exception as e: skill.metrics[error_count] 1 raise RuntimeError(fskill {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(instanceparams, schemaschema) except ValidationError as e: raise ValueError( fskill {skill_name} got invalid params: {e.message} )除了类型和必填项我还会在 schema 里加enum、minimum、maximum、pattern这些约束。特别是枚举参数模型偶尔会造出不在列表里的值用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( namepage_fetch, manifestmanifests/page_fetch.yaml, timeout15.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(headersheaders, timeout10.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, flagsre.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( nameresearch_brief, manifestmanifests/research_brief.yaml, timeout30.0, ) async def research_brief(topic: str, items: int 3): search_results await web_search.search(queryf{topic} 最新进展, max_resultsitems) pages [] for item in search_results[items]: try: content await page_fetch.fetch_page(urlitem[url], max_chars3000) 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_pointsitems ) return {summary: summary, sources: [p[url] for p in pages]}这里我特意让research_brief不直接写抓取逻辑而是调用page_fetch技能目的就是复用底层的超时、监控、参数校验。组合优于继承这条原则放到技能系统里一样适用。底层技能尽量原子化业务技能负责编排调试的时候能很快定位问题到底出在哪一层。3.3 主循环里怎么把技能列表交给模型技能写好了还得把它们“安利”给模型。主循环的核心逻辑大概是从注册中心拿到可见技能列表把技能列表转成模型能理解的结构化描述插入 system prompt模型回复里如果带技能调用指令就解析出技能名和参数调用注册中心的invoke把结果作为新的一轮消息再交给模型循环直到模型给出最终回答。这里的第 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_asciiFalse) }) except Exception as e: messages.append({ role: tool, tool_call_id: call.id, content: json.dumps({error: str(e)}, ensure_asciiFalse) }) continue return response.content return Agent 已执行较多轮次仍未完成请简化需求或检查技能配置。注意第 4 步的错误处理技能抛异常不能直接让整个对话崩掉要把异常作为工具结果回传给模型让模型自行决定怎么应对。这是 agent 容错的关键习惯。3.4 权限与沙箱哪些技能需要“收着点”技能系统权限这块我用四个级别来管控完全公开比如天气查询、通用计算任何对话都可以调用。需用户确认发邮件、发布内容、删除数据、花钱操作模型最多先生成草稿真正执行前必须走确认。环境隔离所有涉及文件操作的技能统一运行在一个临时工作目录里技能无法访问目录之外的路径这个对本地文件读写类技能尤其重要。外部调用白名单技能内部请求的 URL只允许走配置过的域名白名单。我踩过一次坑某个抓网页技能不小心被诱导访问了内部服务地址从那以后所有外呼请求强制过白名单。权限的最核心原则是最小可用技能能拿到的最小范围的数据能访问的最少网络资源。不要图省事给所有技能配一个完整的内网权限或全目录读写权限。4. 常见问题与排查技巧实录4.1 模型就是不调用技能怎么排查这是频率最高的问题。通常我按这个顺序排查先看技能描述是否太长或太绕。有一次我把一个技能描述写了 600 多个字模型直接“看漏”了。后来精简到 150 字左右调用率明显上升。技能描述要像电梯演讲第一句就得说清楚做什么什么时候用。再看参数名是否符合直觉。模型对query、url、text这类常见参数名非常敏感对input_data、param_a这种泛化命名就反应迟钝。我试过把一个搜索技能的参数从q改成query调用准确率直接提升了 10 多个百分点。最后看触发条件是否写清了“不要用”的场景。模型不敢调技能很多是因为你不告诉它什么时候别用它怕用错了。描述里加上“不要用于 XX 场景”反而能让它在正确场景更大胆地调用。4.2 技能返回结果太大把上下文塞爆了怎么办大模型上下文窗口是有限的技能返回一个 8000 字的网页正文再转两轮基本就快到上限了。我的三层策略第一层源头截断。技能内部就限制返回长度比如抓网页默认只取前 2000 字而不是抓完再让模型处理。第二层结构化摘要。有些任务不需要完整正文只需要几个要点。技能内部先做一轮摘要只返回摘要结果。不要怕多花一次模型调用它省下的 token 往往更多。第三层工具结果压缩入库。如果确实需要保存大段内容就把完整内容写进本地缓存文件或对象存储返回给模型的只是一个引用 ID。后续模型如果需要细节再用“读取详情”技能去取。这种模式适合特别重的检索类任务。4.3 技能越来越多怎么避免互相打架技能多了之后模型可能会把 A 技能用在 B 技能的场景上。解决思路有两个方向。第一个是改描述把边界划清楚。比如“查天气”和“查空气质量”很容易混那就在描述里互相提醒查空气质量时不要调用天气技能它们虽然都是气象类但数据口径不同。第二个是技能分组/命名空间。我在注册中心里给技能加了前缀分组比如search.web、search.video、analyze.sentiment。模型在看到技能名时就能自动归类减少了歧义。命名空间的另一个好处是我在日志里能一眼看出是哪一类技能被高频调用。4.4 技能内部异常错误信息要“对模型友好”技能内部出异常是常态但怎么把异常信息返回给模型是门学问。直接返回 Python 堆栈模型虽然能读但容易带偏。我在技能内部统一做了一层异常翻译把错误转成对模型有指导意义的内容。比如“网页抓取失败”这个异常简单的信息是“timeout”模型可能下一轮也不知道该怎么办。好一点的信息是“目标网站响应超时可能是网站暂时不可达。建议换一个 URL或者提示用户稍后再试。”模型看到这种提示下一步行动就明确多了。问题症状常见原因处理办法模型频繁选择错误的技能技能描述边界不清或触发条件太模糊增加“不要使用”场景细化触发条件所有技能都正常但模型回答质量差技能返回的结果没有经过提炼增加摘要类后处理技能先压缩再拼接对话进行几轮后明显变慢技能调用链太长或单技能耗时过大检查每次调用的 metrics优化超时和重试策略技能调用成功率低上游 API 不稳定或参数校验过于严格增加自动重试幂等技能或放宽非关键参数约束新技能上线后老技能“失灵”技能描述太多模型注意力被稀释给技能增加路由助手技能或按对话场景动态裁剪可见列表4.5 动态裁剪技能列表降低模型选择负担最后分享一个很实用的技巧不要把几十个技能一次性全甩给模型。模型注意力有限技能列表越长选择准确率越低。我现在的做法是先给模型一个“路由技能列表”只有三五个一级分类比如“搜索工具”“数据处理工具”“文本生成工具”“系统操作工具”。模型先选一级分类再由分类路由到具体技能。实现上很简单就是两个层级的注册表外层是分类索引内层是具体技能。虽然多了一步调用但整体准确率提升不少。要是模型已经足够强、技能数量不大也可以跳过这层路由直接用一套扁平列表。这个取舍要看你的具体情况。写在最后我在这套技能系统上反复折腾了几个月最深的感受是给智能体做技能管理本质上是在给混乱建秩序。大模型本身是个充满不确定性的东西你没法保证它每次都能做出正确的工具选择但你可以通过把技能的说明书写得足够清楚、把注册和校验流程做得足够严密把不确定性压缩到可控的范围里。如果你正准备给自己的 agent 项目加技能系统我的建议是先别急着实现一堆高级功能从一个最小的技能注册 描述 校验闭环开始跑通一次调用再慢慢加动态启停和监控。等你的技能数量真正超过十个你会明显感受到这套体系带来的从容感——新功能只是往注册表里挂一个描述加一个函数而不是再改一遍主 prompt。这个转折点值得你亲自体验一次。
返回列表