ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

agent-skills 实战:从技能定义到编排,构建可靠智能体系统

agent-skills 实战:从技能定义到编排,构建可靠智能体系统

1. 从“会聊天”到“能干活”:agent-skills 到底在解决什么问题

大模型刚火起来那阵子,大家最兴奋的是“它能陪我聊天”。但真把它接进业务里,你会发现一个尴尬的现实:模型能说会道,却干不了活。你让它查个订单状态,它给你编一段看似合理的回复;你让它调个接口,它连参数格式都拼不对。问题不在于模型不够聪明,而在于它缺少一套可被调用、可被验证、可被组合的技能体系。

agent-skills这个项目,本质上就是在补这块短板。它要做的,是把一个只会“生成文本”的模型,改造成一个能“执行任务”的智能体。这里的“技能”不是抽象概念,而是具体到每一个可执行单元:读文件、发请求、算数据、调工具、做校验。每个技能都有明确的输入输出、边界条件和失败处理方式。模型不再直接面对用户需求,而是先做任务拆解,再按需调用技能,最后把结果组装成人类能看懂的答复。

这套东西适合谁?如果你正在做智能客服、自动化运维、数据分析助手、RPA 流程编排,或者任何需要“模型+工具”协同的场景,agent-skills的思路都值得参考。哪怕你只是想让自己的小助手能查个天气、记个待办,这套技能化拆解的方法也能让你少走很多弯路。它不要求你一开始就搭一个大而全的平台,而是从最小可用的技能开始,逐步扩展。

我见过太多团队一上来就想做“全能智能体”,结果卡在工具调用不稳定、上下文爆炸、错误无法回溯这几个坑里。agent-skills的价值恰恰在于它把问题拆小了:先定义技能,再谈编排;先保证单个技能可靠,再谈多技能协同。这个顺序反了,后面全是坑。

2. 核心设计思路:为什么是“技能”而不是“工具”

2.1 技能与工具的本质区别

很多人会把“技能”和“工具”混为一谈,觉得不就是给模型挂几个函数吗。实际做下来,这两者的设计哲学完全不同。工具是“我有什么能力”,技能是“我能完成什么任务”。工具是静态的,技能是动态的;工具关注功能,技能关注结果。

举个例子,你有一个“发送 HTTP 请求”的工具,这是工具层面的能力。但“查询某城市当前天气并返回温度”是一个技能。技能包含了工具调用,但还包含了参数校验、结果解析、异常兜底、格式转换这一整套逻辑。模型不需要知道 HTTP 怎么发,它只需要知道“我要查天气”,然后调用对应的技能。

这种抽象带来的好处是:模型的心智负担大幅降低。你让模型直接拼 HTTP 请求,它可能把 URL 写错、把 header 漏掉、把超时设成 0。但你让它调用get_weather(city),它只需要填一个城市名。错误率不是一个量级的。

2.2 技能注册与发现机制

agent-skills的核心机制之一是技能注册表。每个技能在注册时,需要提供一份“技能描述”,通常包括:

  • 技能名称:唯一标识,建议用动词开头,如read_file、send_email、query_database
  • 功能描述:一句话说清楚这个技能做什么,给模型看的
  • 参数定义:每个参数的类型、是否必填、默认值、取值范围
  • 返回结构:成功时返回什么,失败时返回什么
  • 调用示例:至少一个完整的输入输出示例

这份描述不是给人看的文档,而是给模型看的“说明书”。模型在决定调用哪个技能时,靠的就是这份描述。所以描述的质量直接决定了调用准确率。我踩过的坑是:描述写得太技术化,模型看不懂;写得太模糊,模型乱调用。最好的描述是“像给一个聪明但不懂业务的实习生解释”那样写。

注册表本身可以是一个内存字典,也可以是一个独立的服务。小规模场景下,内存字典足够;技能多了以后,建议做成可持久化的注册中心,支持热更新和版本管理。

2.3 技能编排与执行链路

单个技能再强,也解决不了复杂任务。真正的价值在于编排。agent-skills的编排逻辑通常是这样的:

  1. 接收用户输入
  2. 模型进行任务理解与拆解
  3. 根据拆解结果,从注册表中检索可用技能
  4. 按依赖关系排序,依次或并行调用
  5. 收集每个技能的执行结果
  6. 模型根据结果生成最终回复

