
在调过几个Agent原型项目之后我越来越确信一件事决定Agent上限的往往不是模型本身而是你给它配了哪些“技能”。agent-skills这个方向本质上就是在解决一个问题——如何把大模型从“只会聊天的大脑”变成一个“能动手干活的员工”。这篇博客我就围绕agent-skills展开讲清楚它的设计思路、具体实现、以及我在实际项目中踩过的一堆坑。1. 项目概述与设计思路1.1 为什么Agent需要“技能”而不是“提示词”先说一个很基础的观察很多人刚接触Agent时第一反应就是把所有指令都塞进System Prompt里让模型自己“临场发挥”。比如你想让Agent帮你操作文件就在提示词里写“你有读写文件的能力请根据用户需求操作”。这种做法在小demo里看着没问题但一旦场景复杂起来模型的自由发挥就会变成一场灾难。原因很简单。大模型本质上是一个概率预测器它并不真正“拥有”工具它只是从训练数据里学会了“模仿使用工具”。你让它自由调用它就会经常出现三类问题参数格式写错、调用时机太随意、出错后不知道恢复。我之前做过一个自动化报表Agent模型在一半的会话里会把日期参数写成YYYY-MM-DD另一半写成MM/DD/YYYY同一个模型、同一个提示词行为完全不稳定。agent-skills的核心思路就是把“技能”从模型的自由意志里剥离出来变成一套显式的、可注册、可校验、可复用的模块。每个技能都有明确的输入输出定义、执行逻辑、错误处理。Agent只能通过我们封装的接口调这些技能不能自由发挥。这就像你带新人你不会只跟他口述一遍流程就让他自由干活而是先给他一本SOP手册规定每一步怎么操作、输入什么、输出什么、遇到异常怎么办。1.2 技能库的整体架构选型在架构选型上我见过三类做法各有优劣。第一类是纯函数式就是把技能写成普通Python函数然后用一个字典按名字注册。优点是代码最少、上手最快适合原型验证。缺点是技能一多参数校验、权限控制、状态管理全都得自己写后期维护成本不低。第二类是基于LangChain、AutoGPT这类框架的Tool抽象。这类框架自带BaseTool基类定义好了name、description、args_schema这些字段还内嵌了校验和错误处理逻辑。优点是和框架生态打通官方文档也多。缺点是你基本被框架绑死了升级框架版本可能就得改代码而且框架封装的复杂度会掩盖你对底层机制的理解。第三类就是我这几轮项目一直在用的轻量自研方案。不依赖重型框架只用一个Python装饰器加JSON Schema把技能定义成标准模块再写一个几十行的注册和调度器。这套方案对技能本身没有侵入性逻辑透明想加权限、审计、沙箱都很方便。我的建议是如果项目确实深度依赖某个Agent框架并且确定后续不会换那可以选第二类但如果你想彻底搞懂技能机制或者项目需要高度定制那第三类轻量自研绝对值得一试。下面整个博客都是基于这个自研方案展开的。2. 核心细节解析与实操要点2.1 技能定义与元信息规范技能定义是整个agent-skills体系的地基。一个技能包含两大部分声明部分和执行部分。声明部分告诉Agent“这个技能是干什么的、需要什么参数”执行部分就是真正干活的Python函数。我把一个技能的元信息固定成五件套技能名、描述、参数Schema、执行函数、以及一组可选的标签。技能名必须是蛇形命名法比如read_file、send_email因为有下划线的名字在函数调用时不容易产生歧义。描述字段是重中之重因为模型靠它来匹配意图描述写得太泛模型就会在错误的时候调用技能写得太窄模型又该调用时不调用。我的经验是描述里至少包含“在什么场景下用”“做什么事”“有没有副作用”三个要素如果可能再给一个触发示例。参数Schema用的是JSON Schema的子集我只需要四个字段type、properties、required、description。类型只用string、number、integer、boolean、array、object这几种基本类型再复杂的嵌套结构也够用了。为什么不用强类型语言的标准库因为JSON Schema天然是描述性的可以直接传给模型做参考不需要额外写一层序列化。比如schema里写清楚哪个字段是required模型在生成参数时就会更小心。# skills/file_tool.py from typing import Any SKILL_DEF { name: read_file, description: 读取指定路径的文本文件。适用于用户要求查看代码、日志或配置内容的场景。只读操作无副作用。, input_schema: { type: object, properties: { path: {type: string, description: 文件的绝对路径或相对路径}, encoding: {type: string, description: 文件编码默认utf-8}, }, required: [path], }, tags: [file, readonly], } def execute(ctx: dict, **kwargs): path kwargs[path] encoding kwargs.get(encoding, utf-8) with open(path, r, encodingencoding) as f: return {ok: True, content: f.read(1024 * 64)}这里要注意一点execute函数里的ctx参数是我刻意加的上下文对象它承载了任务ID、用户身份、会话状态等在执行时才确定的信息。技能不应该自己维护全局状态所有状态都通过ctx传入这样技能才天然可测试、可并行。2.2 技能注册与加载机制有了技能定义接下来要解决的是“怎么让Agent知道有哪些技能可用”。最简单的办法是写一个全局注册表所有技能在import时把自己塞进去。但我在实际项目里发现这种隐式注册方式有个问题技能一多你根本搞不清哪些技能真的被加载了哪些因为import失败被静默跳过了。所以我改成了显式扫描注册规定所有技能模块必须放在skills/目录下每个文件对应一个技能。程序启动时遍历这个目录逐个导入并检查模块里是否有SKILL_DEF和execute这两个必须项。检查通过才注册不通过就打印告警并在列表里剔除。skills/ ├── __init__.py ├── file_tool.py ├── web_search.py ├── time_utils.py └── calculator.py# registrar.py import importlib.util import json from pathlib import Path def load_skills(skills_dir: Path): registry {} for module_file in skills_dir.glob(*.py): if module_file.name __init__.py: continue spec importlib.util.spec_from_file_location( module_file.stem, module_file ) module importlib.util.module_from_spec(spec) try: spec.loader.exec_module(module) except Exception as exc: print(fSkill {module_file.name} load failed: {exc}) continue if not hasattr(module, SKILL_DEF) or not hasattr(module, execute): print(fSkill {module_file.name} missing SKILL_DEF or execute) continue skill_name module.SKILL_DEF[name] registry[skill_name] module print(fRegistered skill: {skill_name}) return registry这种显式加载的好处是出错能看得见加载顺序可控还可以在加载时做依赖检查比如某个技能需要Redis启动时就可以验证连接。我后来还在加载时加了一层技能间依赖校验比如技能A的描述里声明“会调用技能B”注册器会在启动时就检查B是否存在而不是等运行到一半才报错。2.3 参数校验与错误处理技能模块的执行逻辑往往很简单难的是参数校验和错误处理。大模型生成的参数天生带着随机性你必须在执行之前把它拦住。参数校验我用的是jsonschema库把SKILL_DEF[input_schema]直接传给validate()函数。校验不通过时不要直接抛异常给上层而是整理成一条“模型能看懂的错误消息”再让Agent自己修正参数重试。举个例子模型要调用read_file但没传path校验失败后返回这样的结构化错误{ ok: false, error: 参数校验失败缺少必填字段 path, hint: 请确认要读取的文件路径后重新调用 }这个hint字段特别有用。我试过不给hint模型经常会原地想当然地把错误归因成“文件不存在”然后反复尝试无意义操作。给一句明确提示重试成功率能提升30%以上。错误处理我定了三个等级可预期错误、不可预期错误、致命错误。可预期错误比如文件不存在、网络超时技能内部捕获后recover返回清晰的错误消息。不可预期错误比如Python内部的KeyError、TypeError在调度器这层统一捕获包装成内部错误防止堆栈信息直接暴露给模型。致命错误比如内存溢出、权限拒绝则直接终止本轮技能链不让Agent继续调用其他技能避免状态被进一步搞脏。3. 实操过程与关键步骤实现3.1 搭建最小可运行的技能执行环境这一节我直接带你把环境从零搭起来。先说依赖我默认你用的是Python 3.10以上版本只需要三个库jsonschema用于参数校验PyYAML用于读配置文件fastapi和uvicorn用于把技能服务暴露成HTTP接口。如果暂时不想搭服务fastapi可以先不装直接在本地用Python脚本跑通流程也行。第一步建目录和虚拟环境。mkdir agent-skills-demo cd agent-skills-demo python -m venv .venv source .venv/bin/activate pip install jsonschema pyyaml fastapi uvicorn第二步把上面写过的registrar.py、skills/file_tool.py放进来再补一个最简单的技能time_utils.py用于获取当前时间。# skills/time_utils.py from datetime import datetime SKILL_DEF { name: get_current_time, description: 获取当前系统时间。适用于用户询问日期、时间、星期或截止时间的场景。, input_schema: { type: object, properties: { timezone: { type: string, description: 时区名称例如 Asia/Shanghai默认使用系统时区, } }, required: [], }, } def execute(ctx: dict, **kwargs): tz_name kwargs.get(timezone) if tz_name: from zoneinfo import ZoneInfo now datetime.now(ZoneInfo(tz_name)) else: now datetime.now() return {ok: True, time: now.isoformat()}第三步写一个最基础的调度器让技能可以在命令行里被调用。这个调度器做三件事加载所有技能、解析技能名和参数、校验参数并执行。# dispatcher.py import sys import json import jsonschema from pathlib import Path from registrar import load_skills def dispatch(registry: dict, skill_name: str, params: dict, ctx: dict): if skill_name not in registry: return {ok: False, error: fUnknown skill: {skill_name}} module registry[skill_name] try: jsonschema.validate(params, module.SKILL_DEF[input_schema]) except jsonschema.ValidationError as exc: return { ok: False, error: f参数校验失败: {exc.message}, hint: 请检查参数是否符合要求后重试, } try: result module.execute(ctx, **params) return result except Exception as exc: return {ok: False, error: f技能执行异常: {type(exc).__name__}: {exc}} if __name__ __main__: registry load_skills(Path(skills)) ctx {request_id: cli-demo, user_id: tester} skill_name sys.argv[1] params json.loads(sys.argv[2]) print(json.dumps(dispatch(registry, skill_name, params, ctx), ensure_asciiFalse))跑一下试试python dispatcher.py get_current_time {} python dispatcher.py read_file {path: dispatcher.py} python dispatcher.py read_file {path: ...}别看这套东西简陋它已经具备了一个可用技能系统的最小闭环定义、加载、注册、校验、执行、错误返回。我强烈建议先基于这个骨架跑通再往里面加对话模型层、权限层和编排层而不是一上来就上重型框架。3.2 技能间的组合与编排单个技能能做的事情有限Agent真正值钱的地方在于把多个技能串成一个流程。编排我分两层一层是代码层的静态编排另一层是模型层的动态编排。代码层编排适合那些流程完全固定的任务。比如“每天早上生成并发送报表”流程就是先query_database拉数据再generate_chart画图最后send_email发送。这种流程写死在代码里完全没问题快速、稳定、好测试。实现方式可以直接在调度器里加一个pipeline函数按顺序执行并透传中间结果。def run_pipeline(registry, steps: list[str], start_params: dict, ctx: dict): current start_params for step in steps: result dispatch(registry, step, current, ctx) if not result.get(ok): return {ok: False, step: step, error: result.get(error)} current result return current模型层的动态编排就复杂一些。流程不固定模型需要根据用户意图自己决定先调哪个技能、再调哪个。最常见的方式是ReAct模式模型在每个推理轮次输出thought和action调度器执行action后把结果反馈给模型模型再决定下一步。这个模式的核心是把技能列表和调用结果都塞进上下文里。我在实现动态编排时遇到的最大问题是上下文膨胀。每轮都把所有技能的完整描述塞进去几分钟后token就爆了。后来我做了个“技能预筛选”先从所有技能描述里做一次粗匹配挑出相关的三五个技能再把这几个技能的完整Schema塞给模型。预筛选可以简单到只用关键词重叠匹配效果立竿见影。这个优化直接把单轮对话的token开销降了一半还多。3.3 动态技能发现与安全沙箱agent-skills做成熟了以后自然会面临“可插拔”的需求新写一个技能能不能不重启服务就生效我实现了一套简单的动态发现机制每30秒扫描一次技能目录比对文件哈希如果有新增或修改就重新加载。重新加载不是简单替换函数引用而是要处理一个严谨性问题旧技能实例可能正在执行中直接把模块引用换掉会导致状态混乱。我的做法是在注册表里加一个版本号每个技能都带generation计数。调度器在派发时读一次版本号执行完毕后再核对一次版本号如果变了就重新执行一次。实际上发生变化的概率很小我这么加纯粹是为了安心但这种方式让我后来上线新技能时真的可以做到全天候不停机。比动态发现更重要的是安全沙箱。大模型调用的技能如果不受限制你等于把一把没上保险的枪交给了随机参数生成器。至少要做到三件事一是所有技能默认跑在无网络权限的隔离环境里只有声明了allow_network的技能才放行二是磁盘读写限死在白名单目录内三是资源上限包括单次执行时间和最大内存。在纯Python环境里做真正的沙箱并不容易一个稳妥方案是subprocess系统资源和resource模块控制。把技能执行放到子进程设置RLIMIT_CPU和RLIMIT_AS超时直接杀掉进程。# 以Linux/macOS为例在子进程入口处设置 ulimit -c 0 ulimit -t 10如果你跑在Docker里就更好办每个技能容器限定CPU和内存配额效果比进程级沙箱更可靠。我目前在演示项目里用子进程方案生产环境还是上了容器方案两者差别还是很明显的。4. 常见问题与排查技巧实录4.1 技能总是返回“参数校验失败”在项目前期这类报错出现的频率最高根因远远不止“模型笨”这一个。大部分情况下问题出在参数Schema本身写得不清楚。比如字段description写成“文件路径”模型不知道到底是本地路径还是URL频繁猜测自然频繁失败。排查这类问题我建议先打开日志把模型实际生成的参数原文记录下来和Schema并排对比。模型传的如果是{path_1: ..., path_2: ...}而Schema里只有path那说明模型从上下文里看到了别的用法。此时优先改Schema描述而不是怪模型。把描述写详细“读取本地文件时传path字段读取目录列表请改用list_directory技能。”模型看到清晰区分错误率立刻下降。第二个常见的坑是Schema里类型卡得太死。比如数字型参数模型偶尔生成字符串2validate直接失败。给这类参数加一个type: [integer, string]可以缓解或者干脆在技能执行前做一个宽松的强制转换。我在所有数值类参数上都加了自动转换实测错误率降低了一半。4.2 模型压根不调用技能只顾着闲聊这个情况也很典型。Agent该调技能的时候模型直接根据自己的记忆回答结果就是一本正经胡说八道。排查思路第一站是看技能描述——太抽象的描述模型理解不了。打个比方你写“发送邮件”模型可能不知道什么时候该用你写“在用户要求给指定收件人发送邮件时使用需要收件人地址、主题和正文”模型的理解就完全不同。第二个因素是上下文里的技能列表排布。模型在选技能时不是平等对待所有技能的。如果上下文里前几个字就是系统提示“你是助手”模型很可能进入“问答模式”而不是“工具模式”。我试过在System Prompt里加一句明确的触发规则“以下情况你必须至少调用一次技能后再回答查询时间、查询文件、执行计算、发送消息、访问外部数据。”这么一改调用率上来了不少。第三个因素最容易被忽略Agent的输出解析层太严格。很多框架用正则从模型输出里剥离JSON但模型一换格式就解析失败。我后来不再用正则改成寻找第一个{和最后一个}用json库做宽松解析并对解析失败的结果做一次“修复重试”——把错误信息回传给模型让它重写这个策略在大多数情况下都能跑通。4.3 技能执行超时或资源耗尽技能执行超时的原因通常不是技能本身慢而是技能进入了一个无人看管的循环。比如某个函数在拉网络数据时没有设置超时或者某个数据处理逻辑在异常数据下死循环。我在所有网络请求技能里强制规定必须设置超时参数默认15秒长任务单独开异步任务并立即返回一个任务ID不能让Agent在一个技能里干等。另一个资源问题是并发控制。模型经常会对同一个技能发起并发调用比如同时读三个文件如果没做限流瞬间大量文件句柄占用可能导致系统崩溃。我在调度器里加了一个简单的信号量限制同技能并发数为4队列溢出后直接拒绝并提示稍后重试。对你没看错“拒绝”也是技能系统该有的策略它保护的是整个Agent的可用性。4.4 问题排查速查表为了方便直接对症状开方我把实际项目里遇到的高频问题整理成一张速查表按“症状-原因-解法”三列对应。症状常见原因处理方法参数校验失败Schema描述不清晰 / 类型过严完善字段描述、放宽类型约束、加自动类型转换模型不调用技能技能描述语义不明确 / 触发器缺失重写技能描述、在System Prompt明确触发规则技能执行超时网络请求无超时 / 逻辑死循环强制设置超时参数、异步化长任务并发内存暴涨同技能并发无上限调度器加信号量限流技能结果被模型忽略结果格式太杂乱 / 无关信息过多统一技能返回结构、只保留关键字段这张表我贴在产品团队的文档里每次出问题先对着表格排查一轮大概率能省去不少调试时间。5. 个人实操体会与后续扩展我在做这个agent-skills项目时最深的感受是技能系统本质上是一种“信任边界”的设计。你信任模型能理解意图但不能信任它稳定输出格式你信任技能能完成任务但不能假设参数一定合法。技能层的意义就是在两端之间建一道可控的堤坝把随机性框在可处理的范围内。后续想继续扩展的话有几个方向可以做而且收益都不差。一是技能质量评估体系统计分析每个技能的被调用率、成功率、平均耗时以及模型调用后用户是否满意用数据驱动迭代技能定义而不是靠感觉。二是技能依赖关系图当一个技能可以被多个技能调用时自动生成依赖图让Agent在编排时能找到最短路径绕过无效尝试。三是面向非开发者的技能录制让普通用户通过“操作演示”生成新技能而不是手写Schema和函数——这是技能生态能否真正起来的关键一步。如果你正准备给Agent项目加技能层我建议别急着铺大框架先从一个最小闭环入手注册五六个技能让模型跑通一次完整的技能调用再逐步扩展。技能系统这种东西架构设计重要但真正让工程质量立住的一定是踩过坑之后沉淀下来的细节。