上个月帮客户调一个销售分析Agent,遇到一个特别典型的故障:用户问“上周华东大区的销售额是多少”,Agent没有去查数据,反而洋洋洒洒输出了一篇《如何提升销售效率》的演讲稿。翻日志才发现,团队把所有业务逻辑全塞在了一个将近两万字的system prompt里,模型在浩如烟海的上下文里彻底迷失了方向。后来我们花了三天时间,把二十多项业务能力拆成了九个独立技能,同一个问题再问一遍,稳稳返回了正确的数字。
这篇文章就想把我在这类项目里的完整经验讲清楚:为什么要把Agent的能力拆成技能,技能的接口契约应该怎么定,怎么手写一个能落地的技能,多个技能之间如何编排协同,以及我踩过的那些用真金白银换回来的坑。内容主要面向正在做AI Agent应用、想让大模型真正接上业务能力的朋友,也适合想把零散脚本沉淀成可复用技能库的开发者。
1. 从一次翻车说起:为什么"什么都能干"的Agent反而什么都干不好
1.1 那次让我决定重构的对话事故
先还原一下当时的现象。整个系统是一个企业内部知识库加数据查询的Agent,用户会问销售数据、库存水位、客户投诉情况,也会问一些制度流程问题。最初的实现方式很粗暴:把所有工具函数一股脑列在提示词里,每个函数写一段长描述,再附上各种业务规则、口径说明、示例对话,期望模型自己“悟”出该调用哪个函数。
结果就是文章开头那幕。模型在那个场景里选择了“发挥”,而不是“查询”。为什么?因为提示词里信息熵太高了——销售分析、制度问答、演讲稿生成、邮件起草,几十种能力混在一起,每个能力都有一大段说明,模型在做工具选择时相当于在做一道超多分类任务。任务类别越多、每个类别描述越长,分类准确率掉得越快。这跟人一样,给你一本三千页的操作手册,让你现场回答“现在该翻哪一页”,你也容易懵。
那次之后我把架构整体改成了技能化模式。所谓技能,就是一个具备标准输入输出契约、可独立注册、可被模型按需调用的功能单元。每个技能只做一件事,描述写得像“API文档的精确摘要”而不是“散文”。改造完成后,同一个Agent在两百多次测试里的工具选择准确率从不到七成提升到了九成五以上。
1.2 Agent技能和普通函数到底有什么区别
很多同学会问:这不就是函数调用吗?我直接把函数扔给模型不也一样?
这里有一个核心差异:普通函数是给程序员调用的,调用者是确定的;Agent技能是给模型“选择”的,调用者是一个概率系统。这意味着你的技能设计首先要服务的是“模型的判断力”,而不是“函数的接口优雅度”。
具体来说,有四点明显区别:
- 普通函数靠函数名和注释文档说明用途;Agent技能靠description字段帮模型做意图匹配,description写得好不好直接决定模型会不会选错。
- 普通函数的参数是程序员按文档传的;Agent技能的参数是大模型从对话上下文里抽取的,所以参数名、参数类型、枚举值都必须极其明确,最好让模型“没法猜错”。
- 普通函数的异常处理是给上层代码看的;Agent技能的异常会回到对话里,模型需要基于错误信息决定下一步行动,所以错误码和错误消息要能“被模型阅读并理解”。
- 普通函数可以任意嵌套调用;Agent技能的输出常常会成为另一个技能或模型的输入,所以返回格式必须结构化、稳定,不能依赖一段自然语言让人去解析。
简单记一句话:函数是写给代码看的,技能是写给模型看的。设计技能时,你的用户不仅是使用这个Agent的人,也是那个帮你路由函数调用的模型。
1.3 技能化之后解决了我最头疼的三个问题
第一是模型选错工具的频率显著降低。以前一个工具列表里放四十多个函数描述,模型经常张冠李戴。拆成技能后,常用技能不到十个,描述精确,模型选择就像从十张卡片里挑一张,难度完全不是一个量级。
第二是调试效率完全不一样了。以前出问题要在巨大的prompt里排查是业务规则写错了还是上下文污染了;现在每个技能可以单独测试、单独打日志,哪个环节出问题一目了然。技能跑挂了,把那段技能的输入输出拉出来看就行。
第三是复用性上了一个台阶。以前新接一个业务场景要重新设计一大段prompt;现在是从技能库里挑两三个技能做组合,再补一小段流程说明就完成了。说句实话,技能化以后,我的交付速度至少快了一倍。
2. 技能的最小骨架:先把"接口契约"定明白
2.1 一张技能描述卡片应该包含什么
我给团队定的标准,是每个技能对应一张“描述卡片”,用于注册到技能库供模型感知。卡片就五个部分,多了不要,少了不行:
技能名称必须有语义且唯一,比如query_sales_summary、generate_comparison_chart,不要用func1、tool_001这种。名称本身是模型做初步筛选的最强信号,好的名称相当于给模型划了重点。
description的要求最苛刻:两到三句话,第一句直接说明“什么情况下调用本技能”;第二句说明“本技能不处理什么”,明确排除边界;第三句在必要时给出一个用户问法的示例。比如这样写:当用户询问销售额、订单量、客单价、销售环比同比等数据口径时调用。不处理利润、成本、库存相关查询。例如“上月华东区销售额多少”。这种写法能把许多边缘情况直接挡在外面。
input_schema是模型抽取参数的地图。每个字段都要有类型、是否必填、取值范围、示例值。比如日期字段就明确写成YYYY-MM-DD格式,千万别写“一个日期”这种模糊描述。字段数量控制在5个以内,超过5个就说明这个技能粒度太粗,得拆。
返回结构应该固定为JSON,包含status、data、error_code、error_message四个顶层字段。业务数据全放data里,错误统一放error_code。后面章节还会展开讲为什么不能返回自然语言。
sample_call是一个参数填好的调用示例,比如{"start_date": "2025-01-01", "end_date": "2025-01-31", "region": "华东"}。这个示例不仅帮助模型理解参数格式,也是我写自动化测试的重要基线。
2.2 输入输出的Schema设计思路
Schema设计是整个技能体系里最容易被低估的环节。很多团队一开始觉得这不过就是定义几个参数,结果上线之后模型频频抽错参数——把region传成“华东大区”,把日期传成“上个月”,Schema里明明写了格式,模型就是视而不见。
我的实践经验是:Schema要让模型“没机会犯错”,而不是“有机会做对”。
怎么做?有几个小技巧非常管用:
- 字段名直接使用业务通用词汇,不要用内部缩写。用户说“华东”,你的字段就叫
region;用户说“上个月”,你千万别设计一个叫last_30d的字段。 - 枚举值显式列全。如果区域只有华东、华南、华北、西南,就把这四个写在
enum里;模型在有限选项里选,准确率远高于让它自由填写。 - 日期类参数不要只给格式,还可以明确写出“用户说‘上周’时,请换算为具体的起止日期后再传入”。
- 在description里加一句“如果你不确定参数值,请向用户发起追问”,这能在很大程度上避免模型拿空值硬调用。
返回结构同样有讲究。data字段内部尽量使用扁平结构,避免多层嵌套。模型读取一条技能结果时,嵌套越深越容易出现理解偏差。我之前设计过一个返回,结果里套了三层数组,模型在后续总结时经常漏掉内层信息。改成扁平结构以后,这个问题基本消失。
2.3 技能注册与加载机制
技能写完之后要有一个统一的加载机制把它们暴露给大模型。这个机制我建议自建一个轻量注册表,而不是直接堆在系统提示词里。
注册表的核心是三个动作:登记、索引、加载。登记阶段读取技能目录下每个技能的描述卡片,把名称、描述、Schema汇总成一份“技能清单”;索引阶段按业务域给技能打标签,比如销售域、库存域、制度域;加载阶段根据对话上下文动态挑选相关技能,只把候选技能的描述注入到提示词里。
动态加载这一步特别关键。假设技能库积累到五十个技能,如果全部描述都塞进提示词,你就又回到了“信息过载”的老路上。动态挑选的规则并不复杂,可以用一个轻量embedding模型对用户问题做向量化,和技能描述做相似度排序,取Top K注入。K一般取5到8,既保证覆盖面,又不至于淹没模型。
注册表的具体实现可以先极简起步:一个目录,每个技能一个文件夹,里面放skill.yaml描述卡片和实现文件。用一个Python脚本扫描目录生成索引,交给Agent运行时加载。等到技能数量超过三十个,再考虑引入数据库或向量库也不迟。
3. 手写一个真实技能:从"任务拆解"到"可交付"
3.1 技能的目标设定与拆解原则
接下来用一个销售数据查询技能当例子,把这套方法从头到尾走一遍。
先定目标:用户用一句自然语言查询销售汇总数据,技能返回结构化结果,模型再基于结果组织回答。这个技能要处理几个关键分支:
分支一是基础查询,比如“华东区上月销售额”,要能做时间区间过滤和区域过滤;分支二是聚合口径切换,用户说“按月看趋势”,返回就要带月份维度;分支三是数据为空,比如查了一个没有数据的时间段,技能必须返回明确的空结果标识,而不是一串让模型自行脑补的字符串。
拆解原则是一个技能只覆盖一个业务动作,再加两个紧邻的边缘动作。这里的主动作是“查询销售汇总数据”,边缘动作是“切换聚合时间粒度”和“处理空结果”。如果用户还要做销售额和去年同期的对比,那属于另一个技能compare_sales_period的职责,不要揉进来。
3.2 代码实现:从参数校验到结果格式化
技能的Python实现我推荐用类的方式组织,每个技能继承一个BaseSkill基类,基类负责公共逻辑。下面这段代码是一个简化但完整的示例:
class BaseSkill: name = "" version = "1.0.0" description = "" input_schema = {} async def validate(self, params: dict) -> dict: # 参数校验的统一入口 errors = [] for field, spec in self.input_schema.items(): if spec.get("required") and field not in params: errors.append(f"missing required field: {field}") if field in params and "enum" in spec: if params[field] not in spec["enum"]: errors.append(f"invalid value for {field}: {params[field]}") if errors: raise SkillInputError("; ".join(errors)) return params async def run(self, params: dict) -> dict: raise NotImplementedError async def execute(self, params: dict) -> dict: try: await self.validate(params) result = await self.run(params) return {"status": "success", "data": result, "error_code": "", "error_message": ""} except SkillBusinessError as e: return {"status": "failed", "data": None, "error_code": "BIZ_ERROR", "error_message": str(e)} except SkillDatabaseError as e: return {"status": "failed", "data": None, "error_code": "DB_ERROR", "error_message": str(e)} except Exception as e: return {"status": "failed", "data": None, "error_code": "UNKNOWN_ERROR", "error_message": str(e)}业务技能这样写:
@register_skill class SalesQuerySkill(BaseSkill): name = "query_sales_summary" version = "1.2.0" description = ( "当用户询问销售额、订单量、客单价、销售数据时调用。" "不处理利润、成本、库存相关查询。" "例如:上月华东区销售额多少。" ) input_schema = { "start_date": {"type": "string", "required": True, "format": "YYYY-MM-DD"}, "end_date": {"type": "string", "required": True, "format": "YYYY-MM-DD"}, "region": {"type": "string", "enum": ["华东", "华南", "华北", "西南", "全国"]}, "product_line": {"type": "string"}, "aggregate": {"type": "string", "enum": ["total", "daily", "weekly", "monthly"], "default": "total"} } async def run(self, params: dict) -> dict: start = params["start_date"] end = params["end_date"] region = params.get("region", "全国") product = params.get("product_line", "") aggregate = params.get("aggregate", "total") sql = build_sales_query(start, end, region, product, aggregate) try: df = await query_dws(sql) except DatabaseTimeout: raise SkillDatabaseError("销售数据查询超时,请缩小时间范围后重试") if df.empty: raise SkillBusinessError("该条件下没有销售数据,请确认查询条件") summary = sales_summary_from_df(df, aggregate) return { "query_condition": { "start_date": start, "end_date": end, "region": region, "product_line": product, "aggregate": aggregate }, "summary": summary }这段代码里需要注意两个细节。第一,业务错误和数据库错误分开捕获,这样当Agent收到BIZ_ERROR和DB_ERROR时,可以采取不同的策略——空数据就如实告诉用户,数据库超时则可以建议用户缩小范围重试。第二,返回里包含了query_condition,把这次查询实际使用的条件回显给模型。这个字段能有效防止模型在总结时把条件说错,比如用户问“华东”,技能实际按“全国”查了,模型照实说有误时,query_condition就是澄清依据。
3.3 如何测试一个技能的行为边界
技能测试跟普通单元测试很不一样。普通测试关注“正确输入下输出是否正确”;技能测试更要关注“模型可能给出的各种畸形输入下,技能会不会优雅失败”。
我常用的技能测试清单包含以下分支:
- 参数完全正确时,返回是否完整、稳定;
- 缺少必填参数时,是否能返回明确的缺失提示;
- 枚举值传错时(比如region传了个“北方”),是否有清晰报错;
- 日期范围过大且数据库超时,错误类型是否是
DB_ERROR; - 查询结果为空,是否准确返回空结果业务错误;
- 同一个技能并发调用时,是否存在共享状态污染。
这些测试用Pytest把BaseSkill的示例Schema和真实技能实现一起跑就行。每次技能版本更新,先把这些测试跑绿了再上线。这条纪律我在项目里是死命令,很多线上事故其实就是因为某个技能改了一行SQL没回归测试导致的。
3.4 把技能交付给Agent:动态加载与调用链路
技能注册完成后,Agent运行时的调用链路是这样的:用户提问进入会话,先由意图路由层计算问题与所有技能描述的相关度,选Top K技能注入提示词。模型在推理时看到的是精简过的技能说明,决定调用哪个技能并填好参数。参数到达技能执行层,经过校验、查询、格式化后返回结构化结果。结果回到模型,模型基于data内容生成自然语言答复。
这条链路里有一个特别值得强调的点:技能执行层只负责返回数据,永远不要返回“成品文案”。我见过很多团队让技能内部直接拼好“华东区1月销售额为100万元,环比增长5%”这种句子,看似省事,实则麻烦——一旦用户追问“那环比增长的原因是什么”,模型无法从这句话里拆出结构化数据去做进一步分析。技能永远只返回{"amount": 1000000, "growth_rate": 0.05}这类原始信息,把表述的工作交给模型。
4. 多个技能协同:编排层的设计误区与正确姿势
4.1 第一个误区:让一个技能干所有事
技能体系变大之后,下一个问题自然出现:多个技能怎么配合。
最常见的错误做法,是贪图省事把一个复杂任务的所有步骤封装进一个技能。比如“生成销售周报”这个技能,内部集成了查询数据、计算环比、生成图表、排版推送四个步骤。听起来很合理,但真正上线后就发现问题:用户只是想“看看本周销售数据”,也被迫走了整个周报流程,响应慢、费用高、还容易在某一步失败时整体崩溃。
正确的做法恰恰相反:把周报拆成四个独立技能——查询数据、计算同期对比、生成图表、组装文本。每个技能可以被单独召唤,也可以在编排层组合。用户问“本周数据”,只触发第一个技能;用户说“生成周报”,编排层按顺序触发四个技能。
拆分的标准很简单:任何一步的输出如果可能被其他场景复用,就值得独立成技能。数据查询结果可以复用于指标卡、周报、异常分析;图表生成可以复用于周报、汇报PPT、对外战报。独立之后,每种场景都是在不同位置复用同一批底层能力,而不是重复造轮子。
4.2 技能间的数据传递与上下文保持
技能协同的另一个关键技术点是数据传递。每个技能是无状态的,执行完把结果返回就结束了。编排层要负责把上一个技能的结果塞给下一个技能作为输入的一部分。
这里有一个我研究很久的细节:用一个结构化的workspace在技能之间传递数据,而不是靠对话自然语言。
什么是workspace?可以理解为一个轻量的数据暂存区。编排层维护一个字典,每个技能执行后把关键产出放进去,下一个技能按需读取。比如卖报场景:第一步生成销售汇总写入workspace["sales"],第二步读取sales算环比,第三步读取sales和compare画图。技能只需声明自己需要读取哪些键,不必关心数据是从哪来的。这个模式做出来的体系,技能之间的耦合度非常低,换数据源或者调整流程都是改编排层,技能本体不用动。
但也要注意一个分寸:不要把整个数据库都放进workspace。每个技能只写入自己真正产出且后续可能被引用的内容,否则堆料过多又会重现“信息过载”的老问题。
4.3 冲突处理与回退策略
多个技能的调用顺序不是总按预想的来,因为决定顺序的是模型,而模型偶尔会做出奇怪的决策。
我遇到过一种典型情况:模型已经调用了图表生成技能,接着又回头调了数据查询技能,还试图把新查询结果“追加”到已经生成的图里。面对这种乱序调用,编排层最稳妥的做法是一致性校验:为每个技能声明前置依赖。generate_chart依赖query_sales_summary和compare_sales_period的产出,如果编排层发现这两个前驱没有执行记录,就拒绝调用图表技能,并引导模型先执行依赖项。
回退策略同样重要。当某个技能连续失败两次时,编排层应该切换到一条预设的兜底路径,而不是让模型反复重试同一个必败动作。比如图表技能依赖的数据查询超时,兜底路径可以是调用一个轻量版的query_sales_summary_from_cache,用缓存数据出图。如果缓存也没有,就直接告诉用户“图表能力暂不可用”,而不是让模型绕来绕去浪费时间和token。
5. 落地过程中的六个坑,每一个都是真金白银换来的
5.1 描述写得像散文,模型根本选不中技能
第一次大规模上线时,我给一个技能写的description是:“这个功能是用于获取用户不同维度的销售信息,帮助运营团队快速了解最新的业务动态,支持按日期、区域、品类灵活筛选,同时也可以对数据进行简单的统计汇总……”
看起来没什么问题是吧?但模型在用户问“上个月华东卖了多少”时,选中的竟然是另一个技能。排查发现,问题就出在描述太“功能化”而不是“触发化”。模型做技能选择时不是在看“你这个功能有什么能力”,而是在看“用户这个问题跟你哪个技能对得上”。
后来我把描述全部改成“当……时调用。不处理……。例如……”的句式,效果立竿见影。核心原则是:description写触发条件,不写功能清单;写边界,不写能力罗列。团队的技能描述模板到现在还是这个标准,任何人都不能写成功能说明书。
5.2 技能返回的数据格式不稳定
另一个很深的坑:技能返回数据格式频繁变动。最初设计返回时我图省事,把一些额外信息直接追加成一段人性化字符串放在data里。结果下游做环比计算的技能拿到这段字符串后,发现有中文、有数字、有单位,解析正则写了几十条还是不达预期。
这次的教训非常深刻:技能返回格式的不稳定程度,决定了下游技能的调试成本。从那以后,我定了三条硬规矩:所有数值型字段只放数字不加单位;所有时间字段统一ISO 8601格式;data字段下只放结构化JSON,禁止出现任何人类可读的长文本。展示文案永远交给模型生成,技能层不做“presentation”。这条规矩之后再也没有破过。
5.3 超时与幂等:技能调用失败的连锁反应
Agent调用技能和普通程序调用接口有一个显著区别:普通接口超时了调用方可以快速重试,而Agent技能超时会直接影响一次对话的成败。模型等待技能返回的耐心窗口是有限的,超时一次,模型可能在后续回答里胡编一个结果来填补空白,这个风险比超时本身严重得多。
我的应对方案是三管齐下:给每个技能设置合理的超时阈值,比如数据类技能30秒、轻量计算技能5秒;超时后立刻返回带error_code=TIMEOUT的失败响应,并附一句可操作建议;关键技能实现幂等支持——同一个查询请求,重复执行返回相同结果,这样编排层才能放心重试。幂等的实现有时比想象中麻烦,比如涉及“插入”操作的任务就需要生成请求ID做去重,但这一步必须做。
5.4 技能更新后缓存未失效,Agent还在按老规矩调用
技能体系稳定运行了一段时间后,我修改了某个技能的Schema,把字段province改成了region。技能本身和测试都跑通了,但线上Agent时不时还在用province这个旧参数调用,导致大量校验错误。
问题出在动态加载的缓存上。当时技能描述还是从缓存里读取的,Schema更新后缓存却还保留着旧的字段名。修了这个Bug之后,我完善了技能发布流程:凡是Schema变更,必须触发描述卡片缓存重建,同时保留一个版本的兼容映射,让旧参数能自动映射到新字段上。Agent系统的发布流程,和传统后端一样需要严肃对待,一个字段的改动就可能引起连锁故障。
5.5 并发场景下的状态污染
技能设计成无状态是有原因的,但实际操作里还是容易踩坑。有一段时间,我的技能里用了模块级的临时目录来存中间产物,结果两个用户同时触发图表生成任务时,各自的中间文件互相覆盖,生成出来的图完全错乱。
排查之后把临时目录改成了按task_id创建的独立目录,任务结束自动清理。这个教训让我全面审查了所有技能代码,把所有共享的可变状态全部改成了调用级隔离。给Agent做技能跟写单机脚本不一样,线上同时会有多个用户在多个会话里调用同一个技能,任何共享变量、全局配置、公共缓存,都可能成为并发事故的温床。
5.6 测试覆盖率陷阱:只测"成功路径"等于没测
技能测试最后一个坑,也是最隐蔽的:初始测试全在覆盖“正确参数+正确返回”的成功路径,所有失败场景模拟都是后补的。结果上线后碰到的第一个真实问题就是用户输入了超出日期范围的查询,技能抛了一个Python原生异常,没有任何可读错误信息,模型面对异常完全不知道该怎么处理。
那之后我在每个技能的测试计划里强制加入失败分支矩阵:参数缺失、类型错误、枚举越界、依赖服务超时、数据为空、权限不足。每一个分支都必须返回标准化的错误结构。技能测试要做到“失败永不出原始异常”,无论内部发生了什么,丢给模型的一定是结构清晰、可读、可行动的提示。
6. 技能库的持续运营:写一次、用三年
6.1 技能分级:从"一次性脚本"到"公司级能力"
技能库沉淀到一定规模后,管理就成了核心问题。我把所有技能分成三个级别:
L1是个人实验技能,只在本机使用,不注册到生产环境。L2是团队共享技能,通过注册表对特定团队开放,需要经过代码评审和测试。L3是公司级技能,作为标准能力对外统一暴露,必须满足严格的质量要求——描述规范、Schema稳定、超时可控、幂等实现、测试全绿。
这个分级制度最大的价值不是技术上的,而是责任边界清晰。个人实验技能随便折腾,团队技能出了问题有明确的负责人,公司级技能基本只允许向后兼容的小改动。有了分级,技能库才不会在迭代过程中失控。
6.2 命名规范与目录结构
技能库的目录结构我用的是按业务域划分的方式:
skills/ ├── sales/ │ ├── query_sales_summary/ │ │ ├── skill.yaml │ │ ├── main.py │ │ └── tests/ │ └── compare_sales_period/ ├── inventory/ ├── customer_service/ └── common/ ├── generate_chart/ └── date_utils/命名上的几个约定:技能名一律小写加下划线,动词开头,如query_*、generate_*、notify_*;表达“能力”的词放动词后面,不要放最前面。这样技能清单在模型眼里是“按动作索引”的,更利于意图匹配。
还有一个容易被忽略的点:每个技能目录必须自带示例调用和示例返回各一份。这两份示例不仅是给测试用的基线,也是新同事学习技能用法的入口。很多团队技能文档写得天花乱坠,但找不到一个“真实长什么样”的输入输出样例,这种技能到了别人手里基本就是废的。
6.3 技能的度量与持续改进
技能库上线后不能躺平不管,我每季度会做一轮全量复盘。复盘需要关注的指标不是调用次数,而是模型“选错”的比例——被注入提示词但最终没有被调用的技能比例是多少?调用之后返回错误的比例是多少?这两个数字高,说明技能描述和用户真实意图之间存在系统性偏差。
改进方式也很直接:挑出错率最高的三个技能,重新读一遍它们的description,回看实际对话里模型是拿什么理由跳过它们或搞错参数的,然后针对性地改写描述或调整Schema。技能体系是一个持续迭代的活物,不是写完就完了。
按我现在带团队的流程,整个技能库从搭建到稳定大约需要一个月时间。第一周定契约和注册机制,第二周写核心技能加测试,第三周编排协同,第四周灰度上线并做第一轮复盘。过了这个阶段,技能库带来的效率优势会非常明显——新场景接进来就是装配组合的事,很值得投入。
最后再分享一个个人体会:Agent技能体系最大的门槛不是技术,而是克制。别贪大求全,把每个技能的边界划清楚,比让它“什么都会”重要得多。一个五十个技能但个个边界清晰的库,远好过一个五十个技能但描述含糊互相打架的库。