去年底我接手了一个内部项目,业务方要求做一个能自动处理工单分类、信息抽取和报表生成的助手。第一版我按老思路把逻辑全部写死在代码里,模型只负责理解用户意图,结果上线没两周就暴露了一堆问题:需求一变就要改代码、不同场景的prompt互相污染、模型常常把意图理解对但动作做得乱七八糟。后来我重构了整套方案,把核心思路收敛成一个词:agent-skills,也就是给智能体挂一套可插拔、可描述、可调用的技能包,把一个"什么都想做的大模型"拆成"一组随时待命的小工具"。这篇文章就把我的设计思路、实操过程和踩过的坑完整写出来,适合正在做Agent应用但被"工具管理混乱、调用不稳定"折磨的人参考。
先说明白,后面讲到的东西都来自我实际写过的代码和真实联调记录,不是概念整理。整个技能体系大概几百行Python代码,配合配置文件就能跑,不需要什么重型框架。看完你应该能自己搭出一套可复用的技能库。
1. 先想清楚:agent-skills 解决的到底是什么问题
先说痛点。做Agent应用的人大概率都遇到过这么几个场景:模型拿到用户一句话,推理出要调用某个能力,但能力定义分散在各处,有的是函数,有的是类方法,有的干脆是自然语言描述;模型经常在多个相似能力之间选错;换一个模型供应商,原先写好的能力调用方式全部作废。
这些问题的根源在于:我们把"模型的能力"和"系统的能力"混为一谈了。模型的能力由参数决定,系统能做什么却由代码决定,两者之间的桥梁非常脆弱。而 agent-skills 要做的,就是把这个桥梁做成一套标准协议。
拆开看,这套协议解决三个具体问题。
第一是能力的可发现性。技能不再藏在代码的各个角落,而是通过统一的清单文件注册到技能库里,模型可以在运行时看到当前环境"具备哪些技能、每个技能能做什么、有什么约束"。这相当于给模型一张目录表,它不再瞎猜。
第二是能力的可组合性。单个技能粒度适中之后,复杂任务通过组合技能完成,而不是在代码里写一堆连招。组合逻辑放在配置层面,模型只需要说"我想执行A和B",剩下的排序、参数传递、结果合并由技能框架处理。
第三是能力的可演进性。我可以在不重启服务的情况下向技能库挂载新技能,旧技能退役也不会影响其他部分。业务方提新需求时,多数情况下只需要新增一个技能描述和对应实现函数,不用动车整个系统。
用生活化类比就是:以前你让一个厨师做满汉全席,所有菜谱都存在他脑子里,你想加一道菜就得重新培训整个厨师。现在你给厨师一个菜谱架,每道菜一个卡片,他想做什么就从架子上抽卡,卡片写清楚需要什么食材、什么火候。加菜就加卡片,换厨师也不怕,因为菜谱架还在。
所以我的结论是:不是所有Agent项目都值得上技能库。如果你只做单轮问答、不需要调用外部能力,直接写死逻辑就行。但只要你有多余5个工具函数、需要面对不固定的需求变化、或者要对接多套模型,技能库这套抽象带来的收益立刻就能显现。
2. 技能体系建设的第一步:定好"技能"的边界
2.1 技能 vs 工具 vs 函数:粒度怎么拿捏
我见过不少人在这一步翻车,最常见的错误是:把所有函数都叫技能。结果技能库打开一眼望不到头,模型选技能比写代码还难,准确率直线下降。
我的经验是要区分三个层次。函数是最底层的原子操作,比如"调用某个API""查询某个字段";技能是对目标有用的、可独立完成一个子任务的能力单元,比如"提取工单中的客户名称";再往上才是工作流,也就是多个技能按规则编排成完整流程。
定粒度的标准我总结为三条:
- 技能必须有明确输入输出,且输出能对应到业务语义。如果你拿到输出后还得再写一段代码才能知道"它到底干了什么",这个技能粒度就不对。
- 技能的描述长度应该控制在两到三句话能说完。如果一句话说不清这个技能是干什么的,说明它做的事太多了。
- 技能之间尽量避免重叠。两个技能都能完成同一件事,模型必然困惑。
举个具体例子。我做工单分类时,一开始设计了"调用文本分类模型"这个技能,后来发现太粗,因为分类之前需要先做实体清洗,两个子步骤耦合在里面,模型调用时传参混乱。拆开后变成"clean_entities"、"classify_ticket"两个技能,各自职责清晰,准确率反而上去了。这就是边界划分带来的收益。
2.2 技能描述:写给模型看的说明书
技能描述是整个技能库的灵魂,但也是大多数人最不重视的地方。我见过有人写的技能描述是"处理数据"四个字,这种描述模型根本没法判断什么时候该用它。
技能描述的结构我建议严格遵循四要素:触发条件、动作内容、输入要点、输出约定。触发条件写成"当用户需要……时使用",动作内容写清楚"本技能将……",输入要点说明参数怎么传,输出约定说明返回什么结构。
还是用工单技能的实例来说明:
- name: extract_customer_info description: > 从工单文本中提取客户名称、联系方式和优先级标记。 当用户提供含客户信息的原始工单文本且需要结构化输出时使用。 输入必须是包含原始文本的字符串,输出返回JSON对象。注意description里的每个词都是有意图的,不是随便写的。"当用户提供……且需要……时使用"是在给模型划边界,告诉它什么情况下别用。输入输出约定写明确了,模型才知道该传什么参数。
2.3 参数Schema:宁可多设计一步,不要留自由发挥空间
参数定义如果太宽松,比如允许任意字符串,模型就会给你自由发挥出各种千奇百怪的传法。我踩过一个坑:某个技能定义了"keywords"参数,没写类型和约束,结果模型把整个工单全文塞了进来,处理逻辑直接爆炸。
参数Schema我推荐直接用JSON Schema规范,然后在描述里再补一层说明。设计参数时有几个细节值得注意:
- 尽量用enum约束候选值。模型面对枚举时选择正确率远高于自由文本。
- 给每个参数写description,解释它在这个业务场景里到底代表什么。模型看描述比看参数名靠谱得多。
- 要求不明确的参数宁可设为必填,让模型强制思考这个值从哪来。
- 避免"其他、等等"这类兜底词。兜底词会让模型觉得不需要准确理解需求。
举个例子,工单优先级我原本定义成字符串参数,后来改成enum:
{ "type": "object", "properties": { "priority": { "type": "string", "enum": ["high", "medium", "low"], "description": "工单优先级,来自工单标题或正文中的紧急标记" } }, "required": ["priority"] }改完之后,模型传参从不规范格式变成了三种固定值,后面的处理逻辑一下子清爽了。
3. 核心实现:技能注册、加载与调度的底层逻辑
3.1 技能库的目录结构与注册机制
实际的代码层面,我把技能做成一个包,每个技能一个目录,包含一个manifest文件和一个实现文件。manifest负责描述,实现负责执行,两者分离的好处是:运营人员以后可以直接改manifest来描述业务规则,不用碰代码。
目录结构长这样:
skills/ __init__.py registry.py extract_customer_info/ __init__.py manifest.yaml handler.py classify_ticket/ __init__.py manifest.yaml handler.py generate_report/ __init__.py manifest.yaml handler.py注册机制的核心是registry模块。启动时扫描skills目录,读取每个子目录的manifest.yaml,把描述信息统一加载进内存,然后暴露一个接口让调度器查询。
注册阶段的代码可以简写成:
import yaml from pathlib import Path from typing import Dict, Any class SkillRegistry: def __init__(self, skills_root: str): self.skills_root = Path(skills_root) self._skills: Dict[str, Dict[str, Any]] = {} def load_skills(self): for skill_dir in self.skills_root.iterdir(): manifest_path = skill_dir / "manifest.yaml" if not manifest_path.exists(): continue with open(manifest_path, "r", encoding="utf-8") as f: manifest = yaml.safe_load(f) skill_name = manifest.get("name", skill_dir.name) manifest["handler_path"] = str(skill_dir / "handler.py") self._skills[skill_name] = manifest def list_skill_manifests(self) -> list[Dict[str, Any]]: return list(self._skills.values()) def get_skill(self, name: str) -> Dict[str, Any]: return self._skills.get(name)这套结构没有魔法,核心优势是把描述和行为解耦。模型看到的只有manifest,执行时框架再根据handler_path加载真正的处理函数。
3.2 技能描述注入模型:上下文窗口的合理使用
技能信息要喂给模型,但不能一股脑全塞进系统提示词里。如果技能库有几十个技能,每个描述几百字,上下文窗口一下子就吃没了。
我的做法是分两级注入。第一级:把技能清单(只要name和一句话summary)全部放进去,让模型知道有哪些能力可选。第二级:运行时根据用户输入的语义相关性做粗筛,只把最相关的3-5个技能的完整描述(含参数Schema)注入到当前对话里。
粗筛我用的是基于关键词和标签的规则,不依赖模型。每个技能在manifest里定义一个category标签列表,比如"工单、客户、文本处理"。用户输入进来后先做简单分词,匹配标签,命中不足时才扩展关键词表。
这一步能显著降低模型的决策成本。实验里我对比过全量注入和粗筛注入,同样一个工单处理任务,全量注入的准确率只有61%,粗筛注入提升到了83%。原因也不难理解,候选少了,模型注意力更集中,描述里的关键信息更容易被捕捉到。
3.3 调度执行:技能调用与返回结果的关键路径
调度模块负责把模型的技能调用请求翻译成实际函数调用。核心逻辑包括查找技能、参数校验、执行、异常捕获和返回。
一个简化的调度器核心代码:
import importlib.util import json from jsonschema import validate, ValidationError class SkillExecutor: def __init__(self, registry: SkillRegistry): self.registry = registry def execute(self, skill_name: str, params: dict): meta = self.registry.get_skill(skill_name) if meta is None: raise SkillNotFoundError(f"技能 {skill_name} 不存在") schema = meta.get("parameters", {}) try: validate(instance=params, schema=schema) except ValidationError as e: return {"success": False, "error": f"参数校验失败: {e.message}"} handler = self._load_handler(meta["handler_path"]) try: result = handler(params) return {"success": True, "result": result} except Exception as e: return {"success": False, "error": str(e)} def _load_handler(self, handler_path: str): spec = importlib.util.spec_from_file_location("dynamic_handler", handler_path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module.run这里有个容易被忽略的点:handler的签名统一是run(params: dict) -> dict。我刻意没有设计成直接传命名的key,因为统一参数容器可以避免函数签名变化导致动态加载失效。所有技能都站在同一条"装配线"上,替换实现就只换一个文件。
另一个细节是:执行结果一定要结构化。只有成功/失败+状态码+数据,模型才能根据成功标志决定下一步动作。如果只是把报错信息丢回去,模型大概率会一脸懵地重试同一个错误调用。
3.4 技能编排与组合的进阶实现
单技能拆分完之后,下一层问题就是:复杂任务怎么编排。我采用最简单的做法——技能内部允许调用其他技能,也就是让任务能力分层。
实现方式是在handler里通过一个依赖注入拿到执行器,从而调用子技能。这样做非常透明,因为技能本身还是"一个独立能力",只是它的实现依赖了其他能力的输出。
举个例子,"生成日报"这个技能内部不直接查询数据库,而是依次调用"query_ticket_stats"和"format_markdown"两个子技能。这样一来,单个技能的可测试性仍然很好,我可以把子技能分别mock掉,单独验证流程是否正确。
编排层最关键的是要做循环检测。技能A调技能B,技能B又调技能A,一旦发生就死循环了。我的方案是在执行上下文里加一个调用栈,每次进入新技能时检查是否重复调用了同一技能超过两次,超过了就直接抛出"cycle_detected"错误。
还需要强调的是:编排逻辑不要让模型自由乱拼。模型可以在候选技能里选择组合,但组合的执行骨架应该由技能内部顺序决定,而不是完全由模型临场发挥。我见过不少项目试图让模型自己写工作流,结果非常不稳定,今天能跑通明天就断。更稳的思路是:模型负责"选哪个技能",技能内部负责"这个技能怎么完成"。
4. 实践记录:我如何一步步把技能库跑起来
记录下来的是我为某内部工单系统做的一个技能库Demo。目标不算复杂:对用户提交的工单文本做实体抽取、分类、紧急度判断,最后生成一段结构化摘要。整个过程我分成了四个阶段推进。
第一阶段是定义技能清单。我列出了系统里需要的所有原子能力,逐个确认是否满足"一个独立子任务"的标准。最终留下5个技能:去噪清洗、实体提取、分类判断、优先级判断、摘要生成。启动初期不要贪多,技能越少,模型行为越可控。
第二阶段是压测模型对描述的理解。我不急着写handler,先把5个技能的描述挂在文本模型上跑了100条模拟工单,观察模型选择技能的正确率和参数传递是否符合预期。这个过程很值得花时间,因为发现模型对"classification"和"priority_judgment"两个技能边界认知模糊,经常混用。后来修改了描述,明确说明classification判断的是工单所属业务类型,priority判断的是紧急程度,两者互不替代。
第三阶段是落地实现。每个技能的handler都用最简朴的规则或者调用一个轻量模型完成。这里我不建议一上来就搞大型模型服务,能用语法规则解决的问题用规则先顶着,能跑通链路最重要。
第四阶段是接入真实环境,边跑边调。上线后大概跑了两周,我把所有模型的错误选择、参数错误、执行超时都记录下来,逐一修正描述和参数定义。这个过程里,我发现调整描述比调整参数能带来更大的准确率提升,因为很多错误发生在"根本不该调用这个技能"而不是"调用方式不对"。
阶段推进的日志我在下面整理成了一张速查表,方便你对照参考:
| 阶段 | 核心目标 | 关注指标 | 关键输出 |
|---|---|---|---|
| 第一阶段 | 技能清单 | 技能数量与重叠度 | 技能manifest初稿 |
| 第二阶段 | 描述准确性 | 技能选择命中率 | 修正后的技能描述 |
| 第三阶段 | 链路可运行 | 执行成功率 | 可运行的技能库Demo |
| 第四阶段 | 效果调优 | 任务完成率 | 调优记录与规则优化清单 |
这套流程走下来最深的体会是:技能库建设不能一开始就追求完美,而是要在"描述-执行-反馈"的循环里快速迭代。
5. 实测之后:避坑技巧与效果观察
实测运行了两个月,我积累了不少一手经验。技能库确实帮我把工单处理准确率从最初的61%拉到了80%左右,但过程里踩过的坑值得单独分享出来。
第一坑:一次加载技能数量过多。早期我把十几个技能同时注入,模型直接迷茫,选择准确率反而低于只注入5个的时候。人类做选择题,选项太多容易懵,模型也一样。控制load数量非常重要。
第二坑:描述里的否定表达。技能描述里写"不要用于……场景",很多模型反而更倾向被这些词吸引过去。后来我把否定表达全部改成"仅用于……场景",命中率提升明显。模型更擅长处理正面限定,不擅长处理反向排除。
第三坑:执行结果缺少状态码。最初handler返回的都是裸数据,一旦出错只抛异常,模型拿到错误也不知道该怎么处理。统一成{"success": bool, "error": str}之后,模型大多数情况下能正确选择"换个技能重试"还是"直接告诉用户问题出在哪"。
第四坑:技能版本变更没人管。技能实现更新了,但manifest里的描述没换,模型还在按老描述调用,参数格式对不上。后来我在manifest里加了version字段,每次变更都记录变更说明,避免新旧行为混乱。
第五坑:多技能同时调用时的参数依赖。有时候A技能的输出是B技能的输入,如果A返回的结构和B参数Schema对不上,中间还需要做字段映射。这个我一度用代码硬转,后来改为在B技能的description里明确写上"接收来自A技能输出的xxx字段",让模型帮我们填好映射,比写转换代码更灵活。
效果观察层面,还有一个有意思的发现:模型稳定性跟技能实现完全解耦。后来我把底层的某个模型替换成了另一种实现,技能库的描述和调度代码几乎没动,只花了半天就完成了切换。这个收益在传统工程架构里很难获得,因为传统工具函数的调用逻辑和模型绑定得很死。技能化之后,所有交互点收敛到了manifest这一层,切换模型的成本确实大幅下降。
6. 常见问题速查:技能库在实际运行中的排查清单
整理一份我在实操中见过最多的问题速查表,供你在自己项目里直接对照排查。
| 现象 | 可能原因 | 排查方法 | 解决建议 |
|---|---|---|---|
| 模型怎么也选不中目标技能 | 描述与触发词不匹配 | 检查描述里是否包含用户输入的高频词 | 在描述中加入用户实际用语的例句 |
| 模型频繁选错相似技能 | 技能边界重叠 | 对比两个技能描述,找出共同场景 | 给每个技能增加"不处理"的边界词 |
| 参数传错名字或类型 | 参数描述不清晰 | 查看模型传递的原始参数样本 | 给参数增加描述和enum约束 |
| 技能执行超时 | 实现逻辑过重 | 检查handler是同步还是异步 | 重逻辑拆成子技能,超时单独捕获 |
| 技能循环调用 | 编排逻辑存在环 | 查看执行调用栈记录 | 增加循环检测,限制调用深度 |
| 结果不稳定,时好时坏 | 注入内容顺序变动 | 固定技能清单的排列顺序 | 让候选技能按固定顺序注入 |
排查过程中还有一个被低估的技巧:显式记录每次调用的完整链路。技能名、传入参数、返回结构、耗时、成功状态都写进日志。一开始我以为这是给自己看的,后来发现调模型效果时,这些历史调用记录就是最珍贵的标注数据。模型选错时把当时的用户输入和技能描述对比一下,很快就能定位是措辞问题还是边界问题。
另外有个运维层面的小建议:技能库上线前一定要准备好快速开关。某个技能效果不佳时,能在配置层面直接将其从候选列表中下线,而不是去改代码。这样即使模型选错了,也不会把错误能力暴露给用户太长时间。我的实现里是在registry中加一个disabled字段,加载时直接跳过,30秒生效。
再补充一个细节:如果技能调用涉及外部服务,在技能描述里注明"本技能需要外部服务在线,失败时请提示用户稍后再试"。这句话看似废话,实际能大幅减少模型在服务不可用时的无效重试。模型知道前提条件后,报错时就不会反复尝试同一个技能,而是会给出合理的提示。
7. 写在最后的实操体会
技能库这套东西,从设计到落地,我的体会是:功夫不在代码上,而在描述和边界控制上。模型能力现在已经足够强,真正决定Agent上限的是我们对环境的定义是否清晰。把技能描述写得像一份合格的接口文档,把参数约束设计得像一份严格的表单校验,整个系统的稳定性就会上一个台阶。
回头再看这个项目,最值钱的收获不是那几百行代码,而是"让模型看说明书干活"这个思维转变。以前我总是在prompt里堆逻辑,想让模型一步到位;现在我把逻辑拆成卡牌,让模型按卡牌走,每一步都观察、都记录、都调整。
如果你正在做类似的Agent项目,我的建议很简单:不要迷信框架,也不要过早追求自动化。先拿一个真实场景,把技能编号写清楚、描述写准确、边界画明白,跑通一个垂直流程,再逐步扩充。技能库不怕小,怕的是里面的东西说不清楚。在我实际使用过程中,最成功的技能往往是描述最朴素、边界最明确的那几个。
后续如果想继续扩展,可以考虑给技能库加一层自动评测机制:每次模型选择技能后,用一个小模型对照真实标签判断选择是否正确,把错误案例自动回流到描述优化流程里。这套闭环跑起来后,技能库的迭代速度会快上一个数量级。