我最近在重构手头的 Agent 项目时把一堆散落的工具函数全部按技能重新组织了一遍顺手起名叫agent-skills。这个决定看似只是换了个封装层实际把整套开发节奏都改变了——之前每加一个新工具都要重新调 Prompt、改参数描述、处理重复的校验逻辑现在只要按技能规范写一个文件夹Agent 就能自动识别、正确调用。这篇文章就把我梳理出来的设计思路、目录规范、运行时逻辑和踩坑记录完整摊开来讲给正在纠结怎么让 Agent 的能力更模块化的朋友一个可直接落地的参考方案。1. 为什么 Agent 的能力不能只靠堆工具函数很多团队在 Agent 项目初期都会走同一条路把外部能力封装成一个 Python 函数塞进 tools 列表让大模型按 function calling 的规范去调用。一开始只有三五个工具时这套做法没任何问题。但加到二三十个之后你会明显感觉到几件事开始失控。1.1 工具数量膨胀后模型开始选错工具一旦超过十几个大模型在每次请求里对全部工具做意图匹配的负担会成倍增加。实测中我发现工具描述写得太短的模型会忽略写得太长的模型又会过度关注无关细节。更麻烦的是有些工具本身就存在能力重叠比如搜索新闻和搜索股票资讯模型经常会把用户意图导向错误的那一个。技能化改造的核心思路不是消灭这些工具而是给工具加一层元数据语义——每个技能必须明确声明自己的能力边界、适用场景、前置条件和返回格式。模型在选择时不再是看一堆扁平函数名而是看一份结构化的技能清单匹配精度会明显提高。1.2 参数校验和错误处理成了重复劳动传统函数列表还有一个痛点每个函数的入参校验、超时处理、异常重试逻辑都是各写各的。结果是代码风格五花八门有的抛异常有的返回错误字符串大模型面对这些不一致的返回格式很容易产生误判——它把一段错误文本当成了正常结果往下游传。技能仓库把错误处理收敛成了统一的运行时协议。每个技能只负责正常情况下的输出所有异常、超时、限流一类的事情由运行时拦截转换成 Agent 能理解的标准化错误码。这样模型拿到的信息永远是一致的它就知道该报错还是该换个方案继续。1.3 技能化带来一个额外红利可测试性普通工具函数想单独测你得 mock 一堆外部依赖。技能化之后每个技能自带一个最小验证集——一组输入输出样例和一个烟囱测试脚本。我后来每次改完某个技能的基础逻辑跑一遍验证集就知道有没有破坏模型侧的行为约定。这一点后面在实操部分会细讲。先说结论Agent 技能化不是用新的包装概念取代旧架构而是把工具函数Prompt 描述这种散装结构升级成技能描述参数 Schema实现体验证集的完整单元让 Agent 的行为边界从隐式变成显式。2. agent-skills 技能的目录设计与命名规范我第一个落地的模块就是技能的目录结构。这一层不设计好后面所有技能的复用和检索都会乱套。我最终采用的是按能力域分层、按领域动作命名的双轨结构。2.1 目录层级决定了技能的检索效率顶层目录按能力域划分比如web、data、code、communication。每个能力域下再按具体场景拆技能文件夹。这样一个技能文件的路径看起来是agent-skills/ ├── web/ │ ├── fetch_webpage/ │ │ ├── SKILL.md │ │ ├── schema.json │ │ ├── implement.py │ │ └── test_cases.json │ ├── search_keyword/ │ │ ├── SKILL.md │ │ ├── schema.json │ │ ├── implement.py │ │ └── test_cases.json这个结构看着简单但有一个设计细节很关键每个技能必须拥有一个唯一的技能全名格式是域_动作比如web_fetch_webpage。技能全名会传给 Agent 作为调用标识所以一旦定下来就不要改——我刚开始没意识到这一点改过一个技能名导致所有历史会话里的轨迹全部失效。2.2 SKILL.md 才是模型真正读的说明书很多人的技能说明只写一句这是什么其实远远不够。在我这套规范里SKILL.md 必须包含四个字段模块技能目标Goal说清楚这个技能解决了什么问题最好写半句话的场景描述。调用条件When to use明确什么情况下才该选它同时用反面清单说明什么情况下不该选。输入参数说明Inputs每个参数的语义、类型、取值范围、示例值。输出格式说明Outputs正常输出长什么样以及错误码约定。举一个我实际写的例子。web_fetch_webpage的 SKILL.md 里When to use部分明确写了当用户需要获取某个 URL 的正文内容时使用当用户只是提到某个网站名但没有具体 URL 时不要使用应该改为web_search_keyword。这句话直接避免了两个工具打架的问题。## Goal 获取指定 URL 的网页正文并提取标题和纯文本内容。 ## When to use - 用户提供了完整的 HTTP/HTTPS 链接并且明确要求读取页面内容时。 - 用户要求总结、翻译某个网页时。 ## When not to use - 没有完整 URL 时使用 web_search_keyword。 - 用户需要的是页面里的某张图片而不是正文时使用 web_fetch_image。 ## Inputs - url: string, 必填, 需要抓取的完整链接 - css_selector: string, 可选, 限定提取区域 ## Outputs 成功时返回 JSON: { title: string, content: string } 失败时返回错误码: FETCH_TIMEOUT / PAGE_NOT_FOUND / EMPTY_CONTENT有了这份说明模型在意图判断时获得的信息量远大于一个工具函数名加一段描述。它知道什么情况该用我更重要的是它知道什么情况不该用我。2.3 schema.json 决定参数的机器可读性SKILL.md 是给人以及给 Agent 的语义层看的schema.json 是给运行时校验看的。我用的是 JSON Schema 格式因为主流 Agent 框架都对它天然支持。{ type: object, properties: { url: { type: string, description: 需要抓取的完整 URL, pattern: ^https?:// }, css_selector: { type: string, description: 限定的 CSS 选择器可选 } }, required: [url], additionalProperties: false }这里想提醒一个实战经验additionalProperties一定要设成false。否则模型偶尔会自己脑补一个参数传进来如果实现体里没有处理轻则报错重则静默忽略导致结果缺失。我遇到过模型自作主张传了个headers参数的情况就是因为没限制这个字段。3. 一个技能从定义到注册的完整落地过程说完了设计规范这部分把它们串起来走一遍实际的落地流程。我以code_run_python这个技能为例讲讲我是怎么从零到一把它做好并注册进 Agent 的。3.1 先写实现体再回写描述很多人的顺序反了先写工具函数再补描述。我的建议是先定义清楚输入输出再写实现体最后根据实现体的行为打磨描述。code_run_python的实现体逻辑不复杂接收一段 Python 代码在受限环境里执行捕获标准输出、标准错误和返回值。但我特意设计了一个细节——超时控制用multiprocessing而不是threading因为 Python 的线程无法强制杀死死循环只有进程能做到。import multiprocessing import io import sys import contextlib def _run(code, result_dict): stdout, stderr io.StringIO(), io.StringIO() try: with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): exec(code, {__builtins__: __builtins__}) result_dict[output] stdout.getvalue() result_dict[error] stderr.getvalue() result_dict[success] True except Exception as e: result_dict[output] stdout.getvalue() result_dict[error] str(e) result_dict[success] False def run_python(code: str, timeout: int 10) - dict: ctx multiprocessing.get_context(spawn) q ctx.Queue() p ctx.Process(target_run, args(code, q)) p.start() p.join(timeout) if p.is_alive(): p.terminate() return {success: False, error: TIMEOUT} return q.get()这段代码有一个值得注意的坑默认multiprocessing在不同操作系统上的启动方式不同我在 macOS 上必须显式指定spawn否则在 Jupyter 或者某些交互式环境里会反复触发子进程递归创建。3.2 验证集的构建比想象中更重要技能注册进仓库后每次修改都可能引入行为变化。为了能快速回归我给每个技能配了test_cases.json。结构是样例输入加期望输出的列表[ { input: { code: print(11) }, expect: { success: true, output: 2\n } }, { input: { code: import time; time.sleep(5) }, expect: { success: false, error: TIMEOUT } } ]这两个样例覆盖了两条最关键的路径正常执行和超时拦截。现在我要求新增技能时至少提供三个样例正常路径、边界路径、异常路径。少了异常路径的测试等于没测。3.3 注册进 Agent 时的加载逻辑技能的注册我用了一个简单的自动发现机制启动时扫描技能目录读每个文件夹里的SKILL.md、schema.json和实现体统一注册到运行时注册表。这里有个关键设计——描述要分段预拼装而不是直接把整份 SKILL.md 原样丢给模型。我的做法是把 SKILL.md 拆成几段注册时按模型上下文窗口动态决定送多少。def build_skill_descriptor(skill_path: str) - dict: with open(f{skill_path}/SKILL.md, r) as f: content f.read() sections content.split(\n## ) brief sections[0].strip() when_to_use [s for s in sections if s.startswith(When to use)] descriptor f{brief}\n\n{ .join(when_to_use)} return { name: f{Path(skill_path).parent.name}_{Path(skill_path).name}, description: descriptor, parameters: json.load(open(f{skill_path}/schema.json)), impl: import_func(skill_path), }这里我没有把Outputs部分放进描述发给模型因为运行时已经严格按返回值解析了模型不需要预知全部细节只需要知道这个技能是否存在、什么时候调用。4. 技能调用链上下文组装与多技能协同技能注册完成只是第一步真正决定 Agent 表现的是调用链路上的细节。技能怎么被选中、选中后上下文怎么组装、多个技能怎么串联这些环节的打磨对最终效果影响极大。4.1 动态技能选择策略把所有技能描述一次性塞进每次请求是最简单但最浪费的做法。当技能库大于 30 个时提示词里的工具描述会占用大量 token而且模型在长长的描述列表里的检索精度会显著下降。我采用的是两阶段选择第一遍用轻量的关键词和向量检索从全量技能里召回 top 10 候选第二遍把候选技能的完整描述送进模型做最终选择。def select_skills(query: str, top_k: int 10) - list[str]: query_vec embed(query) scores {} for name, skill in SKILL_REGISTRY.items(): score cosine_similarity(query_vec, skill.embedding) keyword_hit any(k in query for k in skill.keywords) scores[name] score (0.2 if keyword_hit else 0.0) return sorted(scores, keyscores.get, reverseTrue)[:top_k]这里有个小心机我把技能名的关键词匹配加了一个固定权重加成。原因是纯向量检索对术语的敏感度不够比如用户说跑一下这段代码时向量可能更接近code_run_python之外的某个技能但关键词运行能帮着拉回正确方向。4.2 技能间数据传递的中间态设计Agent 的多技能协同常见模式是 A 技能的输出当作 B 技能的输入。比如先用web_search_keyword搜索到相关 URL再用web_fetch_webpage抓取详情。这个链路里最容易出问题的是中间态的数据传递。模型拿到web_search_keyword的返回后会把 URL 写入到思考过程中然后模型再在下一轮调用web_fetch_webpage时传参。问题在于web_search_keyword的返回格式如果不严格模型很容易提取出错。我在技能返回结构里统一定义了一个item数组每个搜索条目的 URL 都放在item.url模型按路径提取就非常稳定。{ items: [ { title: xxx, url: https://example.com/page1 }, { title: yyy, url: https://example.com/page2 } ] }这种约定很重要返回结构里有歧义Agent 在后续环节就会走偏而且排查起来特别费劲。4.3 上下文窗口的分配策略每次调用技能前后的对话历史上限也需要动态控制。我的做法是给每轮技能调用分配一个上下文预算记录它与周边历史的关联度。当用户在同一话题上连续触发多个技能时历史窗口会被完整保留一旦话题切换我就把旧上下文做摘要压缩只保留摘要和最近的技能结果。def compress_history(history: list[dict], max_tokens: int 4000) - list[dict]: # 统计每条消息的 token 占用 cost [message_tokens(m) for m in history] if sum(cost) max_tokens: return history summary_parts [] kept [] for msg, c in zip(history, cost): if sum(message_tokens(m) for m in kept) c max_tokens: kept.append(msg) else: summary_parts.append(msg[content]) summary summarize_text(\n.join(summary_parts[:5])) return [{role: system, content: f早期对话摘要{summary}}] kept[-10:]别小看这一步不压缩的话技能一多上下文很容易被历史消息撑爆后面的会话质量直线下降。5. 实测中的失败模式与调优记录技能库跑到一定规模后视角从设计转移到了运维。我把这几个月实际跑出来的问题分成三类每一类都有对应的调优手段下面挑最典型的几个记录一下。5.1 模型选错技能描述重叠问题这是最常遇到的问题而且越到后期越明显。两个技能描述里都出现了获取网页信息时模型的选择会出现随机性有时选 A 有时选 B但实际用户意图只有一个是正确的。后来我搞了一轮技能描述互审原则是每个技能的描述里必须显式写出本技能不处理什么。比如web_fetch_webpage里写明本技能不处理网页搜索搜索请调用 web_search_keyword反过来也一样。加了这层互斥声明后选择准确率大概提升了十几个百分点。5.2 模型传参不合法schema 策略过于严格前面我强调了additionalProperties: false的好处但同样需要提醒的是参数描述不能过于严格。比如code_run_python的timeout参数我最初限定的是默认 10 秒最大值 30 秒模型有时会犹豫传不传。后来我把这个参数直接从必填改成可选并说明不传时自动使用默认值模型的决策负担小了很多。参数描述遵循一条原则能不给模型选择权的就不给它选择权。必填项越少模型传错参数的概率越低。5.3 技能执行慢导致 Agent 整体超时一次任务里如果连续调度了三四个技能而每个技能执行都要 5 秒以上用户的等待时间就很难接受。我除了把超时时间调短之外还加了一层技能级缓存对幂等且结果可复用的技能按输入参数哈希做结果缓存。def cached_call(skill_name: str, input_json: dict, ttl: int 300): key f{skill_name}:{hash(json.dumps(input_json, sort_keysTrue))} if key in CACHE and time.time() - CACHE[key][ts] ttl: return CACHE[key][result] result SKILL_REGISTRY[skill_name][impl](**input_json) CACHE[key] {ts: time.time(), result: result} return result缓存最适用于web_fetch_webpage这种页面内容短时间不变的场景。我实际统计过加了缓存之后好几类高频任务的响应耗时有明显下降。5.4 排错专用技能轨迹可视化技能一多用户说一句含糊的话Agent 可能连续调错了三次技能。这种时候最需要的不是看日志文本而是看技能调用轨迹。我给运行时加了一个轻量的轨迹记录器把每次调用的技能名、参数、返回状态按时间顺序串起来形成一条可回放的事件链。def trace_step(step: dict): trace { time: datetime.utcnow().isoformat(), skill: step[skill_name], args: step[args], result_code: step[result_code], } TRACE_BUFFER.append(trace)排错时打开轨迹一眼就能看出模型是不是在重复无效调用比如同一技能连着调了三次都传同样的参数说明上下文组装环节有问题而不是技能本身的问题。6. agent-skills 后续可扩展的方向技能库的搭建不是一劳永逸的工程。我目前正在规划的下一个扩展方向是技能间依赖关系管理和技能组合模板这里简单分享几个思路。技能依赖图在 SKILL.md 里增加depends_on字段声明前置技能。比如web_fetch_webpage可以声明如果 URL 来源不明可选前置web_check_safety。运行时根据依赖关系自动插入校验步骤。技能组合模板把固定的多技能调用序列封装成流程技能比如调研一个行业可以是web_search_keyword加web_fetch_webpage加data_save_table的组合。这样可以从底层技能逐步构建更上层的业务能力。多实例技能同一套实现体配不同的参数默认值注册成不同实例。比如web_fetch_webpage可以有一个桌面版 UA实例和一个移动端 UA实例适用于不同场景。技能自动评估为每个技能加入长期的线上表现指标采集包括调用次数、成功率、被模型误选的次数。按周汇总用来发现描述越来越模糊的技能。我个人刚落地了组合模板的第一版实际感受是Agent 在高频重复任务上的稳定性提升明显因为模型不需要再在思考阶段反复推导该调哪个技能、按什么顺序调而是直接匹配模板。最后再分享一个我踩过的坑技能库的版本管理一定要跟上。我之前调过某个技能的描述和实现但没更新测试样例结果模型行为变了验证还一直通过出了事故才发现是旧样例已经覆盖不了新逻辑。现在我的规矩是改技能可以但必须同时更新对应的test_cases.json验证集通过后技能才算修改完成。这条规矩的性价比极高建议所有做技能库的朋友都照做一遍。