资讯动态

大模型Agent技能层实战:从技能描述到路由策略的工程化指南

发布时间:2026/10/14 9:51:12 来源:尧图企业网站定制
我一直觉得在大模型应用开发里最容易被低估的技术层就是“技能层”。代码写好了、提示词调好了Agent却经常像没睡醒一样明明有检索工具不用非要凭记忆瞎编或者技能少的时候一切正常技能一多模型反而开始乱点名把搜索当写入、把创建当查询。折腾了半年的Agent项目之后我把零散的经验整理成了一个叫 agent-skills 的工程实践集合。这个标题听起来简单实际做起来涉及技能的声明方式、注册表设计、参数Schema约束、调用日志埋点、命中率评估等一系列问题。它适合正在做RAG问答、浏览器自动化、办公流程Agent、代码生成助手这类场景的开发者参考。今天把这些沉淀下来的东西摊开讲一讲。1. 技能抽象把“会做事”变成一张能被模型读懂的清单1.1 最初我把能力塞进代码里结果模型一次都没调用过我最早做Agent原型的时候习惯是“写一个函数然后让提示词告诉模型有这个函数”。比如做了一个查询库存的函数我就在系统提示词里写一句“你可以调用 check_stock(product_id) 查询库存”。结果测试环境跑下来模型十次里只有一两次会调用大多数时候直接凭上下文编一个数字。当时我还以为是提示词写得不够清楚反复加了各种描述、示例效果还是很差。后来我试着把问题抽象了一下Agent模型的决策输入是一段文本它只能通过文本感知周围有什么工具。藏在代码里的函数、注释里的说明对模型来说是“不存在”的。唯一能让它知道某个能力存在的途径是给它一份结构化的、可阅读的能力说明书。这个认知是我整理 agent-skills 的起点。所以我重新定义了“技能”这个概念一个技能应该是一个自包含的单元包含模型需要的全部描述信息、调用需要的参数结构和执行逻辑而不是裸的一个函数。让模型发现能力、理解能力、调用能力是技能层的三个核心任务三者缺一不可。1.2 技能的三个层次声明、实现、元数据在 agent-skills 里我把每个技能拆成三层来看声明层给模型和路由系统看的东西包括技能名称、描述、参数Schema、使用约束。这一层决定了模型“知不知道”这个技能以及“能不能正确拼出调用请求”。实现层真正执行的代码逻辑包括参数校验、内部调用、异常处理。这一层决定了技能到底能不能完成任务。元数据层记录技能版本、权限、适用场景、依赖关系以及监控用的调用统计。这一层主要是给人看的也给上层路由和评测系统用。这三层分开之后很多问题就变清晰了。比如之前困扰我的“模型不调用”问题几乎都出在声明层而“模型调用了但结果不对”多半是声明层和实现层之间没对齐。后面排查的效率也因此高了很多。可以这样类比技能是菜单实现是后厨的做菜流程。顾客模型只看得见菜单他指着菜单点菜后厨按流程做。如果菜单写得含糊不清顾客就会点错菜如果菜单和后厨实际做法对不上顾客点的和端上来的就不是一道菜。所以技能设计第一个重头戏永远是“菜单文本”怎么写。2. 技能描述怎么写才能提高命中率我在重构描述时踩过的细节2.1 描述的信息结构用途、边界、输入输出、负面约束我先前的写法是典型的工程师思维“这是一个库存查询函数参数是product_id返回库存数量。”给机器看没问题给模型看就差太多了。模型做选择的时候本质是在做语义匹配——它读用户的问题然后在所有技能描述里找语义最接近的那个。描述里没有的语义它就匹配不上。后来我把技能描述调整为四个模块的信息结构用途这个技能解决什么问题一句话说明场景比如“查询某商品在指定仓库的实时库存数量”。边界哪些情况不该用这个技能。这一点我最开始完全忽略。后来发现负面约束反而能显著降低错误调用。输入输出参数含义、格式、单位、示例值以及返回体的关键结构。负面约束明确写“不要在用户没有提供仓库时猜测仓库”“不要用本技能做价格修改”这类约束。我印象比较深的一个例子我有一个“按文档标题搜索”技能一开始描述是“通过关键词在文档库中搜索文档”。模型在遇到“帮我把XX文档的正文总结一下”时也会优先调用这个搜索技能搜出来一篇空文档就开始瞎总结。后来我在描述里补了边界说明“本技能仅返回文档标题和路径不返回文档正文如需正文请使用read_document技能。”再测同样的问题模型的选择就明显合理了。2.2 参数Schema的“可观察性”设计参数Schema这一层很多人觉得就是把类型写对就行但实际踩坑之后才发现Schema里的description字段是模型拼参数时的重要参照。同一个字段写得泛泛和写得具体正确率差很多。举个我实际重构过的例子。原先查询接口的Schema是这样的{ type: object, properties: { query: { type: string }, top_k: { type: integer } }, required: [query] }我把这个Schema改成了这样{ type: object, properties: { query: { type: string, description: 检索关键词建议从用户输入中提取核心名词短语不要整句复制 }, top_k: { type: integer, description: 返回结果数量取值范围1-20默认5 }, user_id: { type: string, description: 必填。传入当前用户的租户标识用于权限过滤不能从库中猜测 } }, required: [query, user_id] }改动之后最明显的变化是模型不再把一整句话直接塞进query也会记得传user_id了。这里的关键是把字段的格式要求、取值建议、来源位置都写清楚。尤其“参数从哪里来”这种信息很重要比如“从用户输入中提取”“从会话上下文中取当前用户ID”“不要在库里猜”。模型不知道参数怎么来就只能瞎猜一猜就错。还有一点要注意枚举类型的字段我会把合法值全部列出并且每个值给一句简短解释。比如工单状态字段只写“状态枚举open、closed”模型还能犹豫一下写成“状态枚举open表示待处理、closed表示已关闭、pending表示等待用户补充材料”模型基本不会把状态传错。2.3 同义技能冲突很多误调用源于功能重叠技能少的时候问题不明显。技能库超过二十个之后我发现大量错误调用不是模型笨而是两个技能的功能边界确实重叠了。比如我的技能列表里同时有“查询项目进度”和“查询项目成员最近动态”两个技能。前者描述写的是“查询指定项目的整体进度、当前阶段、完成百分比”后者写的是“查询项目成员在指定时间段内的操作记录”。表面看井水不犯河水但实际测试中用户问“我们项目最近干了什么”模型两个都会选随机性很大。解决思路是两种第一种如果两个技能本质处理同一类数据就合并成一个用内部参数区分动作第二种如果必须分开保留那描述里必须互相写清楚边界。我后来在“查询项目进度”的负面约束里写了“只返回项目整体进度相关数据不返回成员操作流水成员操作请使用query_member_activity”在后者里也写了对称的约束。这样一改命中率稳定了很多。3. 注册表与路由技能多了以后模型开始“乱点名”3.1 注册表加载与技能发现机制技能层不能是零散的必须有一个注册中心来统一管理。在 agent-skills 里我用了一个轻量实现。每个技能是一个类或一个dataclass通过注册器加载from dataclasses import dataclass, field from typing import Callable, Any dataclass class SkillSpec: name: str description: str parameters: dict handler: Callable[[dict], Any] version: str 1.0 tags: list field(default_factorylist) aliases: list field(default_factorylist) negative_constraints: list field(default_factorylist) class SkillRegistry: def __init__(self): self._skills: dict[str, SkillSpec] {} def register(self, spec: SkillSpec): self._skills[spec.name] spec for alias in spec.aliases: self._skills.setdefault(alias, spec) def get(self, name: str): return self._skills.get(name) def all_skills(self) - list[SkillSpec]: return list(self._skills.values()) def dispatch(self, name: str, arguments: dict): spec self.get(name) if not spec: raise KeyError(fskill not found: {name}) return spec.handler(arguments)注册表这一段看着简单但有一个很容易忽略的点技能注册好以后最终发给模型的“工具清单”是怎么生成的。这里有三条路可以走全量广播把注册表里所有技能都列给模型。技能少于10个时可以这么干多了模型会选择困难。分组声明按场景分成几组每组单独发给模型。适合任务边界清楚的应用。语义预筛先用一个轻量模型或一个嵌入模型把用户请求分类再只把相关技能放进模型可见列表。适合技能很多、调用高频的应用。我目前用的方案是“语义预筛兜底全量”。先根据用户问题的向量和技能描述做相似度排序取前若干个技能如果预筛结果里没有任何技能相似度超过阈值则退回全量广播避免模型完全找不到工具。这个兜底逻辑很重要不然预筛模型的一次小失误就会变成整次调用的致命伤。3.2 三种路由策略的实测对照我在模拟项目X里做了个简单对照实验同样一批技能按三种策略分别跑同一批测试问题策略技能数量调用正确率平均决策耗时失败模式全量广播3071%1.8s相似技能被随机选中分组声明3082%1.5s分组判断错误导致跨组调用失败语义预筛兜底3088%1.2s预筛阈值设置不合适时而误杀这个对照数据是单次实验的参考结果不代表所有场景的绝对结论。但它暴露了一个重要事实技能层不是“注册完就完了”路由策略本身也是决定Agent质量的关键环节。全量广播在技能少的时候没什么问题一旦技能库膨胀就会出现“模型什么都看见了所以不知道该选什么”的局面。3.3 别名与冲突消解技能名称冲突也是我实际遇到的坑。比如我既有“获取日历事件列表”也有“创建日历事件”两个技能在注册表里的名字分别是get_calendar_events和create_calendar_event。表面看没问题但模型输出工具调用时偶尔会把名字搞混尤其是当两个技能参数结构相近的时候。后来我在注册表里加了一个“别名”机制把容易混淆的变体都挂到同一个技能下面。比如“列出日历事件”“查看日程”“今天有什么安排”都作为别名指向获取事件技能这样无论模型用哪种说法注册表都能兜底命中正确的实现。注意别名不是给模型看的而是给后端的容错用的。模型仍然输出规范名但万一它在长上下文里自己“发明”了一个相似名字别名机制就能接住这个错误。4. 三类技能的封装范式查询型、写入型、组合型4.1 查询型技能声明简单但要防止参数被用歪查询型技能是最常见的一类。它的实现逻辑通常就是一个函数调用内部接口麻烦不在实现而在“参数提取”。模型从用户自然语言里提取参数经常会出现三个问题该提取没提取用户说“查一下A仓库的库存”模型把“A仓库”漏了直接用默认值或瞎编。过度提取用户说“查库存”模型编一个仓库名或商品名出来。参数融合错误把“去年”理解成“去年全年”而不是某个时间点导致查询范围扩大。我在查询型技能的封装里加了一个参数规范化层。所有入参先过一遍validator凡是枚举值不合法的直接报错返回给模型不落到真实业务代码。这一层实际上是把“模型的无序输出”转换成“系统的可预期输入”的关键屏障def query_stock_handler(params: dict): product_id params.get(product_id, ).strip() warehouse params.get(warehouse, ).strip() if not product_id: return {success: False, error: 缺少product_id参数请让用户补充商品信息} if warehouse not in [A仓, B仓]: return {success: False, error: f无效仓库: {warehouse}可选值仅包含A仓、B仓} ...这个模式的好处是即使模型参数拼错了错误也会被显式返回模型可以在下一轮根据返回信息自我纠正而不是带着错误参数继续往下走。4.2 写入型技能必须能“反悔”和“去重”写入型技能指创建、修改、删除这类有副作用的操作。它的风险比查询型高一个数量级。我在 agent-skills 里给写入型技能定了三条硬性规范必须有dry_run模式任何写操作技能都支持一个dry_runtrue参数只做校验和预览不真正落库。这样测试链路的时候可以放心跑也不会破坏数据。必须有幂等键每次写入请求带一个request_id同一个request_id重复提交不产生重复数据。这对Agent重试尤其重要因为模型可能在超时后自动重试同一个请求没有幂等键就会出现重复工单、重复订单。关键写操作要二次确认在执行删除、批量修改这类动作前让技能返回一个确认提示要求用户在对话里明确确认。这不是多此一举而是防止模型把“用户想知道能不能删”误解成“用户要求执行删除”。举个实际封装例子创建工单的handler大致长这样def create_ticket_handler(params: dict): req_id params.get(request_id) if req_id and req_id in self._processed_ids: return {success: True, duplicated: True, ticket_id: self._processed_ids[req_id]} title params.get(title, ).strip() desc params.get(description, ).strip() if not title: return {success: False, error: 工单标题必填} # 开启dry_run时不实际创建 if params.get(dry_run, False): return {success: True, preview: {title: title, desc: desc, would_create: True}} ticket_id create_ticket(title, desc) self._processed_ids[req_id] ticket_id return {success: True, ticket_id: ticket_id}一个很深的体会是写入型技能的错误处理不应该抛出异常让框架层处理而应该把错误转成结构化返回让模型看到并自己纠正。模型一旦看到“失败原因缺仓库名”它的下一步大概率是问用户要仓库名而不是放弃整个任务。4.3 组合型技能把多步骤流程沉淀为一次调用组合型技能是我后期才开始做的也是最提高Agent成功率的一类。一个复杂的业务动作如果让模型一步步调多个技能每一步都有决策误差误差会累积。比如“把A项目的成员全部加入B项目”这个操作如果拆成“查A项目成员”“查B项目成员”“对比差异”“逐个添加成员”四步模型在每步都可能犯错最后结果往往不可用。组合型技能的做法是把整套流程在技能内部编排好模型只需要调一次。对外暴露的参数只有两个源项目ID、目标项目ID其余全部封装在内部def transfer_members_handler(params: dict): src_project params.get(source_project_id) dst_project params.get(target_project_id) if not (src_project and dst_project): return {success: False, error: 需要同时提供source_project_id和target_project_id} members list_project_members(src_project) added, skipped add_members_to_project(dst_project, members) return {success: True, added: added, skipped: skipped}组合型技能的价值是降低了模型的决策负担。决策越少出错越少。代价是技能复用性下降一个组合技能往往只服务一个特定场景。我现在的取舍标准是如果某个操作在用户诉求中出现频率高且多步调用链路太长就值得封装成组合技能如果只是低频偶发需求宁可让模型多调几次单个技能也不急着做组合封装。5. 技能调用的故障排查从日志到决策链的定位方法5.1 现象分类与日志模型技能故障排查最大的痛点是看不到决策过程。模型为什么选了A技能不选B技能为什么传了这样一个参数如果日志里只有“最终调用了A技能”你根本没法判断是描述问题、Schema问题还是路由策略问题。我在 agent-skills 的监控模块里给每次技能调用记了这样一份结构化日志{ request_id: xxxx, user_query: 查一下A仓的库存, visible_skills: [query_stock, query_product, create_order], selected_skill: query_stock, raw_arguments: {product_id: 01, warehouse: A仓}, skill_response: {success: true, stock: 156}, latency_ms: 342, selection_score: 0.87 }这里的visible_skills字段尤其重要。它记录了模型当时“面前”的技能列表。如果模型选了一个技能但它当时根本看不到这个技能那就是路由策略的问题如果它能看到但选错了那通常是描述边界不清如果技能选对了但参数传错了那就是Schema和描述里的字段说明有问题。三个层面的问题从这一条日志就能拆开。5.2 一次远程检索技能的完整排查记录我举一个真实排查过的案例。技能是一个“远程文档精确检索”的功能现象是在部分问题下模型返回的是“没有找到文档”但人工去库里搜明明有。观察调用日志时发现一个奇怪现象模型把用户提供的文档ID传成了字符串结果文档ID里有个前导零内部检索处理时把前导零弄丢了自然查不到。再往深挖这个问题的源头在Schema文档ID字段没有约束格式模型可以输出“00123”也可以输出“123”两种都符合字符串类型。后来我在字段描述里增加了“必须保留原始ID的完整格式包括前导零不要做任何格式转换不要在ID前后添加或删除字符”。几轮测试后该类型的错误调用几乎消失了。这个案例让我很受触动很多Agent问题看起来是“模型太笨”实际上是我们给模型的信息不够精确。模型确实有幻觉、有随机性但大量可复现的错误根因都是我们可以改进的。5.3 三类高频故障速查表我把积累的故障类型做了个速查表每次排错先按这个走一遍能省不少时间故障类型现场现象根因方向处理手段技能未被调用模型直接给出生成结果不调任何工具技能描述没进入模型可见列表或描述语义与问题不匹配检查visible_skills调整路由策略重写描述中的用途部分技能选错调用了相似但错误的技能两个技能边界重叠缺少负面约束补充负面约束必要时合并技能或加别名消解参数传错调用了正确技能但参数为幻觉值/错误值Schema描述不精确缺乏格式、枚举、来源约束重构参数Schema补充字段描述、列举合法值技能返回被误读技能返回成功但模型后续决策仍出错返回结构不清晰模型难以重点信息将核心结果结构化为固定字段减少散文式文本5.4 技能返回内容的“话术设计”技能返回内容对模型后续决策的影响我一开始也忽略了。后来发现返回结构化的JSON比返回一大段自然语言文本更有利于模型做下一步判断。比如一个查询接口的返回原先长这样“订单状态正常数量是三个总价为一百五十六元。”模型读懂这句话没问题但要精确地拿这个数值去参与后续计算就可能出小错误。改写后{ order_status: normal, item_count: 3, total_amount: 156.0 }模型直接引用total_amount基本不会出错。这个经验我后来固化成了一个规范技能返回尽量使用“先字段、后解释”的结构把模型下一步大概率要用的数值放在最前面、最显眼的位置。技能返回不是给人看的是给模型做下一步决策用的所以重点信息的位置和格式必须按模型的“阅读习惯”来设计。6. 技能库的评估与迭代如何持续让技能列表变好用6.1 记录命中率与失败原因技能库是活的东西不是写完就固定的。我维护了一张演进记录表每两周统计一次所有技能的调用情况命中率被调用且返回成功的比例。成功率低要先看是不是模型参数传错了还是业务接口本身报错。调用频次完全没被调用过的技能要标记出来要么是这个场景本来低频要么是描述没能被模型发现。失败原因分布把失败归类成参数缺失、参数格式错误、业务规则拦截、接口异常、模型误调。归类之后才能决定改哪里。有一次我发现一个“查询组织架构”的技能上线两周一次都没被调用过但用户问题中确实有相关内容。把可见技能列表日志拉出来一看这个技能的描述里没有“找人”“汇报线”“部门”这些关键词导致语义预筛阶段就被过滤掉了。后来我在描述里补了这些业务词汇调用次数马上上来了。这个经历说明技能描述里的关键词不能只考虑技术命名还要考虑用户的自然叫法。6.2 描述改动的回归验证改技能描述是家常便饭但每次改动都可能让本来对的场景变错。我现在有一套简单的回归流程每个技能维护5到10个“标准触发问题”改动描述或Schema之后把这些问题和对应的期望技能、期望参数跑一遍看正确率有没有下降。比如我想改“查询项目进度”的描述就先把这几个问题拿出来跑基准“我们项目到哪个阶段了”“这个月能完成吗”“A项目的完成百分比是多少”“B项目最近有什么更新”每次跑完记录期望技能命中情况。如果这次改动把“B项目最近有什么更新”错误地拉到了“查询项目进度”而不是“查询成员动态”我就要重新权衡这个描述改动是否值得。这个小流程投入不大但对技能库的稳定性提升非常明显。6.3 几点后续想法目前 agent-skills 还只覆盖了我自己项目里实践到的东西。后续我打算重点补两块一是技能之间的依赖建模比如某个组合技能依赖哪些基础技能在技能下线时能自动发现影响面二是技能权限模型不同用户角色能看到的技能列表应该不同这个不能只在实现层判断也要在声明层做隔离。这两块都是实际项目中一定会遇到但很容易拖到最后才处理的问题。先把当前版本的思路沉淀下来等把新的实践验证清楚了再整理一篇续篇出来分享。最后再分享一个我用了很久的小技巧每次改完技能描述先不要急着跑完整评测拿最近一周线上日志里失败最多的三条问题单独测一遍看是否命中期望技能。如果这三条都过了再跑回归集。这样既快又能守住最关键的底线比我之前一上来就跑全量回归要实用得多。希望这篇关于 agent-skills 的实践经验对正在搭建自己的Agent技能层的朋友有参考价值。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