☰
agent-skills:为AI Agent打造可复用的技能调度与管理框架
2026/10/13 8:33:02 网站建设 项目流程

做 AI Agent 开发一段时间后,会发现一个很尴尬的问题:明明每个 Agent 都在做“根据用户意图选择工具、调用工具、整理结果”这件事,但每次换一个场景,代码就要重写一遍。工具调用的模板塞在 system prompt 里,越堆越长,模型开始乱选函数、漏传参数、返回格式五颜六色。agent-skills 这个项目,就是冲着这个问题去的——它把 Agent 的每一项能力抽象成“技能”,用一套统一的结构去定义、注册、调度和执行,让 Agent 不再靠猜来使用工具,而是有一套明确的匹配逻辑。

这个项目适合两类人看:一类是正在做 Agent 应用、被工具调用稳定性折磨的开发者,另一类是刚接触智能体开发、想搞清楚“技能”和“函数”到底什么区别的学习者。先说清楚它能解决什么问题:技能复用、参数校验、调用回退、可观测性。下面是我的完整设计思路和实操记录。

1. 项目起点:为什么 Agent 需要一份“技能清单”

1.1 靠 Prompt 约束工具调用,越走越窄

很多 Agent 的第一版都是暴力方案:把所有函数说明写进 system prompt,配合“你必须严格按 JSON 格式输出”的提示词,然后祈祷模型别乱来。前 10 个函数的时候还能忍,等函数数量上到 50 个、100 个,问题就开始集中爆发。

我见过最典型的三类失败:

  • 模型把 function name 拼错,大小写不一致、带了奇怪的前缀;
  • 参数类型搞错,明明 schema 里写的是整数,模型输出字符串;
  • 模型把两个相似功能的函数搞混,比如“查询天气”和“查询未来一周天气”,它经常随机选一个。

这些问题的根源在于:Prompt 对模型来说只是“建议”,不是“约束”。模型在生成文本的时候,函数调用本质上也是文本生成的一部分,它天然倾向于语义顺畅的内容,而不是结构正确的 JSON。所以靠提示词去管工具调用,本身就是把可靠性建立在随机性上。

1.2 函数与技能的真正区别

先说一个容易混淆的点:函数是能力的实现,技能是能力的“完整包装”。一个函数只有一个名字和一段执行逻辑,但一个技能还包含了触发条件、参数契约、执行策略、失败回退。

生活化类比一下:函数是一把扳手,放在工具箱里;技能是“使用扳手拧水管接头”的整套流程,上面写着什么时候该用、开口要调多大、拧不动的时候该怎么办。Agent 调度器不直接面对扳手,它面对的是贴满标签的流程卡。

所以在 agent-skills 里,我一开始就定了一个原则:技能是什么形态都行,函数、API 调用、脚本、甚至是一段多人协作流程,都可以被包装成技能。关键是外面套的那层“元数据”必须统一。只有元数据统一,调度器才能用同一套逻辑处理所有能力。

1.3 设计目标:让技能可被发现、可被调度、可被观测

agent-skills 整个项目围绕三个动词展开:

  • 发现:给定一个用户请求,系统能找到最相关的技能;
  • 调度:按技能的参数契约去执行,处理好超时、重试、回退;
  • 观测:每一次技能调用都有记录,能复盘“为什么这次选了 A 而不是 B”。

这三个目标决定了项目架构不会太复杂:一个技能注册表、一个技能描述索引、一个调度执行器。剩下的都是在这三个模块里填充细节。

2. 技能库的核心设计:用什么形态组织“技能”

2.1 技能的结构定义

每个技能,我把它定义成下面这几个字段:

字段类型说明
namestr全局唯一技能名,小写加下划线
descriptionstr给模型看的匹配描述,包含触发场景
keywordslist[str]显式关键词,用于快速预筛
input_schemadictJSON Schema 风格参数定义
executorCallable实际执行的函数对象
timeoutint单次执行超时秒数,默认 10
retry_policydict重试次数与退避策略
fallbackstr失败后回退到的技能名,可空

