如果你在 2024 年之后还在做 LLM 应用,那你一定绕不开一个词:agent-skills。这个词最近在开发者社区里被反复提起,GitHub 上相关项目越来越多,招聘 JD 里也开始出现"具备 Agent 技能设计与编排经验"这样的要求。我自己的理解是,它既指一种工程范式——把大模型的能力拆成一个个可复用、可编排、可评估的技能单元;也指一类具体实现——比如你名下某个仓库里那套 skills 目录、技能注册表、执行器。这篇内容不是理论科普,而是我从实际项目里趟出来的经验总结:agent-skills 应该怎么设计、怎么写、怎么排错,以及哪些坑你完全可以提前避开。适合正在做 Agent 应用、或者准备把现有 Chatbot 升级成真能干活的应用的开发者。

1. 先搞清楚 agent-skills 解决的是什么问题

1.1 为什么 Agent 时代必须把能力拆成 Skills

早期做 LLM 应用,大家习惯把一堆指令塞进 System Prompt,让模型一肩扛下所有任务。这种做法在 demo 阶段完全可行,但一旦业务复杂起来就崩:提示词动辄几千字,模型经常顾此失彼,改一个需求要整体重调,而且你根本说不清到底哪段指令在起作用、哪个环节在出错。

Skills 的思路是反过来的。它不是把所有知识塞进模型脑子,而是把"做事的能力"拆成独立单元,每个单元有明确的触发条件、输入输出、内部逻辑和失败兜底。模型角色从"全知全能的执行者"退化成"调度中枢"——它判断当前该用哪个技能,然后调用技能,拿到结果后再决定下一步。

这里有个生活化的类比。你去餐厅吃饭,一个优秀的前厅经理不会亲自下厨做每一道菜,他的工作是看了菜单之后,准确地把订单派给冷菜间、热炒间、甜品间。每个后厨岗位就是一项技能,各管一摊,接口清晰,坏了谁就检修谁。你要是让经理一个人从买菜到颠勺全包了,那这家店基本开不下去。

1.2 Skills 和普通 Prompt、Function Calling 的边界

很多人会问:我有 Function Calling,为什么还要搞 agent-skills?我的理解是,两者不是同一层的东西。

Function Calling 是模型与外部工具之间的通信协议——它解决的是"模型如何表达'我要调用某个函数'"。而 agent-skills 是更上层的能力抽象——它解决的是"整个 Agent 如何组织、组合、复用一系列能力来完成任务"。一个 Skill 内部可以包含多个 Function 调用,也可以包含子技能、内部 prompt、后处理逻辑,甚至一个完整的子 Agent 循环。

再直接一点:Function 是原子操作,Skill 是业务能力。比如"查天气"可以是一个 Function,但"安排一次周末出行计划"就是一个 Skill,它的内部要串起查询天气、查询交通、生成行程表、推送日历等多个 Function,还需要在中间做判断和兜底。你用 Function Calling 能实现前者,但后者必须靠技能编排来做。

1.3 一个真实场景:从"会聊"到"能干活"

我团队接手过一个内部知识库助手,最初版本就是一个大模型套一个向量检索。用户问"今年的报销制度有什么变化",它能把相关文档段落捞出来生成回答。看着能用,但实际用起来很痛苦——遇到"帮我对比上季度和本季度的差旅标准差异"这种需要多步检索加对比的任务,它就只会死板地拉一堆片段拼在一起,经常答非所问。

后来我们按 agent-skills 的思路重构:拆出"文档检索技能"、"表格解析技能"、"差异对比技能"、"摘要生成技能",再写一个简单的编排层让模型按需组合。同样是那个问题,模型会先调文档检索技能定位两份制度文件,再调表格解析技能把关键条目结构化,然后调差异对比技能逐项比对,最后调用摘要技能输出结论。整个流程可观测、可单独调优,出错了也知道是哪一环的问题。

这个案例我想说明的是:agent-skills 不是炫技,它是当任务复杂度超过模型单次推理能力时,工程上必然要走的拆解路线。

2. 技能系统的整体设计:先想清楚再动手

