不知道你有没有遇到过这种情况:你辛辛苦苦搭好一个智能体,demo阶段表现惊艳,客户当场点头,结果一进入真实业务场景,agent就开始频繁"摆烂"——要么答非所问,要么明明有工具却不去调用,要么调用了工具却把参数传错。我去年在给某制造企业的内部知识库系统做agent能力接入时,就被这个问题折腾了小半个月。后来把项目中沉淀的技能管理思路单独抽出来,做了一个内部代号为agent-skills的模块,才算是真正把"会聊天的agent"变成了"能稳定干活的agent"。
我想聊的并不是又一套Agent框架。agent-skills解决的不是大模型怎么推理的问题,而是它想动手做事时,手上有没有趁手工具、怎么知道该用哪个工具、工具参数传错了会怎样。简单说,就是给大模型造一根拐杖,再告诉它什么时候该拄、怎么拄。本文适合正在做智能体落地、被工具调用可靠性折磨过的开发者,也适合刚接触Agent开发、想避开常见大坑的新手。
1. 从一次失败的智能体交付说起:技能层为什么值得独立出来
1.1 只会聊天的agent,离"能干活"还差整整一层
先说那次让我印象深刻的交付。客户的需求不算复杂:让agent能够查询内部订单状态、能修改某个配置项、能根据模板生成汇报邮件。听起来就是三个典型工具调用场景。我当时的第一版实现非常简单粗暴,把这三个功能的说明直接写进system prompt,再把对应的函数一股脑塞给模型。
最初联调确实顺利,问"帮我查一下订单A10086的状态",agent会自己调用查询接口,返回结果也正确。但问题是,当我把这个问题放大到200多条历史对话测试时,翻车率陡然上升。模型开始混淆两个相似技能,明明应该查订单,它去调了配置修改接口;还有不少时候它坚持用自己的话编一个订单状态,压根不碰工具。
我后来才慢慢想明白:让模型在推理循环里直接处理"工具",相当于让一个项目管理经理同时兼任仓库管理员、设备操作员和质检员。不是不能干,但一定干不长久。工具的本质是一个动作,而技能的本质是一组边界清晰、描述准确、可以被模型可靠选择和执行的动作。把这两者混为一谈,是很多agent项目后期维护成本爆炸的起点。
1.2 把技能写死在提示词里的三个后遗症
那段时间踩过的坑,我总结成三个后遗症,每一个都值得后来者提前规避。
第一个是提示词长度失控。技能描述写得不够,模型就理解不了;写得太细,system prompt越来越长,既浪费token,还稀释了模型对核心指令的注意力。我当时写了十几个技能说明,prompt直接翻了四倍,响应延迟肉眼可见地上升。
第二个是技能互相污染。模型既然能在同一段上下文里同时看到"查订单"和"改配置",那它就不可避免地在语义相似的任务上打转。尤其是中文场景下,两个技能描述都带"订单"两个字的时候,模型很容易选错。
第三个是没法单独测试。技能写死在prompt里,意味着每次想调整其中一个技能,都要改prompt、完整回归、还要担心引入新的conflict。我当时想过写单元测试,但发现无从下手,因为技能说明和业务逻辑根本没有边界。
所以我才下决心把技能从agent主体里抽出来,做成一个独立的可注册、可发现、可调用、可测试的资源层。这就是agent-skills最早的雏形。
2. agent-skills的核心设计:注册、描述与参数Schema
2.1 技能描述是在教模型"什么情况下该用什么工具"
说句实在话,很多开发者写技能描述的时候,习惯性站在"自己"的角度——把函数怎么实现、返回什么字段写得清清楚楚。但agent-skills给我最大的启发是:技能描述不是写给程序员看的API文档,而是写给模型看的"任务说明书"。
模型选择技能的依据是语义匹配,它并不关心你函数内部是查了MySQL还是调了第三方接口。它只关心三件事:这个技能是干什么的、什么时候该用它、用了之后能拿到什么。
我举个例子。一个查询订单的技能,如果你只说"查询订单信息",那模型面对"帮我看看A10086跑到哪了"这种问题时,很可能犹豫该不该调。但如果描述改成"根据订单ID查询该订单当前的处理进度、物流状态与预计完成时间,适用于跟进订单执行情况的场景",模型就会在遇到进度类问题时果断触发。
这背后其实是对模型决策空间的刻意收窄。你在描述里给出的触发场景越明确,模型误选或者不选的概率就越低。我经常跟团队里的小伙伴说,写技能描述就当是在给一个理解力不错但社会经验为零的新员工写SOP,必须把边界条件交代清楚。
2.2 参数Schema要用两个版本:一个给模型看,一个给机器校验
参数定义是agent-skills里我认为最容易被低估的部分。很多人觉得参数就是函数签名上加几个类型标注,但实际上模型的参数填写行为远比想象中粗糙。
我试过让模型自己按自然语言生成参数,结果是把字符串写进数字字段、把日期写成"明天"、把枚举值写成同义中文词。后来我把每个技能的参数定义拆成两个版本。
第一个版本是"轻量版参数说明",它嵌在技能描述里,用非常直白的方式告诉模型每个参数的含义、格式、边界。比如date参数,我会注明"格式为YYYY-MM-DD,不要返回相对时间"。第二个版本是严格版JSON Schema,它不直接给模型看,而是在模型生成参数之后用来做程序化校验。
这样设计的核心原因是省token和降低出错率。完整JSON Schema动辄几十行,塞进prompt里既浪费又容易让模型"迷路";而轻量的纯文本说明,配合执行侧的严格校验,刚好能在灵活性和稳定性之间取得平衡。
2.3 命名空间与权限声明:技能库不是"万能工具集合"
还有个我深思熟虑后才加进去的设计,是命名空间和权限声明。如果说描述和参数是解决"模型会不会用",那命名空间就是解决"技能之间会不会打架"。
一个内部系统里通常会有多个技能模块,比如订单域、库存域、用户域。不同域之间可能存在同名概念,比如"id"在订单里指订单号,在用户里指用户编号。如果技能库没有命名空间隔离,模型就很容易把上下文里的旧参数套到新技能上。
agent-skills里每个技能必须声明自己归属的域,并且执行器会把技能名统一处理成"域.技能名"的形式。模型眼里看到的是order_query、stock_query这样的全局唯一标识,而不是含糊的query。
权限声明则是另一个关键保险。我在每个技能定义里加上了一个简单的资源访问范围列表,比如只读、可写、访问路径限制等。这个声明不直接影响模型选技能,但会在执行层做一次安全拦截。它解决的是模型胡来时的兜底问题,防止一个本该只读的技能被用于写操作。
3. 技能引擎的落地实现:从加载到执行的完整链路
3.1 技能发现机制:目录扫描加清单索引
光有技能定义还不够,还得有一套运行时机制让这些技能能被agent加载和调用。agent-skills的做法我把它们分成两步:启动时扫描构建索引,调用时按名分发。
技能文件放在统一的目录下,每个文件导出一个技能对象,注册表通过约定的接口来收集。我用了一段很朴素的代码来实现这个能力:
# skill_registry.py import importlib import pkgutil import skills class SkillRegistry: def __init__(self): self._skills = {} def load_all(self, package_name="skills"): for mod_info in pkgutil.iter_modules(__import__(package_name).__path__): module = importlib.import_module(f"{package_name}.{mod_info.name}") if hasattr(module, "skill"): skill = module.skill key = f"{skill.domain}.{skill.name}" self._skills[key] = skill print(f"[skill] loaded {key}") def get(self, skill_key): return self._skills.get(skill_key) def list_skills(self): return [{"key": k, "description": v.description} for k, v in self._skills.items()]我特意没有做一个复杂的插件系统,因为大部分项目的技能数量在几十个量级,用轻量的模块扫描就够了。真正重要的是执行器在把技能列表交给模型之前,会先把每个技能的描述拼装成一段紧凑的文本,而不是把整个技能对象序列化进去。
3.2 调用拦截与参数校验:让错误结构化成可处理的信息
技能被模型选中之后,并不是直接执行函数那么简单的。agent-skills在中间加了一个校验层,这是保证线上稳定性的关键一环。
模型返回的是一个JSON格式的参数表,但内容可能是残缺的、类型错的、甚至包含多余字段。我的做法是先用JSON Schema做一次严格校验,校验失败时回传一段结构化错误,而不是直接抛异常。
# dispatch.py from jsonschema import validate, ValidationError def dispatch(registry, skill_key, arguments): skill = registry.get(skill_key) if skill is None: return {"ok": False, "error": "skill_not_found"} try: validate(instance=arguments, schema=skill.parameter_schema) except ValidationError as e: return { "ok": False, "error": "invalid_parameters", "detail": e.message } try: result = skill.execute(**arguments) return {"ok": True, "result": result} except Exception as e: return {"ok": False, "error": "execution_failed", "detail": str(e)}我总结出来的一个心得是:校验错误哪怕没法直接被模型纠正,也一定要控制住格式。如果返回的是花里胡哨的堆栈信息,模型基本没法处理,但如果你明确告诉它"参数date格式不对,需要YYYY-MM-DD",它有相当大概率可以在下一轮自我修正。
3.3 技能执行结果如何回传:让模型看懂工具输出
校验能让模型把动作做对,但执行结果的回传同样会决定agent是否能继续完成任务。很多输出在人类程序员看起来完全正常,放到模型面前就是一份"天书"。
我强调的输出规范是三个:结构简单、语义明确、把关键状态放前面。一个查询技能返回字段,不要直接输出一个数据库查询出来的扁平字典,而是要尽量包装成业务语义。比如返回订单信息的时候,应该明确告诉模型"订单状态=已完成,当前阶段=物流签收,预计完成时间=2025-06-01"。这样模型不需要自己去理解一堆无意义的缩写和字段名。
另一条经验是:技能执行失败时,不要只回传异常文本,要主动传递"接下来可以怎么做"的提示。比如某个查询需要权限,错误回传里就带上"该技能需要管理员权限,请引导用户联系系统管理员"这样的建议。这能让模型把一次失败转成一个有礼貌的用户沟通,而不是傻站着。
4. 用agent-skills打磨的三个典型技能
4.1 内部订单查询技能:别让模型碰SQL
先看一个最常用的业务技能:订单查询。很多开发者一上来就想给模型透出SQL能力,让模型自己写查询语句。我觉得这在大多数内部系统里都是灾难性的设计。
在agent-skills里,我给这个技能设计的粒度是到"按订单ID查询完整状态",而不是把数据库连接暴露给模型。这样做有两个原因。第一,SQL的自由度太高,模型很容易写出语义偏差的查询,还难以察觉;第二,直接查询库表暴露的是内部数据结构,安全和稳定性都不可控。
技能封装好以后,描述里我特意写了适用于"用户询问订单进展、物流节点、预计完成时间"的场景。参数只有一个order_id,校验规则里限制为字符串格式。执行函数内部再去走已有的订单服务接口,拿到结果后转成前面说的业务语义格式。
这个技能的打磨重点在描述上。我前后改过好几版描述文本,最早只写"查询订单",效果很差;后来加上了"如果用户没有提供订单号,请先反问用户"这一句,模型才知道订单号缺失时要主动澄清而不是瞎猜。
4.2 文件操作技能:路径白名单比模型自律更可靠
做知识库类agent的时候,文件读写几乎绕不开。我接待的某客户就希望agent能帮忙分析指定目录下的报表文件,并且支持文件复制、归档等操作。
这种技能的坑在路径安全上。如果让模型自由传路径,它完全可以构造一个意外的路径去读系统其他目录。agent-skills的做法是给文件类技能增加一个路径白名单参数,在执行器里做二次校验。
具体实现时,我在技能声明的参数schema里不只有path参数,还有一个隐藏的allowed_root参数,这个参数由调用方注入,模型看不到也无法控制。执行函数在动手操作之前,先用最严格的方式判断path是否落在allowed_root之下,任何".."或者软链接绕过都会直接拒绝。
这个技能给我的启发是:不要指望模型理解"安全边界",你要在技能执行面上把边界物理化。安全策略越前置,后续出问题的概率就越低。
4.3 组合技能:把多个技能编排成业务流程
agent-skills里最有意思的部分,是支持"技能组合"。单个技能解决单一动作,但很多真实任务需要一个流程,比如"下载报表、清洗数据、生成摘要、发送邮件"。
我一开始让模型在推理循环里自己串联这些技能,结果不太理想。模型经常跳步,或者在中间某一步失败后不知道从哪里重试。于是我在技能层做了一个轻量的编排器,允许把多个技能声明成一个"流程技能"。
# flow_skill.py class FlowSkill: def __init__(self, name, description, steps): self.name = name self.description = description self.steps = steps # [-"download_report", "clean_data", "summarize", "send_email"] def execute(self, context): results = {} for step in self.steps: result = dispatch(step, context) if not result["ok"]: return result results[step] = result["result"] return {"ok": True, "result": results}组合技能的内部步骤是固定的,所以稳定性比模型自由发挥要高得多。对外部用户来说,它还是一个技能,模型只需要做一次选择。这种方式牺牲了一部分灵活性,但换来了极高的可预期性——我强烈建议在涉及资金、通知、数据删除等高风险操作时采用。
5. 从能跑通到稳定运行:调试、评估与维护的实操经验
5.1 给技能的调用过程装上摄像头:可观测性不能事后补
我把agent-skills放到内部工具里跑了快一个月之后,最大的感受是:技能调用过程的日志和可观测性,是你排查问题最重要的放大镜。
每个技能无论是被成功调用还是被校验拦截,都应该留下结构化日志。我记录的字段包括:技能key、模型生成的原始参数、经过校验后的参数、执行耗时、返回结果摘要、错误信息、以及当时对话上下文里的用户意图片段。
有了这些日志,你才能回答三个灵魂问题:模型是不是反复调用错技能?校验拦截了多少本不该发生的调用?技能执行失败是参数问题还是函数内部问题?
我见过很多项目一开始不重视日志,等到线上问题频发才开始补,结果历史数据一片空白,只能靠猜。这是我非常想提醒大家的一点:技能库的日志能力应当在第一版就设计进去,否则后面怎么优化都是盲人摸象。
5.2 回归测试:用"技能用例集"守住底线
技能描述是可以迭代的,但它每次改动都可能影响模型的调用倾向。我在agent-skills里维护了一个"技能用例集",本质是一份测试用的对话样本库。
这个用例集不需要很大,但覆盖每个技能的触发场景、相似技能易混淆场景、缺参场景、参数错误场景等。每次改动任何一个技能描述、参数schema或者编排流程,我都会跑一遍这个用例集,看模型的选择和执行结果是否保持稳定。
实际执行方式很朴素:用例集里事先写好用户提问和期望调用的技能key,跑完自动比对。我不追求所有用例都不出偏差,但我会特别关注那些曾经出过问题的"坏样例",它们才是防止技能回归的最佳防线。
对于没有条件接自动化测试的项目,退一步的做法是至少维护一份手工回归清单,每次发布技能库之前逐条过一遍。这个动作虽然原始,但能拦下大部分低级回归问题。
5.3 版本与灰度:技能库改起来也要讲究上线策略
最后聊一下技能库的维护节奏。我承认技能库和普通业务代码在迭代方式上有一个显著差别:技能描述直接作用于模型行为,而模型行为存在不确定性,所以很多时候不能"拍脑袋直接全量上线"。
agent-skills里我给每个技能加了版本号,所有调用都会把技能版本记录到日志。当需要调整一个技能时,我可以先在测试环境跑一遍case,再灰度放到部分会话里观察指标。指标看两个:一个是技能调用准确率,一个是用户在技能流程内的完成率。这两个指标只有拿到线上真实数据才最靠谱。
有人会觉得这套流程太重。但实际上,技能数量到几十个、日调用量到几千次的时候,没有版本和灰度,一次改坏一个描述可能就要返工半天。我个人现在的习惯是:小步快跑,但每次发布都是一个可回滚的单元,绝不一次性同时改十几个技能。
最后再分享一个小技巧:agent-skills的技能描述文本,我会刻意在每轮迭代时做一次"冗余和极简"的对比测试,把同一技能写一长一短两个版本,在用例集里跑一轮,看哪个误判率低。结果往往反直觉,很多时候多写的那几句话反而帮模型锁定了正确语义。这个习惯让我对技能库的每一处改动都有数据支撑,而不是凭感觉上线。