☰
智能体技能模块设计指南:从参数契约到可复用技能包
2026/10/14 9:00:45 网站建设 项目流程

最近如果一直在折腾大模型智能体,应该没少接触agent-skills这个词。简单说,它就是把智能体完成某类具体任务的能力打包成一个可复用、可插拔的模块,可能是查询订单的一段代码,也可能是生成周报的一套工作流,甚至是一个封装好的内部接口。没有技能的智能体充其量是个健谈的聊天机器人,有了技能,它才真正开始替人干活。

这篇文章不聊深奥的理论,我把这段时间从零搭技能模块踩过的坑、沉淀下来的设计方法和可直接抄作业的模板都整理出来。如果你正准备给自己的Agent接入业务能力,或者想搞一套可维护的工具库,这篇文章基本能覆盖你的核心需求。下文中的代码和目录结构都来自我实际跑过的模拟项目,为了演示方便做了一些简化,但设计思路可以直接沿用。

1. 为什么智能体需要"技能"而不是"提示词"

1.1 从"会聊天"到"能办事"的最后一公里

最开始做智能体应用时,我的第一反应是:把所有工具说明、接口文档、甚至完整的可执行逻辑塞进提示词里,让模型照着干。结果非常惨:上下文一长,模型就开始遗忘前置说明;遇到参数稍微复杂一点的请求,它就开始自由发挥编造调用方式;更别提数据校验、权限隔离这些事了,提示词根本无法约束。

后来我才想明白,大模型本质上是一个"文本生成器",它擅长的是把语言表达映射成合理的文本输出,而技能系统最重要的作用是充当"文本世界"和"真实系统"之间的桥。用户说一句"帮我查一下订单到哪了",模型把这句话解析成结构化的调用意图,然后通过技能模块真正去查数据库、调物流接口,再把结果整理成自然语言回给用户。这个链路里,技能负责的是"确定性逻辑",模型负责的是"模糊意图理解",两者各司其职。

这其实和团队协作很像。你不可能让一个只有嘴皮子、没有任何工具的同事去完成一个需要拧螺丝、焊接、测试的复杂工程,你得给他一套带操作手册的工具箱。这里的技能,就是智能体工具箱里的每一件标准工具。

1.2 技能的本质是输入输出契约

很多人一提到技能就想到"写一个函数"或者"写一段API调用",这只是表象。真正让技能可以复用的,不是代码本身,而是它有明确的输入输出契约。输入契约定义了模型需要提供哪些参数、参数的类型和取值范围;输出契约定义了返回给模型的结构化数据长什么样。

以"订单查询"这个技能为例,输入是订单号、平台标识、手机尾号三个字段;输出统一是一个JSON对象,包含是否成功、错误码、订单状态、物流单号、预计送达时间等字段。这个契约同时约束了模型和业务系统:模型必须按照契约生成参数,业务系统必须按照契约返回结构化的结果。契约在手,哪怕底层数据库换了、接口变了,只要输入输出不变,技能对模型来说依然是"同一个东西",上层应用无需改动。

这一点非常关键。我见过有个团队把技能设计成"直接返回一长段格式化文本",结果模型拿到文本后又要二次解析,不仅损失精度,而且经常漏掉关键字段。如果有明显的契约,比如固定字段名的JSON,模型做文本摘要时会容易得多。

1.3 技能、插件、工具、工作流的关系

这几个词在社区里经常混着用,实际上它们是一个分层结构。

  • 技能(Skill):能力的最小封装单元,一个技能做一件完整的事,比如"查询订单状态"。
  • 工具(Tool):技能背后的具体实现,通常是一个可被调用的函数、脚本或API包装器。
  • 插件(Plugin):技能的发布和分发形态,一个插件可以携带多个技能,以及相关依赖和配置。
  • 工作流(Workflow):多个技能按业务顺序进行组合编排,比如处理售后退款时,需要先查询订单,再校验资质,最后计算退款金额。

用生活类比来说,技能是一条经过质检的流水线工序,工具是构成工序的机器设备,插件是把几台设备打包好的标准工位,而工作流是一整条连接多个工位的生产线。这个分层逻辑理清楚了,后面做技能管理、版本升级、复用组合就顺了。

