☰
Agent技能库设计:从Function Call到稳定编排的实战指南
2026/10/7 4:29:57 网站建设 项目流程

做Agent开发这段时间,我手里最值钱的东西不是某个模型,而是一套沉淀下来的技能库——agent-skills。它不是简单几个函数拼出来的工具集,而是给智能体设计的一套可复用、可编排、可观测的能力层。要解决什么问题?很简单:当你的Agent需要完成文件检索、数据清洗、报告生成、日程梳理这一大堆跨域任务时,怎么让模型每次都稳定地、按预期地调用正确能力,而不是在Prompt里堆一堆工具说明、靠运气等模型发挥。

如果你正在被Function Call不稳定、Prompt越写越长、同一个能力在不同项目里反复复制粘贴这些问题折磨,那这篇文章应该能帮上大忙。我会把agent-skills从设计思路到落地实现整个拆开来讲,包括技能如何定义、参数Schema怎么设计、技能之间怎么编排、踩过哪些坑,以及一套可以直接照搬的技能库结构。

1. 技能库的定位与整体设计思路

1.1 为什么需要一套技能库,而不是把所有逻辑塞进Prompt

很多团队初期做Agent,习惯把所有能力说明写进System Prompt,让模型自由发挥。结果Prompt从几百字膨胀到几千字,模型开始“选择性失明”。不是模型不够聪明,而是人脑在同时读几十个场景说明时都会丢信息,何况是受上下文窗口限制的模型。

技能库把这类需求彻底结构化。每一个技能负责一个清晰边界的能力,技能的名称、描述、参数、使用条件、输出格式全部显式声明,模型只需要按需要去“挑选”技能,而不是从一大段自然语言里推断该干什么。这相当于你给Agent一本目录,而不是一仓库混在一起的书。

举个例子,我早期做一个日程管理Agent,把“解析时间”“查询日历”“创建提醒”“冲突提醒”混在一个大Prompt里,结果模型经常把时间格式解析错,甚至在自己不确定时瞎编一个日程。后来把每个能力拆成独立技能,注册进技能库,模型通过描述选择技能,参数由Schema约束,准确率一下从七成拉到九成以上。

关键是,技能的边界设计和调用决策分离。技能库负责“有什么能力”,模型负责“用哪个能力”,两者通过结构化的元信息对接。这比在Prompt里堆描述清爽太多,模型不用做大量无关判断,只是查目录、选技能、填参数。

1.2 技能设计的三个核心原则

在沉淀agent-skills的过程中,我慢慢总结出三个必须遵守的硬性原则。

第一,单一职责。一个技能只负责一件事。别做“全能技能”,不要想着一个技能同时处理数据清洗和图表绘制。技能越小,描述越精准,模型选对的概率越高。我把一个脚本拆成三个技能——数据读取、数据清洗、可视化输出,分开后准确率和可维护性都好了。

第二,显式输入输出。技能的输入参数、输出格式必须用Schema定义死。模型调用技能,本质上就是“填参数、拿结果”,如果参数边界模糊,模型就会发挥想象。比如一个“检索文件”技能,必须声明路径、递归深度、过滤类型这些参数,输出必须是结构化列表,而不是自然语言。

第三,可观测的错误处理。技能内部必须考虑失败场景,返回的错误信息要结构化。很多初版技能只考虑“正常路径”,模型一传错参数就报一个裸异常,整个Agent就挂在那里。以后端接口的思路设计技能——成功返回数据,失败返回错误码和原因,让上层有降级策略。

1.3 技能库的目录结构与命名规范

技能库本质上是一个有结构的工程目录,不是零散的py文件。我维护的技能库大概长这样:

skills/ registry.json file_ops/ __init__.py skill.yaml search_files.py read_file.py data_ops/ __init__.py skill.yaml csv_clean.py dedup.py schedule_ops/ __init__.py skill.yaml parse_datetime.py check_calendar.py