2.1 技能注册表:让模型知道"你有什么"

整个 agent-skills 架构里,最容易被忽略又最关键的是技能注册表。它本质上是一份能力清单,要在每次请求时以结构化的方式告诉模型:你有这些技能,每个技能是干什么的、什么时候用、需要什么参数。

我踩过的第一个坑就是注册表写得太随意。当时团队成员各自写技能描述,风格五花八门,有的写得很文学:"本技能用于在浩瀚的文档海洋中精准定位用户所需信息",有的写得很潦草:"查资料用的"。结果模型经常选错技能,或者在多个相似技能之间犹豫。后来我们定下了硬性规范:每个技能描述必须包含"能力概述(一句话)、适用场景(什么时候用)、不适用场景(什么时候别用)、关键参数说明、示例调用"。另外界面要说清楚——宁可描述长一点,也要把边界划清楚。模型选错技能,八成原因是注册表没写好。

这里我建议用 JSON Schema 来约束技能定义。它既能让注册表可校验,又能直接转成模型友好的描述。一个技能的注册项大致长这样:

{
  "skill_id": "document_retrieval",
  "name": "文档检索",
  "description": "根据关键词或语义从知识库中检索相关文档片段",
  "when_to_use": "用户需要查找制度、规范、产品资料等文档内容时",
  "when_not_to_use": "用户要求的是计算、对比、生成等非检索任务",
  "parameters": {
    "type": "object",
    "properties": {
      "query": { "type": "string", "description": "检索关键词或问题描述" },
      "top_k": { "type": "integer", "description": "返回片段数量,默认5", "default": 5 }
    },
    "required": ["query"]
  }
}

2.2 技能执行引擎与上下文传递

注册表让模型"知道"有什么,执行引擎则负责"真正跑起来"。执行引擎要处理三件事:参数解析、技能调用、结果回填。

参数解析是最容易出问题的环节。模型输出的参数经常不按 schema 来——多传字段、类型错误、漏传必填项。我的做法是在执行引擎入口做一层严格校验,不合法就打回让模型重新生成,而不是硬着头皮调用。实测下来,打回一两次之后模型基本能自己纠正,比在技能内部做各种容错要干净得多。

结果回填也有讲究。技能返回的结果不能堆在原始消息里甩给模型,而是应该结构化地放进上下文,并标注结果的来源和可信度。比如检索类技能返回的片段,我习惯每条前面加 [来源: 文档A-第3章] ,这样模型在生成回答时能正确引用,减少编造。

上下文传递还有一个细节:长任务的上下文膨胀问题。一次复杂的任务可能调十几个技能,每个技能的结果都塞进上下文,很快就把窗口撑爆。我现在的做法是分层管理:每一轮只保留当前步骤需要的摘要和完整结果,历史结果压缩成一句摘要放进全局上下文,需要回溯时再主动调检索技能取回完整内容。

2.3 技能编排:串行、并行与条件分支

单个技能写得再好,不会编排也白搭。agent-skills 的核心威力在于组合。我常用的编排模式有三种。

串行编排最简单,适合流水线式任务,比如"检索 -> 解析 -> 对比 -> 摘要"。实现上就是让模型在循环里决策下一步调哪个技能,执行引擎按序执行。并行编排适合相互独立的子任务,比如同时检索多个数据源。这里要注意的是,并行意味着你得处理多路结果的合并和去重,不是简单地把结果拼在一起。

条件分支是最体现"智能"的部分。我的实现方式是在技能定义里增加一个 next_steps 字段,技能执行完返回状态码,编排器根据状态码决定下一步走哪条路径。比如检索技能查到结果就走摘要分支,没查到就走"询问用户是否放宽条件"分支。这种显式的分支设计比纯靠模型自由发挥稳定得多,因为它是可预期的、可测试的。

3. 核心实操:从零定义一个可复用的 Skill

3.1 技能定义文件的结构设计

我把一个完整技能拆成四个部分:元信息、参数契约、执行逻辑、测试用例。四者缺一不可,而且都应该在同一个目录里管理。推荐的项目结构是这样:

skills/
├── document_retrieval/
│   ├── SKILL.md            # 注册表信息,面向模型
│   ├── schema.json          # 参数契约,面向校验
│   ├── executor.py          # 执行逻辑,面向系统
│   └── tests/
│       ├── cases.json       # 典型输入输出对
│       └── run_tests.py     # 自动化测试脚本

这个结构的好处是职责清晰:SKILL.md 给模型看,schema.json 给校验器看,executor.py 给执行引擎看,tests 给自己看。不要在 SKILL.md 里写实现细节,也不要让 executor 自己解析参数——各管一摊,后面维护会轻松很多。

3.2 参数设计:少而精,给默认值,做防御

技能参数设计是决定技能好不好用的关键。我的经验是三条原则。

第一,参数宁少勿多。能用三个参数解决的事不要设计五个,参数越多,模型传错概率越大。第二,能设默认值的都设默认值。比如 top_k 默认 5,语言默认跟随主对话,不要让模型每次都猜。第三,所有参数在 executor 里做一次防御性校验。模型传进来的值不能直接信,该 clamp 的 clamp,该枚举校验的校验。

这里给一个参数校验的代码示例,我用 Python 写了一个轻量校验器:

import json
import jsonschema
from typing import Any

def validate_skill_params(schema: dict, params: dict) -> tuple[bool, dict, str]:
    """校验模型传入的技能参数,返回 (是否合法, 规范化后的参数, 错误信息)"""
    try:
        # 先做 JSON Schema 校验
        jsonschema.validate(instance=params, schema=schema)
    except jsonschema.ValidationError as e:
        return False, {}, f"参数不符合契约: {e.message}"

    # 再做业务层规范化:填充默认值、裁剪范围
    normalized = dict(params)
    props = schema.get("properties", {})
    for name, prop in props.items():
        if name not in normalized and "default" in prop:
            normalized[name] = prop["default"]
        if name in normalized and "minimum" in prop:
            normalized[name] = max(normalized[name], prop["minimum"])
        if name in normalized and "maximum" in prop:
            normalized[name] = min(normalized[name], prop["maximum"])

    return True, normalized, ""

3.3 executor 的内部实现与外部工具联动

executor 是技能真正干活的地方,但它不应该是"什么都干"的巨石。我的原则是:executor 只做本技能职责范围内的事,其他能力通过依赖注入调用外部服务。

比如一个"会议纪要整理"技能,它内部可能需要语音转文字、文本摘要、待办提取三个外部能力。正确做法是定义三个接口,在技能里组装调用顺序,而不是自己实现语音识别。这样每个技能可独立测试,外部服务变更时也不至于牵一发动全身。

还有一个我在实战中反复踩的坑:executor 的异常处理。很多人的技能一报错就直接抛异常给上层,导致整个 Agent 会话中断。正确做法是让 executor 返回标准化的结果结构,包含状态码、结果数据、错误信息和建议。比如:

def run(self, params: dict) -> dict:
    try:
        docs = self.retriever.retrieve(params["query"], params.get("top_k", 5))
        if not docs:
            return {
                "status": "no_result",
                "data": None,
                "message": "未检索到相关内容",
                "suggestion": "建议用户扩大关键词范围或换个表述"
            }
        return {"status": "ok", "data": docs, "message": "", "suggestion": ""}
    except Exception as e:
        return {
            "status": "error",
            "data": None,
            "message": f"检索服务异常: {str(e)}",
            "suggestion": "建议稍后重试或联系管理员"
        }

上层编排器看到 no_result 和 error 是两种不同的响应路径,前者走业务兜底,后者走系统兜底,不要混为一谈。

4. 实操过程:搭一个最小可用的 agent-skills 项目

4.1 环境与目录规划

说了这么多理论,我们直接上手搭一个最小项目。这个示例的任务是做一个"文档问答 + 表格对比"的 Agent,包含三个技能:文档检索、表格解析、差异对比。