这个链路里,最容易出问题的是第 3 步和第 4 步。检索技能时,如果描述不准确,模型可能选错技能;排序时,如果依赖关系没理清,可能出现“还没查到用户 ID 就去查订单”这种逻辑错误。

我的经验是:在技能描述里显式声明“前置条件”和“后置效果”。比如query_order的前置条件是“已知用户 ID”,后置效果是“返回订单列表”。这样模型在编排时就能自动推导出调用顺序,而不是靠猜。

3. 核心细节解析:一个技能从定义到落地的完整过程

3.1 技能定义的五个关键字段

一个可用的技能定义,至少包含以下五个字段。少一个,模型调用时就会出问题。

字段作用常见错误
name唯一标识,模型调用时使用用中文、带空格、有歧义
description给模型看的说明太技术化或太模糊
parameters参数 schema类型不明确、必填项没标
returns返回结构只写成功情况,不写失败
examples调用示例示例不完整或与实际不符

name的命名我建议用蛇形命名法,全小写,动词开头。不要用getUserInfo这种驼峰,也不要用获取用户信息这种中文。模型对蛇形命名的识别准确率明显更高,这是实测下来的结论。

description的写法有个技巧:用“当用户需要……时,调用此技能”的句式。这样模型在做意图匹配时,能直接对应到用户表达。比如“当用户需要查询某个城市的实时天气时,调用此技能”,比“查询天气”这种描述匹配率高出一大截。

3.2 参数校验与类型安全

模型生成的参数,永远不要直接信任。哪怕你在描述里写了“city 是字符串”,模型也可能给你传一个数字或者一个对象。所以每个技能在执行前,必须做参数校验。

校验分三层:

  • 第一层是类型校验:字符串、数字、布尔、数组、对象,必须严格匹配
  • 第二层是范围校验:数字的上下限、字符串的长度、数组的元素个数
  • 第三层是业务校验:比如城市名必须在支持列表里、日期不能是过去时间

这三层校验缺一不可。我见过一个案例:模型传了一个负数作为“查询条数”,结果数据库直接返回全表,差点把服务打挂。如果当时有范围校验,这个问题根本不会发生。

校验失败时,不要直接抛异常给模型,而是返回一个结构化的错误信息,告诉模型“哪个参数错了、期望什么、实际收到什么”。这样模型有机会自我修正,重新生成参数。这比直接失败重试整个任务要高效得多。

3.3 错误处理与重试策略

技能执行失败是常态,不是异常。网络抖动、接口限流、数据不存在,这些都会导致失败。关键是怎么处理。

我的策略是分三类:

  • 可重试错误:网络超时、临时限流。这类错误自动重试,最多三次,每次间隔递增
  • 可修正错误:参数格式不对、缺少必填项。这类错误返回给模型,让它修正后重新调用
  • 不可恢复错误:权限不足、资源不存在。这类错误直接终止任务,返回明确原因

重试策略里有个细节:不要所有错误都重试。比如“用户不存在”这种错误,重试一百次结果都一样,只会浪费时间和 token。判断标准是:这个错误是否可能因为时间或状态变化而消失。会,就重试;不会,就放弃。

3.4 技能粒度控制:多大算合适

技能粒度是个很容易走极端的点。太粗,一个技能干十件事,模型调用时参数复杂、错误难定位;太细,一个技能只做一次字符串拼接,编排链路长得离谱,token 消耗爆炸。

我的经验法则是:一个技能对应一个明确的业务动作,且这个动作的输入输出可以用一句话说清楚。比如“根据用户 ID 查询最近一笔订单”是一个合适的粒度;“查询订单并计算折扣并生成发票”就太粗;“把字符串转成大写”就太细。

另一个判断标准是复用性。如果一个技能在多个任务里都会被用到,那它值得独立出来。如果只是某个特定流程里的一次性操作,可以内联在编排逻辑里,不必单独注册。

4. 实操过程:从零搭建一个可用的技能系统

4.1 环境准备与基础依赖

先说明一点:agent-skills不是某个特定框架的专属概念,它是一种设计模式。你可以用任何语言实现,Python、TypeScript、Go 都行。我这里以 Python 为例,因为它生态最成熟,调试也方便。

基础依赖不多:

pip install pydantic openai httpx

