Agent Skill系统设计:从架构到落地的工程实践指南
1. 从“聊天”到“干活”Skill 系统到底在解决什么问题如果你最近半年一直在折腾 Agent 相关的东西大概率会有一种强烈的割裂感模型在对话框里能跟你聊哲学、写诗、编故事甚至能帮你分析一段代码的逻辑漏洞但一旦你让它“去把这件事办了”它就开始装傻——要么反复问你“你具体想让我做什么”要么给你一段看起来像那么回事、实际上根本跑不通的伪代码要么干脆在关键步骤上开始胡编乱造。这个现象背后的本质问题不是模型不够聪明而是模型缺少一个稳定的、可复用的、能被精确调用的“执行层”。LLM 本身是一个概率性的文本生成器它的强项是理解和生成自然语言弱项是精确执行确定性操作。你让它“帮我查一下这个接口返回什么”它可能会给你一段 curl 命令但它自己并不会真的去发这个请求你让它“把这个文件里的数据清洗一下”它可能会给你一段 Python 脚本但它自己并不会真的去跑。Skill 技能系统要解决的就是这个“最后一公里”的问题。它的核心思路很直接把 Agent 需要具备的每一项具体能力封装成一个独立的、有明确输入输出契约的 Skill让 Agent 在需要的时候能够精确调用而不是靠“即兴发挥”去猜怎么做。打个比方LLM 就像一个知识渊博但手脚不太利索的顾问Skill 就是给他配的一套标准化工具包。顾问负责判断“现在该用哪个工具”工具包负责“把这件事精确地做出来”。两者配合Agent 才能从“只会说”进化到“真的能干”。这套东西适合谁来参考如果你正在做 Agent 应用开发不管是基于哪个框架只要你遇到了“模型输出不稳定”“任务执行不可复现”“多步骤流程容易断链”这些问题Skill 系统的设计思路都值得你花时间研究。哪怕你只是用现成的 Agent 产品理解 Skill 的运作机制也能帮你更好地设计提示词和任务拆解策略。2. Skill 系统的整体架构设计为什么不能把什么都塞进 Prompt2.1 核心设计原则能力外置契约先行很多人第一次做 Agent 的时候习惯把所有能力都写进 System Prompt 里——“你可以查天气、可以发邮件、可以查数据库、可以生成报表……”结果 Prompt 越写越长模型反而越来越糊涂经常在该调用工具的时候不调用不该调用的时候乱调用。Skill 系统的第一个设计原则就是能力外置。每一项能力不再是一段自然语言描述而是一个独立的模块有自己的名称、描述、输入参数定义、输出格式定义、错误处理逻辑。Agent 在运行时通过一个统一的调度层来发现和调用这些 Skill而不是靠 Prompt 里的文字描述去“回忆”自己会什么。第二个原则是契约先行。每个 Skill 在被实现之前先定义好它的接口契约输入是什么类型、有哪些必填项、输出是什么结构、可能抛出哪些错误。这个契约一旦确定Skill 的内部实现可以随时替换只要契约不变Agent 的调用逻辑就不需要改动。这一点在多人协作或者长期维护的场景下尤其重要。2.2 分层架构调度层、执行层、适配层一个完整的 Skill 系统通常分为三层调度层负责接收 Agent 的意图判断该调用哪个 Skill组装参数处理返回结果。这一层通常和 LLM 紧密配合LLM 负责“决策”调度层负责“执行”。调度层需要解决的核心问题是如何让 LLM 准确地从一堆 Skill 中选出正确的那一个并且正确地填充参数。执行层是每个 Skill 的具体实现。它可以是本地的一段代码也可以是对外部服务的调用还可以是对另一个 Agent 的委托。执行层不关心是谁调用了它只关心输入是否符合契约、执行是否成功、输出是否满足格式要求。适配层负责处理不同 Skill 之间的差异。比如有的 Skill 是同步的有的是异步的有的返回 JSON有的返回纯文本有的需要认证有的不需要。适配层把这些差异统一成调度层能理解的格式让调度层不需要为每个 Skill 写特殊的处理逻辑。2.3 为什么选择 HTTP SSE 作为通信基础在 Skill 系统的通信机制选型上HTTP 和 SSE 的组合是一个很务实的选择。HTTP 负责请求-响应模式的同步调用SSE 负责服务端向客户端推送流式结果。这个组合的好处是HTTP 连接复用通过 Keep-Alive 机制多个 Skill 调用可以复用同一个 TCP 连接减少握手开销。对于需要频繁调用 Skill 的场景这个优化效果很明显。SSE 天然适合流式输出很多 Skill 的执行过程是渐进的比如一个数据分析 Skill 可能需要几十秒才能跑完SSE 可以让中间结果实时推送给 AgentAgent 可以据此决定是继续等待还是中断重试。协议简单调试方便HTTP 和 SSE 都是文本协议用 curl 就能直接测试不需要额外的工具链。这在开发和排查问题时非常省事。当然这个组合也有它的局限。SSE 是单向的服务端推给客户端客户端不能通过同一个连接发消息回去。如果需要双向通信WebSocket 会更合适。但在 Skill 系统的场景下大部分交互是“调用-等待结果”的模式SSE 的单向推送已经够用了。3. Skill 的定义与注册让 Agent 知道自己会什么3.1 Skill 描述文件的结构设计每个 Skill 都需要一个描述文件告诉调度层“我是谁、我能做什么、你需要给我什么、我会还给你什么”。这个描述文件通常包含以下字段字段名类型说明namestringSkill 的唯一标识建议用蛇形命名如query_databasedescriptionstring自然语言描述供 LLM 理解 Skill 的用途parametersobject输入参数的 JSON Schema 定义returnsobject输出结果的 JSON Schema 定义timeoutnumber超时时间单位秒retry_policyobject重试策略包括最大重试次数和退避算法其中description字段的写法很关键。它不能太短太短了 LLM 理解不了也不能太长太长了会占用宝贵的上下文窗口。我的经验是控制在 50 到 150 个字符之间用“动词 对象 关键约束”的结构来写。比如“查询指定数据库中的记录支持按时间范围和关键词过滤返回最多 100 条结果”就比“数据库查询工具”要好得多。3.2 参数定义的注意事项参数定义是 Skill 系统里最容易出问题的地方。我踩过的坑包括参数类型不明确比如一个参数写的是string但实际上 LLM 可能会传一个数字进来。解决方案是在描述里明确写“请传入字符串格式的数字”或者在执行层做类型转换。必填和选填混淆LLM 有时候会漏传必填参数有时候又会给选填参数传一个空值。解决方案是在 JSON Schema 里严格定义required数组并且在执行层对空值做统一处理。参数之间的依赖关系没有表达比如参数 A 和参数 B 不能同时为空但 JSON Schema 本身表达不了这种约束。解决方案是在description里用自然语言说明或者在执行层做校验并返回明确的错误信息。3.3 注册流程与动态发现Skill 的注册通常有两种模式静态注册和动态发现。静态注册是在系统启动时把所有 Skill 的描述文件加载到内存里调度层直接从一个固定的列表里查找。这种方式简单可靠适合 Skill 数量不多且不经常变动的场景。动态发现是通过一个注册中心来实现的每个 Skill 在启动时向注册中心报到调度层定期从注册中心拉取最新的 Skill 列表。这种方式适合 Skill 数量多、需要热更新的场景但实现复杂度更高需要处理注册中心的可用性和一致性问题。对于大多数项目来说我建议先从静态注册开始等 Skill 数量超过 20 个或者需要频繁更新的时候再考虑引入动态发现。4. 调度层的核心逻辑LLM 如何准确选择 Skill4.1 Function Calling 与 ReAct 的取舍调度层的核心任务是让 LLM 从一堆 Skill 中选出正确的那一个。目前主流的方法有两种Function Calling 和 ReAct。Function Calling 是模型厂商提供的一种结构化输出能力你给它一个工具列表它直接返回“我要调用哪个工具、参数是什么”的 JSON。这种方式的优点是准确率高、格式稳定缺点是依赖模型本身的支持而且工具列表不能太长否则会占用大量上下文。ReAct 是一种提示词工程方法通过让模型在“思考”和“行动”之间交替逐步完成任务。它的优点是灵活不依赖特定的模型能力缺点是输出格式不稳定需要大量的提示词调优。我的建议是如果模型支持 Function Calling优先用它如果不支持或者需要更复杂的多步推理再用 ReAct。在实际项目中我经常把两者结合起来——用 Function Calling 做 Skill 选择用 ReAct 做多步任务的规划。4.2 参数填充的校验与纠错LLM 选对了 Skill 但填错了参数这是非常常见的情况。比如一个查询 Skill 需要start_date和end_dateLLM 可能只传了start_date或者把日期格式写成了“2024年1月1日”而不是“2024-01-01”。解决这个问题需要两道防线第一道防线是在调度层做参数校验。拿到 LLM 返回的参数后先按照 JSON Schema 做一次校验缺必填项的、类型不对的、格式不匹配的直接拦截并返回明确的错误信息给 LLM让它重新生成。第二道防线是在执行层做容错处理。对于一些常见的格式问题比如日期格式、数字字符串执行层可以尝试自动转换而不是直接报错。但要注意容错不能过度否则会把真正的错误掩盖掉。4.3 多 Skill 编排与依赖管理稍微复杂一点的任务往往需要多个 Skill 配合完成。比如“帮我分析一下上个月的销售数据并生成报表”这个任务至少需要三个 Skill查询数据、分析数据、生成报表。这三个 Skill 之间有明确的依赖关系必须按顺序执行。调度层需要能够表达这种依赖关系。最简单的方式是用一个线性的执行计划把 Skill 按顺序排列前一个的输出作为后一个的输入。更复杂的方式是用 DAG有向无环图来表达依赖关系支持并行执行和条件分支。对于大多数场景线性执行计划已经够用了。只有在任务步骤多、需要并行加速的时候才需要考虑 DAG。我个人的经验是如果一个任务的步骤超过 5 个或者有明显的并行机会就值得引入 DAG。5. 执行层的实现细节从同步调用到流式推送5.1 同步 Skill 的实现模板同步 Skill 是最简单的一种调用后等待结果返回。下面是一个用 Python 实现的同步 Skill 示例import requests from typing import Dict, Any def query_database(params: Dict[str, Any]) - Dict[str, Any]: 查询数据库中的记录 # 参数校验 if not params.get(table_name): return {error: table_name is required} # 构建请求 url fhttp://internal-api/db/query payload { table: params[table_name], filters: params.get(filters, {}), limit: params.get(limit, 100) } # 发送请求 try: response requests.post(url, jsonpayload, timeout30) response.raise_for_status() return {data: response.json()} except requests.Timeout: return {error: query timeout} except requests.RequestException as e: return {error: frequest failed: {str(e)}}这个模板的关键点在于参数校验放在最前面错误处理覆盖了超时和请求异常返回值统一用字典格式方便调度层处理。5.2 异步 Skill 与 SSE 流式推送对于执行时间较长的 Skill同步等待会让 Agent 卡住。这时候需要用异步方式让 Skill 在后台执行通过 SSE 把进度推送给 Agent。import asyncio from fastapi import FastAPI from fastapi.responses import StreamingResponse app FastAPI() async def long_running_task(params): 模拟一个耗时的任务 for i in range(10): await asyncio.sleep(1) yield fdata: progress {i*10}%\n\n yield fdata: done\n\n app.get(/skill/long_task) async def long_task(): return StreamingResponse( long_running_task({}), media_typetext/event-stream )这个示例里StreamingResponse会把生成器的输出以 SSE 格式推送给客户端。Agent 端需要用一个 SSE 客户端来接收这些事件并根据事件内容决定下一步动作。注意SSE 连接可能会因为网络问题断开Agent 端需要实现重连逻辑。同时服务端要设置合理的心跳间隔避免连接被中间层断开。5.3 错误处理与重试策略Skill 执行失败是常态关键是怎么处理。我的经验是分三类可重试错误比如网络超时、服务暂时不可用。这类错误应该自动重试重试次数建议 2 到 3 次每次间隔用指数退避。不可重试错误比如参数格式错误、权限不足。这类错误应该直接返回给 Agent让它决定是修正参数还是放弃任务。部分成功错误比如批量操作中有一部分失败了。这类错误需要返回详细的失败列表让 Agent 决定是重试失败的部分还是整体回滚。重试策略的配置建议放在 Skill 描述文件里而不是硬编码在执行层。这样不同的 Skill 可以根据自己的特点设置不同的重试策略。6. 常见问题与排查技巧实录6.1 Skill 调用失败排查速查表现象可能原因排查方法解决方案LLM 不调用 SkillSkill 描述不清晰检查 description 是否准确描述了用途优化 description增加关键词LLM 调用了错误的 SkillSkill 之间描述太相似对比相似 Skill 的 description增加区分度明确各自适用场景参数缺失必填项未在 description 中强调检查 JSON Schema 的 required 字段在 description 中明确标注必填项参数格式错误LLM 不理解参数格式要求检查参数描述是否包含格式示例在 description 中给出格式示例执行超时Skill 执行时间过长检查 Skill 内部逻辑和外部依赖优化执行逻辑或改为异步SSE 连接断开网络不稳定或心跳间隔过长检查网络日志和心跳配置增加重连逻辑缩短心跳间隔6.2 几个容易踩的坑坑一Skill 数量太多导致 LLM 选择困难。我一开始把能想到的所有能力都做成了 Skill结果 LLM 经常选错。后来我把一些低频的、可以合并的 Skill 合并了把数量控制在 15 个以内准确率明显提升。坑二Skill 描述用了太多技术术语。LLM 不是工程师它不理解“幂等”“事务”“回滚”这些词的具体含义。描述要用业务语言说“这个操作可以重复执行不会产生副作用”比说“这个 Skill 是幂等的”要好。坑三忽略了 Skill 的版本管理。当 Skill 的接口发生变化时如果没有版本管理正在运行的 Agent 可能会调用到不兼容的版本。解决方案是在 Skill 名称里加版本号比如query_database_v2或者用单独的版本字段来标识。坑四没有做 Skill 的熔断和降级。当某个 Skill 依赖的外部服务不可用时如果不做熔断所有调用都会堆积在那里拖垮整个系统。解决方案是给每个 Skill 配置熔断阈值超过阈值后直接返回降级结果。6.3 性能优化的几个实用技巧HTTP 连接复用是提升 Skill 调用性能最直接的手段。默认情况下每次 HTTP 请求都会新建一个 TCP 连接完成后再关闭。如果 Skill 调用频繁这个开销很可观。通过配置连接池可以让多个请求复用同一个连接减少握手和慢启动的开销。批量调用是另一个有效的优化。如果 Agent 需要连续调用同一个 Skill 多次可以把这些调用合并成一个批量请求减少网络往返次数。当然这需要 Skill 本身支持批量操作。结果缓存对于查询类的 Skill 特别有效。如果同样的查询在短时间内被重复执行可以直接返回缓存结果。缓存的过期时间需要根据数据的更新频率来设置太短了没效果太长了数据会过时。7. 从 Skill 系统到 Agent 生态一些延伸思考Skill 系统的价值不仅仅在于让单个 Agent 更能干它还为 Agent 之间的协作提供了基础。当每个 Agent 的能力都被封装成标准化的 Skill 后一个 Agent 就可以调用另一个 Agent 的 Skill形成更复杂的协作网络。比如一个负责数据分析的 Agent可以把“生成图表”这个能力封装成 Skill让负责报告撰写的 Agent 直接调用。这样两个 Agent 不需要知道对方的内部实现只需要通过 Skill 契约来交互。这种模式的一个关键挑战是信任和权限管理。不是所有的 Skill 都应该对所有 Agent 开放需要有一套机制来控制哪些 Agent 可以调用哪些 Skill。简单的做法是用 API Key 或者 Token 来做认证复杂的做法是基于角色的访问控制。另一个值得关注的方向是Skill 的自动发现和组合。当 Skill 数量多到一定程度后人工维护调用关系会变得很困难。如果能让 Agent 自己根据任务目标自动发现需要的 Skill 并组合成执行计划那整个系统的灵活性会大大提升。这需要 LLM 有更强的规划能力也需要 Skill 的描述更加结构化和语义化。我在实际项目中的体会是Skill 系统的设计没有一劳永逸的方案它需要随着 Agent 能力的增长和任务复杂度的提升不断演进。一开始可以简单粗暴把所有能力都塞进一个模块里等规模上来了再逐步拆分和规范化。关键是保持接口的稳定性和描述的一致性这样无论内部怎么重构Agent 的调用逻辑都不需要大改。最后分享一个小技巧在开发阶段给每个 Skill 加一个“调试模式”开启后会把完整的输入输出和中间状态都打印出来。这个功能在排查 LLM 为什么选错 Skill、为什么填错参数的时候特别有用比看日志猜原因效率高得多。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →