做Agent项目最头疼的从来不是把模型接上,而是接上之后发现Agent像个失控的实习生——你给它一个目标,它有时候绕来绕去,有时候干得很好,有时候完全是另一个路子。后来我花了两周时间,把项目里跟agent-skills相关的内容重新梳理了一轮,把“一次性的口头指令”改造成“可复用、可测试、可版本化的技能模块”,整个项目的稳定性直接上了一个台阶。
所谓agent-skills,简单说就是把智能体干活时需要的“经验、流程、工具调用规范、判断规则”打包成一个个独立技能单元,而不是每次都在对话里现编。它解决的核心问题是:模型输出不稳定、工具调用不可控、团队经验难沉淀。这篇文章我会从设计思路、架构细节、实操步骤到踩坑记录,完整拆一遍我落地这套机制的过程,适合正在做智能体应用、尤其是被Agent行为失控折磨过的开发者参考。
1. 项目背景与核心思路:为什么我把技能模块从Agent里单独拆出来
先交代一下背景。我负责的项目是一个内部知识助手,早期版本就是“大模型 + 检索 + 工具调用”三层结构,用户问问题,模型决定要不要调用工具,工具返回结果再组织答案。听起来没什么问题,可一上线就暴露了典型症状:同一个问题,上午答得像模像样,下午就换了套路;让它查A系统数据,它非要去B系统绕一圈;几百行工具定义堆在系统提示词里,模型越来越“分心”,把不该做的事也做了。
最要命的是团队协作。几个人同时往提示词里加东西,今天你加一句“回答要严谨”,明天他加一句“尽量多调用工具”,后天再有人把某个工具描述改一版。提示词文件成了大型事故现场,谁改的、为什么改、影响了什么,完全没法追溯。这不是提示词工程的问题,是工程化的问题。提示词的定位是“告诉模型怎么说话”,技能模块的定位是“告诉模型怎么干活”,两码事。
我的核心思路其实很简单:参考编程里的模块化和版本管理,把Agent能执行的每一种工作方式固化成独立技能。一个技能 = 元信息 + 输入契约 + 执行流程 + 工具调用 + 输出校验 + 异常处理。Agent拿到用户请求后,第一步不是直接调工具,而是先做“技能路由”——判断当前请求匹配哪个技能,然后把执行控制权交给这个技能。所谓“控制权”,本质是用一套更严格的上下文去替换掉模糊的自由发挥空间。
这样做有三个直接好处。第一,可复用。同样的技能,今天用在知识问答场景,明天用在数据分析场景,复制过去改改参数就行。第二,可测试。每个技能都是独立单元,能单独写用例、单独验证、单独回归。第三,可沉淀。一个新人接手项目,不用逐段读Prompt才能搞懂Agent会什么,打开技能清单就知道整个系统能干哪些活。这跟代码仓库里放README是一个道理。
1.1 从“会聊天”到“会干活”的鸿沟
很多人对Agent有个误解,觉得模型会调用工具就等同于会干活。实际差得远。模型会调用工具,只是知道“有这么个功能”;会干活,意味着知道“什么场景该用、用的时候按什么顺序、输入怎么处理、输出怎么校验、出错了怎么办”。
举个小例子。模型知道有一个“发送邮件”工具,这不算技能。技能是:当用户说“通知所有未完成周报的同学提交周报”时,先调成员接口拉名单,过滤掉已完成的人,对每个未完成的生成个性化催办正文,再逐个调用邮件接口,最后统计成功和失败数量返回给用户。这几个步骤之间还有依赖关系、失败重试策略、敏感内容校验。这些不可能靠一句Prompt让模型自觉执行,必须固化成技能。
我的经验是,判断一个功能要不要做成技能,就看两个条件:是不是高频执行,是不是流程固定。高频执行保证你有动力去固化它,流程固定保证固化下来能稳定复现。两者缺一个,都先别急着做技能。
1.2 Skills 和 Prompt 到底差在哪
| 维度 | 传统 Prompt 工程 | Agent Skills 技能工程 |
|---|---|---|
| 载体 | 文本指令,写在提示词里 | 结构化模块,含逻辑、校验、工具编排 |
| 可测试性 | 靠人肉对话验证 | 可写单元测试与集成测试 |
| 版本管理 | 几乎无法追溯 | 随代码仓库版本化 |
| 触发方式 | 模型自行理解意图 | 路由层匹配意图,显式分发 |
| 错误处理 | 模型自由发挥 | 预设兜底与重试策略 |
| 团队协作 | 逐字逐句改提示词 | 各技能独立开发互不干扰 |
| 复用性 | 复制粘贴 | 引用、参数化、组合 |
当然这俩不是替代关系,是互补关系。Promp负责表达风格和语气基调,Skills负责执行逻辑和工具流程。我目前的实践中,系统提示词只保留三样东西:角色定位、通用表达规范、技能路由说明。剩下的,全部收进技能模块。这样系统提示词从三千行压缩到三百行,模型注意力集中了,行为反而更稳定。
2. 技能架构设计:一个Skill应该长什么样
把Skills讲透,必须得先给一个意象。你把技能想象成一个企业里的岗位说明书——不是“某某人员的工作职责”那种废话文档,而是一份严格到可以直接交接的SOP。每个岗位有它的入口条件(什么时候这个岗位被启动),有它的主要职责(做哪几件事)、有它的操作手册(每一步怎么做)、有它的交付标准(输出什么算干完)、有它的升级路径(遇到什么情况要上报或转交别的岗位)。Agent Skills就是给智能体的这些“岗位”做了一份份SOP。
我把一个完整的技能分成五个区块:元信息、输入契约、执行流程、输出契约、异常处理。缺了任何一个,这个技能在真实环境里就容易翻车。
2.1 元信息区:给模型一份“看得懂的说明书”
元信息是Agent路由判断时最重要的一块,直接决定模型会不会在你这个技能上触发。元信息里最核心的是三个字段:技能名称、技能描述、触发关键词。我踩过最大的坑就是把技能描述写得过于抽象。
一开始我写技能描述事喜欢用模糊的话术,比如“本技能用于处理用户关于财务报销相关的查询”。结果模型什么情况下都觉得自己该触发,特别是“相关”这种词,模型会进行非常宽泛的联想。后来我把描述改成:“当用户明确提到报销、发票、差旅费用、或要求执行报销流程时触发。仅当用户询问报销政策或需要提交报销申请时使用。普通财务咨询请勿使用本技能。”加了限定词和反例,误召回率瞬间降下来。
技能描述在编写时有一条原则:面向“路由”写,而不是面向“功能”写。你不要写这个技能能干什么,你要写什么情况下模型该用这个技能、什么情况下不该用。就像写搜索工具的提示词一样,正例、反例、边界都要说清楚。
2.2 前置条件与输入契约:把不确定性挡在门外
输入契约解决的是“这个技能接收什么参数、每个参数怎么校验、缺参数怎么办”的问题。我一直用JSON Schema来定义技能的输入结构,让技能在被调用前就能拦截掉格式不对的请求,而不是等执行到一半才报错。
以我的“会议纪要生成技能”为例,输入契约长这样:
{ "type": "object", "properties": { "transcript": { "type": "string", "description": "会议录音转写文本或人工记录的原始内容", "minLength": 50 }, "template_type": { "type": "string", "enum": ["standup", "weekly", "external"], "default": "weekly", "description": "纪要模板类型" }, "action_items_only": { "type": "boolean", "default": false, "description": "是否只提取行动项" } }, "required": ["transcript"] }这里最关键的是required字段。如果在调用之前就发现缺了必要参数,直接不进入技能执行,马上向用户反问澄清。这个动作叫“前置澄清机制”,它能避免很多后期不可恢复的流程错误。
前置条件除了参数校验,还要定义“环境前置”。比如这个技能依赖的外部服务是否可用、数据权限是否具备、当前模型是否具备执行该技能的特殊能力。环境前置不满足时,技能应该直接拒绝执行并给出原因,别硬着头皮往下跑。
2.3 执行流程:把“怎么做”写成人话
执行流程是技能最核心的内部逻辑。我的做法是先写伪代码流程,再转成真正的代码逻辑。记住一个原则:流程到每一步都要问一句“这一步可不可以被验证”,不能验证的步骤就是风险源。
拿我的“周报自动生成技能”举例,执行流程拆成六步:
- 获取用户本周的日志数据(来自日志服务API)。
- 调用大模型总结“本周完成事项”,限制输出为要点列表。
- 校验总结结果,若为空或质量过低,则重新生成一次。
- 按指定模板组装成周报正文。
- 向用户展示预览,并询问是否需要调整语气或增删内容。
- 用户确认后,输出最终版本或写入指定文档系统。
每一步的输入输出都有明确的校验点。第二步大模型输出“要点列表”,我会用正则抽取列表项,抽不出来就判失败,触发重新调用;第三步的质量校验,我会加上最少条目数限制,比如“本周完成事项少于3条就重试”。这些校验不是防御性编程的洁癖,而是Agent在生产环境里不出丑的底线。
2.4 输出契约与观测点:让失败有据可查
输出契约就是技能执行完返回给上层或用户的数据结构,也需要固化。我常见犯的错是技能输出结构不一致,有时候返回的是Markdown文本,有时候返回的是JSON,上层逻辑跟着写得乱七八糟。后来我给每个技能定了统一的输出Schema,用数据类收口,见下方示例。
from dataclasses import dataclass, field @dataclass class SkillResult: skill_name: str status: str # success / failed / partial data: dict = field(default_factory=dict) error: str | None = None trace_id: str | None = None所有技能无论内部多复杂,最后都返回这个结构。上层拿到的永远是“技能名+状态+数据”,加上一个可追踪的trace_id作为埋点。线上排查问题时,只要拿到trace_id就能把一次技能执行的所有中间日志串起来看。这是我的一个执念,所有技能必须在入口打印入参、出口打印结果、中间每个关键步骤打印状态。没有观测点的技能等于没有仪表盘的飞行,飞起来全凭感觉,一炸一个准。
3. 授人以渔:把一切都做成“技能”是泛化还是过度设计
有一阵子我的同事听了技能架构的理念之后,特别激动,说要把所有东西都做成技能——查询天气做成技能,打招呼也做成技能,甚至回复“谢谢”都要套个技能。我赶紧拦住了。技能是有成本的,技能的注册要占模型上下文,技能的路由判断要消耗决策时间,技能的代码要维护、要测试、要迭代。什么都做成技能,最终的结果就是路由混乱,模型挑花了眼。
那么到底哪些东西值得做成技能?我自己给了一套筛选条件,满足两条以上才做:
- 该操作需要多步编排(超过3步),且步骤间有依赖关系
- 该操作需要调用外部工具或系统,且调用有一定复杂度
- 该操作会重复出现在不同用户或不同场景下
- 该操作的失败影响较大,需要预设异常处理
- 该操作需要沉淀领域知识(比如公司内部规章制度、特定岗位经验)
低于这个门槛的,能用Prompt解决的,就留在Prompt里。过度设计在Agent体系里同样是一种负债。我见过一个团队的技能清单有80多个技能,每个技能的描述都写得很长,结果模型做一次路由要反复比对这些描述,延迟明显增加,还经常路由到错误的技能。技能库不是越满越好,而是越精准越好。这就像工具箱——你既希望里面东西齐全,又不希望满到找不到锤子。
值得说明的是,Skills的“粒度”也是可以调节的,存在技能之间的组合调用。我当时的架构里支持一个技能内部调用另一个技能,类似函数调函数。但这种嵌套要克制,嵌套超过两层,排查问题就开始痛苦。如果发现某个技能依赖链条太长,我更建议把这条链路直接合并成一个大技能,而不是让技能像俄罗斯套娃一样层层套住。
4. 实操记录:从零定义一个可复用的“竞品信息收集”技能
理论讲完,直接上实操。我拿项目里最近做的“竞品信息收集技能”当样板,完整走一遍定义、注册、调用的流程。这个技能是典型的“多步骤 + 外部工具调用 + 领域知识沉淀”类型,很适合用来演示。
4.1 技能定义的核心参数与选型计算
第一步,写技能描述。
name: competitor_research description: > 当用户询问国内主流产品(如AI写作工具、RPA工具、BI工具等)的竞品动态、 价格调整、功能发布、客户评价时触发。执行时先收集公开资料,再分类总结。 仅当用户明确要求调研竞品或行业动态时使用,普通的知识问答请勿触发。 trigger_keywords: - 竞品分析 - 竞品调研 - 竞品价格 - 友商动态 - 竞品功能对比这里我把trigger_keywords作为路由辅助信息。注意,虽然模型主要通过description做语义判断,但显式的关键词列表仍然有价值——它给路由逻辑提供了一套“快速路径”匹配,可以在模型理解之前先做一轮低成本硬匹配。
然后定义输入参数:
input_schema: type: object properties: product_area: type: string description: "要调研的产品领域,如:AI写作、RPA、BI分析" examples: ["AI写作", "RPA"] competitors: type: array items: type: string description: "指定的竞品名称列表,不传则由Agent自行确定Top 3-5" timeframe: type: string description: "调研时间范围,如近一周、近一个月,默认近30天" default: "近30天" required: - product_area这里有个细节很多人会忽略:一定要写examples。模型对示例的敏感度远高于对字段描述的敏感度。你写“product_area: 产品领域”,模型可能传进来“帮我看看AI写作那个方向”,有了examples: ["AI写作", "RPA"],模型就知道这里期望的是什么形态的内容了。
第三步是环境前置检查。技能执行前,先检查外部搜索服务、网页抓取服务的可用性,还有本次任务需要的API Key是否存在,然后绑定一个稳定的调用配置:
# 技能注册时的初始化钩子 async def on_load(ctx): if not ctx.secrets.get("SEARCH_API_KEY"): raise SkillLoadError("缺少 SEARCH_API_KEY,无法使用竞品调研技能") if not ctx.secrets.get("CRAWLER_SERVICE_URL"): raise SkillLoadError("缺少爬虫服务地址") return SkillConfig( max_steps=6, timeout=120, allowed_tools=["web_search", "url_fetch", "llm_extract"], enable_audit=True )前置校验不是走形式。我们线上有一次故障就是第三方搜索服务的配额耗尽,技能没有前置校验,硬跑了四步之后才在调用搜索接口时发现报错,白白浪费了时间和token。现在所有依赖外部服务的技能,在入口统一做一次轻量探活,成本极低,收益极高。
4.2 技能注册与提示词模板配合
技能定义好之后,要写进技能注册清单。这个清单是Agent路由层的“目录”,模型靠这个目录知道系统里有哪些技能、各是什么职责。我会在系统提示词里放一个精简版清单,完整版通过一个内部工具按需查询。这项设计的考虑是,上下文窗口是稀缺的,如果80个技能的完整描述全塞进头显,输入token就爆炸了。
我的技能注册清单长这样:
可用技能:8个 1. competitor_research:竞品信息收集与分析,触发词:竞品分析、竞品调研、友商动态 2. meeting_minutes:会议纪要生成,触发词:会议记录、纪要、行动项 3. weekly_report:周报生成,触发词:周报、本周总结、工作汇报 4. data_query:内部数据查询,触发词:查数据、报表、指标 5. knowledge_search:知识库检索,触发词:知识库、规章制度、文档查找 6. email_draft:邮件起草,触发词:写邮件、邮件回复草稿 7. translation:翻译,触发词:翻译、英文版、日语版 8. code_review:代码评审辅助,触发词:代码评审、Review代码 路由规则: - 如果用户请求同时涉及多个技能范围,优先拆分子任务,分别路由到对应技能。 - 如果用户请求不在以上技能范围内,使用默认对话能力回答,不要尝试硬套技能。 - 每个技能执行完后,用技能返回的结构化结果组织最终回复。这段路由规则特别重要。很多人只给模型列技能清单,不告诉模型“匹配不上怎么办”,结果模型把无关请求也硬塞给某个技能。规则里明确写了“硬套技能”是要避免的行为,相当于给模型划了行为边界。
4.3 执行流程的代码骨架与关键参数
技能执行的核心代码,我习惯用一个简单的状态机来管。不要嫌麻烦,Agent的执行流程比普通函数复杂得多,处处有分支、有重试、有回退,没有状态管理就是一锅粥。核心骨架见下:
async def run(request, ctx): # step 0: 参数解析 product_area = normalize(request.product_area) competitors = request.competitors or default_top_n(product_area, 5) # step 1: 收集公开信息 search_results = await ctx.tools.web_search( query=f"{product_area} 竞品 动态 {request.timeframe}", top_n=15 ) if not search_results: return SkillResult.status("partial", error="未检索到足够信息") # step 2: 抓取候选页面 pages = [] for item in search_results[:5]: content = await ctx.tools.url_fetch(item.url) if content and len(content) > 200: pages.append({"url": item.url, "content": content[:3000]}) # step 3: 用LLM结构化抽取 extracted = await ctx.llm.extract( data={"pages": pages, "segments": product_area}, instruction="从页面内容中提取竞品的功能变化、价格变化、市场动作,输出JSON数组,字段名固定" ) if not is_valid_extraction(extracted): return retry_with_different_split(ctx, pages) # 改变切片策略重试一次 # step 4: 汇总成报告 final_report = await ctx.render_template("competitor_summary", data=extracted) return SkillResult.success(data={"report": final_report})每个关键步骤旁边我都加了失败返回。这不是多余代码,这是Agent技能和普通脚本的区别所在——每一步都可能失败,失败之后必须有一个明确的退出路径,而不是让整个任务悬在那里。
我记得第一次写这个技能时,step 2抓页面内容直接全量返回,一篇几千字的文章全塞进上下文,没做截断和过滤,结果调用成本直线上升,还经常触发模型超时。后来统一用content[:3000]控制每篇只取关键开头,加上用正文抽取算法去掉导航栏广告区,才算稳下来。
实操过程中还有一组参数值得记录:
| 参数 | 值 | 说明 |
|---|---|---|
| top_n | 15 | 搜索结果的初始召回数量 |
| fetch_max | 5 | 实际抓取的页面数量上限 |
| content_limit | 3000 字符 | 单页内容截断长度 |
| extract_retry | 2 次 | LLM抽取失败后的最大重试次数 |
| timeout | 120 秒 | 整体技能执行超时 |
| max_steps | 6 步 | 状态机最多推进的步数 |
这组参数看起来简单,但都是踩过坑之后调出来的。比如extract_retry,一开始设了5次,后来发现如果LLM连续两次抽取结果都不合法,后面大概率也不会好,再重试只是烧钱。比如fetch_max=5,前三个抓到的页面往往词不达意,五六个之后信息才会开始重复,所以5是最低成本下的最优解。每个参数背后都有一个线上故事,这大概就是所谓经验吧。
5. 常见问题与排查技巧实录
实话说,把技能机制跑通只是万里长征第一步,真正麻烦的是上线后的各种诡异表现。我把自己踩过的几个典型问题整理成速查表,再挑几个严重的一次性聊透。
| 症状 | 典型表现 | 根因 | 排查手段 |
|---|---|---|---|
| 技能误触发 | 用户问“帮我看看报表”,结果触发了竞品调研 | 技能描述边界模糊 | 检查元信息里的反例描述 |
| 技能互相污染 | A技能执行中途调用了B技能的工具 | 技能上下文隔离不彻底 | 每个技能注入独立执行上下文 |
| 输入参数乱传 | product_area传了一整句话 | 参数描述缺少示例 | 在Schema里加examples字段 |
| 执行到一半卡死 | 调用外部接口后无响应,无超时 | 缺少超时控制 | 检查每个工具调用的timeout设置 |
| 输出不稳定 | 同样输入,输出格式不同 | 缺少结构化输出约束 | 用输出Schema + 正则校验收口 |
5.1 技能误触发:八成是描述写得像散文
技能误触发是最影响体验的问题,典型场景集中在用户请求边界模糊时。排查的时候先在日志里找到触发记录,看模型当时基于技能描述里的哪句话做的决策。有一次我排查“用户问报表却触发竞品调研”的问题,发现正是技能描述里有“行业动态”四个字,和“报表”形成了弱关联。后来我把技能描述改成“仅当用户明确要求调研竞品或行业动态时触发,查询日常业务数据请使用data_query”,误触发率基本归零。
给技能描述写“反例”真的是性价比最高的事情。你写一百句“你要善于做XX”,不如写一句“不要做XX”。模型对否定限制的服从度明显更高。
5.2 技能执行上下文隔离
这个问题的隐蔽性很强。调试时单个技能跑得好好的,多个技能串在一起就乱了。我排查发现自己技能里用来存临时任务状态的一个上下文字段,被另一个技能的同名字段覆盖了,导致第一个技能爬到一半数据变成别人的。
后来做了两件事:第一,每个技能执行时创建独立的执行上下文,技能之间不能共享可变状态,只能通过明确的输入输出传参;第二,内部字段全部加上技能名前缀,比如competitor_research_search_results。虽然是笨办法,但确实稳。
5.3 超时与重试策略
Agent执行链路长,任何一个环节超时整个任务就挂了。我的技能里给每个外部调用都单独设了超时,这是从一次线上事故学到的教训,那次是搜索接口在新版本里加了缓存重建逻辑,导致一次调用耗时翻了十倍,我的技能卡在那里半分多钟毫无反应。
现在的策略是:外部调用默认8秒超时,连续超时两次直接判技能失败并返回“外部服务暂时不可用”。大模型生成调用根据任务复杂度动态调整,简单抽取30秒,长文本总结60秒。超时后不要立即重试,先让外部服务喘口气,间隔2秒再试,最多重试两次。这个节奏是在成本和成功率之间反复拉锯之后找到的经验值。
5.4 模型输出的结构不稳定
即使输入契约和输出契约都定义好了,模型还是会在某些边界情况下输出非标准结构。我的处理方式是:不打无准备之仗,强制模型输出JSON时用固定格式,解析失败后用正则提取JSON片段再尝试解析,仍然失败的,再让模型自己描述输出内容而不是直接抛异常。
最终极的兜底方案是给所有LLM调用步骤加一个后置校验函数,凡是不符合输出契约的结果,一律打回重跑,重跑用的提示词里会附带上一次的错误样例。模型看完样例通常能自我纠正过来。通过这个机制,我把“首次合法输出率”从80%提到了95%左右,剩下的5%靠重试兜底。
6. 最后分享一点我从这套机制里真正悟到的东西
把技能机制跑顺之后,我最大的体会不是“Agent变得多听话”,而是开发团队的分工方式变了。以前所有人都在改同一个提示词,互踩是家常便饭;现在每个技能就是一个小型服务,功能内聚,边界清楚,谁负责哪个技能、改了什么、有没有测试,一目了然。这套体系带来的额外收益是:新同学上手不需要先理解整个Agent的庞大提示词,只需要读一遍技能清单,就能知道这个智能体能干什么、不能干什么,知识传递成本低了很多。
而且我后来发现,技能的“复用”价值比想象中更重要。原来另一个业务线也想做Agent问答,我们直接把技能层抽出来给了他们,他们只换了自己的知识库和业务规则,一周就上线了。技能库这个东西,慢慢会成为团队最具复利效应的数字资产。
还有一个小技巧分享给各位:技能迭代要像产品迭代一样留记录。我在每个技能入口埋了调用次数、成功率、平均耗时,每周跑一次统计。调用次数高但成功率低的技能,优先优化;调用次数低的技能,要么是定位不够清晰,要么是用户确实不需要,果断砍掉,别让死技能占着上下文空间。这套机制跑下来,技能库从最初的22个精简到最后的8个,整体调用成功率反而从91%升到了97%,说明做减法的价值一点不比做加法低。
Agent的能力天花板当然由模型决定,但Agent的可靠性和工程化水平,完全取决于团队在这些看不见的细节上打磨到什么程度。Skills这套思路,本质上就是让Agent从“会聊天的聪明人”变成“能交付的靠谱同事”,这条路还很长,但很值得走下去。