最近接了个活儿要把一套“只会聊天”的对话系统升级成真正能帮忙干活的智能体。结果我发现朋友圈里聊得火热的那个“agent-skills”项目正好就是解决这类问题的关键拼图。这半年我在实际落地项目里反复折腾这套东西把“技能”拆出来独立管理再挂到主模型旁边整个系统的稳定性、可维护性和任务完成率都有了质的提升。这篇文章就把我整套思路和代码逐段拆开讲清楚从架构设计到踩坑实录都有适合正在做AI应用开发、或者想把Agent从“能聊”变成“能干活”的工程师参考。1. 为什么要把技能独立出来背景与核心思路1.1 大模型的短板会规划但不可靠先聊个让很多人头疼的场景。你让大模型“帮我把这个文件夹里的图片压缩一下然后发到指定接口”模型嘴上答应得挺好实际上让你去安装依赖、手写压缩脚本、再处理鉴权参数整个链路全靠它即兴发挥。可问题是大模型对真实操作系统的理解是概率性的同一个任务今天能执行成功明天换个环境就翻车。这不是大模型的错。预训练模型擅长的是“语言层面的规划”而不是“物理世界的精确执行”。就好比一个熟读菜谱但从来没下过厨的人他能把“把油烧热下葱姜蒜爆香”背得滚瓜烂熟但真让他颠勺火候、油温、下锅时机全是拍脑袋。这就是我把技能从对话流程里拆出来的核心原因让模型只负责“决定做什么”把“怎么做”的细节交给经过验证的固定代码模块。1.2 技能与工具、插件、工作流的边界“agent-skills”这个叫法现在有点泛滥很多人把工具调用、函数调用、插件机制、工作流引擎全都叫成“技能”。如果要把这套东西做成能复用的基础设施我建议先把概念边界划清楚工具调用通常指单个API或函数的暴露粗糙但直接比如“查询天气”“发送邮件”。它的特点是粒度小、无状态是技能的基本原材料。技能由多个工具调用组成的、面向完整目标的行为序列并且自带规则校验、状态管理和错误恢复逻辑。比如“完成图片批处理”这个技能需要扫描文件、调用压缩工具、检查输出、上传结果每一步都有边界条件和回退方案。插件偏产品形态是给用户安装卸载的独立功能包可能包含技能、配置、UI入口。工作流偏流程编排通常是人工预定义好的固定图技能则是可被模型动态选择调用的。一句话总结技能 工具 ×策略 状态 异常处理。模型负责按需组合技能而不是从零生成执行代码。理解不了这个边界后面做设计时很容易把系统搞成一团浆糊。1.3 直接省掉的关键成本把技能独立成库之后我实测有三个立竿见影的好处。首先是Token消耗大幅下降因为不再需要把“如何写Python脚本、如何调用某个包”这些知识灌进上下文只需要把技能清单和描述喂给模型输出路径短得惊人。其次是错误率显著降低本来模型即兴写的代码平均跑五次挂三次现在所有操作都用验证过的原子模块稳定性能控制在95%以上。第三点是延迟可控每个技能的执行函数都是纯Python跑出来的固定路径不像让模型在循环里反复自我修正动不动就几十秒。2. 技能系统的架构设计与技术选型2.1 分层架构主模型、调度器、技能库我的设计方案分四层结构清晰每一层干自己的活。最上层是会话层负责接收用户意图和维护上下文。第二层是主模型层负责意图识别和技能选择它不执行具体操作只输出“该调用哪个技能、传入什么参数”。第三层是技能调度层这是整个系统的核心接收主模型的决策查找技能注册表校验入参执行技能函数并把结果整理反馈给主模型。最底层是技能执行层也就是独立的技能函数集合。这个分层最重要的设计约束是每一层都不能越权。主模型永远拿不到裸的执行权限它只能“提名”技能和参数调度器才是决定“能不能执行”的法官负责做白名单校验、参数合法性检查和沙箱限制。这样的好处是即使模型被诱导输出了恶意的参数调度器也能拦下来不会直接造成破坏。2.2 技能注册机制一个技能一份元数据技能不是随便丢个函数进去就完事。每个技能都必须附带一份结构化元数据用JSON描述它的功能边界、参数类型、返回值格式、超时阈值和权限等级。我直接贴一份我项目里的真实定义{ id: skill_image_batch_process, name: 图像批量压缩与上传, version: 2.3.0, description: 对指定目录下的图片进行压缩处理并可选上传到对象存储。适用于用户提供本地路径或URL列表的批量图片处理场景。, params_schema: { type: object, properties: { source_path: {type: string, description: 图片所在目录或压缩包路径}, target_format: {type: string, enum: [webp, jpeg], default: webp}, quality: {type: integer, minimum: 1, maximum: 100, default: 80}, upload: {type: boolean, default: false} }, required: [source_path] }, timeout_ms: 120000, permission_level: 2, runbook: 先遍历目录收集图片文件按目标格式转码完成后将结果清单写入返回字段。 }这套元数据信息量很大尤其要给模型看的description和runbook字段必须写得让模型一眼就能判断出“这个技能管不管用户这件事”。我踩过的坑是早期描述写得太模糊比如“处理图片”结果模型在用户问“能否转成PDF”时也调用了它后来把所有技能描述统一改成“触发条件能力范围典型示例”误选率直接下降了一半。2.3 调度与选择策略不靠感觉靠规则调度器在选择技能时不能只把候选列表一股脑丢给模型。要在注册表里做两轮筛选。第一轮是硬性维度检查技能ID存在、参数Schema校验通过、权限等级不超过当前会话等级、技能未处于熔断状态。第二轮是相关性排序基于用户输入与技能description的语义向量相似度设定一个阈值低于阈值的直接过滤掉再把排序前三的技能连同一段精简的说明发给主模型做最终选择。这么做的好处是能极大减少模型胡乱调用。我做过统计直接给模型二十个技能描述让它选准确率大约72%先过滤成两到三个候选再让模型选准确率能到94%。多出来的这一层过滤用不了多少算力但对整体稳定性的贡献是决定性的。2.4 执行与反馈闭环可观测性设计技能执行过程必须做到全程可追踪。我给每个技能执行都分配了一个全局trace_id从入参、每步改写、错误恢复、最终返回值全部落日志。这样用户说“这个功能坏了”的时候不用靠猜直接从日志里拉出该技能的执行链路看到底是参数传歪了还是后端服务超时。还有一点容易被忽视技能执行结果反馈给主模型时必须做一次“信息压缩”。比如一个技能返回了200行日志你不能全塞进上下文那样既浪费Token又干扰模型判断。我的做法是让技能函数自带一个summarize方法只把结构化精华文件个数、成功数、失败数、产物路径返回给模型要不要看细节再做二次查询。这设计一上长链路任务的上下文长度直接砍了六成。3. 实操过程从零搭建一套可用技能库3.1 环境准备与目录规划我的实践环境是Python 3.11 FastAPI技能加载用importlib动态扫描机制。先说目录结构agent-skills/ core/ __init__.py registry.py # 技能注册中心 scheduler.py # 调度器核心逻辑 models.py # 数据模型定义 exceptions.py # 技能异常体系 skills/ builtin/ file_ops.py # 文件操作类技能 network_ops.py # 网络请求类技能 data_proc.py # 数据处理类技能 custom/ # 按业务域新增的技能模块 config/ skills.yaml # 技能白名单与全局配置 tests/ test_registry.py test_scheduler.py这样的好处是把“通用能力”和“业务定制”彻底分开。builtin下的技能是纯技术性的不依赖任何业务逻辑custom下的技能是给具体场景用的比如对接内部某个订单系统。初始化时只加载skills.yaml里白名单声明的技能目录避免自动扫描把废弃代码也加载进来。因为某些老技能依赖的包可能已经升级冷不丁扫进新进程里跑起来就是一场灾难。3.2 核心调度器实现调度器是整个系统的CPU和大脑它的主循环逻辑分四步解析模型输出 - 校验技能 - 执行 - 反馈。核心代码我简化后贴在这里import asyncio import time from core.registry import SkillRegistry from core.exceptions import SkillNotExistError, ParamValidationError class Scheduler: def __init__(self, registry: SkillRegistry): self.registry registry self.fuse_state {} # 技能熔断状态表 async def invoke_skill(self, skill_id: str, params: dict, session_context: dict): # 第一关是否存在 if skill_id not in self.registry.skills: raise SkillNotExistError(skill_id) skill self.registry.skills[skill_id] # 第二关权限与熔断检查 if skill.metadata.permission_level session_context.get(max_permission, 0): raise PermissionError(当前会话权限不足) if self.fuse_state.get(skill_id, {}).get(open, False): raise RuntimeError(技能暂时不可用请稍后重试) # 第三关参数校验 validated skill.validate_params(params) if validated is None: raise ParamValidationError(f参数校验失败: {params}) # 第四关执行 超时控制 start time.time() try: result await asyncio.wait_for( skill.execute_with_trace(validated, session_context), timeoutskill.metadata.timeout_ms / 1000 ) self._record_success(skill_id) return result.summarize() except asyncio.TimeoutError: self._record_failure(skill_id) raise TimeoutError(f技能 {skill_id} 执行超时) except Exception as e: self._record_failure(skill_id) raise RuntimeError(f执行失败: {e})有几个值得细说的地方。第一个是session_context参数它携带当前会话的权限级别、用户偏好、环境变量等技能执行时能根据这些内容做动态调整比如用户身处海外时网络类技能自动切换通道。第二个是熔断状态我用简单的滑动窗口计数三十秒内连续失败五次就熔断十分钟防止下游服务不稳定时技能反复重试拖垮系统。3.3 技能开发规范一个技能就是一个类为了统一每个技能的行为我用基类约束了开发规范from abc import ABC, abstractmethod from pydantic import BaseModel class BaseSkill(ABC): def __init__(self, metadata, executor): self.metadata metadata self.executor executor self.trace [] abstractmethod def validate_params(self, params: dict) - dict | None: pass abstractmethod async def execute_with_trace(self, validated_params: dict, context: dict) - dict: pass def summarize(self) - dict: return { skill_id: self.metadata.id, ok: not self.failed, summary: self.get_summary_text(), trace_id: self.trace[-1] if self.trace else None } def get_summary_text(self) - str: return 默认摘要严格的继承规范有现实必要性。如果技能实现五花八门有的技能返回data: [{...}]有的返回result: ...调度器就得写一堆条件分支来适配不同格式谁也维护不了。统一了接口以后新增一个技能基本就是写业务逻辑其他框架约束全都自动具备。3.4 示例技能文件批量压缩直接拿“图像批量压缩”这个技能做例子它最能体现“技能”和“工具函数”的区别。工具函数版本可能就是一行PIL.Image.open(path).save(target, quality80)但技能版本必须处理完整生命周期import asyncio import os import hashlib from pathlib import Path from core.base_skill import BaseSkill class ImageBatchCompressSkill(BaseSkill): def validate_params(self, params): source params.get(source_path) if not source or not os.path.exists(source): return None params[_file_list_cache] None return params async def execute_with_trace(self, params, context): # 1. 扫描文件 source_path Path(params[source_path]) files [p for p in source_path.rglob(*) if p.suffix.lower() in (.png, .jpg, .jpeg, .webp)] self.trace.append({event: scan, count: len(files)}) # 2. 限制数量防止失控 max_files 500 if len(files) max_files: files files[:max_files] self.trace.append({event: truncated, original_count: len(files)}) # 3. 模拟逐文件处理 results [] for img in files: await asyncio.sleep(0.05) # 模拟压缩耗时 target img.with_suffix(f.{params.get(target_format, webp)}) results.append({ source: img.name, target: target.name, size_after: 12345 }) self.executor.push(results) return { success_count: len(results), failed_count: 0, output_dir: str(source_path), sample: results[:3] }你看这个技能它包含了扫描、数量上限保护、异步控制、结果收集。未来真要接真实压缩引擎只需要把模拟部分替换成image.convert()调用技能对外接口完全不变主模型也不需要重新学习。3.5 与主模型的集成Prompt怎么写有了技能库以后主模型的Prompt设计就清爽很多。核心思路是给模型一份“技能操作手册”而不是让它自己写代码。我在系统提示词里塞入这么一段你是系统调度规划器。用户的任务必须分解为一个或多个技能调用。 可用技能列表如下 {{skills_summary}} 选择技能时遵循以下规则 1. 优先选择描述与用户意图最匹配的技能。 2. 参数必须符合技能定义的schema不得臆造不存在的参数。 3. 如果没有合适的技能明确回答no_suitable_skill不要自行编写脚本。 4. 当存在多个技能可并行执行时一次输出所有候选调用。这里最关键的是第3条规则必须给模型说“不”的机会。早期我没有这个兜底模型在技能库里找不到匹配项时会强行“发明”技能名或者把不存在的参数拼进去调度器校验失败后整个对话流程直接崩掉。允许模型诚实地说“我不会”反而能把异常引导到更友好的“正在接入更多能力”话术上。3.6 结果反馈链路怎么写技能执行完反馈模板我固定成三段式结论先行、数据随之、异常可选。给模型看的结果大概是这个样子技能执行完毕图像批量压缩 - 结论成功压缩42张图片0张失败 - 输出目录/tmp/compressed - 耗时2.3秒 - 用户可见说明您的42张图片已全部转为webp格式体积平均缩小45%。再把这段反馈作为“用户指令执行结果”回传给主模型主模型据此整理最终回复。注意反馈中不能让模型接触底层Trace日志那类原始日志往往包含路径、内部错误码不仅干扰生成还可能泄露环境细节。4. 常见问题与排查技巧实录4.1 模型选错技能或参数幻觉我遇到最多的坑就是大模型参数幻觉。用户说“把图片压缩一下”模型选技能选对了但传了一个compression_level: 90参数而这个参数在schema里根本不存在。原因在于模型看到了“compression”这个词就脑补出了最像的参数名。解决办法除了调度器强校验做兜底还可以在技能定义里把参数别名显式声明比如增加aliases: [quality, level]调度器在解析时自动做一次参数名映射。这个改动上线后参数幻觉导致的失败率从11%降到了3%以内。4.2 技能执行卡死或超时早期我的技能里充满了同步阻塞调用一个网络IO请求能卡住整个事件循环导致其他技能也被拖累。后来强制要求所有技能内IO操作必须用async/await封装并给调度器加上了asyncio.wait_for包裹。排查这类问题也有一些硬技巧在技能代码关键时刻打Trace节点如果日志里某个节点前后间隔时间异常长那卡点基本就锁定了。另外超时阈值不能一刀切最好在元数据里按技能类型配置比如网络类设30秒本地文件处理设120秒。4.3 权限边界被绕过技能系统天然是一个安全敏感组件因为模型可能被诱导执行恶意的参数。比如用户写“帮我删除本目录下的所有文件”如果技能设计成“输入路径就执行操作”后果不敢设想。我的防护策略分三层第一层每个技能都要声明permission_level涉及删除、覆写、外发数据的操作必须提级审批第二层路径类参数必须做“沙箱根目录归一化”凡试图跳出预先设定的工作目录的路径调度器直接拒绝第三层所有高危技能的调用记录强制同步审计日志。这套三层防护虽然不能做到万无一失但能显著提升系统的抗恶意能力。4.4 技能库膨胀后的可维护性技能越加越多注册表会变得臃肿。到50个技能的时候光是把描述发给模型就已经吃掉不少上下文。我的处理办法是给技能加“域标签”domain字段比如file_ops、data_analysis、web_service调度器先根据用户意图预判所属域只加载该域下的技能列表给模型做选择。这样不仅上下文变短了连模型的选择准确率都提升了因为候选集合小且内聚度更高。每月我还会跑一次“技能使用频率统计”把三个月零调用的技能自动标记为archived状态不进候选列表。4.5 测试技能库单元测试之外做对抗测试常规单元测试只管“正常的路”能不能走通但技能系统真正考验的是“歪路”怎么挡。我现在测试集里加了三类对抗用例第一类是深渊提示词故意诱导技能做越界操作第二类是异常输入比如极长的字符串、特殊Unicode、负数参数第三类是并发压力模拟几十个会话同时调用同一个技能检验熔断和并发控制有没有失效。这类对抗测试很有价值曾经在压测中抓到一个并发计数bug单技能实例在并发超过30时计数器会乱掉直接导致熔断器误触发。5. 一点心得与扩展建议每次有人问我“agent-skills”这个方向值不值得投入精力我的回答都是如果你在做真正的Agent产品这几乎是一条必经之路。主模型永远在进化但切换到再聪明的模型直接让它裸写执行代码的本质风险也不会消失。技能库更像一个“能力的中台层”不管下面换什么模型技能作为稳定封装的执行单元始终是可复用、可审计、可测试的。做这个系统最深的体会是架构设计的重心要从“怎么让模型更聪明”转向“怎么让一个不够聪明的模型也干出不翻车的活儿”。把脏活累活固化进有边界的技能模块里把判断和选择交给模型两者配合才是Agent工程化的正解。后续如果要继续扩展我建议往两个方向走。第一技能之间的依赖编排有些任务需要先执行A技能再执行B技能中间还有数据传递这需要引入轻量的DAG式依赖描述。第二技能效果的自动评估每次技能执行完自动抽取指标存下来做回归对比这样才能真正形成“越用越稳”的闭环。这套东西我自己已经跑了半年多从最初每天十几个执行错误到现在每周偶发一起可解释的小故障稳定性提升非常明显。如果你也在做Agent类应用不妨从拆第一个技能开始试试把最大的那个痛点单独拎出来做成标准模块跑上一周对比下系统表现你多半就不会想走回头路了。