2. 技能模块的标准结构与设计原则

2.1 一个可移植技能包应该包含什么

我自己的技能包目录结构如下,这个结构经过多轮迭代,基本能覆盖从开发到部署的完整链路:

my-skill/ ├── skill.yaml # 技能元信息:名称、描述、参数、输出、依赖 ├── README.md # 给人类开发者看的文档 ├── impl/ # 核心实现逻辑 │ ├── main.py │ └── config.py ├── assets/ # 静态资源,如模板、字典、配置文件 ├── tests/ # 自测用例 │ ├── test_main.py │ └── fixtures.json # 模拟输入输出 └── requirements.txt # 依赖清单

skill.yaml是整个技能包的"身份证",也是智能体运行时扫描技能时的入口。它的核心作用有两个:一是告诉运行平台这个技能是干什么的、参数长什么样、输出怎么解析;二是给大模型生成调用参数时提供参考信息。如果这个文件写得不够清晰,模型就会在调用时频频出错,所以花时间打磨它是非常值得的。

2.2 设计技能的几条铁律

在反复踩坑后,我给自己定了几条设计铁律,按优先级从高到低排:

  • 单一职责:一个技能只做一件事,并且把这件事做彻底。不要搞一个"超级技能"既能查订单又能改地址还能开发票,否则参数会膨胀到连模型都无法准确填写,测试和排查也会非常痛苦。
  • 显式参数:每个参数都必须声明类型、是否必填、取值范围、默认值,最好再配一个典型示例。意图是让模型少猜,减少生成非法参数的概率。
  • 零状态优先:技能实现要尽量无状态,同一份输入永远返回同一份逻辑结果。这样做的好处是并发安全、部署简单,也方便做缓存和性能优化。
  • 错误可解释:返回的错误不是一句简单的"Error",而是包含错误码和可读的修复建议。比如"INVALID_ORDER_ID:订单号长度不足,请检查后以6位以上数字字母组合重试"。模型拿到这个信息后,能自己纠正参数再尝试一次。

2.3 命名与描述:写给模型看的说明书

技能名和描述是给两个读者看的,一个叫"模型",一个叫"运行时框架"。但对模型来说,技能描述的质量直接决定了它会不会在合适的时机调用你提供的技能。

命名上,我强烈建议采用"动词+宾语"的结构,比如query_order_status、create_refund_ticket、generate_weekly_report。别用纯名词如order、report这种,模型很难从模糊名称中判断功能边界。

描述部分要写出三层信息:

  • 什么情况下调用:例如"当用户询问订单物流进度、包裹到哪了、预计送达时间时,调用此技能;如果用户只是提到了订单但没问状态,不要调用。"
  • 参数的具体含义:除了在参数Schema里定义,描述里最好再补充一句"platform参数只支持platform_a和platform_b两种取值,不要传入其他值。"
  • 典型的限制或注意事项:比如"该技能不适用于跨境订单,只查询近一年订单。"

我看到很多人把技能描述写成技术文档风格,满篇专业术语。其实模型的自然语言理解能力很强,你用清晰的业务语言描述,反而能让它更准确地触发技能。

3. 从零搭建一个可复用的技能包

3.1 场景设定:做一个"订单查询"技能

下面用一个最典型的场景来演示:客户想知道自己在某个平台下的订单送到哪了。我们把它抽象成query_order_status技能,输入是平台、订单号、手机后四位,输出是订单状态和物流摘要。之所以加手机后四位,是为了做一个轻量的校验逻辑,这在实际业务里非常常见,也能演示参数约束的正确写法。

这个场景虽然简单,但足以覆盖技能开发的全部关键点:参数Schema定义、输入校验、外部调用模拟、错误处理、返回结构设计。做完之后,你可以很容易地把外部调用替换成真实的数据库查询或HTTP接口。

3.2 定义技能元信息与参数Schema

先写skill.yaml。我习惯用YAML写人类可读的元信息,因为它带注释更友好;但在传给模型或运行框架时,很多时候还需要转换成JSON副本,所以这里我会两个版本都维护。