这里最容易被忽略的是 description 和 keywords 的区分。description 是给模型看的,keywords 是给规则匹配用的。keywords 可以很机械,比如“天气、温度、降雨”;description 必须写成“触发场景+用户意图”的组合,比如“当用户询问某个城市当前或未来的天气情况,包括温度、降水概率、风力时使用”。

2.2 为什么参数契约用 JSON Schema

最开始我图省事,直接让技能执行函数声明 Python 类型注解,然后靠函数签名生成参数说明。但实际测试发现,模型在填充参数时经常不按类型走,尤其是嵌套对象和枚举值,翻车率很高。

后来换成了 JSON Schema,效果立刻不一样。原因有两层:

  • schema 本身就是模型训练时常见的数据格式,生成符合 schema 的 JSON 比生成符合任意 Python 注解的 JSON 要稳;
  • schema 可以显式声明 required、enum、format,比如日期格式用"format": "date",模型就知道该输出 YYYY-MM-DD。

所以我的建议是:别省这一步,直接用 JSON Schema,哪怕只是把 Python 类型手动映射成基础 JSON 类型,也值得。

2.3 技能间的依赖关系怎么处理

复杂场景里,一个技能可能需要先调用另一个技能。我在设计里没有给技能之间做显式依赖声明,原因是会让调度逻辑变成有向图遍历,复杂度直接起飞。

我的做法是:在 executer 内部自行调用其他技能,调度器只负责最外层那个技能。也就是说,依赖关系下沉到代码层,而不是配置层。这样做的好处是调度器保持简单,坏处是没法自动做依赖注入。对大多数场景来说,这个取舍是值得的。

3. 实操入门:技能定义与注册机制

3.1 用装饰器注册一个技能

agent-skills 的注册接口我用的是 Python 装饰器风格,因为最直观,写起来也最省事。下面是一个查询订单状态的技能示例:

from agent_skills import skill, execute @skill( name="query_order_status", description="当用户询问订单状态、物流进度、发货情况时使用", keywords=["订单", "物流", "发货", "快递"], input_schema={ "type": "object", "properties": { "order_id": {"type": "string", "description": "订单号"}, "phone_last4": {"type": "string", "description": "收件人手机号后四位,用于校验"} }, "required": ["order_id", "phone_last4"] }, timeout=15 ) def query_order_status(order_id: str, phone_last4: str) -> dict: # 这里写真实的订单查询逻辑 result = query_order_api(order_id) if result["phone_tail"] != phone_last4: return {"status": "denied", "message": "校验失败"} return {"status": "success", "data": result}

这里有一个容易踩的坑:装饰器注册的技能,函数参数名必须和 input_schema 里的 properties 键完全一致。不一致会导致参数抽取成功但函数调用直接 TypeError。我一开始没做校验,上线后被这个坑炸了两次,后来在注册阶段直接加了一层反射比对,不一致就报错。

3.2 技能的加载与冲突检测

技能不是分散在各处就完事了,应用启动时会统一扫描所有被@skill装饰的函数,把它们注册进全局 SkillRegistry。注册阶段做了三件事:

registry = SkillRegistry() @registry.on_register def check_conflict(skill): # 1. 同名检测 # 2. 描述长度检测,description 少于 20 个字符直接警告 # 3. 关键词去重,防止两个技能 keywords 交集过大 pass

同名检测好理解。描述长度检测很多人会忽略——如果 description 太短,模型匹配阶段信息量不够,会出现两个相似技能随机被选中的情况。我的经验是 description 至少 30 到 50 个字,必须包含“何时使用”和“何时不使用”两个维度。

关键词去重这里多说一句:不是完全禁止交集,而是当两个技能的关键词重合度超过 60% 时,触发告警,提醒你检查是不是技能边界划分不清晰。这个设计的价值在技能数量上百之后会非常明显。

3.3 技能的批量导入

