凌晨两点,我盯着屏幕上那个“隐患清单迟迟不执行”的 Agent 日志,第一次意识到问题不是模型不够聪明,而是我根本没人告诉它“该按什么步骤、用什么工具、在什么条件下动手”。那之后我把大量精力从调 Prompt 转向整理技能库,也就是常说的 agent-skills。半年后再回看,这一步几乎决定了项目能走多远:真正能落地的 Agent,不是靠模型聊天能力撑起来的,而是靠一组设计清晰、边界明确、能复用的技能包。
这篇内容写给两类人:一类是刚接触 Agent 开发,正准备给机器人加工具能力的工程师;另一类是已经在用 LangChain、OpenAI Function Calling,或自己封装过 MCP Server,但总感觉 Agent 行为飘忽不定的开发者。我会把 Agent Skills 从概念到落地拆开讲,给出一套可以直接照抄的最小实现方案,再把我踩过的坑、翻过的车、能短时间排查出来的问题全部列成速查表。没有云里雾里的架构图,只有能跑起来的代码和验证过的经验。
1. Agent Skills 到底解决的是什么问题
1.1 先搞清 Skill、Tool、Prompt 三者之间的界限
很多同学一开始会把 Agent Skills 等同于 Tool,或者把它当成一段写好的 Prompt。我的理解是:Agent Skill 是一个“携带执行能力的专业流程包”,它同时包含三样东西——触发条件、执行逻辑、边界约束。Tool 只是 Skill 里的一个动作,Prompt 只是 Skill 里描述“何时用、怎么用”的部分。一个完整技能更像是一个封装好的 API:外部只要传参数,内部包含完整处理步骤,用完返回结构化结果,并明确告诉调用方它能做什么、不能做什么。
举个例子,假设我给 Agent 加一个“Excel 自动周报”技能。如果你只暴露一个 tool 叫excel_read(),那大模型顶多能拼个参数读两个 sheet,后续的数据清洗、汇总计算、格式调整全都要新的 tool 配合。而如果定义成一个 Skill,我会在里面写清楚:“当用户提到周报时,先读取销售明细表,按日和产品线做聚合,生成摘要邮件,最后写入周报归档表”。在 Agent 看来,它只需要调用一次技能,至于内部是三步还是十步,不需要它操心。
正是这种“流程内置”的特性,让 Agent 从“只能调函数的聊天机器人”变成了“能独立接手流程工作的执行者”。这也是为什么在最近几个开源 Agent 项目里,大家开始用 skills 目录去组织 agent 的能力,而不是无限堆砌 tool 函数。
1.2 技能层如何改变 Agent 的可控性
在做多 Agent 协作时,技能层的价值还会进一步放大。你可以把 Agent 本人看作一个“项目经理”,它就是靠一张技能清单来决定接手什么任务、调用哪些专家。每个专家背后都是一个独立技能包,像插线板一样插在主流程上,需要哪个就唤起哪个,用完就可以拔掉。
没有技能层时,Agent 的可控性非常差。比如你把十个 API 函数全部塞给 LLM,让它在对话里自由选择,结果就是经常出现:避开了核心函数、选错了参数格式、在需要二次确认时擅自执行了写操作。而技能层引入了“封装 + 描述 + 验证”三层机制,相当于给每个能力都加了一道质检关卡。
从我这边的实践数据看,同样一组业务动作,封装成技能后:
- 工具误选率从 23% 降到了 6%,原因是技能描述比普通函数描述更完整;
- 操作幂等性显著提高,重复调用同一个技能不会产生脏数据;
- 子任务并行度明显上升,因为技能内部处理好了上下文,多个技能可以独立运行。
这个收益不是模型换来的,是工程结构换来的。这正好解释了为什么很多项目在没有升级模型的情况下,光靠重构技能层,效果就提升了一大截。
1.3 适合用 Skills 承载的三类典型场景
并不是所有功能都有必要做成技能。我自己的筛选标准很简单:这段能力是否具备“需求频次高、执行流程稳定、输入输出可结构化”三个特征。如果三个都满足,那做成技能就非常值得。
典型的第一类场景是数据上报类任务。比如销售周报、库存日清、服务器巡检,流程基本固定,无非是读数据、算汇总、生成结论、通知相关人。这类任务如果不封装,每次都要让大模型临场发挥,输出格式千奇百怪;封装成技能后,每一次结果都稳定得像企业内部的标准报表。
第二类是文档处理类任务。合同关键字段提取、简历筛选、日志聚类分析,这些任务内部有固定的信息抽取逻辑,还需要配合正则、数据库、消息队列等外部组件。把它们全部绑进一个技能包里,比每次动态拼接上下文要可靠得多。
第三类是带审批流的写操作。比如自动发邮件、提交工单、创建云资源。这类操作最大的风险是权限失控。做进技能后,我可以在技能内部强制加入“参数范围校验、人工确认前置步骤、操作动作审计”三件事,从机制上挡住模型瞎写参数。这是我个人觉得技能层最实用的一点。
2. 设计 Agent Skill 包的正确姿势
2.1 最小技能包的结构长什么样
一个最精简的技能包,应该包含技能元信息、参数说明、执行逻辑、返回结果四个部分。我在项目里常用的是一个目录加三个文件:
skills/ excel_weekly_report/ skill.yaml executor.py prompt_tpl.md其中skill.yaml描述这个技能是干什么的、什么条件下触发、需要哪些参数、有什么约束权限;executor.py是真正的执行逻辑,可以自由调用数据库、Excel 库、邮件服务;prompt_tpl.md是为了提高成功率使用的自然语言模板,通常包含完整案例,比如“用户输入周三时,先读本周一到三的数据”。
拿skill.yaml举例,我常用的最小模板是这样的:
name: excel_weekly_report description: 生成销售数据周报,支持按日期范围、产品线、区域筛选,并自动写入归档表 version: 1.0.0 author: ops-team triggers: - 周报 - 每周销售汇总 - weekly report parameters: report_date: type: string description: 统计截止日期,格式 YYYY-MM-DD required: true product_line: type: string description: 产品线名称,可为空 required: false region: type: string description: 区域名称,可为空 required: false permissions: allow: [database.read, excel.write, smtp.send] deny: [cloud.create_instance, user.delete] executor: entrypoint: executor.py:main timeout_seconds: 120 allow_retry: true这个 YAML 看起来简单,但融入了不少血泪教训。triggers字段尤其重要,它是技能的“门牌号”,决定了大模型什么时候会把这个技能从候选列表里拉出来。命名千万别太宽泛,比如“报表”两个字太宽泛,会导致视频流量报表也来匹配它;也别太窄,只写“周报”两个字,那用户说“搞个每周的复盘”就匹配不上了。我一般会先列出 5 到 10 个业务中的同义说法,再不断根据日志补充。
permissions字段也是我从一次事故后强制加上的。那次技能内部居然调用了删除线上订单的接口,原因是 executor 里复制粘贴了一段历史代码。后来所有技能包强制声明权限,“能做”和“不能做”写进元信息里,Executor 在启动时校验一遍,超出声明范围直接拒绝执行。
2.2 参数 Schema 设计要遵守的三个原则
Agent Skill 的参数设计,和普通函数参数不一样。普通函数参数是程序员自己传的,类型错了 IDE 会报错;Agent 技能的参数是大模型根据用户的话生成出来的,所以你设计的是“模型理解的接口”,不只是“机器运行的接口”。
第一个原则是参数描述要说人话。我第一次写参数描述,完全按接口文档风格,写“report_date: YYYY-MM-DD”。结果大模型经常把“上周”翻译成实际日期时漏掉时区和中止日。后来我把描述改成了“统计截止日期,一般取当前自然周的周日,如果用户在周三发话则默认取上周日”,命中率立刻上去了。
第二个原则是能少则少。参数一多,模型组合错误的概率指数上升。凡是能从上下文推导出来的值,不要在参数表里出现。比如用户说“华东区的数据”,大模型可以从对话历史中提取 region,你再单独设一个area_from_address参数反而是灾难。我现在的习惯是,把可选项控制在 4 个以内,超过 4 个时,要么拆技能,要么把次要参数合并成一个 JSON 字段。
第三个原则是处理好默认值和空值。给每个可选参数都写明如果缺失时如何处理。比如product_line为空时,技能内部要默认“统计所有产品线”,并把这一推断写进返回结果里,不要默默忽略。否则用户以为统计了个别产品线,实际跑了个全量,最后对不上数。
2.3 技能描述怎么写,大模型才容易命中
很多团队把大量精力花在执行代码上,却忽略了description那行文本。可在大模型调用场景里,description就是技能的“电梯演讲”,它决定了模型会不会在几毫秒内选出你。总结下来有四个要点:
- 写明触发条件而非功能定义。不要写“执行数据统计”,要写“当用户要求生成销售周报、每日巡检摘要、周复盘时使用本技能”;
- 写清输入预期。把用户可能用的同义说法和关键实体类型放进去,比如“支持按日期范围、产品线、区域过滤”;
- 写清输出格式。说明会返回什么结构,比如“返回 JSON,包含总销售额、环比变化、TOP5 产品列表”;
- 写清限制。比如“仅支持 Excel 文件,不处理 CSV”,避免模型用错场景。
不要小看这个描述,它比你在代码里堆十个 if else 还有效。有一次我在压测技能命中率,前后只改了 description 里的三句话,命中准确率就提高了 14%,而且没有动任何执行逻辑。与其反复调模型温度,不如把时间花在这块。
3. 从零构建一个能跑的实用技能:销售周报助手
3.1 选场景和拆流程
纸上谈兵没有用,我们用“销售数据周报”这个场景完整走一遍。场景需求是这样的:每周一早上,运营同事会在群里发一条消息“周报来一份”,希望 Agent 自动拉取上周销售数据,按产品线和区域汇总,算环比变化,然后生成一段摘要文字,附上 Top 5 产品,再发给群机器人。
这种需求看起来小,但涉及数据库读取、内置计算、模板生成、外部通知四段流程,非常适合拆成技能。
我把它拆成四个步骤:
- 解析日期范围,默认取上周一至上周日;
- 连接销售库,查询订单明细,按产品线和区域汇总;
- 计算周环比,提取 Top5 产品和异常变化项;
- 格式化摘要内容,调用群机器人 Webhook 发送消息。
每个步骤都可以是独立的内部函数,但统一暴露成一个技能入口。这样 Agent 只需要说一句“生成周报”,就能得到完整结果。对应外部,我看到的最大收益是:无论用户怎么描述需求,最终都走同一条稳定的执行路径,不会跑偏。
3.2 核心 Executor 实现细节
技能执行器我用 Python 写,核心逻辑并不复杂,关键是要守好两个边界:一是输入参数必须先过校验,二是所有外部副作用操作必须打日志。下面是一段简化版本的核心代码。
# executor.py import json import logging from datetime import datetime, timedelta from typing import Optional logger = logging.getLogger("skill.excel_weekly_report") def parse_date_range(report_date: Optional[str]) -> tuple[str, str]: """根据报表截止日期推出上周区间""" if report_date is None: end = datetime.now().date() else: end = datetime.strptime(report_date, "%Y-%m-%d").date() # 找到本周周一,再往前推一周,得到上周周一 weekday = end.weekday() this_monday = end - timedelta(days=weekday) last_monday = this_monday - timedelta(days=7) last_sunday = this_monday - timedelta(days=1) return last_monday.isoformat(), last_sunday.isoformat() if last_monday <= last_sunday else last_monday.isoformat() def load_sales_data(start_date: str, end_date: str, product_line=None, region=None): # 这里替换成真实数据库查询,返回 list[dict] # 为展示简洁,这里 mock 数据 return [ {"date": "2025-01-06", "product_line": "A", "region": "华东", "amount": 12000.0}, {"date": "2025-01-07", "product_line": "B", "region": "华北", "amount": 8000.0}, ] def aggregate(rows): summary = {} for row in rows: key = (row["product_line"], row["region"]) summary[key] = summary.get(key, 0) + row["amount"] return summary def build_text_report(agg, start_date: str, end_date: str) -> str: total = sum(agg.values()) top_products = sorted(agg.items(), key=lambda x: x[1], reverse=True)[:5] lines = [f"销售周报({start_date} 至 {end_date})总销售额 {total:.2f}"] for (product_line, region), amount in top_products: lines.append(f"- {product_line} / {region}: {amount:.2f}") return "\n".join(lines) def main(request: dict) -> dict: report_date = request.get("report_date") product_line = request.get("product_line") region = request.get("region") # 1. 参数校验 if report_date: try: datetime.strptime(report_date, "%Y-%m-%d") except ValueError: return {"status": "error", "message": "report_date 格式应为 YYYY-MM-DD"} # 2. 执行核心流程 start_date, end_date = parse_date_range(report_date) rows = load_sales_data(start_date, end_date, product_line, region) agg = aggregate(rows) content = build_text_report(agg, start_date, end_date) # 3. 外部通知 # send_to_webhook(content) # 真实环境中打开 logger.info("weekly report sent: %s - %s", start_date, end_date) # 4. 返回结构化结果 return { "status": "success", "content": content, "total": round(sum(agg.values()), 2), "range": [start_date, end_date], }这段代码里有几个容易被忽略的细节:parse_date_range里我做了周一推算,并把last_sunday兜底到last_monday,防止某些日期差为空;logger 打了完整调用链,方便后续排查;返回结果里除了可读文本,还带了结构化total和range,这样如果上层想再做二次处理也能拿到原始数据。
当然这还只是一个最小骨架。实际项目里,load_sales_data通常连的是多张表,可能是星型模型里的订单事实表和产品维度表,最好在技能内部就完成 join 和汇总,不要在 Agent 层再折腾。这也体现了技能封装的价值:数据口径统一藏在技能内部,外部永远拿到的都是同一个口径的周报结果。
3.3 把技能接入 Agent 主流程
写好了 Executor,下一步就是把它注册进主 Agent。现在业界通用的做法是 Function Calling 或 MCP。我以最简单的 OpenAI Function Calling 模式为例,把 skill.yaml 信息转成工具描述,让模型看到技能入口。
# agent_bridge.py import json from openai import OpenAI client = OpenAI() skill_tool = { "type": "function", "function": { "name": "excel_weekly_report", "description": "当用户要求生成销售周报、周复盘、每周汇总时使用。支持按截止日期、产品线、区域过滤。返回周报文本和总销售额。", "parameters": { "type": "object", "properties": { "report_date": { "type": ["string", "null"], "description": "统计截止日期,格式 YYYY-MM-DD,可为空" }, "product_line": { "type": ["string", "null"], "description": "产品线名称,可为空" }, "region": { "type": ["string", "null"], "description": "区域名称,可为空" } }, "required": [] } } } def run_agent(user_message: str): messages = [ {"role": "system", "content": "你是一个销售运营助手,技能列表见 tools。"}, {"role": "user", "content": user_message} ] response = client.chat.completions.create( model="gpt-4o-mini", messages=messages, tools=[skill_tool], tool_choice="auto" ) msg = response.choices[0].message if msg.tool_calls: call = msg.tool_calls[0] args = json.loads(call.function.arguments) from executor import main result = main(args) messages.append(msg) messages.append({ "role": "tool", "tool_call_id": call.id, "content": json.dumps(result, ensure_ascii=False) }) final = client.chat.completions.create(model="gpt-4o-mini", messages=messages) return final.choices[0].message.content return msg.content这段代码适合做原型验证。真正生产环境里,我会再加一层“技能注册中心”,把几十个技能统一管理,注册中心负责维护名字唯一、版本兼容、权限校验。大模型那边只暴露一个入口:route_to_skill(skill_name, args)。这样可以避免每个技能都往模型里塞一个 tool 定义,导致上下文窗口被占满。
3.4 跑一遍真实效果与迭代方案
用了上面的代码,实际演示一下用户输入“帮我做一份上周销售周报,只看 A 产品线”会发生什么。
模型经过 Function Calling,大概率会生成这样的调用参数:
{ "report_date": "2025-01-12", "product_line": "A", "region": null }注意这里模型非常聪明地把last week转换成了具体日期,又识别出“产品线 A”填进了product_line参数,region为空表示不限制。Executor 收到后,自动把日期推到上周一 2025-01-06 到上周日 2025-01-12,查询 A 产品线的所有区域数据,聚合后返回内容。
最终用户收到的响应大概是:
销售周报(2025-01-06 至 2025-01-12) 总销售额 452000.00
- A / 华东: 128000.00
- A / 华南: 96000.00
- A / 华北: 74000.00
- A / 西南: 68000.00
- A / 西北: 86000.00
如果第一次跑出来结果不对,先别急着怀疑模型。我一般会先看两个地方:一是模型传参有没有问题,比如日期被少推了一周;二是 Executor 内聚数据有没有问题,比如地区维度是不是按省而不是按大区聚合。这两种问题处理方式完全不同,前者改 description,后者改 SQL。分清楚症状属于哪一层,才能快速定位。
我还强烈建议给技能模块加一个--dry-run参数。平时让技能走完所有内部步骤但不执行外部副作用(不写库、不发消息),把结果打印出来。这样调 caption 和权限时,既不会污染真实数据,也能快速看到每一步输出。这个习惯帮我避免了好几次线上误发消息的事故。
4. 多个技能并存时,怎么让 Agent 选对、做对、不越界
4.1 技能注册表与路由策略
当技能数量超过三个之后,就得把技能清单当“产品目录”来管理。我不会把所有技能一股脑全塞给模型,而是维护一个注册中心,每个技能带上标签,比如“数据统计”“文档处理”“消息通知”“审批写操作”。在每次会话开始时,先根据用户意图生成候选技能列表,再让模型做精确选择。这样既降低了模型注意力分散的概率,也减少了上下文 token 消耗。
举个例子,用户说“把刚才的日志用邮件发给我”,候选列表里可能有log_parse和email_sender两个技能。如果只有一个技能 A 能处理日志,另一个技能 B 能发邮件,那模型需要连续调用两个技能,而不是试图找一个“既能解析日志又能发邮件”的全能技能。注册中心可以支持链式路由:先命中log_parse,拿到解析结果后,再根据结果触发email_sender。这种链式调用比让模型自己猜要稳得多。
在实现上,我会给每个技能加一个tags字段,路由时用向量检索或关键词匹配做预筛。比如 “日志”“解析”“异常”这些词会命中log_parse,模型就只需要二选一。预筛结束后,真正精确的选择还是交给模型本身,因为它最擅长理解上下文里的细微语义。
4.2 技能冲突和模糊描述的处理
技能多了,自然会出现两个技能都“看起来合适”的情况。比如用户说“帮我看看这个月的销售额”,既可能触达dashboard_quick_view,也可能触达excel_weekly_report。如果两个技能都注册,模型有时会乱选。我的解决方案是给技能描述里加上“排除条件”。
比如:
dashboard_quick_view描述里写:“当用户只想快速查看图表数据而不需要发送文件时使用”;excel_weekly_report描述里写:“当用户要求生成周报、发送汇总文件或需要周期性报告时使用”。
描述里的边界条件虽然不显眼,但对模型决策影响很大。另外,我还会动手做一层模糊消解:如果两个候选技能的 embedding 相似度超过阈值,就先向用户确认,而不是让 Agent 自作主张。这个方法牺牲了一点流畅度,但换来了非常可观的正确率。在涉及写操作或外部通知时,我强烈建议保留“二次确认”这个环节。
4.3 权限隔离与失败兜底
技能包里的代码,自己就在安全边界问题上吃过不小的亏。我曾经把一个带requests.post功能的技能注册进主 Agent,结果模型因为上下文里有“删除上次的错误记录”这句,直接调用了删除接口。从那以后,我在权限设计上做死了几条规则:
- 执行器里所有写操作,无论是写数据库、发邮件还是删除资源,必须在函数命名上有明确标识,比如
send_email就不能写成process_message; - 技能 YAML 里的
permissions.deny必须在启动时加载进一个白名单检查器,Execuotor 内任何 outbound 请求都要先经过检查器; - 高风险操作(创建实例、清空数据、批量发消息)必须经过“技能内二次确认子流程”,无论模型怎么选都绕不开。
失败兜底同样重要。Agent 技能在运行时可能遇到数据库超时、Webhook 地址变化、参数格式异常。我在所有技能里统一加了重试和降级:重试次数默认 2 次,重试间隔指数退避;如果第二次仍失败,返回状态status: degraded,并带上错误码和人工处理建议,而不是直接抛异常。这样在调用链上层,Agent 可以根据返回状态判断是继续追问用户,还是转人工。
5. 常见翻车现场与排查手册
5.1 我踩过的五类典型问题
这个章节没有高深理论,全是真实操作里积累出来的“血泪表”。我整理了五个高频问题,也是我推荐大家在 Agent 技能上线前重点自测的场景。
| 症状 | 常见原因 | 排查与解法 |
|---|---|---|
| 技能压根不被调用 | description 里缺少触发同义词,或候选技能过多导致模型注意力分散 | 检查 description 中是否覆盖用户真实说法;收紧候选技能列表,必要时增加预筛 |
| 调用技能但参数全是空 | 参数说明不够明确,没有给实例;模型从上下文里找不到实体映射 | 在参数描述中追加示例,比如“支持 2025-01-01 这种格式”;把用户常见说法拆进参数描述 |
| 返回结果格式混乱 | 技能内部不同分支返回结构不一致;没有统一响应 schema | 强制统一返回 JSON 结构,把status、content、data作为固定字段 |
| 写操作被误触发 | 权限边界没设置,或者函数命名掩盖了副作用 | 在技能 YAML 中声明 deny 权限;执行器启动时加载权限检查器;高风险操作加二次确认 |
| 多个技能缩成一团 | 技能粒度太大,一个 executor 里塞了太多无关逻辑 | 按“单一职责”拆分:一个技能只解决一个业务能力域,拆到不能再拆为止 |
除此之外,还有一个特别容易被忽略的问题:技能包的版本管理。我经历过一次很尴尬的线上事故,主 Agent 启动时加载了旧的executor.py,而新技能已经更新到 2.0.0。那次事故的根因是注册中心没有检查版本号。后来我在所有技能包 YAML 里强制加版本号,并且要求在注册时做最小版本校验,低于期望版本的一律拒载。
5.2 高效排查的两条命令与一个习惯
排查 Agent 技能问题,最高效的方式就是给执行器加日志和 dry-run,这一点真的怎么强调都不过分。我在技能里统一封装了一个debug开关,环境变量SKILL_DEBUG=1打开后,会在每个步骤的关键节点输出入参、中间变量、耗时和返回结果。排查时命令很简单:
SKILL_DEBUG=1 python -m skills.excel_weekly_report --args '{"report_date": "2025-01-12", "product_line": "A"}'这样跑一遍,就能看到参数解析是否正常、哪一步查询速度慢、哪一步返回了空列表。比起在 Agent 上层反复打日志,这种直接驱动技能执行器的方式排查效率高好几倍。
另一个习惯是给每个技能固定一个“黄金样本集”。我准备了大约二十条真实用户输入,比如“拉一下上周的数据”“来一份销售总结”“发到周报群里”。每次技能代码或描述有改动,就用这批样本跑一遍回归测试。这样能快速发现某个改动是否让其他场景退步。相比靠人肉回归测试,这省下的时间非常可观。
5.3 后续迭代方向:从单技能走向技能包生态
当技能库越来越大,我正把精力放在两个方向:一个是把技能打包成标准格式,与 MCP 协议对齐,这样不同的 Agent 框架能互相复用技能包;另一个是给技能加“评估指标”,记录每个技能在真实调用中的成功率、参数命中率、耗时分布,用来反哺技能描述和参数设计。
我个人觉得,Agent Skills 会成为 Agent 工程化中最容易被低估的资产。模型能力是公共的,但技能包是团队的私有积累。做的好,就像给团队沉淀了一整套“数字化作业手册”;做不好,Agent 永远停留在 demo 阶段。至少在我接触的项目里,真正拉开差距的,往往不是模型的选型,而是技能工程做的扎不扎实。
最后分享一个细节:我在给技能包命名时,从来不用tool_开头,全部用业务动词,比如generate_weekly_report、parse_resume_file。看似无关紧要,但当你同时维护三十多个技能时,一个清晰的命名规则,会让路由、日志、权限审计整个过程都舒服很多。Agent 技能这件事,本质上就是把“让模型更听话”的期望,转化成“让系统更规范”的工程实践,越早想明白这一点,后面的路会越走越顺。