环境方面我用的是 Python 3.11 + FastAPI 做服务层,向量检索用一个小型的本地库,LLM 接口用 OpenAI 兼容的 SDK。目录规划如下:

agent-skills-demo/
├── app.py                 # Agent 主循环入口
├── registry.py            # 技能注册表与加载器
├── executor.py            # 技能执行引擎
├── skills/
│   ├── document_retrieval/
│   ├── table_parse/
│   └── diff_compare/
└── data/                  # 示例文档与表格

4.2 实现"文档检索"技能并接入注册表

先写 document_retrieval 这个技能。SKILL.md 内容按上一节规范填,schema.json 定义参数契约,executor.py 实现检索逻辑。这里我放一个简化的检索执行器:

# skills/document_retrieval/executor.py
from typing import Any

class DocumentRetrievalSkill:
    def __init__(self, vector_store):
        self.vector_store = vector_store

    def run(self, params: dict) -> dict:
        query = params["query"]
        top_k = params.get("top_k", 5)
        score_threshold = params.get("score_threshold", 0.3)

        results = self.vector_store.search(query, top_k=top_k, threshold=score_threshold)
        if not results:
            return {
                "status": "no_result",
                "data": None,
                "message": f"没有找到与『{query}』相关的内容",
                "suggestion": "建议换个关键词,或放宽检索范围"
            }
        # 给每条结果带上来源,方便后续引用
        formatted = [
            f"[来源: {item['source']} | 相关度: {item['score']:.2f}]\n{item['content']}"
            for item in results
        ]
        return {"status": "ok", "data": formatted, "message": "", "suggestion": ""}

注册表加载器 registry.py 负责扫描 skills 目录,读取每个技能的 SKILL.md 和 schema.json,生成统一的注册表。注意启动时要校验 schema 合法性,避免坏配置上线。

4.3 实现"表格解析"与"差异对比"技能

第二个技能 table_parse 负责把表格文件解析成结构化数据。这个技能网上有很多现成库可用,核心是让输出格式固定化,便于后面的对比技能消费。我习惯统一输出成 JSON 数组,字段名保持稳定。

第三个技能 diff_compare 是业务味最重的。它接收两份结构化数据,逐字段做差异对比,输出一个统一的差异列表。关键点是差异结果的粒度控制——不要只给模型一大段差异文本,而要给结构化的差异条目,每条包含字段名、旧值、新值、影响说明。这样模型生成最终回答时才有依据。

4.4 编写 Agent 主循环:让大模型当调度员

最后是 app.py 里的主循环。核心逻辑很直接:把注册表描述、对话历史、上一个技能的结果一起发给模型,让模型决定是调用技能还是直接回答。我贴一个关键的循环片段:

# app.py 中的核心循环(简化版)
def agent_loop(user_query: str, max_steps: int = 8):
    context = []
    messages = build_messages(user_query)
    skills_desc = registry.describe_all_skills()  # 把注册表转成描述文本

    for step in range(max_steps):
        response = llm.chat(
            messages=messages + [
                {"role": "system", "content": (
                    "你是任务调度员。你手头有以下技能,按需选用:\n"
                    f"{skills_desc}\n"
                    "如果要调用技能,请输出一个 JSON:"
                    '{"action": "call_skill", "skill_id": "...", "params": {...}}'
                )}
            ]
        )
        decision = parse_decision(response)
        if decision["action"] == "reply":
            return decision["answer"]
        elif decision["action"] == "call_skill":
            skill = registry.get(decision["skill_id"])
            result = validate_and_run_skill(skill, decision["params"])
            messages.append(format_result_as_message(result))
        # 超步数就强制终止
    return "任务步骤过多,已自动停止。"

这个循环的每一步都是可观测的:模型说了什么、调用了哪个技能、传了什么参数、技能返回了什么,全都可以落日志。出了问题直接看日志就能定位,这也是 agent-skills 方法相比单体 Prompt 的巨大优势——可追踪、可调试。

5. 常见问题与排查技巧实录

5.1 模型就是不调用技能,或者乱调用技能

这是做 Agent 最让人抓狂的问题。我的排查顺序是固定的。