pydantic用来做参数校验和 schema 定义,openai用来调模型,httpx用来发请求。如果你用的是其他模型服务,把openai换成对应的 SDK 就行。

目录结构建议这样组织:

agent_skills/ ├── registry.py # 技能注册表 ├── executor.py # 技能执行器 ├── skills/ # 技能定义目录 │ ├── weather.py │ ├── file_ops.py │ └── database.py └── orchestrator.py # 编排逻辑

这个结构的好处是技能之间解耦,新增技能只需要在skills/下加文件,然后在注册表里注册一下,不影响其他部分。

4.2 定义第一个技能:查询天气

我们从一个最简单的技能开始,把整个链路跑通。

from pydantic import BaseModel, Field from typing import Optional class WeatherParams(BaseModel): city: str = Field(..., description="城市名称,如'北京'") unit: Optional[str] = Field("celsius", description="温度单位,celsius 或 fahrenheit") class WeatherResult(BaseModel): city: str temperature: float unit: str condition: str def get_weather(params: WeatherParams) -> WeatherResult: # 实际实现里这里会调用天气 API # 这里用模拟数据演示 mock_data = { "北京": (22.5, "晴"), "上海": (25.0, "多云"), "广州": (28.3, "小雨"), } if params.city not in mock_data: raise ValueError(f"暂不支持城市:{params.city}") temp, condition = mock_data[params.city] if params.unit == "fahrenheit": temp = temp * 9 / 5 + 32 return WeatherResult( city=params.city, temperature=round(temp, 1), unit=params.unit, condition=condition )

这个技能定义里,WeatherParams就是给模型看的参数 schema。Field里的description会直接进入技能描述,模型靠它来理解参数含义。所以描述要写清楚,不要写“城市”两个字就完事,要写“城市名称,如'北京'”。

4.3 技能注册与描述生成

定义好技能后,需要把它注册到注册表里,并生成给模型看的描述。

import inspect from pydantic import BaseModel class SkillRegistry: def __init__(self): self.skills = {} def register(self, name, func, description): sig = inspect.signature(func) param_model = None for param in sig.parameters.values(): if issubclass(param.annotation, BaseModel): param_model = param.annotation break self.skills[name] = { "name": name, "description": description, "func": func, "param_model": param_model, "schema": param_model.model_json_schema() if param_model else {} } def get_skill_descriptions(self): descriptions = [] for skill in self.skills.values(): descriptions.append({ "name": skill["name"], "description": skill["description"], "parameters": skill["schema"] }) return descriptions

这里的关键是model_json_schema(),它能把 Pydantic 模型自动转成 JSON Schema,直接喂给模型。这样你就不用手写参数描述了,改模型定义就自动更新描述,减少不一致的风险。

注册时这样写:

registry = SkillRegistry() registry.register( name="get_weather", func=get_weather, description="当用户需要查询某个城市的实时天气时,调用此技能。返回温度、单位和天气状况。" )

描述里我特意加了“当用户需要……时”,这是为了帮助模型做意图匹配。实测下来,这种句式比单纯的功能描述匹配率高不少。

4.4 编排逻辑:让模型决定调用哪个技能

编排的核心是构造一个给模型的 prompt,把技能列表和用户输入一起传进去,让模型输出调用计划。

import json def build_orchestration_prompt(user_input, skill_descriptions): skills_text = json.dumps(skill_descriptions, ensure_ascii=False, indent=2) return f"""你是一个任务编排助手。根据用户输入,决定调用哪些技能,以及调用顺序。 可用技能: {skills_text} 用户输入:{user_input} 请输出 JSON 格式的调用计划,格式如下: {{ "plan": [ {{"skill": "技能名", "params": {{"参数名": "参数值"}}}} ] }} 如果不需要调用任何技能,返回 {{"plan": []}}。 只输出 JSON,不要有其他内容。"""

这个 prompt 的设计要点是:明确输出格式、给出示例、强调只输出 JSON。模型有时候会加一些解释性文字,导致解析失败。加上“只输出 JSON”能大幅降低这种情况。

拿到模型的输出后,解析并执行:

