我手头这个智能体项目,验证了大半年,终于跑通了一个朴素的道理:只靠提示词,什么都干不成;真正让智能体“能干活”的,是那套看不见的能力层——agent-skills。说白了,就是把模型和外部世界之间那些高频、可复用、可验证的执行单元,做成一组标准化的“技能”,让大模型碰到什么场景就自动调用对应的技能,而不是每次都在提示词里临时教它怎么做事。
这篇文章不聊那种纯理论的概念,我直接把整个项目里关于 agent-skills 的设计思路、代码实现、踩坑记录和复盘数据全部拆开讲。项目背景是一个面向电商客服场景的智能答疑助手,底层接了市面上的开源大模型,上层需要处理查订单、查物流、算退款金额、判断售后时效这些杂活。最初版本纯靠 prompt 堆能力,结果模型经常一本正经地胡说八道;后来我把所有外部操作全部“技能化”,效果立刻拉开了差距:任务成功率从 62% 提到 91%,单次任务的平均 token 消耗也降了将近一半。
如果你是正在做 AI Agent 应用开发的工程师、或者准备在公司里落地智能体项目的技术负责人,这篇文章会非常有用。我会把技能体系怎么设计、怎么注册、怎么让模型“学会”调用,以及我踩过的那些坑全部讲清楚。内容全部来自真实项目,不是那种“复制即用”的玩具 demo,而是能扛住生产流量的工程方案。
1. agent-skills 到底是什么:它和“工具调用”不是一回事
很多人一听到 agent-skills 就觉得,这不就是 function calling 换个说法吗?一开始我也这么想,但真的把项目从“模型会调函数”推进到“模型会用技能解决复杂任务”之后,我才意识到二者的差别非常大。
1.1 智能体一直“做事失败”的根因,出在技能缺失
先说一个我在多个项目里反复观察到的现象:同一个大模型,你让它聊天,它表现得很聪明;你让它帮你完成一个多步骤的业务操作,它就开始失控。比如让它查订单,它会调对了接口但漏传参数;让它判断退款金额,它把税率算错;更常见的是,它根本不知道该在什么时机触发什么操作,直接凭记忆瞎编一个结果出来。
问题的根源其实不在模型本身,而在于你只给了模型“理解能力”,没有给它“执行能力”。理解能力靠提示词就能解决,但执行能力必须靠一套结构化的技能体系来承载。我做的 agent-skills 模块,本质上就是把每一项业务能力封装成标准的执行单元:这个单元叫什么、在什么条件下触发、需要什么参数、内部执行什么逻辑、返回什么结构化的结果,全部用代码固化下来。
这里有一个关键点:技能必须是“可被模型感知”的。如果你的技能只写在后端服务里,模型对它一无所知,那这个技能就根本不会被使用。所以整个 agent-skills 体系的核心工作,是把技能的描述、约束和调用方式翻译成模型能理解的“说明书”,同时保证执行结果的可靠性和可回溯性。
1.2 skills、tools、functions 和 plugin 的边界在哪里
这个必须掰扯清楚,因为团队协作时大家经常因为这些词打架。我个人的理解是这样的:
- functions / tools:最底层的能力单元,通常就是 HTTP API 或本地函数。它们负责“能做什么”,但没有智能。
- skills(技能):在函数之上加了一层“决策上下文”。一个技能包含目标描述、适用条件、参数约束、执行逻辑、结果解析规则等,也就是说,它不仅知道自己能做什么,还知道什么时候该做、怎么做更好。
- plugins(插件):偏产品和交付层面的概念,通常是若干个技能的打包组合,面向某个具体使用场景。比如“售后处理插件”可能包含查售后单、计算补偿金额、生成处理意见三个技能。
我用一个表格来说明它们的关系,方便你对照项目里的模块:
| 层级 | 核心问题 | 包含内容 | 举个例子 |
|---|---|---|---|
| functions/tools | 能不能做 | 函数名、入参出参 | GET /order/{order_id} |
| skills | 何时做、如何做得好 | 触发条件、参数约束、执行策略、校验规则 | 用“订单查询技能”来处理任何用户提供的订单号 |
| plugins | 面向什么场景打包 | 多个技能编排、页面配置、权限控制 | “售后工作台插件”整合查单、判责、补偿三个技能 |
从开发角度,你完全可以只做 tools 不做 skills,模型也能调用,但项目推进会非常痛苦:模型有时调对、有时调错,错因不统一,出了问题你不知道是提示词的问题、是函数的问题,还是模型理解的问题。把 skills 作为一个独立抽象层单独设计后,你才能对“智能体能力”做单元测试、回归测试和迭代管理。
1.3 一个最小可用技能单元应该包含什么
如果只记一个结论,那就是:一个技能不是一个函数,而是一个“自包含的决策执行包”。我在项目里要求每个技能必须包含五块内容:
- 技能标识:全局唯一的英文名称,比如
query_order、calculate_refund。 - 适用场景描述:用自然语言告诉模型“什么时候用这个技能”。这一段非常关键,直接决定模型是否会误调用。
- 输入参数 Schema:参数名、类型、是否必填、取值范围、示例值。我直接用 JSON Schema 格式维护,方便做校验。
- 执行体:内部调用的函数或服务,返回统一的结构化结果。
- 结果解析和异常处理:成功时返回什么格式;失败时如何返回错误码和可读错误信息,方便模型继续决策。
我甚至会把“这个技能解决不了什么”也写进描述里,效果出乎意料地好。比如查单技能里写明“本技能只支持查询近三个月的订单”,模型在遇到三个月前的订单查询请求时就不会强行调用,而是主动告知用户能力边界。
2. 设计一套可维护的技能体系,我建议你这样分层
技能不是越多越好,而是越“清晰”越好。我见过很多团队把技能当成函数库,一口气注册几百个,结果模型选择困难,调用准确率掉得惨不忍睹。好的技能体系一定要分层。
2.1 技能分层的三种类型:基础、组合、策略
我在项目里把所有技能分成三个层次:
- 基础技能:直接对接业务系统,原子操作。比如查询订单、查询物流轨迹、查询商品详情、创建工单。这些技能不做复杂判断,只做数据操作,保证简单、稳定、可复用。
- 组合技能:编排多个基础技能完成任务。比如“处理退款申请”这个组合技能,内部先查订单,再查退款政策,再计算金额,最后调用退款接口。组合技能的好处是隔离复杂度,模型只需要调用一次,不用自己编排多步。
- 策略技能:指导前两类技能如何被调用的元技能。比如“情绪安抚策略技能”,它本身不执行任何操作,但会让模型在检测到用户情绪激动时优先使用安抚话术,再走售后流程。
这个分层的核心价值是让“决策链路”和“执行链路”解耦。模型面对复杂任务时,先选择策略技能,再用组合技能串联基础技能,每一层都有明确职责,出了问题也能快速定位。
我强烈建议不要在第一个版本就疯狂注册组合技能,而是先把基础技能做扎实,观察模型的使用频率和失败场景,再逐步沉淀组合技能。我在项目早期犯过的错误就是提前封装了十几个组合技能,结果组合逻辑和真实业务对不上,模型调用的成功率很低,最后全部推翻重写。
2.2 注册与发现:让模型“看得见”技能
技能体系能不能跑起来,核心在于“注册”这一环。所谓注册,不是把函数加进一个列表那么简单,而是要把技能的全部元信息标准化存储,并在每次请求时动态组装成模型可读的“技能描述文本”。
我在项目里用的是一张技能注册表,字段包括:技能名、技能类型(基础/组合/策略)、适用场景描述、参数 Schema、执行入口、超时时间、限流策略、是否需要用户确认。每次大模型发起对话时,系统会把当前用户语境下可能相关的技能描述传给模型,而不是把所有技能一股脑地塞进去。这个“动态技能发现”机制非常关键,因为模型的上下文窗口有限,技能描述太长会挤占对话空间,甚至干扰模型对用户问题的理解。
动态发现怎么做?我最初用关键词匹配,后来切换成向量检索:把技能的适用场景描述做成 embedding,再用用户当前的 query 去检索最相关的 Top N 个技能。实测下来,将候选技能从 50 个缩减到 5 个时,模型选择技能的准确率明显提升,单轮 token 消耗也下降了约 30%。
2.3 技能编排:让多个技能协同起来,而不是各自为战
很多时候用户的问题无法靠单技能解决,比如“我想退掉上个月买的那个有质量问题的手表”。要完成这个任务,模型至少需要:调查询订单技能找到具体订单,调售后政策技能确认是否支持退款,调退款计算技能算出金额,再调创建工单技能落地处理。这一串动作,就是技能编排。
我在项目里实现了一套轻量级编排器,它不写死任何业务逻辑,而是将模型每次“调用技能”的请求解析成标准动作,再按以下顺序处理:
- 从请求中解析出技能名和参数。
- 到注册表里找到对应技能定义,校验参数合法性。
- 执行技能,拿到标准化结果。
- 判断结果状态是成功还是失败。
- 将结果转成一段结构化文本,重新交还给模型继续决策。
这里最容易被忽略的是编排器的执行结果必须被模型理解。我见过很多项目把函数返回的原始 JSON 直接拼进对话历史,比如返回{"code": 200, "data": {"order_status": 3}},模型根本不知道3代表什么。所以我在编排器里增加了一个“翻译层”,把执行结果转成自然语言,比如订单状态为“已发货”,物流公司为顺丰,运单号为 SF123456。这一步改造之后,模型后续决策的正确率提升非常明显。
多技能协同中的另一个难点是“中途失败怎么办”。我的原则是:任何一步失败,都不能让模型静默跳过,必须把清晰的错误原因写进上下文,让模型感知到“某一步没走通”,从而选择重试、换策略或者向用户解释。这套机制保证了复杂任务的可控性,也大幅减少了模型“假装成功”的情况。
3. 实操:从零实现一个 agent-skills 模块
下面这部分我直接展示项目里的核心代码和配置逻辑,虽然是简化版,但完整保留了工程落地中最关键的几个设计决策。整个模块用 Python 实现,框架是 FastAPI,模型侧用的是 OpenAI 兼容接口的 function calling 协议。
3.1 技术选型与目录结构
我选择 Python 的原因很简单:团队技术栈统一,而且大模型生态的工具链最成熟。目录结构上,没有用复杂的微服务,而是保持一个“技能开发套件”的形态:
agent-skills/ ├── skills/ │ ├── __init__.py │ ├── base.py # 技能基类与注册表 │ ├── registry.py # 注册与发现逻辑 │ ├── orchestrator.py # 编排器,解析并执行技能调用 │ ├── translator.py # 执行结果翻译成模型可读文本 │ └── builtin/ │ ├── query_order.py # 基础技能:查询订单 │ ├── query_logistics.py │ └── refund_calc.py # 组合技能:退款金额计算 ├── schemas.py # 参数 pydantic 模型 ├── config.py └── main.py # FastAPI 入口不需要过度设计,重点在于让技能的“定义”和“执行”分离。开发新技能时,大多数场景只需要新增一个文件、写一个装饰器即可完成注册。
3.2 技能如何定义并写入注册表
技能核心定义我用了一个数据类SkillSpec,它和模型的 function calling schema 是强对应的:
# courses reminder: 这里只展示核心字段,生产代码还会带上权限、审计、超时等配置 @dataclass class SkillSpec: name: str description: str parameters: dict # JSON Schema 格式 handler: Callable # 真正的执行函数 type: str = "base" # base / combo / strategy timeout: int = 10真正写技能的时候,我封装了一个@skill装饰器,开发同学不需要关心注册逻辑:
@skill( name="query_order", description="根据用户提供的订单号或手机号查询订单详情,仅支持近三个月内的订单。", parameters={ "type": "object", "properties": { "order_id": {"type": "string", "description": "订单号,通常由字母和数字组成"}, "phone": {"type": "string", "description": "下单手机号,用于未提供订单号时的辅助查找"} }, "oneOf": [{"required": ["order_id"]}, {"required": ["phone"]}] } ) def query_order(order_id: str = None, phone: str = None) -> dict: # 内部逻辑:查数据库或调第三方接口 return {"code": 0, "data": {...}}装饰器内部做的事情是:解析函数的签名和 docstring,组装成SkillSpec,写入注册表。用这种方式,开发一个基础技能大概只需要半个小时,几乎所有 CRUD 类技能都能复用这个模式。
注册表本身就是一个带线程锁的字典,另外还会维护一份技能全文索引,供动态发现模块使用:
class SkillRegistry: def __init__(self): self._skills = {} self._lock = threading.Lock() def register(self, spec: SkillSpec): with self._lock: if spec.name in self._skills: raise ValueError(f"skill {spec.name} already exists") self._skills[spec.name] = spec def discover(self, query: str, top_k: int = 5) -> list[SkillSpec]: # 这里是简化版,实际会调用向量检索模型 scores = {name: similarity(query, skill.description) for name, skill in self._skills.items()} ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True) return [self._skills[name] for name, _ in ranked[:top_k]]关键设计:注册表里必须记录参数的完整描述,而不是只记录参数名。因为大模型生成参数时,靠的就是这些字段描述来推断用户意图。比如order_id字段如果只写订单号,模型遇到用户说“我手机尾号 8899 的单子”时就不知道怎么填;但如果描述改成订单号,当用户未提供时可通过手机号反查,模型就会优先用手机号参数发起反查。
3.3 调度器如何执行并标准化结果
调度器是 agent-skills 模块的引擎。每次大模型返回一个“要调用技能”的指令时,都是先到调度器,由调度器去执行技能、处理异常、翻译结果,最后把结果重新塞回对话历史。
调度器核心逻辑如下:
class Orchestrator: def __init__(self, registry: SkillRegistry): self.registry = registry self.translator = ResultTranslator() def execute(self, skill_call: dict) -> dict: skill_name = skill_call["name"] arguments = json.loads(skill_call.get("arguments") or "{}") spec = self.registry.get(skill_name) if not spec: return self.error_response(f"技能 {skill_name} 不存在,请检查技能名称") try: # 参数校验,这一步我用的 pydantic,能自动报缺参和类型错误 validated_args = spec.parameters(**arguments) except ValidationError as e: return self.error_response(f"参数校验失败: {e},请根据技能说明重新生成合法参数") try: raw_result = spec.handler(**validated_args.dict()) except Exception as e: return self.error_response(f"技能执行失败: {str(e)},请告知用户稍后重试") normalized = self._normalize(raw_result) return {"type": "success", "content": self.translator.to_text(skill_name, normalized)} def error_response(self, message: str) -> dict: return {"type": "error", "content": message}_normalize做了一件很基础但重要的事:把后端返回的各种格式统一成{"code": int, "data": {}, "message": str}。为什么要统一?因为模型对“结构清晰的文本”理解能力远好于“嵌套随意的 JSON”,所有技能的结果都翻译成同一套自然语言模板,模型就不需要去猜。
结果翻译层ResultTranslator的示例逻辑:
def to_text(self, skill_name: str, result: dict): if skill_name == "query_order": data = result["data"] return ( f"订单查询成功:订单号 {data['order_id']}," f"商品名称 {data['item_name']}," f"订单状态 {data['status_text']}," f"下单时间 {data['created_at']}。" ) return json.dumps(result, ensure_ascii=False)这一步千万别省。我最早就是直接把底层接口的原始 JSON 返回给模型,结果模型根本判断不了接下来该做什么,经常答非所问。后来全部改成标准话术模板,任务链路的成功率才真正稳定下来。
3.4 给模型一本“技能说明书”:prompt 工程与 few-shot 的配合
技能注册好了,调度器也写好了,但模型“不知道什么时候调用”、以及“调用时参数怎么填”,是另一个必须解决的问题。我用了三个手段组合:
第一,把动态发现的技能描述插入到 system prompt 中。每次请求前,根据用户 query 通过向量检索选出候选技能,把候选技能的description和parameters的完整 JSON Schema 写进 system prompt。这一步保证模型能“看见”当前任务相关的技能。
第二,对每个技能,在注册表里额外维护 2 到 3 个“典型调用示例”。这些示例不是给人看的,而是拼进 prompt 的 few-shot。比如query_order技能的示例是:“用户说“查一下我最后一单到哪了”,你应该调用 query_order,参数 order_id 留空,使用 phone 参数反查用户最近订单。” 实测发现,加了 few-shot 之后,参数填充正确率能提高约 20 个百分点。
第三,对 model 返回的 function call 结果做“二次校验”。不光是参数格式校验,还会看这个技能是否真的合适当前上下文。比如用户问“退货政策是什么”,模型如果调用了query_order,我会判断为误调用,在返回给模型前直接拦截,并告诉它“该 skill 不适合当前问题,请重新选择”。这种对抗式的反馈能持续优化模型的行为。
4. 用数据说话:技能好不好用,必须量化
很多团队把技能上线后,只停留在“能调通”的层面,完全不做数据评估。我在项目里吃过亏:有些技能看着很顺,实际上模型根本没在用;有些技能被反复调用,但失败率极高,白白浪费 token。后来我建立了一套垂直的量化评估体系,每个技能是否值得保留,都用数据说话。
4.1 评测集怎么建更接近真实使用
技能评测集和一般的大模型问答评测集完全不同。通用问答评测集关注答案是否准确,而我更关心“模型在什么时候调用技能、调用后参数是否合法、执行结果是否被正确利用”这三件事。
我按照业务场景把评测集分为三类:
- 明确触发型:用户问句里直接包含技能关键词,例如“帮我查下单号 ORD20240115”,预期是模型必然调用查询订单技能。
- 模糊触发型:用户没有明确指出要查什么,例如“我买的东西怎么还没到”,预期是模型先判断出“可能用户想查物流”,然后再调用物流查询技能。
- 不应触发型:用户只是闲聊,例如“你们公司几点下班”,预期是模型不要调用任何技能,直接回答即可。
每类测试 case 至少准备 50 条,全部来自真实客服对话日志脱敏后的数据。评测的时候,我会盯着三个指标:技能选择准确率、参数填充合法率、最终回答正确率。这三者层层递进,哪一环掉链子都能立刻定位到是“选错技能”“填错参数”还是“表达错误”。
4.2 我常用的四个核心指标
除了准确率这种常见指标,我在项目里还额外记录四个非常实用的指标:
| 指标 | 计算方式 | 说明 |
|---|---|---|
| 技能调用率 | 调用技能的任务数 / 总任务数 | 太低说明模型不信任技能,太高说明可能误调用 |
| 参数一次合法率 | 参数校验通过的调用次数 / 总调用次数 | 反映模型对参数 schema 的理解程度 |
| 技能执行成功率 | 技能成功返回结果次数 / 总调用次数 | 反映后端接口的稳定性与正确性 |
| 单任务平均额外轮次 | (实际对话轮次 - 理想轮次) / 任务数 | 反映技能调用链路是否顺畅,轮次越多问题越大 |
这几个指标可以直接放到日常监控看板里。比如某个版本更新后,如果“参数一次合法率”掉了 10%,那大概率是新技能的参数描述写得不清楚,或者和旧技能的语义重叠了。有了数据,每次迭代就不再是拍脑袋,而是有依据的回滚或修复。
4.3 回归测试是技能质量的兜底
技能迭代特别容易引发“按下葫芦浮起瓢”——修好了 A 技能的 bug,结果 B 技能的调用率下降了。所以我在每次修改技能注册信息、prompt 模板或编排器逻辑后,都会跑一遍完整的评测集,对比旧版本的指标。
最开始我手工跑,后来写成了自动化脚本:每次发版前,自动在沙箱环境跑完 300 条评测 case,输出一份对比报告。任何指标出现明显下降,直接阻断发布。这套回归流程帮我挡住了至少五次线上事故,强烈建议你在项目初期就把它搭起来,否则后面技能越来越多,回归成本会高到让你根本不想跑。
5. 踩坑记录:agent-skills 最常见的五个问题
最后这部分是我最想讲的,因为网上教程很少暴露这些真实的坑。每一个问题我都在生产环境里真实遇到过,并且都付出了不小的代价才排查清楚。
5.1 模型死活不调用技能
这是最常见的启动问题。模型在对话里似乎知道该怎么回答,但就是不发起技能调用。我复盘后发现,原因通常是:技能描述里没有明确说明“你必须调用技能才能回答这个问题”。
解决办法有两个。第一,在 system prompt 里显式约定行为规则,例如“当用户需要查询订单、物流、退款信息时,必须先调用对应技能,不能根据上下文记忆臆测数据”。第二,对每个技能描述加上“触发优先级”,比如在 query_order 描述开头写上“这是获取订单数据的唯一途径,不要自己编造订单信息”。改完后,技能调用率立刻从 40% 涨到 85% 以上。
5.2 参数总填错,尤其是把时间格式搞乱
模型对参数的格式遵循能力参差不齐。我遇到最多的是时间参数:业务系统要求YYYY-MM-DD,模型却返回2024/01/15或者“今天”这种相对说法。后来我在 JSON Schema 里不仅标注了格式,还加了一个默认值示例和一条正则说明。配合调度器里的 pydantic 校验,在出错时给模型回传非常具体的错误信息:“时间参数格式应为 YYYY-MM-DD,你返回的 2024/01/15 无法解析,请重新生成”。这种“错误即反馈”的循环,能很快让模型学会规范填参。
5.3 技能太多导致选择混乱,误调用高发
技能注册超过 30 个之后,所有技能描述全塞进去,模型就开始“选择困难”。经常用户只是想聊天,模型却调了个查单技能。这个问题靠动态技能发现解决了大半。另外我还做了一个限制:每个技能描述必须控制在 50 字以内,只写触发条件和核心能力,不写废话,给模型的信息密度越高,选择越准。
5.4 技能执行出错时,模型会“嘴硬”不肯认错
这是个比较隐性但影响体验的问题。技能执行失败后,我最早返回的错误信息不够清晰,模型会掩盖失败事实,直接对用户说“您的订单已处理完成”,造成严重的信任问题。后来我硬性规定:任何技能失败,必须生成一段明确的“失败说明”返回给用户,同时调度器会把错误原因单独放入一个error字段,并提示模型“此任务未完成,需要重新选择方案或解释失败原因”。这样调整之后,模型的失败处理行为才变得可靠。
5.5 问题排查速查表
我把典型的技能链路问题整理成了速查表,团队新人排查问题时直接对照定位:
| 现象 | 可能原因 | 排查位置 | 常规解法 |
|---|---|---|---|
| 模型完全不调用技能 | 技能描述不充分、prompt 缺少强制约定 | system prompt、技能描述 | 增加“必须先调用技能”的规则,补充触发优先级 |
| 调用了错误的技能 | 技能间语义重叠、描述不清晰 | 技能 description、动态检索排序 | 去掉模糊词汇,增加“不是…场景”排除说明 |
| 参数校验频繁失败 | 参数描述不全、缺示例 | JSON Schema、few-shot | 补充字段说明和日期格式等强约束 |
| 技能执行一直失败 | 后端接口异常、协议不匹配 | 技能 handler、日志 | 查看异常堆栈,修复接口或做降级策略 |
| 结果返回后模型答非所问 | 结果未标准化或太杂乱 | translator 输出模板 | 统一为自然语言话术,不直接吐原始 JSON |
| 技能越加越乱、性能下降 | 注册过多无效技能 | 注册表、动态发现 | 做技能下线和收敛,减少候选数量 |
这个速查表目前已经是我们组里新人上手 agent 项目的必备资料,很多问题其实不需要看代码,先对照表现查描述和注册信息就能解决一大半。
最后再分享一个小技巧。如果你只想为这个项目留一条经验,我会选:永远让技能自己报错,而不是让模型猜。技能模块做得越标准,错误信息越具体,模型的恢复能力就越强。一套带着清晰错误边界的 agent-skills 体系,比任何花哨的 prompt 都管用。我后面还会继续把组合技能编排的自动化和技能效果归因这块做深,等有新的结论再来更新。