YAOTU INSIGHTS

Agent技能单元设计:从Prompt到Skill的工程化实践

Agent技能单元设计:从Prompt到Skill的工程化实践
1. 从“2.skill”这个标题说起一个被低估的Agent能力单元第一次看到“2.skill”这个标题很多人会一头雾水。它不像“Agent开发实战”那样直白也不像“MCP协议详解”那样有明确的技术指向。但恰恰是这种极简的命名方式暴露了它背后的真实语境——这是一个在Agent开发工作流中对“技能”这一核心概念进行二次拆解和工程化落地的项目记录。我接触Agent开发有一段时间了从最早的Prompt Engineering到后来的Function Calling再到MCP协议的出现整个技术栈的演进速度非常快。但有一个问题始终困扰着实际落地大模型本身很聪明但它不知道怎么用工具也不知道什么时候该用哪个工具。你给它一个API文档它可能会在不需要的时候乱调也可能在需要的时候完全忽略。这就是“技能”这个概念要解决的核心问题。所谓skill在Agent语境下我把它理解为一个封装好的、可被模型识别和调用的能力单元。它比单纯的函数调用更上层比完整的Agent更轻量。一个skill通常包含三个要素触发条件什么时候用、执行逻辑怎么用、输出格式返回什么。而“2.skill”这个标题里的“2”我倾向于理解为这是系列内容中的第二个技能模块或者是对skill概念的第二层拆解——从“什么是skill”进入到“怎么设计一个skill”。这个内容适合谁来参考如果你正在做Agent开发手头有一堆API但不知道怎么让模型稳定调用或者你已经在用MCP协议但发现模型经常“忘记”某些工具的存在那这篇内容就是为你准备的。它不涉及底层模型训练也不需要你精通Rust或Go核心是设计思路和工程化方法。哪怕你只用Python写过一个简单的聊天机器人也能从中找到可以直接抄作业的部分。接下来我会从设计思路、核心细节、实操过程、问题排查四个维度把“2.skill”这个项目拆开揉碎。中间会穿插我在实际开发中踩过的坑和总结的技巧尽量让不同基础的读者都能拿走能用的东西。2. 技能单元的整体设计与思路拆解2.1 为什么要把能力封装成skill而不是直接写Prompt早期做Agent的时候我最常用的方式是把所有工具描述塞进System Prompt里然后让模型自己判断该调哪个。这种做法在工具数量少于5个的时候还能凑合一旦超过10个模型的调用准确率就会断崖式下降。我实测过一个场景给模型15个工具描述让它根据用户输入选择正确的工具准确率只有62%左右。模型经常把相似的工搞混或者在多轮对话中“忘记”某些工具的存在。把能力封装成skill本质上是在做关注点分离。每个skill只负责一件事有自己的触发关键词、参数定义和返回结构。模型不需要一次性记住所有工具而是通过skill的元数据名称、描述、触发示例来快速匹配。这就像你把一个巨大的工具箱拆成一个个独立的小盒子每个盒子上贴好标签用的时候找对应的盒子就行不用把整个工具箱翻个底朝天。另一个关键考量是可测试性。Prompt里的工具描述是自然语言很难做单元测试。但skill可以。你可以给每个skill写测试用例输入什么触发词应该激活输入什么参数应该返回什么结果。这种工程化的测试能力是Prompt方案不具备的。2.2 skill与MCP、Agent架构的关系这里需要理清一个概念层级。MCPModel Context Protocol是一套通信协议解决的是“模型怎么和外部工具通信”的问题。Agent是一个完整的智能体包含规划、记忆、执行等模块。而skill是Agent能力的具体载体它可以通过MCP协议暴露给模型也可以直接以函数调用的形式集成。我倾向于把skill设计成协议无关的。也就是说skill本身不关心它是通过MCP暴露还是通过Function Calling暴露它只关心自己的输入输出契约。这样设计的好处是当底层协议发生变化时比如从Function Calling切换到MCPskill的实现不需要改动只需要换一层适配器。在实际项目中我的分层是这样的最底层是具体的API或本地函数中间层是skill封装参数校验、错误处理、格式化最上层是Agent的调度逻辑。MCP协议作为可选的通信层负责把skill暴露给不同的模型客户端。这种分层让每一层都可以独立测试和替换。2.3 为什么选择“2.skill”这种极简命名命名这件事在Agent开发里被严重低估了。我见过太多项目用“tool_001”“function_a”这种命名结果模型根本分不清哪个是哪个。skill的命名应该做到自解释看到名字就知道它是干什么的不需要额外解释。“2.skill”这个命名方式我理解是一种编号类型的约定。编号用于区分同一类下的不同技能类型用于标识这是一个skill文件。这种命名在文件系统层面很友好排序清晰批量处理方便。但在模型层面skill的name字段应该用更具描述性的名称比如“query_weather”而不是“skill_2”。文件命名和模型可见名称可以分离这是两个不同的关注点。注意skill的模型可见名称建议用英文小写下划线长度控制在3个单词以内。太长的名称会增加Token消耗太短的名称容易歧义。我一般用“动词_名词”的格式比如“search_docs”“send_email”“calc_tax”。3. 核心细节解析与实操要点3.1 skill的元数据结构设计一个skill的元数据决定了模型能不能正确识别和调用它。我经过多次迭代最终固定下来的元数据结构包含以下字段字段名类型是否必填说明namestring是模型可见的唯一标识英文小写下划线descriptionstring是一句话说明技能用途控制在50字以内triggersarray是触发示例3-5条自然语言短句parametersobject是JSON Schema格式的参数定义returnsobject是返回值的JSON Schemaversionstring否语义化版本号默认1.0.0timeoutnumber否超时毫秒数默认5000description字段是最关键的。我试过用很长的描述结果发现模型反而抓不住重点。后来改成“一句话触发示例”的组合准确率明显提升。触发示例要覆盖不同的表达方式比如“查一下北京天气”“北京今天热不热”“帮我看看北京温度”都应该能触发同一个skill。parameters用JSON Schema定义这样可以直接被大多数模型框架解析。我一般会给每个参数加上description和example这两个字段对模型的参数填充准确率影响很大。实测下来加了example之后参数填充错误率能降低40%左右。3.2 触发条件的写法与避坑触发条件写得好不好直接决定skill的召回率和准确率。我总结了一个“三三制”原则三个正向示例、三个边界示例、三个负向示例。正向示例就是最典型的用户表达比如“帮我查天气”。边界示例是那些容易混淆但应该触发的表达比如“外面冷吗”隐含查天气需求。负向示例是那些看起来像但不应该触发的表达比如“天气真好”只是感叹不需要查天气。负向示例在元数据里不直接暴露给模型而是用于测试。我会写一个测试脚本把负向示例输入进去看skill会不会被误触发。如果误触发率超过5%就需要调整description或triggers。实操心得触发示例不要用完整的句子用短语效果更好。比如“查天气”比“请帮我查询一下今天的天气情况”更能让模型抓住核心意图。完整句子会引入很多无关Token稀释关键信息。3.3 参数校验与错误处理模型填充的参数不能直接信任。我遇到过模型把“2024-01-01”填成“2024-1-1”把数字填成字符串甚至把不存在的参数名塞进来。所以skill内部必须做严格的参数校验。我的做法是在skill入口处加一层校验中间件做三件事类型检查、范围检查、必填检查。类型检查用JSON Schema验证器范围检查针对数值和字符串长度必填检查确保关键参数不为空。任何一项不通过直接返回结构化的错误信息而不是抛异常。错误信息也要设计好。不要返回“参数错误”这种模糊提示要返回“参数date格式应为YYYY-MM-DD实际收到2024/01/01”。这样模型在下一轮可以自我修正。我实测过结构化错误信息能让模型的自我修正成功率从30%提升到75%以上。3.4 Token用量控制策略skill的元数据会占用Token尤其是当skill数量多的时候。我算过一笔账一个skill的完整元数据大约消耗150-200个Token如果有20个skill光元数据就占3000-4000 Token。这在上下文窗口有限的模型上是很可观的消耗。我的优化策略是分层加载。把skill分成核心技能和扩展技能。核心技能5-8个始终加载在上下文中扩展技能只在需要时通过检索动态加载。检索可以用关键词匹配或向量相似度我一般用关键词匹配就够了因为skill的triggers本身就是很好的关键词来源。另一个技巧是压缩description。把“这个技能用于查询指定城市的天气情况”压缩成“查询城市天气”Token消耗减少60%效果几乎不变。模型对简洁描述的识别能力反而更强。4. 实操过程与核心环节实现4.1 环境准备与依赖安装我用的技术栈是Python 3.11 Pydantic 2.x FastAPI。Pydantic用于参数校验和Schema生成FastAPI用于把skill暴露成HTTP接口方便后续接入MCP。如果你只用Function Calling可以不用FastAPI直接写Python函数就行。pip install pydantic fastapi uvicorn httpx目录结构我习惯这样组织skills/ __init__.py base.py # skill基类和校验中间件 registry.py # skill注册中心 weather/ __init__.py skill.py # weather skill实现 schema.json # 元数据 search/ ...base.py里定义Skill基类包含name、description、parameters等属性以及一个execute方法。所有具体skill继承这个基类实现execute方法。registry.py负责扫描skills目录自动注册所有skill。4.2 编写第一个skill以天气查询为例先定义元数据schema.json{ name: query_weather, description: 查询指定城市的当前天气, triggers: [查天气, 城市温度, 外面冷吗, 会下雨吗], parameters: { type: object, properties: { city: { type: string, description: 城市名称, example: 北京 }, unit: { type: string, enum: [celsius, fahrenheit], default: celsius, description: 温度单位 } }, required: [city] }, returns: { type: object, properties: { city: {type: string}, temperature: {type: number}, condition: {type: string}, humidity: {type: number} } }, timeout: 5000 }然后实现skill.pyfrom skills.base import Skill import httpx class WeatherSkill(Skill): name query_weather async def execute(self, params: dict) - dict: city params[city] unit params.get(unit, celsius) # 调用天气API这里用示例URL async with httpx.AsyncClient() as client: resp await client.get( https://api.example.com/weather, params{city: city, unit: unit}, timeout5.0 ) resp.raise_for_status() data resp.json() return { city: city, temperature: data[temp], condition: data[condition], humidity: data[humidity] }关键点在于execute方法的输入输出都是纯字典不依赖任何框架特定的类型。这样这个skill可以在任何Agent框架里复用。4.3 注册与调度逻辑实现registry.py的核心逻辑是扫描目录、加载schema、实例化skill类import json import importlib from pathlib import Path from skills.base import Skill class SkillRegistry: def __init__(self, skills_dir: str skills): self.skills {} self._load_all(Path(skills_dir)) def _load_all(self, root: Path): for schema_file in root.rglob(schema.json): with open(schema_file) as f: meta json.load(f) skill_dir schema_file.parent module_path str(skill_dir).replace(/, .) .skill module importlib.import_module(module_path) # 找到Skill的子类 for attr_name in dir(module): attr getattr(module, attr_name) if isinstance(attr, type) and issubclass(attr, Skill) and attr is not Skill: instance attr() instance.meta meta self.skills[meta[name]] instance break def get_skill(self, name: str) - Skill: return self.skills.get(name) def list_skills(self) - list: return [ { name: s.meta[name], description: s.meta[description], parameters: s.meta[parameters] } for s in self.skills.values() ]调度逻辑我一般放在Agent主循环里。每一轮对话先把list_skills()的结果注入System Prompt然后模型返回要调用的skill名称和参数registry.get_skill()拿到实例后执行execute把结果返回给模型。4.4 接入MCP协议的适配层如果要把skill通过MCP暴露需要写一个适配层。MCP的核心是JSON-RPC over stdio或HTTP。我用FastAPI写了一个简单的HTTP适配from fastapi import FastAPI, HTTPException from pydantic import BaseModel from skills.registry import SkillRegistry app FastAPI() registry SkillRegistry() class CallRequest(BaseModel): skill: str params: dict app.get(/skills) def list_skills(): return registry.list_skills() app.post(/call) async def call_skill(req: CallRequest): skill registry.get_skill(req.skill) if not skill: raise HTTPException(404, fSkill {req.skill} not found) # 参数校验 errors skill.validate(req.params) if errors: raise HTTPException(400, {errors: errors}) try: result await skill.execute(req.params) return {result: result} except Exception as e: raise HTTPException(500, {error: str(e)})这个适配层很薄核心逻辑都在skill内部。这样设计的好处是如果以后要换协议比如从HTTP换成gRPC只需要改适配层skill代码不动。5. 常见问题与排查技巧实录5.1 模型不调用skill或调用错误skill这是最常见的问题。排查思路分三步走第一步检查skill的description和triggers是否足够清晰。我遇到过一个案例skill的description写的是“处理用户请求”模型完全不知道这个skill是干什么的。改成“查询快递物流信息”之后调用准确率立刻上来了。第二步检查System Prompt里skill列表的格式。如果格式混乱模型很难解析。我一般用固定的JSON格式每个skill一行包含name、description、parameters三个字段。第三步检查是否有相似skill互相干扰。如果有两个skill的description很接近模型容易混淆。解决办法是在description里明确区分场景比如“查询国内快递”和“查询国际快递”。问题现象可能原因排查方法解决方案完全不调用description太模糊人工阅读description改成具体动作对象调用错误skill多个skill描述相似对比相似skill的description增加区分性关键词参数填充错误参数缺少example检查parameters定义补充example字段多轮后忘记skill上下文Token超限统计Token用量分层加载skill5.2 Token失效与权限问题的处理在Agent开发中Token失效是绕不开的问题。这里的Token指的是访问外部API的凭证不是模型的Token。我遇到过几种典型情况Token过期、权限不足、频率限制。处理策略是在skill内部做统一封装。不要在每次调用时手动检查Token而是写一个TokenManager负责自动刷新和重试。我的实现是TokenManager维护一个Token池每次调用前检查是否过期过期则自动刷新。如果刷新失败返回结构化的错误信息给模型让模型决定是重试还是告知用户。注意Token刷新逻辑要加锁避免并发刷新导致重复请求。我用asyncio.Lock实现实测下来很稳。另一个坑是权限范围。有些API的Token只能访问特定资源如果skill请求了超出权限的资源会返回403。这种错误不要直接抛给模型而是转换成“当前凭证无权访问该资源请检查权限配置”这样的提示。模型看到这种提示后通常会选择其他skill或告知用户。5.3 skill执行超时与重试策略外部API不稳定是常态。我统计过在生产环境中大约3%的skill调用会因为网络问题超时。如果不处理用户体验会很差。我的策略是分级超时指数退避重试。每个skill的timeout字段定义基础超时时间默认5秒。第一次超时后等待1秒重试第二次超时后等待2秒重试第三次超时后放弃并返回错误。重试次数最多3次。但不是所有skill都适合重试。查询类skill可以重试写入类skill比如发邮件、下单不能重试否则可能产生重复操作。我在skill元数据里加了一个idempotent字段标记是否幂等。只有幂等的skill才启用重试。async def execute_with_retry(skill, params, max_retries3): for attempt in range(max_retries): try: return await asyncio.wait_for( skill.execute(params), timeoutskill.meta.get(timeout, 5000) / 1000 ) except asyncio.TimeoutError: if attempt max_retries - 1: raise if not skill.meta.get(idempotent, False): raise await asyncio.sleep(2 ** attempt)5.4 常见问题速查表问题现象根因解决skill不触发模型直接回答不调用description模糊改具体描述加触发词参数缺失模型只填部分参数required未标注检查JSON Schema返回格式错模型解析失败returns未定义补充returns Schema并发冲突偶发数据错乱共享状态未隔离每个skill独立实例内存泄漏运行久了变慢连接未关闭用async with管理资源日志缺失出问题无法排查没打关键日志在execute入口出口打日志实操心得skill的日志要打三个点——入口参数、出口结果、异常堆栈。但注意不要打敏感信息比如Token、密码。我一般用脱敏函数处理后再打日志。6. 技能设计的进阶思路与扩展方向6.1 技能组合与编排单个skill能力有限真正强大的是skill的组合。比如“订机票”这个需求可能需要“查询航班”“比较价格”“选择座位”“支付”四个skill协作完成。我目前的实现方式是让模型自己编排但模型在多步编排时容易出错。更稳的方式是预定义工作流。把常见的组合场景写成workflow每个workflow是一组skill的调用序列。模型只需要选择workflow不需要自己编排每一步。这种方式牺牲了一些灵活性但换来了稳定性。我实测下来预定义工作流的任务完成率比模型自由编排高30%以上。workflow的定义我放在单独的YAML文件里name: book_flight steps: - skill: search_flights params: from: {{user.from}} to: {{user.to}} date: {{user.date}} - skill: compare_prices params: flights: {{steps[0].result}} - skill: select_seat params: flight: {{steps[1].result.best}} - skill: pay params: order: {{steps[2].result.order}}这种模板化的编排方式既保留了灵活性参数可以动态填充又保证了执行顺序的确定性。6.2 技能的安全边界设计skill能调用外部API这就带来了安全风险。我见过最危险的情况是skill直接执行用户传入的SQL或Shell命令。这种设计等于把整个系统暴露给模型模型一旦被诱导后果不堪设想。我的安全原则是最小权限输入白名单。每个skill只申请它需要的最小权限比如查询天气的skill只能访问天气API不能访问文件系统。输入参数做白名单校验比如城市名称只允许字母、数字和常见汉字拒绝任何特殊字符。另一个原则是输出脱敏。skill返回的结果里如果包含敏感信息比如用户ID、内部IP要在返回前脱敏。我写了一个通用的脱敏函数用正则匹配常见敏感模式替换成占位符。注意不要信任模型传来的任何参数。模型可能被Prompt注入攻击传入恶意参数。所有参数必须经过校验才能使用。6.3 技能版本管理与灰度发布skill是会迭代的。今天写的查询逻辑明天可能因为API变更需要调整。如果没有版本管理更新skill时很容易影响正在运行的任务。我的做法是语义化版本灰度发布。每个skill的元数据里有version字段遵循major.minor.patch格式。不兼容的变更升major兼容的功能新增升minorbug修复升patch。registry支持同时加载多个版本的skill模型调用时默认用最新版但可以通过参数指定版本。灰度发布是通过配置实现的。新版本skill先只对10%的请求生效观察一段时间没问题后再全量。这个比例可以在配置中心动态调整不需要重启服务。6.4 从skill到Agent的演进路径skill是Agent的基础能力单元但Agent还需要规划、记忆、反思等能力。我的演进路径是先有skill再有workflow最后有Agent。skill阶段解决“能不能做”的问题workflow阶段解决“按什么顺序做”的问题Agent阶段解决“做什么”的问题。很多团队一上来就做Agent结果发现基础能力都不稳定Agent的规划再强也没用。我的建议是先把skill做扎实再往上叠。具体来说skill的测试覆盖率要达到80%以上workflow的完成率要达到90%以上再考虑引入Agent的自主规划。这个顺序不能反。7. 我在实际项目中的几点体会做Agent开发这两年最大的体会是不要过度设计。一开始我总想把skill设计得很通用结果每个skill都变得很复杂模型反而不知道怎么用。后来我改成“一个skill只做一件事”代码量少了效果反而好了。另一个体会是测试比实现更重要。skill的实现可能就几十行代码但测试用例我写了上百行。这些测试用例覆盖了各种边界情况让我在迭代时很有信心。没有测试的skill我根本不敢改。还有一点是日志要打够。Agent的运行链路很长模型决策、skill调用、API请求任何一环出问题都可能导致任务失败。我在每个环节都打了详细的日志出问题时能快速定位。日志格式我统一用JSON方便后续做分析和告警。最后分享一个小技巧skill的description可以定期用模型自己来优化。把skill的实际调用记录喂给模型让它根据误触发和漏触发的案例重新生成更准确的description。我试过这个方法经过两轮优化skill的调用准确率从78%提升到了91%。这个技巧不需要额外标注数据成本很低效果很明显。