如果你接触过AI Agent开发,一定遇到过这种情况:模型本身能力很强,但让它去查天气、操作数据库、调用内部API的时候,表现却像第一次上班的新人——要么答非所问,要么干脆说"我没有这个功能"。问题往往不在模型,而在你给它配备的"技能"没有设计好。agent-skills这个概念说的就是这件事:给Agent构建一套可复用、可编排、可演进的能力单元,让模型知道在什么场景下用什么工具、以什么格式传参、期望什么结果。这篇内容当作一次完整的项目复盘,我会把技能定义、注册机制、路由策略、调试方法和避坑经验都拆开讲。
这套东西适合谁来参考?如果你正在做Agent类应用,比如智能客服、自动化运维助手、企业内部知识问答机器人,或者单纯想把LLM接到真实业务系统里,那这篇文章正好对路。如果你对Prompt Engineering已经很熟,想进一步解决"模型知道该做什么但做不了"的问题,也可以从中找到切入思路。
1. skills不是工具列表:它是一套完整的"能力契约"
很多人第一次上手给Agent配技能,做法很简单:写一堆函数,把函数名和描述塞进System Prompt,然后等着模型自己选。真实跑起来就会发现,技能一多,模型就开始乱——该调A的时候调了B,参数传错不说,面对两个功能相近的技能时甚至直接忽略可用的那个。
这里有个关键认知:skills不应该被设计成"工具列表",而应该被设计成一组"能力契约"。
1.1 从一次失败的"查天气"说起
我最早做的一个Demo是让Agent帮忙查天气并给出出行建议。我当时定义了两个Python函数,一个调天气API,一个把天气数据转成出行文案。System Prompt里写得很详细:你是助手,你可以调用get_weather获取天气,可以调用generate_travel_advice生成建议。
测试的时候模型表现很迷惑,它常常只调用get_weather,然后自己编一段"出门记得带伞"的话,完全无视generate_travel_advice的存在。更烦的是,有时候它为了"看起来合理",会虚构一个城市代码传进去,把API搞崩溃。
问题出在哪里?我给的只是一个"函数列表",没有告诉模型:
- 这个函数在什么条件下应该被调用?
- 它的输入输出和整个任务流程有什么关系?
- 如果多个函数配合工作,执行的先后顺序是什么?
1.2 技能契约的三层结构
后来我把每一个skill的定义重构成三层结构,效果立刻不一样:
第一层是元数据层,主要负责描述技能的用途、适用场景、调用边界。注意,这里不要再写"当你需要天气信息时"这种含糊话,要写具体的前置条件:"仅当用户明确询问某城市今天或未来的天气情况时,本技能才应当被激活。若用户只是闲聊天气感受,禁止调用。"这类约束性描述对模型的作用比想象中大得多。
第二层是输入输出契约层,必须严格定义参数结构。不要直接复用函数签名的默认值,要为每个参数写清楚取值范围、格式、是否必填,最好直接给出一个expectation示例。我在实际项目里会在描述里加一句"参数中对城市名的表达必须与城市代码表严格一致,不允许自行推断缩写",这句看着像废话,但能挡住大量幻觉性参数错误。
第三层是执行流程层,告诉模型这个技能被调用后会怎样影响环境,代价是什么。比如"该技能会触发短信发送,无法撤销""该技能返回的数据在30分钟内有效,过期必须重新获取"。让模型具备基本的"风险评估"意识,它才不会把一个重操作技能随手调出来。
我把这套实践放在agent-skills项目里的核心位置:技能数量一旦超过5个,单纯依赖自然语言描述维持路由准确性就会迅速失效。真正可靠的方案是给每个技能做结构化的"能力契约",让模型判断的不是"这个函数像不像",而是"当前上下文是否完全符合这个技能的前提条件"。
2. 技能定义与注册:如何让技能被模型"看得见、分得清、用得好"
定义好契约之后,下一步就是落地——这些技能要以什么形式被模型感知?目前主流做法有Function Calling、JSON Schema描述、或自定义的工具调用协议。我以自己的实际项目为例,说一下怎么把技能注册到Agent运行时中。
2.1 技能描述书写的实际案例
我项目里有个内部工单查询技能,第一版描述写的是:"查询工单状态,参数为工单编号。"运行一周后发现,模型经常把工单编号和订单编号搞混,导致查询结果张冠李戴。
改成完整描述后代码大致长这样:
skill_config = { "name": "query_ticket_status", "description": ( "查询内部工单的当前处理状态。" "适用条件:用户明确提供了以'TK'开头的工单编号,并期望获得该工单的进度、" "处理人或当前状态节点。" "禁止条件:用户提供的是订单号、物流单号、流水号等其他编码,必须先引导用户" "确认编号类型,不得尝试用其他类型编号查询。" ), "parameters": { "type": "object", "properties": { "ticket_id": { "type": "string", "description": "完整的工单编号,形如TK1234567890,必须包含TK前缀", "pattern": "^TK[0-9]{10}$" }, "need_history": { "type": "boolean", "description": "是否需要返回完整的流转历史,默认为false,仅返回最新状态", "default": False } }, "required": ["ticket_id"] } }这里有个我曾经忽略的细节:description字段里"禁止条件"这一段,比"适用条件"更管用。模型对"不该做什么"的遵守度,远高于对"应该做什么"的理解度。这可能跟训练数据的对齐方式有关,但从实测效果来看,显式写明禁止条件能让误调用率下降不少。
2.2 技能注册表与冲突消解
技能数量一多,光靠单条描述已经不够了。我会做一张技能注册表,把每个技能的核心信息汇总起来,便于运行时做初筛和冲突消解。
技能注册表大概长这样:
| 技能名 | 所属域 | 数据代价 | 副作用 | 调用优先级 |
|---|---|---|---|---|
| query_ticket_status | 工单域 | 低 | 无 | 高 |
| create_ticket | 工单域 | 中 | 创建新记录 | 中 |
| send_sms_notify | 通知域 | 低 | 触发短信,计费 | 低 |
| batch_export_report | 报表域 | 高 | 后台计算,耗时 | 低 |
这张表真正的作用不是给模型看,而是给运行时的路由逻辑用。当模型返回的技能调用请求与当前上下文冲突时——比如用户只是问问"有哪些工单",模型却调了batch_export_report——路由层可以直接拦截。拦截规则是硬编码的,不依赖模型判断。这一步我称之为"安全兜底"。
另一个必须提到的问题是技能名冲突。多个技能不能起相似的名字,否则模型的语义区分度会急剧下降。比如get_ticket_list和query_ticket_status这种,描述再清楚模型也可能混淆。我后来统一了命名规范:动词_对象_范围,保证同一域内的技能名彼此有足够区分度。
2.3 注册后的首轮验证清单
技能注册完毕不要急着上线。我在agent-skills实践里沉淀了一份验证清单:
- 用最直接的自然语言提问,模型能否正确命中技能?
- 用模糊的、缺省参数的提问,模型能否主动澄清而不是瞎猜?
- 用与技能无关的问题,模型能否明确拒绝调用?
- 用两个功能相近技能的边界问题,模型能否正确区分?
- 参数校验失败时,Agent能否给出可理解的纠错提示?
这份清单的意图很明显:技能注册的核心目标是让模型"快速命中、准确传参、克制调用"。上线前的验证必须覆盖"不该调用"的场景,因为这类错误比"该调用没调用"危害更大——至少用户能看出来Agent不会做某件事,而不是看着它把错事做得有模有样。
3. 技能路由与选择策略:从"模型自由发挥"到"工程可控"
定义好技能以后,下一个问题来了:模型实际执行的时候,到底怎么决定用哪个技能?是不是让我刚才说的那段描述自动生效?答案是:描述很重要,但不够。为了让Agent在复杂的真实对话中稳定选择技能,我加了几个工程层面的控制策略。
3.1 系统级预筛选
在模型调用前做一个硬性预筛选。具体做法是:把用户当前输入先过一次轻量分类器,判断意图落在哪个域——工单域、报表域、通知域,还是纯闲聊。如果落在某个域,就把该域相关的技能描述拼进System Prompt;如果纯闲聊,一个技能描述都不放。
这个做法的好处是,技能描述不再全部塞给模型,减轻了上下文负担,也降低了跨域误调用的概率。模型每次看到的选择范围变小,路由准确率自然上升。你可以想象成:以前给模型看一百个工具的说明书,让它挑一个;现在先按部门归类,只把相关部门的说明书递过去,模型挑错的概率自然小很多。
3.2 意图置信度与二次确认
预筛选也解决不了一切问题。当用户输入比较模糊时,比如"帮我处理一下这个订单",这种表达同时涉及工单查询、订单修改、物流跟踪等多个技能的可能,模型的初次路由置信度往往不高。
我的策略是设置一个置信度阈值。路由模块会给每个候选技能打分,如果最高分和次高分差距不大,就触发澄清追问:"您是想查工单进度,还是要修改订单?"一次澄清的成本很低,但能避免一连串后续操作的连锁错误。
这里有个经验:澄清问题要给出选项,不要开放式提问。让用户从预设选项中选,比让用户自己组织语言快得多,而且选项本身就是一种提示,能帮用户理解Agent能干什么。
3.3 技能执行的观测与回滚
调用了技能不代表事情结束,还需要观测执行结果是否合理。有些技能副作用不可逆,比如发短信、创建工单、执行转账。对这种"重操作"技能,我会在路由层设一个额外的"确认门槛":模型必须把将要执行的完整动作、参数、目标对象组织成一段可读摘要,经用户确认后才真正执行。
如果技能执行过程中发现异常——接口超时、返回数据格式不合法——要保证Agent能体面地退出,而不是硬着头皮把错误结果当作正常回答呈现给用户。我在Agent运行时里加了一个约定:任何技能返回error状态时,模型必须明确告知用户"操作未完成"并说明原因,禁止用猜测性语言掩饰。
还有一点关于回滚的经验:有些技能虽然副作用不可逆,但可以设计"反向操作"。比如发送短信之后,支持发送一条撤销说明短信;创建工单之后,支持关闭工单。把这些反向技能也注册进技能库,并建立正向技能和反向技能的关联关系。这样万一判断失误,还有补救机会,不至于直接造成事故。
4. 实战中的坑:我在agent-skills项目里踩过的十类问题
这一节分享真实跑项目时踩过的坑,我会把每个问题都拆成"表象、根因、解法"三部分。这些经验不是从文档里抄来的,是实打实调试出来的。
4.1 参数幻觉:模型编造不存在的合法参数
最常见的坑就是模型传出一个我从未定义过的参数,比如我在参数里定义了ticket_id和need_history,模型却多传了一个user_role进来。Debug日志一看,发现模型在"脑补"我的接口可能需要权限参数。
根因是System Prompt里职权边界模糊——它不确定自己有什么信息,就开始编。解决方式很直接:在技能描述里显式写一句"本技能仅接收已定义的参数,任何未定义的额外参数将被忽略"。同时在代码层做严格的参数白名单校验,直接过滤掉未知字段。
4.2 模型把"回复"当成技能调用结果
另一个高频问题:技能明明返回了JSON数据,但模型不直接转述,反而自己发挥一段"根据查询结果,我认为……"。当返回数据本身很明确时,这种发挥就变成了信息失真。
根因在于对模型角色的设定错误。我当时把Agent的角色设定成"智能助手",语气上鼓励分析,它就忍不住评论。解法是把任务的输出规范写清楚:"当技能返回结构化数据时,你的回答必须严格基于该数据,不得自行补充或推断数据中不存在的信息。若数据无法回答用户问题,直接回答'该信息暂未获取到'。"
4.3 循环调用:Agent在技能之间绕圈
还有一种画面:模型调query_ticket_status,发现工单已关闭,于是又调create_ticket,表示要重新开单,接着又调batch_export_report想导出历史记录比比看。在无人工干预的情况下,这种调用链可能无限延长。
根因是路由层缺少调用次数与逻辑循环的检测。我的处理措施比较工程化:
- 限制单轮对话中技能调用的最大次数(例如5次);
- 如果同一技能连续被调用2次以上,且用户没有新增指令,自动终止并询问用户意图;
- 在Prompt层面加入"调用的每个技能都必须对用户当前问题提供新的可回复信息,若没有新增价值,应当结束调用并直接回复"。
这招治住了多数无意义循环。
4.4 上下文遗忘导致的重参数传递
Agent要在多轮对话中保持状态是比较难的一件事。用户第一轮说"帮我查一下工单TK123",第二轮说"顺便看下历史",模型有时不再把ticket_id传进去,反而直接问用户"请问要查哪个工单"。
这类问题的根因在于没有把对话状态持久化到每一次技能调用中。解法是在Agent外层维护一个"关键状态记忆"组件:一旦某技能成功提取了关键实体,就把该实体值缓存起来,在下一次技能调用时自动注入参数,而不依赖模型自己记住。这属于工程层补位,把模型不太擅长的事用代码搞定。
4.5 描述过长的反效果
我也试过把技能描述写得非常详细,一个技能写五六百字,事无巨细地介绍。结果模型反而抓不住重点,路由准确率下降。后来我理解了:模型的注意力资源有限,描述越长,给该描述分配的权重越分散。
现在的做法是:描述控制在150字以内,核心信息前置。把"适用条件"和"禁止条件"放在开头两句,剩下的才是参数说明。如果技能背后有复杂逻辑,把详细说明放到技能执行的"说明书"文档里,不占Prompt空间。
4.6 测试时"过拟合"于真实场景
我在测试阶段犯过一个错误,就是用真实场景的模板测试太多,导致模型对模板说法响应极佳,但换个说法效果骤降。比如用户说"工单啥情况了",模型能正确处理;但用户说"我那个单子现在卡在哪儿了",模型就犹豫了。
根因是测试数据多样性不够。现在我的测试集里强制加入三类变体:口语化说法、省略关键实体的说法、包含冗余信息的说法。确保模型在噪音环境下也能正确命中技能。
4.7 并行调用时的依赖冲突
有些技能之间是存在依赖关系的,比如先要查权限才能创建工单。模型如果并行发起两个调用,可能会因为权限未确认导致创建失败。虽然LLM可以一次返回多个函数调用,但我在设计技能时要求标明depends_on依赖列表,路由层遇到依赖关系时会自动改为顺序执行。
这条经验对小规模的Demo可能不重要,但一旦技能数量上到几十个,并行依赖的正确性就会显著影响稳定性。
4.8 日志缺失导致的问题排查困难
没有结构化的技能调用日志,排查问题会非常痛苦。每次模型调用技能后,我至少记录:时间戳、会话ID、触发用户原始输入、技能名、完整参数、技能返回码、返回数据摘要、模型最终回答。这些日志不只是给程序排查用的,也是给Prompt调优提供数据的——回看哪些误调用是描述不清导致的,比瞎猜管用得多。
4.9 不做A/B测试就上线
技能描述微调后,想象中是好了一点,但实际效果如何,不能凭感觉。我养成的习惯是:每次调整描述,都准备一组固定测试集,跑一遍新旧版本的对比。记录命中率、参数正确率、误调用率三个指标。没有对比就没有伤害,A/B测试能帮你筛掉大多数"看起来更好"但实际更差的改动。
4.10 忽略技能执行的性能代价
有些技能看起来配置简单,实际执行要消耗大量算力或时间。比如batch_export_report要跑几分钟的后台计算,如果模型在一个即时对话里调用了它,用户体验会非常糟糕。我的解法是给技能标记"异步执行"属性:对于耗时超过阈值的高代价技能,先返回一个"任务已受理"的中间状态,后台跑完后通过后续通知补充结果。不能让用户对着转圈等待发呆。
5. 从单技能到技能库:组织、治理与演进
当技能数量超过某个量级后,靠一个一个配置已经不太现实,需要把技能资产沉淀成一个可持续演进的"技能库"。这一节聊组织方式与治理思路。
5.1 技能分类与命名空间设计
我在agent-skills项目里把技能按域划分:ticket域、report域、notification域、user域。每个域有独立的前缀和目录,便于检索和权限管理。更重要的是,分域之后Prompt组装模块可以按域加载技能描述,避免全量加载造成的注意力稀释。
每个技能字段里还增加了version字段。技能背后的接口升级后,不要直接改旧技能,而是新增一个版本号。这样一方面支持灰度切换,另一方面可以快速回滚。版本管理这事儿看着繁琐,真正出过一两次事故之后就会觉得值得。
5.2 技能质量评估与淘汰机制
技能库不是只增不减的。我每两周会做一次技能使用分析:调用次数、命中率、误调率、用户反馈。如果一个技能连续一段时间调用量极低且与核心场景无关,就考虑下线或合入其他技能。技能库的规模一旦失控,路由准确率会反向下降。
技能上线前还要统一过一遍"最小可用描述"检查:删掉所有冗余修饰词,看描述是否仍然清晰。很多技能描述中"强大、高效、智能"这类词价值不大,反而削弱了关键信息的权重。我现在的描述风格偏"清单式",是可以直接刷到重点的。
5.3 来自反馈闭环的持续优化
最后补充一个认知:技能库的质量上限不取决于定义技巧,而取决于反馈闭环的速度。每次模型误调用或参数错误,都要有渠道回收到"技能维护者"那里。否则同一个错误会反复出现,用户失去耐心,技能库也难以前进。
我做的闭环很简单:运行时把每次调用行为打分(命中是否准确、参数是否合规、用户是否纠错),异常行为自动进入"待优化池"。每周我只需要优先进化池中的高频错误技能,迭代节奏就会稳很多。这种方式比纯靠Prompt刷感觉更可持续。
6. 实操总结与下一步建议
最后分享几条我实操下来最有价值的体会。
第一,技能的数量和复杂度要与业务场景匹配。给只有5个用例的Demo配上50个skill,效果一定比10个skill更差。克制是技能设计里最重要的能力。
第二,任何Prompt层面的优化都不如工程层的"安全兜底"可靠。技能描述的合理设置能提升模型的正确行为概率,但要防止灾难性错误,仍需在路由层做参数白名单、次数上限、重操作确认等硬控制。
第三,开始做agent-skills之前,建议先想清楚一个场景:你的Agent最重要、最常用的一件事是什么?先把这一个技能做到极致,再扩展到其他场景。早期技能过多只会拖垮路由准确率,让Agent看起来什么都会但什么都做不靠谱。
如果你想试试,可以按我之前提到的三层契约结构先定义3个技能跑一周,记录误调用率,再逐步加量。对比一下有契约和无契约时的表现差异,会比我在这里写一千字的论证更有说服力。
我在实际开发中还有一个体会:技能的维护过程是没有终点的。模型版本、接口结构、用户语言习惯都会不断变化,今天表现良好的技能库,下个月可能就开始出现奇怪的误调用。留出持续调优的时间和机制,比追求"一劳永逸"的完美配置更有意义。这也是agent-skills这套做法的核心价值——它不是一次性代码资产,而是一套让Agent持续变聪明的基础设施。