Agent 话题这两年的热度一直没降过,我最近在好几个项目里反复调教智能体,发现一个很普遍的现象:很多人把 agent 玩成了“高级聊天机器人”,模型再强,遇到具体任务还是原地打转。问题出在哪?多半出在“agent-skills”上——也就是智能体的技能设计。技能不是一句 prompt,不是让模型“自由发挥”的提示词,而是一套可声明、可调用、可复用的能力封装。今天这篇就把我折腾 agent-skills 这段时间的思考、踩坑和一套能直接用的设计方法完整梳理一遍。
先说清楚它解决什么问题:一个没有技能的 agent,本质上只有“对话能力”;而挂上技能之后,它才具备“干活的能力”——查天气、算报表、操作内部系统、写代码、做翻译,都是技能。适合谁看?如果你正在做 agent 类产品,或者想给自己的自动化脚本加一层“智能调度层”,这篇文章应该能帮你省掉不少试错成本。
1. agent-skills 到底解决什么问题
1.1 从“会聊天”到“会干活”
用过主流大模型的人应该有感受:纯对话模式下,模型对事实性、实时性、专有系统类的需求非常吃力。你跟它说“帮我查一下今天的物流单号走了哪条线路”,它要么编一个,要么让你自己去查。原因不复杂,模型本身是个“文本生成器”,它的知识截止时间是固定的,它也没有能力去访问你的内部订单系统。
agent-skills 要补的正是这个缺口。所谓技能,我习惯把它理解成一个“带说明书的函数”——模型负责决定“该不该调用、用什么参数调用”,技能负责真正把事做掉。这种模式下,模型不需要知道物流接口的内部逻辑,只需要理解这个技能的描述和参数约束。我把这种设计叫作“大脑和手脚分离”:模型是大脑,技能是手脚,彼此通过协议配合。
做这个设计最大的收益是确定性。纯 prompt 让人干活,十次可能给你十个花样;技能则把行为边界定死了,模型只能在约束范围内做选择。这在生产环境非常重要——你总不希望 agent 帮你发邮件的时候,自己编一个收件人地址吧。
1.2 技能与普通 prompt 的区别
很多人一开始会把技能和 prompt 混在一起。我在早期项目里也走过弯路:把“你是一个客服助手,请帮用户查询订单状态,回复要友好……”整段塞进 system prompt,结果模型经常不按套路来,不知道该先查订单还是先安抚用户。
后来我总结出两者的本质区别:
- Prompt 是“行为准则”,解决的是“怎么说”,语气、风格、流程边界,它对模型是软约束;
- Skill 是“能力单元”,解决的是“怎么做”,输入是什么、输出是什么、调用什么接口,它对模型是硬约束。
打个不太严谨的比方:prompt 是公司的员工手册,写的是价值观和行为规范;skill 是具体岗位的作业指导书,规定了任务的输入单据、操作步骤和交付标准。员工手册不会告诉你怎么用 ERP 系统,但岗位 SOP 会。
所以在我的项目里,prompt 只保留一小段“角色和人设”,真正的核心能力全部下沉到技能层。这样每次扩展能力,不需要调大段提示词,新增一个技能即可,模型对技能的选择也会更准确。
2. 技能架构设计与选型
2.1 三种主流技能组织方式
我实际调研过社区里几种组织方式,各有适用场景。这里先讲框架,后面再讲取舍。
第一种是“工具注册式”。这是最常见的方式,模型服务商普遍支持的 function calling 就是这种。每个技能对应一个工具描述,包含名称、描述、参数 JSON Schema。模型根据用户 query 决定是否触发某个工具,并生成符合 Schema 的参数。优点是对接成本低,主流模型基本原生支持;缺点是技能一多,模型容易“挑花眼”,需要做描述优化和数量控制。
第二种是“插件扩展式”。这种方式把技能做成一整套独立插件,每个插件内部除了工具定义,还包含自己的资源文件、prompt 片段、配置项。运行时由 agent 框架统一加载,再把插件里的工具暴露给模型。优点是能力内聚、便于分发共享;缺点是框架层面要做更多统一管理,调试链路变长。
第三种是“意图路由式”。它更像是传统 NLP 里的意图识别,模型不直接生成参数,而是先分类到某个意图,再由对应的技能模块执行。优点是可解释性好,适合规则多的企业场景;缺点是灵活性差,遇到分类边界模糊的 query 容易失效。
我自己主推的是第一种——工具注册式,加一点第二种的“技能包”思路。先用工具注册保证主流模型兼容性,再用技能包的做法把相关工具、说明文档、验证逻辑打个包,方便维护和分发。
2.2 技能的声明与注册机制
无论选哪种组织方式,技能总归需要一套“声明结构”。我见过太多人直接把函数名和函数体丢给模型,描述写得稀烂,最终模型完全不会调用。声明机制就是要避免这种混乱。
一个合格的技能声明至少包含五样东西:
- 技能唯一标识:英文短横线命名,比如
order-query,用于运行时索引; - 名称与别名:给模型看的人类可读名称,以及可能的常见说法,方便模型联想;
- 功能描述:说明“什么时候该用这个技能、能做什么”,这是模型决策是否调用的关键;
- 输入参数 Schema:每个参数的名称、类型、必填性、取值范围、示例;
- 输出契约:返回值结构,模型需要用哪些字段来生成回复。
建议把每一项都当成“写给另一个工程师的接口文档”来写,而不是写给模型看的提示词。描述要客观、明确,少用形容词,多写触发条件。比如不说“这个技能非常强大,可以帮你获得最新信息”,而是说“当用户明确请求查询实时数据或最新进展时,使用本技能”。
2.3 选型背后的取舍
技能数量少的时候,怎么设计都差不多;一旦超过二十个,问题就冒出来了。
我实测下来,模型对工具描述的长度非常敏感。当前主流模型虽然支持很长的上下文,但工具列表过长时,模型的选择准确率会明显下降。我在一个模拟项目里挂过 40 个工具,结果模型的工具命中率一度掉到七成左右,部分查询还被错误路由到语义相近的技能上。
我的建议是分层管理、按需注入。不要一次性把几十个技能全塞给模型,而是先做一层“技能路由”——让模型先根据 query 判断大的技能域(比如“数据查询类”“内容生成类”“系统操作类”),再把对应域内的少量技能注入第二层决策。本质上是用一次额外的模型调用换取准确率,实测准确率能回到九成以上。
另外还要考虑技能的依赖关系。有的技能B依赖于技能A的输出,这种依赖最好由框架层维护,而不是让模型自己串联。模型的串联能力在短路径上可用,但三条以上工具调用链时,链路失败率会指数上升。所以能硬编码的依赖,就把它直接写进技能内部逻辑里,暴露给模型的永远是最小操作单元。
3. 从零实现一个 agent 技能
3.1 技能接口设计
直接进入实操环节。我自己常用的技能封装格式是 JSON 描述 + HTTP 回调函数(或本地函数),两者通过一套运行时协议对接。先看一个统一接口示例,我用“查快递物流”做演示:
{ "skill": { "id": "logistics-track", "name": "物流轨迹查询", "aliases": ["查快递", "物流信息", "包裹到哪里了"], "description": "当用户提供快递单号并询问物流状态时调用,返回最近轨迹与当前状态", "version": "1.2.0", "input_schema": { "type": "object", "properties": { "tracking_no": { "type": "string", "description": "快递单号,例如 SF1234567890" }, "carrier": { "type": "string", "enum": ["顺丰", "中通", "圆通", "韵达"], "description": "快递公司,若用户未提及可留空自动识别" } }, "required": ["tracking_no"] }, "output_schema": { "type": "object", "properties": { "status": { "type": "string", "enum": ["in_transit", "delivered", "exception"] }, "tracking_events": { "type": "array", "items": { "type": "string", "description": "轨迹描述文本" } }, "eta": { "type": "string", "description": "预计送达时间" } } } }, "endpoint": "https://internal-api.example.com/v1/track", "method": "GET" }有几个细节值得注意:aliases 字段我一般建议加,它对模型“联想”帮助很大。有时候用户说“我的包裹到哪了”,模型需要能关联到“物流轨迹查询”这个技能,aliases 就是给模型铺的关联线索。此外output_schema很多人会省略,但我觉得必须写——模型要用返回值组织自然语言回复,没有输出结构,它就只能瞎编。
3.2 让模型自主调用技能
声明写好了,怎么让模型用起来?以当前主流的 function calling 机制为例,本质是两步:
第一步,把技能描述转成工具列表,随请求发给模型:
tools = [] for skill in available_skills: tools.append({ "type": "function", "function": { "name": skill["id"], "description": skill["description"], "parameters": skill["input_schema"] } }) response = model.chat.completions.create( model="your-llm-model", messages=messages, tools=tools, tool_choice="auto" )第二步,判断模型返回值里是否带tool_calls。如果带了,就解析出函数名和参数,执行对应技能,然后把结果作为一条tool消息回填给模型。这里有个非常关键的点:回填结果的格式必须和 output_schema 对齐,否则模型面对一堆没有结构的文本,很难生成高质量回复。
if response.choices[0].message.tool_calls: tool_call = response.choices[0].message.tool_calls[0] result = execute_skill(tool_call.function.name, tool_call.function.arguments) messages.append(response.choices[0].message) messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": json.dumps(result, ensure_ascii=False) }) # 再次调用模型生成最终回复 final_response = model.chat.completions.create(model="your-llm-model", messages=messages)很多初学者会忽略tool_call_id这个字段,或者把 tool 消息和 assistant 消息的顺序搞反。这里强烈建议按“系统请求 → 模型返回工具调用 → 追加 assistant 消息 → 追加 tool 结果消息 → 再次请求模型”的顺序来,一旦错乱,轻则模型不理解结果,重则直接报错。
3.3 技能返回值设计要点
技能的返回值是你和模型之间的“契约”,它的质量直接决定回复质量。我踩过最深的坑是技能返回了一长串 JSON 原文,字段名还是后端开发随手起的缩写,模型根本看不懂,最终生成的回复颠三倒四。
设计返回值遵循三个原则:
- 扁平化:避免过深的嵌套结构,深度超过三层模型就容易“迷路”;
- 语义化字段名:用
estimated_delivery_time而不是eta_ts,模型不需要靠猜; - 剔除冗余:只返回和用户意图相关的字段。比如物流轨迹查询,内部分页数据里的游标、服务端耗时标记都不该出现在返回值里。
如果技能内部逻辑复杂,输出前记得做一步“结果规整”——把内部对象映射成公开 schema。这个规整步骤也可以顺带做敏感信息过滤,比如身份证号、手机号中间四位脱敏,避免模型无意间把隐私字段拼进回复。
4. 技能管理与运行时机制的细节
4.1 技能发现与动态加载
真实生产环境里,技能不是写死在一份 JSON 里就能完事的。我在某跨平台系统里维护过 30 多个技能,最头疼的是“发布一个新技能,老服务要重启才能生效”。后来我把技能做成了“外部注册中心 + 本地缓存 + 定时刷新”的模式。
核心思路是这样的:技能描述统一存放到一个注册中心服务里,agent 启动时拉取全量技能列表构建索引,运行时按需从注册中心拉取具体技能的代码包(可能是一个 Python 插件文件,也可能是一个容器镜像的调用地址)。技能代码更新之后,不需要重启 agent 主服务,只需要在注册中心更新版本号,agent 下一次加载时自然拉到新版本。
这个方案在技能数量超过几十个、或者团队多人协同维护不同技能时优势明显。每个人可以在自己的仓库里开发技能,打包上传后由注册中心统一管理,互不阻塞。
4.2 权限与安全边界
技能是一个“让模型有能力操作真实系统”的入口,权限设计必须前置。我的经验是:永远不要让模型直接调用原始接口,而是让模型调用一个带权限校验的技能封装层。这个封装层做三件事:
- 身份鉴别:从会话上下文取出用户身份,判断是否有权限触发该技能;
- 参数校验:用 Schema 做白名单校验,防止模型生成非法参数;
- 操作审计:记录调用人、技能、参数、结果、耗时,方便回溯。
早期我图省事,让模型直接调用一个删除订单的接口,结果测试中模型在上下文干扰下生成了一个误删除操作,虽然只是测试环境,但也吓出一身汗。自那以后,所有破坏性操作前都必须走一层显式的二次确认技能,由模型向用户确认后再真正执行。
4.3 技能冲突与优先级
技能多了之后,另一个常见问题是语义重叠。比如“查订单状态”和“查物流状态”,看上去是两个技能,但用户说“我的订单到哪了”时,模型可能同时命中两个技能。这里有几种处理方式。
第一种,调描述。把两个技能的触发条件解释得更互斥一些,比如明确“订单状态指待付款/已付款/已发货等交易状态,物流状态指货物运输轨迹”。第二种,合并技能。如果两个技能底层数据源一致,不如合并成一个技能,用参数区分查询维度。第三种,引入优先级。当模型同时返回多个工具调用时,按技能声明里的priority字段做排序,优先执行高优先级的那个。我在实际项目中三种方式混合用,描述调优解决大多数问题,剩余冲突用合并和优先级兜底。
5. 实测中绕不开的那些坑
5.1 上下文过长导致调用失败
技能一多,工具描述本身就成了上下文负担。我在一个问答 agent 里挂了 20 个技能,工具描述加起来接近一万字,直接导致首 token 延迟飙升,模型偶尔还会把用户问题里的关键词错误关联到某个技能描述的名词上。
解决思路前面提过,就是“技能路由分层”。我额外做了一个极简路由表,将技能按域分组,每组用一句话概括。模型先选域,再选域内技能,单次请求注入的工具数量控制在 5 个以内。这样既保住了首 token 速度,又明显提升了命中率。
5.2 工具参数幻觉
所谓“参数幻觉”,就是模型在调用技能时补齐了一些用户根本没提供的参数。比如用户说“帮我查个单号”,模型可能给carrier硬填了一个“顺丰”,但实际用户用的是中通。这是我实战中遇到最多的故障类型。
针对这个问题,三个手段比较有效:
- 参数的
description里写明“若用户未提供,请留空并依靠自动识别”; - 在技能封装层做自动识别兜底,比如根据单号前缀判断快递公司;
- 最关键的一招——对参数做“来源标注”。如果某项信息不是用户明确给的,而是模型推断的,就让 result 里带上
inferred: true标记,最终回复时模型会倾向于使用“推测为顺丰,请确认”这种谨慎表述。
5.3 循环重试与死循环
模型在技能调用链上可能出现“反复调同一技能,拿到结果不满意再调一次”的循环。常见场景:查询天气技能返回“今日有雨”,模型觉得信息不完整,又调用一次查询地点,然后再调一次天气,来回打转,浪费 token 也拉长响应时间。
我的对策是在运行时加两层护栏:
- 单轮会话内同一技能调用次数上限,默认不超过 3 次,超出后强制中止工具调用并让模型基于已有信息作答;
- 技能结果“重复提交免疫”,如果新结果与上一次结果的语义相似度超过阈值,框架主动拦截。
5.4 技能返回值过大污染上下文
有些技能返回的数据量非常大,比如“导出用户订单列表”,一次性返回几千条,直接把上下文撑爆,模型在后续对话中频繁遗忘早期信息。
处理办法是给技能返回结果加一个“容量过滤器”:默认只返回前 N 条记录加一个total_count字段;如果用户确实需要完整数据,再调用第二个导出的技能,走异步任务或分页拉取。这样既满足大多数查询需求,又把上下文占用控制在合理范围。
5.5 多模型兼容问题
同一个技能在不同模型上的表现差异不容忽视。我测过几个主流模型,有的对工具调用格外激进,经常画蛇添足;有的则“懒得”调用,用户问实时信息它直接用自己的旧知识作答。这就带来一个非常麻烦的问题:技能层设计得好好的,换了个模型引擎,效果天差地别。
最省心的方案是给技能设置开关。每个技能在模型配置表里有一个enabled_models列表,只对成熟稳定的模型开放完整技能暴露;对能力较弱的模型,要么隐藏技能让它走纯对话,要么简化参数 Schema 减少决策负担。这种“按模型裁剪能力”的做法听起来不够智能,但生产环境里稳定比聪明更重要。
6. agent-skills 的扩展方向
6.1 技能共享与标准化
技能做完之后,我最大的感触是:如果只在单个项目里自用,成本其实很高。每做一个 agent 就要重新写一遍技能,重复劳动太多。目前社区里已经出现了一些技能共享的雏形,把某个垂直能力(比如“PDF 内容提取”“二维码生成”“会议纪要点总结”)封装成标准技能包,通过包管理工具一键安装。这个方向我很看好,但前提是技能描述、输入输出契约要有统一规范,否则装进来一堆“方言”依然没法互通。
6.2 从单个技能到技能编排
单一技能解决单一任务,但真实用户需求往往是多个技能组合完成的串联任务。比如“整理一份竞品分析报告”,可能需要依次调用“网页信息抓取技能”“结构化摘要技能”“报告生成技能”。目前我是靠模型自己生成多步工具调用来串联,但前文提到,链路超过三步成功率会明显下降。
更可靠的方向是“流程模板 + 技能节点”。把常见任务拆成固定流程模板,每个节点绑定一个技能,模型只需要在节点内部做参数填充。这相当于把主动权从“模型自由发挥”收回到“半自动流程”,稳定性和可解释性都提升一个档次。我后续计划在项目里试点这种模式。
6.3 带状态的技能
普通技能是无状态的,调一次就结束了。但很多场景需要技能“记住”之前的状态,比如“购物车添加商品”“多轮表单填写”。我现在的做法是在技能封装层维护一个会话级状态桶,用会话 ID 隔离各用户的技能状态,技能内部可以读写这个状态桶。这样模型不需要每次把全部历史信息重述一遍,状态管理的压力和出错率也显著降低。
我前阵子做了一个小型“旅行规划 agent”,就是用带状态的技能管理“目的地偏好、日期范围、预算上限”这些跨轮次信息。如果没有状态桶,模型每轮都要从长上下文里提取这些信息,既费 token 又容易出错。加了一层状态之后,体验立刻上了一个台阶。
写在最后的一点体会
技能系统做得越久,我越确认一个判断:Agent 的能力上限,不取决于模型有多聪明,而取决于你给它装配了多少高质量、高确定性的技能。模型负责“判断”,技能负责“执行”,两者之间靠一份清晰可用的契约连接。把技能描述当成接口文档来写,把调用链路当成生产系统来设计,把权限边界当成第一优先级来建设——这三件事做到位,agent 才真正从“demo 级”走向“可用级”。踩过的坑写出来也就是上面这些,希望后面做 agent-skills 的朋友能少走几步弯路。