def execute_plan(plan, registry): results = [] for step in plan["plan"]: skill_name = step["skill"] params = step["params"] if skill_name not in registry.skills: results.append({"skill": skill_name, "error": "技能不存在"}) continue skill = registry.skills[skill_name] try: validated = skill["param_model"](**params) result = skill["func"](validated) results.append({"skill": skill_name, "result": result.model_dump()}) except Exception as e: results.append({"skill": skill_name, "error": str(e)}) return results

执行时每个技能独立 try-catch,一个失败不影响其他。结果收集起来后,再交给模型生成最终回复。

4.5 结果组装与最终回复生成

最后一步是把技能执行结果转成人类可读的回复。

def generate_final_response(user_input, results): results_text = json.dumps(results, ensure_ascii=False, indent=2) prompt = f"""用户问:{user_input} 技能执行结果: {results_text} 请根据以上结果,用自然语言回答用户。如果某个技能失败了,如实告知用户失败原因。不要编造结果中没有的信息。""" # 调用模型生成回复 return call_model(prompt)

这里有个重要原则:不要让模型自由发挥。明确告诉它“不要编造结果中没有的信息”。否则模型看到结果不完整,可能会自己脑补,导致幻觉。这个约束在技能系统里尤其重要,因为用户信任的是技能返回的真实数据,不是模型的想象。

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

5.1 模型选错技能怎么办

这是最高频的问题。模型面对十几个技能,经常选一个“看起来差不多”的,但实际不对。排查思路分三步:

第一,检查技能描述是否有重叠。如果两个技能的描述都包含“查询”和“信息”,模型很容易混淆。解决办法是在描述里加入区分性关键词,比如“查询用户信息”和“查询订单信息”,把“用户”和“订单”加粗强调。

第二,检查技能数量是否过多。一次性给模型超过 20 个技能,准确率会明显下降。解决办法是分组,先让模型选大类,再在大类里选具体技能。这叫“两级检索”,实测能提升不少准确率。

第三,检查用户输入是否模糊。如果用户说“帮我查一下”,模型根本不知道查什么。这时候应该让模型先追问,而不是硬选一个技能。在 prompt 里加一条“如果用户意图不明确,返回追问计划”,能有效减少误调用。

5.2 参数传递错误的排查方法

参数错误通常表现为:类型不对、缺少必填项、值超出范围。排查时按这个顺序来:

先看模型输出的原始 JSON,确认参数名和技能定义里的是否一致。常见错误是模型把city写成city_name,或者把unit写成units。这种拼写差异在 JSON 里不会报错,但校验时会失败。

再看参数值是否符合预期。模型有时候会把数字写成字符串,比如"count": "10"而不是"count": 10。Pydantic 默认会做类型转换,但有些严格模式下会直接报错。建议开启宽松模式,让 Pydantic 自动转换,减少不必要的失败。

最后看是否有嵌套结构错误。如果技能参数是一个对象或数组,模型很容易把结构搞错。解决办法是在描述里给出完整的嵌套示例,让模型照着抄。

5.3 技能执行超时与并发控制

技能执行超时是另一个高频问题。尤其是涉及外部 API 调用的技能,网络一抖动就超时。处理策略:

  • 每个技能设置独立的超时时间,不要用全局统一值。查询类技能可以短一点,比如 5 秒;写入类技能可以长一点,比如 30 秒
  • 超时后不要立即重试,先判断是否可重试。如果是网络超时,可以重试;如果是业务逻辑超时,重试也没用
  • 并发调用时,设置最大并发数。不要一次性把所有技能都发出去,容易把下游服务打挂。建议用信号量控制,比如最多同时 5 个

我踩过的一个坑是:没有设置并发上限,模型一次性编排了 20 个技能调用,结果下游服务直接限流,整个任务失败。后来加了信号量,问题就解决了。

5.4 常见问题速查表

问题现象可能原因排查方法解决方案
模型不调用任何技能描述不清晰或意图不明确检查 prompt 和技能描述优化描述,增加追问逻辑
调用错误技能技能描述重叠对比相似技能的描述增加区分性关键词
参数校验失败参数名或类型不匹配查看模型原始输出开启宽松模式,增加示例
技能执行超时网络或下游服务慢查看日志中的耗时设置独立超时,控制并发
结果组装出错模型编造信息检查最终回复与原始结果加强约束,禁止编造
多轮对话上下文丢失历史未正确传递检查上下文管理逻辑保留关键历史,压缩无关内容

