写一个通用型的“技能包”,让原本只会聊天的大模型,变成能干活、能查数、能操作外部系统的数字员工。这个方向业内叫Agent Skills,核心思路是:把复杂任务拆成一个个可命名、可描述、可调用的最小操作单元,模型根据任务描述按需加载并执行。
如果你正在做AI应用落地,或者刚接触智能体开发,就会发现目前最大的困境不是模型不够聪明,而是模型的聪明没办法落地。模型能理解你的意图,但你指望它自己调用数据库查询、处理Excel表格、发送HTTP请求,基本是做梦。我做了几个真实业务项目后,越来越确认一件事:做Agent不是写提示词,不是调模型参数,而是做一套技能系统。
这篇就按照我自己动手从零搭一套Agent技能系统的过程来写,包含设计思路、核心数据结构、加载策略、以及实际跑项目时踩过的坑。
1. 从需求到架构:技能体系到底在解决什么
先说我做这个技能系统的背景。某次接了个企业内部数据问答的活,要求员工通过自然语言问销售数据、库存状况,系统自动从数据库捞数并生成分析结果。一开始我用的是最朴素的做法——把数据库表结构、业务规则全塞进System Prompt,让模型自己生成SQL去查。结果也猜得到:提示词写到6000字,模型一遇到复杂查询就开始胡说八道,生成的SQL要么字段写错,要么连表逻辑完全不对。对一次可以,不可能每次都对。
后来我换了个思路:模型不需要自己会写SQL,它只需要知道“有个技能叫销售数据查询,传给它一个日期范围和区域,它就能返回结果”。具体怎么连数据库、怎么SQL拼条件、怎么处理空值,全封装在技能内部,模型只负责解析用户意图,然后把参数提取出来、调用对应技能。
这就是技能系统的价值——你不需要逼模型什么都懂,你只需要让它当一个聪明的“调度员”。模型的理解能力负责听懂人话,技能系统负责把听懂的意图变成可靠的执行动作。
插一段对比,方便理解两种模式的差异:
| 维度 | 裸调模型(提示词硬编码) | 技能化方案(技能系统) |
|---|---|---|
| 业务逻辑封装 | 全堆在Prompt里 | 封装在独立技能模块中 |
| 模型幻觉影响 | 直接生成错误SQL或参数 | 只做意图识别,执行由代码控制 |
| 新增能力 | 改Prompt,风险大 | 加一个技能文件,零侵入 |
| 可调试性 | 靠对话日志猜 | 技能调用可单测、可追踪 |
| 多场景复用 | 基本不可复用 | 技能可跨Agent复用 |
做过一次你就明白,技能化这个方向对Agent工程化来说不是可选项,是必选项。
2. 技能系统设计:先把主流程走通
下面说说我设计的这套技能系统主流程,分四步,核心原则是“越简单越不容易出错”。
- 技能清单加载
- 用户意图路由
- 参数提取与执行
- 结果反馈与错误恢复
其中第一步最不起眼,但影响最大。一开始我图省事,把所有技能的描述全塞进System Prompt,结果上下文很快被塞满,模型选择技能的准确率直线下降——这就像你给一个实习生发了300页的产品手册,他反而找不到第5页那条关键规定。
改法是用技能索引机制。每个技能在系统启动时注册为一个轻量描述条目,包含编号、功能和关键词,然后一次性注入上下文。模型通过编号引用技能,而不是靠记忆冗长的完整描述。技能数量控制在25个以内时,这种方案效果很稳。
第二步意图路由,我采用的是“让模型做主选,规则兜底”的双轨结构。模型先根据用户输入选择最合适的技能编号;如果置信度不足或编号非法,就走规则匹配把用户输入切割成关键词块,按关键词命中率决定走哪个技能。双轨的好处是既保留了大模型的语义理解弹性,又用规则保底,不至于在模型走神时全线崩盘。
第三步参数提取是实操里最容易翻车的环节。技能定义里必须写明每个参数的中文别名和抽取规则,比如“日期”参数要同时支持“最近一周”“7月1日到7月5日”这类自然语言表达。如果只给模型一个空的JSON参数结构,它经常会漏抽、错抽。让模型以填空的方式去理解上下文,效果会好得多。
第四步错误恢复同样重要。技能执行不可能每次都成功,数据库超时、接口返回异常、参数内容非法,都需要有对应的失败分支。我的做法是:错误信息会经过“翻译层”转成用户能理解的自然语言,再回传给模型,而不是把原始报错堆给用户看。
3. 技能描述的数据结构:这是最核心的设计
如果你问我技能系统里哪一块投资回报率最高,我会毫不犹豫说,是技能描述的Schema设计。模型能不能准确调用技能,七成靠技能描述写得好不好。记不太清楚有多少次,就是因为技能描述写得含糊,模型把该走A技能的请求路由到了B技能。
先看我这边的技能描述Schema核心结构:
{ "skills": [ { "id": "skills.orders.stats", "name": "销售订单统计查询", "description": "按时间范围、区域、品类汇总销售订单金额、订单量、客单价,仅用于销售数据分析场景", "trigger_words": ["销售", "订单", "营收", "销售额", "业绩"], "parameters": [ { "name": "start_date", "description": "统计开始日期,格式YYYY-MM-DD,支持相对日期描述", "required": true, "alias": ["开始日期", "起始日期", "从"] }, { "name": "end_date", "description": "统计结束日期,格式YYYY-MM-DD,支持相对日期描述", "required": true, "alias": ["结束日期", "截止日期", "到"] }, { "name": "region", "description": "区域过滤,支持多个区域,逗号分隔", "required": false, "alias": ["区域", "地区", "城市"] } ], "output": "返回JSON,包含total_amount、total_orders、customer_unit_price字段" } ] }字段设计有几个讲究。description要写清楚这个技能的边界,避免“啥都能干”的错觉。比如“销售订单统计查询”就必须点明“仅用于销售数据分析场景”,这能显著减少模型把不相干请求硬塞给技能的情况。trigger_words是给规则兜底用的,不参与模型推理,但对双轨路由来说必不可少。alias这个字段相当有用,模型抽参数时,看到“从6月到7月”能正确映射到start_date和end_date。
另外会在每个技能上标注max_retry和timeout字段——这两个在后面做并发控制和防故障扩散时非常关键,一开始不设计后面就得返工。
这里补充一个容易忽视的点:技能描述里的
description不要写“这个技能可以帮你完成销售订单统计”。这句话对模型来说全是废话。要说“销售订单统计查询,按条件汇总订单金额与订单量”,干净利落,一针见血。
4. 技能执行器的实现:把调用过程工程化
说了设计,这里放一段执行器的核心代码。这个执行器承接模型层和技能层,负责调度、超时控制、错误捕获与日志记录。
import asyncio import json import logging import time from typing import Any, Callable, Dict logger = logging.getLogger("agent_skills") class SkillExecutor: def __init__(self): self._registry: Dict[str, Callable[..., Any]] = {} self._timeout_map: Dict[str, float] = {} def register(self, skill_id: str, handler: Callable, timeout: float = 10.0): self._registry[skill_id] = handler self._timeout_map[skill_id] = timeout logger.info(f"skill registered: {skill_id}, timeout={timeout}s") async def execute(self, skill_id: str, params: Dict[str, Any]) -> Dict[str, Any]: if skill_id not in self._registry: return { "status": "error", "error": f"skill {skill_id} not found" } handler = self._registry[skill_id] timeout = self._timeout_map.get(skill_id, 10.0) try: start = time.time() result = await asyncio.wait_for( handler(**params), timeout=timeout ) elapsed = time.time() - start logger.info(f"skill {skill_id} ok, elapsed={elapsed:.2f}s") return {"status": "success", "result": result} except asyncio.TimeoutError: logger.error(f"skill {skill_id} timeout after {timeout}s") return { "status": "timeout", "error": f"技能执行超时(>{timeout}s),请缩小数据范围后重试" } except Exception as e: logger.exception(f"skill {skill_id} failed: {str(e)}") return { "status": "error", "error": f"技能执行失败:{str(e)}" }关键在于用asyncio.wait_for强制超时控制。之前没有这层保护,技能内部如果连了个慢数据库,一个请求能把整个Agent卡死好几分钟,用户早就流失了。加超时后,慢查询能快速失败并反馈给模型,让模型自行调整查询条件或换技能。
注册机制这块,我采用装饰器式注册,读起来更清爽:
executor = SkillExecutor() @executor.register("skills.orders.stats", timeout=8) async def order_stats(start_date: str, end_date: str, region: str = ""): # 内部实现:拼SQL、查库、聚合计算 ...以这种形式注册技能,技能函数的参数天然成为参数抽取的约束来源。函数定义的参数名、默认值、类型注解,可以直接用于生成技能描述里的parameters结构,不必两边手动维护,从根上减少“函数签名和Schema不一致”的问题。
5. 技能选择策略:模型为主,规则兜底
实际的技能路由,我用的是两层结构。第一层让模型自己做意图分类并在候选技能列表中选最佳匹配;第二层是规则引擎兜底,防止模型抽风或者没选出来。这样双层保险,效果比只靠模型稳得多。
模型选择那层,我构建的System Prompt长这样:
你是技能调度器。根据用户的问题,从下方技能列表中选择最匹配的一个,只返回技能ID。 技能列表: - Id: skills.orders.stats, 名称: 销售订单统计查询, 用法: 按时间/区域/品类汇总订单金额与订单量 - Id: skills.inventory.alert, 名称: 库存预警查询, 用法: 查询SKU库存低于安全水位的情况 - Id: skills.customer.portrait, 名称: 客户画像分析, 用法: 分析与某客户关联的交易行为偏好 规则: 1. 如果用户询问销售、营收、订单金额,优先选 skills.orders.stats。 2. 如果用户询问缺货、补货、库存预警,优先选 skills.inventory.alert。 3. 如果问题不匹配任何技能,返回 no_skill。 4. 只返回技能ID,不要返回任何解释。规则引擎那层则很简单:用trigger_words做关键词打分,命中分数最高的技能胜出。两者优先级上,模型选在前,规则兜底在后,模型出结果但规则判定风险高,就信规则的。
模型选错的典型案例,是用户问“对比这两个区域哪个卖得好”。模型一眼看到“对比”,可能直接跳到一个叫“数据对比工具”的技能,完全忽略了用户讨论的对象是销售订单。叠了规则层之后,关键词“销售”“区域”“订单”命中销售统计技能,就把这个偏离拽回来了。
实际操作中我在路由日志里打印过一批数据,发现模型选错技能的情况里有将近一半是把“泛指查询”和“特定业务技能”搞混。这个问题的解法就是在技能描述里把适用范围写窄,越窄越不容易误触。
6. 技能的参数抽取与格式化
技能选对了,参数抽错了照样白搭。我用一个二次抽取策略来解决:模型先抽原始参数JSON,再写一个校验函数做格式归一。比如把“最近一周”这种相对时间翻译成具体的start_date和end_date。
内核对日期表达式的翻译思路是这样的:
from datetime import datetime, timedelta def normalize_date(text: str) -> str: """将自然语言日期转为YYYY-MM-DD格式""" text = text.strip() if text.endswith("天") and "最近" in text or text.endswith("天") and "过去" in text: n = int(text.replace("最近", "").replace("过去", "").replace("天", "")) return (datetime.now() - timedelta(days=n)).strftime("%Y-%m-%d") if text.endswith("周") and ("最近" in text or "过去" in text): n = int(text.replace("最近", "").replace("过去", "").replace("周", "")) return (datetime.now() - timedelta(weeks=n)).strftime("%Y-%m-%d") # 其他复杂表达直接交给模型解析后校验 return text这个翻译逻辑不追求全,够用就成。真正复杂的时间语义还是靠模型处理,规则只负责那些稳定可枚举的模式。
参数抽取出错场景里,最典型的是“多值参数被抽成字符串”。比如region字段支持多区域,用户说“华东和华南”,模型抽参数时可能把值抽成"华东和华南",导致技能执行时报区域不存在。我后来在Schema里加了parameter_type: array的标识,并在抽取后将字符串按分隔符拆分成数组,再配合枚举值校验,才把这个问题的发生率降下来。
参数校验函数我放在技能内部,每个技能自己管自己。这样比统一校验器灵活,也符合单一职责原则。
7. 技能编排:让多个技能协同完成复杂任务
单独一个技能只能解决单一问题,真实业务里动不动就要两个以上技能串起来跑。比如“这个月华东地区销售下降,帮我查一下是不是库存出了问题,顺便看看这个区域重点客户的近期采购行为”。
这个请求涉及三个技能,销售统计、库存预警、客户画像。正确的流程是,先查销售数据确认“下降”程度,再看库存判断“缺货”是否是原因,最后落到客户行为上。技能编排这块,我的方案分两条路走。
线性串联方式:靠模型的ReAct式推理,在上下文中不断追加技能执行结果,让模型决定下一步调用什么技能。代码层面对模型返回做循环解析,识别出skill_call指令,循环执行直到模型给出完整结论。
用户请求 -> 模型选择技能A -> 执行A -> 结果回填上下文 -> 模型再选技能B -> 执行B -> ... -> 模型汇总最终结果这种方式的优点是灵活,适合场景不固定、技能组合路径多的业务;缺点是Token消耗大、延迟逐轮累积。对于交互时延敏感的场景,要谨慎用,超过三轮技能串联用户就会明显觉得慢。
预设工作流方式:把固定流程硬编码,比如“销售数据异常分析”固定三步:查销售汇总→查库存水位→查客户动向。流程执行器直接按顺序调用,中间不加模型推理,延迟低且可控。
两种方式,前一种适合探索性、组合多变的业务,后一种适合业务路径已经跑熟、固定下来的高频场景。我现在的倾向是,能预设就预设,探索性的场景才让模型动态编排。
8. 关键注意事项与踩坑总结
说了这么多思路,下面这部分是最值钱的。这些坑都是实际跑出来的,不发出来可惜。
坑一:技能数量贪多,上下文塞爆。一开始我注册了40多个技能,全量注入Prompt,结果模型选技能的准确率肉眼可见地下降。后来做了技能分组+按需注入:根据用户画像、当前对话意图,只动态注入相关分组的技能描述。比如做数据分析的会话,就只注入数据查询类技能;做客服的会话,注入工单查询类技能。技能全量放索引区,分组放在详细区。这个优化让技能选择准确率从81%升到93%左右。
坑二:同一技能并发请求打爆后端。用户涌进来时,如果20个人同时触发销售统计,数据库连接池直接爆掉。解决思路是给执行器加信号量并发控制,限制同一技能最大并行数,超出部分排队等待,并调短超时时间快速失败。
坑三:技能错误信息直接裸露给用户。有一次技能抛了个数据库字段冲突的原始异常,模型把这段异常原样复述给用户。这既不专业,也容易暴露内部结构。正确做法是技能内部捕获异常后统一转成业务语义错误,比如“当前数据范围过大,请缩小时间跨度重试”,并让模型基于这个业务错误组织话术。
坑四:模型自己“发明”技能。有次模型没匹配到技能,竟然自己在返回里编了一个skills.orders.delete,然后假装执行成功。这其实是对模型指令约束不足导致的。解法是在系统提示里写明“仅可使用列表中的技能,禁止自创技能ID”,收到非法技能ID时要直接短路处理,一律返回no_skill。
坑五:技能描述互相包含,路由冲突。比如有个“订单查询”技能,还有个“订单退款查询”技能。用户问“查一下退款进度”,模型经常被“订单”这个词带偏到前者。后来我重新梳理了技能边界描述,把相似技能改成层级关系,在description里显式提示“如需查询退款,请使用skills.orders.refund.status”,冲突就明显减少了。
9. 常见问题速查与排查技巧实录
平时维护这套技能系统,最常被问的几类问题,我整理成一张速查表:
| 现象 | 可能原因 | 排查思路 | 解决方案 |
|---|---|---|---|
| 模型选错技能 | 技能描述边界模糊、触发词重叠 | 看路由日志里候选技能排序 | 收敛技能描述,增加Skills边界对比 |
| 参数漏抽或抽错 | Schema缺少别名和格式约束 | 打印原始抽取JSON,比对用户原句 | 补alias、补枚举校验、加二次解析 |
| 技能执行超时 | 后端查询慢、并发堆积 | 查看技能执行日志耗时分布 | 加超时控制,加并行信号量,优化SQL |
| 错误信息被原样暴露 | 异常未分类捕获 | 看错误信息是否含SQL或堆栈 | 统一异常翻译层,敏感信息脱敏 |
| 模型自创技能ID | System Prompt约束不足 | 查看模型输出是否在技能清单之外 | 强化ID白名单校验,非法ID短路 |
| 全局上下文被撑爆 | 每轮都注入全部技能描述 | 查看Token消耗趋势 | 技能分组、按需注入、索引与详情分离 |
排查时我习惯先把路由日志完整打出来,包括用户原话、候选技能排序、最终选择的技能ID、抽取出的参数JSON、技能返回的状态码。这套日志是不需要猜的“铁证”。日志结构长这样:
{ "user_query": "这个月华东区销售情况怎么样", "candidate_skills": ["skills.orders.stats", "skills.orders.detail", "skills.customer.portrait"], "selected_skill": "skills.orders.stats", "confidence": 0.87, "extracted_params": {"start_date": "2025-11-01", "end_date": "2025-11-30", "region": "华东"}, "execute_status": "success", "elapsed_ms": 423 }日志里confidence字段有什么用?我设了个阈值,低于0.75时会把这次请求标成“低置信度”,后续可以拿着这些样本去迭代技能描述,比漫无目的地改Prompt高效得多。
10. 技能系统的通用扩展方向
做到这一步,很多朋友会问,那这个技能系统是不是就只能用在内部数据问答?当然不是。这套骨架可以把任意外部能力包成技能,比如发邮件、创建工单、导报表、查天气、调推荐算法接口。每个技能是独立的、可测试的模块,像搭乐高一样往Agent上拼。
按我个人的实操体会,后续值得投入的方向有这么几个。
第一是技能自动生成。现在注册技能还得手写描述、手写Schema、手写参数校验函数,比较繁琐。我在尝试让模型阅读一段工具代码后自动生成技能描述,再经过人审入库。这能大幅降低扩展新技能的边际成本。
第二是技能推荐。根据用户的历史对话和常用技能,给新会话推荐默认技能集。这一步能把技能选中准确率再往上拔,因为注入的候选列表已经从40个缩小到5~8个,模型选择压力大幅下降。
第三是技能执行反馈闭环。把技能执行成功率和用户反馈数据拉通,定期淘汰低质量技能、修正歧义描述。这属于纯工程化管理,但长期做下来积累的收益相当可观。
最终回到那个核心认知:Agent的上限取决于技能边界,而不是模型本身。模型负责理解和表达,技能负责稳定和可靠,这两者结合,才能真正把大模型从“聊天玩具”变成“业务工具”。如果你正在做Agent应用,希望这篇对你构建自己的技能体系有那么一点参考价值。