写这次的项目复盘,我犹豫了挺久。不是因为它复杂,而是因为“agent-skills”这个方向太容易被讲成概念科普。但我想聊的其实是另一件事:一个真正能跑起来的技能系统,应该怎么设计、怎么落地、怎么在真实业务里不翻车。这个项目我从零搭了一遍,中间踩了不少坑,也推翻过几次方案。这篇文章就把整个思考过程和实操细节都摊开说清楚,适合正在做Agent应用、想给智能体加技能体系,或者单纯对工具调用机制感兴趣的人。
1. 项目背景:为什么要单独做一套“技能库”
1.1 从“一个Agent干所有事”到“一组技能件”
先交代一下背景。我手上的业务场景是做一个面向内部运营团队的智能助手,最初版本就是一个大模型接上几个API,让它帮忙查数据、发通知、写周报。刚开始效果还行,但随着需求变多,问题很快暴露出来:每加一个新功能,都要改主流程代码;模型经常把参数理解错;不同场景下的调用逻辑互相纠缠,改一处崩一片。
后来我意识到,问题不在模型,而在架构。大模型本质上是一个推理引擎,它不应该也不需要知道每个业务功能的实现细节。它只需要知道“在什么情况下、用什么参数、调用哪个能力”,至于这个能力内部怎么执行,应该由独立的模块去负责。这个模块,就是技能(Skill),而把这些技能组织起来、统一管理、对外暴露给Agent调用的整套体系,就是agent-skills。
这个思路类似于把一个大而全的机器人,拆成一个个可以独立维护的小工具。每个工具只做一件事,但做得足够好,Agent通过描述信息就能知道该用哪个,然后按约定的格式调用。
1.2 Agent Skills解决了哪三类痛点
我整理了一下,这套方案主要解决了我们在实际开发中遇到的三个痛点,你们可以对照看看是不是也踩过类似的坑。
第一是职责混乱问题。早期版本里,业务逻辑、Prompt模板、工具调用全都揉在Agent主循环里,每一次功能迭代都要动核心代码,风险极高。把技能独立出来之后,主循环只负责“决策”,技能模块只负责“执行”,责权清晰,改动隔离。
第二是模型理解偏差问题。我们最开始给模型的工具描述写得很随意,经常一句话带过,结果模型频繁选错工具或填错参数。后来我们把技能描述当成接口文档来写,包含触发条件、参数规则、注意事项、典型示例,模型的选型准确率提升非常明显。
第三是能力复用问题。不同业务线都需要“查数据”这个能力,但底层数据源不同、返回格式不同。如果没有技能抽象层,每个业务线都得单独接入一遍,重复代码一大堆。有了技能层之后,接入方只需要适配统一的输入输出格式,底层实现隔离,各用各的。
1.3 Skills、Tools、Function Calling之间的关系
这里想顺便说清楚三个容易混淆的概念。Function Calling是大模型API提供的一种能力,它让模型在回答中输出一个结构化的调用请求;Tools是Function Calling的具体描述单元,告诉模型有哪些函数可以调;Skills则是一个更上层的概念,它把函数的描述、实现、依赖、校验、回退逻辑整个打包成一个完整的功能单元。
用个类比来解释:Tools相当于菜单上的菜名,Function Calling是服务员记下你点了什么菜,Skills则是后厨里那道菜完整的做法和食材清单。菜单可以写得很简单,但真正把菜做出来,靠的是后厨的整套流程。
2. 技能系统的核心设计思路
2.1 技能描述(Skill Description)是命根子
如果让我只说一条设计经验,那就是:技能描述的质量直接决定Agent调用的准确率,比代码实现本身还重要。
很多人在设计技能时,把大量精力花在实现逻辑上,描述则草草写几句。但实际跑下来你会发现,模型毕竟是模型,它只能通过文本来理解你的技能是干什么的。描述写得模糊,它就只能靠猜。
我们项目中每个技能的描述(description)至少包含五块内容:功能概述(一句话说明这个技能干什么)、触发场景(什么情况下应该调用它)、参数说明(每个参数的类型、取值范围、默认值)、典型示例(一个完整的调用示例)、注意事项(比如参数之间的依赖关系、需要避开的坑)。
举个例子,我们要做一个“查询员工信息”的技能。如果描述只写“查询员工信息”,模型根本不知道参数怎么填。写成下面这样就靠谱得多:
{ "name": "query_employee_info", "description": "根据姓名或工号查询员工基本信息,包括部门、职级、入职时间。当用户询问某个员工的信息、联系方式或组织归属时使用。如果同时提供姓名和工号,以工号为准。", "parameters": { "name": { "type": "string", "description": "员工姓名,支持模糊匹配,例如'张'可以匹配所有张姓员工" }, "employee_id": { "type": "string", "description": "员工工号,精确匹配,优先于姓名" } }, "examples": [ { "input": "帮我查一下张三在哪个部门", "output": { "name": "张三", "employee_id": "ZHANG001" } } ] }我们后来统计过,描述从“一句话版”升级到“结构化完整版”之后,技能选型准确率从62%提升到了91%。这个提升幅度说明,模型的判断能力其实不差,差的是我们有没有给它足够的判断依据。
2.2 参数校验和标准化要前置
第二个关键设计,是把参数校验放在Agent调用技能之前,而不是技能内部。什么意思?就是说,当模型决定调用某个技能并填入参数后,我们先用一套独立的校验逻辑检查参数是否合法,再决定是否执行。
正常的流程是这样:Agent输出调用请求,进入调度层;调度层先做参数格式校验,必填参数有没有、类型对不对、取值是否在合法范围内;校验通过后,技能才会真正执行;执行结果返回后,再经过一层输出标准化,转成Agent方便理解的结构化内容。
这样做有两个好处。第一,避免脏数据进入业务逻辑,很多技能内部的Bug其实都是参数异常导致的;第二,如果模型填错了参数,我们可以在执行前就拦截,让Agent重新生成一次请求,而不是等技能运行到一半才报错,浪费时间和资源。
我当时在项目中写了一个轻量校验函数,核心逻辑大概是这样:
def validate_and_coerce(skill_schema: dict, raw_args: dict) -> tuple[bool, dict, str]: """ 校验并标准化参数。 返回: (是否合法, 标准化后的参数, 错误信息) """ required = skill_schema.get("required", []) for field in required: if field not in raw_args or raw_args[field] in (None, ""): return False, {}, f"缺少必要参数: {field}" properties = skill_schema.get("properties", {}) coerced = {} for key, value in raw_args.items(): if key not in properties: continue expected_type = properties[key].get("type", "string") if expected_type == "integer": try: coerced[key] = int(value) except (ValueError, TypeError): return False, {}, f"参数 {key} 需要整数类型,实际得到 {value}" elif expected_type == "array": if not isinstance(value, list): return False, {}, f"参数 {key} 需要列表类型,实际得到 {value}" coerced[key] = value else: coerced[key] = str(value) return True, coerced, ""有了这一层,技能的内部实现就简单了很多——进到函数体里的参数,一定已经是合法且标准化的。
2.3 注册表模式:技能的统一管理和发现
技能多了之后,管理和发现就成了新问题。我们初期把所有技能按文件组织,目录结构还算清晰,但Agent运行时需要一个统一的机制去感知“有哪些技能可用、每个技能的描述是什么、怎么调用”。这里我用的是注册表模式(Registry Pattern)。
核心逻辑不复杂:每个技能模块在加载时,把自己的描述信息和执行函数注册到一个全局注册表中;Agent启动时,遍历注册表,把所有技能的描述汇总成Tools列表,传给大模型;当模型输出调用请求时,调度器从注册表找到对应的执行函数并调用。
注册表的核心数据结构是名称到技能对象的映射,技能对象包含描述元数据和执行入口。这样做的好处是:技能之间完全解耦,新增技能不需要改动既有技能;Agent不需要感知技能实现细节,只需要看描述元数据;不同的应用可以按需加载不同的技能子集。
2.4 技能间通信和组合调用的处理
单技能跑通之后,下一个问题就是多个技能之间的组合。举个实际场景:用户说“帮我把上周的销售数据汇总一下,然后生成一份PDF周报发给李经理”。这个需求牵涉到三个技能:查数据、生成PDF、发邮件。模型需要先调用查数据技能,拿到结果之后调生成PDF,最后再调发邮件。
听起来像是模型一步步来就行,但实际会遇到一个麻烦:模型每次调用只能拿到结构化结果,这个结果往往是JSON或纯文本。如果查询结果很大(比如几千行的销售记录),模型根本没法把这个结果原封不动地传给下一个技能——上下文窗口也扛不住,传输效率也低。
我采用的方案是实现一个轻量的暂存机制:每个技能执行后的输出,如果体积超过阈值,会被存入一个暂存区并返回一个引用ID;后续技能如果需要引用前序结果,在参数中传入这个ID,调度层会自动将它解析为实际数据。
class SkillContext: """跨技能的数据暂存区, 避免大对象在上下文里反复传输""" def __init__(self): self._store = {} def put(self, data) -> str: ref_id = f"ref_{uuid.uuid4().hex[:12]}" self._store[ref_id] = data return ref_id def get(self, ref_id: str): return self._store.get(ref_id) # 使用示例 context = SkillContext() def generate_report(sales_data): ref = context.put(sales_data) # 此时只需要把 ref 传给 PDF 技能 return {"ref_id": ref, "data_size": len(sales_data)} def send_email_via_ref(ref_id: str, recipient: str): data = context.get(ref_id) # 从暂存区取回数据, 继续处理这套机制相当于给技能之间加了一个“中转仓库”,大对象不需要经过模型转发,直接在技能之间流转,效率和稳定性都好很多。
3. 实操实现:从零搭一个agent-skills最小闭环
3.1 目录结构和模块划分
直接上一份我们项目初期的目录结构,你们可以参考,也可以直接拿来改:
agent-skills/ ├── main.py # 入口, 初始化Agent和技能注册表 ├── registry.py # 技能注册表核心实现 ├── context.py # 跨技能数据暂存区 ├── skills/ │ ├── __init__.py # 自动导入所有技能模块 │ ├── base.py # 技能基类 │ ├── query_employee.py # 员工信息查询技能 │ ├── send_notice.py # 站内通知技能 │ └── daily_report.py # 日报生成技能 └── examples/ └── demo_usage.py # 演示脚本模块之间有一个明确的依赖方向:main依赖registry,registry依赖skills,skills内部互不依赖。这个方向一定要守住,否则很快就会变成一团乱麻。
3.2 技能执行器的设计
我们项目中,技能是接口体系和执行体系分离的。注册表里注册的是“技能描述”,而真正干活的是“技能执行器”。
技能执行器负责跟外部系统打交道——查数据库、调HTTP API、读写文件,等等。为了不让外部服务的细节污染Agent的主流程,每个技能执行器必须遵守同一份协议:入参是一个标准化字典,出参是一个标准化字典,错误信息也是标准化字符串。
技能执行器与技能描述分离,带来的直接好处是:同一个数据服务,可以注册成不同发布范围、不同权限级别的多个技能描述;而同一个技能描述,也可以在后台切换不同的执行器实现(比如从测试API切到生产API)。这对项目上线前后的联调和灰度发布特别有用。
输出标准化也很关键。我给所有技能定了一个统一返回结构,包含状态码、提示消息、数据体和耗时信息。Agent可以根据状态码快速判断结果是成功、失败还是空数据,然后决定是继续后续动作还是结束对话,也可以把耗时信息拼进上下文,帮助模型感知延迟。
3.3 Agent侧调度逻辑与核心流程
有了技能注册表和执行器协议之后,Agent的调度逻辑其实就变得很简单了。我将它浓缩成了一段非常核心的循环,你们跑起来就能看到一个最简Agent是怎么工作的。
调度逻辑精简单之后,Agent变成了这样一套流程:
def agent_loop(user_input: str, registry, model_fn): messages = [{"role": "user", "content": user_input}] for _ in range(MAX_STEPS): tools_desc = registry.get_tools_description() response = model_fn(messages=messages, tools=tools_desc) # 模型没有要求调用技能, 说明已经可以直接给出最终回答 if not response.get("tool_calls"): return response["content"] # 一个响应里可能同时请求多个技能调用 for tool_call in response["tool_calls"]: skill_name = tool_call["function"]["name"] skill_args = json.loads(tool_call["function"]["arguments"]) ok, standardized_args, error = registry.validate_params(skill_name, skill_args) if not ok: messages.append({ "role": "tool", "tool_call_id": tool_call["id"], "content": f"参数校验失败: {error}" }) continue # 查注册表, 找执行器, 真正执行 result = registry.execute(skill_name, standardized_args) # 结果回填到对话里, 供模型下一步判断 messages.append({ "role": "tool", "tool_call_id": tool_call["id"], "content": json.dumps(result, ensure_ascii=False) }) return "执行步骤过多, 已停止"这段代码基本就是整个Agent的骨架。核心设计是:模型永远不直接接触任何业务逻辑,它只做两件事——根据用户问题判断要不要调用技能,以及根据技能返回结果组织最终回复。剩下的体力活全部交给注册表和执行器。
我自己实测跑通这套流程,大概花了一个晚上的时间。你们如果要从头写,重点盯三个点:注册表的描述格式要跟大模型的Tools格式兼容、参数校验要闭环(失败后要能把错误喂回给模型再试一次)、最大步数要设一个合理值比如10,防止模型陷入死循环。
3.4 与LLM协作的边界划分
这里要单独强调一下“什么是Agent做的,什么不是Agent做的”,因为做这个项目过程中我见过太多团队在这里栽跟头。
我定的划分原则是:Agent只负责“理解意图”和“编排动作”,不负责“执行动作”和“记忆数据”。理解意图,是模型根据用户输入判断该调用哪个或哪几个技能;编排动作,是模型决定调用顺序和参数值。但真正去数据库里查数据、真正去调API发通知、真正生成PDF文件,这些都是技能的体力活,跟模型没关系。数据暂存也是context的职责,模型只是转交了一个ref_id,它不需要知道实际数据长什么样。
这个边界划清楚之后,代码写起来非常舒服。模型侧的逻辑始终很薄,技能侧的逻辑也很纯粹——不需要考虑意图理解,只需要做好输入校验和数据处理。两边各自演进,互不拖累。
3.5 文本生成型技能的配置要点
除了典型的命令型技能,还有一类“文本生成型技能”也值得单独说。这类技能本质上是让模型在特定场景下产出符合固定风格的文本,比如自动生成周报、产品文案、会议纪要等。
我把这类技能和命令型技能在配置上做了区分:命令型技能偏向结构化参数,文本生成型技能更看重“风格描述”和“约束条件”。为此我在注册表里预留了自由文本字段,专门用来放让模型参考的语气风格和内容边界。
例如,一个写日报的技能,我会在配置里描述:需要统计当日完成任务、明日计划、遇阻问题;语气要求简洁,用要点陈列,不使用敬语。这样模型在调用文本生成型技能时,生成结果基本不用二次修改。实测下来,这类技能的配置成本很低,但对输出质量的提升立竿见影。
另外提醒一点,文本生成型技能虽然走的是模型生成,但它同样应该走注册表和参数校验的流程,不要图省事直接拼接提示词。只有把这类技能也当作一等公民纳入统一管理,后续的审计、回退、效果统计才能全面覆盖。
4. 常见问题和排查技巧实录
4.1 模型总把参数传偏怎么办
这是我们在项目里遇到最多的一个问题,尤其是在模型版本升级之后,参数理解行为会有波动,明明之前还正常的场景突然就传错了。
排查思路是:先确认是“描述不清晰”还是“参数太复杂”。如果是描述问题,就按前文说的五要素补齐描述内容,特别是Examples部分要尽量覆盖真实场景。如果是参数太复杂,比如一个技能有七八个参数同时存在依赖关系,那就应该拆技能,而不是指望模型自己推理。
我一般建议一个技能的参数不要超过五个,而且尽量去掉非必填参数。非必填参数越多,模型的选择压力越大,就越容易出错。如果实在没法避免,就给非必填参数设置合理的默认值,并在描述里明确“能不用就不用”。
4.2 技能执行超时但模型还在等
这个问题是这样的:技能调用的某个外部API响应很慢,比如内部数据分析服务偶尔要跑十几秒才能返回。Agent侧如果设置了十秒超时,API还没回来,技能就已经报错了。但模型在下一步还是继续等一个完整的结果,导致整个对话卡在那。
我们的解法是给每个技能专门设计一个“超时反馈”分支:当技能判断当前执行可能要超时时,主动返回“任务超时,但系统还会继续在后台重试”的特殊结果。模型收到这个结果后,就不会干等,而是走一条独立的重试逻辑,或者给用户一个明确提示,而不是无限挂起。
另一个实用技巧是给技能执行加上同步异步分流:需要快速响应的查询类技能走同步调用,耗时的数据汇聚类技能直接丢到异步任务队列。Agent先回复一个“已开始处理”,等后台跑完再通知。这套模式适配长耗时场景非常有效,我们后来几乎所有数据类技能都切到了异步方案。
4.3 两个技能职责重叠导致选择混乱
当技能数量超过十几个之后,一定会出现职责重叠的情况。比如我们有一个“查员工信息”技能和一个“查组织架构”技能,两者都能回答“某某在哪个部门”这个问题,区别只是返回信息的详细程度不同。模型经常选错。
解决思路是重新划分技能边界,让技能之间的职责尽量正交。后来我把“查员工信息”定位为“只看单人的基本信息”,把“查组织架构”定位为“看部门和汇报关系”,并在描述中明确标注各自的使用场景和排他情况。模型选型准确率很快就上来了。
如果你不希望频繁改动底层技能,也可以考虑加一个上层路由技能,也就是一个“metadata技能”专门负责判断哪个技能适合当前请求。但路由依赖模型再走一层,会增加额外的调用损耗和出错面,我更推荐直接改技能描述。
4.4 技能效果的回归测试体系
技能系统最容易被忽略的就是回归测试。代码改了一个小地方,可能某个调用场景就挂了;模型的Prompt微调了语气,可能选型逻辑就偏了。
我给这个项目搭了一套很轻的回归测试流程:每一类技能都保存少量典型输入范例,每次改动后自动回放一遍,检查技能选择和执行结果是否符合预期。初期靠手工测试,后来把回放脚本集成到CI里,每次提交代码都自动跑一遍所有技能的全量回测。
我这里用了一个分段对比的思路,类似于断言,包括意图覆盖测试和参数覆盖测试。意图覆盖测试验证的是那些典型问题,是否成功命中了预期技能;参数覆盖测试验证的是那些典型参数的边界和错误参数,是否成功返回可理解的错误信息。两者加在一起,能给技能系统上一道基础保险。
4.5 数据安全与权限隔离
技能系统里的一个大坑是权限隔离,尤其是当一个Agent服务于多种角色的时候。比如普通员工和HR看到的数据范围完全不同,但技能执行器如果没有权限判断,就会把数据泄露出去。
安全控制绝对不能只放在前端或代码层,要下沉到底层执行器:每个技能在入参中必须带上调用者身份标识,执行器内部根据身份做数据范围过滤。我在注册表里也为每个技能维护了一个可见等级字段,只有调用者权限不低于该等级时才允许执行。
这里有一个很实际的教训分享:如果技能系统同时被聊天工具、API接口、自动化工作流等多入口调用,不能只依赖调用方传来的角色,一定要在执行端二次校验。否则一旦某个入口忘记传角色,或者传了伪造角色,整个权限体系就是形同虚设。
5. 后续优化方向和进阶玩法
5.1 技能版本管理与回滚
随着技能数量增长,我们会遇到“升级了一个技能导致其他场景异常”的情况。虽然架构上技能之间已经解耦,但业务上是纠缠的——A技能输出格式变了,B技能又依赖了它。
因此我强烈建议从第一天就建立技能版本管理的意识,而不只是保存文件。每个发布版本记录技能代码、描述内容和依赖环境三个层面的快照,并支持一键回滚。我用的方案是给注册表的技能描述框架里补上版本号字段和变更原因字段,定期归档一个版本签名。
回滚这件事,如果没有自动备份就是空谈。我在CI流程里加了一步自动打快照的动作,每次发布技能库都会先记录当前全部技能描述和执行器代码的哈希值。一旦线上出现异常,能迅速回到上一个稳定版本。
5.2 动态加载技能,不重启Agent进程
早期版本的技能注册表是静态的,所有技能在启动时一次性注册完成。但业务上有时我们希望某个新技能立即上线,不影响正在跑着的Agent服务。
后来我把注册表改成了支持动态加载:技能包被放到指定目录后,系统通过文件监听自动发现并注册新技能,整个过程不需要重启进程。这里有一个小技巧:为了确保动态加载不出问题,每个技能执行器必须遵循纯函数风格,不能依赖全局状态。
动态加载带来的另一个好处是可以在业务节点上独立做小流量实验:先在灰度环境注册一个新技能,验证调用准确率和效果指标,再决定是否全量推送。这样每次技能上新心里都有底,不至于上线后才发现问题。
5.3 从单一Agent扩展到多Agent协作
技能体系稳定之后,下一个自然的需求就是多Agent协作。我最近在尝试的方向是:为不同Agent建立不同的技能子集,比如数据Agent只能加载数据类技能,文案Agent只能加载写作类技能,两者通过一个消息总线交换结果。
在这种架构下,每个Agent都维护一个独立注册表,只装入与自身职责相关的技能描述,避免上下文被无关技能干扰。交互层则由一个编排Agent统一调度,决定哪个子Agent去处理哪一类请求。多Agent的优势是角色隔离清晰、技能上下文短、并发能力强,但代价是需要额外维护编排逻辑和线程模型,小规模团队需要权衡成本和收益。
5.4 技能效果的数据反馈闭环
这个优化方向是我认为最值得投入的:让技能系统通过调用数据自我进化。我在每次技能调用时都会记录完整的入参、出参、耗时、是否成功、模型选型置信度等信息,沉淀成一张技能调用日志表。
有了这张表,我们就能客观分析:哪些技能的调用频率最高;哪些技能经常被模型选了但执行结果没人点开看;哪些技能频繁因为参数校验失败而返工。下一步是让技能库自动报告这些数据,并推荐修改描述或合并技能的方向。
虽然现在还没有完全做到自动闭环,但数据驱动的思路已经帮我们优化了七八个技能描述,让选型准确率又上了一个台阶。这个方向我会继续做下去。
6. 最后的经验总结与个人心得
这个agent-skills项目做到现在,我自己最有感触的一点是:真正值钱的核心,不是“让大模型会调用工具”,而是“怎么把能力模块化、标准化、安全地组织起来,让模型和业务系统像齿轮一样咬合”。技能描述、参数校验、执行器协议、注册表,这些听起来很基础的东西,恰恰是最影响线上稳定性和开发效率的。
如果你正准备做一个Agent应用,我的建议很直接:不要一上来就追求复杂的框架或炫酷的多智能体编排,先踏踏实实把技能注册表、校验层和执行器协议这三件套做好。等基础扎实了,模型选型准确率上去了,再考虑异步化、动态加载、多Agent协作这些进阶能力。地基稳,楼才不会塌。
如果你们项目里也遇到过类似的问题,或者有更好玩的技能设计思路,欢迎在评论区聊聊。后续我会再整理一篇关于技能调用数据分析和自动优化描述的文章,把这次项目中数据驱动优化那部分再展开讲讲。