每个技能包里有三个关键文件:

  • skill.yaml:技能元信息,包括名称、描述、参数Schema、输出规范
  • __init__.py:技能注册入口,告诉框架这个技能如何被加载
  • 具体的技能实现文件

命名规范上,技能名统一用动词_对象结构,比如search_files、parse_datetime,这样模型在语义匹配时更容易命中。千万不要起utils、helper这种名字,模型根本分辨不出这些泛化名对应的能力是什么。

提示:技能名尽量控制在2~3个单词,过长会让模型在选择时犹豫,过短又缺少语义。我用的模式是“动词+名词”,偶尔加一个限定词,比如find_weekly_events。

2. 技能注册机制与参数Schema的设计细节

2.1 从函数签名到JSON Schema的映射

agent-skills的技能注册机制,核心是把Python函数映射成一个模型可读可调用的JSON Schema结构。这是Function Calling类Agent最关键的工程环节,也是最容易出问题的地方。

我习惯这样定义技能:

from agent_skills import register_skill, SkillResult @register_skill( name="search_files", description="根据路径和文件名模式递归搜索本地文件,返回匹配文件列表", parameters={ "type": "object", "properties": { "path": { "type": "string", "description": "起始搜索目录的绝对路径" }, "pattern": { "type": "string", "description": "支持glob模式的文件名匹配,例如 *.py" }, "max_depth": { "type": "integer", "description": "递归搜索的最大目录深度,默认3", "minimum": 1, "maximum": 10 } }, "required": ["path", "pattern"] } ) def search_files(path: str, pattern: str, max_depth: int = 3) -> SkillResult: # 具体实现... return SkillResult.success(file_list)

这里有个关键设计:所有技能统一返回SkillResult,它既传递数据,也传递执行状态。这个封装让上层调度逻辑不需要感知每个技能的具体异常类型,只要判断result.success和result.error。

JSON Schema里的每一个description都值得用心写。它不止是给人看的注释,更是模型用来理解参数语义的关键信息。比如max_depth如果不写“最大目录深度”,模型可能传一个负数进去。参数的description越具体,模型填错的概率越低。

2.2 技能描述怎么写,模型才愿意调用

技能的描述是模型选择技能的唯一依据。很多团队花大力气实现逻辑,却随意写描述,结果模型根本不知道该在什么时候调用这个技能。

我的经验是,描述必须包含三个信息:能力范围、适用场景、触发条件。放个对比:

  • 糟糕描述:搜索文件
  • 中等描述:根据路径搜索本地文件,返回匹配结果
  • 好用描述:当用户需要查找特定文件时使用,根据起始路径和文件名glob模式递归搜索本地文件,返回文件路径、大小、修改时间组成的列表。仅用于本地文件系统搜索,不用于读取文件内容

第三版描述把“什么时候用”“能干什么”“不能干什么”都说清了。模型看到这样的描述,就能在“找上周的报表文件”这个需求下稳定触发search_files,而不会错误转给read_file。

边界声明很重要。描述里明确写“不做什么”,能避免大量误调用。比如read_file技能的描述里我会加一句“仅读取文件内容,不负责查找文件位置”,这样模型在用户只需要获取文件内容时才不会绕弯。

2.3 参数校验与上下文裁剪的平衡

技能入参的校验逻辑决定了Agent的稳定性。一个技能要承担两种错误:模型“幻觉”传错参数、用户数据本身异常。两者都要在技能入口处拦截。

我在search_files实现里加了类型检查和边界校验:

def search_files(path: str, pattern: str, max_depth: int = 3) -> SkillResult: if not path or not pattern: return SkillResult.error("PARAM_INVALID", "path和pattern不能为空") if not os.path.isdir(path): return SkillResult.error("PATH_NOT_FOUND", f"目录不存在: {path}") if max_depth < 1: return SkillResult.error("PARAM_INVALID", "max_depth必须大于等于1") # 实际搜索逻辑...

