上周给手头一个Agent项目加“查询员工工时”技能时,我踩了个很典型的坑:直接在系统提示词里追加了一段工具说明,结果原本稳定的“生成周报”技能突然开始乱调参数,输出的JSON一堆坏死字符。排查下来问题很明确——这个Agent的技能体系已经膨胀到近三十项,全塞进Prompt里既不经济也不利于模型做工具选择。
于是我把技能从提示词里彻底搬了出来,做了个轻量的技能管理框架,名字就叫agent-skills。它不是又一个大而全的Agent框架,而是一套面向LLM Agent的“技能抽象与管理层”:把工具、脚本、API、知识检索统一封装成可注册、可发现、可编排的技能实体,让Agent按需加载、精确调用。如果你也在自研Agent、或者基于LangChain/CrewAI做应用层封装,这个思路可以直接抄作业。
1. 整体设计思路:为什么“技能”必须从Prompt里解放出来
1.1 技能和提示词混在一起,规模一大必然失控
很多人最初写Agent工具调用,都是在系统提示词里堆函数说明,像这样:
你有以下工具可用: - search_product(name, category): 按名称或类目搜索商品 - get_order_status(order_id): 查询订单状态 - calculate_shipping(address, weight): 计算运费 ...前5个工具这么写没问题,但到了30个、50个的时候,问题就出来了。首先是Token预算,每个工具描述平均120到200字符,50个工具就是上万字符,GPT-4级别的大模型还能勉强处理,小参数模型基本直接糊掉。其次是选择准确率断崖式下跌,工具多了以后,“该调哪个工具”这件事本身就变成了一道困难选择题,模型会频繁选错、遗漏必填参数。
这就像让一个实习生干活,你不可能每次都把厚厚一本操作规程从头念一遍。更合理的做法是给他一本手册,让他按需查、按需学。agent-skills的核心思路就是这个:技能是一个独立实体,有自己的元数据、描述、输入输出Schema、版本和依赖关系,Agent只有在需要时才去“检索并加载”技能,而不是一次性把全家桶灌进去。
1.2 技能三层模型:原子技能、复合技能、策略技能
做技能抽象的时候,我一开始也想得很简单:就是一堆函数注册表。但真正动笔才发现,不同技能的复杂度差太远了,把基础能力和高层的业务流程混在一个池子里,选择器根本没法干活。最后参考微服务里的服务拆分思路,把技能分成三层:
- 基础技能(Atomic Skill):不可再拆的最小可执行单元,比如读文件、执行Python代码、发HTTP请求、查数据库。通常对应一个函数,输入是简单类型,输出是字符串或结构化数据。
- 复合技能(Composite Skill):由多个基础技能按照确定的流程组合而成,执行路径是预先设计好的。比如“生成销售周报”这个技能,内部要依次跑“查订单表 -> 计算环比 -> 渲染Excel -> 发邮件”,外部看它只是一个技能。
- 策略技能(Strategic Skill):这个是最有意思的一层,它不做具体的工具调用,而是负责“拆解目标、给子技能排期”。策略技能一般由LLM驱动,手写下规划器,根据用户的目标动态决定调用哪些复合或基础技能。
为什么分三层而不是做一个大列表?因为每一层的选择策略完全不同。基础技能适合用规则或embedding检索来选,快且准;复合技能适合用固定路由,稳定可审计;策略技能则需要LLM的语义理解。放一起统一用LLM选,既贵又不稳定。
1.3 为什么不直接“把所有函数注册进去”
最偷懒的方案是把所有Python函数都用装饰器注册,自动生成schema给到LLM。我试过,这种方式在Demo阶段很香,但项目一复杂就露怯。第一,不是所有函数都适合暴露给Agent,比如删除数据的函数、写生产环境的函数,如果没做权限标记,模型一旦“发挥想象力”调用,后果很严重。第二,普通函数缺少副作用描述,模型不知道这个操作是否会改数据、是否会花钱、是否会等很久。第三,自动生成的参数名往往不友好,模型在意图识别时很容易搞混。
所以agent-skills里每个技能必须显式声明三件事:description(这个技能什么时候该用、什么时候不该用)、side_effects(是否有写操作、是否耗时、是否涉及外部资源)、input_schema(严格的参数类型定义)。这三个字段是后面做技能筛选、权限控制和参数校验的基础。
2. 技能定义与注册:让模型“看得懂、调得对”
2.1 技能描述是给LLM看的“说明书”,不是给人看的注释
实操中我发现,技能是否被正确调用,80%取决于描述写得好不好。一个常见的错误是把描述写成“这个函数实现了某某业务逻辑”,模型看了完全不知道什么时候该用它。好的技能描述应该从“模型视角”出发,说清楚三件事:适用场景、典型输入、使用边界。
举个例子,一个查天气的技能:
描述(反面教材): 查询天气信息 描述(正面教材): 当用户询问某个地点的当前天气、未来预报、温度、风力、降雨情况时使用。 参数location必须是城市名或经纬度,格式为"城市名"或"lat,lng"。 如果用户没有给地点,不要调用此技能,直接反问获取地点。差别很明显。正面描述把触发条件、参数格式、以及一个典型的“不要做什么”都写清楚了。我在项目里做了个约定:每个技能描述必须包含“当用户……时使用”“参数……必须/如果”“如果……不要调用”三个句式,虽然看着机械,但模型调用的准确率提升非常显著。
2.2 用Pydantic约束输入Schema,参数校验必须前置
LLM生成的参数永远不会100%规范,这不怪模型,怪我们给的约束不够。我见过太多项目对工具入参完全不做校验,模型传个"price": "1,200元"就直接丢给后端函数,然后噼里啪啦报错。agent-skills借鉴了Pydantic的做法,每个技能的入参由Schema定义,执行前统一做类型校验、枚举校验、必填校验。
from pydantic import BaseModel, Field class SearchProductParams(BaseModel): keyword: str = Field(..., description="商品关键词,必须去掉空格") category: str | None = Field(None, description="商品类目,可选") max_price: float | None = Field(None, gt=0, description="最高价格,单位元")执行器在收到模型返回的工具调用参数时,先用这个Schema做一次解析,失败就把错误信息回传给模型,让模型自己修正参数重新调用。这个机制叫“参数纠偏”,比直接抛异常体验好得多。当然要设一个最大重试次数,比如2次,否则模型可能在同一个烂参数上循环。
2.3 装饰器注册 + 目录扫描:技能中心的两种加载姿势
技能注册我实现了两条路:一是代码内用装饰器显式注册,适合确定性强、很少变动的内部技能;二是目录扫描加载,把每个技能做成一个独立的.py文件放到skills/目录下,启动时自动扫描加载,适合多人协作、需要热更新的场景。
# skills/search_product.py @skill_entry.register( name="search_product", version="1.0.2", tags=["商品", "检索", "电商"], description="当用户按关键词查找商品时使用,可按类目和价格过滤。", side_effects=["read_only"], ) def search_product(params: SearchProductParams) -> list[dict]: return product_db.query( keyword=params.keyword, category=params.category, max_price=params.max_price )目录扫描实现的时候,有几个细节值得说。每个技能文件必须包含一个register()入口函数,框架扫描时只认这个入口,避免把无关函数误注册。加载顺序要稳定,靠文件名前缀排序,避免技能在注册中心里随机排列。另外一定要捕获单个技能文件的加载异常,一个文件语法报错不能导致整个Agent起不来,该技能跳过并打上load_error标记,其他技能照常工作。
3. 调用链路:从用户提问到技能执行,中间隔了四道闸
3.1 技能选择器:用embedding检索代替LLM大海捞针
技能多了以后,我第一次意识到“把技能描述全放进Prompt给LLM选”是件很奢侈的事。一次用户提问,系统却要把几百KB的技能描述发给模型,延迟和Token费用都受不了。所以我给agent-skills加了一个技能选择器,它本质上是一个检索层,提前把技能描述和标签编码成了向量。
执行流程是这样的:用户提问进来后,先不做任何LLM调用,而是把用户输入转成embedding,和技能库里所有技能的描述向量做相似度检索,取Top K个候选技能,只把这K个技能的详细描述注入到当次LLM调用的工具列表里。K一般取5到8,太多会重新引入选择噪声,太少又容易漏掉关键技能。
这里还有个细节:embedding相似度不是万能的,有些技能之间很像(比如“查询订单”和“查询退款单”),光靠向量检索容易互相混淆。我的做法是再叠加一层关键词强制规则,比如用户问题里出现“退款”“退货”就强制把query_refund技能加入候选,用规则兜底。检索召回 + 规则强插,两步结合比单纯靠模型选准确率高得多。
3.2 统一工具调用协议:不绑定某一家模型
agent-skills最初是在OpenAI的function calling API下开发的,后来要切换到其他模型时发现,各家工具调用协议的格式差异很大。与其写一堆适配胶水代码,不如自定义一套中立的工具调用协议,让Adapter层去做格式转换。
执行链路的核心就三件事:
用户提问 -> Agent核心循环 1. 技能选择器召回候选技能,生成当前轮的system prompt和tools schema 2. 调用LLM,解析输出中的tool_call 3. dispatcher根据tool_call.name找到技能,校验参数、执行、返回结果while step < max_steps: response = llm_client.chat(messages, tools=current_tools_schema) tool_calls = parse_tool_calls(response) # 适配不同模型输出 if not tool_calls: break for call in tool_calls: skill = registry.get(call.name) if skill is None: messages.append(tool_error(call.id, "技能不存在,请勿调用")) continue try: parsed_params = skill.schema.validate(call.arguments) result = skill.execute(parsed_params) messages.append(tool_result(call.id, result, ok=True)) except ValidationError as e: messages.append(tool_error(call.id, f"参数格式错误:{e}")) except ExecutionError as e: messages.append(tool_error(call.id, f"执行失败:{e}"))注意看错误分支,我刻意把“参数校验失败”和“执行失败”分开回传。因为这两种错误的修复策略完全不同:参数错了,模型大概率能自己改;执行错了,可能需要更换技能或换一种方法,两项混在一起会让模型行为变得不可预测。
3.3 结果回填与上下文治理:别让一次工具调用撑爆对话窗口
工具调用的结果经常是超长文本,比如查数据库返回一万行、curl一个接口返回超大JSON。如果原封不动塞进messages,最多两三轮对话,上下文窗口就被吃光了。我在这里做了三个处理:
- 结果截断:默认只保留前2000个字符,超出部分用
... [已截断,共N行]代替。 - 结果摘要:如果截断后信息仍不够,Agent可以主动调用一次
summarize_text技能对长文本做摘要。 - 状态机上抛:工具结果里带上
success/failure状态码,模型看到类似[tool_result: failure]的记录时会优先考虑修正方案,而不是把错误信息当正常内容继续聊。
这三个处理看着简单,却直接决定了Agent能否在长任务里保持稳定。很多Agent项目跑着跑着就没上下文了,回头一查,全是工具结果给撑爆的。
4. 编排与组合:从“一个技能一步走”到“多技能协作”
4.1 顺序链、条件分支还是DAG
技能调用的下一步是组合。举个真实需求:用户说“帮我搜一下最近一周关于AI Agent的新闻,整理成摘要发到群里”。这个任务至少涉及搜索、抓取正文、摘要生成、消息推送四个技能。如果每个技能都让LLM临时决定调用顺序,执行路径会飘忽不定,今天先搜索再抓取,明天可能先推送再搜索。
我试过让LLM完全自由编排,结果不可控。后来做了一个柔性DAG引擎,技能之间可以声明依赖关系和执行顺序,Agent的规划器只负责决定“要不要做某件事”,而“这件事内部怎么跑”完全由DAG固定。
# 技能编排定义: weekly_ai_news_digest.yaml steps: - id: search_news skill: web_search when: input.need_search is true - id: fetch_page skill: page_fetcher uses: search_news input: urls: "{{ output.search_news.items[0:3].url }}" - id: summarize skill: llm_summarize uses: fetch_page input: texts: "{{ output.fetch_page.contents }}" - id: send_notify skill: im_sender uses: summarize input: content: "{{ output.summarize.result }}"这个DAG定义看起来麻烦,但它带来的好处是确定性和可观测性:每一步的输入输出可以被记录和回放,出问题能精确定位到某个节点。对任何要上生产的Agent来说,这点比“灵活”重要得多。
4.2 复合技能的两种实现:内置DAG让LLM自己拼
复合技能的具体落地,我总结了两套模式。第一套是“内置DAG”,上面那段YAML就是典型,适合流程固定、步骤明确的场景,优点是稳定、耗时可控、方便审计;缺点是场景一变就得改配置。第二套是“动态规划”,只提供给策略技能,让LLM在运行时拆分目标、选择基础技能并组装成临时流程,灵活但需要约束很多:限制最大步数、每个技能调用前都要过权限校验、超过步数强制收敛。
实际项目我推荐混合用:80%的稳定流程走内置DAG,20%的开放性任务让策略技能动态编排。这样既保证了日常场景的稳定,又保留了应对长尾需求的能力。比如在我的agent-skills项目里,把“数据分析报告生成”做成了内置DAG:数据提取 -> 数据清洗 -> 统计分析 -> 图表渲染 -> 报告排版。而“用户随手给的一个奇怪请求”则走策略技能,由LLM临时组合技能。
4.3 技能编排里的副作用控制
技能做了编排之后,副作用控制就变成了一个不可回避的问题。基础技能里,有些是只读的,比如查数据库;有些是会写数据的,比如发送消息、创建工单。我给技能增加了side_effects属性,并在编排引擎里加了规则:如果复合技能DAG里包含 write 类型的技能,那么整个复合技能也被标记为 write,在Agent前台调用时必须先经过一段“确认话术”,请用户确认后再执行。
这个“审批模式”在生产环境非常重要。你可能见过Demo里Agent一顿操作猛如虎,最后把测试库的订单表清空了的惨案。副作用标记 + 确认机制 + 操作日志,三条防线缺一不可。
5. 实战演示:5分钟搭一个带技能库的Agent
5.1 场景设计
下面用一个可运行的简化案例,展示agent-skills的实际用法。假设我们要搭一个“本地文件小助手”,用户可以用自然语言查文件信息、计算文件数据、生成摘要。我们注册三个技能:
list_files:列出指定目录下的文件read_file_stats:读取一个文本文件,返回行数、词频统计generate_digest:调用LLM给一段文本生成摘要
5.2 技能定义与注册代码
先看技能文件怎么写,这里为了演示,把三个技能放在一个文件里,实际项目建议一技能一文件:
import os from collections import Counter from pydantic import BaseModel, Field # 技能列表文件: skills_impl.py from agent_skills import skill_entry class ListFilesParams(BaseModel): directory: str = Field(..., description="要列出的目录绝对路径") @skill_entry.register( name="list_files", version="1.0.0", tags=["文件"], description="当用户需要查看某个目录下有哪些文件时使用。参数directory必须是绝对路径。", side_effects=["read_only"], ) def list_files(params: ListFilesParams) -> list[str]: return sorted(os.listdir(params.directory)) class ReadFileStatsParams(BaseModel): file_path: str = Field(..., description="文本文件的绝对路径") @skill_entry.register( name="read_file_stats", version="1.0.0", tags=["文件", "统计"], description="读取文本文件,统计总行数、总词数和Top10高频词。当用户需要分析文本内容时使用。", side_effects=["read_only"], ) def read_file_stats(params: ReadFileStatsParams) -> dict: with open(params.file_path, "r", encoding="utf-8") as f: text = f.read() words = text.split() counter = Counter(words).most_common(10) return { "file_path": params.file_path, "total_lines": text.count("\n") + 1, "total_words": len(words), "top_words": counter, } class GenerateDigestParams(BaseModel): text: str = Field(..., description="需要生成摘要的原始文本") max_length: int = Field(100, description="摘要最大字数,默认100") @skill_entry.register( name="generate_digest", version="1.0.0", tags=["LLM", "摘要"], description="对一段长文本生成中文摘要,当用户请求总结、概括、摘要时使用。", side_effects=["read_only"], ) def generate_digest(params: GenerateDigestParams) -> dict: # 实际项目里对接大模型接口,这里省略 return {"digest": "(这里是对文本的自动摘要)"}5.3 组装Agent并执行
然后是Agent初始化和一次完整的人机交互。这里假设LLM客户端是OpenAI兼容接口,但通过Adapter层可以换任意厂商:
from agent_skills import SkillRegistry, SkillSelector, AgentLoop registry = SkillRegistry() registry.register_dir("skills/") # 自动扫描skills目录下的所有技能 selector = SkillSelector(registry, embedding_model="text-embedding-3-small", top_k=5) agent = AgentLoop( llm=llm_client, system_prompt="你是一个本地文件助手,请严格根据技能结果回答用户问题。", selector=selector, registry=registry, max_steps=5, ) reply = agent.run("帮我看下当前目录有哪些文件,然后统计每个文件的行数") print(reply)执行过程中,Agent会先触发技能选择器,根据“目录、文件、行数、统计”这几个语义召回list_files和read_file_stats,然后进入主循环:第一轮调用list_files拿到文件列表,第二轮对每个文件调用read_file_stats,最后汇总结果。如果用户说“把内容总结一下”,选择器会把generate_digest也召回来,流程自然就多了一步。
5.4 这个Demo暴露的三个真实问题
跑通这个Demo很容易,但真正值得注意的问题都在细节里。第一,目录路径必须是绝对路径,否则Agent会拿相对路径调用技能,结果可能是预期外的;第二,如果目录下有很多文件,read_file_stats会被并发调用,技能执行器需要做并发池控制,不然会把本地IO打满;第三,LLM在汇总时可能会虚构技能日志里没有的数据,所以我在AgentLoop里强制要求:最终回答必须附上引用的技能调用记录,凡是没有调用记录支撑的数字,都要标注“未验证”。
这个最后一句话特别重要,它做了真实性约束。很多Agent瞎编数字,根源就是模型不知道哪些数据它有“证据”。技能调用的日志回填,实际上就是给模型的回答提供了证据链。
6. 常见问题与排查技巧实录
6.1 高频问题速查表
| 问题表现 | 可能原因 | 解决办法 |
|---|---|---|
| 模型经常调用错误的技能 | 技能描述存在歧义,或候选技能太多 | 重写描述,明确触发条件与排除条件;降低Top K数量 |
技能参数出现None或类型错乱 | 参数Schema定义过于宽松,缺少必填限制 | 用Pydantic定义必填字段和类型,开启参数校验纠偏 |
| 对话在第3轮响应突然变慢 | 上下文里积压了大量工具结果,Token超长 | 对工具结果做截断,只保留前2000字符 |
| 同一个技能被反复调用卡在循环里 | 缺少最大步骤限制,或错误信息未正确回传 | 设置max_steps,对相同技能连续调用做熔断 |
| 热加载技能后旧版本仍在运行 | 技能注册中心没有做版本清理 | 每次扫描前复制一份新注册表,替换时迁移状态 |
| 技能执行失败但模型说成功了 | 工具结果里的错误码被模型忽略了 | 在工具结果中显式提供success/failure状态码 |
这里面最值得展开的是“同一个技能被反复调用”这个循环问题。LLM在执行时如果拿到的工具结果是failure,有些模型会试图用相同参数重试,导致死循环。除了设置最大步数,我还加了一种“重复动作熔断”:连续三轮内同一个技能调用次数超过5次,就直接结束并告诉用户“当前技能执行卡住,已终止”。
6.2 排查时的高价值日志:一次调用一记录
排查Agent问题,最痛苦的是黑盒。我的做法是在AgentLoop里把每一步的输入、输出、skill调用、耗时、token用量都打一份结构化日志,存到本地文件或数据库。这样每个用户问题都对应一条完整的执行轨迹,出了问题直接按trace_id回放。
日志里我特别强调要记录两个容易被忽略的字段:一是skill_source(技能是selector召回的,还是规则强插的),这能帮你判断选择器的召回策略有没有问题;二是llm_raw_output(模型原始输出全文,包括没被解析的部分),有时候模型输出了工具调用但格式不对,如果只记录解析后的结果,就永远发现不了是解析器的问题还是模型的问题。
6.3 独家避坑心得
最后分享几个从实际项目里换来的教训。第一,技能描述里千万不要写“如果...就调用我”这类过于宽泛的话,会让选择器召回大量无关技能;第二,技能注册后要有一个滞后的“验证期”,建议先用影子模式运行,也就是只记录这次技能会被哪些请求召回,但实际执行仍然走旧逻辑,跑两天确认召回和参数都没有问题,再切全量;第三,给危险技能加“审批模式”不是可选项,只要有写操作的技能,就一定要在编排层加人工确认,否则出一次事故就把一个月的开发成果全赔进去了。
技能库这个东西,一开始做会觉得多此一举,但等到Agent的技能从几个涨到几十个,你会发现它是唯一能让系统继续保持可控的路径。我现在的体会是,Agent能力的天花板不取决于模型本身有多强,而取决于我们能不能把工具、权限、流程、记忆这些工程化部件有条理地组织起来。agent-skills这条路我会继续走下去,下一版准备把技能评测和自动化回归补上,让每次改技能描述都能量化地看到调用准确率的变化。如果你也在做类似的实践,强烈建议先从技能描述和调用日志这两个地方下手,收益最大。