5.5 几个独家避坑技巧

第一个技巧:给技能加“使用频率”标记。高频技能放在描述列表前面,低频放后面。模型对前面的内容注意力更集中,这样能提升高频技能的调用准确率。

第二个技巧:失败时返回“建议”而不是“错误”。比如参数校验失败,不要只返回“city 字段类型错误”,而是返回“city 应该是字符串,比如'北京',请重新生成”。模型看到具体建议,修正成功率会高很多。

第三个技巧:定期清理僵尸技能。有些技能注册了但从来不被调用,或者调用了但总是失败。这些技能留在注册表里只会干扰模型选择。建议每月 review 一次,把没用的技能下线。

第四个技巧:用真实用户输入做回归测试。每次修改技能描述或编排逻辑后,拿一批真实的历史输入跑一遍,看调用准确率有没有下降。这比拍脑袋改描述靠谱得多。

6. 技能系统的扩展与演进方向

6.1 从单技能到技能链

当技能数量超过一定规模,单层编排就不够用了。你需要支持技能链:一个技能的输出作为另一个技能的输入。这需要在编排时显式声明依赖关系,比如step2的参数来自step1的结果。

实现方式有两种:一种是在 prompt 里让模型直接引用,比如"params": {"user_id": "{{step1.result.user_id}}"};另一种是在执行器里做变量替换,模型只写占位符,执行器负责解析。后者更可靠,因为模型不擅长处理嵌套引用。

6.2 技能版本管理与灰度发布

技能是会迭代的。今天get_weather返回温度,明天可能增加湿度字段。如果直接改,正在运行的任务可能受影响。解决办法是给技能加版本号,注册表里同时保留多个版本,编排时指定版本或默认用最新稳定版。

灰度发布的做法是:新版本先注册但不启用,用一小部分流量测试,观察成功率和耗时。没问题再全量切换。这个机制在技能多了以后非常必要,否则一次改动可能引发连锁故障。

6.3 技能市场的可能性

如果技能定义标准化了,理论上可以做成技能市场:不同团队贡献技能,其他团队直接引用。这需要解决几个问题:技能描述的统一规范、参数 schema 的兼容性、执行环境的隔离、权限和计费。

目前这个方向还在早期,但思路是清晰的。agent-skills的价值不仅在于单个系统的实现,更在于它定义了一套技能交互的“协议”。协议一旦统一,生态就能起来。

6.4 性能优化的几个方向

技能系统的性能瓶颈通常在三个地方:模型调用、技能执行、结果组装。优化方向:

  • 模型调用:用更小的模型做意图识别和技能选择,用大模型做最终回复生成。分级调用能省不少成本
  • 技能执行:能并行的并行,能缓存的缓存。查询类技能加短时缓存,避免重复调用
  • 结果组装:结果多的时候不要全塞给模型,先做摘要或筛选,只把关键信息传给模型

我实测下来,分级调用模型能降低 40% 左右的成本,并行执行能缩短 30% 以上的端到端耗时。这两个优化性价比最高,建议优先做。

7. 我个人在实际操作中的几点体会

做技能系统这段时间,最大的感受是:可靠性比智能性更重要。用户能容忍模型回答得不够聪明,但容忍不了模型给出错误结果。一个技能调用失败,用户可能只是觉得“今天运气不好”;但一个技能返回了错误数据,用户可能直接失去信任。

所以我的原则是:宁可让模型说“我不知道”,也不要让它编一个答案。在 prompt 里反复强调这一点,在结果组装时严格校验,在技能执行时做好兜底。这些看起来是“笨功夫”,但恰恰是系统能长期运行的关键。

另一个体会是:技能描述值得反复打磨。我前后改了十几版天气技能的描述,从“查询天气”改到“当用户需要查询某个城市的实时天气时调用此技能,返回温度、单位和天气状况”,调用准确率从 60% 多提升到 90% 以上。这个投入产出比非常高,建议大家在描述上多花时间。

最后分享一个小技巧:给每个技能加一个dry_run模式。执行时不真正调用外部服务,只返回模拟数据。这样在调试编排逻辑时,不用担心把真实数据搞乱,也能快速验证链路是否通畅。等编排逻辑稳定了,再关掉dry_run跑真实调用。这个模式在开发阶段帮我省了很多事。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进