注意返回的错误码,尽量用机器可读的短码,配合人类可读的说明。模型在收到PARAM_INVALID错误码后,可以自动修正参数重试,而不是面对一大段堆栈信息发呆。

上下文裁剪这块,容易被忽视。技能返回的数据量如果太大,会撑爆Agent的上下文窗口。我的处理是在SkillResult里加一个截断策略:

SkillResult.success( data=file_list[:50], truncated=len(file_list) > 50, summary=f"共匹配{len(file_list)}个文件,已返回前50个" )

模型需要知道“后面还有更多”,它才能决定是否追加请求。这个truncated标志就是给模型的关键信号,不加的话模型会以为数据就这么多,可能基于不完整信息做错误判断。

3. 技能编排与调度:从单技能调用到多技能协作

3.1 顺序编排与依赖传递

单一技能只能做原子操作,真正体现Agent价值的是技能编排——把多个技能串成一条工作流。我最有代表性的一条工作流是“生成周报”:它需要依次调用search_files查找本周项目文件、read_file读取关键数据、extract_data解析指标、render_markdown生成报告。

顺序编排的关键是数据依赖。每个技能的输出是下一个技能的输入,这要求输出格式必须高度规范。如果search_files返回的列表字段叫path,read_file期望的参数也叫path,两个技能就能丝滑对接。

实操中,上层调度逻辑用一个pipeline结构来声明这种依赖:

report_pipeline = [ SkillCall("search_files", {"path": "/data", "pattern": "*.md"}), SkillCall("read_file", {"path": "$prev.result.path"}), SkillCall("extract_data", {"content": "$prev.result.content"}), SkillCall("render_markdown", {"stats": "$prev.result.stats"}) ]

这里的$prev.result.path就是从前一个技能的结果里取指定字段。这种显式依赖声明比让模型自己“看着办”稳定得多。模型只需要按剧本走,不需要每一步都重新推理该调用什么技能,大幅度降低错误率。

3.2 并行调用与条件分支的取舍

一个常见需求是并行技能调用。比如“对比上周和本周的数据”,如果数据文件互相独立,两个extract_data调用完全可以同时跑,节省时间。但工程上引入并行,意味着调度器要处理多个结果的对齐和合并,复杂度陡增。

我的建议是:大多数场景先别做并行。Agent的处理瓶颈通常不在技能执行速度,而在模型推理和上下文管理。并行反而容易让多路结果争抢上下文空间,导致关键信息被截断。实测下来,串行第一次虽然慢几秒,但稳定性和调试体验好得多。只有单一技能执行时间超过10秒、确实拖慢整体响应时,我才会考虑局部并行。

条件分支则是另一种常见编排需求。比如“根据文件是否存在,决定后续走新增还是更新逻辑”。这种分支逻辑我强烈建议放到代码层,而不是让模型自己判断。在pipeline里声明条件:

branch = { "condition": "$results.check_file.exists", "true": SkillCall("create_record"), "false": SkillCall("update_record") }

把条件判断交给代码而非模型,能杜绝很多不确定性。模型做分支判断时,偶尔会“脑子一热”选错,但在代码层,这个判断是确定性的。

3.3 技能链的失败恢复与降级

技能链跑起来之后,最大的敌人是“一个技能失败拖垮整条链路”。我早期遇到一个典型情况:周报工作流里extract_data因为数据格式不符合预期挂掉了,整个链路回滚,用户什么都拿不到。

后来我在每个SkillCall上加了失败恢复策略:

  • 单一技能失败时,先尝试携带错误信息重试一次,如果模型能修正参数就继续
  • 重试仍然失败,允许跳过该步骤,用默认值占位,并标记报告数据不完整
  • 关键路径技能失败,才整体终止并给用户清晰提示