name: query_order_status description: >- 当用户询问订单物流进度、包裹到哪了、签收时间、预计送达时间时,调用此技能。 如果用户只是提及订单但并未询问状态,不要调用。 version: 1.0.0 type: function parameters: - name: platform type: string required: true enum: [platform_a, platform_b] description: 订单所属平台标识,只允许这两个固定取值,不要做同义词映射。 example: platform_a - name: order_id type: string required: true min_length: 6 pattern: "^[A-Za-z0-9]+$" description: 订单号,由字母和数字组成,最少6位。 example: A123B456 - name: phone_tail type: string required: true min_length: 4 max_length: 4 description: 下单手机号的最后四位数字,用于校验归属。 example: "8899" output: type: object fields: success: type: boolean data: type: object nullable: true fields: order_status: type: string logistics_company: type: string tracking_no: type: string estimated_time: type: string error: type: object nullable: true fields: code: type: string message: type: string

这个Schema的精髓在于,所有约束都尽量写成模型能直接看懂的内容。enum、pattern、min_length这些虽然不是自然语言,但模型在训练中见过太多类似的函数定义,理解起来没有障碍。

3.3 实现核心处理逻辑

核心逻辑放在impl/main.py里。为了演示,我用一个模拟查询函数代替真实系统调用,函数的核心是两件事:输入合法性校验 + 返回标准化结果。

import datetime import json def validate_input(platform: str, order_id: str, phone_tail: str) -> dict: """输入校验,失败时返回错误信息,以便模型自行纠正。""" if platform not in ("platform_a", "platform_b"): return { "success": False, "error": { "code": "INVALID_PLATFORM", "message": f"platform参数只支持platform_a和platform_b,当前收到{platform!r},请修正后重试。", }, } if len(order_id) < 6 or not order_id.isalnum(): return { "success": False, "error": { "code": "INVALID_ORDER_ID", "message": "订单号必须是字母和数字的组合,且长度至少6位。请重新确认订单号。", }, } if len(phone_tail) != 4 or not phone_tail.isdigit(): return { "success": False, "error": { "code": "INVALID_PHONE_TAIL", "message": "phone_tail必须是4位数字字符串。", }, } return {"success": True} def query_order_status( platform: str, order_id: str, phone_tail: str, debug: bool = False, ) -> str: """技能主函数,返回JSON字符串,便于模型直接解析。""" check = validate_input(platform, order_id, phone_tail) if not check["success"]: return json.dumps(check, ensure_ascii=False) # 模拟真实查询:根据平台和订单号生成状态,可替换为HTTP或数据库调用 if platform == "platform_a": order_status = "shipped" logistics_company = "speed_express" tracking_no = "PKG" + order_id.upper() estimated_time = (datetime.date.today() + datetime.timedelta(days=3)).isoformat() else: order_status = "delivered" logistics_company = "fast_transport" tracking_no = "FT" + order_id.upper() estimated_time = datetime.date.today().isoformat() result = { "success": True, "data": { "order_status": order_status, "logistics_company": logistics_company, "tracking_no": tracking_no, "estimated_time": estimated_time, }, "error": None, } if debug: # 调试模式下额外输出时间戳,方便排查 result["debug"] = {"queried_at": datetime.datetime.now().isoformat()} return json.dumps(result, ensure_ascii=False) if __name__ == "__main__": print(query_order_status("platform_a", "A123B456", "8899", debug=True)) print(query_order_status("unknown", "A123", "8899", debug=True))

这里有一个看似微小但很重要的决定:主函数返回的是JSON字符串,而不是Python字典。原因是很多Agent运行时的"技能输出"最终要拼进上下文给模型看,字符串是一种稳定的传输格式,也可以很方便地落日志,而字典在跨语言、跨进程时容易出现序列化问题。如果你跑本地Python直调,字典更方便;但只要考虑模块化部署,统一返回字符串是更稳的做法。

3.4 本地自测:不接模型也能验证

技能开发完,第一件事不是接模型,而是本地自测。写一个tests/test_main.py,用一些典型的输入去调用函数,重点验证三件事:

  • 正常输入是否返回预期结构;
  • 非法输入是否返回可读的错误信息;
  • 错误信息是否足够模型进行纠错。
