做 agent 的人越来越多但很多项目挂在第一步上提示词越堆越长模型越学越乱。我最近把手上一个叫 agent-skills 的个人项目重新整理了一遍核心思路很简单——把 agent 的能力拆成一个个可注册、可复用、可单独调试的技能单元而不是把所有逻辑都塞进 system prompt 里。这篇文章会讲清楚我在这个项目里踩过的坑、最终落地的结构以及一套可以直接抄走的实现方案。如果你正在做 LLM agent、工具调用、任务自动编排这类东西这篇应该能帮你省不少时间。1. 项目概述agent-skills 到底在解决什么问题1.1 什么是我理解的 agent skill先聊清楚概念。我这里说的 skill不是那种“给 agent 写一段 prompt 让它会做某事”的临时提示词而是一个结构化的能力单元它有名字、有描述、有参数约束、有执行逻辑还能被模型按需调用。打个比方一个不接入技能系统的 agent 就像一个大厨你告诉他“你会做所有菜”但他脑子里其实只有一套模糊的做菜思路。而接了技能系统的 agent等于给这个大厨配了一整个厨房切菜有切菜台、炒菜有独立灶台、煲汤有专门汤锅。每次来订单他自己判断该用哪个台面而不是在同一口锅里干所有事。我的项目里一个 skill 通常长这样skill.register( namecalculate_payment, description计算订单的最终支付金额支持优惠券和积分抵扣适合在结算场景调用, parameters{ type: object, properties: { order_id: {type: string, description: 订单编号}, coupon_code: {type: string, description: 优惠券码可空}, }, required: [order_id], }, ) def calculate_payment(order_id: str, coupon_code: str ): # 执行逻辑 ... return result这个结构不是拍脑袋定的后面会详细拆解每一部分为什么这么设计。1.2 为什么需要一套技能体系很多人一开始觉得没必要“我直接在 prompt 里写清楚让 agent 干什么不就行了吗”确实简单场景这样没问题但一旦任务类型超过五六个问题就来了。第一prompt 会失控。你为了让模型偶尔用一下某个工具得在 system 里塞一大段工具说明。塞多了模型反而抓不住重点经常在该用工具的时候瞎编答案又或者把两个工具的职责搞混。第二没法单独调试。所有逻辑混在一段提示词里出问题你根本定位不到是哪段话引起的。是描述不清是参数格式错了还是模型根本没读到这段指令排查成本高得离谱。第三复用性为零。换个项目或者同一项目里换个场景之前写的 prompt 基本要重写。你没法把一个“订单金额计算”的能力从一个 agent 身上拆下来装到另一个 agent 身上。agent-skills 这套东西本质上就是把“能力”从“提示词”里解耦出来变成独立的、带元信息、可控的模块。模型只需要看每个 skill 的 name 和 description自己决定调哪个、怎么调。开发者则可以针对单个 skill 独立测试、迭代、回滚完全不影响其他技能。2. 技能系统的整体设计2.1 技能注册表一切能力的中枢既然技能是独立模块那总得有个地方统一管理它们。我设计了一个全局注册表所有技能在模块加载阶段把自己登记进去运行阶段 agent 再从注册表里挑选。注册表的核心数据结构就三样技能字典、标签索引、状态标记。class SkillRegistry: def __init__(self): self._skills {} self._tags {} self._disabled set() def register(self, skill): self._skills[skill.name] skill for tag in skill.tags: self._tags.setdefault(tag, []).append(skill.name) def get_all_skills(self): return [s for name, s in self._skills.items() if name not in self._disabled] def get_by_tag(self, tag): names self._tags.get(tag, []) return [self._skills[n] for n in names if n not in self._disabled]注册表本身不复杂复杂的是围绕它的几个策略。第一个策略是按标签过滤。项目规模上来之后技能可能有几十个全量塞给模型既不经济也容易干扰判断。比如电商 agent 有订单技能、库存技能、客服技能那就各自打上 order、inventory、customer_service 标签在会话初始化时按场景注入对应的技能集合而不是一次全部加载。第二个策略是动态启停。有时候某个上游服务不稳定比如支付接口在维护这时候不能把支付技能下掉——下掉模型就完全不知道有这个能力会直接编造支付结果。更好的做法是把技能标记为 disabled但保留注册信息再在描述里加上“当前不可用”的状态提示让模型知道该能力暂时无法使用转而走人工兜底流程。这个细节看着小实际生产里非常重要。模型不知道一个工具的存在和知道它存在但不可用行为逻辑是完全不一样的。前者可能直接拒绝任务后者会主动走你预设的备用方案。2.2 技能描述最容易被低估的元信息如果说注册表是骨架那 description 就是技能的灵魂。几乎所有人刚开始写技能时都会忽略这一块随手写一句“计算支付金额”就完事。然后跑出来结果乱七八糟还以为是模型不行。我后来总结了一套写描述的方法论核心就一句话描述是在替模型做“何时用”的判断不是“怎么用”的说明。一个好的描述要包含三部分信息这个技能做什么功能边界它适合在什么场景下被触发触发条件它不适合处理什么排除项举个例子对比一下两种写法# 烂描述 description 计算支付金额 # 好描述 description ( 计算订单最终应付金额支持优惠券抵扣、积分抵扣、会员折扣。 当用户询问我要付多少钱这个订单多少钱时使用。 不要用这个技能处理退款金额计算退款走 refund_order 技能。 )这里有个很容易忽略的点排除项。多写一句“不要用它做什么”能显著减少模型乱调技能的概率。原因是模型在语义匹配时相近技能之间的区分度往往不够明确写出边界等于帮模型划了一条界线。参数描述同样重要。每个参数除了类型和是否必填之外一定要写清楚这个参数是什么含义、取值的格式要求。比如 order_id你要说明它是数字字符串还是带前缀的编号coupon_code 要说明用户没提优惠券时该传空字符串而不是 null。这些细节直接决定了技能执行时是零报错还是频繁参数校验失败。3. 核心链路从模型选技能到执行完成3.1 技能选择让模型做路由技能系统的运行链路可以拆成四步注入、选择、执行、回填。注入阶段把当前可用的技能列表转成模型接口能够识别的结构。市面上主流的大模型接口一般都支持 function calling 格式就是把每个技能翻译成 name、description、parameters 三个字段。这里有个比较隐蔽的性能优化点如果你用的是带 token 计费的模型服务几十个技能的全量注入会吃掉大量上下文窗口。我实测下来一个描述写得稍微详尽点的技能光元信息就要占掉 300~500 token十个技能就是三五千。所以前面说的标签过滤不是可选项而是规模上来之后的必选项。选择阶段模型根据用户请求和技能描述做匹配返回它想调用的技能名和参数。这一步完全是黑盒你只能通过描述质量来影响结果这也是为什么上一节把描述写得那么细。需要注意的是不是所有模型接口都原生支持 function calling。有些模型只能靠 prompt 约定输出 JSON。我的项目里做了一个适配层把两种情况统一封装成同一种 SkillCaller 接口上层逻辑不需要关心底层是什么模型、什么协议。class SkillCaller: def call(self, skill_name: str, arguments: dict) - SkillResult: # 校验参数 # 执行技能函数 # 捕获异常并格式化为统一结果 ...3.2 参数校验把脏数据挡在技能外面模型传进来的参数千万别信。这是我在项目里被教育得最惨的一次某个技能接收一个文件路径参数模型传了一个带特殊字符的相对路径代码直接去读文件结果路径穿越把系统目录里的文件内容当成业务数据返回给了用户。从那以后所有技能的参数入口必须过一层严格校验。校验分两层第一层是格式校验。每个技能定义参数时就带 JSON Schema执行前用校验器跑一遍不满足直接拒绝并返回格式化错误让模型去修正参数而不是硬着头皮执行。第二层是业务规则校验。格式对不代表业务对比如金额字段格式是数字字符串没错但你得额外判断它是不是负数、是不是超过订单实付金额。这类校验写在技能函数内部异常要抛出带有业务语义的错误信息而不是裸抛一个 ValueError。因为模型需要根据错误信息来重新生成参数如果错误信息写得晦涩模型根本不知道该改哪里。我把校验结果设计成了统一结构dataclass class SkillResult: success: bool data: any None error_code: str error_message: str 所有技能不管成功失败都返回这个结构。这样上层 agent 处理起来非常统一——它只需要读 success 字段失败就看 error_message并把 error_message 原样带回给模型作为修正依据。3.3 执行器统一调度与上下文控制执行器是整个系统的调度中枢。它从注册表拿到技能对象校验参数调用技能函数最后把结果格式化成模型能读懂的文本。这个环节最需要想清楚的是结果文本的长度控制。模型执行完技能之后返回的结果还要塞回上下文供模型做后续推理。如果技能返回一个巨长的 JSON比如查出来几百条库存记录这些内容会全部灌进上下文token 烧得快不说还会把模型的注意力从用户原先的问题上带偏。我的处理办法是给技能结果加一道“压缩”逻辑默认只保留前 N 条记录其余截断并附上一句“共有 X 条记录已展示前 Y 条”。这样模型既能拿到足够信息做回答又不会被海量数据淹没。def _summarize_result(result: any, max_items: int 5) - str: if isinstance(result, list): if len(result) max_items: return json.dumps({ items: result[:max_items], total: len(result), truncated: True, }, ensure_asciiFalse) return json.dumps(result, ensure_asciiFalse) return str(result)4. 实操搭一个可复用的技能加载框架4.1 目录结构与发现机制技能文件按模块组织每个技能一个文件文件内部通过装饰器完成注册。项目目录大致长这样agent-skills/ ├── skills/ │ ├── __init__.py │ ├── _registry.py │ ├── order/ │ │ ├── __init__.py │ │ ├── calculate_payment.py │ │ └── refund_order.py │ └── inventory/ │ ├── __init__.py │ └── query_stock.py ├── caller.py └── main.py用目录和标签双维度组织原因有两个目录负责代码层面的模块化一个业务域一个文件夹方便维护标签负责运行时的动态筛选解耦了物理组织和业务筛选之间的关系。一个技能甚至可以属于多个标签比如退款技能既算 order 域也算 finance 域物理目录只能放一个地方但标签可以挂多个。4.2 自动加载与延迟注册写装饰器注册不复杂复杂的是怎么让 Python 在启动时自动把所有技能文件都导入一遍。我用的办法是扫描 skills 目录下所有 .py 文件逐个 import导入过程自然触发装饰器注册。import importlib import pkgutil import skills def load_all_skills(): registry SkillRegistry() for module_info in pkgutil.iter_modules(skills.__path__): module importlib.import_module(fskills.{module_info.name}) if hasattr(module, register_skills): module.register_skills(registry) return registry这里每个技能包的init.py 里写一个 register_skills 函数统一把包内所有技能注册进去。好处是隐藏了技能对象被创建的具体时机也方便在测试环境里只注册一部分技能不用全部加载。4.3 装饰器与数据模型装饰器是用户最常接触的一层直接决定了写新技能的体验。我的装饰器接收 name、description、parameters、tags 这几个核心参数内部把函数包成一个 Skill 对象。def register(name, description, parameters, tagsNone): def decorator(func): skill Skill( namename, descriptiondescription, parametersparameters, tagstags or [], funcfunc, ) skill_registry.register(skill) return func return decorator很多人觉得这一步无所谓随便写写就行。实际不是装饰器参数的定义直接决定了技能作者的犯错成本。我第一版把 description 和 parameters 混在一个大 dict 里传结果写技能时经常漏字段、写错类型后来拆成独立关键字参数配合 IDE 的自动补全新手上手速度快了很多。4.4 接入模型调用的完整示例最后一步是把注册好的技能列表喂给模型。以兼容 function calling 的接口为例核心流程是这样的from caller import SkillCaller def run_conversation(registry, user_message): skills registry.get_all_skills() caller SkillCaller() messages [{role: user, content: user_message}] tools [ { type: function, function: { name: s.name, description: s.description, parameters: s.parameters, }, } for s in skills ] # 第一轮模型决定是否调用技能 response llm_chat(messages, toolstools) assistant_msg response[choices][0][message] if assistant_msg.get(tool_calls): call assistant_msg[tool_calls][0] skill_name call[function][name] raw_args call[function][arguments] result caller.call(skill_name, json.loads(raw_args)) messages.append(assistant_msg) messages.append({ role: tool, tool_call_id: call[id], content: result.to_json(), }) # 第二轮模型基于技能结果生成最终回答 final_response llm_chat(messages, toolstools) return final_response[choices][0][message][content] return assistant_msg.get(content, )这个流程有两个细节值得说。第一第一轮模型输出 assistant_msg 必须原样追加回消息列表不能只把 tool result 加进去。很多初学者在这里翻车丢失了 assistant 的 tool_calls 记录第二轮调用时模型报错或者无法关联结果。第二第二轮仍然要带上同样的 tools 参数。因为模型在拿到技能结果后可能发现结果不够还想再调一次另一个技能这就是多轮工具调用的基础。如果你第二轮把 tools 去掉了模型就只能基于已有结果强行作答无法完成复杂任务的编排。5. 常见问题与排查技巧实录5.1 技能选取不准模型就是不用这是所有接技能系统的人遇到最多的问题。大部分情况下不是模型笨是你的描述写得像废话。排查思路我一般是三步走。第一步把 description 里用来触发的那几个关键词和用户真实会说的话做一遍比对。比如技能描述写的是“用于查询订单状态”但用户实际说的是“我的包裹到哪了”那描述里应该补充“包裹、物流、快递”这类词汇。第二步检查是不是有多个技能的描述互相重叠。我维护了一个简单的重叠检测脚本把两两技能的 description 做一次文本相似度计算超过阈值的打出来人工审查。重叠的直接后果就是模型选择困难最后随机挑一个。第三步也是最容易忽略的一步看 prompt 里有没有“隐性干扰”。有时候 system prompt 里写了一句“不要调用工具除非用户明确要求”再好的技能描述也白搭。先检查全局指令有没有抑制工具调用再回头优化描述。5.2 参数传递不对报错或者执行错误参数类问题分成两种模型传了错误格式以及模型缺了必填参数。错误格式一般是 JSON Schema 定义不严导致的。比如你定义 order_id 是 string但实际业务用的是纯数字模型很可能传成 number 类型把自己的理解强加进来。解决方法是把 description 里写明白“这是一个字符串类型的数字订单号不是整数”同时把 Schema 里加上 maxLength、minLength 这类细粒度约束压缩模型自由发挥的空间。缺必填参数的场景往往是描述里没有把必填项和可选项的关系讲清楚。最常见的是“优惠券码可空”这类字段如果模型觉得没有优惠券就不用传 coupon_code而你的 Schema 又没把它标记为 requiredFalse那就会校验失败。所以定义参数时默认把非必填字段显式标出来别赌模型能猜出来。5.3 上下文爆炸token 消耗飙升技能系统天然会增加 token 开销因为每一轮都要带上全部技能定义。一旦出现循环调用模型反复调同一技能、拿到结果、又决定再调一次上下文会快速膨胀。我加了两个保险丝。第一记录每条会话的技能调用次数单个技能超过 5 次就强制停止并向模型返回“该技能已达到本次会话调用上限”。第二对技能结果做严格截断按前面说的 summarize 逻辑压缩结果体量。还有一个很多人没想到的点把技能列表的注入顺序固定下来。模型在函数调用场景下技能选择很大程度上受前面内容的注意力权重影响固定顺序至少能保证行为可复现不至于因为技能排列变了导致结果时好时坏。5.4 异常处理不规范模型进入死循环技能执行抛异常后返回给模型的信息质量直接决定模型下一步能不能自救。如果只是简单返回一句“系统错误”模型会一脸懵可能会换个参数再试一次然后继续报错形成死循环。正确的姿势是返回结构化的错误反馈告诉模型“哪个参数不对、应该传什么、可不可以换一种方式处理”。我在统一错误结构里特意加了 error_code 字段用来标记错误类别比如 PARAM_INVALID、BIZ_RULE_VIOLATION、UPSTREAM_UNAVAILABLE。模型在 prompt 里被明确指示看到 PARAM_INVALID 就检查参数格式看到 UPSTREAM_UNAVAILABLE 就告知用户暂不可用。实测下来把错误信息写清楚了模型的自我纠错成功率能提高一大截至少大部分参数类问题它自己能改好。6. 我对技能系统的经验总结做完 agent-skills 这个项目我最大的体会是技能系统的复杂度不在代码而在元信息设计。装饰器、注册表、执行器这些代码加起来也就几百行真正花时间的是琢磨每个技能的 description 怎么写、参数边界怎么划、错误怎么反馈。这部分没有标准答案只能在自己的业务场景里不断试错、迭代。另外一个经验是不要把技能设计成“万能”。我一开始总想把相近的能力合并成一个技能觉得技能越少模型越好选。结果恰恰相反一个技能承担太多职责描述只能写得很泛模型反而更频繁地误判。拆细、拆专让每个技能只负责一件事描述做到“唯一明确”模型的选择准确率会高很多。最后给一个建议从一开始就要有单独测试技能的机制。我后来给每个技能都配了一个测试入口可以直接在命令行里传参数调用输出完整的 SkillResult。确认单个技能本身没问题再去排查 agent 调度层面的问题效率能翻好几倍。技能的调试永远是第一步千万别一跳就跳到“模型为什么不听话”这个黑盒里去翻。