这个机制里,每个技能都要有“降级方案”。比如extract_data失败时,降级方案是返回原始文本并标注“未解析”。这样后续的render_markdown还能工作,只是报告里有些部分显示原始要素。

注意:降级不是掩盖问题。返回的结果里必须带warning字段,让用户知道这份产出有哪些数据不可靠。否则看着正常的报告中藏着缺失数据,风险更大。

4. 实操:从零搭建一套“个人知识库Agent”的技能栈

4.1 定义技能包的元信息文件

我先定义技能包的元信息。对话AI的Agent Skills体系里常见用SKILL.md来描述技能,我用更结构化的skill.yaml做兼容扩展,除了基础描述还能携带参数Schema和输出格式。

name: search_files version: 1.2.0 description: 当用户需要查找特定文件时使用,根据起始路径和文件名glob模式递归搜索本地文件 parameters: type: object properties: path: type: string description: 起始搜索目录的绝对路径 pattern: type: string description: 支持glob模式的文件名匹配,例如 *.md max_depth: type: integer default: 3 minimum: 1 maximum: 10 description: 递归搜索的最大目录深度 required: - path - pattern output_schema: type: object properties: file_list: type: array items: type: object properties: path: { type: string } size: { type: integer } mtime: { type: string } truncated: type: boolean

这个yaml是技能与框架之间的契约。注意version字段,技能演进时必须有版本变更记录,否则旧Agent实例还在用老参数,很容易出兼容性问题。

4.2 注册中心与技能加载器

注册中心是整个agent-skills的心脏。它负责加载所有技能包,构建一张“技能-描述-参数”的路由表。模型发起调用请求时,注册中心根据技能名分发任务。

class SkillRegistry: def __init__(self): self._skills = {} def register(self, skill_meta, implementation): self._skills[skill_meta.name] = { "meta": skill_meta, "impl": implementation, "version": skill_meta.version } def dispatch(self, skill_name: str, params: dict): skill = self._skills.get(skill_name) if not skill: return SkillResult.error("SKILL_NOT_FOUND", f"未找到技能: {skill_name}") # 在调用前执行参数Schema校验 validated = self.validate_params(skill["meta"].parameters, params) return skill["impl"](**validated)

关键点是调用前的参数校验,不能省。模型有时候会传错类型,比如把max_depth传成字符串"3"而不是整数3,注册中心要做严格校验和类型转换。这里浅浅举一个类型转换示例:

if param_type == "integer": try: value = int(value) except (TypeError, ValueError): return SkillResult.error("PARAM_TYPE_ERROR", f"参数{name}需要整数类型")

4.3 技能调用的完整链路演示

现在我们来看一条完整的调用链路。假设用户对Agent说:“统计2024年12月的项目文档,生成一个Markdown汇总。”

流程是这样走的:

第一步,模型解析用户意图,匹配到search_files技能,填入参数:path=/data/docs,pattern=*2024-12*.md。注册中心校验通过后,技能执行,返回匹配的文件列表。

第二步,模型看到文件列表后,决定调用read_file技能,依次读取每个文件内容。这里有个细节:模型可能会一次性传多个文件路径,如果技能只支持单文件,就会出问题。我在read_file里直接用列表参数,让模型一次可以读多个文件,减少调用次数。

第三步,模型把所有文件内容汇总成一个清单,调用render_markdown技能,生成带表格的汇总报告。

链路跑通后,我把它封装成一个复合技能“generate_monthly_report”,只需要一个参数month,内部串起上面的流程。以后同一个Agent发布“生成一月报告”的需求时,模型会直接选中这个复合技能,而不是再一步步走。

4.4 新技能的开发与测试清单

技能库持续增长的瓶颈往往不在开发,而在测试。每新增一个技能,如果没有回归测试,旧技能的隐性破坏很难被发现。我维护一个技能测试清单,包含以下必测项:

  • 正常路径:输入合法参数,验证返回格式与Schema一致
  • 边界输入:空路径、空pattern、超深深度等边界值
  • 错误路径:目录不存在、文件无权限等异常场景验证错误码
  • 上下文影响:技能输出内容超长时的截断行为是否正确
  • 描述可匹配性:用5个典型用户问题验证模型能否选对技能