import json import main def test_success(): out = json.loads(main.query_order_status("platform_a", "A123B456", "8899")) assert out["success"] is True assert out["data"]["order_status"] in ("shipped", "delivered") def test_invalid_platform(): out = json.loads(main.query_order_status("unknown", "A123B456", "8899")) assert out["success"] is False assert "INVALID_PLATFORM" in out["error"]["code"] assert "platform_a" in out["error"]["message"] def test_short_order_id(): out = json.loads(main.query_order_status("platform_a", "A123", "8899")) assert out["success"] is False assert "INVALID_ORDER_ID" in out["error"]["code"]

自测的目的不是为了证明代码能跑,而是为了把技能的"契约稳定性"固定下来。等以后迭代升级时,只要跑一遍测试,就知道有没有破坏对模型和对业务系统的承诺。这个习惯帮我挡住过好几次改数据库字段导致技能返回结构变化的灾难。

4. 把技能挂载到智能体运行时

4.1 注册与发现机制

技能包写好了,需要让智能体运行时认识它。现在主流的Agent框架基本都支持"扫描目录——读取元信息——注册技能"这种模式。我的做法是做一个简单的注册脚本,遍历指定目录下的skill.yaml,把解析出的技能名映射到具体的执行函数。

import os import yaml import importlib.util def load_skill(skill_dir: str) -> dict: with open(os.path.join(skill_dir, "skill.yaml"), "r", encoding="utf-8") as f: meta = yaml.safe_load(f) # 约定impl/main.py下存在与技能同名的函数 spec = importlib.util.spec_from_file_location( "skill_impl", os.path.join(skill_dir, "impl", "main.py") ) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) func = getattr(module, meta["name"]) return {"meta": meta, "func": func, "dir": skill_dir}

这个注册过程相当于给Agent的"工具箱"里放了一把经过校验的螺丝刀。运行时只关注两件事:这把螺丝刀是干什么的(meta),以及怎么用它(func)。至于内部怎么实现,运行时完全不关心,这也是技能可插拔的原因。

4.2 让模型学会调用技能

模型不会自己发现技能,你需要用某种机制把技能暴露给它。目前主流做法有三种:

  • 提示词注入:把技能名和参数说明写成自然语言放进系统提示词。适合技能数少的场景,但对复杂参数描述效果一般。
  • 函数调用协议:把skill.yaml转换成目标模型支持的tools格式,模型在对话中会返回一个"调用某技能、参数为某值"的意图,然后由运行时执行并返回结果。这是目前最推荐的做法,因为它精确、结构化、支持多轮调用。
  • 微调:把技能调用示例做成训练数据让模型内化。适合有固定调用模式的场景,但迭代成本高,一般团队不建议一上来就做。

函数调用协议下,skill.yaml中的参数Schema需要转成类似这样的结构:

[ { "type": "function", "function": { "name": "query_order_status", "description": "查询订单物流状态。用户问包裹到哪了、预计什么时候送达时使用。", "parameters": { "type": "object", "properties": { "platform": { "type": "string", "enum": ["platform_a", "platform_b"], "description": "平台标识,只接受枚举值。" }, "order_id": { "type": "string", "description": "订单号,6位以上字母数字组合。" }, "phone_tail": { "type": "string", "description": "手机号后四位。" } }, "required": ["platform", "order_id", "phone_tail"] } } } ]

这个转换过程看着简单,但有个坑:skill.yaml里的min_length、pattern在部分模型的工具Schema里不一定被支持。如果强行保留,可能被运行时忽略,甚至报错。我通常的做法是,为每个技能维护一份"运行时兼容Schema",把严格约束写进description而不是依赖结构字段,这样兼容性更好。

4.3 动态加载与热更新

业务迭代快,技能定义三天两头要改。如果每次改完都要重启整个Agent服务,线上请求就会断。我后来实现了一个简单的热更新机制:运行时持续监听技能目录的修改时间,发现某个skill.yaml或impl/main.py有变化,就重新执行加载逻辑,替换内存中的函数句柄。

