早先把Agent接到线上业务时,我最头疼的不是模型能力不够,而是每次给Agent布置新任务,都要把一大段人设、步骤、规则、输出格式全部塞进system prompt里。塞到第四个正经功能的时候,prompt长得离谱,模型开始答非所问,有时候连最基本的指令都会执行错。后来我换了agent-skills这套思路——把一个能力封装成一个独立技能模块,按需加载、按需执行。看起来只是写法变了,实际上把整个Agent的扩展和维护方式都改了过来,这套结构我已经用了一年多,今天把完整的设计思路、落地细节和踩过的坑一次性说清楚。
这篇文章适合正在做Agent应用开发的工程师,尤其是被"提示词越堆越长、功能越加越乱"折磨过的人。我会从技能的拆分原则、结构设计、主循环集成、组合编排几个层面展开,最后分享几个我认为比较关键的避坑经验。
1. 为什么要把Agent的能力拆成"技能"而不是继续堆提示词
1.1 长提示词带来的三个隐形问题
做Agent的过程中我发现,最影响系统稳定性的往往不是模型本身,而是我们把太多东西硬塞进一个提示词里面。我早期做过一个内部工具,一个system prompt里包含项目管理问答、代码变更分析、会议纪要总结三个功能,每个功能又带了自己的few-shot示例和输出格式。刚开始还能跑,后面随着需求叠加,出现了三个问题。
第一个问题是意图干扰。三个功能共用一段上下文,用户问一个项目进度,模型却很容易被代码分析那部分的示例带跑偏,输出一些不相关的内容。第二个问题是上下文长度压力。每个功能的关键信息都要常驻在上下文里,加上历史对话,很快就把窗口打满,导致更早的信息被丢弃。第三个问题是维护困难。改任何一个功能都要动整条prompt,测试几轮下来经常会发现这个功能修好了、另一个功能又坏了。
1.2 skill的核心思路:像工具箱一样管理能力
agent-skills的核心思路和堆prompt完全不同。每个技能是一个独立的模块,有自己的描述、输入参数、执行指令和返回格式。Agent运行时先判断用户意图,再决定调用哪个技能——用到了才加载,用不到完全不占上下文。
我第一次体会到这个思路的差别,是在做一个自动排期助手的时候。当时需要在系统里加入"根据项目紧急程度重新安排时间块"的能力。如果按老办法,我得把排期规则、星期格式、优先级判断标准全部写进主提示词。但用skill的方式,我只需要在外面写一个日期处理模块,再配一套完整的执行指令,Agent只在用户表达排期需求时才把这段指令加载进来。整体prompt的长度立刻降了一半,而且排期逻辑的修改完全不影响其他功能。
1.3 skill与tool、function call到底有什么区别
很多人会把skill和function call混为一谈,我最初也踩过这个误区。function call本质上是给模型暴露一个可调用的函数接口,模型根据对话内容决定要不要调、传什么参数。它解决的只是"模型如何触发外部动作"的问题。
而skill更像是一个完整的"能力包"。它不仅包含触发判断,还包括内部的执行策略、需要的工具调用链、输出格式化、异常处理。一个skill可以内部调用多个function call,也可以纯靠提示词引导模型完成某项分析,甚至可以组合另一个skill。换句话说,function是技能内部的零件,技能是Agent面对一类任务时的完整解决方案。
提示:如果你只做简单的天气查询、计算器这类功能,function call就够了。但当你做的是一套需要稳定复用的业务能力,比如"周报生成""项目风险扫描""行程冲突检测",就值得升级成skill的封装方式。
2. Skill到底长什么样?拆解一个技能模块的完整结构
2.1 技能声明:决定"什么时候被触发"的部分
我当时设计技能结构的时候,最先确定下来的是技能声明区。这部分相当于技能的"门牌信息",Agent看到它才知道这个技能是干什么的、什么情况下该调用。
一个合格的技能声明至少包含技能名称、一句话描述、触发场景关键词。我第一次做的时候描述写得太泛,比如"处理日期相关需求",结果用户问"这周三是几号"也会触发这个技能——这本来应该直接用模型知识回答的,白白消耗了一次调用。后来改成更精确的描述,用"仅用于项目时间表冲突检测与调整建议",误触发率明显下降。
我自己用的是name、description、keywords三段式。keywords不是必需的,但我在实测中发现,加上它能显著提升Agent在意图模糊时的选择准确率。比如"冲突检测"这个关键词,就能在用户说"周三下午和评审撞了"这种口语化表达时,把技能准确匹配上。
2.2 输入定义:给Skill装上参数校验的"漏斗"
技能的第二层是输入定义。系统会根据对话内容提取参数,填充到技能里面再执行。这一步特别容易出现"模型自由发挥"的情况,所以我把输入设计的重点放在约束上。
每个参数都有三个属性:名称、类型、约束范围。拿我做的排期冲突检测技能来举例,它接收的参数包括任务ID、开始时间、结束时间、优先级。其中优先级字段我会限制枚举值,只允许填入最低、普通、最高三档,而不是让模型随便写"比较急""下周要交"这种模糊表述。开始和结束时间则强制要求ISO 8601格式,不接受任何别的日期格式。
这种约束看着繁琐,实际帮了大忙。有一次测试时模型传了一个“下周三下午”这种相对时间描述,因为在输入定义里强制了绝对时间格式,校验层直接把请求拦下来,让上游重新解析。如果没有这个门槛,技能拿到半结构化数据往下跑,后面的所有逻辑都得跟着错。
2.3 执行指令:真正干活的提示词模板
技能的执行指令部分,是替代主提示词工作量的核心。我把它理解成一个独立的"临时system prompt",只在技能被激活后注入到模型上下文里。设计原则是自包含、可独立运行、边界清晰。
还是拿排期技能举例。执行指令里有这么几层逻辑:先要求模型输出一个"时间线总览",把现有任务按时间顺序排列;再要求定位出两两冲突的任务对;然后才要求生成调整建议,而且明确要求建议按照"调整低优先级任务时间"优先于"整体后移"的顺序来提供。这些细节我全部放在技能内部,主Agent完全不知道这些规则的存在,自然也不会被它们干扰。
一个技能的好坏,很大程度上取决于这个执行指令写得够不够具体。我的经验是:宁可写细一点,也不要让模型在执行过程中去猜。比如"调整低优先级任务时间"这句话,后面我还补了一段说明,标注了模型必须考虑任务之间的依赖关系、不能把高优先级任务移到周末。这段规则在外面看起来是废话,但在模型执行时是实实在在的边界约束。
2.4 输出规约:让复杂结构稳定落地
技能最后一部分是输出规约,也就是规定返回给主Agent的内容格式。这块做得越规范,后面的编排和组合就越省力。
我用JSON Schema来描述返回结构,每个技能的输出都被限制在一个固定格式里。排期技能的输出会包含冲突列表、建议列表、剩余可用时间块三个字段。模型执行完之后,还有一个校验步骤——检查关键字段是否存在、枚举值是否合法、时间先后顺序是否成立。如果格式不对,会触发一次修正重试,最多重试两次。
下面是我保存的一个技能模板,结构基本固定,自定义空间都在prompt和skill_params里:
{ "skill_name": "schedule_conflict_detector", "description": "仅用于项目时间表冲突检测与调整建议,在用户提出日程撞期、时间冲突等问题时触发", "keywords": ["冲突", "撞期", "排期调整", "时间块"], "input_schema": { "type": "object", "properties": { "task_ids": { "type": "array", "items": {"type": "string"}, "description": "需要检查的任务ID列表" }, "time_range": { "type": "string", "format": "date-range", "description": "检查的时间范围,格式:YYYY-MM-DD/YYYY-MM-DD" }, "priority_floor": { "type": "string", "enum": ["最低", "普通", "最高"], "description": "只检查该优先级及以上的任务" } }, "required": ["task_ids", "time_range"] }, "execution_prompt": "你是一名排期冲突分析专家...", "output_schema": { "type": "object", "properties": { "conflict_pairs": { "type": "array", "items": { "type": "object", "properties": { "task_a": {"type": "string"}, "task_b": {"type": "string"}, "conflict_window": {"type": "string"} } } }, "suggestions": {"type": "array", "items": {"type": "string"}}, "remaining_blocks": {"type": "array", "items": {"type": "string"}} }, "required": ["conflict_pairs", "suggestions", "remaining_blocks"] }, "max_retries": 2, "timeout_ms": 60000 }3. 把Skill接入Agent主循环:技能路由与参数填充的关键步骤
3.1 技能路由:让Agent知道该摸哪个工具
技能拆好了,下一步是让Agent在运行时能够准确选择该调用哪个技能。这一步我称之为"技能路由"。我实验过三种路由方式:基于规则的关键词匹配、基于embedding的语义相似度、以及让LLM自己直接选择。
直接上LLM选择看起来最智能,实际效果却未必最好。因为当技能数量上来之后,LLM面对一大串技能描述,仍然会陷入选择困难,甚至被相似描述误导。我现在的方案是混合路由:先用关键词和embedding做一轮粗排,筛出2到3个候选技能,再让主LLM在这几个候选里做最终决定,成本低,准确率也够高。
路由这一步有个很重要的细节:技能描述的质量直接决定路由准确率。建议把"什么时候不用这个技能"也写进描述里。比如排期技能的描述末尾可以加一句"如果只是询问当前时间,请不要调用此技能"。这种负面排除条件,对减少误触发非常有帮助。
3.2 参数填充:最容易出"幻觉参数"的地方
路由确定之后,进入参数填充阶段。系统要从对话历史和上下文里提取出技能需要的各项参数。这一步是我整个开发过程中遇到问题最多的环节。
最常见的问题是参数幻觉。模型会自信地填入对话里根本没有提过的内容。我有一次测试数据统计技能,用户问的是"看一下文档里的项目数据",模型居然自己生成了一个"上周发布的待办清单"作为参数传了进去——这个词从未在上下文里出现过。
我的解决方案有两层。第一层是定义中尽量使用enum、format这类硬约束,让模型没有自由发挥的空间。第二层是加一个"参数来源标注",每条参数不仅要给值,还要给出是从哪句话里提取的依据。如果找不到依据,就标记为"未明确",由系统决策是向用户追问还是使用默认值。这一步直接把参数幻觉率降到了可接受的范围。
3.3 技能的两种执行模式:工具型与文本型
接入主循环前还要分清技能的两种执行模式。第一种是"工具型技能",执行的时候可以调用外部API、数据库、代码函数,我直接接一个executor在本地跑。第二种是"推理型技能",它不调用外部资源,只是通过一段独立的prompt让模型专注于分析某个具体任务,比如"代码变更风险评估"。
这两种模式在代码里只需要做一层抽象就能兼容。我的做法是给每个技能定义一个execution_mode字段,工具型的走API调度器,推理型的走一个独立的LLM调用流程。关键是两者的输出都要经过同一个output schema校验器,这样上游调用方可以无差别地拿结果。
3.4 一个完整的调用链长什么样
把前面这些环节串起来,一次技能调用的完整流程分别指向五步:任务进来后先做意图识别,粗筛出候选技能,填充参数并校验,然后执行技能,最后把结果格式化成统一结构返回。下面这段伪代码描述了这个过程:
def route_and_execute(messages, skills): # 第一步:候选过滤,用关键词和embedding粗筛 candidates = keyword_rough_filter(messages[-3:], skills) candidates = embedding_filter(messages[-3:], candidates) # 第二步:让主LLM从候选中选择最终技能 selected = llm_select_skill(messages, candidates) # 第三步:抽取参数,并校验参数来源 params = llm_extract_params(messages, selected.input_schema) params = validate_params(params, selected.input_schema) # 第四步:执行技能,工具型走executor,推理型走独立prompt if selected.execution_mode == "tool": result = execute_tool_skill(selected, params) else: result = execute_llm_skill(selected, params) # 第五步:校验输出格式,失败则最多重试两次 result = validate_output(result, selected.output_schema) return result这个流程跑通之后,我再往系统里加新功能就省心多了。不需要去改动主prompt,只需要新增一个技能包文件,然后系统启动时自动注册进来就行。
4. 从"单点技能"到"技能编排":复杂任务怎么拆和怎么合
4.1 为什么需要编排:单技能解决不了复杂任务
技能拆得越细,就越会碰到一个问题:一个完整的业务任务往往需要多个技能协作才能完成。比如做一个"生成项目周报"的功能,至少涉及三件事:拉取本周的代码提交记录、分析提交信息里的变更点、生成一份符合格式的周报文本。如果我把这三件事塞进一个技能里,技能会变成一个臃肿的"大杂烩",失去拆分的意义。所以我在设计时把这类功能拆成多个独立技能,再由一个"编排者"来指挥它们。
编排者本身也是一个特殊技能,只不过它不干具体活,只负责规划:把任务拆解成步骤,决定每一步用哪个子技能、按什么顺序执行、上一个技能的输出如何传给下一个。这种"母技能管子技能"的设计,让每个技能保持小而专的同时,又具备处理复杂任务的能力。
4.2 三种基础编排模式
我实践的编排模式有三种:串行、并行、条件分支。串行是最常见的,上一个技能的输出作为下一个技能的输入,适合流水线式任务。并行适用于彼此不依赖的子任务,比如同时拉取代码记录和统计未关闭的issue,然后再合并结果。
条件分支则依赖上一步的决策结果。比如风险评估技能返回了"高风险"标记,编排者才会去调用"风险升级通知"技能;如果是"低风险"就直接进入下一步。编排者不需要在系统里显式写if-else逻辑,只要把分支处理的能力提示词写清楚——模型会根据上一步的实际输出来决定走哪条路径。
4.3 输出契约:编排能否成功的分水岭
选好编排模式只是第一步,真正决定编排成功率的是子技能之间的"输出契约"对齐。也就是说,上一个技能输出的结构,必须恰好是下一个技能期望接收的结构。
我在这里吃过很大的亏。有次做自动排期编排,上游"任务优先级评估"技能输出的是普通文本,下游"冲突检测"技能的input_schema要求的是严格JSON。结果每次传递都要靠模型现场转换格式,不是少了字段就是多嵌套了一层,整个链路的成功率只有六成左右。
后来我做了两件事:一是把所有技能的输入输出都统一到JSON Schema,二是为那些需要跨技能传递的关键字段,比如任务ID、时间块、状态值,建立了全局统一命名规范。下游技能直接复用上游字段名,几乎不需要额外的映射说明。改完以后整条链路的成功率直接提升到九成以上。
4.4 一个实际编排的完整流程示例
以"自动生成周报"这个场景为例,我设计的是一个三步串行编排。第一步调用"代码提交聚合"技能,拉取本周的提交记录并按模块归类;第二步调用"变更影响分析"技能,把归类后的提交记录转换成"功能变更、问题修复、性能优化"三类摘要;第三步调用"周报格式化"技能,把摘要填进公司的周报模板。
其中第二和第三步之间有一个关键信息传递:第二步的输出摘要,第三步的input_schema期望它包含summary_type和content两个字段。我只需要在第二步技能的输出规约里也定义这两个字段,整个链路就顺畅了。如果中途我改了模板样式,只动第三步技能,前两个技能完全不受影响。
提示:在技能编排场景下,给每个技能的输出字段起名时,多想一步"这个字段会不会被别的技能消费"。如果会,建议起一个全局统一的名字,别为了省事在各技能里各叫各的——这是我在实战里认为性价比最高的一个习惯。
5. 实测中的坑:技能误触发、参数幻觉与上下文污染的真实处理过程
5.1 误触发:描述里的一个单词引发的连锁反应
我最早设计技能声明时,给"日期解析"技能写了一句"把用户表达的时间转换为标准格式"。这本来是很直白的描述,但上线之后出现了大量误触发。用户只要提到"上周""月底""明天"这些词,Agent就会调用这个技能去"转换时间",哪怕用户只是在闲聊里顺带说了一句"明天的会变成周三开"。
排查链路是这样的:我先查看了路由日志,发现触发时embedding分数并不高,但关键词匹配那关直接把相关词全命中了。然后我意识到问题出在keywords字段上——我把"明天""上周""月底"这些都列成了keywords,结果它们每次出现都会被粗筛捞起来交给LLM确认,而LLM在候选数量少、描述不够明确的时候,倾向于选择看起来"跟时间有关"的技能。
修复方案分两步。第一步,从keywords里删掉所有"常见时间表达",只保留业务强相关的词,比如"解析时间""转换格式""日期规范化"。第二步,在description末尾加排除条件:"如果用户只是提及时间进行陈述,不涉及结构化转换需求,请勿调用此技能"。改完之后误触发率下降了快七成。
5.2 参数幻觉:模型填了一个不存在的ID
参数幻觉问题我在3.2里提过,这里讲一个完整的处理过程。测试"任务状态更新"技能时,用户对话里根本没有具体任务,只是说"把该办的事情办一下"。结果模型在填充task_id参数时,凭空捏造了一个"ABC-123"传了进来。如果执行端不校验这个ID和真实任务列表的映射关系,系统就会对一个不存在的任务执行更新——后果很严重。
我第一次只是简单加了非空校验,发现没有用:模型知道task_id不能为空,于是更努力地去编造,甚至从上下文里模模糊糊提到过的"那个需求"里推断出一个ID。那段时间这类问题反复出现。
后来我把校验逻辑从"非空"提升到"存在性校验"。执行端在收到task_id后,第一次调用前先查一次任务列表,如果ID不存在,立即返回"参数无效-REQUIRE_FIELD_CHECK",并附上当前可选的任务ID列表。由于我的input_schema里声明了参数必须来自用户明确提及内容,模型在下一次生成时会看到真实的候选列表,基本上就不会再凭空瞎编了。
5.3 上下文污染:技能返回值太大,把主线任务冲没
技能执行完,会把结果返回给主Agent继续对话。这里有个性能陷阱:如果技能返回内容太长,尤其是一些工具型技能一次性返回了大量日志或明细,主Agent的上下文里就被塞满了无关细节,导致对话质量明显下降。
我遇到的一个典型场景是"数据拉取"技能。它返回了五十多条原始记录,而用户其实只想知道样本有没有异常。主Agent拿到这一大坨数据之后,反而把用户的核心意图给忽略了,甚至开始根据日志里的细节编造分析结论。
解决方案是给每个技能的输出规约里加一个"summary_first"原则:技能返回的内容规定只包含摘要信息,最多附带三条代表性明细的链接或引用。如果用户明确要求更多细节,再由Agent在后续轮次里发起一个"明细查询"技能。自那之后,技能返回的平均体积直接降了一个数量级,主Agent的执行稳定度提升明显。
5.4 技能版本管理:改坏了不会拖累整个Agent
最后一个值得记录的坑是版本管理。技能是一段会被反复调用的结构,一旦某个技能上线后会出现幻觉或者逻辑错误,如果我没有版本控制,全系统的Agent都会受影响。
后来我把每个技能模块都纳入版本库,执行日志里记录技能版本号和具体的执行时间。线上出了一个涉及技能的问题,我可以在编排器的日志里直接定位到是哪个版本的哪个技能导致,然后决定回滚。这个习惯在团队协作时尤其重要——其他同事更新了一个技能,我至少能明确知道系统行为的变化点在哪。
6. 我现在的技能设计原则与收尾心得
经过这些坑之后,我总结出几条支撑整个构建过程的设计原则。
第一条,每个技能只解决"一类"问题,而不是"一个问题"。同类问题的细节差异通过参数去调节。比如"排期冲突检测"和"排期自动调整"一定是两个技能,前者只负责分析,后者才负责输出调整建议。混在一起的结果就是执行指令里出现两个目标,模型会顾此失彼。
第二条,技能描述既写明"什么时候用",也写明"什么时候不要用"。负面排除信息看起来降低了所谓"灵活性",但显著提高了路由准确率。这其实不算严格限制,更像是在降低模型的选择成本。
第三条,所有技能的输入输出都走结构化校验。哪怕内部逻辑再完美,只要衔接处有一个字段对不上,整条链路就会崩塌。我在实践中给所有技能包的输出都加了一道格式验证流程,没有通过验证的一律重新执行,这个小小的强制机制比我在prompt里写十句"请严格按格式输出"都管用。
第四条,技能包应该像代码一样管理。有版本号、有更新日志、有责任人。Agent技能本质上也是一种代码逻辑,只是表现形式是提示词加配置,完全应该用对待后端服务的态度来对待。
如果再往远了想,技能编排这个方向还可以继续扩展出"技能间的超时与熔断机制""动态扩展现有技能的参数范围""多Agent共享技能库"等玩法,这些东西我还在陆续尝试之中。就目前的实际收益来看,用agent-skills的模式来组织能力,无论从系统稳定性、维护成本还是扩展速度上看,都比过去堆长提示词的方式好太多。如果你也在做Agent应用,不妨从手头那个功能开始,试着把它拆成一个独立的技能模块跑一遍,应该很快就能感受到差别。