
直接聊正题agent-skills这个项目名听起来像是一个“Agent会什么”的功能清单但真把它当成“给大模型多接几个函数”去写你大概率会得到一个啥都能聊、啥都干不好的玩具。我一开始也是这么理解的后来在真实业务里反复试才明白技能skills这套东西核心不是“调用”而是“封装判断”。这篇文章想把这个从设计到落地的完整路子拆开聊聊特别是给正在折腾Agent技能系统的开发者和技术负责人提供一个可以直接拿回去用的参考。1. 先把agent-skills这个题目拆明白先说个一句话总结Agent技能不是增加工具的“接口层”而是把工具的触发条件、使用边界、参数约束、失败处置一并打包让大模型在“什么时候用”和“怎么用”上有稳定预期的一套机制。1.1 技能不是“多一个函数”那么简单我最初做这个项目时第一步是照搬Function Calling的套路定义了一堆函数然后期待模型在对话里自己选对。结果上线后问题一大把模型会在不需要的场景硬调技能还经常把参数传来传去传迷糊尤其是当两个技能在某类场景上行为接近时模型基本靠蒙。后来我用一次典型对话举例定位问题用户说“帮我看下这个目录的文件”。我挂了两个技能——list_files和search_files函数名和参数都正常。但模型迟迟不选技能而是直接给用户解释“您可以通过命令行查看”。原因就是技能定义里只写了“做什么”完全没写“什么时候做、什么时候不做”。我这才把思路调整过来函数定义是接口技能定义是产品承诺。接口告诉模型“你能做什么”技能要告诉模型“你该不该做、做了之后怎么收场”。这块如果不先想明白后面所有代码都是白搭。1.2 一个典型技能包含的四个层经过几次推倒重来后我固定了一个技能的四层结构触发层明确技能适合出现的场景包括对话意图类型、上下文信号、前置状态。比如“文件目录遍历”技能触发前提必须是“存在明确目录路径”或“用户表达出浏览目录的意图”。参数层对入参做格式化约束每个字段必须有默认值并且定义合法的枚举范围不能把字符串直接透传给底层系统。执行层真正调用内部服务的动作这部分需要幂等设计和超时保护保证同一技能重复执行结果一致。结果层把原始输出转换成能被模型理解和继续推理的文本结构化字段要有清晰标签原始大日志绝对禁止直接塞回上下文。这张图对应到代码就是一层抽象让技能具备“可描述、可校验、可回退、可观测”四个能力。许多开源仓库里叫“Skill”或者“AgentSkill”的模块细看代码其实都逃不出这个框架。后面我在工程实现里也是按这四个层来组织的。2. 技能从设计到落地核心参数拆解设计一套技能时最重要的不是先动手写代码而是先把下面这几个问题用文字答复清楚。这些问题回答越具体技能在模型侧的成功率越高。2.1 描述怎么写模型才容易“听懂”我看到很多人写技能描述就一句话“List files in a directory”。这种描述对应到模型几乎没有决策价值。模型并不缺“能力信息”它缺的是“你以为何时该用这个技能”的上下文。后来我总结了一套写法描述里至少要包含三块内容使用时机明确列出“当用户要求查看文件、当用户意图涉及文件浏览、当对话上下文出现目录路径”等条件。禁止时机明确列出“当用户只是讨论文件名或模糊提问、当没有明确目录时应请求用户补充而不是直接调用”。预期输出注明“结果将以文件列表形式返回包含文件名、更新时间、大小供用户进一步选择操作”。这里有个我踩过的坑描述太长也不行。模型上下文有取舍技能描述太长反而会淹没真正关键的触发条件。经验值参考一个技能描述控制在100到200个汉字左右核心触发词提前放在前两句话里禁止条件放在中间输出说明放到末尾。注意技能描述是给模型“看”的不是给文档系统“存”的。写完之后做信息密度自检把每一句话都问一遍这句话是否影响模型决策如果删掉它行为不变就删掉它。2.2 技能入口参数schema的常见坑参数schema是另一处高发翻车点。我看过很多项目参数定义得很详细但执行时一调就错因为漏掉了三个细节。第一个是参数之间的依赖约束。比如“搜索技能”里path是可选参数但recursive布尔值一旦为真max_depth就必须有值。这种依赖关系JSON Schema里单靠字段定义表达不清楚需要在执行前加一段校验逻辑。第二个是隐式上下文的注入。用户经常说“打开上一步生成的那个文件”这里“上一步生成的文件”在用户输入里没有需要Agent运行时从对话状态里提取。技能参数如果只面对用户原话那注定失败。我在设计里加了“运行时上下文输入”每个技能可以声明自己依赖哪个状态字段。第三个是默认值的选择。默认值不能随便给一个技能里最危险的默认值就是空字符串和null。它们等于把判断权又踢回给模型。我的做法是凡是必选参数就标成必填全是可选参数时执行层里也要有“未传参时的兜底路径”比如回到当前工作目录。下面是参数校验的典型代码骨架from pydantic import BaseModel, Field, field_validator class SearchSkillParams(BaseModel): query: str Field(..., min_length2, max_length200) path: str Field(default.) max_depth: int Field(default2, ge0, le10) recursive: bool Field(defaultFalse) field_validator(max_depth) classmethod def depth_must_with_recursive(cls, v, info): if v 1 and not info.data.get(recursive): raise ValueError(max_depth 1 时 recursive 必须为 True) return v这段代码本身不是重点重点是它把“参数之间的约束”放到了技能边界而不是交给后续代码慢慢判断。这样即使模型传了不合法的组合也会在进入执行逻辑之前被拦下来。2.3 技能版本与回退策略技能一旦被模型调用就变成线上行为的一部分所以它也得有版本概念。我在项目里给每个技能配了version字段并且在技能注册表里保留最近三个版本的行为记录。回退策略是另一个让我踩坑比较深的地方。以前技能执行出错我直接让模型看到异常堆栈结果模型会“脑补”一个修正版本再调用一次连续错三次都不停下。后来我加了“错误分级”可恢复错误参数不合法、权限不足、目录不存在这类错误让模型基于提示调整参数后重试限制最多两次。不可恢复错误执行环境异常、结果过大超出上下文、底层服务不可用这类错误直接终止调用并把错误描述改写为标准话术返回用户。这个机制在一次实际需求里帮了大忙文件搜索技能在某个目录上权限不足以前模型会反复尝试用户体验很差加上错误分级后第一次失败就知道“要提示用户无权访问并建议切换目录”响应速度和用户满意度都上来了。3. 实操给一个Agent接上文件操作技能说完了设计原则用一段完整的实操过程把知识点串起来。这个技能的定位是“文件系统探索”用户输入自然语言后Agent决定是否调用技能、怎么调用并把结果整理后回复用户。3.1 准备一个最小可运行的技能骨架我没有直接裸写Function Calling而是建了一层“技能注册表”这样后续新增技能时统一维护也能做统一观测。注册表的数据结构我用了一个简单的dataclass先把元信息固化下来from dataclasses import dataclass, field from typing import Callable, Any dataclass class AgentSkill: name: str description: str params_schema: dict execute: Callable[..., Any] version: str 1.0.0 max_retries: int 1 timeout_seconds: int 10 tags: list[str] field(default_factorylist)这个数据结构解决了一个隐性需求技能不只是“函数描述”它能在注册阶段就把超时、重试、版本这些横切关注点统一绑定。后面加观测日志时也只需要扩展dataclass字段即可。技能执行函数我写得比较简单核心是一个安全的目录列举逻辑from pathlib import Path import time def execute_list_files(params: dict) - dict: base_path Path(params.get(path, .)).expanduser().resolve() if not base_path.exists() or not base_path.is_dir(): return { status: recoverable_error, message: f目录不存在或不可访问: {base_path}, data: None, } recursive params.get(recursive, False) max_depth params.get(max_depth, 2) results [] if recursive: for p in base_path.rglob(*): depth len(p.relative_to(base_path).parts) if depth max_depth: results.append(str(p)) else: for p in base_path.iterdir(): results.append(str(p)) # 控制结果体积避免撑爆上下文 if len(results) 50: results results[:50] truncated True else: truncated False return { status: ok, data: results, truncated: truncated, }这段代码有两个细节必须说明。第一是加了resolve()把相对路径变成绝对路径避免“当前目录”在不同调用环境下语义不一致。第二是结果超过50条就截断这个裁剪策略要不了多少代码却决定了上下文会不会被一个技能塞爆算是我这里的小心得。3.2 技能调用参数解析与校验参数解析不能用模型给的原始JSON直接执行。我在执行前加了一道pydantic校验同时把模型容易传错的地方用alias做了兼容比如模型经常把filepath传成path如果直接透传底层就会报错。更关键的一步是“自动修正”和“请求澄清”的分流。校验出错时我不会立刻让模型重复尝试而是把错误信息结构化后回填给模型。这个机制能显著减少无效重试from pydantic import ValidationError def parse_params(skill: AgentSkill, raw_params: dict) - dict: try: schema_cls skill.params_schema validated schema_cls(**raw_params) return {ok: True, params: validated.model_dump()} except ValidationError as e: errors e.errors() clarify_needed [] for err in errors: loc err[loc] msg err[msg] if err[type] missing: clarify_needed.append(f缺少必填参数: {loc}) else: clarify_needed.append(f参数 {loc} 校验失败: {msg}) return { ok: False, clarify: clarify_needed, # 保留原始参数方便模型定位问题字段 raw_params: raw_params, }这里我特意分离了“缺参数”和“参数格式错误”缺参数时模型应该向用户提问格式错误时模型应该尝试修正参数。两种处理动作完全不同一旦混在一起模型大概率会给出不合理的重试。3.3 与大模型对话循环的衔接方式技能系统真正进入工作状态是挂到对话循环里。我用了一个很朴素的循环结构用户请求进来后先让模型决定“是直接回复还是调用技能”如果是调用技能就执行技能并返回结果然后再让模型基于结果生成回复。中间有个重要环节是“结果回填前的清洗”上面提到超过50条要截断除此之外还要过滤二进制文件名、隐藏文件等噪声。这个清洗逻辑放在执行函数里会导致职责混乱所以我是放在主循环里的def run_agent_with_skill(user_message: str, skill_registry: dict, chat_fn): system_context build_system_context(skill_registry) messages [{role: system, content: system_context}, {role: user, content: user_message}] response chat_fn(messages) while response.get(tool_calls): for call in response[tool_calls]: skill_name call[function][name] raw_args json.loads(call[function][arguments]) skill skill_registry.get(skill_name) if not skill: # 未知技能名需要向用户说明 messages.append({ role: tool, tool_call_id: call[id], content: 未知技能请勿调用 }) continue parsed parse_params(skill, raw_args) if not parsed[ok]: content json.dumps({error: 参数校验失败, clarify: parsed[clarify]}, ensure_asciiFalse) messages.append({role: tool, tool_call_id: call[id], content: content}) continue result skill.execute(parsed[params]) messages.append({ role: tool, tool_call_id: call[id], content: json.dumps(result, ensure_asciiFalse), }) response chat_fn(messages) return response[content]这个循环的核心是保持“模型-工具-模型”的交替结构正确任何一步返回异常都必须在tool消息里说清楚而不是直接中断。很多技术团队在这里图省事把异常直接抛出来结果模型一看到报错就开始编理由问题越滚越大。4. 技能上线后的两件大事观测和评估技能接上之后麻烦才是真正开始。因为模型的调用行为是概率性的你不能拍胸脯说“这个技能一定会被正确触发”也没有任何测试环境能完全覆盖线上对话的开放度。所以我把重点放到了观测和评估上这两件事做得越早后期成本越低。4.1 记录“决策发生”不只记录“调用结果”大多数项目只会记录“技能被调用返回了什么”但对调用的决策过程是黑盒。我的做法是记录下面几类内容至少形成一个可回溯的“决策轨迹”触发前的用户消息和上下文摘要判断技能是否被该触发的依据。模型返回的技能名和参数对比真实TCP与理想调用的差距。校验结果特别记录参数被修正或缺失的场景能反推描述质量。执行模式是否重试、是否超时、是否触发回退。我做过一个日志字段模板每次技能调用都会追加一条JSONL记录关键字段如下{ session_id: uuid, ts: 1710000000, skill: list_files, trigger_type: model_decision, params_after_validate: {path: ., recursive: false}, result_status: ok, execution_ms: 85, raw_choices: [ {index: 0, finish_reason: tool_calls, tool_name: list_files} ] }这里有一个细节“执行模式”和“原始choices”必须分开记录。raw_choices主要反映模型在那一刻的概率分布而执行模式反映的是技能系统的实际响应表现。两个数据对照着看才能定位是模型没有触发还是触发了但系统执行失败。4.2 用回归集对抗技能退化任何一次技能描述改动、参数调整或模型版本升级都可能让原先正常的技能失效这就是所谓的技能退化。对付退化我建立了一套轻量回归集把过去一周线上遇到的、技能处理正确或失败的典型对话样本收集成若干条测试用例每次变更后先跑一遍回归集看技能触发率、参数正确率、最终回复满意率有没有变化。回归集不需要弄得特别复杂我直接用json文件维护大概30到50条覆盖常见意图和反例即不该触发技能的场景[ { user: 这个目录下面有什么文件, expected_skill: list_files, expected_params: {path: .} }, { user: 你觉得文件存哪里比较好, expected_skill: null, note: 咨询类问题不应触发技能 } ]跑回归集时我会把每次调用后的完整决策轨迹存下来方便对比新旧行为。这些视野一开始没建立起来等到线上出问题时只能靠用户反馈和被动的对话记录排查成本高好几倍。5. 我的经验总结想让技能真正被用起来这些地方别偷懒技能系统的文档、博客、开源项目看了很多都说“让Agent调用工具”但很少说清楚“为什么模型就是不按你写的描述走”。最后把我自己的几段经验做个总结能少走一些弯路。5.1 技能命名与描述里的“玄学”其实是工程问题命名看似小事其实影响很大。我早期的技能叫explore_filesystem模型识别率一般改成list_files_in_directory之后识别率有了肉眼可见的提升。后来我总结原因是模型对动词短语的联想更强命名里最好把“动作”和“对象”同时写清楚避免抽象名词。触发优先级也要小心处理。当多个技能都可能覆盖同一场景时比如“列出文件”和“列出目录结构”要在描述里明确写“另一技能适用于X本技能适用于Y”否则模型会随机选择。别看这只是一句话它能直接把冲突调用率降一半。5.2 技能颗粒度怎么定颗粒度太细技能数量爆炸模型在决策空间里容易迷路颗粒度太粗一个技能内部杂糅多个动作参数复杂校验困难。参考经验是这样当三个场景之间参数重合度超过70%时就合并成一个技能内部通过参数分支实现低于70%时拆开成独立技能并且在描述里交叉指向。举个例子list_files和list_directory_structure参数都是path和depth只是输出形态不同就合并成explore_directory而“统计文件占用空间”因为输出逻辑完全不同就拆成独立技能。这是个反复权衡的过程没有绝对标准但对着一批真实对话调几轮颗粒度会自然收敛。5.3 这是我踩过的最大的一个坑最后说说我印象最深的一次事故。有一回我调整了一个技能的前置判断逻辑加了“必须先从用户消息中提取目录路径”的条件本来是为了减少误触发结果上线后整整半天这个技能几乎没有被触发过。查日志才发现模型的tool_call里大量出现了该技能但参数校验一直报missing不断重试后模型选择了直接回复用户“不建议查询”之类的兜底话术。问题根源是我把校验逻辑加得太激进而没有先检查模型当前是否能稳定产出达到校验标准的参数。技能系统里每一道约束都是有成本的约束削弱自主性约束太弱又导致误触发。后来我在每次策略变更后都会先跑一遍回归集确认约束的代价可接受再推到线上。另外提一句agent-skills这类系统后续值得扩展的方向还有不少技能间依赖编排、技能自动评估打分、多版本A/B对比。核心思路不变那就是让技能的“描述、触发、执行、观测”形成一个闭环而不是去堆更多函数和工具。我在生产环境里用这套闭环逻辑技能的可控性和可维护性都提升了一截可以放心地把更多自主性交给模型。