热更新需要注意三点:

  • 先检查新技能模块能否正常导入,失败则回滚到旧版本;
  • 在替换函数句柄时,加一个读写锁,避免正在执行中的请求拿到旧函数后又被释放掉;
  • 建议给技能元信息加一个version字段,热更新后把新版本号打印到日志里,方便追溯。

这个机制帮我免去了很多半夜爬起来重启服务的经历,但前提是技能本身尽量做到无状态,否则热更新期间残留的进程状态会很难清理。

5. 调试技巧与血泪经验

5.1 参数不匹配:模型幻觉输出非法值

最常见的故障是模型自作聪明地把参数值改写成了同义词。比如我明明定义enum: [platform_a, platform_b],模型却可能输出平台A或platform a。原因在于某些模型对枚举约束的遵循度并不高,特别是在参数描述不够直白的时候。

我的解决办法是三层防线:

  • 在技能描述里写得特别死:"platform参数只接受字符串platform_a或platform_b,不要传中文名或空格,不要加引号。"
  • 在代码里做归一化处理,将常见的同义词映射到合法值,例如平台A -> platform_a、平台A -> platform_a、platform a -> platform_a。
  • 如果仍然非法,则返回特定错误码INVALID_PLATFORM,并且错误信息里包含当前收到的值,引导模型自我纠正后再重试。

这套组合拳实测下来,无效参数的比例至少下降了七成。

5.2 返回体过大:撑爆上下文

有一次我让一个"订单列表查询"技能直接返回全量订单数据,结果用户只问了一句"我上个月买了几单",模型却拿到了整整200条订单记录。这些记录塞进上下文后,不仅拖慢了后续响应,还导致模型遗漏了关键信息,开始胡编乱造。

后来我给大结果类技能定了一条规矩:默认只返回摘要,不返回明细。摘要包括总数、总金额、最近几条记录的前几条,以及一个提示字段"如需查看第2页,请调用查询订单分页技能"。这样既控制了上下文的占用,又保留了继续深挖的路径。

如果业务上确实需要一次返回大量数据,那就不要把原始数据直接给模型,而是先把数据写入临时存储,然后只给模型返回一个"任务ID",由另一个技能负责按ID拉取数据。这个"数据中转站"模式在处理大批量导出时特别好用。

5.3 异步长任务:别让Agent干等

有些技能的执行时间很长,比如生成一个销售报表、跑一个数据清洗任务,动辄几十秒。模型调用技能时通常有一个响应超时阈值,超过十秒就可能判定调用失败。如果让Agent一直阻塞等到结果,整个对话就会被卡死。

我采用的做法是"即收即走"模式:技能收到请求后,先把任务推入后台队列,立刻返回一条"任务已受理,任务编号为xxx,预计需要2分钟,你可以稍后询问我任务完成情况"。同时,我额外提供另一个技能query_task_status,模型可以主动轮询任务结果。

这种模式在真实业务里几乎必不可少,因为大模型对话往往要求快速反馈,不能忍受长时间的静默等待。用户那边也不会觉得奇怪,反而觉得"这个助手很聪明,知道把耗时任务挂后台"。

5.4 权限与安全边界

技能一旦能调用真实系统,就会涉及权限问题。我之前盲目地让技能主函数直接复用业务系统的一个连接池,结果测试时一个误操作差点把测试库的表改了。教训就是:技能运行时必须是一个最小权限的沙箱。

具体做法分几层:

  • 数据库账号只给只读权限,技能执行进程使用独立系统账号运行;
  • 对技能输入做严格类型和范围校验,禁止任何参数拼接进SQL或命令;
  • 敏感字段在技能返回前做脱敏,比如手机尾号之外的中间四位打上星号;
  • 有条件的团队可以把技能跑在容器或子进程中,限制网络访问和文件系统挂载。

安全方面最容易被忽视的是"技能输出中的隐私泄露"。很多业务系统返回的数据里混着用户全名、地址等敏感信息,而这些信息模型并不一定需要。在技能输出层做一次字段白名单过滤,比在提示词里说"不要泄露隐私"要可靠得多。

5.5 常见问题速查表

这里整理了一份我实际使用过程中遇到的问题清单,可以直接当排查手册用。

