这两年做 AI 应用,被问得最多的一个问题就是:Agent 和普通的 ChatBot 到底差在哪?我的答案一直是同一个——差在agent-skills上。同样是接了大模型接口,ChatBot 只会“说话”,Agent 却会“办事”。而让 Agent 从“会说话”进化到“会办事”的关键,就是给它配上一整套可调用、可编排、可兜底的技能体系。这篇博文不聊虚的,直接讲讲我在实际项目里怎么设计、实现、测试一套 agent-skills,踩过哪些坑,以及哪些经验是文档里不会写但你必须知道的。
这篇文章适合谁?如果你正在做 AI 应用开发,想让模型具备调用工具、操作数据、执行业务逻辑的能力;或者你已经在用函数调用但总觉得模型“不听话”、调错工具、传错参数,那这篇文章就是给你准备的。我会从概念讲清楚,再给出一套能直接落地的实操方案。
1. Agent Skills 到底是什么:先搞懂概念边界
1.1 从“会说话”到“会办事”
先讲个最简单的类比。你招了一个实习生,第一天他能跟你聊行业趋势,聊得头头是道,但你说“帮我把这个季度的报表整理一下”,他懵了——因为他只会“说”,不会“做”。Agent 也一样,大模型本身只是一个“语言大脑”,你问它什么它都能接上话,但你要它查数据库、调接口、发通知、算参数,它没有手没有脚,干不了。
Agent Skills 就是给这个“语言大脑”装上的一套“手脚”。每个 Skill 代表一个具体可执行的能力单元,比如“查询订单状态”“计算运费”“生成周报”等。模型在理解用户意图后,会选择合适的 Skill 来执行,然后把执行结果结合上下文组织成最终回复。这一套机制,把大模型从“聊天机器”升级成了“能干活的工作流引擎”。
我在一个实际项目中的体会是:同样的模型、同样的提示词,有没有 Skills 体系,用户感知完全是两个档次的东西。没有 Skills 的时候,用户问“帮我查一下上个月销售额”,它只会告诉你“我无法访问您的数据,建议您登录后台查看”;有了 Skills,它真的能查出来,再附带一句“和上个月相比下降了 12%”。这个感知差异,比任何炫酷的 UI 都管用。
1.2 Skill、Tool、Function 到底有什么区别
很多人会把 Skill、Tool、Function 三个词混着用,其实它们是有层级的。搞不清楚这个,后面设计的时候就会乱。
- Function是最底层的,就是一段可调用的函数代码,负责具体的执行逻辑。
- Tool是一个通用说法,泛指模型可以调用的外部能力,包括函数、API、知识库检索等。
- Skill是更高维度的封装,它不仅包含 Function,还包括模型的调用描述、参数 Schema、输入输出约束、错误的兜底逻辑,甚至包含示例。
用一个比喻:Function 是一把螺丝刀,Tool 是你工具箱里的任何工具,Skill 则是一本“工具使用说明书+操作规范”。模型不看螺丝刀内部是怎么造的,它只看说明书,然后决定“这个场景该用哪把工具、怎么用、用完怎么反馈”。
如果你只是做一次性的简单调用,定义个 Function 就够了。但当你面对的是一个有多个业务环节的真实项目,你需要的是 Skill 体系——它让“模型该在什么情况下调用什么工具”这件事变得可控、可持续维护、可测试。很多项目死在“函数调用一多就乱套”上,就是因为没有这一层设计。
1.3 不是所有场景都需要 Skills
也别一听 Skills 就上头,有些项目压根用不上。判断标准就一句话:你的应用是不是需要模型去操作真实的外部系统?如果只是做纯文本问答、内容生成、角色扮演这种,不需要 Skills,硬加反而画蛇添足——模型会因为工具描述的干扰影响本身的语言表现。
需要上 Skills 的典型场景包括:查询业务数据、执行写操作(下单、更新记录)、调用第三方 API、多步骤任务编排、需要感知实时状态等。判断的时候还有一个实用标准:如果这个流程中用户必须切到别的系统才能完成目标,那它就值得做进 Skill 里。
我自己见过不少失败的案例:团队一听说 Agent 火,立刻给每个对话都塞上十几个工具,结果模型在无关场景下频繁误调用工具,用户基础体验反而一塌糊涂。Skills 的正确用法,是“该出手时才出手”,而不是“把所有工具都摆到台面上”。
2. 为什么“会写工具”不等于“会用工具”
2.1 模型是“近视眼”,它看不到你的代码
我在指导团队时最常说的一句话就是:模型是近视眼,它眼中的工具只是几句话和一个 JSON 结构。你的函数写得再漂亮,注释再详尽,模型根本看不到。它只看到你在接口层描述的那几行字——工具名称、一段说明文字、参数表。
这就导致一个非常普遍的坑:开发人员花了大量精力写业务代码,但给模型“看”的描述是随便写的。结果模型不知道这个工具是干嘛的、什么时候该用、参数怎么填,于是乱调用、瞎传参,最后大家还以为是模型智商不行。其实问题出在“接口可见层”太潦草。
我后来定了一条铁律:给模型写的工具描述,要当作给一个聪明但没经验的同事写交接文档来对待。你得说清楚这个工具是干嘛的,什么情况下用,什么情况下不要用,有什么坑。比如你写一个“计算运费”的函数,不能只写“计算运费”,要写清楚“根据收货地址和包裹重量计算快递运费,仅支持国内地址,偏远地区需额外加收附加费,包裹超 30kg 时不可使用此工具”。这些信息,才是模型做决策的真正依据。
2.2 描述里藏着模型的所有“决策信息”
模型选择工具的决策依据是什么?就是工具描述和参数说明。这就像你在搜索引擎里搜东西——网页内容再好,标题和摘要写得不明不白,你也不会点进去。同样的,函数实现得再稳,描述写得含糊,模型就不知道这个入口通向哪里。
实际操作中,我建议把每个 Skill 的描述都按固定的结构写:是什么(一句话说明)、适用场景(什么时候用)、排除场景(什么时候不用)、注意事项(边界和限制)、示例(可选)。你可以理解为给模型准备一份“决策树”,只不过这份决策树得用自然语言写。
举个例子,一个“获取用户订单”的 Skill,有的团队这么写:“获取订单信息。”模型看了等于没看——它是获取当前用户的订单,还是按订单号查?要不要权限?查不到怎么办?你把这些写在描述里,模型才能把用户的问题和工具精准匹配上。我实际测试过,同样的工具,描述从一句话扩成四句话,调用准确率能提升 20% 以上,这还只是保守数字。
2.3 参数 Schema 越宽松,模型越会给你“编”
另一个常见坑是参数 Schema 设置得过宽。模型在不确定参数值的时候,会基于上下文“猜”一个值填进去,专业说法叫“参数幻觉”。比如你只声明了参数是字符串,没有写格式,模型就可能传一个超出预期的值——传了不存在的订单号,传了一个不支持的货币单位,甚至传了带有特殊符号的文本,直接把后面的代码搞崩。
解决思路不是靠模型自觉,而是从 Schema 层面卡死。能用枚举就用枚举,能限定格式就限定格式,能标必填就标必填。JSON Schema 里那些enum、format、pattern、minimum、maximum、required不是给前端表单用的,同样是给模型用的紧箍咒。模型在生成参数时会尽量遵循约束,约束越明确,幻觉率越低。
我自己的习惯是:string 加上enum或正则,number 加上 min/max,object 只开放必要的字段,additionalProperties一律设为false。这些细节单独看都不起眼,合在一起能省掉大量后面的解析和校验代码。有一句话总结得很好:你给模型多大的自由度,就得准备多大的容错机制。
3. 手把手设计一个 Skill:从需求到上线
3.1 需求拆解:从用户问题反推能力点
设计 Skill 的第一步不是写代码,而是拆需求。我惯用的方法是:列出一批真实的用户问题,然后反推“要回答这个问题,模型需要具备什么能力”。
举个例子,假设你要做一个“智能日程助手”。先定义一批用户问题:
- “帮我看看明天下午三点有没有空。”
- “给张三发个会议邀请,时间定在下周二上午十点。”
- “把和李四的会改到周四。”
- “下周有哪些需要准备的会议?”
从这些问题可以反推出至少四个能力点:查询日程、创建日程(带着参会人信息)、修改日程、汇总日程列表。每个能力点就是一个候选的 Skill。别一上来就拍脑袋定义工具,先让需求说话。
这里还有个容易被忽略的问题:同样是“日程”,查空闲和查日程明细其实是两个不同的操作,你可以合并成一个工具(在参数里区分),也可以拆成两个工具。我的建议是优先拆开——Skill 粒度越小,模型的决策就越简单。一个工具只做一件事,描述也更好写,测试也更好覆盖。
3.2 定义元数据与参数 Schema
在代码层面,一个 Skill 的注册信息通常长这样。以“查询日程”为例:
skill_meta = { "name": "query_schedule", "description": "查询用户在指定时间段内的日程安排。" "适合回答'我某天有没有空''某时间段安排了什么'这类问题。" "当用户没有明确指定时间段时,默认查询当天。" "本工具只做查询,不做创建或修改," "如需创建或修改日程请使用 create_schedule / update_schedule。", "parameters": { "type": "object", "properties": { "date": { "type": "string", "description": "查询日期,格式 YYYY-MM-DD," "例如 2025-01-15。不传则默认当天。" }, "start_time": { "type": "string", "description": "可选,查询起始时间,24小时制 HH:MM,例如 14:00。" }, "end_time": { "type": "string", "description": "可选,查询结束时间,24小时制 HH:MM,例如 18:00。" } }, "additionalProperties": False } }里面每一项配置都有它的意义:描述里的“只做查询,不做创建或修改”是在给模型划边界;参数上的日期格式说明是在引导模型生成合法值;additionalProperties: False是拒绝一切不在 Schema 里的多余字段。这些细节全是实战中填过的坑换来的。
3.3 实现执行函数与异常处理
元数据定义完后,实现函数本身。这里有个非常关键的点:函数要假设模型会传任何值,做好参数校验和异常处理。模型可以遵守约定传一个合法的日期,也可能抽风传一个“昨天”或“下周五”,这时候不能直接崩。
def query_schedule(date: str = None, start_time: str = None, end_time: str = None): # 参数兜底 if date is None: date = datetime.now().strftime("%Y-%m-%d") # 格式校验 try: datetime.strptime(date, "%Y-%m-%d") except ValueError: return {"error": "invalid_date", "message": f"日期格式应为 YYYY-MM-DD,收到:{date}"} # 正常逻辑:查询数据库或调用日历 API events = calendar_service.query(start=date, start_time=start_time, end_time=end_time) if not events: return {"message": "该时间段暂无日程安排", "events": []} return {"events": [{"title": e.title, "start": e.start.isoformat(), "end": e.end.isoformat()} for e in events]}注意这里我做了两层防护:一是默认值兜底,二是格式校验并返回结构化错误信息。为什么要返回结构化错误而不是抛异常?因为模型能读懂返回值——你告诉它“日期格式错了”,它能自己修正后重新调用;你直接抛异常,模型只能一脸懵地告诉用户“系统出错了”。
3.4 把 Skill 注入到模型请求里
到这一步,Skill 已经定义好了,但模型还不知道它的存在。你需要把工具列表注入到每次模型请求的上下文中。目前主流模型服务的函数调用机制都有对应的接口,我简化说明流程:
- 构造对话消息列表(用户问题 + 历史上下文)。
- 在请求参数中加入工具列表,即上面定义的 skill_meta。
- 模型根据对话内容决策:要么返回普通文本回复,要么返回一个“工具调用请求”和参数。
- 你的代码检测到工具调用请求后,执行对应的函数,把结果追加为一条新的消息。
- 再把“函数结果消息”发回给模型,让它基于结果组织最终回复。
伪代码大致长这样:
def run_agent_with_skills(user_query: str, history: list, skills: list): messages = history + [{"role": "user", "content": user_query}] while True: resp = llm_client.chat_completion( messages=messages, tools=skills # 注入技能列表 ) msg = resp.choices[0].message if msg.tool_calls: for call in msg.tool_calls: result = execute_skill(call.function.name, call.function.arguments) messages.append({ "role": "tool", "tool_call_id": call.id, "content": json.dumps(result, ensure_ascii=False) }) continue else: return msg.content这里有三个实操要点。第一,循环控制在 5 轮以内,防止模型在工具链里死循环。第二,工具执行结果要转成 JSON 字符串再回传给模型,保持结构清晰。第三,每轮决策都要保留完整消息历史,模型才能理解前一步做了什么。
4. 多 Skills 编排:技能越多,越要讲规矩
4.1 技能多了模型就会“乱点鸳鸯谱”
单 Skill 的时候一切好说,模型没得选。但当你把 5 个、10 个甚至 20 个 Skill 摆到模型面前时,问题就来了。我见过一个真实场景:某团队做客服系统,接了十几个工具,模型经常把“查订单状态”和“查物流信息”搞混,用户问“我的货到哪了”,模型调了订单查询,返回一堆数字,答非所问。
这就是典型的技能编排失控。模型本质上是一个基于概率的匹配器,当两个 Skill 的功能描述高度重叠时,它的选择就成了一场赌博。你得从设计上降低它的选择难度。
我的解决方案有三个:一是让 Skill 职责单一且边界清晰;二是统一命名和描述风格,让模型能快速识别差异;三是场景化分组,如果有必要,根据用户意图类型动态决定注入哪些工具,而不是每次全量注入。
4.2 统一的命名规范与描述模板
技能越多,命名规范就越重要。我推荐一套固定的命名风格:动词开头 + 业务对象。比如create_order、cancel_order、query_order_status、query_product_stock。别用那种含糊的名字,比如do_sth、handle_data,模型根本拆不出含义。
描述模板我固定为四个部分:定义句、触发场景、排除场景、备注。举一个例子:
定义句:查询用户当前账户余额及最近十笔交易明细。 触发场景:用户询问"我还有多少钱""账户余额""最近花了哪些钱"等。 排除场景:不处理转账、不处理账单分析,这些问题请找相应工具。 备注:返回的金额单位为分,展示给用户前需转换为元。为什么要把“金额单位是分”这种细节都写在说明里?因为模型的最终回复要基于工具返回值组织语言,如果返回的金额单位不明确,模型可能直接把“123456”读成“十二万三千四百五十六”还是“一千二百三十四块五毛六”?它没法判断。把单位写清楚,它才能给出正确的回复。
4.3 冲突消解与降级策略
技能之间难免有交叉。比如“查询订单”和“查询全部订单列表”功能相近,“修改收货地址”和“新增收货地址”边界有重叠。遇到这种情况,我的经验是:宁可让两个技能边界有少量重叠,也不要让一个技能啥都干。重叠带来的是误调用概率上升,但可以通过描述中的“排除场景”来约束。“啥都干”带来的是模型决策不明确,问题更大。
另一个必须考虑的,是技能执行失败后的降级。例如查询库存失败时,你要给模型准备一个“转换为常见问题回复”的兜底;比如修改日程失败时,要允许模型退化成“只读查询”模式,先把现有日程列出来让用户确认。没有降级策略的 Agent,线上跑起来就是个定时炸弹——一个接口抖动,整条对话就废了。
我给每个 Skill 都配一个fallback字段,标记这个 Skill 失败后模型应该尝试哪个替代 Skill,或者直接返回什么样的人性化提示。这个信息同样注入到工具描述里,模型在第一步失败时能自行降级。
4.4 上下文管理:工具描述也是要花 Token 的
工具列表越长,每次请求的体积越大,响应越慢,成本越高。尤其是你写了详细描述后,10 个工具可能就要吃掉几千 token。这里有个很现实的取舍:是将所有技能全量注入保证召全率,还是动态注入节省成本?
我的方案是分层注入:核心的 2~3 个技能(比如查余额、查订单)永远全量注入;其余技能根据用户当前会话的意图预测来动态注入。实现方式很简单,先用一次轻量的模型调用判断意图类别(或者用关键词规则初步筛选),再按意图类别加载对应的工具子集。这样既保证模型“看到的工具都是相关的”,又把 token 消耗控制在合理范围内。
我实测过一个项目,动态注入后单次请求的 token 消耗下降了约 40%,技能调用的准确率还略有提升——因为可选项少了,模型的选择更聚焦了。当然,这个方案需要你有一个可靠的意图分类层,不然反而会误伤。如果你刚起步,建议先从全量注入开始,等技能超过 8 个再考虑动态加载。
5. 测试与回归:Skill 体系靠“喂案例”长大
5.1 为什么普通单元测试不够
普通的单元测试关注的是函数逻辑:传入参数,断言返回值。但 Skills 体系多了一层模型决策的不确定性。同一句话,模型这次调了工具 A,下次可能调工具 B,甚至第三次完全不调工具直接回答。所以你的测试必须要覆盖“模型看到什么、选择什么、怎么执行、怎么回复”这一整条链路。
我实践下来的做法是:建一个行为回归测试集,里面存放大量“用户问题 + 期望行为”的用例。每改一次技能描述、每升级一次模型版本,就跑一遍这个测试集,看整体表现是否退化。
5.2 搭建最小可用评测集
评测集的用例不需要很多,但覆盖面要全。我常用这个表格结构来维护:
| 用例ID | 用户输入 | 期望调用的Skill | 期望关键参数 | 允许的回复意图 |
|---|---|---|---|---|
| TC-001 | 明天下午有空吗 | query_schedule | date=明天, start=13:00, end=18:00 | 返回日程或空档 |
| TC-002 | 帮我约周四早上开会 | create_schedule | date=周四, time=09:00 | 确认创建 |
| TC-003 | 我上周花了多少钱 | query_expense | 最近一周 | 返回金额摘要 |
| TC-004 | 你好 | 无 | 无 | 闲聊回复,不应调工具 |
这里有个关键点:TC-004 这种“不该调工具”的用例往往比“该调工具”的用例更重要。我曾经在某个版本里把工具描述改得太“主动”,结果用户打声招呼模型都去查了一遍数据库,整个体验非常诡异。负向用例守住的是模型“不乱来”的底线。
5.3 用真实模型跑回归,别只信模拟
你可能想用 Mock 一个假的 LLM 返回结果来跑测试,省成本。我的建议是:模拟可以用于调试,但回归必须用真实模型。因为 Skills 体系最核心的稳定性问题恰恰来自模型的决策,你用假模型测不出真实模型“今天心情好不好”。
每次跑回归我是这样操作的:准备一批测试用例,用自动化脚本逐条调用真实模型服务,把模型的行为记录成日志——记录它选择了哪个工具、传了什么参数、工具返回了什么、最终回复是什么。然后逐条跟期望行为比对,标出不一致的用例,再人工分析是描述问题、参数问题还是模型本身抽风。
这个方法费钱费时间,但价值巨大。我靠着这套回归机制发现过好几个隐蔽的问题:某次工具描述更新后,英文内部混合导致断句混乱;某次新增参数后旧用例全乱;某次模型供应商升级了底层版本后,工具调用风格整体漂移。没有回归测试,这些问题大概率就直接上线坑用户了。
5.4 别忘了测试多轮对话中的 Skill 行为
多轮场景是测试的高发雷区。模型的调用决策不只受当前用户问题的约束,还很依赖历史上下文。举个真实例子:用户先问“帮我订明天北京到上海的机票”,模型调了搜票工具;接着用户说“换成后天的吧”,模型需要基于上一轮已经搜出来的航班信息做修改,这就要求历史消息中的工具结果被正确保留并在后续轮次中可被引用。
我测试多轮时,会刻意构造这样的用例:上一轮调用了工具 A,下一轮用简称指代上一轮的结果;上一轮的工具结果里包含特定数据字段,下一轮要求基于这个字段做进一步操作。这些用例能精准暴露“上下文传递”和“工具调用状态管理”的问题。
6. 常见问题与排查实录
6.1 模型死活不调用技能
这是最高频的问题。用户问“查一下我的订单”,模型却直接回答“我无法查询订单”。排查清单如下:
- 工具列表有没有真正传进请求里?很多 SDK 版本字段名变了,工具传错层级。
- 工具描述里有没有“被动语态”?描述写得太含糊,模型会认为它不该主动提供此类服务。
- 模型的工具调用开关是不是被系统提示词压制了?有些提示词写了“作为 AI 无法执行操作”,模型就直接放弃调用。
- 上下文里是否已经包含了答案?如果历史消息里有用户订单信息,模型可能直接引用而不调工具。
- 模型版本是否支持函数调用?有些轻量模型对复杂工具调用的支持比较弱。
大多数情况,前 2 条能解决 80% 的问题。
6.2 参数幻觉:模型传了不存在的字段
模型生成参数时经常“无中生有”。比如你只定义了order_id和reason,它额外传了一个refund_amount。如果你在代码里直接把这个参数透传给下一个接口,可能直接报错。解决办法正如前面说的:Schema 卡紧 + 代码层二次过滤。在函数入口加一个白名单校验,只允许 Schema 中声明的字段进入业务逻辑。
def execute_skill(name, raw_args): skill = SKILL_REGISTRY[name] args = cleanup_args(raw_args, skill["parameters"]) # cleanup_args 移除未声明的字段,并做类型转换6.3 工具返回了数据,但模型答非所问
工具结果不是文本,是 JSON。模型要基于这个 JSON 组织用户易懂的回复,中间有一段“翻译”过程。经常出问题的是:工具结果里字段含义不明确,比如status: 1没注释这个 1 是“已支付”还是“已取消”;或者返回格式嵌套太深,模型“迷失”在 JSON 里,漏掉了关键字段。
解决方案:返回结果保持扁平化,字段名自解释,重要状态值带上文本注释。我甚至会在返回 JSON 里额外加一个summary字段,直接把“给用户看的一句话结论”准备好,模型直接拿来用就够了。
6.4 技能列表太长,请求超时
工具描述占的 token 越多,模型首字延迟越长。排查时先量化一下:一次请求总共多少 token,其中工具描述占了多少。如果超过三成,就应当采取措施了。我的做法有两条:描述尽量精简到“够用但不冗余”,以及动态分场景注入。前者是日常优化,后者是结构性解法,两条都得做。
6.5 常见问题速查表
| 现象 | 根因 | 应对手段 |
|---|---|---|
| 模型不调工具 | 描述含糊或工具未注入 | 检查注入层级,重写描述 |
| 调错工具 | 多个工具边界重叠 | 拆分职责,统一命名 |
| 参数乱传 | Schema 过宽 | 枚举、格式约束、白名单过滤 |
| 回复答非所问 | 返回结果字段不明确 | 扁平化 + 添加 summary 字段 |
| 响应太慢 | 工具列表 token 过大 | 精简描述,动态注入 |
| 多轮丢失状态 | 上下文截断 | 保留工具结果历史,截断策略优化 |
写在最后的一个小技巧
聊到最后,分享一个我个人的习惯:每个 Skill 我都会在描述里放一个“调用示例”。不是写给人看的示例,而是写给模型看的——比如query_schedule的描述末尾加上一句“示例:用户问‘周五下午有空吗’,应传入 date=周五对应日期, start_time=14:00, end_time=18:00”。这个小动作成本极低,但实测能让模型在相似问题上少犯一半以上的错。模型是不擅长纯抽象推理的,给它一个具体例子,它照着抄的能力远比你想象中强。
在 Skills 这条路上,我踩过的坑比写出来的还多。但有一点是确定的:凡是认真对待工具描述、参数约束和回归测试的项目,技能调用准确率都能做到让人满意的水平;凡是偷懒跳过这些的,后面都在拿线上事故还债。这个内容后续还可以扩展的方向很多,比如 Skill 之间的状态共享、复杂多步任务的图编排、技能的自学习演进——但不管怎么扩展,先把今天这套基本功练扎实,比什么都重要。