Agent-Skills架构解析:让Agent能力可组织、沉淀与复用
老实说我第一次拆解“agent-skills”这个标题的时候第一反应是“这不又是一个工具调用封装吗”。但真正把这套东西落地到Agent项目里我才发现它解决的根本不是“怎么调用工具”而是“怎么让Agent的能力可以被组织、沉淀和复用”。对于正在做Agent平台、个人助理、浏览器自动化或者复杂工作流编排的开发者来说这一层设计恰恰是决定项目能走多远的分水岭。项目本身的核心思路很清晰把Agent能做的事拆成一个一个独立、可描述、可校验的技能单元每个技能由模型根据任务动态选择并触发执行。这套方案的直接收益有三个一是大幅降低单个技能的复杂度排查问题不用再翻几百行混合逻辑二是技能可以跨场景复用新项目直接挂载已有技能包就能用三是给模型的上下文做减法让它每次只面对当前任务真正需要的几个选项而不是在一个几十KB的工具说明书里大海捞针。下面我把这个项目的设计思路、核心实现、踩坑记录和进阶玩法完整梳理一遍代码和配置都按可直接复现的标准给出。适合已经跑通过基础LLM调用、想往Agent方向深入一层的开发者阅读。1. 先想明白agent-skills到底在解决什么问题1.1 从function calling说起为什么工具多了会失控很多人以为把工具列表塞给模型Agent能力就自动长出来了。早期我也是这么干的项目里十几个工具函数每个都写了详细的JSON Schema模型也确实能选对工具。但做到第二个月问题全浮出来了。首先是工具描述越写越长。模型需要理解每个工具是干什么的、参数怎么填、有什么约束而描述一旦超过三行模型在长上下文里的选择准确率就开始下降。实测一个50个工具的项目里模型经常在相似功能的工具之间犹豫甚至出现把参数填错工具的情况。其次是工具之间形成隐式耦合。比如“查询天气后发送通知”如果做成两个独立工具模型需要自己记住先调哪个后调哪个一旦中间结果被上下文挤掉它就可能只发通知不查天气或者拿着历史天气数据当实时数据用。这种状态层面的混乱靠prompt已经压不住了。agent-skills的核心思路就是把这种状态管理从模型手里收回来。每个技能自带前置条件和执行策略技能内部知道它依赖什么数据、需要什么环境、产出什么结果模型只需要说“我要完成这件事”剩下的由技能体系来完成。1.2 技能的定位接口、策略与经验的三合一一个技能在我的理解里不是简单一个函数而是三层结构的组合第一层是接口层。明确这个技能接受什么参数输出什么结构有哪些副作用。这一层面向LLM是模型做决策的依据所以描述要像写岗位JD一样清晰职责边界、输入要求、输出格式都要写死。第二层是策略层。技能内部不是一个裸函数而是一段完成目标的执行逻辑它可能包含子步骤、条件分支、重试机制和回退方案。比如“搜索网页”这个技能内部可以先尝试API方式失败后切换到爬取方式最后再对结果做去重排序这些策略不暴露给模型。第三层是经验层。技能携带运行时统计调用频率、成功率、平均耗时可观察数据内容还可以记录历史典型失败模式方便持续改进。把这三个层次拆开之后技能的开发模式就从“写一个函数”变成了“设计一个服务”。开发成本看似变高了但每个技能的独立性和稳定性也随之提高这个取舍是值得的。1.3 这个方案适合谁典型的应用场景我梳理了几个最适合用这套设计的场景如果你的项目落在其中agent-skills会带来比较明显的效果提升多任务Agent平台。比如一个工作台里有邮件处理、日程管理、文档协作等多个域每个域一个技能包按需挂载。垂直领域数字员工。比如客服机器人需要查订单、查物流、建工单、发优惠券这些动作全部封装成技能业务流程清晰可控。工具数量超过15个的Agent。当工具列表让模型“挑花眼”时用技能分域路由能够有效降低选择错误率。需要长期维护的Agent产品。技能有版本、有测试、有文档新成员接手时不用通读全部业务代码只需要查看技能清单就能理解系统能力边界。2. 核心架构拆解技能库的组织与运行方式2.1 技能单元一份接口描述一段可执行逻辑技能单元的数据结构本身并不复杂关键在于字段设计。以我实现过的技能定义为例# skill.py from pydantic import BaseModel, Field from typing import Any, Callable, Optional class Skill(BaseModel): name: str Field(description技能名称必须是动词名词格式如search_files) description: str Field(description面向LLM的能力描述说明用途、适用场景、限制条件) parameters_schema: dict Field(descriptionJSON Schema格式的参数定义) execute: Callable Field(description核心执行函数接收参数dict返回结构化结果) prerequisites: list[str] Field(default_factorylist, description前置条件key列表如[config.llm.api_key]) timeout_seconds: int Field(default10) tags: list[str] Field(default_factorylist, description域标签如[io,search]) version: str 1.0.0这里有个关键约束execute函数的签名必须统一为fn(params: dict) - dict。所有技能都遵循同一输入输出协议执行引擎才能用同一套代码统一调度。我用一个“技能执行结果”的标准结构作为所有技能的返回协议def ok(result: Any): return {success: True, data: result} def fail(error: str): return {success: False, error: error}为什么不直接让技能返回任意类型因为执行引擎需要对返回值做统一处理包括token截断、缓存判定、日志记录。如果你不做这几件事就会在后续流程里反复打补丁我第一版就吃过这个亏。2.2 技能注册表全局索引与按域加载技能注册表是一个内存索引负责管理技能的全生命周期。它至少要实现四件事注册、查询、加载、卸载。# registry.py class SkillRegistry: def __init__(self): self._skills {} self._domains {} def register(self, skill: Skill, domain: str general): self._skills[skill.name] skill self._domains.setdefault(domain, []).append(skill.name) return skill def list_by_domain(self, domain: str): return [self._skills[n] for n in self._domains.get(domain, [])] def get(self, name: str): skill self._skills.get(name) if not skill: raise KeyError(fskill [{name}] not found) return skill def unload(self, name: str): skill self._skills.pop(name, None) for d in self._domains.values(): if name in d: d.remove(name) return skill按域加载的作用是给“模型视野”做裁剪。比如一个Agent同时处理办公场景和代码场景在实际对话中模型不需要同时看到两个域的几十个技能。注册表支持动态加载当对话内容明显进入某个域时再把对应技能包挂到模型可见列表里。这个设计解决了一个非常实际的痛点模型可见的工具数量与选择准确率负相关。把技能按域隔离之后单次决策的候选技能数量通常不超过8个选择准确率会明显上升。2.3 执行引擎从“模型想调用”到“代码被执行”的完整链路执行引擎是agent-skills的心脏它的核心任务是接收模型输出的结构化调用指令校验参数执行技能返回结果。一个最小可用的执行循环如下# executor.py class SkillExecutor: def __init__(self, registry: SkillRegistry): self.registry registry def run_step(self, call_name: str, call_args: dict, context: dict): skill self.registry.get(call_name) # 1. 前置条件检查 for req in skill.prerequisites: # 递归解析 config.llm.api_key 这类路径 if not nested_get(context, req): return fail(fmissing prerequisite: {req}) # 2. 参数校验通过jsonschema errors jsonschema_validate(skill.parameters_schema, call_args) if errors: return fail(fparameter validation failed: {errors}) # 3. 超时保护执行 try: result run_with_timeout(skill.execute, call_args, timeoutskill.timeout_seconds) return result except TimeoutError: return fail(fskill [{skill.name}] timed out) except Exception as e: return fail(fskill [{skill.name}] raised: {e})参数校验这层不能省。第一版我贪省事直接强制转换参数类型结果模型传了个字符串“2024-01-01”到date类型字段技能内部把字符串当数字操作直接运行时异常。加JSON Schema校验之后这类错误从运行时提前到了调用边界出错信息也更容易反馈给模型让它自行修正。每次执行完成后执行引擎会把返回结果写回上下文作为下一轮模型决策的依据。这个“模型决策→执行→结果回填→再决策”的循环就是Agent工作流的最小闭环。2.4 与模型交互的关键设计只暴露该暴露的在agent-skills架构里模型永远不直接看到技能内部代码它只看两部分技能名称和技能描述。这里我要重点讲讲描述信息怎么写。从实际测试来看模型对技能描述的理解方式类似于人阅读岗位JD太宽泛会被误解太细节会抓不住重点有歧义的表述会导致错选。我建议用这个模板技能名: 一句话说明该技能完成什么 使用时机: - 当用户需要场景A时 - 当用户需要场景B时 不要使用: - 如果用户只是场景C请使用其他技能 - 如果某种边界条件请勿使用 输出说明: - 成功后返回什么结构 - 失败时返回什么错误形态模型能用的信息密度是有限的所以我的原则是描述控制在120字以内只保留决策必需信息。把剩余的信息放到注册表里供内部策略层使用而不是全部丢给模型。我还做了一个小设计在每个技能描述里加“不要使用”反向提示。实测这个做法比只写正向用途的准确率提升明显因为它把易于混淆的相邻技能的边界画清楚了。3. 实操过程从0到1把一个技能模块搭起来3.1 定义第一个技能文件搜索实战以一个“文件搜索”技能为例完整走一遍定义流程。这个技能的作用是在指定的工作目录下按文件名模式搜索文件返回匹配列表。# skills/search_files.py from pathlib import Path def search_files(params): root Path(params[root_dir]) pattern params[pattern] recursive params.get(recursive, True) if not root.exists(): return fail(fdirectory not found: {root}) matcher root.rglob(pattern) if recursive else root.glob(pattern) results [] for p in matcher: if p.is_file(): results.append(str(p.relative_to(root))) if len(results) params.get(max_results, 50): break return ok(results)注册到技能库时参数Schema写得是否严谨直接决定模型的调用成功率{ type: object, properties: { root_dir: { type: string, description: 搜索根目录的绝对路径 }, pattern: { type: string, description: 文件名通配符如 *.txt 或 report*.md }, recursive: { type: boolean, description: 是否递归搜索子目录, default: True }, max_results: { type: integer, description: 最多返回多少个结果防爆量, default: 50, minimum: 1, maximum: 200 } }, required: [root_dir, pattern] }在参数描述中写清楚单位、范围、默认值这些细节会直接影响模型生成参数的规范程度。比如限制max_results上限为200可以避免模型突发奇想要求返回一万个文件。3.2 技能选择器的实现别让模型做全量选择题当技能数量上了30个以后把所有技能描述一次性塞给模型会带来两个问题token消耗大、选择准确率低。我的做法是在模型和技能库之间加一个“技能选择器”。选择器的实现方式比较轻量先用域标签做粗筛再用关键词相关性做精排。每个技能注册时带上多个标签包括能力域、实体类型、操作类型当用户输入到来时选择器先做标签匹配把候选技能范围缩小到5个以内再把这几个技能的描述传给模型。# selector.py def select_skills(query: str, available_skills: list[Skill], top_k: int 5): # 1. 标签匹配打分 scored [] for skill in available_skills: score 0.0 for tag in skill.tags: if tag in query: score 1.0 # 2. 描述关键词匹配打分 desc_terms set(skill.description.split()) query_terms set(query.strip().split()) overlap len(desc_terms query_terms) score 0.3 * overlap scored.append((skill, score)) # 3. 取top_k scored.sort(keylambda x: x[1], reverseTrue) return [s for s, sc in scored[:top_k] if sc 0.5]这个选择器不追求完美它的目标是“别让模型被80个技能淹没”。即便偶尔误筛模型兜底的能力还在这个方案是划算的。如果追求更高精度可以给选择器升级成embedding检索或者小模型分类但在初期阶段规则打分已经够用。3.3 执行循环与结果回填一次完整调用的全流程下面把整个流程串起来实现一个真正能跑起来的Agent循环。它会调用模型解析结构化工具调用执行技能并把结果回填给模型。# agent_loop.py class AgentLoop: def __init__(self, llm, registry, selector): self.llm llm self.registry registry self.selector selector self.messages [] self.max_rounds 8 def build_tools_payload(self, query): candidates self.selector.select_skills(query, list(self.registry._skills.values())) return [ { type: function, function: { name: s.name, description: s.description, parameters: s.parameters_schema } } for s in candidates ] def run(self, user_input): self.messages.append({role: user, content: user_input}) for _ in range(self.max_rounds): payload self.build_tools_payload(self.messages[-1][content]) resp self.llm.chat(self.messages, toolspayload) msg resp[message] if msg.get(tool_calls): self.messages.append({role: assistant, content: msg.get(content, ), tool_calls: msg[tool_calls]}) for tc in msg[tool_calls]: result self.execute_tool_call(tc) self.messages.append({ role: tool, tool_call_id: tc[id], content: json.dumps(result, ensure_asciiFalse)[:4000] }) continue # 无tool_call视为最终回答 return msg.get(content, ) return reach max_rounds, stop. def execute_tool_call(self, tc): fn_name tc[function][name] fn_args json.loads(tc[function][arguments] or {}) return {skill: fn_name, result: self.run_with_skill(fn_name, fn_args)}这里的max_rounds限制非常重要。我曾经不加限制让Agent自行决定是否继续结果在一次错误循环里模型反复调用同一个失败技能白白消耗了几百个API调用。设置上限之后即使循环没有收敛系统也能体面退出。3.4 上下文的精打细算缓存、截断与压缩在agent-skills体系里执行结果回填是token消耗的大头。比如一个搜索结果技能可能返回几万字全部塞进上下文会导致下一轮决策的信息噪声剧增还可能直接爆上下文窗口。我建议对技能返回值做三类处理对结构稳定且可能复用的结果例如实体信息、用户配置等开启短期缓存相同参数在5分钟内直接读取缓存不重复执行。对超长结果统一截断。搜索类技能默认只保留前20条结果并加一行提示“共找到157条此处展示前20条如需更多请告知”把选择权交回用户。对历史对话做滚动压缩。超过一定轮次后用LLM对之前的对话做摘要把摘要放回上下文整段原文移出。def compress_if_needed(self, messages, max_len24000): total sum(len(m[content]) for m in messages) if total max_len: return messages # 对最老的80%内容做摘要保留最近两轮原始内容 head, tail messages[:int(len(messages)*0.8)], messages[int(len(messages)*0.8):] head_text \n.join(m[content] for m in head)[-6000:] summary self.llm.summarize(fSummarize:\n{head_text}, max_tokens500) return [{role: system, content: fearlier context summary: {summary}}] tail这个压缩策略在长会话场景下效果很明显对话轮次从12轮延长到25轮才需要做摘要压缩整体体验更连贯。注意执行结果回填前一定要做长度截断。否则你的技能可能成功执行了但模型读不到关键信息反而会编造一个错误的结果。4. 实战中的坑问题排查与避坑指南4.1 技能描述写不好模型就会瞎选这是我在实际项目里踩得最深的一个坑。技能描述看起来是件小事但它的质量几乎决定模型选技能的正确率。踩坑记录早期我写过一些“大而全”的技能比如一个名叫process_data的技能description写的是“处理各类用户数据”。模型确实常选它但每次选完都不知道该传什么参数参数校验经常报错用户看得到技能在转圈但得不到结果。后来把技能拆成了validate_email_list、clean_duplicate_records、convert_date_format三个独立接口每个接口的描述写清触发条件和输出格式调用成功率从40%升到85%以上。现在我给每个技能写描述之前都会问三个问题用户这句话会怎么表达哪些词是触发这个技能的关键信号哪些相邻技能需要明确排除把这三个问题回答清楚再动笔。4.2 参数过载一次调用塞太多信息第二个高频坑是参数Schema设计不合理。模型在生成结构化参数时它的输出长度和能力边界是有限的。如果你给一个技能定义了20个字段必然会出现字段遗漏、嵌套结构错误、枚举值乱写等问题。举一个真实例子一个“发送邮件”技能最初参数包含收件人列表、抄送列表、密送列表、附件、正文模板变量、定时发送时间、优先级、回执要求……字段一多模型就频繁在附件路径和模板变量格式上出错。优化方式把必填参数压到3个以内其他全部用默认值承载。收件人、主题、正文是必填其他都放到配置对象里由技能内部读取默认配置。参数Schema里同一层级的嵌套不要超过2层否则模型生成的JSON结构很容易错位。4.3 技能冲突与模糊路由当两个技能的功能边界有重叠时模型很容易踩到“灰色地带”。比如我的项目里同时有“获取股票行情”和“获取股票基本面”两个技能面对“帮我看看某公司股票”这个输入模型有大约30%的概率选错。这本质上是领域切分没做好。我的处理办法有三种按优先级排序把容易混淆的技能合并成一个内部通过参数区分模式。比如merge成“获取股票信息”带mode字段取值quote或fundamental。如果业务上必须分开就给每个技能补一条“不要使用”的说明把灰色场景明确分配给其中一个。在技能选择器里加规则当输入同时命中两个技能时默认优先选择更高频或更细粒度的技能。4.4 并发、超时与资源清理最后一个坑来自运行时。技能里经常有外部HTTP调用、临时文件读写、数据库连接如果并发执行时不处理超时和清理服务端资源会被慢慢耗尽。我的工程实践是给每个技能执行包裹独立超时控制默认HTTP调用8秒整体技能30秒。临时文件统一走临时目录技能结束时自动清理避免磁盘占满。对技能做并发度控制用池化方式限制同时执行的技能数比如最多3个其他排队。实测并发太高时LLM响应和外部API的延迟都会显著劣化。注意技能执行必须有幂等性设计。同一个技能用相同参数执行两次其结果应该一致。否则Agent在重试或者回退恢复时可能出现数据重复写入等事故。5. 进阶玩法从单个技能到技能编排5.1 组合技能把多个原子操作串成一条流水线当你有了足够多的原子技能下一个自然需求就是“组合技能”。比如用户问“把我桌面上所有PDF转成Word并发送到邮箱”这涉及三步扫描目录、格式转换、发送邮件。组合技能的核心在于两点定义内部执行的有向顺序以及定义提前终止的条件。实现方式并不复杂在技能定义里加一个steps字段引擎按序执行任一步失败即中止并返回错误信息。这比让模型一步步自由调用更有优势组合技能是确定的可测试的。你可以针对整条流水线写单元测试而不是赌模型的每一步都选对。5.2 技能路由给不同场景分配不同技能包不同场景会触发不同的技能集合。以客服Agent为例在“售前咨询”状态下模型只需要看到商品查询、库存查询、优惠计算这几个技能在“售后处理”状态下模型需要看到退款申请、物流跟踪、工单创建这些技能。实现路由的方式是在执行引擎里维护一个当前技能域标当预处理模块识别到场景切换时改变模型的可见技能集。我用一个比较轻量的做法把技能选择器升级为“场景路由”先在系统提示里声明当前场景再对查询做意图分类。分类置信度不高时兜底开放所有技能。这套方案把单轮调用的工具payload从2KB降到了500B左右调用成本显著下降。5.3 用日志和评测持续打磨技能库技能库不是写一次就完成的。它需要随真实使用持续迭代迭代依据就是日志。我建议每个技能记录四类数据调用参数全文、执行结果状态、成功失败标签、错误摘要。每周固定时间跑一次统计关注的指标包括各技能调用频率分布找出“僵尸技能”低频但占空间的存在意义存疑。失败率Top 3的技能按错误类型分类排查是参数问题、外部依赖问题还是描述误导问题。模型选择错误的案例检查是否与技能描述边界有关。平均token消耗趋势防止上下文管理退化。有了这些数据技能库的迭代就不是拍脑袋而是有据可查的持续优化。5.4 团队沉淀技能库的流程建议如果这个项目要团队协作建议从一开始就约定好版本管理和评审机制。技能定义存在版本控制系统中每次变更配套更新该技能的适配测试。新增技能走“提案→原型→压测→上架”的流程。灰度策略也要考虑新技能先对5%流量开放观察无异常再全量放量。团队里的角色分工大概是这样平台负责人维护执行引擎和路由逻辑各业务成员负责自己域内技能的开发和维护另一个人负责日志分析和质量指标汇总。这个流程能有效避免“技能库变成垃圾场”。我在项目中见过太多没有评审机制的自由发展现场最终结果就是技能描述混乱、参数格式不统一、执行结果互相矛盾整个Agent系统的可信度被不断拉低。最后分享一个我个人的实际体会在agent-skills这类项目里真正决定上限的往往不是模型能力的强弱而是技能层的组织水平。技能定义得像产品文档而不是代码片段Agent的稳定度就会上一个台阶。每当你发现Agent在“看似聪明却不可控”的边缘徘徊先别急着换模型回头看看技能库是否还有拆解和整理的余地这个方向通常比换模型更解决问题。