Agent技能体系设计实战:从function calling到agent-skills的完整落地指南

发布时间:2026/10/8 4:59:24
Agent技能体系设计实战:从function calling到agent-skills的完整落地指南
我前阵子把一个内部项目从零重构了一遍核心就是把原来散落各处的 LLM 工具调用逻辑统一收敛成了一套叫 agent-skills 的技能体系。最初只是为了让多智能体协作时不再乱成一锅粥后来发现这个东西的价值远不止于此——它本质上是给模型装上了一层可复用、可组合、可调度的肌肉记忆。这篇就把我的完整设计思路、落地步骤、以及踩过的坑一次性写清楚给正在做 Agent 应用、或者准备从裸 prompt 转向结构化技能库的同学一个可以直接照搬的参考。做 Agent 相关开发的朋友应该都有同感第一阶段是模型想干嘛就干嘛第二阶段是我们想干嘛就教模型干嘛第三阶段才轮到模型知道它能干嘛、并且自己选择干嘛。agent-skills 要解决的就是第三阶段的问题——它不是一个单一函数而是一套完整的能力管理方案包括技能的定义、注册、描述、参数校验、执行反馈、冲突消解和调度策略。整套体系落地之后我的最大感受是Agent 的不可控性显著下降任务完成率明显上升而且新技能的接入周期从改 prompt 测半天压缩到了写一个类、跑一轮测试、上线。我把它拆成五个部分来讲先聊清楚设计思路再讲技能的核心构成然后是完整实操接着是问题排查最后分享点个人体会。1. agent-skills 到底是什么核心思路与设计拆解1.1 从 function calling 到 agent skills一次抽象层的跃迁很多人会把 agent-skills 和 function calling 混为一谈。确实底层都是把模型能力暴露成可调用接口但在抽象层级上完全是两码事。Function calling 关注的是某个工具怎么被调用一个函数一个函数的彼此之间没有协作关系agent-skills 关注的是某个目标怎么被达成一个技能内部可能串联了多个函数、多个外部 API、多轮状态管理。打个比方function calling 是给了机器人一只手每个手指是独立控制的agent-skills 是给了机器人一套抓取的动作集它可以根据目标自主决定用几个手指、怎么用力、抓不住的时候换什么姿势。这个抽象层的跃迁直接决定了 Agent 的可扩展性——当你需要新增一个总结网页并发送摘要到邮箱的能力时前者要拆成网页抓取和邮件发送两个函数、然后在 prompt 里拼逻辑后者直接写一个 text__digest_and_send 技能就完事了。实际在我这个项目里最直观的变化是系统提示词system prompt变短了。以前要列十几条工具规则现在只用告诉模型你有一组技能按需调用然后动态地把技能目录注入进去。整体上下文长度砍掉了差不多 40%模型在关键任务上的第一跳准确率反而更高了。1.2 为什么技能不是工具结构化设计的三层动机我坚持用技能这个概念的动机有三层。第一层是复用性——同一个技能可以在不同智能体之间共享比如PDF 内容提取这个技能既可以被财务 Agent 用也可以被法务 Agent 用写一次就够了。第二层是可组合性——技能可以嵌套高层技能调用底层技能类比编程里的函数嵌套这让复杂任务被拆解成一个个可单独测试的原子动作。第三层是可治理性——每个技能是独立单元可以单独上线、回滚、限流、做灰度这在多 Agent 协作的系统里至关重要。这三层动机落到架构上就要求技能模块必须满足三个条件首先是单一职责一个技能只完成一件完整的事其次是自描述技能必须携带面向模型的完整说明让模型理解这个技能擅长什么、什么时候调用它最后是可验证技能必须有输入参数校验和执行结果校验不能让模型给啥就跑啥。多说一句这三个条件里最容易忽略的是自描述。我见过不少团队把技能写得很优雅但描述语写得稀烂模型根本不理解什么时候该用结果技能库等于摆设。这个坑后面专门讲。2. 技能体系的四大支柱描述、参数、验证、记忆2.1 描述即入口写给模型看的说明书技能描述description是最容易被低估的部分。它直接决定了模型会不会在正确的时机调用正确的技能。我在实践中总结出一个黄金公式技能描述 触发条件 能力边界 典型场景 忌讳事项。举个例子同样是翻译技能普通写法是将文本翻译成英文合格的 agent-skills 写法是触发条件用户请求涉及中译英、或原文为中文且目标语言为英文时调用能力边界仅处理文本翻译不支持语音、图片中的文字翻译典型场景翻译句式可以有但职责内不允许对译文风格进行主观改写忌讳事项当用户明确要求保持原文格式时不得调整排版结构。这样写的直接效果是模型在任务模糊的情况下也能做出相对准确的工具选择。我实测过把描述从一句话扩展到上面这种结构化描述之后误调用率从 22% 降到了 6% 左右效果立竿见影。但注意描述也不能写得太长我建议控制在 150 个汉字以内超出这个长度模型会产生新的困惑——信息过载同样会淹没关键信号。2.2 参数即协议JSON Schema 不是摆设参数定义是整个技能系统里最细但最关键的地方。模型生成参数本质上是一个从自然语言到结构化数据的映射过程你必须给它足够明确的约束它才能稳定输出。我的做法是给每个技能配一份完整的 JSON Schema包含每个字段的类型、是否必填、格式要求、枚举值范围、以及字段之间的依赖关系。这里有个容易出错的技术点不要相信模型能自动理解参数格式。比如日期字段模型可能输出June 3rd、2024-06-03、今天三种形式。如果你不在 schema 里明确 format后面做类型转换的时候就等着被坑吧。我有个技能叫会议纪要归档入参里的日期格式一开始没定死结果测试时一天之内生成了四种日期格式解析逻辑写了三版才算稳下来。另一个经验是参数数量控制在 5 个以内。模型在工具选择时的注意力资源有限参数越多出错率越高。如果你的技能确实需要超过 5 个入参我建议拆成子技能或者把部分参数挪到技能内部去自动获取只在对外接口上暴露最关键的信息。记住接口越窄越不容易出错。2.3 验证即安全技能自治的核心技能在被模型调用之后不能直接信任返回值每个技能内部至少要做三层验证才能对外输出第一层是参数验证——在执行之前校验入参是否满足 schema 约束不满足就返回标准化错误码而不是继续往下跑。第二层是中间态验证——如果你的技能涉及多步骤操作比如先搜索再总结每一步都要检查结果是否为空、格式是否异常。第三层是语义验证——这是最容易忽视的模型生成的技能输出如果是一个总结或评价你需要做个简单的一致性检查比如长度是否过短、是否出现大量原文复制等异常迹象。这套验证体系在我项目里直接减少了一个最常见的生产事故模型调用了数据分析技能技能内部因为某个 API 挂了返回了花括号都不闭合的 JSONAgent 拿到之后接着往下编造了一段数据结论。有了语义验证之后这类问题会在源头被拦截然后触发重试或降级策略而不是污染后续对话。2.4 记忆即演进技能怎么越用越聪明技能体系要想真正活起来不能做完就扔要有记忆和演进机制。我在设计里加入了两个反馈回路一个是调用日志分析——记录每次技能调用的入参、出参、耗时、成功与否定期聚合分析发现那些总是失败的技能要么修参数约束要么优化描述语另一个是阈值自适应——有些技能在特定条件下频繁失败比如网络请求类技能在 5 秒超时内总是失败系统会自动调高超时上限而不是每次都撞墙。这个设计给我带来的最大改变是技能库不再是一个静态的代码目录而是一个可以观测、可以迭代的动态系统。每跑一两个月我就把调用日志导出来看一眼哪个技能被冷落了、哪个技能老出错一目了然。淘汰掉冷门技能、优化高频技能整个系统的质量是在持续上涨的。3. 从零搭建一个 agent-skills 项目完整实操3.1 环境准备与目录结构先搭好技能注册机制在动手写代码之前我强烈建议先定好目录结构和注册机制。我自己的项目是 Python 写的用的是 OpenAI 兼容的接口层但整套思路换到任何语言、任何模型平台上都能成立。最基本的目录结构长这样agent-skills-project/ ├── skills/ │ ├── __init__.py │ ├── registry.py │ ├── base.py │ ├── web_read.py │ ├── notes_create.py │ └── data_summary.py ├── core/ │ ├── schemas.py │ ├── validator.py │ └── memory.py ├── scheduler/ │ └── dispatcher.py ├── prompts/ │ └── system.md ├── tests/ │ ├── test_web_read.py │ └── test_dispatch.py └── main.py建议先别急着写业务代码把registry.py和base.py定好后续所有技能都走同一套注册和加载流程这样后期加技能就是加文件、写类、跑测试的事不用动核心代码。先说base.py所有技能都继承的这个基类它定好了统一的接口契约每个技能必须有name技能名、description描述、parameters参数 schema、execute()执行方法和validate()校验方法。用代码表示就是from abc import ABC, abstractmethod from typing import Any, Dict, Optional class BaseSkill(ABC): name: str description: str property abstractmethod def parameters(self) - Dict[str, Any]: 返回 JSON Schema 格式的参数定义 pass abstractmethod def validate(self, **kwargs) - None: 执行前参数校验不合法直接抛异常 pass abstractmethod def execute(self, **kwargs) - Dict[str, Any]: 执行技能逻辑返回结构化结果 pass这个基类的意义在于它强制了每个技能的形状是一致的。这就像插座标准统一了之后什么电器都能往里插。如果没有这个约束每个技能各自为政后期维护起来就是一场灾难。3.2 注册中心与动态加载让技能目录自动构建有了基类下一步是注册中心。注册中心的核心职责有两个一是收集所有技能二是生成供模型使用的技能目录skill manifest。技能目录的格式要尽量贴近模型的 function calling 格式方便直接注入到 API 请求里。我的registry.py实现逻辑大概是这样的import inspect import pkgutil from typing import Dict, List, Type from skills.base import BaseSkill REGISTRY: Dict[str, Type[BaseSkill]] {} def register_skill(cls: Type[BaseSkill]) - Type[BaseSkill]: 类装饰器自动把技能类注册进全局字典 REGISTRY[cls.name] cls return cls def load_skills_from_package(package_name: str skills) - None: 扫描包内所有模块触发类装饰器进行注册 package __import__(package_name, fromlist[]) for module_info in pkgutil.iter_modules(package.__path__): __import__(f{package_name}.{module_info.name}, fromlist[]) def build_skill_manifest() - List[Dict[str, Any]]: 生成符合 function calling 格式的技能清单 manifest [] for name, skill_cls in REGISTRY.items(): manifest.append({ type: function, function: { name: name, description: skill_cls.description, parameters: skill_cls.parameters, } }) return manifest这套机制的好处是每新增一个技能文件只要在文件里用register_skill装饰器标记一下类load_skills_from_package扫描时就会自动把它登记进来。你的 system prompt 和 API 请求里的工具列表就自动更新了不用手工维护两份配置。我第一次跑通这个逻辑时确实有种终于上道了的感觉。3.3 核心执行流程一个完整的技能从调用到返回光有注册不行还得有一个标准化的执行链路。我实现了一个dispatcher.py它负责把模型选中的技能名和参数分发到对应的技能实例上。执行链路分为五步第一步参数解析接收模型的 tool call 请求把参数 JSON 解析成字典。第二步技能查找根据技能名到 REGISTRY 里找到对应的类实例化。第三步参数校验调用技能实例的 validate 方法逐个字段检查合法性。第四步技能执行调用 execute并捕获异常。第五步结果标准化把返回值统一包成status、data、error三个字段的结构方便模型后续读取。核心调度代码大概是这样的from skills.registry import REGISTRY from core.validator import validate_against_schema def dispatch_skill(skill_name: str, arguments: dict) - dict: skill_cls REGISTRY.get(skill_name) if skill_cls is None: return {status: error, error: fskill_not_found: {skill_name}} skill skill_cls() schema skill.parameters # 1. 参数校验不符合 schema 就直接拒绝不要进 execute try: cleaned_args validate_against_schema(arguments, schema) skill.validate(**cleaned_args) except Exception as e: return {status: error, error: fvalidation_failed: {str(e)}} # 2. 执行技能任何异常都转为标准错误结构 try: result skill.execute(**cleaned_args) return {status: ok, data: result, error: None} except Exception as e: return {status: error, error: fexecution_failed: {str(e)}}你可能注意到了这里对参数校验做了两道先是通用 schema 校验validate_against_schema再是技能自定义校验skill.validate。前者管格式后者管语义。比如网页阅读技能schema 校验确保 url 字段是字符串格式自定义校验则检查这个 URL 是否在允许的域名白名单里。双层校验的设计初衷就是把通用约束和个性约束分开避免把业务逻辑堆到基类里。3.4 完整技能示例把网页阅读摘要器掰开揉碎接下来写一个具体的技能示例让大家完全理解一个业务技能长什么样。以网页阅读摘要器为例它的功能是接收一个 URL抓取网页正文生成摘要返回结构化结果。import re from urllib.parse import urlparse import requests from bs4 import BeautifulSoup from skills.registry import register_skill from skills.base import BaseSkill register_skill class WebReadSkill(BaseSkill): name web_read_summarize description ( 当用户请求总结/提取某个网页的正文内容时调用。 仅限于处理可公开访问的 URL不支持登录后的页面。 典型场景用户贴出链接要求总结要点 忌讳不需要解释网页排版、不需要生成原网页全文。 ) property def parameters(self): return { type: object, properties: { url: { type: string, description: 目标网页的完整链接必须以 http:// 或 https:// 开头 }, max_length: { type: integer, description: 摘要最大长度字默认 200范围 50-1000, minimum: 50, maximum: 1000, default: 200 } }, required: [url], additionalProperties: False } def validate(self, **kwargs) - None: url kwargs.get(url, ) parsed urlparse(url) if parsed.scheme not in (http, https): raise ValueError(url 必须以 http:// 或 https:// 开头) # 这里是自定义校验拒绝明显不对的域名 if parsed.netloc in (localhost, 127.0.0.1): raise ValueError(不允许访问本地地址) def execute(self, **kwargs) - dict: url kwargs[url] max_length kwargs.get(max_length, 200) headers {User-Agent: Mozilla/5.0 (compatible; AgentSkills/1.0)} resp requests.get(url, headersheaders, timeout10) resp.raise_for_status() soup BeautifulSoup(resp.text, html.parser) for tag in soup([script, style, nav, footer]): tag.decompose() text re.sub(r\s, , soup.get_text()).strip() # 这里的截断逻辑在实际场景中可以换成大模型摘要 summary text[:max_length] (... if len(text) max_length else ) return {url: url, summary: summary, content_length: len(text)}这里面有几个细节值得专门说一下。第一additionalProperties: False这个字段特别重要。它告诉模型参数只能是我定义的这些可以有效抑制模型自作主张地把一些乱七八糟的字段塞进来。第二description 里的仅限于处理可公开访问的 URL是给模型划清能力边界避免它拿这个技能去访问什么内网系统。第三execute 里用raise_for_status()检查 HTTP 状态码这属于中间态验证的范畴——网络请求非常容易出错你必须在请求完后立刻确认结果而不是盲目继续处理。3.5 构建技能清单与注入接口让模型真正知道自己有什么能力技能定义好之后最后一步就是把它们注入到模型请求里。以 OpenAI 兼容接口为例API 请求里的tools参数直接填build_skill_manifest()的返回值即可。核心请求代码大致是这个样子from skills.registry import load_skills_from_package, build_skill_manifest # 程序启动时执行一次 load_skills_from_package(skills) # 构造请求时把技能清单注入 tools 字段 tools build_skill_manifest() response client.chat.completions.create( modelgpt-4o, messages[ {role: system, content: 你是一个任务规划助手。根据用户需求优先使用技能完成可执行的部分。若技能不足说明缺少哪个能力。}, {role: user, content: 帮我总结一下 https://example.com 的内容} ], toolstools, tool_choiceauto, )注意 system prompt 里那句若技能不足说明缺少哪个能力——这句话是设计过的。它给了模型一个明确的降级路径当技能库覆盖不了用户需求时模型应该如实反馈而不是硬编一个结果出来。这样既能保护技能调用的纯度又能给后续扩展技能库提供一手需求线索。3.6 调度策略进阶让 Agent 学会挑活干当技能数量超过 10 个之后调度策略会成为新的瓶颈。模型在所有技能描述里做精确选择的难度会上升误调率会重新抬头。我在实践中尝试过几种优化方案效果最好的是分层技能按需注入。具体做法是把技能分成通用技能和专用技能两类。通用技能常驻 tools 列表专用技能先不进列表而是通过一个能力索引就是一行文字描述列出所有专用技能的名称和一句话简介塞进 system prompt。当模型在对话中发现任务匹配某个专用技能时它会先用一个专门的load_skill工具把这个技能的完整描述加载进来然后再真正调用它。这个思路的本质是延迟加载——模型先从索引里找到候选技能再按需加载详细 schema。我把这套机制跑通之后60 个技能共存时的误调率基本回到了 5% 左右而且每轮请求的 token 消耗也降了将近一半。如果你的技能库规模很大强烈推荐这个方案。4. 实测中踩过的坑问题排查与避坑实录4.1 技能描述陷阱太短不调、太长幻觉这是我踩过最深的一个坑。技能描述写得太短模型经常在应该调用的时候不调用写得太长模型又会望文生义把技能的能力脑补到它不该管的范围。我早期有个信息查询技能描述写的是查询各类信息。结果模型在用户问天气的时候调它问翻译的时候也调它乱成一锅粥。后来我把这个技能拆成了三个技能分别描述为适合查询实时天气/适合翻译文本包括中英互译/适合查询百科类事实信息如人名、地名、历史事件模型的调用准确率立刻上来了。反过来描述太长的副作用是模型会生成根本不存在的参数来配合它。有一次我的报销单审核技能描述里写了一堆输出格式说明模型调用时竟然自己发明了一个format参数传进来而我根本没在 schema 里定义这个字段。后来强制给所有技能加了additionalProperties: False这类幻觉参数才被彻底挡在门外。4.2 参数校验失控LLM 给的 JSON 不能直接信这个坑几乎每个做 Agent 的人都会遇到模型返回的工具参数 JSON 偶尔会是非法 JSON——多一个逗号、少一个引号、花括号不闭合什么怪样子都有。如果你直接把字符串丢给json.loads()分分钟给你抛一个大红异常。我的解决办法是在 dispatcher 入口加一个宽容解析层先尝试标准解析解析失败就用一个修正器处理常见错误。比如用正则把末尾多余的逗号去掉把单引号替换成双引号再重新解析。实在修不了的直接返回参数解析错误让模型修正后重新调用而不是让整个链路崩掉。代码大概是这样的import json import re def safe_parse_arguments(raw_args: str) - dict: try: return json.loads(raw_args) except json.JSONDecodeError: # 常见修正去掉末尾逗号、替换单引号、补全花括号 fixed raw_args.strip().rstrip(,) if not fixed.endswith(}): fixed } try: return json.loads(fixed.replace(, )) except json.JSONDecodeError: raise ValueError(arguments 无法解析为合法 JSON)实测下来这套宽容解析能把参数解析成功率从 91% 拉到 98%。剩下那 2% 的错误就该让它明明白白地失败而不是悄悄地被吞掉。4.3 上下文污染技能返回不该带情绪技能返回值会被注入到后续的对话上下文里这意味着技能返回什么直接影响模型接下来怎么思考。我最常犯的错误是技能返回值里掺杂了太多非结构化文本——比如把一个网页的整个正文都塞回来或者把日志信息也一起带出来。结果是模型在处理这些脏数据时浪费了大量上下文预算甚至在后续回复里引用这些杂音。优化方向很清晰返回值只保留任务需要的结构化字段其余一概丢弃。再说得直白一点技能返回的每一行数据都应该能被模型直接用于回答用户问题没有用的信息多一点都是负担。我给自己定的规矩是单个技能返回的 JSON 必须小于 1KB超了就做压缩或截断。4.4 调度混乱命名空间与冲突消解当我技能数量到 30 多个的时候新的问题出现了——两个技能在名字或功能上产生了重叠。比如既有weekly_report_create又有report_summary_create模型面对这两个描述相近的技能时频频选错。这时候靠改描述已经不太好使了我改成了更彻底的两个措施第一个是命名空间化。所有技能名统一采用领域__动作__对象的格式比如web__fetch__content、data__analyze__trend、note__create__memo。这样不仅让技能名自带语义信息还方便做技能名模糊匹配时的权重排序。第二个是重叠意图消解。我会定期跑一次技能描述聚类把语义相似度超过 90% 的技能挑出来人工仲裁要么合并、要么在描述里明确划清边界。这一套做完之后调度混乱的问题基本绝迹。前端的路由准确率从 74% 提升到了 91%维护者看到技能列表时也不再头疼——只看名字就知道这个技能是干嘛的。4.5 高频问题速查表现象可能原因解决方案模型该调技能时不调技能描述太短模型识别不了触发条件按触发条件能力边界典型场景忌讳事项补全描述模型调技能时传了多余参数schema 缺少 additionalProperties: False所有参数定义加additionalProperties: False技能执行成功但结果用不上返回字段含大量非结构化文本强制输出精简的结构化 JSON字段压缩到 1KB 以内多个技能功能重叠导致误调描述相似、边界不清晰聚类分析拆分或合并技能统一命名空间参数解析报错导致链路中断模型返回非法 JSON入口加宽容解析层修正末尾逗号、单引号等常见问题技能偶发超时导致任务失败外部 API 不稳定技能内部加重试与降级策略超时阈值做成可配置参数5. 写在最后的一些私货整套 agent-skills 体系从设计到落地我大概花了两周时间但真正让系统变得好用是在上线后持续打磨了一个月。我的体会是技能系统的难点从来不在写代码而在对模型-技能-用户三者边界的理解。做任何技能之前多想一步——模型在什么情境下会需要它它能给模型提供什么在系统提示词里拿不到的信息它的返回值是否会让模型的下游判断变得更清晰。想清楚这三件事写出来的技能质量完全不在一个档次。最后分享一个实操技巧你可以给技能库配一个体检日报。每天凌晨跑一遍全量技能自检每个技能用一个内置的测试用例请求一遍检查执行耗时、错误率、参数字段匹配度。这个日报我坚持看了两个月技能库的质量曲线是肉眼可见地往上走的。很多潜藏的问题在用户发现之前就已经被体检报告暴露出来了。这个习惯强烈建议你也建立起来。