1. 项目概述
1.1 核心需求解析
先说这个标题想表达什么。agent-skills 拆开看就是“Agent 的技能”,这也是当前大模型应用开发里最热门、最要紧的坑之一。做个带捏人设的聊天机器人不难,但你真的想让它干实事——查天气、拉订单、跑 SQL、操作浏览器——你就必须给 Agent 配一套靠谱的“技能系统”。
我前阵子接了一个内部自动化项目,需求是让模型根据用户一句含糊指令,自己决定调用哪套工具完成多步操作。项目管理方一开始觉得“模型会调用函数嘛,给几个 JSON Schema 就行了”。结果一跑起来全是坑:工具一多模型就不知道该选哪个、参数格式一会儿对一会儿错、调A工具失败后不知道怎么补救、日志里完全看不清这家伙刚才干了什么……这时候你才意识到,把几十个、几百个工具挂到模型面前让它挑,跟让一个新人同时看一百页操作手册再干活,基本是一个效果。
agent-skills 本质上就是解决这个问题的:你不想塞给模型一堆零散的 openapi schema,而是想给它一套经过组织的、能感知场景的、带前置校验和失败处理的能力库。围绕这套需求,我梳理了一个从设计到落地都比较完整的思路,也就是这篇文章想讲的东西。
1.2 适合谁看
如果你的场景符合下面任何一条,这篇文章值得读:
- 你在做大模型 Agent 应用,已经实现了基本的 Function Calling,但工具变多后开始混乱。
- 你想让 Agent 不只是“回答说得好”,而是能稳定完成拿数据、写文件、调接口这类实操任务。
- 你在设计一个多 Agent 协作系统,需要一套统一的技能注册和发现规范。
- 你手头正在维护一个开源或内部用的 Agent 框架,觉得技能管理这块代码越写越乱。
我后面写的内容不是某个特定框架的文档翻译,而是从通用工程视角拆解技能系统的设计思路和实现细节。你用的是 Python 也好、TypeScript 也好,核心逻辑都是相通的。
2. 整体设计思路与方案选型
2.1 技能、工具与函数:三个概念先分清
动手之前,先把概念理清楚。很多项目死在第一步,就是因为这三个词混着用:
| 概念 | 含义 | 粒度 | 例子 |
|---|---|---|---|
| 工具 | 一个可被调用的外部能力接口,比如 API、命令行、代码函数 | 细 | get_weather(city) |
| 技能 | 面向场景的能力封装,内部可以编排多个工具调用 | 粗 | plan_trip(city, days)内部会查天气、查机票、查酒店 |
| 函数 | 模型侧的调用形态,是技能给模型暴露的外壳 | 接口 | 一个带 JSON Schema 的函数声明 |
这里最容易被忽视的认知是:模型看到的是“函数”,函数背后挂载的才是“技能”。你不需要让模型理解技能内部有多少步骤,模型只需要声明“我要用 plan_trip”,然后由你的系统去执行这个技能。这就像你叫代驾,你只需要说出目的地,路线规划、路况规避是代驾团队的事。
基于这个认知,我把整个架构拆成三层:
- 接口层:暴露给模型的各种 function schema,由技能自动生成。
- 执行层:真正的技能实现,包括工具组装、分支判断、异常处理。
- 管理层:技能的注册、检索、状态维护,以及日志埋点。
2.2 为什么选择“技能”而不是直接堆工具
有人会问:我直接把每个工具都声明给模型不行吗?行,前提是模型支持超长上下文且不崩溃。GPT-4 时代很多项目就是这么干的,但到 Anthropic 的 200K 上下文、Gemini 的 1M 上下文时代,你还这么干,成本直接爆炸。
工具声明是有 token 开销的。一个中等复杂度的 API 如果 openapi schema 折算成 token 大概是 200 到 500 token,50 个工具就是 1 万到 2.5 万 token。每次请求都带上,等于每次都让模型在巨大的检索空间里找正确答案,不但慢,而且准确率显著下降。
技能的思路是做“两层映射”——场景层到技能层,技能层到工具层。模型看到的是二三十个粗粒度技能,而不是七八十个细粒度工具。模型先选技能,你的系统再根据上下文执行内部的子步骤。这就像一本手册先给目录,再给分页正文,而不是把全部内容摊在桌上让读者自己翻。
2.3 技术选型考量
在具体实现上,我参考了几个主流思路,但最终做了些自研调整。
首先是技能描述信息的组织方式。最基础的做法是每个技能写一段 description 文本,让模型靠语义匹配。问题在于文本质量参差不齐,语义边界模糊。改进方向是给技能加上标签体系,类似 intent 分类,让模型不仅能通过自然语言理解,还能通过结构化标签匹配。
其次是技能注册方式。用装饰器语法是 Python 生态里最自然的做法,类似装饰器收集函数元信息,这个思路最早出现在很多 Web 框架里,现在迁移到 Agent 技能管理,效果也出奇地好。
最后是执行模型的选择。技能内部的编排逻辑,我倾向用“状态机 + 条件分支”而非让大模型自由发挥。原因很简单:大模型自由发挥的路径是不可控的,生产环境里你要的是确定性优先。技能内部步骤固定,外部入口灵活,这样组合起来才既好维护又不容易翻车。
3. 技能定义规范与接口设计
3.1 技能描述信息的数据结构
一套技能系统能不能立住,先看它的元数据结构是否完整。我最终采用了下面这个核心结构:
@dataclass class SkillMeta: name: str # 技能唯一标识,如 "booking_flight" description: str # 给模型看的语义描述 tags: list[str] # 结构化标签,用于路由 input_schema: dict # 输入参数的 JSON Schema output_type: str # "text" / "json" / "file" / "none" timeout: int # 技能执行超时时间,单位秒 version: str # 技能版本,用于灰度 author: str # 负责人,方便排查问题description 这一栏最容易写砸。有人直接写“这个技能可以预订航班”,模型基本靠猜。好的描述应该包含:触发场景、关键参数解释、使用限制、返回内容特征。我举一个前后对比的例子:
- 差:“查询城市天气”
- 好:“当用户询问某个城市当前或未来几天的天气情况时使用。支持城市名中文或拼音,可选参数 days 控制预报天数(1-7),返回包含温度、降水概率、风力等级的 JSON 数据”
一个好的 description 能做到:模型只看这段文字就知道什么情况下选它、怎么填参数、结果大概长什么样。
3.2 输入 Schema 的设计原则
输入 Schema 是模型错误率最高的地方。说实话,模型每次都在跟你的 Schema 搏斗,你的 Schema 写得多严谨,它就能多稳定。
总结实战经验,有五个原则:
第一条:字段名尽量语义自明。用city_name而不要用loc,用depart_date而不要用d1。模型没那么聪明,能少翻译一步就少一步。
第二条:时间类字段统一用 ISO 8601 字符串。你让模型传时间戳还是2025-06-01?模糊不清的格式约定最容易出错。我一般只允许两种:YYYY-MM-DD或YYYY-MM-DDTHH:MM:SS。
第三条:必填与选填的边界要清晰。很多技能设计者为了省事什么都选填,结果模型随机发挥。凡是主流程需要的,一律必填;凡是可选增强的,明确标注默认值。
第四条:枚举值越少越好。枚举值一多,模型就可能在边缘情况里瞎选。能用 bool 的不要搞三元枚举,能自动推导的不要让模型填。
第五条:嵌套对象要克制。模型擅长填扁平的键值对,不擅长构造嵌套几十层的 JSON 对象。如果你某个技能需要复杂嵌套入参,认真考虑把这个技能拆成几个小技能,或者让系统侧负责组装。
4. 技能注册与动态发现机制
4.1 一行装饰器搞定技能注册
有了元数据结构,下一步就是让开发者注册技能足够快。我最终采用装饰器方案,代码会像这样:
from simagent.skill import skill @skill( name="booking_flight", description="当用户需要预订航班时使用...", tags=["travel", "booking"], input_schema={ "type": "object", "properties": { "from_city": {"type": "string", "description": "出发城市"}, "to_city": {"type": "string", "description": "到达城市"}, "depart_date": {"type": "string", "description": "出发日期 YYYY-MM-DD"} }, "required": ["from_city", "to_city", "depart_date"] }, timeout=30 ) def booking_flight(from_city: str, to_city: str, depart_date: str) -> dict: # 真实的航班预订逻辑 ... return {"status": "success", "ticket_no": "T123456"}装饰器背后其实是一张注册表,一个全局的dict:
SKILL_REGISTRY: dict[str, SkillMeta] = {} def skill(**kwargs): def decorator(func): meta = SkillMeta(name=kwargs["name"], ...) SKILL_REGISTRY[meta.name] = SkillMetaEntry(meta=meta, fn=func) return func return decorator这套设计的价值在于,开发者不需要知道系统如何管理技能,只需要写好函数、贴上装饰器。所有技能自动出现在注册表里,后续的路由、发现、执行都从注册表取数据。工程上这叫“约定优于配置”,省掉了大量手写配置文件的麻烦。
4.2 动态发现:模型如何选出正确的技能
技能注册只是第一步,真正难的是模型如何在几十个技能里选对。这部分是我的实现重点。
我先说清楚一个区别:技能描述文本是给模型“理解”的,而标签是给模型“检索”的。理解靠语义,检索靠精确匹配。两个环节互为补充。
举一个真实的例子:我的技能库里有get_weather和get_air_quality,两个技能描述都涉及“天气”“空气”“城市”。模型面对用户问“明天适合跑步吗”,到底调用哪个?如果只有描述文本,模型会纠结。但有了tags=["outdoor", "health", "exercise"],再加上描述里写明“当用户提及跑步、锻炼等户外活动场景,且关心空气质量时使用”,模型的判断便有了双重依据。
我还做了一步加权:命中多个标签的技能排名靠前。比如:
def rank_skills(query_tags: list[str], candidate_meta: list[SkillMeta]) -> list[SkillMeta]: def score(meta): overlap = len(set(query_tags) & set(meta.tags)) return overlap * 2 + semantic_score(meta, query_tags) return sorted(candidate_meta, key=score, reverse=True)语义分可以用任何 embedding 计算,但权重不要太大,因为 embedding 在短文本上的区分度其实很差,标签命中往往更可靠。
4.3 技能参数自动转换
技能选对了,参数填错了照样翻车。这里我做了一个防御层:在技能执行前,对模型返回的参数做一次“净化”。包括类型转换、值域校验、枚举映射。
def sanitize_params(input_schema: dict, params: dict) -> dict: cleaned = {} for field, spec in input_schema["properties"].items(): if field not in params: if field in input_schema.get("required", []): raise MissingParamError(field) continue value = params[field] field_type = spec.get("type") if field_type == "string": value = str(value) elif field_type == "integer": value = int(value) elif field_type == "number": value = float(value) if "enum" in spec and value not in spec["enum"]: raise InvalidEnumError(field, value) cleaned[field] = value return cleaned这一步看着蠢,真能挡住不少问题。模型经常把数字填成字符串、把日期格式写错、把枚举值换成自己造的词。你多这一层防御,后面执行层的报错就少一大截。
5. 技能编排:从单技能到多步骤任务
5.1 单技能执行的完整生命周期
一个技能从被模型选择到执行完成,内部经历多个阶段。这里分步拆开讲:
第一步,意图路由。系统拿到模型的 function call,先检查技能名是否存在于注册表。不存在就直接返回“技能不可用”,不给模型编造工具名的机会。
第二步,参数净化。按上面的 sanitize_params 逻辑处理入参。失败则返回带错误码的信息,让模型根据错误码自行修正后重新调用。
第三步,前置检查。有些技能需要在执行前验证外部状态——用户是否登录、API Key 是否有效、依赖服务是否在线。这一步放技能内部的pre_checks()里,不单独占一轮模型调用。
第四步,执行主逻辑。真正调用目标 API 或运行业务逻辑,捕获所有异常并把异常转成结构化的错误信息。
第五步,结果打包。输出统一格式:
{ "success": true, "data": {...}, "error": null, "latency_ms": 1234 }第六步,日志埋点。把技能名、入参摘要、出参摘要、耗时、错误信息全部写入结构化日志。这一步虽然不直接影响用户侧,但你排查问题时没有它等于闭眼开车。
5.2 组合技能与任务流水线
单个技能好做,组合技能才是 Agent 真正展现价值的地方。我的经验是:组合技能不要在装饰器里直接写大段编排逻辑,不然 500 行代码塞进一个函数,后面没人愿意维护。
我的做法是引入一个极简的流水线模型:
class SkillPipeline: def __init__(self, steps: list[callable]): self.steps = steps self.context = {} def run(self, initial_params: dict) -> dict: current = initial_params for step in self.steps: current = step(self.context, current) if self.context.get("abort"): break return current每个步骤是一个可独立测试的函数,负责一个子任务。步骤之间通过 context 传递中间结果,步骤只管消费和产出,不关心上游是谁。
比如“规划旅行”这个组合技能,内部三步:
def travel_skill(params: dict) -> dict: pipeline = SkillPipeline([ fetch_city_info, # 查城市基本信息 search_flights, # 查航班 search_hotels, # 查酒店 ]) return pipeline.run(params)每个子步骤都可以单独调试、单独降级。航班服务挂了,你只需要跳过 search_flights 步骤,返回一个提示给用户,而不是整个技能瘫痪。
5.3 状态保存与断点续跑
Agent 任务动辄几十秒,网络抖动、第三方 API 超时在所难免。没有状态保存的话,一个步骤失败就得全部重来,体验极差。
对我来说一个比较稳的方案是:整个执行过程进 Redis 或本地磁盘,以任务 ID 为主键,每完成一个步骤就写一次状态快照。任务中断后,新的请求可以带上任务 ID,直接从最后一个成功的步骤续跑。
这里有个坑:参数里包含大对象、图片数据时,序列化后体积可能几 MB,写入 Redis 很慢。我的做法是只保存对象引用或路径,不存数据本身。文件类中间结果先落盘,Redis 里只存路径字符串。
6. 常见问题与排查技巧实录
6.1 技能路由不准
症状:模型面对一个明明白白的请求,却调用了完全无关的技能。
排查思路:先看技能描述是否足够具体。你直接拿几个测试请求跑,看模型选的技能和你的预期差在哪。如果每次都是差“一步”,大概率是描述里有歧义。
其中一个最实用的技巧是:把你所有技能的 description 抽出来,一起打印到一页纸上,自己读一遍。想象你是一个完全不了解业务的陌生人,只看描述判断该调用哪个。如果你自己能判断错,模型一定也判断错。
另外一个高发原因:技能名起得太抽象。process_data这种名字等于没起,模型理解不到底处理什么数据。技能名一定要包含动作和对象,像merge_pdf_pages、translate_docx_file就清楚得多。
6.2 参数频繁报错
很多技能参数报错其实是描述不足导致的,不完全是模型的问题。你 Schema 里只写了字段类型,没写字段含义,模型只能猜。正确做法是在 description 里完整写清楚。
解决参数问题时,我整理过一个经验表格:
| 问题现象 | 常见原因 | 解决方案 |
|---|---|---|
| 字段缺失 | 模型漏读必填项 | 缩小 Schema 体积,避免一次性声明几十个字段 |
| 格式错误 | 格式约定不明确 | 在描述中给示例值,如“格式如 2025-06-01” |
| 枚举乱选 | 枚举项语义模糊 | 给枚举项加描述,或减少枚举数量 |
| 类型错乱 | 模型把数字当字符串 | 加强 sanitize 层,做强制类型转换 |
6.3 技能内部异常导致误判
这是所有坑里最隐蔽的一个:技能内部的代码抛异常,但 Agent 层误以为“模型选错了技能”或“服务端崩溃”。其实只是你技能代码里没做异常兜底。
我在写技能时强制要求:
try: resp = http_client.get(url, timeout=5) except ConnectionError: return {"success": False, "error": {"code": "NETWORK_ERROR", "message": "连接超时"}} except TimeoutError: return {"success": False, "error": {"code": "TIMEOUT", "message": "请求超时"}}所有可能出错的 IO 操作必须捕获具体异常类型,而不是用裸露的except Exception。一旦裸捕获,真实错误会被吞掉,你连排查的线索都没有。
同时建议在技能内部打点,记录每个外部调用的状态码和耗时。真正上线后你会发现,很多“Agent 表现不稳定”的案子,其实是某一个下游接口在深夜两三点变得特别慢。
6.4 技能灰度发布与回滚
技能更新不是改完代码就完事。我经历过一次事故:某个技能重构后,同一句话模型选择变了,整个流程走了另一条分支,数据全错。从那以后,我强制要求技能带version字段,ID 形如booking_flight@2025.06.01。
每一步变更按两步走:新版本技能先注册为canary,只把 10% 流量引到新版本;跑一段时间没有明显错误再全量切换。切换前保留旧版本注册表项,做到一键回滚。这套机制在单机技能库就能实现,核心就是注册表里同一个 name 维护多个版本条目。
7. 实测案例分析
7.1 背景与目标
我拿一个内部做的“客户支持助手”模拟项目来演示整套设计。这个助手面对的客户问题涵盖:查订单、改地址、退换货、查物流、咨询优惠券使用规则。最初方案是直接五个工具的 openapi schema 全部堆给模型,跑下来错误率不低。随后我按文中的思路改造了技能系统。
7.2 技能库设计对照
改造前五个零散工具:
- query_order(order_id)
- change_shipping_address(order_id, new_address)
- create_return(order_id, reason)
- track_shipment(express_no)
- check_coupon(coupon_code)
改造后重组为三个技能:
@skill( name="manage_order", description="用户查询订单、修改收货地址、发起退换货时使用。需先用订单号定位订单。", tags=["order", "after_sale", "logistics"], input_schema={...} ) def manage_order(order_id: str, action: str, payload: dict) -> dict: ... @skill( name="track_delivery", description="当用户咨询快递物流进度时使用。支持快递单号或订单号反查。", tags=["logistics", "package"], input_schema={...} ) def track_delivery(express_no: str) -> dict: ... @skill( name="promotion_calc", description="当用户询问优惠券、满减、折扣计算时使用。", tags=["promotion", "coupon", "discount"], input_schema={...} ) def promotion_calc(coupon_code: str, order_amount: float) -> dict: ...核心变化:原先查订单是查订单,改地址是改地址,退换货是退换货,三个函数三个调用意图,模型极容易选错。现在合到一个manage_order技能,由action参数区分具体子操作,模型只需要按“处理后订单问题”这一个意图来选,错误率立刻降下来。
而track_delivery为什么单独保留?因为物流查询和订单管理本身场景不同,用户聊“快递到哪了”时没有改地址的意思,强捆到一起反而增加无关参数。
这个案例说明一个关键经验:技能划分的粒度,要以“用户意图场景”为边界,而不是以“功能模块”为边界。功能模块是开发者的视角,意图场景是用户的视角,agent 系统必须站在后者去组织能力。
7.3 实测效果对比
采集 200 条线上真实用户提问跑回归测试,结果:
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 技能路由准确率 | 82.5% | 96.0% |
| 参数一次通过率 | 74.0% | 91.5% |
| 平均响应时间 | 4.2s | 2.8s |
| 用户投诉率 | 6.5% | 1.8% |
响应时间下降主要是两个原因:一是单轮请求的 token 消耗少了(工具声明少了一大半),二是错误调用少了,模型不用反复修正。这个对比也直接回答了“费半天劲做技能系统值不值”这个问题——非常值。
8. 经验总结与后续扩展建议
技能管理这件事,做个能跑的 demo 不难,难的是在真实流量下保持稳健。我经历了从“堆 schema 给模型”到“设计技能系统”的改造过程,最大的感受是:Agent 的能力上限不只看模型本身,更看你如何组织它的工具视角。
给正在做同类系统的朋友三个建议:
第一个,技能优先按用户意图场景划分,不要按系统功能划分。模型不是为你的数据库表设计的,它是为“人说的话”设计的。让技能的边界贴近人的表达习惯,路由准确率会显著提升。
第二个,技能描述文本值得反复打磨,不要像写注释一样随便填。把技能描述当成产品文案来写,写明使用时机、参数规则、输出内容。我自己的习惯是每次模型路由出错,第一件事就是去看对应的 description 是否有歧义,而不是急着改代码。
第三个,日志和状态管理从第一天就要做。技能系统不是一次性写完就算了,它是一个持续演进的运行时。没有日志你连它走错路的原因都查不到;没有状态保存你就承受不住任何外部依赖的抖动。这两块看起来不产生直接业务价值,但没有它们你后续寸步难行。
这个系统后续我可以继续扩展的方向:一是给技能加自动评估集,每次迭代跑一遍回归,防止“改好一个技能废掉另一个技能”;二是做技能版本热更新,不需要重启服务就能切换技能实现;三是把组合技能的编排逻辑改成可视化编辑,非技术运营也能维护。技能系统的路还很长,但这套基础框架目前我跑下来,稳定性是过关的。
如果再让我重新做一遍,我依然会从“技能元数据结构”开始,而不是直接从写函数代码开始。结构设计对了,后面一切都是水到渠成的。