描述可匹配性测试是我特别想强调的。我会把典型的用户query收集起来,构建成一个测试集合,每次修改技能描述后跑一遍“query到技能”的选择匹配。这个测试很多团队不做,结果模型经常在类似需求上选错技能。

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

5.1 模型总是选错技能,先怀疑描述而不是参数

我在实践中遇到过很多次模型在类似需求上选错技能。排查的第一步永远不是看参数,而是看技能描述。比如同时有search_files和read_file,用户说“打开那个报表文件”,模型应该选read_file而不是search_files,但如果search_files的描述里写了“查找并打开本地文件”,模型就会被误导。

遇到选错技能,我首先做三件事:

  1. 检查两个技能描述里是否有重叠关键词,把重叠词从“选错”技能的描述中删掉
  2. 在“正确”技能的描述里增加用户query中的高频触发词
  3. 在两个技能描述里都加上边界声明,说明“不负责什么”

调整完描述后,跑一遍匹配测试集验证,一般能解决大半误选问题。代价是技能描述会不断膨胀,所以我定期用一个新角度重写描述,把冗余信息清理掉。

5.2 技能输出截断导致的数据损坏与处理

技能输出截断是另一个高频问题。尤其是读取大文件时,如果直接把整个文件内容塞进SkillResult,很可能撑爆上下文。我的解决思路是给所有可能返回大数据的技能加read_size参数和控制开关。

def read_file(path: str, max_chars: int = 4000) -> SkillResult: content = load_text(path) if len(content) > max_chars: return SkillResult.success( data={"content": content[:max_chars], "total_length": len(content)}, truncated=True, hint=f"文件较长,已截取前{max_chars}字符,总长度{len(content)}字符" ) return SkillResult.success(data={"content": content, "total_length": len(content)})

关键是truncated和hint必须放到结果里。模型读到截断标记后,如果用户后续还需要更多内容,就会用偏移量参数发起新一轮读取。这有点像我大二用分页接口的场景——一次拿不完就多拿几次,关键是接口得支持分页。

5.3 技能版本演进与旧Agent实例的兼容

技能库持续迭代后,版本管理跟不上会非常痛苦。我遇到过一个具体问题:search_files从v1升级到v2,把max_depth的行为从“包含起始目录层”改成了“从起始目录下一层开始计算”。旧Agent实例还在按v1理解,传同样的参数却得到不同结果,用户一脸茫然。

后来的策略是:技能注册表里维护多个版本入口,调用方在参数里显式声明版本号,或者用默认版本策略。最保险的做法是“新版本上线,旧版本保留至少一个迭代周期”,同时监控技能调用错误率,等确认新版本稳定后再下线旧版本。

版本兼容这件事没有捷径,只能在设计参数时就考虑“语义不可变”。参数语义不可变的意思是:同一个参数名在不同版本下,含义必须保持一致。新增能力就新增参数,不要改旧参数语义。能守住这条底线,绝大多数兼容性问题都追不到你头上。

我在维护agent-skills的过程中,最大的一个体会是:技能库的工程化程度,直接决定了Agent项目能走多远。前期多花点心思在设计技能边界、参数Schema和错误处理上,后期维护的摩擦会小很多。尤其是当技能数量超过20个后,如果没有一套规范的结构和版本策略,整个Agent会变得越来越难预测。反过来,技能库结构清晰,模型的选择准确率、系统的稳定性和可观测性都会有质的提升。最后再分享一个小经验:如果你刚起步,不要追求技能数量,先围绕自己最常做的三件事打磨三个高质量技能,跑通后再慢慢积累,比一开始就铺开几十个半成品技能靠谱得多。

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

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

立即咨询