
“agent-skills”这个词最近一阵在Agent工程圈子里出现的频率越来越高了。我第一次看到它的第一反应是这不就是把提示词拆出来挂在某个目录下让大模型当插件调用吗真动手做过一轮之后才发现根本不是这么回事。技能模块化这件事表面上是文件组织问题骨子里是Agent架构的职责边界问题、调用链生命周期问题以及上下文预算分配的工程取舍。这篇文章我不想讲那些大而全的Agent框架只想从我在一个本地知识库问答项目里落地agent-skills的真实过程讲起怎么设计一份技能协议怎么把复杂任务包装成可执行的“子智能体”怎么做技能注册和参数校验以及最后怎么把整个调度压到本地模型可接受的时延范围内。如果你是团队里负责Agent编排、工具调用层或模型落地的工程师又或者你正在被“工具太多了、提示词乱成一锅粥、上下文动不动就爆”这些问题折磨那这篇文章应该对你有用。1. 先想清楚一个前提技能为什么不能全塞进提示词里动手设计agent-skills之前我先做的不是写代码而是把“为什么非要这层抽象”这个问题想透。只有想明白了瓶颈在哪后面做出来的东西才不会变成另一堆花架子配置。1.1 纯文本提示词方案的三个瓶颈我最早的第一版Agent指令全部写在系统提示词里工具描述也全塞在里面代码大概长这样SYSTEM_PROMPT 你是智能助手。你可以执行以下操作 1. 搜索本地文件工具名: search_files参数: query(str), path(str) 2. 读取文件内容工具名: read_file参数: path(str), line_start(int), line_end(int) 3. 执行Python代码工具名: run_python参数: code(str) 4. 搜索网页工具名: web_search参数: q(str) ... 刚开始只有五六个工具的时候这套方案没问题。但当工具数量上到二十几个每个工具描述还带example的时候三个问题会同时爆发。第一个是上下文浪费。系统提示词本身就要占token工具越多提示词越长留给真正对话和检索内容的预算就越少。我实际量过一组数据工具描述加示例大概占了每轮请求context的18%到23%而我最终真正用到的工具通常只有两三个。花了大价钱把几千个token塞给模型它一次只看其中一小部分。第二个是冲突和漂移。工具的调用参数如果设计得不够统一模型在长指令里经常搞混参数名比如把search_files的query传到read_file里去。这类错误不是模型笨而是提示词里的信息密度太高模型在做工具选择时等同在考场上做一道超长阅读理解。第三个是维护性问题。任何一个工具描述改了整个系统提示词都要跟着改涉及到多个Agent时还要同步修改多处副本。版本回滚更是难受经常出现“明明改的是A机器人结果B机器人的响应风格也变了”这种诡异情况。后来我才意识到问题出在共享了同一份System Prompt。1.2 技能模块化真正要解决的任务边界把技能拆成独立模块真正要回答的问题不是“把字符串放到哪里”而是模型在什么情况下应该加载哪段指令、那段指令里的工具参数边界是什么、执行逻辑由谁来实现。所以agent-skills在我的项目里被定义为一组“自带说明书、自带执行器、自带参数校验规则”的功能单元每个技能都遵循同一个协议。大模型的核心提示词只保留基础角色设定和对话规则所有具体能力以技能方式挂载用的时候才加载用完就从上下文里卸载。这样设计后提示词长度从原来的几千token降到几百token而且各个技能可以独立开发、独立测试、独立更新互不污染。还有一个很容易被忽略的好处技能模块化之后我可以在不同项目间直接复制技能目录。比如文件搜索技能写好了在知识库项目里能用换到代码审查项目里也能用只需要改一下允许搜索的根路径。这种复用价值才是技能体系真正的意义所在而不是单纯给提示词减减肥。2. 设计技能执行接口少一些“万能函数”多一些窄接口把“技能”从概念变成代码第一步是定协议。我见过很多团队这一步就翻车了最常见的做法是把每个技能实现成一个“万能函数”接收一个巨大的kwargs字典然后在函数内部用if-else去判断要做什么。这种设计一开始写起来痛快后面让你哭的地方可太多了。2.1 一份可落地的技能协议get_instructions与execute我最终采用的协议非常简单每个技能是一个目录目录下至少有3个文件skills/ ├── __init__.py ├── calculator/ │ ├── instructions.md │ ├── executor.py │ └── schema.py └── file_search/ ├── instructions.md ├── executor.py └── schema.py一个技能的核心是三个函数get_instructions()、get_schema()、execute(arguments, context)。# skills/file_search/executor.py from typing import Any, Dict SKILL_NAME file_search def get_instructions() - str: 返回给大模型看的技能使用说明这块内容只在需要调用本技能时才注入上下文 return 当用户需要在本机查找文件时使用。 1. 调用参数query为文件名关键词path为搜索根目录。 2. 如果用户没有指定目录默认path为当前项目根目录。 3. 返回结果包含文件路径和修改时间按修改时间倒序排列。 def get_schema() - Dict[str, Any]: 返回技能参数JSON Schema用于大模型生成结构化参数和运行时校验 return { type: object, properties: { query: {type: string, description: 文件名关键词}, path: {type: string, description: 搜索根目录默认为项目目录}, max_results: {type: integer, description: 最多返回几条结果, default: 10} }, required: [query] } def execute(arguments: Dict[str, Any], context: Dict[str, Any]) - Dict[str, Any]: import os, time, glob root arguments.get(path) or context.get(workspace_root, .) pattern f**/*{arguments[query]}* results [] for p in glob.glob(os.path.join(root, pattern), recursiveTrue): if os.path.isfile(p): results.append({path: p, mtime: os.path.getmtime(p)}) results.sort(keylambda x: -x[mtime]) results results[:arguments.get(max_results, 10)] return {results: results}这里有个关键点get_instructions返回的指令只在需要调用该技能时才被放进提示词。我管这个叫“指令按需注入”也是后面能压住上下文长度的核心原因。刚开始做agent-skills的人最容易忽略的是context参数执行器往往只依赖调用参数导致很多任务必须把所有信息都塞进参数里传给模型又变回上下文爆炸的老路。我的做法是把工作区路径、用户身份、会话历史摘要之类的基础上下文统一放到context里参数里只放任务本身的名词性描述大模型生成的参数就干净很多。2.2 “窄接口”为什么比“万能接口”更适合大模型调用窄接口的意思是一个技能只做一件清晰的事参数尽量少语义尽量明确让模型一眼就知道该传什么。例如文件搜索这个技能就到路径拼接和glob匹配为止不做文件内容解析不返回摘要更不做提炼关键词的联动。后面这些功能交给别的技能处理如果大模型判断确实需要它会依次调用多个技能来完成一个复杂任务。如果你把文件搜索做成“搜索摘要情感分析”的万能接口参数数量会迅速膨胀到十个以上模型漏参、错参的概率会成倍增加。这不是模型能力不行而是任务边界模糊导致参数空间过于复杂。窄接口本质上是在帮模型缩小决策空间降低每一步工具选择的难度。这一条我在后面多轮调用数据里也得到了验证窄接口技能的参数解析成功率比宽接口技能高不少。实际操作中我给自己定了一条规矩一个技能描述里只允许出现一个核心动词和最多三个核心名词参数。动词超过一个就说明要拆技能参数超过三个就要想想哪些能放进context、哪些能通过默认值解决。3. 用子智能体包装复杂技能当工具节点本身也需要推理时技能协议能解决“简单工具的标准化”但真实项目里还有一类更麻烦的技能表面上看是一个动作实际上需要内部走完一整套决策流程。比如“对一份新文档生成摘要并归档到指定知识分类”单靠一个函数很难优雅完成因为你要模型自己判断分类又要模型写摘要摘要风格还可能随文档类型不同而不同。3.1 什么情况下必须把技能升级成子智能体我判断该不该把技能升级成子智能体的标准很简单看这个技能在执行过程中需不需要“自我决策”。如果技能内部只是循环处理固定规则用函数就够了如果内部要根据输入内容动态决定下一步动作比如读文件、判断类型、换不同的摘要模板那就该换子智能体方案。子智能体的本质是把一个复杂技能包装成带独立上下文的Agent节点。它有自己的系统提示词、自己的工具集和自己的对话循环对外只暴露统一的execute(arguments, context)接口。主智能体只需要知道“我有一个技能可以完成文档归档”至于技能内部读了几次文件、调用了几轮模型主智能体完全不用关心。这种分工方式和团队里的“专家外派”很像需要专业判断时把整件事外包出去拿到结果就好。在agent-skills架构里引入子智能体还有一个额外的收益技能内部的推理过程不会污染主智能体的上下文。文档分类和摘要生成的推理走的是子智能体自己的上下文窗口结束后只把最终结果以小段文本返回给主智能体。从主智能体的角度看这次技能调用只是一个智能函数输入是文档路径输出是归档位置和摘要。3.2 一个子智能体执行器的实现示例我基于上面的思路实现了一个轻量子智能体执行器没有引入重型框架核心就是拿到参数后自行构建临时的system prompt和消息历史走同样的模型接口完成多轮推理。# skills/doc_archiver/executor.py import asyncio from typing import Any, Dict SKILL_NAME doc_archiver def get_instructions() - str: return 当用户需要将文档归类并生成摘要时使用。 1. 调用参数必须包含target_path指向完整文件路径。 2. 归档目录固定为archive_root下的{类别}/{年}/{月}。 3. 类别只能从: project_docs, meeting_notes, research, misc 中选择。 4. 摘要输出不超过200字必须包含核心结论。 def get_schema() - Dict[str, Any]: return { type: object, properties: { target_path: {type: string, description: 需要归档的文档完整路径}, archive_root: {type: string, description: 归档根目录默认取context中的archive_root} }, required: [target_path] } async def execute(arguments: Dict[str, Any], context: Dict[str, Any]) - Dict[str, Any]: import os, shutil, datetime from pathlib import Path from model_api import chat_completion # 统一模型接口 file_path Path(arguments[target_path]) archive_root Path(arguments.get(archive_root) or context[archive_root]) # 第一轮读取文件让子智能体判断分类 content file_path.read_text(encodingutf-8)[:3000] sub_messages [ {role: system, content: 你是文档分类助手只输出一个类别词。}, {role: user, content: f文档内容\n{content}\n\n请从project_docs/meeting_notes/research/misc中选一个分类} ] category_resp await chat_completion(messagessub_messages, temperature0.2) category category_resp.strip() today datetime.date.today() dest_dir archive_root / category / str(today.year) / f{today.month:02d} dest_dir.mkdir(parentsTrue, exist_okTrue) dest_file dest_dir / file_path.name shutil.move(str(file_path), str(dest_file)) # 第二轮让子智能体生成摘要 sub_messages.append({role: assistant, content: category_resp}) sub_messages.append({role: user, content: 请生成不超过200字的摘要必须包含核心结论。}) summary_resp await chat_completion(messagessub_messages, temperature0.4) return {category: category, dest_path: str(dest_file), summary: summary_resp.strip()}注意这里有个细节子智能体执行器里的chat调用我复用了和主智能体相同的模型接口但temperature设得不一样。分类任务用低温度追求确定性摘要生成用略高一点的温度保持表达自然。同一套技能接口可以给我足够的自由度去给内部决策配置不同参数这才是子智能体方案比普通函数优雅的地方。子智能体方案唯一让我犹豫过的是成本和时延。多轮调用必然比单次调用慢但在归档这种对时延不敏感的场景里多花两三秒换回的结果质量是值的。如果你在做一个必须实时响应的技能就不要执迷于子智能体老老实实把内部决策固化成规则用普通函数实现。4. 技能注册与参数校验把“能用”变成“稳定用”技能协议和子智能体方案定下来后项目迎来一个幸福的烦恼技能目录越来越多文件搜索、文档归档、代码格式化、数据库查询、邮件草稿生成……几十个技能堆在同一个目录里调度器该怎么知道自己有哪些技能可用大模型该从哪里知道技能的最新参数定义一个团队多人协作时怎么避免技能写法和参数风格五花八门4.1 技能注册表结构与加载时机我的解决办法是给所有技能加一个统一的注册机制。每个技能目录里增加一个skill.yaml里面标注技能名称、版本、作者、描述、入口模块和默认超时时间。调度器启动时扫描所有技能目录把skill.yaml解析成一张注册表。# skills/file_search/skill.yaml name: file_search version: 1.2.0 description: 按文件名关键词搜索指定目录返回路径和修改时间 entry: executor.py timeout: 15 tags: [local, search] allowed_roots: [/workspace, /home/user/docs]加载时机也很讲究。如果所有技能都提前加载到内存里几十个技能的instructions加起来照样是几万token前面省的上下文又回来了。所以我在注册表里只保留技能的“元信息”包括名称、一句话描述和必要的tag。大模型在做工具选择时看到的是一份精简的技能列表只包含技能名和一句话说明。真正要把某个技能的instructions注入上下文是在模型表现出意图之后。这一步的感受是agent-skills设计里最容易被忽略的效率点其实不在执行阶段而在“发现阶段”。如果一个技能描述写得过长模型在扫描整个技能列表时就会花更多的注意力也更容易被不相关信息干扰。注册表里一句话描述必须说清楚“这个技能什么时候用”其他细节全部放到instructions里延迟注入。4.2 参数校验、依赖声明与安全边界技能参数严格说需要两道校验。第一道是模型生成参数阶段的schema约束我让大模型按JSON Schema格式输出参数生成后再做一次程序化校验防止模型幻觉出不存在的关键字。第二道是执行前的运行时校验包括路径是否在允许范围内、文件是否存在、代码是否有执行权限等。def validate_arguments(schema: Dict[str, Any], args: Dict[str, Any]) - List[str]: errors [] required schema.get(required, []) for field in required: if field not in args or args[field] in (None, ): errors.append(f缺少必填参数: {field}) for key, meta in schema.get(properties, {}).items(): if key in args: expected meta.get(type) if expected string and not isinstance(args[key], str): errors.append(f参数 {key} 应为字符串) if expected integer and not isinstance(args[key], int): errors.append(f参数 {key} 应为整数) return errors这个函数看起来不复杂但它是把技能从“在特定模型上碰巧能用”变成“在模型迭代后依然稳定”的关键。我遇到过模型升级后某次调用把max_results传成了字符串如果没有运行时校验这个错误会一路透传到glob的slice逻辑里返回结果异常排查半天还以为是技能代码本身的问题。现在所有技能入口统一走校验函数任何参数问题在入口直接卡住并返回给模型修正。安全边界也要在注册阶段声明而不是在执行时碰运气。比如file_search的allowed_roots字段限制了搜索路径只能落在工作区目录内exec_code类技能必须声明allow_network: false。调度器在执行前会检查传入参数是否越界越界就直接拒绝不给模型一个“绕过边界”的机会。依赖管理方面我给每个技能准备了一个requirements.txt安装器会在激活技能时检查并安装缺失依赖。这里有个值得分享的坑不要用“全局安装所有技能依赖”的方式因为不同技能的依赖可能冲突。我实际遇到过A技能要求numpy版本小于2.0B技能要求大于等于2.0放到同一个环境里互相打架。后来改成每个技能一个轻量虚拟环境代价是第一次调用会慢一点但环境隔离带来的稳定性远大于这点性能损失。5. 把时延压下去流式任务队列与按需加载agent-skills这块拼图到了这里功能上已经完整了但我真正在项目里花掉最多时间的地方是性能和稳定性调优。本地部署的Agent模型跟云端商业模型不一样显存有限、推理速度慢、并发能力弱如果技能调度器不做控制分分钟把服务打满。5.1 本地模型的并发瓶颈与worker调度本地模型服务最常见的问题是并发把显存挤爆。多个技能被先后触发时如果调度器不管不顾地同时发起推理模型服务端会进来大量并发请求导致显存溢出或者推理互相排队超时。我的方案是给技能执行套一个流式任务队列每个模型推理请求按顺序进入worker池而worker池的数量根据显卡显存动态调整。import asyncio from collections import deque class SkillExecutor: def __init__(self, max_concurrency2): self.max_concurrency max_concurrency self._queue deque() self._workers [] async def submit(self, skill_name, arguments, context): future asyncio.get_event_loop().create_future() self._queue.append((skill_name, arguments, context, future)) return await future async def run(self): for _ in range(self.max_concurrency): worker asyncio.create_task(self._worker_loop()) self._workers.append(worker) async def _worker_loop(self): while True: if not self._queue: await asyncio.sleep(0.05) continue skill_name, arguments, context, future self._queue.popleft() try: result await self._dispatch(skill_name, arguments, context) future.set_result(result) except Exception as e: future.set_exception(e)这个队列的价值不只是限制并发。它还是一个天然的背压机制当任务太多时新的请求会排队而不是把系统打崩。我把最大并发数设成2实测在单卡部署的模型上推理吞吐反而比并发5的时候更高因为显存不撞了每个请求的batch也更稳定。如果你在云端用商业模型API这个队列可以考虑不放那么紧但要加一个基于时间的超时重试很多模型API偶尔会慢直接在超时后重试比一直等更现实。调度器还有一个优先级策略主智能体的第一轮响应永远是最高的优先级技能子任务其次后台预加载类任务最低。这样可以保证用户的第一字响应时间尽量短技能调用产生的等待不阻塞主对话。5.2 用eval scope按需加载压缩上下文性能优化里另一个大头是上下文压缩。技能注册表虽然避免了把所有技能描述都塞进提示词但多技能联动时主上下文里还是可能同时出现好几个技能的instructions。一个文档归档任务运行完两轮之后主上下文里残留了大量技能指令和子智能体返回的中间结论再接新问题时成本非常高。我的办法是引入一个eval_scope的概念。每个技能调用有自己的临时上下文栈技能运行结束后会把“对后续对话仍然有用的结论”显式写到主上下文摘要里而把完整的执行过程细节留在日志中不再污染主上下文。过去的完整对话历史会交给一个压缩模块定期把不重要的工具调用轨迹折叠成一行摘要。这个做法让一个长会话在连续使用二十多项技能后上下文占用仍然能控制在一个比较健康的水平。举一个具体数字感受一下优化前一个“查找文件→读取内容→生成摘要→归档”的四步流程累计消耗的context token大约在16000到22000之间。优化后主上下文只保留归档结论和摘要中间的查找路径、文件内容、分类推理全部被折叠一轮完整的同类任务消耗只有7000到9000 token节省超过一半。这个数字在云端API项目里可能只是省点钱但在本地模型固定显存的场景里可能就是“能跑”和“跑不动”的区别。6. 可观测性技能轨迹、性能画像与回放调试技能多了以后你迟早会遇到一个场景用户在对话里说“帮我找一下上个月开会提到的预算表”系统先调用了文件搜索又调用了文档解析最后调用了归档技能结果用户反馈说“这不是我要的东西”。这时候如果没有记录技能调用轨迹你几乎没法定位是哪一步出了问题。6.1 技能调用轨迹的关键字段与回放我给技能调度器加了一个极简的轨迹日志每次技能调用都会记录下面几个字段技能名称、版本、输入参数、输出摘要、调用耗时、模型token用量、校验是否通过、错误信息。日志会写进结构化文件中使用时按会话ID聚合成一条调用链回放时能看到整个任务流。def log_skill_call(record: dict): import json, time with open(logs/skill_trace.jsonl, a, encodingutf-8) as f: record[ts] time.time() f.write(json.dumps(record, ensure_asciiFalse) \n)这条日志文件看似简单但排查问题的效率提升是立竿见影的。有一次用户反馈文档摘要风格突然变了。回放轨迹后发现是某个技能版本号从1.1升到了1.2而1.2版本里把摘要的要求从“不超过200字”改成了“不超过300字”但get_instructions文档没有同步更新导致模型描述和执行逻辑产生偏差。如果没有轨迹日志里记录版本号这个问题几乎不可能查到。还有一点是“回放”不仅仅是看日志我还在本地实现了一个简单的replay工具可以把某次会话的技能调用参数重新执行一遍。无论模型服务升级还是技能代码改动回放工具都能快速验证“同一个输入是否仍然得到同样的输出”这让技能迭代时回归测试的成本大大降低。6.2 技能时延与token消耗的量化分析性能优化的前提是量化和可衡量。我把技能调用数据按周聚合生成一幅技能调用画像内容包括调用频次、平均时延、P95时延、token消耗占比和失败率。某次统计后我发现调用频次最高的前三名是文件搜索、代码格式化、数据库查询但token消耗最大的却是文档摘要、子智能体归档这类复杂技能。也就是说高频技能并不等于高消耗技能优化时要分开处理。高频技能要优化注册表发现效率让模型更快选中高消耗技能要优化内部推理轮数和指令长度减少子智能体内部的无效对话。另一个量化指标是“技能选择的精确率”。我统计了每次模型选定技能后该技能是否在后续执行中被证明是正确选择把误解模型意图、调错技能的情况记录下来。这个指标比单看模型准确率更有工程价值因为工具选择的错误往往可以通过更好的技能描述修正而不用动模型本身。7. 落地过程中的踩坑实录六个高频问题与排查方法前面讲的都是设计顺畅时的理想情况。真实项目里agent-skills从初版到稳定我踩了不少坑有些坑到现在想起来还觉得疼。整理成一份速查表给后续做类似架构的同学一些参考。7.1 症状、原因与解决对照表症状可能原因排查方法解决思路模型总是选错技能技能描述太泛多个技能边界重叠查看技能选择日志确认模型是被哪个描述误导重写描述突出“什么时候用”而不是“能做什么”参数解析频繁失败schema太复杂必填字段过多直接打印模型生成的原始JSON看错在哪减少必填字段能填默认值的都填默认值技能内部多轮推理后上下文溢出子智能体没有自己的摘要压缩观察sub_messages长度变化在子智能体内部加入旧的摘要折叠逻辑每轮处理固定块技能调用后主对话风格突变技能返回结果包含了大量指令性文本检查execute返回内容确认没有混入prompt文本对技能返回结果做后置清洗只保留结构化字段依赖冲突导致技能时好时坏所有技能共用同一个Python环境查看安装时间和技能激活顺序改成技能级虚拟环境或依赖隔离机制重试时重复执行副作用操作执行器缺少幂等控制查看日志确认重试是否来自超时给技能调用生成唯一request_id执行前检查是否已执行过我印象最深的是最后一个幂等性问题。一个“发送邮件”类技能如果执行超时但邮件实际已经发出重试就会造成重复发送。给每个技能调用加唯一request_id并在技能内部按id记录执行状态是治本的方案。7.2 让技能体系活下来的几条心得如果只看文档每个技能都写得规规矩矩但它能不能在真实场景里活下来往往取决于很多“软性”因素。第一技能描述要请真实用户来写至少也要从真实问题数据里提炼。我早期技能描述都是自己拍脑袋写的后来把用户实际对话记录翻出来才发现用户问“预算表”的时候就是要文件搜索问“上个月的账”的时候其实是要数据库查询。贴近真实表达的技能描述模型选择准确率会显著提升。第二技能要不要合、要不要拆永远以调用数据为准别靠感觉。我有个技能“搜索并总结文档内容”的调用失败率特别高一查发现是尝试把异步搜索和摘要生成打包进了一步丢了中间状态。把它拆成一个搜索技能加一个摘要技能后同一个任务的失败率立刻下来。拆技能有时候确实会多一轮模型调度但换来的是每步的确定性对于工具调用来说这是值的。第三设计agent-skills时不要追求一次性搞一套“完美协议”更实际的路径是从两三个最常用技能开始把协议跑顺了再逐步扩展。协议稳定后尽量别频繁改动因为我统计过每次协议变化后模型都需要一段时间适应期间工具选择准确率会有一个明显回落。如果必须改尽量保持函数签名和schema字段的向后兼容。最后一点当整个技能系统运行稳定后我会有意识地去审查每一条技能是否真的有被调用。有些技能写出来之后半年都没被模型选中哪怕一次这不一定是技能没用更可能是描述和真实需求脱节。要么重写描述要么果断下线别让它留在注册表里继续消耗模型每次扫描时的注意力。我在这个项目里的一个深刻体会是Agent能不能理解用户很大程度上取决于工程上给模型铺了一条多顺的路。agent-skills做得好的时候模型不是变聪明了而是它每一步的判断都更容易做对了。