先看注册表描述是否准确。如果模型在"该用检索技能"的场景却直接回答,往往是因为描述里没有给出足够强的触发信号。我试过把 when_to_use 字段从一句改成带反例的三句,调用准确率立刻涨了一截。再检查是不是技能名有歧义。比如"信息获取"和"信息查询"两个技能名,模型根本分不清,直接合并或改名。

如果描述没问题还乱调,那就检查上下文里的示例。给注册表配上 1 到 2 个 few-shot 示例是性价比最高的优化手段,示例要选那种"边界容易混淆"的典型场景,而不是选最简单的标准场景。

5.2 技能参数传错、模型编造参数值怎么办

模型生成参数时偶尔会"一本正经地胡说",比如把 top_k 传成字符串"五个",或者把枚举值传成不存在的值。我的处理方案有三层。

第一层是 schema 严格校验,不合格就打回重生成,上面已经说过。第二层是给每个技能写一个 param_fixer 回调,针对常见的错误值做启发式修正,比如把"五个"映射成 5。第三层也是我最推荐的:当你发现某个参数模型经常传错,大概率是这个参数设计得不自然,优先通过删参数、改枚举、改默认值来消除歧义。别指望模型永远不出错,要通过设计让"出错的空间"变小。

5.3 技能多了之后如何管理

技能数量超过 20 个之后,注册表会变得很长,模型反而会"眼花缭乱"。这时候要做技能分层。我目前采用的是两级结构:顶层是能力域的目录描述(比如"文档处理域"、"数据分析域"),模型先选域,再在域内选技能。这样注册表从一长串平铺变成了带索引的分类清单,调用准确率明显提升。

另外,技能也是有"生命周期"的。我在生产环境里给每个技能加了调用统计和成功率统计。连续一段时间调用量低、成功率差的技能,就标记为"隐藏"状态,不进注册表。这个机制能防止注册表越来越臃肿,也让模型集中精力用真正好用的技能。

5.4 排查工具与调试技巧

最后分享几个实战中救过我多次的调试手段。

日志是第一步。我在执行引擎里强制打印每次技能调用的完整 JSON,包括入参、出参、耗时、错误信息。出问题先翻日志,不要靠猜。

第二步是做"技能单测"。每个技能都有测试用例集,我在改动技能后必跑一遍,确保老场景不回归。很多 Agent 项目挂着挂着就不可用了,就是因为技能改崩了没人发现。

第三步是回放调试。我把生产环境里的真实请求连同当时的上下文、模型输出、技能结果全部记录下来,本地复现问题时直接喂给同一个链路。这个方法比用测试数据模拟高效得多,因为生产问题往往就出在你没想到的边界场景里。

5.5 快速故障排查速查表

症状 优先排查项 常用修复手段
模型不调用任何技能 注册表描述、few-shot 示例 补充触发场景与反例,增加演示样本
调用多个错误技能 技能名相似度、技能粒度 合并相似技能,划分能力域分层
技能参数频繁报错 schema 契约、参数设计 删减参数、加默认值、写 param_fixer
技能返回结果但在回答中没用上 结果回填格式 结果结构化,标注来源信息
任务跑到一半上下文超限 上下文管理策略 中间结果摘要化,历史结果按需回溯
技能报错导致整个会话中断 executor 异常处理 统一返回状态码,上层走兜底分支

这张表我建议贴在项目文档首页,团队新成员接手时照着查能省很多时间。

做 agent-skills 这一年多,我最大的体会是:它没什么玄学,本质上就是软件工程里的"分而治之"思想在 LLM 应用上的复现。技能拆得好不好、描述写得清不清楚、编排稳不稳定,决定了 Agent 底座的上限。踩过几次坑之后,我现在看一个 Agent 项目,先不看它的模型多强,先看它的技能设计——技能边界模糊、描述随意、没有测试的项目,后期维护成本一定爆炸。所以如果你准备重构自己的 Agent 应用,不妨从梳理技能注册表开始,把每一项能力当作一个严肃的软件模块来对待,效果会超出你的预期。

更多推荐