这些年做大模型应用相关的开发,我发现在智能体落地的路上,最常卡人的不是模型本身,而是“模型明明会说话,却不会干活”。项目标题里这个agent-skills,说白了就是把“会说话”变成“会干活”的那层关键能力库——它是一套围绕智能体技能的设计、组织与执行的完整方案。不管你是正在做智能客服、行业问答,还是想给内部系统配一个能调度工具的助手,这篇文章想聊的东西都很值得你花几分钟看一遍。
这个项目名字虽然短,但背后其实藏了一条完整的设计主线:技能要怎么定义、怎么注册、怎么被模型正确调用、怎么安全地执行,以及踩了坑之后怎么排查。我按自己实际折腾过一遍的顺序,把从零搭一个技能库的思路、代码骨架和避坑经验都拆开讲。
1. 这个项目到底在解决什么问题
1.1 智能体从“会聊天”到“会干活”的鸿沟
先说说大背景。最近一年多,基于大模型做应用的思路基本上大家都摸到了同一根主线:模型负责理解意图和理解上下文,但它对外部世界的操作能力几乎是零,它既不能替你查系统里的订单数据,也不能真的帮你发一封邮件。要让模型“干活”,必须给它接上外部工具,也就是业界常说的 function calling,或者更通用一点叫工具调用。
但工具调用这件事,如果只是把十几个函数堆给模型,很快你就会发现问题:模型不知道该在什么场景下选哪个函数;参数填得乱七八糟;返回结果太长直接把上下文窗口撑爆;更别提多个工具叠加使用时谁先谁后、谁的结果要喂给谁这种事。agent-skills这个项目要解决的,就是把“工具”变成“技能”——它不只是一个可调用的函数,而是一套有描述、有参数约束、有执行策略、有安全边界的完整单元。
我自己的体会是,工具是点状的,技能是面状的。工具解决“能不能调”,技能解决“在什么情况下、用什么方式调、调完之后怎么办”。这个抽象层一旦做好了,智能体的能力边界和可维护性会拉开很大差距。
1.2 技能库的核心价值:复用、编排、收敛
把技能从模型对话逻辑里抽出来,独立成库,带来三个直接好处。
第一个是复用。同一套技能,今天放在客服机器人里,明天放在内部知识库助手里,后天放在自动化工作流里,都只需要注册一次。技能库就是一个能力的“公共仓库”,不需要每个上层应用各写一套。
第二个是编排。当技能够多,你自然需要一套机制来管理它们之间的协作关系。比如一个“查订单状态”的技能可能需要先“验证用户身份”,再“查询订单接口”,最后“格式化结果返回”。这种多步骤的组合如果全部交给模型自由发挥,结果极不稳定;在技能库层面预置好流程模板,效果会稳很多。
第三个是收敛。没有技能库之前,工具函数散落在业务代码各个角落,描述也写得随心所欲,改一处影响一片。有了技能库,所有技能都走同一套注册标准、参数规范和返回格式,行为可控,思路清晰。
所以这个项目的第一步设计,不是急着写代码,而是先把“技能到底是什么”这件事定义清楚。
2. 技能库的整体设计:先定规范再写实现
2.1 技能的四个基本要素
在动工之前,我参考了几种常见的智能体框架的实现思路,最后沉淀出一套适合自己项目的技能抽象。一个技能单元,至少要包含四个要素:名称、描述、参数声明、执行函数。看起来简单,但每一条放到实际场景里都有讲究。
名称要短、要唯一。它是模型调用时的标识,也是日志排查时的定位符。名称起得太长,模型生成容易出错;起得太抽象,人看到日志会一头雾水。我一般用“动词加名词对象”的格式,例如get_order_status、send_email_draft。
描述要精。描述是模型判断“我该不该用这个技能”的主要依据。描述写得太泛,模型什么任务都往这个技能上靠;写得太窄,又容易漏掉真正的匹配场景。这个度要靠反复测试去校准,后面我会单独讲。
参数声明是模型填参的唯一依据,必须用结构化的格式。它不只要写字段类型,更要写清楚每个字段的语义、取值范围、默认行为。
执行函数是真正被调用的业务代码,它和前面的描述区严格分离。模型模型负责决定调哪个技能,执行函数负责人负责执行真正的逻辑,两者不该耦合在一起。
2.2 注册中心:技能库的“目录页”
有了技能单元,下一步需要一个注册中心。这个中心维护一张“技能清单”,里面是所有可用技能的元信息。它对外提供两个核心能力:一是给模型看,因为模型需要知道有哪些技能、各自什么用途;二是给执行器看,因为执行器需要根据模型的决策,找到正确的函数来调用。
注册中心的数据结构,我建议尽量保持轻量。下面是我实际用下来比较顺手的一个雏形:
# skill_schema.py from dataclasses import dataclass, field from typing import Any, Callable, Dict @dataclass class Skill: name: str # 技能唯一名称 description: str # 给模型看的自然语言描述 parameters: Dict[str, Any] # JSON Schema 格式的参数声明 handler: Callable # 实际执行函数 tags: list = field(default_factory=list) # 分类标签 timeout_sec: float = 10.0 # 超时控制 disabled: bool = False # 临时下线开关这里有个容易被忽略的点:parameters字段用的是 JSON Schema 标准。为什么不用简单的“字段名加类型”字典?因为 JSON Schema 能表达更完整的约束,比如枚举值、格式、必填字段、嵌套对象。模型在生成参数时,凭的就是这份 schema 的约束力。约束写得越清楚,参数解析的成功率就越高。
注册中心还负责技能的路由。一个请求进来,注册中心检查技能是否启用、参数是否符合声明的结构,再把执行权交给 handler。这个流程让技能库的内外边界变得非常清晰。
2.3 执行器与编排层:技能到底怎么跑起来
技能库不能只有“登记”,还得有“调度”。这里我做了两层:一层是执行器,负责单独一个技能的执行;另一层是编排器,负责把多个技能按顺序串起来。
执行器的职责很简单:接收模型选择的技能名和参数,做校验,调 handler,把结果规范化后返回。它要处理的核心问题是异常——handler 抛异常了,返回给模型的消息应该是什么?我一般约定一个统一的返回结构,里面带status字段,成功还是失败,失败原因要控制在人话级别,方便模型基于失败信息做下一次尝试。
编排器则复杂一些。它管理的是流程模板,也就是一串预序的技能调用列表。比如一个完整的“查询订单状态并通知用户”流程,可以是:校验用户身份 -> 调用订单查询 -> 根据结果生成通知文案。编排器会逐步执行,把上一步的输出作为下一步的输入,最后汇总结果。
这里我再多说一句:编排逻辑不能全部依赖模型自己临时规划。模型临时规划灵活但不可控,预置模板稳定但扩展性差。折中的办法是:日常高频流程走预置模板,低频或异常情况才让模型自由发挥。这样既稳又活。
3. 核心实现:从零搭一个可用的技能框架
3.1 技能如何注册:一个装饰器搞定
在代码层面,我推荐用装饰器来做技能注册,这是最不打扰业务代码的方式。每个技能函数只需要加一行标注,就能把自己挂到注册中心里去,不需要改函数内部逻辑,也不会把技能定义和业务实现搅在一起。下面是一个简化版实现:
# registry.py class SkillRegistry: def __init__(self): self._skills = {} def register(self, skill: Skill): self._skills[skill.name] = skill def get(self, name: str) -> Skill: return self._skills.get(name) def instructions(self) -> str: """生成给模型看的技能说明文本""" lines = [] for skill in self._skills.values(): if skill.disabled: continue lines.append(f"## {skill.name}\n{skill.description}\n参数: {skill.parameters}") return "\n\n".join(lines) registry = SkillRegistry() def skill(schema: dict, name: str, description: str): def decorator(func): s = Skill( name=name, description=description, parameters=schema, handler=func, ) registry.register(s) return func return decorator注意这个instructions()方法。它在每次发起模型调用之前,把技能清单渲染成一段自然语言描述,塞进系统提示词或用户提示词里。模型其实是靠这段文本来“知道”技能存在的。这个生成过程是动态的,好处是:技能库增删技能时,提示词不需要手工更新,每次都自动带上最新的技能清单。
3.2 从模型输出到技能执行:一轮标准循环
要实现“模型决定调用技能并填参数、然后执行、再把结果交回给模型继续推理”的核心循环。这一步是整个技能库最关键的执行链路。
一个标准的交互循环长得像这样:
- 用户输入问题。
- 系统把技能清单和用户问题一起发给模型,模型决定要不要调用技能,如果要,则输出一个结构化调用指令,包含技能名和参数。
- 系统解析这个指令,校验技能名是否存在于注册中心,校验参数是否满足 schema。
- 执行器调用 handler。
- handler 的结果被包裹成统一结构的文本,作为新的一条消息继续发给模型。
- 模型基于结果生成最终回复,或者在需要更多信息时再次发起技能调用。
这个循环之所以不厌其烦地拆成六步,是因为每一步都可能出错,而一旦出错,你要能快速定位是哪一环的问题。比如,模型没选对技能,问题出在描述文本上;参数解析失败,问题出在 schema 约束上;handler 执行失败,问题才真正出在业务代码上。错误定位的粒度,直接决定排查效率。
3.3 参数解析与校验:别让模型的自作主张害了你
所有从模型返回的调用指令,本质上都是文本,哪怕是“看起来像 JSON”的文本,也不一定就是合法的 JSON。模型偶尔会多输出几个反引号、多加一个逗号、字段名和声明不一致。所以参数解析这一步必须用最保守的策略。
我的做法是先用正则提取出疑似 JSON 的片段,再做 JSON 解析,解析失败后不直接报错,而是尝试做轻量修复,比如去掉首尾的代码围栏、处理多余的逗号。修复不了,就把“参数格式不合法,期望的字段是xxx”作为反馈发给模型,让它重新生成。别小看这个重试动作,实测里相当高比例的错误在描述清楚后都能被模型自己纠正。
校验这一步用现成的 JSON Schema 校验器来做。校验的目的不是找模型的茬,而是防止明显不合理的参数进入业务代码。比如你声明某个字段取值范围是 1 到 100,模型传了 999,这种参数进了业务系统,轻则报错,重则产生脏数据。必须在入口拦掉。
# executor.py import json import re from jsonschema import validate, ValidationError def parse_and_validate(raw_call: str, schema: dict): # 提取疑似 JSON 片段 match = re.search(r"\{.*\}", raw_call, re.S) if not match: raise ValueError("no json found") call_dict = json.loads(match.group()) # 校验技能名和参数 validate(instance=call_dict.get("arguments", {}), schema=schema) return call_dict["name"], call_dict["arguments"]一个小技巧:让模型输出调用指令时,我要求它把技能调用放在一个单独的字段里,而不是混在普通回复中。这能极大简化解析逻辑,也避免模型在自然语言和结构化输出之间纠缠不清。
4. 技能库的选型与演进:不是堆得越多越好
4.1 内置技能怎么选:高频、低风险、可验证
自己搭技能库,最常犯的错是求全,恨不得第一天就接上二十个技能。我的建议正相反,首批技能只挑三类:高频使用的,低风险不涉及敏感操作的,以及结果容易验证的。
高频决定了这个技能很快就能在真实对话里被反复用到,能尽早暴露问题;低风险决定了即使模型调用错误,最坏情况也只是返回一个错误提示,不会造成破坏;容易验证决定了自动化测试能写,调试时能一眼看出逻辑对不对。以最简单的“时间查询”技能为例,它高频、无风险、结果能被轻松断言,就是绝佳的起步技能。
等到第一批技能稳定跑通,再逐步扩充。每次新增技能,都建议走同一条评审路径:业务上是否真的需要、模型是否能理解它的触发场景、执行失败时是否有兜底文案。记住了,技能库的维护成本是随数量非线性增长的,二十个技能可能还能靠记忆管理,五十个技能就必须靠分类和检索来辅助了。
4.2 第三方技能接入:统一适配层
实际项目中,你一定会遇到“这个功能人家已经有现成接口了”的情况。比如内部有一个业务系统的查询接口,文档齐全但风格和你写的 handler 完全不同。这时候不应该让上层代码直接调第三方的 SDK,而是把它包成一个技能。
适配层要做三件事:把第三方接口的入参映射成你定义的 schema,把第三方返回的数据结构转换成你统一的返回格式,把第三方接口的异常都拦截住并翻译成统一错误描述。这样做的好处是上层模型和编排器完全感知不到第三方接口的存在,技能库里每个技能的行为都保持一致的行为约定。以后就算第三方接口换了版本,受影响也只是适配层一处修改。
4.3 版本管理与灰度发布
技能也是一种软件资产,它也会升级改版。我遇到过很尴尬的场景:一个技能改了参数 schema,旧脚本还在按老参数调用,于是校验失败。要避免这种事,最简单有效的做法是,技能注册信息里增加一个version字段,并在生成给模型的技能说明中加入版本号。当模型收到两个同名的不同版本技能时,它自己就会选择语义更匹配的那一个调用。
灰度发布方面,我的经验是给关键技能加一个“抽查模式”:新版本技能先以一定比例启用,其余请求仍走旧版,观察一段时间看成功率有没有波动,再逐步放量。这个机制不复杂,就是一个简单的随机百分比判断,但它在真实场景中救过我很多次。
5. 踩坑实录与排查技巧
5.1 模型死活不调用技能,怎么排查
这是我遇到最多的一个问题:技能注册得好好的,模型就是不调用。排查路径有一个固定套路:先把“模型收到的技能清单”完整打印出来,看描述是不是被渲染格式破坏了;再检查技能描述是否过于宽泛或过于狭窄;最后试一个非常直白的用户问题,比如“现在几点”,看模型能否响应。每一步都有可能定位到问题。
很多时候是描述出问题。描述太泛,比如“查询信息的技能”,模型不知道这个技能是查订单还是查库存;描述太窄,比如“只能用于通过订单号查询已支付订单的物流状态”,那用户换个说法问“我的快递到哪了”,模型就匹配不上了。校准描述没有捷径,只能收集一批真实用户问题,逐条测试,反复微调措辞,直到触发率达到满意水平。
5.2 JSON 解析失败,究竟是谁的锅
参数解析失败的原因要分四类:第一类是模型输出的 JSON 确实不合法,属于模型侧问题,可以重试或做轻量修复;第二类是 schema 太严或太松,比如漏标了必填字段,导致模型漏填,这类要改 schema;第三类是前后端字符编码不一致,比如全角引号、特殊空格,解析前做一次字符清洗;第四类是上下文里的旧格式干扰了模型,比如历史消息里出现过另一套调用格式。
我通常会把手写的排查流程固化成一套自动化测试:准备一组不同的模型输出样本,包括正常、多余围栏、字段错位、缺括号等场景,跑一遍解析逻辑,确保每个场景都有明确输出。这个测试文件跟着技能库走,每次改动都能快速回归。
5.3 执行超时与并发控制
技能执行不是无限快,外部接口也可能慢。如果 handler 阻塞太久,整个智能体对话会卡在那里,让用户觉得系统死了。所以每个技能都必须有超时控制,超时后按失败处理,并且要把失败原因表述得足够清晰,比如“服务响应超时,请稍后再试”。
并发控制同样重要。多个用户同时触发同一个技能是常态,如果你的技能内部操作了共享的可变状态,比如往同一个文件写数据、操作同一个全局变量,必须要加锁或用无状态实现。我倾向于要求所有 handler 都是无状态的,数据之间通过参数传,结果通过返回值给,这样做并发安全就基本不用操心了。
5.4 安全边界:技能是最容易被利用的入口
技能挂在模型后面,等于向外部世界多开了一个执行口子。必须假设用户一定会想尽办法让模型调用到意料之外的技能参数。所以三个边界一定要守住:第一,任何把输入拼进命令或 SQL 的地方,必须做参数化处理,不推荐直接拼字符串;第二,技能的权限范围要遵循最小权限原则,尽量不给 handler 超出的系统权限;第三,凡是涉及写操作、删除、转账这类敏感技能,要有二次确认机制,不能模型一念之差就直接执行。
我还加了一层“技能白名单”逻辑:模型只能调用注册中心里启用的技能,任何不在清单里的名字,执行器直接拒绝并报错。这套底线虽然简单,但能把风险控制在一个可接受的水平。
6. 最后说几句个人体会
agent-skills做到现在这个阶段,我最大的感受是:技术难点从来不在某个单点功能上,而在“让模型、技能、业务代码三者长期和谐相处”这件事上。技能库不是一个一次性的代码仓库,它更像一个不断生长的活物——随着真实用户问题的变化,你要不断调整描述、增删技能、优化校验。
我踩过的坑,包括描述写太泛导致模型乱调用、schema 忘记标必填导致参数频繁解析失败、外部接口变更导致技能静默失效,每一个都能追到设计阶段的一个疏忽。所以如果你也准备搭自己的技能库,我的建议是:先小后大,先稳后全,设计好规范和排查路径,再谈数量和复杂度。
最后分享一个小技巧:给每个技能都留一个“调试模式”字段,在这个模式下,执行器会把完整的调用指令、参数、校验结果、handler 返回值和耗时全部输出到日志。平时关闭,排查问题时再打开。就这一条,能帮你省掉大量“猜谜”式排查时间。