我接触过不少做Agent项目的团队,聊到最后大家都不约而同地卡在同一个问题上:模型本身已经很聪明了,可一旦让它去"做事情"——查个库存、发封邮件、改个工单——它就手足无措。过去我的解决方式是往System Prompt里堆工具说明,堆到后面Prompt比业务代码还长,模型开始把无关工具也翻出来调用,调用参数还传错。后来我彻底转向了一套专门的"技能层"来管这件事,也就是大家常说的agent-skills。这篇文章就是我实践这套思路的完整记录,从技能协议设计到上线后排查,再到技能库的持续演化,希望对正在做或准备做Agent的你有实际参考价值。
1. 为什么我强烈建议每个Agent项目都单独建一套skills层
1.1 从"聊天模型"到"干活模型"的那道坎
先说一个反直觉的结论:大语言模型能不能把事儿办成,难度不在模型推理,而在它身边有没有一套"趁手的技能"。ChatGPT刚火的时候,大家觉得只要模型够强,啥都能干。可真把Agent丢到业务流程里才发现,模型只能做"表达层"的工作——理解意图、生成文本、拆解步骤。真正落地的那一哆嗦,永远要落在某个具体动作上:调一个API、写一条数据库记录、上传一个文件。
这个"最后一公里"就是技能层的分内事。
我见过很多团队把工具函数直接写死在Agent主逻辑里。工具一多,主逻辑变成一坨巨大的if-else或switch-case,每次加一个工具都要动核心代码,测试回归成本高得吓人。更麻烦的是,模型的工具选择空间和提示词里的描述质量强耦合,工具描述写得含糊,模型就瞎猜,一猜就错。这就像给一个人装了无数只手,却不告诉他每只手到底能握什么。
1.2 技能不是函数,是Agent身上"可插拔的肌肉"
我在自己的项目里把"技能"定义成这样一个东西:它是Agent能够执行的一个明确动作单元,并且围绕这个动作单元,系统性地打包了触发条件、输入输出协议、执行逻辑、错误处理和说明文档。普通函数解决的问题是"这行代码怎么算",技能解决的问题是"Agent在什么情况下应该调用什么动作,调完之后怎么把结果并入对话上下文"。
如果做个类比,函数是工具箱里的单个扳手,技能则是"在什么工况下用扳手、怎么用、拧坏了找谁修"的一整套操作手册。Agent有了技能层,就不再是一个"会背百科全书的话痨",而是一个"知道自己手里有电钻、电钻用在什么场景、用完会放回原位"的工人。
我自己早期犯过的错,是直接把一个类里的public方法全部注册成"技能"。结果模型面对几十个粒度参差不齐的方法,完全不知道该选哪个——有GetUserById,又有GetUserByName,还有GetUserDetailedInfo。对模型来说,选择成本极高。技能层要做的第一件事就是用业务语义去重新包装这些函数,把三个接口收敛成一个"查询用户信息"的技能,并配好参数映射关系。
1.3 什么时候你该开始建技能库
有人问我,是不是一开始就要设计一个大而全的技能框架?我的答案是否定的。项目刚跑通Demo的阶段,只有两三个工具,写在Agent主逻辑里完全没问题。但当你遇到下面三个信号自我介绍,就该认真搭技能库了:
- 工具数量超过10个,模型选错工具的频次明显上升,或者提示词里塞工具描述已经塞到影响主任务指令。
- 同样的工具要在多个Agent或任务流里复用,比如"查天气"既要在闲聊Agent里用,又要在出行规划Agent里用。
- 开始有外部系统接入需求——你要调别人的API、别人也要挂你的技能,这时候没有一个标准协议,协作成本会指数级上升。
我见过的务实做法是:从第二个信号出现时就动手搭一个极简框架——一个技能描述文件加一个注册机制。别一上来就上复杂的编排引擎、状态机、图数据库,先用最朴素的字典+列表跑起来,把协议订好,后面迁移也容易。
2. 技能协议:先定契约,再写技能
2.1 一个技能描述文件的结构长什么样
技能层的第一块地基是"技能描述文件"。这个文件是给模型看的说明书,也是给执行引擎看的规格书。我习惯用JSON格式,兼顾人类可读和机器解析。一个最小可用的技能描述大概长这样:
{ "name": "query_user_info", "description": "根据用户ID或手机号查询用户的基本信息,包括昵称、等级、注册时间。当用户询问'我的账号信息'或需要用户ID之外的资料时使用。", "version": "1.2.0", "author": "team-core", "tags": ["user", "read-only"], "input_schema": { "type": "object", "properties": { "user_id": {"type": "string", "description": "用户唯一标识,优先使用"}, "phone": {"type": "string", "description": "手机号,仅当user_id缺失时使用"} }, "required": [] }, "output_schema": { "type": "object", "properties": { "nickname": {"type": "string"}, "level": {"type": "integer"}, "registered_at": {"type": "string"} } }, "execution": { "timeout_ms": 3000, "retry_count": 2, "backend_endpoint": "internal://user-service/get-info" }, "error_policy": { "on_failure": "return_error_message", "fallback_skill": "query_user_info_via_admin" } }这个文件里最关键的三块是description、input_schema和error_policy。description决定了模型的"选择正确性",input_schema决定了"调用正确性",error_policy决定了系统在失败时能否体面地处理问题。
2.2 入参出参设计里的三个原则
入参和出参的设计,是我踩过最多坑的地方。整理下来就三条原则:
第一,字段名用业务术语,不用底层字段名。模型不懂usr_id_str是什么,但能理解user_id。我们后台字段叫u_id,技能层一定要包一层映射,把它暴露成user_id。这不是讨好模型,而是减少模型传参出错的最直接手段。
第二,入参要宽容,出参要克制。入参设计上,允许冗余的查询条件,让模型有多条路径能凑齐参数。比如查询用户,既可以接受ID,也可以接受手机号;查询订单,可以传订单号、也可以传用户ID加时间范围。出参设计上则相反,只给最精炼、最相关的字段,其余全部丢弃,或者放在一个单独的raw_data字段里。模型上下文窗口有限,给太多无关字段等于拉低它对关键信息的注意力。
第三,所有数值型参数必须写明单位和边界。这个听起来很基础,但出错率极高。一个timeout_seconds,上层模型可能传"1"(以为是分钟),可能传"60"(以为单位是毫秒)。在description里写明"单位:秒,允许范围:1-60",模型的出错率会直线下降。
# 我常用的一段入参校验逻辑,处理模型多传、漏传参数的问题 def normalize_parameters(skill_schema, model_params): cleaned = {} for key, spec in skill_schema["properties"].items(): if key in model_params and model_params[key] is not None: cleaned[key] = model_params[key] # 如果必填参数缺失,返回可读的错误提示,而不是直接抛异常 missing = [k for k in skill_schema.get("required", []) if k not in cleaned] if missing: return {"success": False, "error": {"code": "MISSING_REQUIRED_PARAMS", "fields": missing}} return {"success": True, "params": cleaned}2.3 为什么我不用"纯自然语言描述"来调用技能
有一段时间行业里流行"用纯自然语言让Agent自己生成调用代码",也就是不定义结构化schema,只给模型一段话描述,让它自由发挥。这个概念很性感,但工程上非常难落地。模型生成的代码在语法上能跑,可是一旦涉及URL拼接、鉴权头、枚举值,它的出错率就会显著上升,而且错误是随机分布的——同一个问题,这次参数写对了,下次就写错,你甚至没法做系统的单元测试。
所以我的原则是:**自然语言只用于让模型"选择"技能,绝不让它"生成"调用细节。**选择走语义匹配,具体执行细节统统交给预定义好的schema和机器生成代码。这样哪怕模型开篇把技能选错了,至少它调用技能时传参是安全的,不会造成数据污染这类更严重的后果。
3. 我的技能库是从这四类技能开始的
技能设计没有标准答案,但我在多个项目里沉淀出了一套分类法。按能力性质,技能库里的技能基本可以分成四类,每一类在库里的写法、更新频率、故障模式都完全不同。
3.1 原子工具技能:把手伸进外部系统
原子工具技能是最常见、也是最好理解的一类,它的本质是对外部API或内部服务的一次封装。查询订单、发送短信、创建工单、修改库存,都属此类。这类技能的特点是:一个技能对应一个明确的外部动作,输入输出可预期,粒度最小。
写这类技能时,我最在意的是两件事:一是封装要薄,二是错误信息要完整。封装薄的意思是,技能层不要夹带私货,不要在技能里内置业务规则,比如"查询后自动判断金额是否超限"这种逻辑,应该放在决策层而不是技能层,否则技能就没法复用了。错误信息要完整的意思是,当外部API返回非预期结果时,技能必须把状态码、响应体、可能的原因一并吐回来,让上层决策能够据此判断是重试、换技能还是直接向用户道歉。
# 原子技能的错误处理示例:把外部错误翻译成Agent能理解的信息 def run(self, params): try: resp = requests.post(END_POINT, json=params, timeout=3) resp.raise_for_status() return {"success": True, "data": resp.json()} except HTTPError as e: return { "success": False, "error": { "code": f"HTTP_{e.response.status_code}", "message": e.response.text[:500], "suggestion": "可能是参数无效或服务端异常,可尝试修正参数后重试" } }3.2 检索增强技能:让Agent有"临时记忆"
第二类技能是检索增强技能。它的作用是给Agent提供一个"翻资料"的能力入口,也就是把RAG(检索增强生成)包装成一个技能。很多人以为RAG是单独一个模块,不该算技能。但站在Agent的角度,从一个知识库检索资料和从一个数据库查记录,行为模式完全一样——都是输入一个query、返回一段相关的内容块。把它技能化之后,一个Agent可以同时挂上"公司政策知识库""产品文档库""客户历史工单库"等多个检索技能,由模型根据问题内容决定去哪个库里翻。
这类技能的设计要点在于query改写和TopK设置。用户的一个原始问题往往很长,含大量与检索无关的寒暄,直接拿去做向量检索效果很差。我会在技能内部先做一个query改写小步骤,把问题抽成几个关键词组合,再执行检索。TopK一般不要给太大,3到5个就够了,给多了反而会让上下文里塞满不相关内容。
3.3 编排决策技能:它决定了任务往哪走
第三类技能比较特殊,叫编排决策技能。它不直接触达外部系统,而是负责"决定下一步调用什么"。在简单的Agent架构里,这个决策由模型的主推理链路完成,不单独设技能。但当你的Agent开始面对复杂任务,比如"帮客户制定一份包含机票、酒店、日程的出行计划",你就需要把一些常用的决策流程固化成技能。
我用过一个很典型的编排技能,叫"travel_planner"。它的执行逻辑不是简单调一个API,而是先调用查询航班的技能、再调用查询酒店的技能、再调用生成日程的技能,并按照给定顺序把结果汇总到一份报告里。这实质上是用代码固化了一条任务流水线。好处是,这类任务不再每次消耗大量模型推理token,且执行路径稳定可测。坏处是,它牺牲了一定灵活性。我的做法是只把"已被验证过的高频路径"固化成编排技能,低频的、需要创造性拆解的任务依然让模型自由规划。
3.4 状态维护技能:让多轮对话不"失忆"
第四类技能很容易被忽略,却是支撑Agent体验的关键,就是状态维护技能。一个Agent在跑任务的过程中,往往需要记住"刚才做到哪一步了、已经拿到了哪些信息、还有哪些没拿到"。多数Agent框架的做法是把对话历史全部塞给模型,让它自己从中取出状态,但对话一长,这个办法就会失效。
所以我把状态管理也做成了技能。比如有一个update_task_progress技能,专门记录当前任务的阶段标记和关键变量;还有一个get_task_progress技能,供多轮对话开始时恢复状态。这相当于给Agent配了一本"工作笔记"。我实测过,加了状态维护技能之后,Agent在执行一个多步骤、跨多轮对话的复杂任务时,丢步骤的频次下降非常明显。
4. Agent技能上线后的五个致命坑
4.1 技能清单越来越长,匹配却越来越不准
技能库第一个致命坑,就是随着技能数量增长,模型选错技能的频率不降反升。你以为是模型推理能力不够,其实是技能之间的"描述互扰"。当技能A的描述里出现了技能B的关键词,模型就很容易混淆。举个例子,我有一个"查询天气"技能,description里写了"适合在安排出行时使用",结果另一个"建议穿搭"技能也写了"适合在出行时参考"——模型碰到"周末要不要去爬山"这种问题时,可能会选错。
解决办法有两个。第一个办法是技能隔离,就是检查所有技能的description,确保每个技能的关键特征词是独特的、不与其他技能共享。第二个办法是分组路由,当技能超过20个,就要引入"技能分组"概念。比如先让模型在"出行类、信息查询类、订单处理类、系统管理类"这四组中做一次粗选,再在选定组内部做细选。粗选可以用非常简单的分类器,成本低,效果却好得惊人。
4.2 授权与权限边界:技能不该全知全能
技能一定要做权限管理,这是我在一次线上事故后学到的血泪教训。当时我们的Agent有一个"删除项目"的技能,设计得功能正常、参数清楚,唯独漏了权限校验。于是在一次对话中,模型误判了用户的删除意图,直接把一个未完成的方案项目删了。数据恢复花了大半天,从那之后我把技能权限列成了架构上的硬性要求。
我现在会在每个技能的执行层加上一个统一的鉴权前置步骤。步骤里检查两个维度:一是"这个技能当前用户有没有权限调用",二是"调用者Agent自己有没有执行级别"。这两个维度必须同时通过才放行。技能网关的鉴权逻辑长这样:
def enforce_skill_permission(user_ctx, skill_name, agent_level): skill = get_skill(skill_name) if not skill.allow_list.get(user_ctx.role, False): return {"success": False, "error": {"code": "PERMISSION_DENIED", "message": "当前用户无权调用此技能"}} if agent_level not in skill.agent_level_allowed: return {"success": False, "error": {"code": "AGENT_LEVEL_DENIED", "message": "当前Agent等级不允许执行此操作"}} return {"success": True}这个鉴权既保护了外部系统的资源安全,也保护了Agent自己不被用户诱导去做越权操作。初学者往往忽略后者,但模型越狱攻击里有一大类就是通过"诱导Agent调用危险技能"来实现的。
4.3 超时与重试机制:外部调用出问题时的正确反应
凡是连接外部系统的技能,一定会遇到超时、限流、短暂不可用这类问题。很多技能第一次上线时只处理了"成功"路径,没有认真设计失败路径,导致Agent在外部系统抖动时像断线木偶一样反复报错。
我总结了一套三级超时策略。第一级,每个技能内部设置一个合理的超时时间,超过即中断外部调用。第二级,技能返回一个结构化错误,其中包含"建议重试"还是"不要重试"的明确标记。第三级,Agent决策层根据错误标记决定是否重试,以及是否切换到备用技能。这里特别注意:错误标记为"幂等可重试"的操作(比如查询类)可以重试,非幂等操作(比如创建订单、扣款)绝对不能盲目重试,否则会造成重复扣款、重复下单。
4.4 技能间的隐性依赖:你以为解耦了,其实没有
技能看似是独立部署的,但它们在业务层面往往存在隐性依赖。最典型的是数据依赖——技能B需要技能A的输出作为输入。比如"生成报销单"技能依赖"查询订单"技能的返回值,如果"查询订单"改了输出字段名,报销单技能就会静默失败。
我处理这个问题的方案有两个:一是强制在技能描述文件里声明依赖关系,用depends_on字段标注,让执行引擎在启动时做依赖检查;二是给关键输出加一层"契约测试",每当上游技能变更,自动跑一遍下游技能的测试用例,不合格就阻断上线。这个机制帮我拦截过至少三次因字段改名导致的线上故障。
4.5 回滚与兼容:技能升级伤害了存量任务
最后一个坑和版本管理有关。技能升级很容易,改个逻辑重新发布就行,但存量任务怎么办?如果用户正在执行一个多轮任务,任务流中间调用的技能版本突然变了,轻则行为不一致,重则直接执行失败。
我的经验是,技能在执行引擎里一定要支持多版本共存。当一个任务开始执行时,就锁定当前技能版本号,后续调用都走这个版本,不跟随最新发布。新任务才使用最新版本。这和我们平时做数据库迁移的"向后兼容"思路完全一致。如果某个技能的非兼容变更无法避免,那就要提供"迁移脚本",在任务开始前检测旧版本任务并进行提示。
{ "skill": "create_refund", "task_ref": "task_20250321_xyz", "locked_version": "1.1.0", "current_version": "1.2.0", "migration_status": "REQUIRED", "migration_action": "confirm_with_user_before_continue" }5. 让技能库持续进化的四件事
技能库不是建一次就完事的静态资产,它会随着业务变化不断膨胀、腐化、重组。我把维护技能库的日常工作也做成了固定节奏,定期复盘。具体来说,我会做四件事。
5.1 用"失败追踪"反向敲打技能设计
我会把所有技能失败的调用记录汇总到一张大表里,每周分析一次,看失败的模式是什么。是模型选错技能、参数缺失、还是外部系统错误?选错技能,说明description有问题;参数缺失,说明input_schema设计得不友好;外部系统错误,说明要梳理限流或服务稳定性。这些失败信号就是技能设计最直接的反馈,比任何代码评审都管用。
我特别看重"归因准确率"这个指标。就是在汇总失败记录时,引擎要把"到底是哪一步失败"标记清楚,不能只笼统地记一个技能执行失败。做到这一步,后面的优化才有针对性。
5.2 给技能打分:不是所有技能都值得保留
技能和代码一样,会有坏味道。有些技能当初建起来是因为某个临时需求,之后再也没有被用到;有些技能描述和其他技能高度重叠,成了干扰源。我维护了一份技能评分表,从"调用次数、失败率、语义冗余度、维护成本"四个维度给每个技能打分,定期清退低分技能。
这里有一个容易忽视的点:删除技能比新增技能更影响系统稳定性,因为如果有存量Agent或存量任务还在引用,删除会直接导致执行失败。所以我的清退流程一定是"先停用三天,观察线上是否有报错,再彻底删除"。停用期间,引用它的旧任务会走fallback逻辑,直到确认安全。
5.3 组合竞技场:测试"两三个技能叠起来"的效果
单个技能好用,不代表组合起来好用。我见过不少Agent项目,单个技能通过全部测试,可真叠加起来就出现各种怪问题——A技能的出参接不上B技能的入参、两个技能都尝试处理同一个操作导致冲突、模型在两个技能之间反复横跳。
我设计了一个"组合竞技场",它本质上是一组预定义的多技能关联测试场景,每次技能库有新增或变更,就自动跑一遍。比如给定一个"帮用户预订酒店"的目标,同时允许模型调用"搜索酒店""查询余额""创建订单"三个技能,跑完检查预订链路是否顺畅。这个场景覆盖成本不高,但拦截组合问题的效果很好,比单测有价值得多。
5.4 技能文档与代码同源:别让Agent读到一份旧说明书
最后一个经验关乎技能库的长期维护——文档和代码必须同源。技能的description文件就是给Agent看的唯一文档,如果它和真实执行逻辑不一致,Agent就会照着错误的说明书干活。这个问题在团队协作时尤其严重:新同学改完执行逻辑,忘了同步更新描述文件,模型就在新功能上用旧描述,行为开始飘。
我在代码工程里做了一条CI检查规则:描述文件里的input_schema和代码里的参数校验函数必须严格匹配,一旦不一致,CI直接报红,不允许合并。靠这个规则,我堵住了至少五次"说明和实现"分家的潜在事故。
很多做Agent的朋友问我,说"你到底是怎么把Agent的可靠性做上去的"。我思来想去,答案其实不在模型选择,也不在Prompt技巧,而在于这个不起眼的技能层设计。把每一个能力都当作一个严肃的、有契约、有版本、有权限、有失败预案的技能来对待,Agent才不会像一个"什么都懂但什么都不敢干"的实习生,而会像一个"知道自己的工具边界、知道出了问题该找谁、也知道自己的权限禁区"的靠谱老员工。这一层的设计投入,会随着你的技能数量增长,从边际成本变成边际收益——这也是我在多个项目里反复验证过的事。