上个月我把手头一个Agent项目重新整理了一遍最大的变化就是多了一个叫agent-skills的模块。刚开始我觉得这名字太普通但用了几周之后发现它才是决定Agent能不能从演示品变成生产力工具的那道分水岭。后来不少朋友来问这个模块是怎么设计的我干脆把整个思路、踩过的坑、以及一套可以直接复制的最小实现整理出来。先说清楚agent-skills是什么。它不是某个框架的专属功能而是一种组织方式把Agent能够调用的所有外部能力无论是查天气、查订单、发消息还是跑一段计算脚本都统一描述成带名字、带参数、带说明的“技能”放进一个注册表里再由一套调度器根据大模型的选择去执行。可以理解为模型是大脑技能系统是手脚。大脑负责决定要做哪件事手脚负责把事真正办成。这套内容适合谁看如果你正在写一个会调用工具的大模型应用或者你想把自己手头零散写的几百个工具函数整理成一个可维护的技能库又或者只是好奇Agent内部是怎么做到“让它点击按钮它就点击按钮”的都可以接着往下看。我不打算用太高的门槛核心思路和实操细节都会展开。1. 为什么需要一套“技能系统”从裸模型到能干活的Agent1.1 Agent能干的活取决于技能边界先聊一个很现实的问题大模型本身只会生成文本。你问它“北京今天适合跑步吗”它能凭训练数据给你编一段天气但真让它去查当前实时温度、风力、空气质量它做不到。这时你给它一个工具函数比如get_weather(city)模型就可以先决策调用这个函数拿到返回值之后再组织语言回答。没有技能系统的时候我自己是这么干的写一堆if 天气 in user_input: result get_weather()之类的关键词判断然后人工把结果塞回提示词里让模型继续生成。项目规模小还好一旦工具超过二十个关键词匹配就彻底乱套。用户说“帮我看看明天去杭州穿什么”你的关键词里可能既配到“天气”又配到“杭州”又配到“出行建议”代码逻辑很快就变成一坨没人敢动的面条。技能系统的核心价值是把“让模型决定用哪个工具、参数是什么”这件事标准化。模型服务现在基本都开放了结构化调用接口前端声明一批技能签名模型在回答时直接输出一个结构化的调用请求不再需要你自己写关键词匹配。也就是说Agent能干的活完全取决于你暴露给它的技能边界。技能越丰富、定义越清晰Agent的上限越高反过来技能描述写得稀烂模型再聪明也会像没看说明书的新员工一样乱操作。1.2 技能系统要解决的四个核心问题我整理技能系统时强迫自己用四个维度去检验每加一个技能都问一遍它够不够“可见、可用、可控、可复盘”。可见模型能不能在候选技能列表里准确地“看见”这个技能。技能数量越多可见性问题越严重。可用模型输出的参数能不能被正确校验、转换然后真正落到函数调用上来。可控技能执行有没有权限边界、超时控制、限流和审批机制。这点在接真实业务系统时特别要命。可复盘用户那次请求到底用了哪些技能、传了什么参数、执行了多久、成功还是失败能不能全程追溯。这四个维度里大多数人一开始只盯着“可用”代码跑通就以为成了。结果上线之后模型频繁选错技能、参数校验失败、一个技能被并发调用几百次导致下游服务被打挂又或者出了事故根本查不出是哪一步出的问题。所以我在一开始设计agent-skills时就把这四件事全部纳入框架而不是等出了问题再补。1.3 为什么叫“技能”而不是“工具”或“插件”这个名字不只是为了好听。我见过有团队把每个工具函数定义成一个“tool”直接暴露给模型也有团队把各种外部系统接入叫“plugin”插件本身带了复杂的配置和UI能力。而“技能”这个词强调的是一层更完整的行为包装一个技能不光包含“执行函数”还包含“这个技能在什么场景下用、参数怎么填、有哪些限制、失败之后怎么处理”。换句话说工具偏向描述“我能做什么动作”技能偏向描述“我能在什么情况下、怎么把一件事做成”。以查天气为例工具视角get_weather(city: str) - dict只暴露函数签名。技能视角一段完整的技能说明书告诉模型“当用户询问某城市当前天气、穿衣建议、出行装备时使用参数city必须传中文城市名没有找到城市时返回错误码”。这层包装看起来多写了不少字但它解决的是Agent应用最常见的问题模型想用工具却不知道什么时候用最合适。后面我会专门讲技能描述怎么写那时候你就知道多写的这些字有多值钱。2. 技能的定义与注册把能力变成模型看得懂的说明书2.1 一份技能文件里该放什么我建议每个技能用一个独立目录去存放里面至少包含一份描述文件和一个执行脚本。描述文件的最简结构可以长这样name: weather_query description: 查询指定城市的实时天气。适合在用户询问当前天气、穿衣建议、出行准备时使用。如果用户没有给出城市名先把城市列表提供给用户确认后再调用。 category: weather version: 1.0.0 timeout: 3000 parameters: - name: city type: string required: true description: 城市名使用中文全称例如北京、杭州。不接受简称和拼音。 - name: unit type: string required: false enum: [celsius, fahrenheit] default: celsius description: 温度单位默认摄氏度你可能会问这些字段是不是每项都必要我的经验是name、description、parameters是金三角缺一个都会出大问题。timeout是给调度器兜底用的后面你会看到模型傻等超时的痛苦。category在技能数量变多之后非常有用可以作为分组过滤的维度。关于name我强烈建议全项目唯一并且采用“领域_动作”的命名方式比如weather_query、order_cancel、doc_search。因为模型在同时看到几十个技能时名字本身就是一种记忆锚点取一个一眼能看懂的名字比让它读完整段描述再判断快得多。2.2 描述文本写得越像“人话”模型选得越准这是我前后调了很多版最深的体会技能描述是写给谁看的不是写给人看的是写给大模型看的。所以你写的描述必须包含决策信息什么场景用、什么场景不要用、参数的坑在哪里。举个反例。我早期给搜索技能写的描述是description: 提供搜索服务然后模型在用户问“今天新闻有哪些”的时候真的调搜索了在用户问“2加2等于多少”的时候也调搜索了。它根本分不清什么时候该搜什么时候不该搜。后来我改成description: 搜索互联网资源适合用户需要获取实时信息、外部资讯、专业知识或不确定事实时使用。 不适合简单的数学计算、常识性问题、用户明确不要联网的内容。加了“不适合”之后误调用率明显下降。这个技巧非常推荐每个技能描述都写清楚“什么时候别用我”作用等于给模型画了一条减速带。2.3 注册中心命名空间、分类与版本当技能数量超过五十个你就不能只靠一个数组硬扛了。我的做法是引入注册中心启动时扫描技能目录建立三层索引按命名空间索引例如weather.*、order.*、doc.*方便做权限控制按标签索引例如read_only、needs_approval、high_frequency方便调度前做筛选按版本索引同一个技能可以存在v1、v2路由时按配置灰度切换。技能多了以后还会遇到命名冲突。比如两个团队各写了一个send_message我要求所有技能必须带命名空间前缀内部注册名类似im.send_message、sms.send_message避免互相覆盖。否则后加载的那个会把先加载的顶掉线上模型可能突然调用到一个错误实现。2.4 参数定义决定调用成功率模型选择技能之后下一步是把自然语言里的关键信息映射成参数。这一步特别容易翻车。我有一次测试技能order_query的参数order_id被模型填成用户说的“我上个月买的那个黑色耳机”而不是订单编号结果查询接口直接报错。后来我把参数定义改得更细每个字段都写清格式、枚举、例句并在校验层做了强校验。理想的参数定义应该包括类型和格式比如“字符串长度8到32位”枚举值写死给定范围依赖关系比如“query_type 为 phone 时phone 必填customer_id 可不填”是否需要用户确认比如涉及下单、发送信息等敏感操作必须拿到用户明确同意后再执行。参数校验失败时不要直接把错误堆栈丢给模型而是返回一段友好、可供模型二次修正的错误信息。比如“order_id 格式不正确需要是10位纯数字请向用户确认订单号。”模型看到之后往往会重新向用户追问正确信息而不是直接放弃任务。3. 技能的执行与调度连接模型和真实世界的关键一跳3.1 一次完整调用的五步流程技能系统跑通的核心流程我总结为五步解析、查找、校验、执行、回填。每一步都不能省。解析把模型输出的结构化调用请求解析成具体的技能名和参数字典。注意这里要处理单轮多个技能同时调用的情况有些模型服务会一次返回多个工具调用。查找拿着技能名去注册表里定位对应的技能实现。找不到时立即返回“技能不存在”的错误不要让模型继续猜。校验按技能定义里的参数规则做类型、必填、枚举检查。检查失败就返回可读的错误信息建议带着错误信息再调一次模型让它修正。执行确认技能存在、参数合规之后进入真正的副作用操作比如查询数据库、发HTTP请求、落文件。回填把执行结果格式化成一段简短的结构化文本塞回对话上下文让模型基于真实结果继续生成回答。下面这段伪代码展示了最小调度器的核心结构def dispatch(model_output, registry): skills model_output.get(tool_calls, []) results [] for call in skills: skill_id call[name] args call[arguments] skill registry.lookup(skill_id) if skill is None: results.append({name: skill_id, ok: False, error: skill_not_found}) continue validated, error skill.validate(paramsargs) if error: results.append({name: skill_id, ok: False, error: error}) continue output skill.run(**validated) results.append({name: skill_id, ok: True, output: summarize(output)}) return results我特别想强调“回填”这一步的summarize把执行结果压缩之后再送回模型。直接往上下文里塞原始执行结果是上下文爆炸和费用失控的最大来源。3.2 超时、重试和失败降级真实世界里外部服务不可能永远稳定。我在做技能系统的时候给每个技能都配了独立的超时时间并且区分“连接超时”和“读取超时”。比如查询类技能连接超时给2秒读取超时给5秒发送类技能连接超时给3秒整链超时给10秒。超时之后不要静默失败要把超时原因写成简短提示回填给模型让模型决定是重试、换方案还是如实告诉用户。这里有个非常关键的坑带副作用的技能不能盲目重试。查天气重试三次问题不大但“发送订单确认短信”这种技能如果第一次实际已经发出去了只是网络延迟导致超时你再重试一次用户会收到两条一模一样的短信。我的处理方案是给这类技能增加幂等参数比如让前端生成一个request_id后端通过这个ID去重。没有幂等能力的技能超时后宁可告诉用户“操作结果未知请稍后查询”也不要无条件重发。3.3 技能编排让技能自己调用技能单个技能解决单点问题但很多任务天然是复合的。比如用户说“帮我把昨天的销售数据整理成周报发给总监”这里需要拆成“查数据”“生成周报文本”“发邮件”三个动作。如果让模型一次性调三个技能参数很难一次给全而且中间一步失败很难处理。我的做法是支持技能编排技能的执行函数内部可以继续调用其他技能。比如定义一个复合技能weekly_report_generate它的代码逻辑是先调用sales_data_query拿到数据后调用content_maker生成文本最后把文本传到email_send。这样模型从一开始只需要决定调用一个技能内部流程全由代码控制比让模型自由编排稳定得多。编排需要注意上下文传递。子技能的执行结果不要直接丢给大模型的对话上下文而是在内部代码里做处理和格式化最后只在对话里回填一个精简结论。否则模型会被一大堆中间数据干扰很容易答非所问。3.4 安全边界白名单、审批、沙箱与配额技能是Agent接触真实世界的通道安全设计不能省。我把技能按风险分成三个等级L1只读且低成本比如查天气、查文档、算算数可以直接执行L2可写但对影响面有限比如创建草稿、发起工单需要用户在界面上确认一次L3高风险操作比如批量发送、删除数据、发起支付必须打到人工审批或者走强校验。除此之外调度器还需要做配额控制。我就遇到过几次这样的线上问题模型在单轮对话里被激发出十几个技能调用连环执行把下游系统打挂了。后来每个会话都加了配额限制单轮最多执行5个技能、每分钟最多执行20个技能超过之后明确告诉模型“当前操作过于频繁请先汇总给用户确认”。配额限制看起来粗暴但在生产环境里是保命符。4. 从零搭一套最小可用的技能系统实操记录4.1 第一步设计技能目录结构我自己用的是下面这种目录结构简单清晰一个技能一个文件夹skills/ weather_query/ skill.yaml handler.py order_query/ skill.yaml handler.py weekly_report/ skill.yaml handler.pyskill.yaml放技能定义也就是前面那张表handler.py放执行逻辑对外暴露一个run(params)函数。一个文件夹里多出的其他文件都算这个技能的私有资源比如order_query里放的数据库连接配置、SQL模板只属于它自己。这样每个技能的边界都清晰团队多人协作时互不干扰。4.2 第二步写一个通用的技能加载器加载器的任务是启动时扫描目录、读取定义、动态导入处理函数然后注册到中心注册表。Python实现大概是这样import importlib.util import yaml from pathlib import Path class SkillLoader: def __init__(self, root: Path): self.root root def load_all(self): registry {} for skill_dir in self.root.iterdir(): yaml_path skill_dir / skill.yaml handler_path skill_dir / handler.py if not (yaml_path.exists() and handler_path.exists()): continue spec yaml.safe_load(yaml_path.read_text()) handler self._load_handler(handler_path, spec[name]) registry[spec[name]] {spec: spec, handler: handler} return registry def _load_handler(self, path, skill_name): module_name fskills_{skill_name} module_spec importlib.util.spec_from_file_location(module_name, path) module importlib.util.module_from_spec(module_spec) module_spec.loader.exec_module(module) return module.run注意两个细节第一动态导入的模块缓存要小心热更新技能时最好在独立进程里做重载不要直接importlib.reload容易把旧状态的全局变量带进去第二加载前一定要做一次技能名合法性检查避免路径拼接问题。4.3 第三步让模型学会选技能的提示词设计技能注册好了还得让大模型知道怎么用。每次请求模型前需要把当前会话可用的技能列表拼进系统提示词。我常用的一种写法是你是智能助手负责根据用户请求选择合适的技能完成任务。 可用技能如下 {skills_json} 选择规则 1. 仅在确实需要外部信息或操作时才调用技能简单常识问题不要调用。 2. 一次可以调用多个技能但必须保证每个技能都有明确用途。 3. 调用时严格遵循技能参数要求缺少必要信息时先向用户提问。 4. 技能执行结果以观测内容为准不要猜测。 请同时输出要执行的动作和回复文本动作格式为JSON数组。这里最大的坑是技能太多当技能列表超过30条模型的选择准确率会明显下降还会消耗大量token。我的解决方案是“两级召回”先用分类粗筛比如根据对话意图匹配若干候选技能只把候选技能列表拼进提示词如果候选里没有匹配项再退回全量技能列表。这样既省token又提升准确率。4.4 第四步执行结果回填与上下文管理技能执行完返回的数据怎么进入下一轮模型对话是决定对话质量的关键。这里我特别不建议把原始返回一整个塞回去。举例来说order_query可能返回一个几十行的大订单列表直接塞回去模型会迷失在数据里还会产生巨额token消耗。我通常先做一次结构化摘要比如把“查询到3笔订单合计金额1280元最近一笔是昨天购买的运动鞋”这种轻量结论回填。如果用户后续明确要求查看明细再触发一次带明确条件的查询把需要的那部分明细展示出来。这背后是一个“先粗后细”的思路技能系统不负责一次性输出所有信息而是根据对话需要按需拉取。4.5 第五步日志、追踪与效果度量没有观测的技能系统是没法迭代的。我要求系统里所有技能调用统一打印结构化日志内容包括会话ID、技能名称、入参、出参摘要、耗时、错误信息、模型生成的原始调用串。每个会话再分配一个trace_id把模型的推理轮次和技能调用串成一个完整链路。日志不只用来排查线上问题更是优化技能描述的依据。我会定期统计几个指标技能命中率候选列表里被实际调用的比例参数校验失败率模型生成参数一次通过校验的比例执行成功率技能真正跑通并返回有效结果的比例平均耗时单个技能从被选中到返回结果的时间。这几个数字一出来哪些技能描述不清晰、哪些技能签名太复杂立刻一目了然。比如参数校验失败率高的技能优先去优化parameters.description命中率低的技能大概率是因为描述里缺少典型触发场景。5. 常见问题与排查经验实录5.1 模型为什么老选错技能这几乎是每个人都会遇到的第一个瓶颈。症状是用户问A话题模型却去调了B技能。常见原因有三个技能描述太宽泛、技能命名近似、缺少反例约束。有一次我上线两个技能doc_search和doc_preview。前者搜文档列表后者预览某个具体文档内容。模型经常在用户想看某篇文档具体内容的时候去调doc_search把返回的文档列表原样念给用户。后来我把doc_preview的描述里加了“仅当用户已经确定了具体文档ID或文档标题时调用”并把doc_search的描述改成“仅返回文档标题列表不包含正文内容需要预览详情请使用 doc_preview”。两条技能描述一互补选错率立刻降了下来。如果你发现怎么改描述都不见效建议直接加一个反面示例到系统提示词里把用户可能会说出的近义表达列出来并指明不该匹配的技能。给模型看例子比讲一百遍规则都管用。5.2 模型把参数填成了幻觉值参数幻觉有两种一种是填了不存在的值比如把“上个月”解释成一个具体日期但算错了另一种是填了用户根本没提到的值比如用户只说了“查最近的订单”模型自己补了一个order_id12345。我的处理套路是三层防线。第一层参数描述里写清楚来源比如“order_id必须来自用户提供的订单号如果用户未提供该参数置空并询问用户”。第二层校验层对枚举值和格式做强校验格式不对直接拦截。第三层如果模型连续两次校验失败就不再让它猜测了改成向用户发起一次结构化澄清列出当前已获得的参数和缺失参数请用户确认。5.3 技能执行超时模型开始编造结果这是个很隐蔽的问题。有一次技能因为慢查询超时了我在超时后没有回填错误信息给模型结果模型为了完成回答自己“脑补”了一个查询结果用户还在那儿等。后来我特意验证了一下发现确实有部分模型服务在拿不到工具结果时会选择自行编造而不是承认失败。这提醒我两件事第一技能执行结果不管是成功还是失败一定要有明确状态返回第二超时后必须把这个事实原样告诉模型禁止它猜测。正确的回填格式类似工具状态timeout 错误信息order_query 在5秒内未返回结果 处理建议告知用户查询超时建议稍后重试不要提供任何推测数据对于真正耗时的任务比如生成一份几十页报表就不要用同步等待的方案了。改为技能先返回“任务已受理ID为xx”系统后台跑完后把结果回填进上下文。这种异步模式体验会好很多。5.4 多轮对话里上下文被技能结果撑爆技能调用次数一多上下文里会积累大量工具结果、中间思考片段。我见过一个会话才聊了七八轮就触发了模型上下文上限前端直接报错。原因基本都出在把完整的技能返回原文塞回上下文。对策是“每层都要压缩”技能返回的原始数据不进上下文只进执行层处理逻辑进入上下文的必须是摘要摘要里只保留跟当前用户问题直接相关的内容其余用一句“还有X条其他记录”带过。如果确实需要长文本比如调了一个文档预览技能返回了一篇长文就单独把长文挂到会话的临时存储里上下文里只放一个文章ID和开头摘要等模型决定要给用户展示时再读全文。5.5 技能上线前的验收清单整理了一套我每次上技能前都会过的检查清单分享出来给你抄作业。检查项通过标准没通过会怎样描述清晰度让一个不了解业务的同事读一遍描述能说清什么场景用这个技能模型也会同样困惑选错技能参数完整性所有参数都有类型、必填标记、格式描述和示例参数幻觉、校验失败超时与重试策略明确超时时间副作用操作有幂等方案超时后模型编造结果或重复操作失败提示文案执行失败时能返回一句可读的处理建议模型无法自行修正任务中断日志埋点包含技能名、参数、耗时、状态、trace_id出问题无法定位配额与权限技能级别已分级敏感操作已加确认安全问题、系统被拖垮容量评估高频技能已做缓存或限流下游服务被打挂每次上线新技能之前过一遍这张表能省掉至少一半线上事故。特别是“描述清晰度”这一项我强烈建议你在真实模型上多试几个说法别只盯着代码逻辑跑通就发版。这套agent-skills我前后迭代了三版最深的体会是代码的复杂度没有想象中高真正耗时间的是把每个技能说明书写到让模型一眼就能看懂。你去看市面上很多Agent能力不及预期的案例八成不是模型不行而是技能边界描述得模模糊糊模型像个在公司没待两天、对流程一知半解的新人上哪儿找工具都不知道又怎么指望它把事办漂亮。如果你正在搭自己的技能系统我建议从最小的两三个技能开始先把加载、校验、回填的闭环跑通再慢慢往上加。技能描述不要靠拍脑袋写上线之后盯着命中率和校验失败率去调你会很明显地看到Agent的表现一点点变好。这套方法不需要什么高深理论但它真的能让你的Agent从“会聊天”升级成“会干活”。