
先讲一个我自己的经历。去年我做了一个客服问答Agent最初把所有功能都塞进系统提示词里会查订单、会算运费、会推荐商品、会转人工……prompt写了快三千字。上线第一周还行第二周开始翻车——用户换个问法Agent就忘了该调用哪个工具我改了一条规则另外三个功能跟着出问题。那段时间我每天的工作就是提示词排雷。后来我痛定思痛用两周时间把整个Agent重构了一遍核心改动只有一件事把能力从对话文本中抽出来做成独立的skill技能模块。今天想把这套设计思路、实现细节和踩坑复盘完整写下来。文章会围绕agent-skills这套方法展开包括Skill怎么定义、怎么注册、怎么被Agent动态发现和调用以及多技能协同时的调度与容错。无论你是在做大模型应用、做自动化机器人还是想给现有产品接上智能体能力这篇都能给你一套可以直接落地的参考。1. 从提示词堆叠到技能层设计Agent能力边界的三个失控信号1.1 三个失控信号改一坏十、上下文挤爆、无法测试先说说我是怎么被逼上这条路的。最初的Agent只有一个system prompt里面事无巨细列了各种指令。表面看模型确实知道很多但这种知道是假的。我总结出三个典型的失控信号第一改一坏十。你为了让Agent在某个场景下表现更好在prompt里加了一段规则结果它在另一个完全不相关的场景里也开始套用这段规则。因为LLM的指令遵循是概率性的你给它看的字越多它越容易把示例当成通用原则。我加了一条用户生气时先道歉结果Agent在所有对话开头都先道歉。第二上下文挤爆。功能越加越多prompt越来越长。长prompt不仅浪费token更严重的是中段信息会被模型选择性遗忘。有测试表明prompt超过4千token后模型对中段的注意力明显下降。你精心写的规则放在第20行可能根本不会被读到。第三无法测试。一个纯文本prompt没有单元测试的概念。你没法单独验证运费计算规则是否正确因为它是和换货流程退款策略混在一起的。每次改动都是全量回归纯手工靠感觉。这三个信号其实指向同一个问题Agent的能力被写成了散文而不是被结构化成模块。散文改起来牵一发动全身模块才能单个替换、单独验证。1.2 Skills把能力从对话文本里抽出来技能层设计的出发点很简单把Agent能做的事拆成一个一个独立的skill每个skill有名字、有描述、有输入输出契约、有可执行的逻辑体。LLM的角色从记住所有规则变成根据用户需求从技能目录里选择合适的技能并调用。这就像人不需要把微波炉说明书背在脑子里——想热饭的时候找到微波炉按几个按钮就行。你不需要知道微波炉内部的磁控管怎么工作你只需要知道它能热饭、怎么把饭放进去、按哪几个键。对应到Agent上每个skill就是一个微波炉。LLM只需要知道这个技能是干什么的、输入什么、输出什么不需要把技能内部的实现细节灌进上下文。这样有几个立竿见影的好处单个技能可以独立测试、独立迭代不影响其他技能。技能的调用逻辑是确定性的代码不是概率性的prompt稳定性大幅提升。上下文里只放技能目录和当前会用到的技能定义其他技能的细节按需加载。1.3 与Function Calling/Tooling的关系不是替代而是组织这里要澄清一个常见混淆skill和函数调用Function Calling、工具Tool到底什么关系我的理解是Function Calling是底座机制Tool是单次调用单元Skill是面向任务的完整能力封装。一个Skill可以由多个Tool组合而成也可以携带状态、内部校验、重试逻辑。举个例子创建定时任务这个skill内部可能需要调用parse_time时间解析、schedule_task任务入库、send_notification通知用户三个工具函数。直接把三个函数暴露给LLM它很可能会漏掉其中一步封装成skill之后LLM只需要说我要创建一个定时任务剩下的流程由skill内部的代码按顺序执行。维度纯提示词方案技能层方案能力存储散文式文本结构化模块上下文占用随功能线性增长只暴露目录和当前技能修改影响面改一句动全局改一个skill不影响其他可测试性几乎不可测每个skill可单独测试升级方式重写prompt替换skill模块所以agent-skills不是一个独立的算法它是一套组织代码和prompt的架构方法。底层模型还是那个模型但你把它该怎么表现变成了它能调用什么模块这是两种完全不同的工程路径。2. Skill的最小解剖描述、参数契约与执行体的设计红线2.1 Skill的六个构成零件一个可以被Agent稳定使用的skill我在实践中至少包含六个部分。缺任何一块都会在线上暴露出问题。name技能的唯一标识英文小写加下划线如scheduler、web_fetcher。这个名字不用给用户看但LLM会通过它来引用技能。description给LLM看的能力说明。这是整个skill里最重要的一块直接决定模型能不能在正确的时候选中它。后面单独说怎么写。triggers触发条件的补充说明描述什么场景下必须用、什么场景下千万别用。很多初学者忽略这一块结果模型在查询天气时调用了订机票技能。parameters入参契约我统一用JSON Schema定义支持类型、必填项、枚举值、描述。参数契约越清晰模型的自由发挥空间就越小。handler执行体。我坚持一个原则——执行体必须是确定性代码。也就是说给定一组入参输出是确定的不做任何模糊发散。exit_callback执行完之后的回调。通常是把结果格式化后返回给LLM让它组织成人话回复用户。也有人把这步并入handler但单独拆出来会更清晰因为给LLM看的结果和用户看到的结果往往需要不同形态。2.2 面向LLM的描述文案写什么时候用而不是是什么描述怎么写决定了检索准确率。我走过一段弯路一开始我把描述写成了这是什么比如这个技能可以用来解析用户输入的日期。听起来没毛病但实际命中率很差。问题在于LLM做技能选择时它是一个语义匹配过程——它看到用户说帮我定个闹钟要去描述库里找最像的那条。改成什么时候用之后效果立刻不一样了。比如差「解析用户输入的日期时间字符串支持多种格式。」好「当用户提到明天、后天、下午三点、下周一等任何时间表述并需要基于该时间执行任务如提醒、定时、闹钟时使用。」注意第二句出现了大量用户可能怎么说话的词汇和场景。这本质上是把技能描述写成了检索索引的摘要文本让用户query和description之间的语义距离更近。我后来还会要求描述里必须出现至少两个用户原话可能长这样的例子比如提醒我明天9点开会、每两个小时采集一次数据。2.3 参数契约JSON Schema与严格校验给LLM的参数定义越宽松出脏数据的概率越高。我早期的某个技能允许模型自由传入逗号分隔字符串结果模型传出了各种诡异的格式下游解析直接崩溃。后来我统一改用严格JSON Schema每个字段强制声明类型和必填性能列枚举就列枚举。拿定时任务里的重复规则举例{ type: object, properties: { task_name: { type: string, minLength: 1, maxLength: 50, description: 任务名称如每日数据备份 }, when: { type: string, description: 执行时间尽量传ISO 8601格式如2025-07-01T09:00:0008:00 }, repeat: { type: string, enum: [once, hourly, daily, weekly, monthly], default: once }, command: { type: string, description: 要执行的动作说明支持命令或指令文本 } }, required: [task_name, when, command] }我在handler里还会做一道物理校验时间能不能解析、日期是不是过去、任务名有没有违禁词。模型传过来的参数要当成不可信的第三方输入来对待不能因为是模型传的就默认正确。2.4 执行体的纯度原则这是我在重构过程中领悟最深的一条skill内部能不用LLM就不用LLM。原因很简单——LLM调用意味着延迟、成本和不确定性而你封装skill的目的恰恰是为了消灭这三样东西。例如用户问查一下我的订单正确链路是LLM识别意图返回{skill: order_query, params: {order_id: ...}}然后skill内部直接查数据库、算状态矩阵、返回固定格式结果。如果把判断订单状态也丢给LLM做等于把同一个模型的概率性问题搬到了你已经确定要执行的地方完全得不偿失。当然有些任务是纯LLM更擅长的比如总结一篇长文。对这种任务我通常把是否调用LLM做成skill内部的一个策略开关而不是让外部模型替我做判断。边界在skill内部可控而不要扩散到全局。3. 手写一个定时任务管理Skill并接入Agent的全过程3.1 需求界定与数据设计理论部分讲太多容易飘我们落地一个实战例子做一个定时任务管理skill要求Agent能创建、查询、取消定时任务。先定义数据模型。我用的存储就是最简单的JSON持久化生产环境可以换成SQLite。字段设计如下import json, os, uuid from datetime import datetime TASKS_FILE tasks.json def load_tasks(): if not os.path.exists(TASKS_FILE): return {} with open(TASKS_FILE, r, encodingutf-8) as f: return json.load(f) def save_tasks(tasks): with open(TASKS_FILE, w, encodingutf-8) as f: json.dump(tasks, f, ensure_asciiFalse, indent2)一个任务记录长这样{ task_id: a1b2c3d4, task_name: 每小时备份数据库, when: 2025-07-01T09:00:0008:00, repeat: hourly, command: backup_db --target /data/backup, status: active, created_by: user_001, created_at: 2025-06-30T18:22:1008:00 }我不在这里实现真正的定时触发那是调度器的事只做任务的增删改查这正好演示skill的确定性逻辑体部分。3.2 三个核心动作的实现代码接下来实现create_task、list_tasks、cancel_task三个函数。注意每个函数都尽量返回一段JSON字符串这个字符串是给LLM看的。LLM拿到JSON再组织成自然语言回复用户。class SchedulerSkill: name scheduler description ( 当用户需要创建、查看、取消定时任务时使用。 包括不限于提醒我明天上午9点开会、每天中午12点备份数据、 查一下我有哪些定时任务、把那个备份任务取消。 ) triggers ( 必须使用用户明确表达需要某个任务在指定时间自动执行 或需要管理已有的定时任务。 禁止使用用户只是闲聊提到时间没有执行意图时不要调用。 ) def create_task(self, task_name: str, when: str, command: str, repeat: str once): try: parsed_time datetime.fromisoformat(when) except ValueError: return json.dumps({success: False, error: 时间格式无法解析请要求用户提供更具体的时间}) if parsed_time datetime.now(parsed_time.tzinfo): return json.dumps({success: False, error: 任务时间不能早于当前时间}) tasks load_tasks() task_id uuid.uuid4().hex[:8] tasks[task_id] { task_id: task_id, task_name: task_name, when: when, repeat: repeat, command: command, status: active, created_at: datetime.now().isoformat() } save_tasks(tasks) return json.dumps({success: True, task_id: task_id, status: created}) def list_tasks(self): tasks load_tasks() active [t for t in tasks.values() if t[status] active] return json.dumps({success: True, tasks: active}) def cancel_task(self, task_id: str): tasks load_tasks() if task_id not in tasks: return json.dumps({success: False, error: 任务ID不存在}) tasks[task_id][status] cancelled save_tasks(tasks) return json.dumps({success: True, task_id: task_id, status: cancelled})注意我把每个函数的入参都定义成具名参数而不是一个泛化的kwargs字典。这样后续做参数schema时可以直接从函数签名生成减少手写不一致的问题。3.3 注册与接线的完整链路写完了核心逻辑还得让Agent能调用它。这里我做了两件事第一步把函数参数定义转成JSON Schema。我用的是Python的inspect库自动生成再手动补描述。import inspect, json def generate_schema(func): sig inspect.signature(func) properties {} required [] for name, param in sig.parameters.items(): if name self: continue properties[name] {type: string, description: f{name}参数} if param.default is inspect.Parameter.empty: required.append(name) elif param.default is not None: properties[name][default] param.default return { type: function, function: { name: func.__name__, parameters: { type: object, properties: properties, required: required } } }第二步把Skill注册表接入Agent的推理循环。我这里用一个极简的Registry先把skill实例挂进来然后每次对话时把技能目录拼进system prompt。class SkillRegistry: def __init__(self): self._skills {} def register(self, skill): self._skills[skill.name] skill def get_manifest(self): return {name: skill.description for name, skill in self._skills.items()} def execute(self, skill_name, func_name, params): skill self._skills.get(skill_name) if not skill: return json.dumps({error: skill not found}) handler getattr(skill, func_name, None) if not handler: return json.dumps({error: handler not found}) return handler(**params)接入后的system prompt只保留一小段说明可用技能目录 - scheduler定时任务管理。当用户需要创建、查看、取消定时任务时使用。 你需要调用技能时返回JSON格式{skill: scheduler, func: create_task, params: {...}} 我会执行后将结果返回给你由你组织成对用户友好的回复。这里的关键是我不在prompt里写scheduler的具体参数格式。模型只需要先把用户意图映射到某个技能的某个函数上参数抽取是下一步的事。如果你同时暴露所有的参数定义模型反而更容易在抽取时出错。3.4 多轮对话测试与边界问法接好线路之后我一般会跑一组多轮对话用例覆盖各种问法。摘几个印象深刻的用户说明天下午3点提醒我开周会——模型正确调用create_taskwhen被转成2025-07-02T15:00:0008:00。这个转换是LLM完成的为了降低出错率我在参数描述里明确给了ISO8601示例。用户问我有哪些任务——模型调用list_tasks拿到JSON后组织成你有2个活跃任务每小时备份数据库、每日清理临时文件。这种回答。用户说把那个备份取消了——这里有个坑模型不知道具体task_id如果直接调用cancel_task就会参数缺失。我后来在triggers里加了一条规则当用户未明确给出任务ID时先调用list_tasks查询再把任务列表呈现给用户请用户确认后再取消。这样一来取消动作被拆成了查询加确认两个回合反而比一次性尝试更可靠。边界问法原则宁可让模型多问一轮也不要让它瞎猜参数。瞎猜是skill被打回原形的最常见原因——原本已经脱离概率的确定性逻辑又被不确定的参数拖入了泥潭。4. Registry与动态装载上百个技能共存而不撑爆上下文的实践4.1 Registry内存字典还是持久化存储当你的Agent只有两三个技能时内存字典就够了。但技能数超过二十后我强烈建议把注册表做成持久化存储。原因有两个一是热更新需要你上线一个新版本技能不能靠重启进程来生效二是审计需要你总得知道一段时间内哪些技能被调用过、成功率如何。我的设计方案是分两层上层是内存缓存继承自dict的SkillRegistry下层是SQLite表。CREATE TABLE IF NOT EXISTS skills ( name TEXT PRIMARY KEY, version TEXT, description TEXT, triggers TEXT, module_path TEXT, enabled INTEGER DEFAULT 1, updated_at TEXT );启动时从表里加载enabled1的技能注册进内存。修改技能后既可以重启加载也可以做一遍增量reload——把新版本重新register一次覆盖旧版本。这种事在纯prompt方案里完全做不到你现在知道模块化的好处在哪了。4.2 给LLM一份好用的检索目录当技能数量上去之后模型能不能一眼选中正确的技能就变成了核心指标。我实测过一个数据在没优化描述之前技能命中率只有62%。也就是说一百次请求里将近四十次模型要么选错技能要么犹豫不决反复问用户。优化描述后命中率提到了91%。这不依赖模型升级纯粹是描述文案的工程优化。优化方法就一条把技能描述当成搜索索引来写而不是技术文档来写。我分享一个模板当用户{描述具体场景}并期望{描述具体动作}时使用此技能。 必须使用的信号{列2-3个用户原话示例}。 禁止使用{列1-2个容易混淆的场景明确划清边界}。 示例输入{给一个LLM可以直接参考的入参示例}。禁止使用这一段很关键。模型犯错的常见模式不是在无关处召唤技能而是在疑似相关处召错了技能。比如设定每周五下午清理日志这个需求可能会被模型同时匹配到logger_cleaner和task_scheduler。如果你在logger_cleaner的禁止使用里写当用户要求的是未来某个时间自动执行清理时不要用本技能改用scheduler误选就会显著减少。4.3 按需装载先看目录再翻详情暴露给LLM的信息量我用了一个两级曝光策略第一级只暴露技能目录name 一两句description这部分常驻system prompt。第二级当模型决定调用某个技能时我再把该技能的完整JSON Schema塞进这一轮请求的tool definitions里。这样做的收益很直观。我做过一次对比一个加载了50个技能的Agent如果把每个技能的完整schema都塞进上下文光工具定义就消耗约12000 token用两级曝光后常驻上下文只占约1500 token只有当某个技能被选中时才额外加载。差异接近十倍。方案常驻Token模型选错率响应速度全部技能定义常驻约12000较高干扰项多慢首字延迟明显两级曝光目录按需加载约1500显著降低快上下文更清爽两级曝光还有个隐藏好处它倒逼你把目录描述写得好。如果你知道模型只靠这句话去做选择你自然会在什么时候用/什么时候不用上花足功夫。5. 多Skill协同调度编排、冲突仲裁、失败回退与死循环治理5.1 链式、分支、并行的三种编排模式一个稍微复杂一点的任务往往不是一个skill能搞定的。比如每天早上9点检查服务器磁盘如果超过80%就发告警到钉钉群这里面就涉及定时任务、检查命令、告警通知三个技能。我常用的编排模式有三种链式编排A的输出作为B的输入。典型场景是先查询订单状态再根据状态触发退款或催发货。这种模式最可靠我通常把链路预置进一个流程skill里而不是让LLM自由编排。条件分支根据某个确定性判断走不同分支。比如订单状态为已发货则调用物流查询为未付款则调用支付提醒。这种判断我在代码里用if-else写死不交给LLM。并行汇聚多个无依赖的查询同时发起比如需要同时查天气、查航班、查酒店再汇总行程建议。并行能显著降低总延迟但要注意LLM在等待多个结果时需要挂起状态工程上有一定复杂度。一个常被忽略的点很多多技能协同场景其实不需要模型参与。如果你明确知道步骤顺序就把它固化成一个复合skill。LLM介入得越少系统越稳定。5.2 技能冲突仲裁谁更具体谁上当用户表述比较模糊Model可能同时匹配到两个技能。比如帮我把这份文档翻译成英文并总结要点理论上可以有translate_doc和summarize_doc两个技能。我的仲裁策略是按特异度打分用户原话命中技能示例句子的个数每个1分。技能描述里的必须使用关键字命中的2分。两个技能打分相同默认优先执行处理面更窄的那个。因为窄技能通常有针对性而宽技能往往需要额外处理。在这个例子里translate_doc的示例如果包含把文档翻译成英文而summarize_doc只包含总结文档要点那么翻译技能分数更高先翻译再总结。如果模型识别到两步都要做那就走链式编排而不是二选一。5.3 失败回退链路设计Skill执行失败时最忌讳的是让整个Agent直接断掉。我设计的回退链路分四档第一档重试。网络波动、临时超时这类可恢复错误自动重试一次隔几秒再执行。第二档降级。主技能失败换一个功能相近的备用技能。比如web_fetcher抓取失败降级到web_fetcher_simple用另一种请求方式。第三档交还LLM决策。把错误信息原样打包返回给模型由模型根据上下文决定是换一种问法、还是让用户补充信息、还是给出替代方案。这是Agent感的来源——它不是死板的报错而是像人一样换个思路。第四档人工兜底。连续两次失败直接转人工并把完整的错误调用链附上。日志里必须能看到模型选择了哪个技能、传了什么参数、底层报了什么错否则转人工等于扔黑盒给客服。5.4 死循环与成本失控的治理多技能协同最大的隐藏风险是无限循环。模型可能在一个失败回退里反复选择同一个技能或者AB两个技能互相激活。我上过线之后专门做了四道闸门最大迭代次数一次用户请求最多执行5次工具调用超过就强制结束并回复当前无法完成已为您转接人工协助。重复调用检测如果模型在连续3轮内调用了同一个技能且入参完全相同说明陷入死循环直接中断。时间预算整个Agent响应延迟上限设成20秒超时即返回当前已获得的部分结果。成本预算每次请求往上限住按token消耗或按调用次数防止某个失控逻辑烧掉API账单。这四道闸门不是可选项是技能线上跑起来之后的生存底线。只在demo里玩的Agent可以不管但要放到真实用户面前一个死循环就能打崩整条链路。6. 上线一周后的四个踩坑复盘与技能层铁律6.1 踩坑一描述越漂亮选错率越高我第二个技能是天气查询。一开始描述写得特别华丽实时获取全球任意城市的天气信息支持温度、湿度、风速、紫外线指数、空气质量、未来七天预报……结果模型开始了花式滥用用户问明天适合爬山吗会触发它用户问上海今天冷不冷也触发它甚至用户问帮我看看附近有什么餐厅它都能绕到天气上——因为模型觉得餐厅需要考虑下雨。后来我学乖了把描述改成带必须使用和禁止使用的朴素版本命中率反而从76%直接干到了94%。所以skill描述不是营销文案它是给模型看的路由规则。该用的时候狠一点不该用的时候也要坚决地说不。6.2 踩坑二工具定义膨胀吃掉上下文前期我没做两级曝光把所有技能的定义全部塞进请求。24个技能、18个工具函数两万多token。效果反而很差——模型在分析该用哪个工具时自己先蒙圈了偶尔还会把不同工具的参数混在一起传。后来切到目录按需加载上下文瘦身90%选错率直接降了一半。这个教训让我彻底明白上下文不是用来跟模型闲聊的省下来的token要留给真正的用户对话和推理。6.3 踩坑三参数校验松脏数据进下游有个临时写的小技能接收文件名参数我图省事没做校验。结果模型在几次对话里传出了带路径穿越符的字符串下游模块尝试读文件时差点读到系统目录。事后我补了两道防线第一道handler入口统一过白名单校验文件名禁止包含/、\、..等字符第二道把参数schema里的每个字段都标上pattern正则限制。虽然模型传脏数据的概率不高但一旦传到核心系统代价是毁灭性的。技能层的每个入参都要当作外部用户的恶意输入来设计。6.4 踩坑四没有评估指标优化全凭感觉重构完成后的前两周我遇到一个问题某个技能在新版本prompt调整后表现到底是变好了还是变差了我的感觉在第三天失灵了——隐约觉得变差了但没有数据佐证。于是我建了一个最简评估表每一条线上调用记录一行用户原话、模型选中的技能、参数抽取是否正确、handler是否报错、用户是否追问。跑了一周统计出三个核心指标技能命中率用户意图与所选技能的匹配度我目标定在90%以上。一次成功率第一次调用就完成且用户没追问的比例。修正率需要模型再次调用同一技能或换技能的占比。有了这些数字技能优化就从猜变成了查。哪条用户原话修正率高就把那条原话的变体加进对应技能的示例里。一周下来我有哪些没付款的订单这类问题的一次成功率从79%提到了96%。6.5 我给自己定的三条技能层铁律踩完这些坑我给自己定了三条规则现在每次设计新技能都会先过一遍第一一个技能只做一件事能做到80分就不贪多。想同时管理任务又管理日历拆开。模型的意图识别在单意图上最准多意图场景很容易走神。第二凡是代码能写死的行为绝不让LLM自由发挥。执行顺序、判断条件、异常处理都放代码里。LLM只做选技能、填参数两件事。第三每个技能都要有止损线。最大调用次数、超时时间、失败降级方案写在skill定义里。没有止损线的技能上线就等于把一个定时炸弹装进Agent肚子里。最后再分享一个我觉得最值钱的实用小技巧。找半天选技能不准有时不是描述问题而是你的用户query被前面的对话污染了。你会看到模型把用户上一句提到的天气当成当前这句要查的天气。解决方法是建立会话内的意图快照——在每轮工具调用的上下文里单独维护一个字段记录当前正在处理的意图和已经获取到的关键参数。模型看这个快照而不是自己去翻海量对话历史技能选择的准确率会明显提升。我自己的项目里只加了这一层命中率又往上走了好几个点。这套agent-skills体系不是一次建完就一劳永逸的它需要你像维护代码库一样持续迭代每个技能。但换个角度想这也正是它迷人的地方——Agent的能力边界终于变得可以被你稳稳握在手里了。