Agent技能库设计指南:从提示词工程到技能工程
最近在梳理手头几个 Agent 项目时我越来越觉得真正决定一个智能体能不能上生产环境的往往不是模型本身的“智商”而是它手边有没有一套像样的技能库。所谓 agent-skills说白了就是把 Agent 要反复执行的一类能力——比如计算、查天气、解析文档、调 API——封装成可复用、可检索、可测试的技能模块让模型在任务到来时能快速找到最合适的工具而不是每次都用提示词从头现编。这个思路能解释一个很常见的现象同一个模型有人调出来的 Agent 像毛手毛脚的新人有人调出来的却像干了十几年的老手。区别基本不在提示词写得有多花哨而在能力的组织方式。这篇文章我会从 agent-skills 的设计思路讲起落到一套可以直接跑起来的轻量代码实现再把生产环境里踩过的坑一并倒出来。适合正在做 Agent 应用开发或者准备把多步骤业务流程交给模型去执行的团队参考。1. 先搞清楚 agent-skills 到底要解决什么问题1.1 Agent 的能力瓶颈不在模型在技能复用前两年大家做 Agent普遍有个错觉只要模型够强把任务描述写清楚它就能自己搞定一切。实测下来完全不是这么回事。模型每次处理同类任务时都在“重新发明轮子”。比如让它写一条 SQL它每次都从零构思表结构和字段命名哪怕上一轮已经见过同一张表让它调一次第三方 API参数格式经常写错GET 变 POST、字段名大小写对不上这种问题每天都在发生。这不是模型智力不够而是缺少稳定的执行上下文。你可以想象一个聪明但毫无经验的实习生每次交代任务都要从最基本的东西教起稍微复杂点就忘忘了还得重新教一遍。agent-skills 要解决的核心问题就是把这些“基本的东西”固化成技能卡片让模型调用的时候直接照做而不是重新推理一遍。我见过很多团队把希望寄托在“更详细的 system prompt”上结果提示词越写越长行为却越来越不稳定。因为提示词本质上是一段一次性文本它没法被单独测试没法做版本回滚更没法被多个项目复用。技能模块化之后这些问题就变成了普通的工程问题——每个技能就是一个独立单元功能单一、边界清晰坏了拆下来单独修就行。1.2 从提示词工程到技能工程如果打个比方提示词是给模型看的用户手册那技能库就是给模型准备的操作清单、工具字典和犯错日志。前者描述“你应该怎么做”是方向性的后者定义“你可以调用什么”是可执行、可观测的。我刚开始探索 agent-skills 时也走过弯路以为所谓技能就是写一堆函数让模型去选那不还是 function calling 吗后来才意识到区别。Function calling 只是把函数暴露给模型它解决的是“模型如何调用工具”的通信问题而技能系统还要管另外几件事技能怎么被发现、怎么被描述、怎么验证结果、怎么沉淀迭代、不同任务之间怎么共享。这套体系才是 agent-skills 真正值钱的地方。从工程角度看技能化的收益非常直接。第一可测试性提升了一个量级每个技能都能写独立用例跑 CI 时顺手把所有技能回归一遍模型的幻觉问题能在开发阶段就暴露一半第二Token 消耗显著下降因为不需要在每轮对话里都重复粘贴完整指令和示例第三故障定位变得简单任务出错时可以直接看是哪个技能返回了脏数据而不是在一大段对话里猜模型哪一句理解偏了。2. 技能系统设计核心模块与关键选型2.1 原子技能、复合技能与技能注册表在设计技能系统时我习惯把技能分成两种粒度原子技能和复合技能。原子技能是不能再拆的最小动作比如“计算一个数学表达式”“读取某个 URL 的 JSON 内容”“把一段文本做摘要”它们通常直接对应一个函数或一个 API 调用。复合技能是把多个原子技能按固定顺序编排起来的结果比如“根据用户需求查数据库再把结果格式化成 Markdown 表格返回”这中间就串了查库、格式化两个动作。这两种粒度各有各的用途。原子技能讲究小而稳方便复用和测试复合技能讲究编排效率避免模型每次都要自己串联多步操作增加犯错概率。我见过一个很经典的案例同样一个“从订单列表里算总金额”的需求让模型直接写代码去处理偶尔会漏掉税费、折扣这些字段但封装成一个复合技能之后逻辑固定了只要输入格式对输出就一定正确稳定率从 80% 直接拉到 99% 以上。不管原子还是复合技能最终都要注册到一个统一的地方这就是技能注册表。注册表管理技能的元信息技能名称、描述、参数 schema、版本号、所属目录。模型本身不需要知道技能怎么实现它只需要通过注册表拿到一份“技能清单”像翻菜谱一样挑选合适的菜来做。选型上注册表不必引入重框架用简单的字典或者 SQLite 都行关键是访问接口要稳定。关于代码和配置的边界我的建议是凡是经常逻辑调整的尽量写成配置驱动凡是固定不变的写成代码。比如技能的参数定义、使用场景说明、返回格式约定这些属于“会频繁微调”的部分放 YAML 或 JSON 里非常合适改完不用重新部署就能生效。2.2 技能描述写不好再强的检索也白搭技能系统里最容易低估、也最影响效果的是描述。很多人在注册表里只写一句话“计算器用于计算”。这种描述基本等于没写模型根本不知道什么场景该选它、参数怎么传、返回什么格式。一套好的技能描述至少要包含四块内容它做什么、什么场景下用、参数的具体含义和类型、返回数据的结构。举个例子一个查天气的技能如果描述写成“根据城市名返回实时天气数据”模型大概率会在需要“未来三天预报”时也误调它如果写成“仅支持实时天气查询参数 city 是城市中文名返回包含 temperature、wind、precipitation 字段的 JSON”模型就能准确判断什么时候该用它、什么时候不该用。这里要给个提示描述里一定要写清楚边界和限制。模型对技能的选择很大程度依赖描述里的“允许/禁止”信号你不写限制它就会默认技能什么都能干。我见过一个查电商订单状态的技能因为描述里没写只支持近 90 天订单模型就直接拿它去查一年前的订单返回空结果后还一本正经地告诉用户“查无此单”误导性极强。编写描述时可以参考一个简单模板能力概述 适用场景 禁止场景 参数说明 返回示例。其中返回示例非常关键模型拿到返回示例后后续的回答格式会稳定很多相当于给它做了一个 few-shot 示范。实际写下来一个技能的描述通常在 200 到 500 字之间长点没关系关键是信息密度要高。2.3 技能的可测试性与版本管理技能一旦多了可测试性就是生命线。我在自己的技能系统里给每个技能配了一个最小测试用例输入是一份固定的样例数据输出要校验字段是否存在、类型是否正确、核心逻辑是否跑通。每次修改技能代码或描述之后第一件事就是跑一遍技能级测试通过后再接入整体链路。测试用例的设计我建议遵循“三条线”正常路径、边界值、异常输入。正常路径覆盖典型场景边界值覆盖比如空字符串、超大输入、特殊字符异常输入则专门验证技能在拿不到数据时会不会优雅报错。有一个很实际的例子查数据库的技能如果查询结果为空返回格式是{data: [], error: null}还是直接抛异常这个看似无关紧要的决定会让模型后续行为完全不一样。前者模型会告诉用户“没查到数据”后者模型可能会编造一条不存在的结果。版本管理同样不能省。技能部署到线上后不是永远不变的今天优化了算法明天调整了描述后天新增了功能。如果不做版本管理出问题的时候连对比的基准都没有。我在注册表里为每个技能加了个version字段格式用x.y.z主版本号变动代表不兼容更新次版本号代表功能增强补丁号代表描述修正。模型调用时可以在消息里带上版本信息出问题后通过日志排查当前用的是哪个版本的技能直接对比就能知道是不是升级引发的。3. 从零搭一个轻量技能系统实操记录3.1 技能基类定义下面我直接给出一套在实际项目里跑通的轻量实现技术栈是 Python 3.10依赖只有一个 pydantic方便做参数校验。如果项目里不是 Python这套结构也可以平移核心思想是一致的。from abc import ABC, abstractmethod from typing import Any from pydantic import BaseModel class SkillResult(BaseModel): ok: bool True data: Any None error: str | None None class Skill(ABC): name: str description: str parameters: dict {} version: str 1.0.0 abstractmethod def execute(self, **kwargs) - SkillResult: ...这里有个设计细节值得说明所有技能统一返回SkillResult对象而不是直接抛异常或返回裸数据。这样做的原因是技能的执行结果会被模型当作上下文继续使用格式必须统一模型才知道怎么处理。ok字段表示执行是否成功error用于携带错误信息data存放真正的结果。有了这个统一外壳后续做日志采集、结果校验就非常方便。3.2 技能注册中心与加载机制注册中心是整个技能系统的枢纽所有技能都要在这里登记。我习惯写成单例模式方便在多个模块里共享同一个技能池。核心逻辑就是注册、获取、列出非常简单但也非常稳定。class SkillRegistry: _instance None def __new__(cls): if cls._instance is None: cls._instance super().__new__(cls) cls._instance._skills {} return cls._instance def register(self, skill: Skill): if skill.name in self._skills: raise ValueError(fskill {skill.name} already registered) self._skills[skill.name] skill def unregister(self, name: str): self._skills.pop(name, None) def get(self, name: str) - Skill | None: return self._skills.get(name) def list_skills(self) - list[dict]: return [ { name: s.name, description: s.description, parameters: s.parameters, version: s.version, } for s in self._skills.values() ]在实际项目里我还会加一个load_skills_from_dir的方法自动扫描指定目录下的技能文件按模块名动态导入并注册。这样新增技能就不再需要改注册代码只要往目录里丢一个新文件就行。对团队协作来说这个改动体验提升非常明显。3.3 技能检索与路由的设计技能注册好之后下一步就是让模型能“找到”合适的技能。这里有两种策略一种是全量塞给模型让模型自己选另一种是先做粗筛缩小候选集再让模型精准选。策略选择取决于技能总量。技能少于十个全量塞过去没毛病技能超过二十个描述加起来可能就几千个 Token再有其他上下文模型容易“看花眼”。我一般在代码里先做一个简单的关键词粗筛再用模型做最终选择。粗筛的逻辑可以非常朴素把技能描述和用户查询都转成小写计算关键词重叠度取 Top 10 作为候选。如果团队有条件也可以接入向量检索效果会更好但起步阶段不必上重武器。def rank_skills(query: str, skills: list[dict], top_k: int 8) - list[dict]: query_tokens set(query.lower().split()) scored [] for skill in skills: desc_tokens set(skill[description].lower().split()) score len(query_tokens desc_tokens) scored.append((score, skill)) scored.sort(keylambda x: x[0], reverseTrue) return [s for _, s in scored[:top_k]]粗筛过后系统会把候选技能的名称、描述、参数定义发给模型模型用 function calling 的方式返回它想调用的技能名和参数。这一步本质上是在做“决策”把决策权留给模型而不是用硬编码规则写死这样才能应对用户各种千奇百怪的问法。3.4 完整调用链路从用户输入到技能执行把前面的模块串起来一套完整的调用链路是这样的用户输入 → 粗筛候选技能 → 模型选择技能并填参数 → 执行技能 → 把结果回填给模型 → 模型生成最终回复。我用一个伪代码把这套链路完整展示出来方便直接对着抄。def handle_user_message(user_message: str, client, modelgpt-4o): registry SkillRegistry() all_skills registry.list_skills() candidates rank_skills(user_message, all_skills, top_k10) tools [ { type: function, function: { name: s[name], description: s[description], parameters: s[parameters], } } for s in candidates ] messages [{role: user, content: user_message}] response client.chat.completions.create( modelmodel, messagesmessages, toolstools, ) msg response.choices[0].message messages.append(msg) if msg.tool_calls: for tc in msg.tool_calls: skill registry.get(tc.function.name) if skill is None: messages.append({ role: tool, tool_call_id: tc.id, content: json.dumps({ok: False, error: skill not found}), }) continue try: args json.loads(tc.function.arguments) result skill.execute(**args) if isinstance(result, SkillResult): result result.model_dump() messages.append({ role: tool, tool_call_id: tc.id, content: json.dumps(result, ensure_asciiFalse), }) except Exception as e: messages.append({ role: tool, tool_call_id: tc.id, content: json.dumps({ok: False, error: str(e)}), }) final_response client.chat.completions.create( modelmodel, messagesmessages, ) return final_response.choices[0].message.content这段代码的精髓不是某一行写得有多漂亮而是它把“技能执行”和“模型决策”做了明确分层。模型只负责决定“调用谁、怎么调”技能层负责“执行并返回结果”两边不互相干扰。这种分层带来的好处是任何一个环节出问题都能单独定位、单独修复而不是让模型在对话里兜底。写技能的时候参数定义要用 JSON Schema 格式而且要尽量把参数约束写清楚。比如某个技能接受一个limit参数最好写成{type: integer, minimum: 1, maximum: 100}这样模型在生成参数时就能自动约束取值范围减少非法调用。4. 实际项目中踩过的坑和排查技巧清单4.1 技能返回格式“偶尔漂移”怎么办技能系统的第一个坑往往不在技能本身而在模型对返回结果的处理。早期我把技能执行结果原样丢给模型发现模型偶尔会忽略某些字段甚至自己编造数据。后来才明白模型不是真的“忽略”而是它不确定这些字段的含义只能发挥想象力。解决办法是给每条技能结果加一层“解释性包装”。执行完技能后在返回内容里补一段简短的使用说明告诉模型这些数据是什么、可以怎么用。比如天气技能返回{temperature: 28}时可以包装成「当前气温 28 摄氏度数据来自城市天气 API可直接用于回答用户」。这样模型就不需要猜回答的准确率会高很多。4.2 技能描述撞车导致的误调用技能多了之后很容易出现两个描述高度相似的技能。比如“查订单详情”和“查订单物流”名字像、描述也像模型经常选错。这个问题在前期几乎不可避免只能靠线上日志和 eval 数据不断修正描述。我处理这类问题的套路是给技能描述增加“负向说明”。在“查订单物流”的技能描述末尾加一句注意本技能仅用于查询物流轨迹不返回商品明细、金额信息请勿用于查订单详情。这句负向说明看着啰嗦实际效果非常显著能把误调用率降一半以上。4.3 技能数量膨胀后的检索失效技能库超过三四十个之后关键词粗筛就有点力不从心了。用户问法稍微口语化关键词重叠度可能就为零导致候选列表里根本不含正确技能。这时候最明显的现象是模型经常说“我没有找到相关能力”但实际上技能库里有现成的。这个阶段我建议引入向量检索。把技能描述用 embedding 模型编码用户查询也编码用余弦相似度做召回跟关键词结果做加权融合。代码思路不复杂但检索效果提升非常明显。如果不想引入额外服务也可以先试试提高粗筛候选数、优化描述措辞这些低成本手段能续一段时间。4.4 技能内部异常导致整个任务失败年轻的时候写技能习惯直接把数据库连接、HTTP 请求这些放技能里结果一旦网络抖动或数据库超时异常就往上抛整个 Agent 任务直接失败。后来我把技能内部所有的网络操作都包了一层超时和重试并且把失败信息转成用户可理解的错误描述而不是堆一堆堆栈。一个推荐的做法是技能内部不轻易抛异常而是返回SkillResult(okFalse, error数据库连接超时请稍后重试)。这样模型拿到错误后还能根据上下文决定是重试、换技能还是如实告诉用户。把“技能执行失败”当作一种正常结果来处理系统的鲁棒性会有质的变化。4.5 排查技巧日志里必须能看到调用前后技能系统的排查一定要靠结构化日志。我在每个技能执行前后都打一条日志记录技能名、参数、耗时、返回结果摘要、请求 ID。这样一旦线上出问题按请求 ID 拉出整条链路的日志立马就能看到哪个技能执行了、参数对不对、结果是否正常。日志的作用平时体现不出来出问题的时候就是救命稻草。我见过有团队上线 Agent 后一个多月没看日志直到用户投诉才排查结果发现日志里什么都没打只能靠猜。我的建议是技能系统从第一天起就必须把日志设计进架构里这比任何监控告警都重要。经过几轮迭代我把这套技能系统的排查经验整理成一张速查表每次出问题都按这个顺序过一遍。现象优先排查方向常见原因模型没选对技能技能描述、粗筛召回描述边界不清、检索漏召技能执行成功但结果不对技能逻辑、参数校验参数类型解析错误、边界未处理技能执行抛异常网络、超时、依赖服务外部服务不稳定、缺少重试模型忽略技能结果返回格式、结果包装结果缺少解释性说明响应 Token 超长候选技能数量、上下文长度全量塞技能、历史消息截断策略不当5. 再往后走如何让技能系统持续进化5.1 从固定技能到动态技能生成技能系统稳定运行一段时间后就会进入新的阶段你不再只是手动添加技能而是想让 Agent 自己从任务历史中提炼技能。思路是记录那些执行成功、效果稳定的多步任务把关键步骤提取成模板保存为新技能。这一步做好了Agent 的成长性会变得非常惊人相当于它有了一本自己写的工作手册。实操上可以每隔一段时间人工审核一批高质量历史对话标注出哪些子任务适合沉淀为技能再用脚本半自动生成技能代码和描述。前期人工审核不能省因为自动提炼出来的东西如果不经筛选会把很多一锤子买卖的逻辑固化下来反而污染技能库。等积累了足够的评测集才能逐步自动化。5.2 技能系统的评测是长期投入我越来越觉得做 agent-skills 本质上是在做一套能力管理系统而管理就需要度量。给技能库配一套评测集包含各种常见用户问法和预期技能选择每次改动技能描述、新增技能、调整检索逻辑都在评测集上跑一遍能直观看到是否引入了回归。评测集的维护成本不低但收益非常高。它相当于给了技能系统一张“考卷”让所有修改都有据可依。有了它团队成员就能放心大胆地调整技能描述而不用担心把线上搞坏。建议把评测结果接入 CI技能描述或代码有变更时自动跑一遍。提示这个领域迭代频率很快技能描述的写法、检索策略、评测方法都在快速演化但核心原则不会变——技能要能被稳定地发现、可靠地执行、清晰地度量。5.3 团队协作中的技能共享与沉淀最后说一个容易被忽视的点。技能系统做大了之后它不只是一个技术组件更像团队的“能力资产库”。新人入职直接翻技能清单就能了解系统能做什么不同业务线之间也能互相引用已经验证过的技能避免重复造轮子。把技能当成产品来迭代给每个技能设置负责人定期 review 描述质量和执行效果会让整个系统长期保持干净可用。我在实际维护中发现技能库里真正吃灰的往往是那些一开始兴致勃勃写出来、后来没人维护的边角技能。所以现在我加技能的原则是先确认至少有两个真实场景会用到才允许注册。宁缺毋滥比看起来丰富更重要。写在最后的几点个人体会整套技能系统的搭建本质上是在给模型构建一个稳定、可靠、能成长的“工作环境”。模型的能力边界固然重要但给它配一套趁手的技能库往往比换一个更大的模型更能提升最终效果。我自己最大的体会是技能库的维护成本前期看似额外负担越到后面回报越大但前提是必须把描述写清楚、测试做到位否则几周之后你连自己写的技能是干什么用的都未必想得起来。如果只能从这篇文章里带走一句话我的建议是不要等到技能多了才去想管理方案哪怕现在只有三个技能也把描述规范、注册中心、结果包装这些地基打好。Agent 应用上线之后迭代最快的不是模型不是提示词而是你给 Agent 配的这套技能体系。地基稳了后面怎么盖楼都不慌。