
我做了几年大模型应用开发碰过不少智能体项目也踩过很多坑。坦白说很多项目死掉的原因不是模型不够强而是Agent根本不知道怎么调用能力——工具散落在代码里没有统一管理参数靠硬编码模型一换或者技能一多就崩。这个标题里的agent-skills看起来只是普通名词但如果你把它当成一个独立的工程模块来设计整个Agent项目的稳定性、扩展性和可维护性都会完全不一样。这篇文章我想以agent-skills这个项目名为切入点拆解一套通用的Agent技能库设计思路技能如何注册、如何描述、如何动态加载、如何编排调用以及我在实操过程中遇到的典型问题和对应解法。不管你是刚接触Agent开发还是已经在生产环境里维护多个智能体这套思路都能直接借用不需要重写主线代码。1. Agent技能系统这个项目到底在解决什么问题1.1 从能对话到能干活差的不是模型而是技能层很多人习惯把Agent能力等同于大模型能力模型强了Agent自然强但这个认知在工程落地时是严重误导的。模型本身只会做两件事理解文本、生成文本。它没办法发HTTP请求没办法读写本地文件没办法执行一个Python脚本去算数据。要让Agent真正完成一个任务比如查天气、下订单、跑数据分析必须把外部能力封装成它可调用的技能。所谓技能其实就是一组可以被模型感知和调用的函数或工具配合上清晰的描述、参数定义和执行结果。诸多个技能组合起来才构成了Agent的动手能力。这里有个非常隐蔽的工程问题技能不是堆得越多越好技能之间也不是割裂的。如果没有一个合理的组织方式技能多了以后会出现几种恶心的情况——名字冲突、参数互相覆盖、模型不知道该调用哪个技能、技能调用结果无法回传给后续步骤。这些问题本质上都是技能层的架构混乱。agent-skills这个项目核心价值就是把这些散落的函数调用整合成一个可控、可扩展、可观测的体系。1.2 边界划定技能库不等于工作流编排做这个项目之前我先是把边界想清楚的因为Agent相关概念的软件很容易越做越臃肿。技能库完成的是能力封装与暴露它回答的是Agent能做什么、用什么参数做、结果长什么样。至于Agent下一步该做什么、多个技能怎么串联决策那是工作流编排层比如我的主控制循环或者外部编排框架的事情不该塞进技能库里。这个边界决定了很多细节设计技能模块只负责单个能力单元的实现和元信息声明不做任务级的状态管理不持有跨技能的共享状态也不主动去理解用户意图。理解意图是LLM的任务技能模块只需要被理解、被调用、被验证。把这些边界划清楚了后续扩展才不会陷入每个技能都要管全局状态的泥潭。2. 整体架构设计一个轻量但完整的技能库需要哪些模块2.1 技能注册中心一切的入口技能库的第一件事是登记。每个技能必须有一个唯一名字、一段供模型阅读的功能描述、一份参数schema以及一个可执行实现。注册中心维护着这三者的映射关系并提供查询接口按名称查、按关键词查、列出全部技能。我参考了常见插件系统的做法把注册中心设计成一个简单的dict 装饰器风格。代码大概长这样# registry.py _skills {} def register_skill(name, descriptionNone, schemaNone): 装饰器将一个函数注册为Agent技能 def decorator(func): skill_name name if name else func.__name__ _skills[skill_name] { name: skill_name, description: description or func.__doc__ or , schema: schema or _infer_schema(func), impl: func, } return func return decorator def get_skill(name): return _skills.get(name) def list_skills(): return [v[name] for v in _skills.values()] def skill_prompt_block(): 生成供模型使用的技能列表文本 lines [] for name, meta in _skills.items(): lines.append(f- {name}: {meta[description]}) return \n.join(lines)你会发现注册中心本身没做任何复杂的事情它只是一个集中的登记簿。但这已经解决了几个痛点技能被集中管理模型侧的提示词可以从注册中心生成不用每次手写一长串工具列表同时调用逻辑从主控制流里剥离主程序不需要在代码里逐个写if-else来判断调用哪个函数。这就是注册中心的实际价值。2.2 描述即接口模型是靠文字不是靠签名理解技能的传统开发者写函数最关注的是类型签名和入参出参。但Agent场景有个反直觉的地方代码签名模型看不见模型只能看见我们写给它的文字描述。所以技能描述的质量直接决定了模型决策的准确率。在实际测试中我发现描述写得含糊的技能模型经常误用或者跳过。比如有个技能表面上叫send_email描述只写Send an email结果模型无论用户问什么都想用它。后来我把描述改成Send an email to a recipient. Should be used when user explicitly asks to send a message via email. Requires recipient address. Do not use this skill for other purposes.误调用的概率大幅下降。这里有一个值得细化的技巧描述里要写清楚什么时候该用同时也要写清楚什么时候不该用。不该用这类反面约束往往比正面描述更能减少误调用。这属于典型的经验层面知识写工具文档时很少有人会提。2.3 动态加载机制技能库不能靠改主代码来扩展项目初期我直接把所有技能写在同一个文件里主控制流import全部函数。技能数量到三四十个以后这种做法就很痛苦改一个技能要重启整个服务技能之间偶尔还有命名冲突。后面我把技能改成目录 文件的物理结构配合动态加载skills/ __init__.py email_tools.py data_analysis.py web_search.py动态加载逻辑用一个loader.py实现启动时扫描目录逐个模块导入然后把模块里用装饰器注册的技能批量回收进注册中心import importlib import pkgutil import skills def load_all_skills(): for mod_info in pkgutil.iter_modules(skills.__path__): if mod_info.name.startswith(_): continue importlib.import_module(fskills.{mod_info.name})这个做法的好处是新增技能只需要在skills/目录下新建一个文件登记好技能函数重启后自动生效主控制流一行代码都不用改。即使哪天要支持热更新在这个结构上加一个文件监听机制就行了。3. 核心实现细节技能函数怎么写才真正符合Agent工作需要3.1 参数Schema绑定类型与校验而不是靠模型猜技能函数和普通函数的其中一个关键区别是普通函数的参数由调用方明确传入技能函数的参数由模型根据用户对话生成。这里就存在一个风险——模型可能会生成完全不合法的参数比如把字段名拼错、把字符串当成数字、甚至漏掉必填参数。所以技能函数一开始就必须设计好参数机制。我在项目中采用了接近JSON Schema风格的声明方式。它给模型提供了非常明确的调用格式约束同时还能天然地做运行时校验。下面是我在项目中使用的Schema示例# weather_tools.py register_skill( nameget_weather, description( Get current weather information for a given city. Use this only when the user asks about weather. Do not use this for climate or forecast questions. ), schema{ type: object, properties: { city: { type: string, description: City name in Chinese or English, e.g. 北京 or Beijing, }, unit: { type: string, enum: [celsius, fahrenheit], default: celsius, }, }, required: [city], }, ) def get_weather(city: str, unit: str celsius): # 真正的实现调用天气服务、解析返回 return {city: city, unit: unit, temp: 24, desc: 晴}这里有一个细节值得注意required字段只写[city]因为unit有默认值。如果我把两个字段都标成必填模型就可能为了凑数乱填unit。Agent场景下的原则是尽量降低模型做选择的成本——能给默认值的就给默认值。3.2 结果返回统一化把结构带回控制流技能执行完之后返回结果会有三种类型纯文本、结构化JSON、异常。主控制流在拿到结果后需要决定下一步怎么走所以结果格式必须高度统一。我把技能返回结构约束为{ ok: true, data: {...}, # 或直接放文本 error: None, context_hint: The weather in Beijing is 24C, sunny, }其中context_hint是给模型看的精简摘要它非常重要。模型在多轮对话中会把技能结果作为上下文继续推理如果直接把整个原始JSON塞进上下文既浪费tokens又容易让模型被无关字段干扰。我在每个技能返回时额外生成一段一句话摘要主控制流拿到后再拼回提示词里。可能有人觉得这样有点重复但实操中收益很大。比如数据查询技能返回了几十行记录原始数据需要持久化也可能要触达用户而上下文中只需要查询到5条订单总金额为3200元这种摘要。模型拿到摘要后就能决定追问还是继续。3.3 技能编排组合能力从何而来单个技能能做的事有限Agent要解决真实需求往往需要多个技能按顺序配合。比如用户问北京和上海今天谁的天气更适合户外跑步如果技能库里有get_weather和compare_cities主控制流就要安排两次天气查询再做对比。技能组合的编排逻辑我在项目里没有硬编码到技能模块内部而是交给主控制流去处理。技能层只做两件事暴露能力、返回结构化的可消费结果。这避免了一个经典设计错误——把编排逻辑塞进技能函数里。以下是我使用的主控制循环的逻辑骨架用于串联多技能调用def agent_loop(user_input): messages [ {role: system, content: You are an agent. Available skills:\n skill_prompt_block()}, {role: user, content: user_input}, ] for step in range(MAX_STEPS): response llm.chat(messages, toolsorchestration_tool_schema()) if response.is_tool_call(): skill_name response.tool_call.name args json.loads(response.tool_call.arguments) result execute_skill(skill_name, args) # 含校验 messages.extend(response.tool_message(), result_to_message(result)) continue return response.text这就是Agent中标准的tool-calling pattern但有几个容易出错的细节点skill_prompt_block()和orchestration_tool_schema()需要保持字段一致调用技能的参数必须经过JSON解析和schema校验解析失败要回传错误信息让模型修正而不是直接崩溃。4. 实操记录实装一个可以跑的技能库测试3个典型场景4.1 最小闭环写三个实用技能跑通全流程为了验证架构可用性我搭了一个最小技能库包含三个技能查天气、查当前时间、调用外部API抓数据。下面是核心代码# time_tools.py import datetime register_skill( nameget_current_time, descriptionGet current datetime. Use this when user asks about time or date., schema{ type: object, properties: {}, required: [], }, ) def get_current_time(): now datetime.datetime.now() return { ok: True, data: now.isoformat(), context_hint: fCurrent time is {now.strftime(%Y-%m-%d %H:%M:%S)}, }# api_tools.py import requests register_skill( namefetch_web_json, description( Fetch JSON data from a URL. Use this when user asks about live data from a specific public API. Requires a url. ), schema{ type: object, properties: { url: {type: string, format: uri}, }, required: [url], }, ) def fetch_web_json(url: str): resp requests.get(url, timeout10) resp.raise_for_status() data resp.json() return { ok: True, data: data, context_hint: fFetched JSON from {url}, top-level keys: {list(data)[:5]}, }这3个技能加起来的代码量很小但它们已经覆盖了Agent技能设计中最核心的几种情形无参技能、有参技能、外部副作用技能。跑通之后主控制循环就能借助这些技能回答时间类、天气类、数据类问题。如果你自己要做类似模块我建议也先从这个最小闭环开始不要一开始就追求技能数量先把注册-调用-回传这条链路跑顺。4.2 测试场景一多技能连续调用我实际测试了一个典型请求用户北京天气怎么样顺便告诉我明天几点日出。模型第一步调用了get_weather拿到了天气数据第二步尝试调用一个日出时间技能但我的技能库里没有这个技能。这里出现了一个非常有趣的失败模式模型并没有直接告诉用户做不到而是把fetch_web_json伪造了一个参数、试图用外部API拼出一个日出时间。虽然fetch_web_json确实能调用API但这明显超出了技能描述的负责任范围。核心控制流通过schema校验拦下了这一步——因为url虽然是合法的API地址但语义上与用户问题不匹配。从技术角度校验通不过不是目前的落点但让我发现一个大问题模型在找不到合适技能时会将就拿一个不相关技能凑合回答。所以后来我给系统提示词加了一条明确约束如果你没有任何技能可以处理该请求直接回复无法处理不要尝试用其他技能代替。这个案例不是孤例在真实场景中更常见的是模型拿搜索技能硬套所有问题。这类技能误用问题单靠校验挡不住必须在提示词层明确边界。这个经验在文档里很难找到属于实践后才能积累的细节。4.3 测试场景二参数校验与错误回传另一个高频问题场景是用户输入不完整。例如用户帮我查一下上海的明天天气。这个输入里其实缺少具体时间点当前时间明天但我的天气技能只接受city和unit所以模型可能生成一个缺少必要字段的调用或者自行脑补一个城市名。在这个项目里我设置了一套校验返回逻辑def execute_skill(name, args): if name not in _skills: return {ok: False, error: fUnknown skill: {name}} meta _skills[name] valid, errors validate_schema(args, meta[schema]) if not valid: return { ok: False, error: fInvalid arguments: {errors}, context_hint: fArguments did not match schema for {name}. Correct your arguments and retry., } result meta[impl](**args) return normalize_result(result)校验失败时错误信息会被回传给模型修正参数而不是直接终止对话。这个设计非常重要。模型拿到arguments did not match schema后往往会自己纠正——比如发现自己没有传city重新生成一次带city的调用。这套自纠错机制让整个技能调用的成功率提升了非常多。4.4 测试场景三技能之间的状态隔离第三种典型场景是多轮对话中的状态问题。比如用户先问北京天气怎么样又追问那上海呢模型如果不在上下文里带上上海的候选第二轮的技能调用可能返回未知城市。此时技能库本身不应该保存上一次查询的城市这是主控制流的记忆职责。我在技能模块里明确禁止了跨技能共享状态——每个技能都应该是无状态的状态全部放在主控制流的对话历史里。这个约束保证了任意技能都可以被独立测试、独立复用不会互相污染。5. 实际运行中的问题与排查速查表5.1 技能被识别为存在但调用时总是失败这类问题我遇到过几次比较典型的根因注册的时候名字用了驼峰风格但在提示词展示时又转换成snake_case模型生成的调用名和注册名对不上。或者装饰器注册的description里包含了换行导致模型生成的arguments缺失了某个必填字段。排查方向先打印list_skills()看实际注册名再打印skill_prompt_block()确认模型实际看到的描述文本不要把锅甩给模型。多数情况是注册信息和提示词信息不一致。5.2 模型生成参数合法但语义不对这在agent-skills里最难排查——Schema校验全通过结果也执行了但Agent做的事和用户需求不是同一件事。比如用户要求查询半小时后的天气模型却调用当前天气技能。我的排查思路是去看模型调用技能前的那一轮完整上下文。通常问题出在系统提示词里没有附带当前时间模型不知道半小时后具体指向哪个时刻。这种问题就得在技能描述或系统上下文里补充时间信息而不是让模型猜。比如在天气技能描述里明确写当前时间为2023年...可支持查询指定日期模型就会自动套用当天日期。5.3 技能库膨胀到几十个之后模型出现选择困难技能少的时候模型选得很准技能到了40~50个后开始频繁漏选或混选。我试过几种缓解方案方案效果适用场景分组描述按类别汇总有效降低漏选率技能数量多、类别清晰的长期项目精简每个技能的描述字数中等改善但对描述质量要求高技能数量中等、单技能职责明确动态路由先用一个轻量模型粗筛效果较好但增加一层推理延迟技能类别差异极大时全部技能都保留完整描述在约20个技能以内效果尚可小规模项目我最推荐的做法是层次化描述模型只先看技能类别列表选定类别后再看该类别的详细技能列表而不是一次性把所有技能细节全塞给模型。这个方案不需要额外模型推理只是提示词组织的改变实测下来稳定很多。5.4 各类型的常见故障排查表现象可能原因处理方式技能列表为空动态加载模块没被执行检查load_all_skills()是否在启动时调用模型调用了不存在的技能名提示词里旧技能没清理注册表里已删除统一从注册中心生成提示词不同步手写参数校验失败schema的required设置过严或描述没给字段示例放宽必填项并在描述里补充字段填写示例技能执行抛异常外部依赖超时、网络异常、API key错误在execute_skill里统一捕获异常并转成ok:false返回模型反复调用同一技能不退出结果被截断或者结果摘要不足以支持决策检查返回结果是否被完整拼到上下文必要时剪短摘要这个速查表我每次新接一个Agent项目都会翻出来看基本百分之八十的模型变笨了技能动不动就崩都是这些原因。我个人在实际操作中的体会是Agent技能库这个模块真正难的不是写那几十个函数而是把注册—描述—校验—回传这个链路做到极致的确定性。大模型的输出天然带有随机性技能层必须作为系统里最稳定、最可控的部分存在它不是用来表演智能的而是用来兜底和提供锚点的。最后再分享一个很实用的小技巧在技能库里加一个日志装饰器每次技能被调用时自动记录模型生成的原始arguments和执行耗时。这些日志在项目上线初期极其宝贵——你会看到真实场景里模型到底是怎么理解你的描述的哪些描述含糊、哪些参数模型经常填错都能从日志里一眼看出。别等到线上出了问题才去复现能提前靠日志发现的机会就不要留到客户反馈的环节。