agent-skills:把智能体从“会聊”变成“会干”的核心设计
1. 为什么我强烈建议把技能单独抽出来设计
最近在做一个偏向生产落地的智能体项目,团队内部的架构经过了三次比较大的调整。最初大家习惯把所有逻辑都堆在提示词和主流程代码里,但很快发现一个问题——模型很聪明,但干活不稳定。同样是让智能体去整理一份数据文件,今天能调通接口,明天可能就报路径错误;换一个场景,明明步骤差不多,结果却完全不可用。后来我们把所有“动作类”的能力单独拆成一套独立的模块,统一叫agent-skills,整个系统的稳定性和可维护性立刻上了一个台阶。
先说清楚这个概念。agent-skills和通常说的“函数调用(function calling)”不完全是一回事。函数调用更偏向“模型调用某个API”,是一个很薄的技术接口;而技能(skill)是一个更厚、更完整的“能力包”——它包含模型如何描述这个技能、需要哪些输入、内部执行哪些逻辑、如何处理异常、最终以什么格式返回结果。简单类比的话:函数是工具箱里的一把扳手,技能是一整套“如何用扳手把螺母拧到规定扭矩”的操作规程。
很多人会问,为什么不直接写一大段提示词让模型“自由发挥”?我在实际项目中遇到过非常典型的例子。有一次让智能体从PDF里抽取表格数据,提示词里写了各种“请仔细识别”“注意列对齐”之类的要求,但结果千奇百怪,有时把页眉页脚也抽进去,有时干脆漏掉一整列。后来把“PDF表格抽取”封装成技能,内部用专门的解析库,加上规则校验输出格式,效果立刻稳定了。这件事给我一个很深的体会:智能体的可靠性不取决于模型多聪明,而取决于有多少把事情拆解成“确定性流程”的能力。技能就是这些确定性流程的容器。
agent-skills适合谁来参考?如果你是做智能客服、自动化流程、个人助理、数据分析辅助这类应用的开发者,这篇文章应该能帮你省很多试错的成本。我会从设计思路讲到具体实现,再到一个可以照着写的例子,最后是真实的踩坑记录。全程不涉及特定框架,核心思路在大多数主流大模型平台上都能落地。
2. 整体设计与思路拆解:技能、模型与主流程的关系
2.1 一个技能到底该包含什么
我设计技能模块时,参考了社区里比较常见的规范方案,结合自己的项目做了取舍。一个完整的技能,至少包含四大部分:
- 描述声明:给模型看的“说明书”,告诉模型这个技能什么时候该用、什么时候不该用、需要什么输入。这部分质量直接决定模型能不能在正确时机选中正确技能,比想象中重要得多。
- 输入输出Schema:严格定义参数类型、必填项、返回格式。可以是JSON Schema,也可以是轻量级的注解加校验函数。让模型“按格式填参”比让模型“自由发挥参数”可靠十倍。
- 执行体:真正干活的代码。可以是Python函数、API调用脚本、命令行封装、甚至是一段可复用的Shell命令序列。唯一的要求是“输入确定,输出可预测”。
- 元信息:技能名称、版本号、作者、依赖环境、运行超时、权限要求等。元信息的作用在于支撑后续的权限控制、容器隔离、技能路由等功能。
开发过程中,团队的共识是:描述声明和输入输出Schema要优先于执行体来设计。很多人一上来就写函数,写完才补描述,结果模型经常在调用时传错参数。我们把顺序反过来——先想清楚“作为人类,你拿到任务后需要问哪几个关键问题”,这些问题就是输入Schema;再把回答和解决步骤写成描述,最后才动手实现执行体。
2.2 模型、技能和主流程的三层解耦
架构上我倾向于把系统拆成三层:
- 模型层:负责语言理解、意图判断、技能选择。这一层只做“决策”,不去执行具体动作。
- 技能层:由若干独立技能组成,对外暴露统一的接口。每个技能内部可以是一个函数、一次API调用、一个短工作流。
- 编排层(主流程):负责上下文维护、技能调用结果的解释、多技能组合、最终答复生成。
这个分层最大的好处是让每一层可以独立演进。当我发现某个技能的准确率不够,我只需要替换技能层的实现,模型层和编排层完全不用动。反过来,当有更强的模型版本发布,我只需要升级模型层,技能层依然是那些稳定的“手和脚”。
从单体智能体到技能化设计,实际上是一次思维模式的转变。传统做法让模型“用工具”,本质上是模型兜底一切执行细节;技能化做法则是“工具做标准动作,模型做选择与串联”。前者适合原型验证和简单场景,后者适合对稳定性、权限、可维护性有要求的生产环境。我见过不少项目一开始用函数调用跑得好好的,到后面技能数量超过几十个,命名空间一冲突、参数一混乱,整个系统就变成一团乱麻。尽早拆分技能,越往后收益越大。
2.3 为什么技能要按“最小可操作单元”划分
关于技能的粒度,我的经验是:一个技能最好只做一件“模型视角下不可再拆”的事。什么叫不可再拆?比如“读取文件内容”是一个技能;“读取文件内容,清洗数据并生成报表”就不应该是一个技能,而是一个技能组合。
按最小可操作单元划分,有几个实际好处。第一,组合灵活。数据清洗、特征提取、格式转换分别独立,业务上不同任务可以复用不同的排列组合。第二,调试简单。出错时能快速定位到具体某个环节,而不是在一个几百行的“超级技能”里大海捞针。第三,模型选择准确率更高。小技能的描述更聚焦,语义空间更小,模型在各个技能之间做推理时不容易混淆。
当然,粒度也不能无限拆。如果技能数量超过模型在一次对话中能够稳定感知的范围,就必须加一个技能索引和路由层。我目前的做法是把技能划分为多个领域(比如文本处理、文件操作、数据分析、网络请求),每个技能带领域标签,模型先选领域再选技能,这样即使技能数量过百,选择准确率也能保持在较高水平。
3. 核心细节解析与实操要点
3.1 写好技能描述:这是模型能否正确选对技能的关键
技能描述是很多人最容易忽视的高杠杆环节。描述写得模糊,模型就会在相近技能之间摇摆。以“PDF抽取”和“Word转PDF”这两个技能为例,如果两个技能的描述里都出现“文档处理”这个词,模型很容易搞混。我在项目中总结出一套描述模板:
- 第一句:做什么,必须极其直白。比如“把PDF文件中的表格区域抽取为CSV数据”。
- 第二句:使用场景,列举两三个典型任务描述。比如“当用户说‘把这个PDF里的表导出来’”。
- 第三句:不使用场景,明确指出边界。比如“如果PDF是扫描件,请提示用户该技能不支持OCR”。
- 第四句:输入参数的说明,尤其是格式和单位。
这个模板看起来朴素,但落地效果非常好。团队里刚接手的新人也能够根据描述判断一个技能的使用边界。描述写成后,我会专门拿一批历史对话做回归测试,看模型选择技能的准确率变化。准确率低于预期时,最先检查的永远是描述,不是代码。
3.2 输入输出Schema的边界约束与默认值策略
输入输出Schema的核心作用是把“模型的自由发挥”限制在“可控的范围”内。JSON Schema是目前最通用的做法,每个技能定义parameters(输入约束)和response(输出约束)。实际工程里我有几个值得分享的原则:
- 必填字段要少而准。能让模型少猜的,就尽量设计成可自动获取。比如文件操作类技能,输入一般只需要文件标识符(路径或ID),其他信息能从环境读取的就不让模型传。
- 枚举值优于自由文本。比如操作类型字段,定义成
["extract", "convert", "merge"]这种枚举,而不是让模型自己写一个动词字符串。枚举值能大幅降低非法输入概率。 - 设置合理的默认值。比如“保留原始文件名”这类逻辑,默认值是true,模型没传参数时也能安全执行。默认值的选择要以“失败成本最小”为原则。
- 输出格式尽量避免自由文本。能用JSON就绝不用文本拼接,因为后面编排层还要解析结果,自由格式会极大增加下游复杂度。
3.3 技能内部的状态隔离与异常处理
技能在执行时会涉及文件缓存、临时变量、环境配置等状态。我踩过最大的一个坑是技能之间的状态污染。当时两个技能共用一个临时文件目录,结果第二个技能跑完后把第一个技能的结果覆盖了,排查了好几个小时才定位。后来我们定了三条硬性规则:
- 每个技能独立临时目录:执行前创建新目录,执行后清理。目录名必须包含技能名和会话ID。
- 不修改全局配置:如果技能需要依赖环境变量,统一在进入技能前设置,退出后恢复。实在不行就放到子进程里跑。
- 内部异常必须转成结构化的错误码:比如
FILE_NOT_FOUND、PARSE_FAILED、TIMEOUT,而不是只抛一个带堆栈的原始异常。这样模型拿到错误码后才知道该怎么向用户解释或者换用其他技能。
错误码设计对模型的“自愈能力”有直接的促进作用。当技能返回TIMEOUT,模型可以告知用户重试;当返回NEED_OCR,模型就知道要转到一个OCR识别技能。如果没有这种结构化反馈,模型只能死记硬背一些异常信息,可用性会大打折扣。
4. 实操过程与核心环节实现
4.1 从零实现一个简单的 agent-skills 框架
我不会去堆砌源码,而是给你一个可以照着搭的最小骨架,语言用的是Python,核心数据结构是 Pydantic 模型。先定义一个技能基类:
from abc import ABC, abstractmethod from typing import Any, Dict, Optional from pydantic import BaseModel, Field class SkillMeta(BaseModel): name: str # 技能唯一名称,如 "csv_clean" version: str = "1.0.0" description: str # 给模型看的描述 input_schema: Dict[str, Any] # JSON Schema timeout: float = 30.0 env_required: list[str] = [] # 需要的环境变量列表 class BaseSkill(ABC): meta: SkillMeta @abstractmethod def execute(self, params: Dict[str, Any], context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """ params 是按 input_schema 校验后的参数 返回值建议统一为 {"success": bool, "data": Any, "error": Optional[str]} """ def run(self, params, context=None) -> Dict[str, Any]: # 统一入口:参数校验、执行、异常捕获、日志记录 return self.execute(params, context)这个基类最大的作用是统一了所有技能的执行入口。后面每加一个新技能,只需要继承BaseSkill,填写meta,实现execute即可。run方法里我会额外加超时控制、运行日志和错误码转换,这个不展开,但建议你不要省。
4.2 示例技能1:CSV数据清洗
这个技能解决的问题:用户给一个CSV文件路径,要求去除空行、去除重复行、修正明显异常的字段,并输出清洗后的文件路径。这是个非常典型的“脏活”,让模型直接写代码容易出问题,封装成技能最合适。
import pandas as pd from pathlib import Path class CSVCleanSkill(BaseSkill): meta = SkillMeta( name="csv_clean", version="1.0.0", description=( "对CSV文件进行基础数据清洗,包括:去除完全重复的行、去除所有字段均为空的行、" "去除字段值首尾空白字符。当用户提到CSV脏数据、重复行、空行、清洗数据时使用。" "如果用户需要更复杂的规则请使用其他技能。" ), input_schema={ "type": "object", "properties": { "input_path": {"type": "string", "description": "输入CSV文件路径"}, "output_path": {"type": "string", "description": "输出文件路径,缺省则自动生成"}, "drop_duplicates": {"type": "boolean", "default": True} }, "required": ["input_path"] }, timeout=60.0 ) def execute(self, params, context=None): input_path = Path(params["input_path"]) if not input_path.exists(): return {"success": False, "data": None, "error": "FILE_NOT_FOUND"} try: df = pd.read_csv(input_path, dtype=str) df = df.dropna(how="all") df = df.apply(lambda col: col.str.strip() if col.dtype == "object" else col) if params.get("drop_duplicates", True): df = df.drop_duplicates() output_path = params.get("output_path") or str(input_path.with_name(f"{input_path.stem}_cleaned.csv")) df.to_csv(output_path, index=False) return {"success": True, "data": {"output_path": output_path, "cleaned_rows": len(df)}, "error": None} except Exception as e: return {"success": False, "data": None, "error": f"PARSE_FAILED: {str(e)}"}这段代码有两个小心思。一个是用dtype=str读取,避免Pandas自动把数字列转成数值后导致前后格式不一致;另一个是drop_duplicates先用keep默认值保留第一行,实际项目中按业务需求还可以改成保留最后一行或全部丢弃。这些细节你在文档里看不到,但生产中经常决定结果对不对。
4.3 示例技能2:网页截图模块
这个技能解决的是:给一个URL和选择器,截取页面可见区域或指定元素,保存图片并返回路径。这在生成报告、竞品分析、自动化监控场景里很常见。
from playwright.sync_api import sync_playwright import uuid class ScreenshotSkill(BaseSkill): meta = SkillMeta( name="web_screenshot", version="1.1.0", description=( "对指定的网页URL进行截图,支持截取整个页面或指定CSS选择器的元素区域。" "当用户说‘截个图’、‘保存页面图片’、‘把某个模块截图发我’时使用。" "如果用户给的链接需要登录,提示该技能暂不支持登录态。" ), input_schema={ "type": "object", "properties": { "url": {"type": "string", "format": "uri"}, "selector": {"type": "string", "description": "CSS选择器,可选"}, "full_page": {"type": "boolean", "default": False}, "save_dir": {"type": "string", "default": "/tmp/screenshots"} }, "required": ["url"] }, timeout=90.0 ) def execute(self, params, context=None): save_dir = Path(params["save_dir"]) save_dir.mkdir(parents=True, exist_ok=True) filename = f"{uuid.uuid4().hex}.png" filepath = str(save_dir / filename) try: with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page(viewport={"width": 1280, "height": 800}) page.goto(params["url"], wait_until="networkidle", timeout=45000) if params.get("selector"): page.locator(params["selector"]).first.screenshot(path=filepath) elif params.get("full_page"): page.screenshot(path=filepath, full_page=True) else: page.screenshot(path=filepath) browser.close() except Exception as e: return {"success": False, "data": None, "error": f"SCREENSHOT_FAILED: {str(e)}"} return {"success": True, "data": {"file_path": filepath}, "error": None}注意这里我用了 Playwright 而不是 Selenium,原因很实际:Playwright 的启动速度和资源占用都优于 Selenium,headless模式下截图质量也更稳定。还有一点,wait_until="networkidle"并不是所有页面都适用,如果页面有长时间轮询的接口,会一直等到超时。后来我把策略改成了尝试networkidle,失败后回退到domcontentloaded,这个兼容逻辑在实际项目里非常有用。
4.4 技能注册与路由实现
技能写好后,需要一个注册中心来统一管理。设计上我用了字典加协议,不使用过重的插件框架。
class SkillRegistry: def __init__(self): self._skills = {} def register(self, skill: BaseSkill): self._skills[skill.meta.name] = skill def get(self, name: str) -> Optional[BaseSkill]: return self._skills.get(name) def descriptions(self) -> list[str]: # 返回给模型看的技能列表,格式统一 return [ f"{s.meta.name} v{s.meta.version}: {s.meta.description}" for s in self._skills.values() ] registry = SkillRegistry() registry.register(CSVCleanSkill()) registry.register(ScreenshotSkill())调用侧很简单,模型选择了技能名和参数后,编排层执行skill.run(params)。还有一个比较实用的设计:注册中心支持别名映射,比如用户说“清理表格”,我们把它映射到csv_clean技能,避免模型总记不住精确名字。这个别名表同样由人工维护,不需要模型自己推断。
4.5 调用链路的组装与测试
为了让技能真正能跑通,我给出一个最小可运行的调用链路示例:
def handle_turn(user_query, registry, llm_func): # 1. 构造系统消息:展示可用技能 system_msg = "你是一个任务执行助手,根据用户需求选择合适的技能。可选技能:\n" + "\n".join(registry.descriptions()) messages = [{"role": "system", "content": system_msg}, {"role": "user", "content": user_query}] # 2. 请求模型返回结构化动作 raw = llm_func(messages, response_format={"type": "json_object"}) action = json.loads(raw) # 3. 根据动作执行技能 skill = registry.get(action.get("skill")) if skill is None: return {"reply": "抱歉,当前没有找到合适的功能处理你的请求。"} result = skill.run(action.get("params", {})) # 4. 根据执行结果构造最终回复 if result["success"]: return {"reply": f"任务完成了,结果是:{result['data']}"} else: return {"reply": f"执行失败,原因:{result['error']}"}测试这个链路时,不建议一开始就拿真实对话跑。我的顺序是:先用单元测试框架直接调用单个技能,验证参数边界;再用一组人工构造的对话样例,模拟模型选择技能和参数的情况;最后小流量上线,通过日志观察真实调用成功率和失败原因分类。每一层都通过以后,才把新技能纳入主流程。
5. 常见问题与排查技巧实录
5.1 模型总是选错技能,该怎么办
这是上线后最常遇见的问题,通常有三类原因。第一类:技能描述相似度过高,两个技能的边界不清。解决办法不是改代码,而是重构描述,把各自适用的任务场景写得更具体,并主动加“什么时候不要用”的句子。第二类:技能数量过多,模型在长列表里出现注意力衰减。解决办法是加领域路由,先让模型选领域,再在领域内选技能,每个层级的候选都不超过十个。第三类:模型能力本身偏弱。这种情况下,我会把“选错技能”作为一个反馈信号纳入系统,当发现某个请求连续几次选到无关技能,就对该技能描述做A/B对比测试。
5.2 技能执行成功但结果质量差
有一个阶段,技能执行总是返回success: true,但用户明确说结果不对。细查后发现,清洗CSV时,Pandas默认把某些数字列读成了整数,导致类似00123这种客户编号被截断成123。这种问题如果没在技能层面处理,模型再怎么换上下文也没用。
解决思路是:由执行体内部去处理数据类型不明确的问题,而不是依赖模型提示词来纠正。给每一个技能定义“输出质量校验器”,比如CSV技能必须检查输出文件的行数是否为0、列数是否与输入一致、是否出现截断特征等。校验不过,技能直接返回INVALID_OUTPUT错误码,而不是把坏结果交给模型。
5.3 技能间并行调用导致的资源竞争
当编排层开始支持并行调用多个技能时,很快遇到资源竞争问题。某个技能占用了大量内存,另一个技能因此崩溃。我推荐的方案是给每个技能打上资源等级标签,调度器统一控制并发数。例如:
| 技能 | 资源标签 | 预估耗时 | 建议并发 |
|---|---|---|---|
| csv_clean | 轻量 | 1~5秒 | 8 |
| web_screenshot | 重量 | 10~90秒 | 2 |
| pdf_extract | 中等 | 5~30秒 | 4 |
| translate | 轻量 | 2~10秒 | 6 |
有了这张表,调度器就能按权重限制同时运行的技能数量,避免重要任务被资源饥饿拖垮。这张表是我基于真实运行数据慢慢调出来的,不同机器配置差异会很大,建议你上线后先观察一段时间再定死。
5.4 模型生成参数总是类型错误
这类问题多发生在模型对JSON Schema理解不到位的时候。一个现象是数字参数被生成成字符串,比如{"rows": "10"}。我的处理是解析参数后、执行技能前,增加一个严格类型转换层,按Schema定义自动把数字字符串转数字、布尔型转为True/False。同时把转换失败的情况明确返回INVALID_PARAMS,让模型有机会重新生成参数。千万别让技能内部“尽量宽容”地去解类型,因为这样模型永远不会学会生成正确格式。
5.5 技能版本更新后,旧对话的兼容性
用户和智能体的对话往往是多轮会话,如果一个会话开始时使用的是旧版技能,中途技能更新了,同一个会话里可能就出现结果格式不一致的问题。我的做法是给会话上下文打上“技能版本快照”,即会话开始时记录当时使用的技能版本列表,后续所有调用都基于快照版本执行。这个方案复杂一点,但在长期运行的项目里能避免大量莫名其妙的不一致问题。
6. 进阶:从单技能走向技能生态
6.1 技能的组合与降级策略
单个技能是原子能力,组合技能才能解决复杂业务。比如“导出销售报表”可以是“读取数据库”+“数据透视”+“生成PDF”+“发送邮件”四个技能的组合。我倾向于用独立的编排配置来定义这种组合,而不是把组合逻辑写死在代码里。
另一个容易被忽略的是降级策略。当核心技能由于依赖环境不可用(比如数据库连接失败),组合流程不能直接宣告失败,而是应该尝试降级路径,比如先从缓存中取数据,或者提示用户“报表生成失败,但历史报表已发送”。降级逻辑的编写原则是:降级后的结果要明确标注置信度,不能让模型把不完整结果当成完全结果来答复用户。
6.2 技能的评测与回归机制
技能上线不是终点,而是评测的起点。我的习惯是建立一个“技能回归集”,人工准备50到100个典型输入,每次技能代码变动或模型升级时,跑一遍回归集,统计成功率、失败原因分布、平均用时时长。这个回归集的价值会随时间增加,它能阻止很多“改一行代码导致另一个技能挂掉”的回归问题。
回归集里的案例来源于生产日志,我会定期把真实用户请求做去隐私处理后加入集子中,保证覆盖的新场景越来越多。这里特别提醒:凡是记录用户数据的,必须先做严格的脱敏处理,不要碰任何带身份标识的信息。
6.3 跨框架的可移植性
如果有一天要更换底层模型或框架,agent-skills的设计能不能平滑迁移?我在这方面的经验是:保证技能层与模型层完全隔离。技能内部不引用任何模型API,连提示词都不出现在技能代码里。技能只需要暴露“可被回调的接口”,无论上层是哪个模型、哪个思维链框架,都能直接调用。
这一点在项目后期特别值钱。我们曾经从一套自研的循环调用方案迁移到一个更新的开源编排框架,技能层基本零改动,只重写了模型层的接口适配。这种可移植性也意味着技能资产可以复用到相似项目的不同产品中,长期积累下去,技能库本身就成了一笔可复用的核心资产。
7. 一些实际操作的心得
回想整个开发过程,有几个体会想分享给正在做智能体开发的同行。
第一,从第一天就开始给技能写回归测试。哪怕是两三行数据的样例也好,至少能保证你不至于在技能数量变多后不敢改任何代码。没有回归测试的技能库,到了一定规模就是定时炸弹。
第二,技能的“描述文档”要纳入代码评审。团队里很多人把注意力放在函数实现上,觉得描述无所谓。其实描述质量直接决定了智能体决策层的准确率。我见过一个技能,函数写得非常漂亮,但描述里全是含糊的行业黑话,模型几乎废弃了这个技能,白白浪费了开发成本。
第三,不要太早引入复杂的编排框架。先用最朴素的原型把三个技能跑通整个链路,理解清楚“模型选技能、技能执行、结果回填”的最小闭环。等积累到十来个技能、遇到真实的并发和冲突问题之后,再引入框架会从容得多。一上来就搭框架,很容易被抽象的底层设计拖住,迟迟见不到可见的效果。
第四次踩坑之后,我真的把这句话刻在团队的白板上:智能体不是模型一个人单打独斗,而是模型和一大批稳定技能的分工协作。模型的泛化能力负责处理未知,技能的可控性负责守住底线。两者配合好,这个系统才真正谈得上“能用”和“好用”。
最后再分享一个小技巧。每个技能里留一个dry_run参数,设置为true时技能只做参数校验和依赖检查,不真正执行动作。这个参数在开发和排障时非常有用——当用户反馈“这个功能好像坏了”的时候,你先让技能跑一遍dry_run,能很快判断问题是出在模型选参环节还是技能本身的执行环境。这个看似微不足道的设计,在线上救了我无数次。