除了装饰器注册,我也支持从一个 JSON 文件批量导入技能描述。这个功能主要用于团队协作——算法工程师写好技能元数据,业务工程师实现执行函数,两边通过一个 JSON 文件解耦。

JSON 文件长这样:

{ "skills": [ { "name": "query_order_status", "description": "...", "keywords": ["订单", "物流"], "input_schema": {}, "handler_module": "handlers.order_handlers", "handler_func": "query_order_status" } ] }

加载器读到 handler_module 和 handler_func 后,会去指定模块里找函数,把定义和实现做绑定。这样做的核心原因是:让技能描述可版本管理、可 diff,而不是散落在代码注释里。每次改 description,Git 历史里都能看到来龙去脉。

4. 调度器实现:Agent 怎么“想起来”该用哪个技能

4.1 匹配管线:规则 → 语义 → 模型

调度器最核心的模块是技能选择器。我把它做成三段式管线:

第一段是规则预筛。拿用户输入去匹配所有技能的 keywords,命中的技能进入候选集。这一段的目的是缩小范围,哪怕选错也比全量扫描快。

第二段是语义排序。把用户输入向量化,和候选技能的 description 向量做余弦相似度排序。这里用的是本地小模型做 embedding,速度很快,成本也低。候选集超过 20 个时才触发这段,不然直接进第三段。

第三段是模型精排。把语义排序前 5 的技能 description 拼接,连同原始用户输入一起交给模型,让模型直接输出应该调用的技能名。

为什么前面已经有了语义排序还要模型精排?因为 AB 测试下来,纯 embedding 匹配的准确率大概在 75% 到 80%,加上模型精排能提到 92% 以上。代价是多了一次模型调用,但换来的稳定性非常值得。

4.2 匹配阈值与拒识

技能匹配不能总是硬选一个。用户输入不在任何技能覆盖范围内时,强行匹配只会让 Agent 胡说八道。所以调度器里有一个 reject 分支:

if best_score < 0.35: return SkillResult(status="rejected", reason="low_confidence") elif best_score < 0.6: # 进入确认模式:向用户复述“你是想问xxx吗” return SkillResult(status="needs_confirmation", candidate=best_skill)

0.35 和 0.6 是我在测试集上调出来的经验值,不同领域可能不一样。这个机制解决了一个很实际的问题:用户说“帮我订个酒店”,系统里只有“查询酒店”和“取消订单”两个技能时,不要假装自己会订酒店,而是明确告诉用户“我只能查,不能订”。

4.3 执行器:一个简单的状态机

匹配完成之后,执行器接管。我把技能调用过程建模成状态机:

  • pending → scheduled → extracting_args → executing → succeeded
  • extracting_args → failed_args → extracting_args(重试) 或 terminal_failed
  • executing → timeout → fallback_skill 或 terminal_failed

这个状态机写在代码里大概一百多行,状态流转用字典映射加状态回调实现,没有引入重型框架。好处是每一步都能输出日志,复盘时能看到调用链。

执行器内部用线程池并发控制技能执行,避免一个耗时技能阻塞整个 Agent。默认线程池大小是 8,技能执行时间超过 timeout 后直接放弃,走重试或回退。

5. 参数抽取与结果回填:最容易翻车的地方

5.1 用约束生成替代自由发挥

技能执行前,必须从原始用户输入里抽出参数。这一步我踩过的坑最多。最开始我是把整个对话历史连同参数定义一起丢给模型,让它“凭感觉”抽参。结果就是模型经常抽取用户没说过的参数,或者把地名抽成时间。

后来改成约束生成模板:给模型的不是对话历史,而是经过整理的用户意图摘要加参数 schema,并且明确要求“只能从原文抽取,不能臆造”。

根据用户输入抽取以下参数,严格按照 JSON Schema 输出。 用户输入:{{user_input}} 参数定义:{{input_schema}} 规则: 1. 每个参数必须能在原始输入中找到依据 2. 缺少必要参数时,对应字段输出 null,不要猜测 3. 只输出 JSON 对象,不要输出其他内容

