做Agent应用开发这段时间我踩过最大的坑不是模型能力不够而是我自己把工具调用写得像一团乱麻。一开始只是把几个工具函数丢给大模型让它自己选等工具一多模型开始瞎选Prompt越来越长维护成本直接失控。后来我动手整理了一个叫 agent-skills 的实验项目核心思路很简单把那些反复调用的Prompt、工具函数、后处理逻辑全部封装成一个个独立、可测试、可组合的“技能”。这篇文章就把这个项目的设计思路、关键实现和踩坑记录完整写出来。如果你正在用大模型接口做智能体应用每天被工具描述、参数格式和上下文长度折磨这篇内容应该能帮上忙。项目不大但把“技能层”想明白之后Agent的稳定性和可维护性都能上一个台阶。1. 设计思路与整体拆解1.1 为什么需要技能层从“工具堆”到“技能库”大多数做Agent的同学应该都有过这个阶段把能力全部塞进tools数组让大模型自己决定调哪个。刚开始只有三四个工具的时候挺顺模型基本不会选错等到工具数量超过八个情况开始失控。它会在两个功能相近的工具之间反复横跳甚至把A工具的参数字段传给B工具同一个功能改一次描述还得担心另外几个工具被误伤。为什么会有这个问题因为工具列表本质上是一堆“动作描述”的平铺模型面对的是一个没有层次的菜单它只能靠名字和一两句话猜什么时候该用。而技能层要解决的不是“怎么把动作写清楚”而是“怎么把一个动作变成一个完整的能力单元”。一个技能不只是“能做什么”还包括什么时候触发、需要哪些输入、内部按什么顺序跑、结果怎么返回、出错时怎么兜底。这就像机器人身上的标准动作包你可以单独控制每一个关节但真正干活的时候你调用的是一整套“抬手—抓取—移动—放下”动作序列。我用一个生活化的类比来理解工具函数是乐高积木技能则是拼装好的组件。积木可以自由组合但每次都要从头想怎么拼组件直接插上去就能用而且每个组件都经过测试知道它有多大的承重范围。技能层就是让我们从“拼积木”切换成“选组件”的开发方式。这个设计带来的好处有三点一是可复用同样的“文件归档”逻辑不用在每个Agent项目里重写一遍二是可测试技能可以脱离模型单独跑回归测试三是可组合复杂任务可以由执行器把多个技能串起来而不是把上千行逻辑塞进一个Prompt里。1.2 模块怎么拆registry、executor、contextagent-skills 整体结构不复杂我拆成了三大块注册中心、执行器和上下文对象。先看目录结构agent-skills/ skills/ archive_files/ list_files/ search_code/ planner/ runtime/ __init__.py registry.py executor.py context.py api/ server.py client.py config/ skills.yaml tests/ test_archive_files.py test_registry.pyskills目录下每个技能占一个子目录子目录里固定放四样东西skill.md行为说明、schema.json参数定义、run.py实际执行逻辑、examples/触发示例。这样设计是刻意的让一个技能的全部信息都收拢在同一个目录里人和程序都能一眼看懂。runtime/registry.py负责扫描并注册技能。启动时递归读取skills目录校验schema.json和run.py是否存在然后把技能元数据汇总成列表。我踩过的第一个坑是在启动流程里手动维护技能清单每加一个技能都要改注册代码非常容易遗漏。后来改成自动扫描目录新增技能只需要把文件夹放进去注册中心会自动识别。runtime/executor.py是整个项目的核心调度器。它接收大模型返回的意图技能ID和参数查询注册表然后调用对应技能的run方法。执行器还要负责技能之间的互斥检查、结果截断、错误重试。简单理解执行器就是一个“路由保镖”路由决定技能往哪儿走保镖保证不越界、不爆上下文。runtime/context.py里是一个贯穿全流程的上下文对象。它保存会话ID、历史消息、临时文件目录、运行轨迹日志。每个技能的run方法都接收这个 context而不是自己去读全局状态。这样可以避免并发请求之间的数据污染也方便追踪一次AI对话里到底执行了哪些操作。为什么要坚持“一个技能一个目录”除了清晰之外还有一个很实际的好处并行开发不冲突。我这边一个二期功能需要让另一位同学同时开发十来个技能如果大家都在改同一个文件合并起来能让人崩溃。按目录隔离后每个人负责自己的技能代码评审只要看新增目录回归测试也只需要跑相关的测试文件。2. 核心细节解析与实操要点2.1 技能描述怎么写模型才肯听技能描述是整个项目里最容易被低估的部分。很多人觉得描述就是一句话的事实际上模型是否触发技能70%取决于这段描述写得好不好。我见过失败的案例长这样description: 一个根据用户输入进行文件操作的工具可以整理文件、移动文件、删除文件。这段描述看起来没问题但模型拿到之后基本等于没看。原因在于它没有说清楚“什么时候该用”只说了“能做什么”。模型在真实对话里接收到的是“帮我看看下载目录里那些图片太乱了整理一下”它要判断的是这段用户输入和哪个技能匹配。如果描述里没有明确的触发条件模型就会犹豫最后选错。后来我改成这样的写法description: 当用户提到需要整理、归档或按规则重命名文件时使用。 典型场景包括把下载目录里的图片按年份归档、把桌面上的PDF移动到指定文件夹。 不要用于文件内容搜索、不要用于文件权限修改。加上了“什么时候用”和“什么时候不要用”触发准确率肉眼可见地提升。模型判断成本低因为它能直接从描述里找到和用户请求的对应关系。除了技能主体的skill.md参数定义里的字段描述同样影响执行效果。我见过有人把参数写成这样{ name: source_dir, type: string, description: 源目录 }大模型填参数时很难判断该传绝对路径还是相对路径、该传文件路径还是文件夹路径。改成这样就好很多{ name: source_dir, type: string, description: 需要整理的源目录绝对路径例如 /home/user/downloads/ }还有一个很便宜的提效方法examples/。每个技能目录下放两到三个用户请求到参数的映射示例注册时把它们拼进系统Prompt。比如{ user_request: 把下载目录里的图片按年份归档, skill_id: archive_files, params: { source_dir: /home/user/downloads, target_root: /home/user/pictures, extensions: [.jpg, .png, .gif] } }模型看到这种现成的“问题—答案”对比看一大段描述更容易学会触发条件。这也是我在agent-skills里最想推荐的做法不要只给规则要给例子。2.2 上下文与执行器让技能跑得稳上下文管理看起来简单但做不好会埋很多坑。最初的版本里我在每个技能的 run 函数里直接开了一个全局临时目录结果并发请求一多两个会话的文件互相串排查了很久才发现是上下文没有隔离。现在所有技能统一从 context 里取变量。Context对象里包含会话ID、历史消息摘要、临时工作目录、运行轨迹。技能的签名固定是run(context, **params)这样执行器可以统一调用不用管每个技能内部有什么特殊需求。技能如果需要写临时文件就写到context.temp_dir下面需要记录执行过程就调用context.trace(step_name, detail)写入结构化日志。执行器的匹配环节也要小心。大模型返回的意图里可能带着一个不存在的技能ID比如它自己编了一个听起来很合理的ID。如果硬查注册表会直接抛异常。我在执行器里加了一层fallback精确匹配失败以后把所有技能的描述和当前意图做语义相似度计算选最接近的那个如果分数低于阈值就返回一个“无法匹配”的结果给模型而不是强行执行。技能内部如果有多个步骤更要写trace。我曾经遇到过一次“技能执行了一半文件移动了一点然后报错退出”的情况因为没有日志根本不知道哪一步出问题。后来我在每个技能的关键步骤都调用context.trace输出大概长这样stepcheck_source, statusok, files_found234 stepfilter_by_ext, statusok, matched89 stepmove_to_year_dir, statuspartial, moved43, failed1 errorpermission_denied, path/home/user/pictures/2025/old_photo.jpg这样的设计让定位问题的成本大幅下降也让用户能理解Agent到底做了什么而不是只看到一个最终结果。3. 实操过程与核心环节实现3.1 从零实现一个“文件整理”技能用一个具体例子走一遍完整流程。这个技能处理“把下载目录里的图片按年份归档”这种需求。目录结构如下skills/archive_files/ skill.md schema.json run.py examples/example1.jsonskill.md里写清楚技能边界。除了对外描述还有给开发人员看的内部执行步骤# archive_files ## 对外描述 当用户提到整理、归档图片文件时使用。 典型场景将源目录里的图片文件按年份移动到目标目录。 不要用于剪辑视频、转换格式、删除文件。 ## 参数说明 - source_dir: 源目录绝对路径 - target_root: 目标根目录按年份生成的子目录会放在这里 - extensions: 需要处理的扩展名列表 ## 执行步骤 1. 检查 source_dir 是否存在 2. 遍历文件按扩展名过滤 3. 读取文件修改年份 4. 创建 target_root/年份 目录 5. 移动文件记录成功与失败schema.json是参数定义执行器拿它来校验大模型生成的参数是否合法。注意required字段不要乱加有些参数可以设置默认值避免模型因为没有拿到所有字段就停止执行。{ type: object, properties: { source_dir: { type: string, description: 源目录绝对路径如 /home/user/downloads/ }, target_root: { type: string, description: 目标根目录绝对路径如 /home/user/pictures/ }, extensions: { type: array, items: { type: string }, description: 要处理的扩展名列表, default: [.jpg, .jpeg, .png, .gif] } }, required: [source_dir, target_root] }run.py是技能的执行主体我用Python写了一个精简版本import os import shutil from datetime import datetime def run(context, **params): source_dir params[source_dir] target_root params[target_root] extensions params.get(extensions, [.jpg, .png, .gif]) if not os.path.isdir(source_dir): return {status: error, message: fsource_dir not found: {source_dir}} os.makedirs(target_root, exist_okTrue) result {moved: [], failed: [], status: ok} for filename in os.listdir(source_dir): if not any(filename.lower().endswith(ext) for ext in extensions): continue filepath os.path.join(source_dir, filename) mtime os.path.getmtime(filepath) year str(datetime.fromtimestamp(mtime).year) target_dir os.path.join(target_root, year) os.makedirs(target_dir, exist_okTrue) try: shutil.move(filepath, os.path.join(target_dir, filename)) result[moved].append({file: filename, year: year}) except OSError as exc: result[failed].append({file: filename, error: str(exc)}) result[status] partial return { status: result[status], total_moved: len(result[moved]), total_failed: len(result[failed]), moved: result[moved], failed_sample: result[failed][:5], }这里有一个细节run方法没有打印日志而是返回结构化JSON。因为执行器要把结果回传给大模型还要写日志统一的结构化返回能同时满足两个需求。结果里加一个failed_sample而不是返回所有失败项是为了避免上下文被撑爆。完成这三个文件之后技能就能被注册中心发现。我把examples/example1.json也放进了目录注册时自动读取拼接到系统Prompt里方便模型学会这个技能的典型用法。3.2 注册与测试接入Agent闭环注册中心会自动扫描但项目里还是留了一个显式的config/skills.yaml用来配置技能之间的优先级和互斥关系。简单配置长这样skills: archive_files: priority: 70 excludes: - delete_files list_files: priority: 30priority是技能被选中时的优先级当多个技能都能匹配用户请求时执行器优先选择分数高的。excludes声明互斥技能比如归档文件和删除文件不能同时执行。单元测试是agent-skills里最值得投入的部分。我吃过亏早期没有写测试每次调整一个技能描述都要手动开一个会话试半天。后来我把测试分成两类一类是纯函数测试直接给run传参数检查返回结构另一类是模型触发测试用固定的模型响应模拟大模型意图检查执行器是否正确路由。先看纯函数测试import unittest import tempfile from pathlib import Path from skills.archive_files.run import run class TestArchiveFiles(unittest.TestCase): def test_create_year_dirs_and_move_files(self): with tempfile.TemporaryDirectory() as tmp: source Path(tmp) / downloads target Path(tmp) / pictures source.mkdir() (source / a.jpg).write_bytes(bfake) (source / b.png).write_bytes(bfake) result run(contextNone, source_dirstr(source), target_rootstr(target)) self.assertEqual(result[status], ok) self.assertEqual(result[total_moved], 2) year_dirs list((target / 2025).glob(*.jpg)) self.assertTrue(len(year_dirs) 1)这个测试不依赖大模型跑起来非常快适合做回归。模型触发测试则稍微复杂一点我预先准备了几个用户请求片段让模型生成意图然后用规则判断意图ID是否等于archive_files。这类测试我会放在单独的标签里不放进普通的单元测试因为它受模型版本影响更适合做周级回归。组合调用是技能层级的进阶场景。归档技能内部其实可以复用更基础的“罗列文件”技能但我不建议一开始就做复杂编排。比较稳妥的做法是先把独立技能沉淀稳定等技能超过十个之后再引入一个planner技能负责拆解任务并调用其他技能。我现在在agent-skills里就是先让各个技能保持单线程的独立性再用执行器统一调度避免出现技能互相调用的递归混乱。4. 常见问题与排查技巧4.1 模型就是不触发技能怎么办这是群里被问得最多的问题。模型不触发技能通常不是模型傻而是技能描述和实际用户输入之间的对应关系不够清楚。我整理了一个排查表现象排查点解决方案用户提到了典型场景模型却调用其他技能描述里缺少正面触发词在描述中加入“当用户提到……时使用”的句式模型每次都用同一个技能处理所有任务描述太宽泛为每个技能增加明确的边界和反例模型触发了技能但参数全错参数描述没有给格式示例在schema字段描述中加入绝对路径示例或枚举值几个功能相近的技能互相抢缺少优先级和互斥在配置里设置priority和excludes我自己踩过的一个坑是在一个“搜索代码”技能的描述里写了“如果有需要可以用于搜索代码”结果模型真的只在“觉得有必要”的时候才触发大部分时候都觉得没必要。后来我把“如果有需要”这类模糊措辞全部删掉改成明确的触发条件才恢复正常。写技能描述要像写用户手册里的故障排查准确、直接、不绕弯。4.2 工具结果太长上下文爆掉怎么办上下文长度是Agent应用绕不开的瓶颈。技能执行完返回一个巨大的JSON比如列出几百个文件名模型要把整个JSON翻一遍才能回答用户“一共有多少张图片”这既浪费token又容易把上下文撑爆。我在 run.py 里做了两个处理第一个是结果摘要不返回完整文件列表而是返回数量、各年份分布、前几个文件名作为样例。第二个是在执行器加了一道截断逻辑任何工具返回给模型的文本如果超过某个阈值就只保留开头摘要和结尾统计中间用省略号代替并附上原始结果存储路径。def summarize_result(text, max_chars4000): if len(text) max_chars: return text head text[:2000] tail text[-1500:] return f{head}\n...[truncated {len(text) - 3500} chars]...\n{tail}这个方法适合大部分结果过长的场景。另外技能内部每一步的完整输出不应该拼进上下文只把摘要写入context的trace里完整日志落到本地文件。这样既保证模型能看到足够的信息又不会因为中间过程的噪音干扰后续对话。4.3 技能冲突与重复执行当技能数量多起来之后冲突不可避免。比如用户说“把下载目录里的旧文件清一下”模型可能在“归档”和“删除”之间犹豫。我处理冲突的办法是三层防护优先级、互斥、轮次去重。优先级解决“哪个更合适”互斥解决“哪些不能同时跑”轮次去重解决“同一轮对话里同一个技能不要反复执行”。轮次去重这个问题是我在实际使用时发现的。模型有时会觉得自己刚才执行得不够彻底在同一个用户请求的后续回复里又调用了一次同一个技能导致文件被处理两遍。执行器里加了一个简单的判断如果当前轮次已经有相同skill_id的执行记录再次执行前必须返回上次的结果而不是重复跑。还有一个更隐蔽的问题技能是相互独立的但数据不是。比如一个技能在临时目录里生成了中间文件另一个技能也需要读这个文件如果它们不知道对方的约定就会出问题。我的处理是在 context 里维护一套“共享临时区”的路径所有需要传递数据的技能都默认读写这个区域并把路径写入trace。这样既保持了技能独立又让数据能顺利流转。我不建议在一开始就设计复杂的技能编排引擎。真正的Agent项目里很多需求只需要两三个技能组合就能完成手动在planner技能里写清楚调用顺序比设计一套工作流图更实用。等技能数量到了二三十个再考虑引入可视化编排也不迟。结尾从实际维护的角度看agent-skills 带给我最大的收获是给Agent套上了一层“边界感”。技能不只是告诉模型怎么干活也告诉它什么时候不该动手。早期我把精力全放在写更复杂的Prompt上后来发现把能力拆成清晰、可测试的技能单元比一味叠Prompt稳定得多。我也比较后悔没有在一开始就写测试每次调整描述都要手动试对话效率很低后来补上测试改任何技能都有底数了。接下来我计划给每个技能维护一张效果表记录触发率、参数正确率和任务完成率目标是形成一个可量化的技能评估基准。这个项目还在持续迭代后续如果有新的进展我会再写一篇实战记录分享出来。