
如果你正在做一个Agent项目大概率会遇到那个经典瓶颈功能越堆越多工具函数越加越复杂但大模型选择工具的准确率反而越来越差。我们一度被“工具函数越加越多、调用准确率越来越差”这个问题卡了很久直到把思路从“堆工具”换成“搭技能体系”——也就是给Agent配置一套可注册、可检索、可组合的skills库让模型不再面对庞大的工具海而是按需选择当前任务真正需要的能力单元。这篇文章是我在项目里把agent-skills落到生产环境后的一些完整复盘包括为什么需要它、技能清单怎么设计、注册与检索机制怎么做、描述怎么写得让模型更准确以及我们踩过的几个坑。如果你也在做Agent应用、AI工作流或者正准备给助手类产品加“技能扩展”能力这篇应该能给你一些实际参考。1. 从“工具堆”到“技能库”这一步为什么绕不开1.1 二十个工具函数塞进System Prompt之后发生了什么先说说我最开始的做法。早前某Agent项目只有几个固定功能比如查天气、设提醒、算个算术模型调用得挺准。后来业务一扩张功能涨到二三十个我把所有工具定义全部序列化后塞进System Prompt里让大模型自己挑。结果很快就出问题了。首先是Prompt膨胀。每个工具定义都带名称、描述、参数Schema和示例二十个工具加起来接近上万token模型在长上下文里做“选择”的难度成倍上升token成本和响应延迟也跟着涨。其次是选择准确率下降。明明用户问的是“明天出门要不要带伞”Agent先调了“查询待办事项”又调了“查询日历”最后才调用天气类工具。原因不难理解工具之间描述相似、边界模糊模型在几十个选项里做决策天然容易受干扰。后来我做过一次统计在有近三十个工具定义同时注入的情况下工具选择的正确率只有六成多部分低频工具几乎永远不被选中哪怕用户的需求和它完全匹配。说到底传统工具函数是“被动等待调用”的它不知道当前对话上下文也不知道自己的能力边界只是一堆平铺的函数签名。功能少的时候没问题功能一多这个模型必然崩。1.2 技能系统和工具系统到底差在哪工具和技能的差别我用生活里的话说工具就像一把螺丝刀技能不是螺丝刀是怎么换好一颗螺丝钉的完整工序——选什么刀头、用多大力度、怎么防滑、怎么知道拧紧了。技能系统本质上做了一次“能力抽象”工具系统只提供一个函数入口技能系统则提供完整的“能力单元”包含触发条件、前置检查、执行步骤、失败处理方式、依赖关系和输出约定。工具系统把所有函数一次性暴露给模型技能系统则通过注册中心和检索器每次只暴露当前场景需要的一小部分能力。工具往往是原子操作技能可以组合和嵌套。例如“生成周报”这个技能内部可以依赖“拉取项目进度”“汇总会议记录”“格式化Markdown”三个子技能。工具系统的可测试性一般聚焦在单函数逻辑技能系统则在“模型是否会正确选择该技能”这个层面也需要测试。下面这张对比表可以直观说明维度普通工具函数技能系统暴露方式全部注入Prompt按需检索后注入上下文占用随数量线性增长相对可控能力边界不清晰显式声明触发条件和禁用条件复用与组合单函数复用支持技能间依赖与编排失败处理由外层代码处理技能内部自带降级逻辑可测试性单测函数调用决策执行结果双层测试从工具到技能的转变其实是一次分层设计。最底层是具体执行代码中间层是技能注册中心上层是决策层也就是让大模型面对“能力地图”而不是“源代码”。这个分层非常关键它直接决定了Agent能不能做“大而全”的功能扩展。2. 技能的最小可用形态清单、运行器与边界约定2.1 一份技能清单里需要写哪些字段我们把每个技能都放在一个独立目录里目录下必有一个清单文件skill.yaml用来告诉注册中心“我是什么、什么时候该用我、怎么调用我”。这是我们项目里实际用的一个最小清单id: meeting-minutes name: 生成会议纪要 summary: 根据会议转写文本生成结构化会议纪要 tags: - meeting - minutes - summarize description: | # 何时使用 当用户提供会议录音转写、会议笔记或聊天记录要求输出会议纪要、行动项、决策记录时使用。 # 何时不要使用 如果用户只是提到会议但并没有要求整理纪要不要调用本技能。 如果用户要求的内容和会议无关属于普通文本摘要请考虑使用通用摘要技能。 inputs: - name: transcript type: string required: true description: 会议转写文本或会议笔记原始内容 - name: language type: string required: false default: zh description: 输出语言zh/en outputs: - name: meeting_minutes type: string description: Markdown格式的会议纪要 dependencies: - calendar-reader # 可选依赖用于自动读取关联日程 run: mode: prompt template: templates/meeting_minutes.jinja2 fallback: | 如果无法生成纪要先尝试精简输入仍然失败则返回错误码MM_001。这个清单里有两个特别容易忽略的字段一个是summary一个是description里的“何时不要使用”。前者用来做快速检索后者用来约束模型的行为边界。我们后来发现很多“乱调用”问题根因就是技能描述里只写了“我擅长什么”没有写“我不该什么时候出现”。2.2 技能不只有Prompt代码、流程与混合实现很多人以为一个技能就是一段提示词模板实际远远不够。在项目里我们把运行模式分成了四种prompt模式纯模板生成能力适合“改写、总结、抽取”等任务。code模式直接执行Python函数或外部API调用适合强逻辑操作比如发请求、算数据。flow模式编排多个子技能每个子技能完成后进入下一步适合业务流程。hybrid模式先用代码获取数据再用Prompt整理结果例如先拉取报表数据再生成解读结论。不同模式对应不同的执行器。代码模式需要声明入口函数和参数映射Prompt模式需要声明模板路径Flow模式则要声明子技能列表和流转条件。这样设计的好处是技能的元数据足够完整注册中心可以提前做静态校验而不是等运行时才发现配置错误。2.3 一个可直接抄的会议纪要技能样例上面那个YAML已经比较完整了。对应的目录结构通常是skills/ meeting-minutes/ skill.yaml templates/ meeting_minutes.jinja2 assets/ output_schema.json calendar-reader/ skill.yaml run.py weekly-report/ skill.yaml flow.yaml run.py schema.json实际编写时建议给每个技能配一个assets/output_schema.json输出Schema会被嵌入到模型调用约束里保证技能返回格式稳定后续解析不容易出错。这个“输出Schema”是小成本大收益的设计尤其是当技能会被其他技能依赖的时候稳定的输出格式就是技能之间协作的基础。3. 注册、检索、按需加载把技能系统真正跑起来3.1 基于目录约定的注册中心扫描与校验技能系统第一步是注册中心。我们选择目录约定大于配置也就是固定规则扫描skills/下的每个子目录解析skill.yaml做静态校验后建立索引。注册中心的加载逻辑大概是这样的import yaml from pathlib import Path def load_skill_from_dir(skill_dir: Path): manifest yaml.safe_load((skill_dir / skill.yaml).read_text(encodingutf-8)) required_fields {id, name, summary, description, run} missing required_fields - set(manifest.keys()) if missing: raise ValueError(f技能清单缺少字段: {missing}) run_mode manifest[run][mode] if run_mode prompt: template_path skill_dir / manifest[run][template] if not template_path.exists(): raise FileNotFoundError(fPrompt模板不存在: {template_path}) elif run_mode code: entry_file skill_dir / manifest[run][entry] if not entry_file.exists(): raise FileNotFoundError(f技能入口文件不存在: {entry_file}) manifest[_dir] skill_dir return manifest def load_all_skills(skills_root: Path) - dict: registry {} for skill_dir in skills_root.iterdir(): if not skill_dir.is_dir(): continue manifest_file skill_dir / skill.yaml if not manifest_file.exists(): continue skill load_skill_from_dir(skill_dir) if skill[id] in registry: raise ValueError(f技能ID冲突: {skill[id]}) registry[skill[id]] skill return registry注意两点第一校验依赖闭环比如A技能依赖BB又依赖A需要报警第二校验字段冲突不同技能的ID和名称都不能重复。这些静态检查虽然简单但能避免后面发布阶段很多诡异问题。3.2 两层召回策略关键词粗筛与语义精排注册中心构建好之后最核心的问题是怎么把技能按需加载给模型。我们没有用单一检索方式而是用两层召回关键词粗筛对用户当前消息和摘要做关键词匹配匹配技能名称、summary、tags、aliases召回一批候选保证“确定性”。语义精排把用户当前意图和历史对话摘要拼成一个查询向量对所有候选技能的描述做embedding相似度计算把最相似的若干技能排到前面。为什么需要两层纯向量检索对代码类技能名称不敏感比如“待办”和“todo”这类语义向量不一定拉得近纯关键词匹配又覆盖不了“帮我记一下明天下午三点开会”这种自然语言表达。两层一结合稳定性和泛化性都有了。def retrieve_skills(query: str, history_summary: str, top_k: int 5): combined f{history_summary} [SEP] {query} keyword_candidates keyword_recall(combined, k20) vector_candidates semantic_recall(combined, k20) merged merge_candidates(keyword_candidates, vector_candidates) ranked rerank(combined, merged) return ranked[:top_k], ranked合并候选的时候关键词命中的技能可以加权0.2语义相似的分数保持原值最终按综合分数排序。我在项目里实测过四十多个技能全部暴露给模型时选择正确率不到七成改成按需加载到五到八个技能后正确率能明显提升。这个提升不是模型变强了而是决策面变小了。3.3 上下文组装给模型看的不是全部技能检索完成之后还有一个容易被忽略的步骤如何把技能信息组装成模型能读懂的输入。我们不是把整个skill.yaml全量塞给模型而是裁剪成类似工具的JSON Schema保持轻量[ { name: meeting-minutes, description: 根据会议转写生成结构化会议纪要。适用于用户要求整理会议内容、产出行动项时。, parameters: { type: object, properties: { transcript: {type: string, description: 会议转写内容}, language: {type: string, enum: [zh, en], default: zh} }, required: [transcript] } } ]至于技能内部更详细的Prompt模板、执行步骤、依赖关系、失败处理策略都不进入模型上下文而是保存在技能执行器里等模型决定调用之后再由执行器加载。这个设计保持了Prompt的轻量也让技能内部细节可以随时修改而不影响决策层。4. 描述质量才是调用准确率的隐藏杠杆4.1 同样的技能两种描述两种效果技能描述是模型决定“要不要调用它”的唯一依据很多时候准确率上不去的瓶颈不在模型而在描述写得太敷衍。同样是“创建待办”这个技能差描述是这样用于创建待办事项参数内容、时间。模型看到这种描述很难判断“明天下午提醒我交报告”是不是应该调它。好描述是这样当用户提出要在任务管理、日历、待办清单中记录一件事或者要求设置提醒、安排后续动作时使用。 适用场景记录任务、创建提醒、预约安排。 不适用场景用户只是口头讨论计划但没有明确表示要记录时不要调用。 参数content为待办内容due_time为ISO8601格式截止时间priority可选low/medium/high。 输出创建成功后的待办ID。后者包含了三层信息触发场景、边界条件、参数约束。这就是“给模型看的描述”和“给开发者看的注释”之间的核心区别——描述不是在解释函数逻辑而是在帮模型做决策。4.2 让Alias替描述分担口语化的表达我们接入了很多口语化表达的用户。用户可能说“记一下”“安排一下”“提醒我”这些都不带“待办”二字。为了解决这个问题我们在技能清单里增加了aliases字段aliases: - 记录 - 安排 - 提醒 - 待办 - to-do - remind me这个字段不只是给关键词召回用的在给模型的描述里也会出现类似“别名记录/安排/提醒”的说法帮助模型把口语化表达映射到技能。aliases怎么来我们没有完全靠人工维护而是定期从线上日志里抽取“模型未调用任何技能但用户明显有需求”的对话总结高频表达再回流到这个字段里。4.3 描述颗粒度怎么对齐模型的决策能力描述写得太粗或太细都会出问题。太粗的例子是“提供全面信息处理功能”模型根本不知道什么时候该选它太细的例子是“先用JSON解析器将字符串转为对象再遍历数组调用API”把执行逻辑写在描述里不仅占token还容易让模型产生误解。我们内部的控制原则是描述里只保留“什么时候用、传什么参数、得到什么结果”三层信息执行细节全部放到技能内部。另外所有描述尽量控制在100到300字太长的描述在检索排序和模型读取时都会产生干扰。写完描述后我们还会做一个自查如果一个新来的工程师只读description不看代码能不能准确判断调用场景如果不能说明描述还需要改。5. 实测踩坑五个问题对应五套解法5.1 技能在库里却找不到的“沉默失败”最让人头疼的问题不是模型乱调用而是该调用的技能它不调用而且不带任何报错静默失败。我做排查时发现这类问题基本都出在检索召回环节要么关键词没匹配上要么语义相似度排序太低候选里压根没有这个技能。后来我养成了一个调试习惯每次请求都把“用户问题、检索返回的前十候选、最终注入给模型的技能列表”完整打进日志。查看时先看候选列表里有没有目标技能再去看模型到底选了哪个最后看执行结果。这样一步步定位而不是上来就怀疑模型能力。排查链路固定为检索结果 - 模型决策 - 执行状态三个环节逐层看。5.2 相似技能互相打架不如合并接口项目里有个场景特别典型邮件类功能拆成了“发送邮件”“回复邮件”“创建邮件草稿”三个技能。这三个技能描述高度重叠模型经常选错比如用户说“把草稿发给小张”模型先去调“创建邮件草稿”再调“发送邮件”白白多跑几轮。我们的解法不是把描述改得更细而是把三个技能合并成一个“邮件操作”技能把具体动作移到参数里id: email-operation name: 邮件操作 description: 处理邮件创建、发送、回复、草稿保存等操作。通过action参数区分具体行为。 inputs: - name: action type: string enum: [draft, send, reply, save] - name: email_data type: object合并不是万能的但接口语义相近、无法清晰切分边界的技能合并成“一个技能一个动作参数”往往比硬拆三个更好。反之如果两个技能的输入输出差异很大、适用场景判若鸿沟那还是要拆开。5.3 技能内部子步骤失败不能拖垮主线技能内部的子步骤很容易失败比如“生成会议纪要”依赖“读取日历”这个子技能如果日历接口超时整个纪要技能就崩了外层Agent直接报错。这暴露了一个问题技能执行器缺少局部降级能力。我们的改进方式是给执行器加了两层容错第一层是局部重试对可重试的子步骤最多重试两次第二层是降级逻辑在技能清单的run.fallback里声明备用方案。比如日历读取失败时可以利用会议转写文本里的时间信息生成纪要并标记“日历部分未验证”。这样主流程还能继续走只是质量打了折扣而不是整个链路断掉。5.4 长对话下检索漂移需要意图快照对话超过十几轮之后我们用历史摘要拼出来的检索Query会变得越来越“飘”包含了太多无关信息经常召回一些和当前意图完全无关的技能比如用户正在聊天气检索结果里却出现“排班表”技能。后来我们改成了维护一个“意图快照”而不是每轮都用整段历史做检索。具体做法是每一轮结束后用一个小模型对“当前正在执行的任务栈”做增量更新下一轮检索时用任务栈 最新一条用户消息组合成Query。这个改动之后长对话的召回准确率稳定了很多也不容易被历史噪声带偏。5.5 热更新不生效版本快照与缓存失效技能系统的迭代频率很高但我们最初遇到过“配置改了线上完全没生效”的情况排查了一圈发现是技能元数据缓存没有及时失效。后来我们给注册中心增加了版本管理每个技能清单里加version字段执行器按版本号绑定同时支持两种失效策略一种是TTL定期刷新另一种是文件系统监听变更后主动通知。更保险的是“发布快照”机制新版本通过审批后才发布到生产当前请求固定引用某个版本快照避免请求处理过程中技能配置被中途替换导致行为不一致。6. 下一步组合编排、自动生成与回归评测6.1 用Workflow把技能编排成完整工序当技能数量多了之后单个技能完成一个任务的情况越来越少更多时候是几个技能串成一条流程。我们的做法是在技能清单里增加flow字段id: weekly-report name: 生成周报 run: mode: flow steps: - skill: event-collector input_map: start_time: 上周一 00:00 end_time: 本周日 23:59 - skill: progress-summarizer input_map: events: {{ steps.event-collector.output }} - skill: markdown-formatter input_map: content: {{ steps.progress-summarizer.output }}这种编排的价值在于每个步骤独立、可复用、可测试中间任何一步失败了都能定位到具体的子技能。相比把整个流程写死在一个代码文件里编排方式更灵活也更贴近业务逻辑。6.2 从调用技能到生成技能渐进式玩法我们还在尝试让Agent自己生成新技能。做法是定期分析线上日志里“模型检索了但候选为空且用户问题反复出现”的失败样本把这些样本聚类尝试让大模型根据历史对话生成候选的skill.yaml和run.py骨架再由工程师审核上线。目前这个流程还不能完全无人化但已经能明显缩短新技能的开发时间。之前拉一个技能可能要半天现在只要人工确认描述、补两个边界分支就能发布。这个方向让我觉得“技能系统”最终会从“手写技能库”走向“技能生态”由Agent自己扩展能力边界。6.3 技能也有回归测试建立调用预期样本集技能系统上线久了最怕的就是“改了一个技能描述另一个场景的调用全乱了”。我们为此建立了一个技能级回归测试集每条样本包含三部分用户输入、期望调用的技能ID、期望的关键参数。每次技能配置变更或模型升级都会批量跑一遍这个样本集统计调用准确率和参数正确率。这个做法本质上有点像接口自动化测试只是被测对象换成了“模型的调用决策”。它能很好地兜住技能系统的底让我们每次改描述的时候都有一个量化标准而不是靠感觉说“应该更准了”。回到开头那个项目现在这套技能系统已经稳定跑了一段时间最直观的感受是新功能接入成本降下来了模型选错工具的case也少了而且每个技能都可以被独立追踪和优化。如果你也正在经历“工具越加越多、准确率越来越差”的困局建议先从盘点已有工具开始把它们拆成带边界、带依赖、带降级方案的技能单元再搭一个简单的注册和检索层你会明显感觉到Agent的行为从“碰运气”变成了“有章法”。