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, contextNone) - Dict[str, Any]: # 统一入口参数校验、执行、异常捕获、日志记录 return self.execute(params, context)这个基类最大的作用是统一了所有技能的执行入口。后面每加一个新技能只需要继承BaseSkill填写meta实现execute即可。run方法里我会额外加超时控制、运行日志和错误码转换这个不展开但建议你不要省。4.2 示例技能1CSV数据清洗这个技能解决的问题用户给一个CSV文件路径要求去除空行、去除重复行、修正明显异常的字段并输出清洗后的文件路径。这是个非常典型的“脏活”让模型直接写代码容易出问题封装成技能最合适。import pandas as pd from pathlib import Path class CSVCleanSkill(BaseSkill): meta SkillMeta( namecsv_clean, version1.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] }, timeout60.0 ) def execute(self, params, contextNone): 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, dtypestr) df df.dropna(howall) 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, indexFalse) return {success: True, data: {output_path: output_path, cleaned_rows: len(df)}, error: None} except Exception as e: return {success: False, data: None, error: fPARSE_FAILED: {str(e)}}这段代码有两个小心思。一个是用dtypestr读取避免Pandas自动把数字列转成数值后导致前后格式不一致另一个是drop_duplicates先用keep默认值保留第一行实际项目中按业务需求还可以改成保留最后一行或全部丢弃。这些细节你在文档里看不到但生产中经常决定结果对不对。4.3 示例技能2网页截图模块这个技能解决的是给一个URL和选择器截取页面可见区域或指定元素保存图片并返回路径。这在生成报告、竞品分析、自动化监控场景里很常见。from playwright.sync_api import sync_playwright import uuid class ScreenshotSkill(BaseSkill): meta SkillMeta( nameweb_screenshot, version1.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] }, timeout90.0 ) def execute(self, params, contextNone): save_dir Path(params[save_dir]) save_dir.mkdir(parentsTrue, exist_okTrue) filename f{uuid.uuid4().hex}.png filepath str(save_dir / filename) try: with sync_playwright() as p: browser p.chromium.launch(headlessTrue) page browser.new_page(viewport{width: 1280, height: 800}) page.goto(params[url], wait_untilnetworkidle, timeout45000) if params.get(selector): page.locator(params[selector]).first.screenshot(pathfilepath) elif params.get(full_page): page.screenshot(pathfilepath, full_pageTrue) else: page.screenshot(pathfilepath) browser.close() except Exception as e: return {success: False, data: None, error: fSCREENSHOT_FAILED: {str(e)}} return {success: True, data: {file_path: filepath}, error: None}注意这里我用了 Playwright 而不是 Selenium原因很实际Playwright 的启动速度和资源占用都优于 Seleniumheadless模式下截图质量也更稳定。还有一点wait_untilnetworkidle并不是所有页面都适用如果页面有长时间轮询的接口会一直等到超时。后来我把策略改成了尝试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秒8web_screenshot重量10~90秒2pdf_extract中等5~30秒4translate轻量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能很快判断问题是出在模型选参环节还是技能本身的执行环境。这个看似微不足道的设计在线上救了我无数次。