加了规则之后,参数抽取的准确率从大约 70% 提升到 88%。剩下的 12% 错误集中在模型的“补全心理”上——看到缺了城市名,就自动把对话历史里的城市填进去,但那个城市明明是上一次对话的,跟当前请求无关。

5.2 参数校验失败后的自愈策略

参数抽取完成,执行器会对参数做一次 schema 校验。校验通过,进入执行;校验失败,不会直接报错,而是带着校验错误信息重新追问一轮补充信息:

for attempt in range(retry_policy.get("max_retries", 2)): extracted = extract_args(user_input, skill.input_schema) errors = validate_schema(extracted, skill.input_schema) if not errors: break # 把 errors 反馈给模型,让它针对缺失字段再次抽取 user_input_for_retry = build_retry_prompt(extracted, errors)

这个机制叫做“交互式参数补齐”,它让 Agent 在缺参数时能主动发问:“请问您的订单号是多少?”而不是呆呆地报错。体感上的效果非常明显,用户不再觉得 Agent 是个脆弱的玩具。

5.3 结果回填:别把函数的裸返回直接丢给用户

技能执行函数的返回值,很多是结构化数据,比如一堆 JSON 字段。直接原样返回给用户,用户体验很差,而且容易暴露内部字段名。

所以在执行器尾部有一层结果格式化,它做两件事:

  • 根据结果类型拼接自然语言摘要;
  • 敏感字段打码,比如手机号只保留后四位。

这层逻辑每个技能可以自定义一个format_result回调,没定义就用默认模板。默认模板会把 dict 的 key-value 拼成一句通顺的中文。这个设计虽小,但对 Agent 的“智商感”贡献很大。

6. 踩坑清单与排查技巧实录

6.1 高频问题与解决方案速查

我在开发和调优 agent-skills 的过程中,整理了一份高频问题清单,直接放这里当速查表:

现象根因处理方式
技能匹配总选到相似项description 没有写“不使用场景”重写 description,增加负面约束
参数抽取出空值模型缺少上下文,原文确实没给启用交互式补齐,反问用户
技能执行报错但 Agent 无感知异常被吞掉,结果状态没传递执行器捕获异常后写入 result.status
重复调用同一技能没有做执行结果缓存对幂等技能加 10 分钟缓存
技能响应慢导致整体卡顿线程池耗尽或超时设置过大调小 timeout,线程池隔离
描述过长导致匹配混乱description 超过 200 字,信息熵太大控制在 100 到 150 字

6.2 描述风格怎么迭代

我调试 description 的经验是:不要坐在电脑前空想,去翻真实用户对话记录。每来一条匹配错误案例,就把用户原话和错误选择结果记录下来,然后反推 description 缺了什么信息。

举个例子,用户说“帮我看看快递到哪了”,系统匹配到了“查询快递网点”而不是“查询物流轨迹”。看案例发现,原 description 里写的是“查询某快递公司网点地址、营业时间”,没有提到“包裹、轨迹、当前到哪”。把关键词和描述补上这两个要素之后,这个误匹配立刻消失了。

6.3 性能优化记录

最后说下性能。整个技能的匹配管线中,最耗时的不是技能执行,而是模型精排那一次调用。我做了个优化:

  • 规则预筛和语义排序全走本地,耗时控制在 50 毫秒内;
  • 模型精排做异步化,和参数抽取合并成一次模型调用。

合并之后,Agent 从接收请求到技能执行完成的端到端耗时,从平均 1.8 秒降到了 1.1 秒。体感好了不少,尤其是对话多轮场景。

根据我个人实际使用的体会,agent-skills 这类技能管理框架的收益,在技能规模小的时候感知不强,但一旦技能数量突破 30 个,它带来的稳定性和开发效率提升是断崖式的。你不需要一开始就把技能定义得多完美,先用最朴素的描述跑起来,然后靠真实案例慢慢打磨。技能库是一个越用越值钱的东西,因为它沉淀下来的不只是代码,还有对用户意图和系统能力边界的理解。

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

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

立即咨询