ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

我一直觉得在大模型应用开发里,最容易被低估的技术层就是“技能层”。代码写好了、提示词调好了,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_factory=list) aliases: list = field(default_factory=list) negative_constraints: list = field(default_factory=list) 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(f"skill 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_run=true参数,只做校验和预览,不真正落库。这样测试链路的时候可以放心跑,也不会破坏数据。
  • 必须有幂等键:每次写入请求带一个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技能层的朋友有参考价值。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进