ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Agent技能库设计与实践:从SKILL.md到Function Calling的完整落地指南

Agent技能库设计与实践:从SKILL.md到Function Calling的完整落地指南 1. 为什么Agent最终都会走向技能库这条路线先说我自己的经历。去年我做过一个客服问答Agent一开始的实现方式非常朴素把业务规则、话术模板、查询逻辑全部写进System Prompt再配上几个Python函数。Demo阶段一切正常模型回答得头头是道。等放到生产环境跑了两周问题全来了——上下文越塞越长单次请求的Token开销翻了几倍规则之间开始互相覆盖模型时不时把老版本的话术当成最新政策输出。最头疼的是权限边界只能靠提示词约束模型一旦自由发挥就可能去调不该调的接口。后来我把这套东西彻底重构核心思路只有一个把Agent的所有能力从Prompt里抽出来做成一个个独立的、可被发现、可被加载、可被执行的技能文件。每个技能都有自己的名称、描述、参数契约、实现代码和运行约束Agent在对话中按需选择和调用。这个形态在社区里通常叫agent-skills它本质上不是某个具体框架而是一套组织Agent能力的方法论。1.1 从堆Prompt到给工具的切换很多人会问我不做技能库直接在代码里写一堆if 用户提到天气: call_weather_api()不行吗答案是行但那不叫Agent叫流程引擎。Agent的灵魂在于模型自己决定调用什么工具、以什么顺序调用。技能库就是给模型提供一份可选能力清单同时把这份清单做成机器可读的结构化数据让模型能准确理解每个技能是干什么的、什么时候该用、参数长什么样。这次切换给我带来的变化很直接。原先改一条业务规则要重新梳理整个Prompt后面的人根本不敢动现在改某个技能只需要动对应目录下的SKILL.md和实现脚本不影响其他能力。原先模型因为上下文溢出开始胡言乱语现在每条技能只在需要时才注入上下文中长期只有路由信息和当前任务相关的几段描述整体稳定性提升非常明显。1.2 技能库到底解决了哪三个问题第一是复用。一个写好的网页搜索技能可以同时给客服Agent、数据分析Agent、内容生成Agent用不用每个项目重新实现一遍。第二是可控。技能运行时的超时时间、网络权限、内存上限、可访问的外部服务都集中在技能定义里由执行引擎强制校验不再靠模型的自觉。第三是可观测。每次技能调用都可以记录入参、出参、耗时、Token消耗出问题时能顺着链路查而不是对着黑盒猜。对比一下两种组织方式对比项全量Prompt 散装函数技能库 注册中心上下文开销所有规则常驻随业务膨胀按需注入只带相关技能更新维护改一条规则可能牵一发动全身技能独立版本独立发布权限边界靠提示词约束不可靠执行层强制写死在代码里可观测性函数内部自己打日志没有统一格式统一入参出参记录统一追踪这套东西踩过坑之后回头看真不是花架子。等技能数量上了50个你会发现如何组织技能这个工程问题比如何写某个技能重要得多而技能库恰好就是回答这个问题的。2. 先把技能清单定下来SKILL.md的字段设计技能库的第一步不是写代码而是定义一份技能的元信息规范。我见过不少团队直接跳过这一步把技能说明写在代码注释里或者塞在一个巨大的JSON配置文件里最后维护成本高到离谱。我的做法是给每个技能单独建一个目录目录下放一个SKILL.md作为技能的身份证再放一个实现脚本。SKILL.md用Markdown或YAML写都可以关键是字段要稳定。2.1 一份能用但不啰嗦的SKILL.md长什么样下面是我目前在项目里使用的最小可用版本基于YAML格式字段不多但每个都有明确用途name: web_search description: 在互联网上执行公开网页搜索返回标题、链接和摘要。当用户询问实时信息、热点事件或需要外部资料佐证时使用。不适用于内部数据库查询。 version: 1.2.0 author: agent-team timeout_seconds: 15 allow_network: true allow_files: false parameters: type: object properties: query: type: string description: 搜索关键词建议限定到具体实体或事件 max_results: type: integer description: 返回结果条数默认5最大10 default: 5 required: - query entry: impl.py简单解释一下每个字段的意图。name是全局唯一的技能标识模型调用时靠它定位。description是给模型看的使用说明书这一段写得好不好直接决定模型能不能在正确场景下选中这个技能我后面单独讲。timeout_seconds和allow_network属于运行约束执行引擎会强制生效不依赖模型自觉。parameters是标准的JSON Schema既给模型提供参数结构参考也给执行层做入参校验。entry指定实际执行入口我这个实现里是Python脚本。2.2 description怎么写LLM的命中率差很远这是个经常被低估的点。同样的技能描述写得好和写得差实际调用准确率能差出30%以上。我见过最差的写法是只写一句话比如搜索或者网页搜索工具模型确实知道这是个搜索功能但完全不知道什么场景该用它、什么场景不该用它于是经常出现模型明明在回答内部业务问题却莫名其妙调起了网上搜索。我总结了一个三段式写法实测效果最好第一句写能力边界这个技能能做什么输入是什么输出是什么。第二句写适用场景什么样的用户请求应该触发这个技能。第三句写明确禁区什么情况下不要用这个技能。拿上面的web_search举例第一句是在互联网上执行公开网页搜索返回标题、链接和摘要第二句是当用户询问实时信息、热点事件或需要外部资料佐证时使用第三句是不适用于内部数据库查询。模型在做函数调用选择时会对候选技能的description做语义匹配写得越具体匹配越准确。参数部分同样需要详细描述。以query为例我写的是搜索关键词建议限定到具体实体或事件这看起来是给模型看的废话但实际能显著减少模型传一些泛泛的、无法检索的词。max_results我给了默认值和上限避免模型传一个超大数字导致接口超时。2.3 技能清单的版本管理与权限声明一个容易被忽略的问题是版本。技能不是写完就不变的搜索服务的接口可能升级业务规则可能调整。我建议在SKILL.md里带version字段注册中心在加载时记录版本号调用日志里也带上版本这样线上出问题时能快速定位是哪个版本的技能在运行。我们团队现在要求每次改技能必须升版本号否则CI直接拦截一开始觉得麻烦后来发现排查问题时间省了一大半。权限声明这块我的原则是默认拒绝。技能定义里明确写出allow_network、allow_files这些开关执行引擎只按声明放行没声明的资源一律拒绝访问。比如一个负责格式化文本的技能根本不需要联网那就把allow_network设为false即使实现代码里不小心写了网络请求执行层也会直接拦下来。这种代码层强制比任何提示词约束都可靠。3. 技能注册中心与执行沙箱让技能真正能跑起来元信息规范定型之后下一步就是把技能从一堆文件变成可被Agent调用的运行时能力。我这边做了一个轻量的注册中心核心职责是三个扫描目录、校验技能声明、按需加载执行。不依赖任何重量级框架Python标准库加少量辅助库就能跑。3.1 目录扫描与注册流程我习惯把所有技能放在一个skeletons目录下每个技能一个子目录注册中心启动时递归扫描。扫描的过程不复杂关键是校验逻辑要全import os import yaml from pathlib import Path from typing import Dict, Optional class SkillRegistry: def __init__(self, skills_dir: str): self.skills_dir Path(skills_dir) self.skills: Dict[str, dict] {} def scan(self) - None: for skill_path in self.skills_dir.iterdir(): if not skill_path.is_dir(): continue manifest_path skill_path / SKILL.md if not manifest_path.exists(): continue manifest yaml.safe_load(manifest_path.read_text(encodingutf-8)) self._validate(manifest, skill_path) self.skills[manifest[name]] { manifest: manifest, path: skill_path, } print(fregistered {len(self.skills)} skills) def _validate(self, manifest: dict, skill_path: Path) - None: required_fields [name, description, entry, parameters] for field in required_fields: if field not in manifest: raise ValueError(f技能缺少字段 {field}: {skill_path}) entry skill_path / manifest[entry] if not entry.exists(): raise ValueError(f技能入口文件不存在: {entry}) params manifest[parameters] if type not in params or params[type] ! object: raise ValueError(f技能参数必须为object类型: {manifest[name]})这里我在启动时做全量扫描和校验宁可启动慢一点也不允许一个坏技能混进来。校验重点包括必填字段、入口文件是否存在、参数是否为对象类型。有几次线上事故就是因为技能目录里缺了入口文件模型还在正常调用结果一执行就报错。启动期强校验能直接把这类问题挡在门外。3.2 沙箱执行我能给的安全边界执行层面我推荐用子进程隔离而不是在Agent主进程里直接import技能代码。理由很简单技能代码可能来自第三方可能有Bug也可能包含恶意逻辑直接在主进程跑一次内存溢出或一个死循环就能拖垮整个Agent服务。子进程至少能把崩溃范围隔离开配合超时机制最坏情况也就是那个技能调用失败不影响主进程。我目前的执行器实现大概是这样的import json import subprocess import sys def run_skill(skill_name: str, params: dict, registry: SkillRegistry) - dict: skill_info registry.skills[skill_name] manifest skill_info[manifest] entry_path skill_info[path] / manifest[entry] # 入参校验防止模型幻觉参数直接落到脚本里 schema manifest[parameters] jsonschema.validate(params, schema) payload json.dumps(params, ensure_asciiFalse) timeout manifest.get(timeout_seconds, 10) try: result subprocess.run( [sys.executable, str(entry_path), payload], capture_outputTrue, textTrue, timeouttimeout, ) except subprocess.TimeoutExpired: return {ok: False, error: f技能执行超时{timeout}s} if result.returncode ! 0: return {ok: False, error: result.stderr[-500:]} try: return {ok: True, data: json.loads(result.stdout)} except json.JSONDecodeError: return {ok: False, error: 技能输出不是合法JSON}几个关键点。第一是入参校验jsonschema.validate这一步必须有模型生成的参数偶尔会多传、漏传、传错类型不校验直接进脚本错误信息会非常难看而且排查成本高。第二是超时技能定义里的timeout_seconds在这里真正生效我一般给网络类技能10到15秒纯计算类技能3秒宁紧勿松。第三是输出格式我强制要求技能以JSON字符串写到stdout这样主进程解析简单技能实现者也不用关心框架怎么调用只需要读入JSON参数输出JSON结果。这种沙箱级别属于线上工程隔离不是高安全场景下的内核级沙箱。如果技能涉及安全敏感操作还得配合容器或更严格的权限模型。4. 接到大模型调用链路上协议转换与动态编排技能注册中心只是底座真正让技能发挥作用的是把它接进大模型的调用链路。大多数模型厂商的Function Calling协议都要求传一个工具列表每个工具包含name、description和parameters。这不就是技能清单里已有的字段吗所以核心工作其实是协议转换把技能库里的SKILL.md翻译成模型API认识的Tool Schema。4.1 把SKILL.md翻译成Function Calling Schema这个转换函数非常直接def to_tool_schema(skill_info: dict) - dict: manifest skill_info[manifest] return { type: function, function: { name: manifest[name], description: manifest[description], parameters: manifest[parameters], } } def build_tool_list(registry: SkillRegistry, skill_names: list[str]) - list[dict]: return [to_tool_schema(registry.skills[name]) for name in skill_names]注意这里有一个容易被忽略的点每次请求不要把注册中心里的所有技能都塞给模型。一是Token开销大50个技能的描述加起来可能上万Token二是候选太多模型选错技能的概率会上升检索精度会下降。所以我在调用模型前会先做一次技能筛选只选当前对话最相关的5到8个技能再转换成Tool Schema传给模型。4.2 按需注入不要在每次请求里塞全部技能技能筛选我用的是最朴素但有效率的方式——向量检索。预先把每个技能的description和名称拼成文本用Embedding模型转成向量存起来每次请求来的时候把当前用户问题也转成向量去技能向量库里做余弦相似度检索取Top N再设定一个相似度阈值低于阈值的技能即使进了Top N也不注入。def retrieve_skills(query: str, skill_vectors: dict, top_n: int 5) - list[str]: query_vec embed([query])[0] scored [] for skill_name, skill_vec in skill_vectors.items(): score cosine_similarity(query_vec, skill_vec) scored.append((skill_name, score)) scored.sort(keylambda x: x[1], reverseTrue) # 只保留相似度大于0.30的技能 return [name for name, score in scored[:top_n] if score 0.30]阈值0.30是我根据实际数据调的不同Embedding模型的分布不同建议上线前拿一批真实对话样本跑一遍看看命中的技能相似度大概落在什么区间。另外要处理一个细节如果用户多轮对话里已经明确用过某个技能那后续轮次应该持续保留它不能因为这一轮问题短就把它筛掉了。我通常在会话上下文中维护一个已使用技能集合检索结果与它做并集再注入。4.3 技能的组合与失败降级单个技能调用很简单模型在Function Calling结果里返回技能名和参数执行器跑完把结果回传给模型生成最终回答。复杂场景是技能之间的组合。比如用户问今天某公司股价怎么样理想链路是先调web_search找到相关新闻再调web_scraper抓取具体页面内容最后可能还要调text_summarizer生成摘要。这种多技能组合模型自己会通过多轮Function Calling来完成但我们的执行器需要支持在同一轮请求里多次调用技能的能力。我实现了一个轻量的编排循环核心逻辑是把初始技能列表注入模型模型返回工具调用请求执行对应技能把执行结果回传模型如果模型继续要求调用工具重复执行直到模型返回最终回答或达到最大轮次这里有个关键参数最大工具调用轮次。我默认设5防止模型在一个问题上无限循环。降级策略也很重要某个技能失败了不要让整个对话崩溃。我的做法是把失败信息拼成{ok: false, error: ...}回传给模型让模型判断是换一种方式再试还是直接用已有信息回答。实测下来搜索技能偶尔失败模型会自己决定重试一次或者根据搜索结果标题先给出初步答复体验比直接抛异常好得多。5. 技能数量到50个之后真实踩过的坑技能库刚搭好的前几周一切都很美好。等到技能数量冲上50个问题开始集中爆发。我在这里把踩过的坑按严重程度列出来给正在做类似项目的人提个醒。5.1 相似技能相互抢占模型开始串台最典型的问题库里有web_search、news_search、document_search三个技能描述都提到搜索模型经常选错。比如用户问帮我查一下公司内部文档里关于报销的规定模型可能去调web_search而不是document_search。我排查后发现根因是两个技能的description太像了语义空间高度重叠向量检索和模型选择都分不清。解决办法是给每个技能定义更窄的边界同时把禁区写清楚技能适用场景描述中必须强调的边界web_search公开网页、实时信息不适用于内部数据库、本地文件document_search内部知识库、公司文档仅搜索内部文档系统不访问互联网news_search新闻媒体、突发事件仅返回新闻类来源不返回一般网页另外我把技能筛选的Top N从8降到了5候选少了模型干扰也少了。不要觉得5个不够用多数对话真正用到的技能就两三个与其让模型从20个里挑不如让它从5个里挑。5.2 JSON Schema校验没做参数幻觉直接落到脚本有一次线上事故用户问帮我查最近三天北京和上海的天气Agent连续调了两次天气技能都报错。查日志发现模型第一次传的city参数是[北京,上海]而我的Schema定义的是string类型数组直接被执行器的jsonschema.validate拦下第二次模型把日期参数2025-01-01传成了最近三天同样被校验拦下。这两次失败本来可以靠模型重试修正参数挽回但当时我没有把校验失败信息清晰回传给模型模型不知道错在哪只能反复用错误参数重试。这个坑让我明白两件事。第一参数校验不能省略模型在复杂对话中产生参数幻觉是常态不校验就是把脏数据直接喂给脚本。第二校验失败的信息要结构化我现在的做法是返回{ok: false, error: 参数校验失败: city必须是字符串期望类型string实际类型array}这样模型能读懂错误原因下一步才有可能修正参数。在把这段错误信息喂回模型时我还会在消息里附一句请根据错误信息修正参数后重试实测重试成功率提高了很多。5.3 观测缺失技能执行失败根本查不到原因技能数量一多每天有几百上千次调用没有统一的可观测性根本没法排查。我最早只在技能执行器里打了print日志线上服务日志一刷屏什么都找不到。后来做了三件事效果立竿见影每次Agent请求生成一个trace_id贯穿模型调用、技能检索、技能执行全程每个技能调用记录一份结构化日志包含技能名、版本、入参、返回状态、耗时、Token数技能执行失败时额外记录错误类型和堆栈前几行def log_skill_call(trace_id, skill_name, version, params, result, duration_ms): log_data { trace_id: trace_id, event: skill_call, skill_name: skill_name, version: version, params_preview: json.dumps(params, ensure_asciiFalse)[:200], ok: result.get(ok), duration_ms: duration_ms, } if not result.get(ok): log_data[error] result.get(error) logger.info(json.dumps(log_data, ensure_asciiFalse))我才意识到技能库的工程价值和技能数量的平方成正比数量少时随便写都能跑数量一多规范、校验、可观测性这些底层能力才是真正撑住系统不塌的东西。我在实际项目里的体会是agent-skills这套体系的落地难点从来不在写某个具体技能而在于一开始就定好元信息规范、注册流程、执行沙箱和检索链路。把这些骨架搭对了后面加技能就是往里填内容的事模型调用准确率、系统稳定性、团队协作效率都会有质的提升。如果你正在被Agent能力越加越乱这个问题困扰不妨从一份SKILL.md开始把一个技能抽象好再逐步推广到全部能力。
返回列表