现象可能原因解决办法
模型一直不调用技能技能描述不够具体,或触发条件没写清在描述里加上"当用户提到XX关键词时使用"
模型调用技能但参数残缺required字段不完整或描述里示例过少每个参数给一个example,并注明必填
返回结果模型解析不出来输出不是合法JSON或字段名不稳定统一返回JSON字符串,字段名全局固定
技能执行时间太长,模型报超时同步阻塞调用改为异步任务+结果查询技能
热更新后调用了老函数没有加读写锁,替换时机不对处理新请求前加锁,替换完成再放行
输出数据量太大,上下文被撑爆没有做摘要限制技能默认只返回摘要,明细用分页技能读取

排查思路一般按顺序走:先看技能描述是否写清楚,再看参数Schema是否合法,再看返回结果是否满足模型上下文需求,最后看执行环境是否有权限或网络问题。百分之八十的问题出在前两步,所以技能定义文件值得反复打磨。

6. 技能生态与后续扩展思路

6.1 成立内部技能集市

随着技能数量从几个膨胀到几十个,你会发现"复用"比"实现"更难。每个人都想自己写一套订单查询,导致大量功能重复,而且风格不统一。比较务实的做法是搞一个内部技能集市,相当于给团队做了一个"工具的应用商店"。

集市里每个技能包按命名空间管理,比如订单域/查询状态、售后域/提交退款。每个技能包需要维护版本号、维护人、依赖清单、调用示例、性能指标和最近更新时间。上新一个技能之前,必须有至少两个用例通过自测,并且代码走查记录挂在页面上。听起来很繁琐,但一旦形成习惯,后续的联调和排障能省很多时间。

初期不需要做得很重,一个Git仓库加一份目录索引就能跑起来。等到人员变多、跨团队协作变多,再去考虑Web管理台和自动化评分系统。

6.2 从单体技能到技能编排

单个技能解决单点问题,真实的业务往往需要多个技能协作。比如"客户申请退款"这个场景,至少需要订单查询、可退金额计算、风控校验、退款单创建四个技能。这时候就需要编排层。

我是用类似DAG的描述文件来定义工作流的,每个节点指向一个技能名称和入参映射,连线定义执行顺序。比如先执行订单查询,然后把订单状态和金额传给风控校验,校验过了再创建退款单。编排器负责把前一个技能的输出转成后一个技能的输入,同时处理出错重试和并行分支。

在编排中有一个容易被忽略的细节:错误回退。假如风控校验失败,这时候不应该直接结束,而是应该调用另一个技能"退款风险提示"告知用户补充材料。能不能把这种兜底逻辑设计进去,直接决定了工作流在真实场景里的鲁棒性。

6.3 多模型与技能路由适配

不同大模型对工具调用的原生格式差异很大,有的支持函数调用,有的只支持启发式工具描述,还有的微调模型完全是自己的一套协议。如果我们把所有技能都绑定某一个模型,将来想换模型就像推倒重来。

所以我在设计技能定义时,刻意让skill.yaml保持模型无关,纯用人类可读的通用Schema描述。到了真正接入某个运行时,再通过一个适配层把通用Schema转换成对应模型需要的格式。适配层维护一套映射规则,比如枚举字段在不同模型中如何处理、pattern字段是否需要从描述中去掉等。

目前这个适配层我还在持续打磨,但方向很明确:让技能资产沉淀下来,不让它绑定任何具体模型。未来来了更强的新模型,只要适配层跟得上,技能库依然能发挥价值。

做技能模块这段时间,最大的体会是:不要低估定义文件的重要性。很多人觉得"技能不就是一个函数吗",把精力全花在写功能上,结果后来反复改描述、改参数、改返回结构,反而更累。先把契约定清楚,把边界划明白,后面的事情会顺很多。

最后再分享一个实用习惯:每次迭代完技能,我会把模型真实调用的日志保存下来,隔一段时间回看一遍。你会发现很多失败案例非常相似,比如某类参数总被模型写错,或者某个描述永远触发不了。针对这些重复性问题去修改技能描述,比盲目调模型参数要有效得多。如果现在的你还不知道从哪个技能开始改造,就从那个调用频率最高、报错最多的技能入手,收益一定最大。

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

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

立即咨询