如果你照着某些开源框架的文档搭过一两个能跑起来的 Agent Demo,很快会撞上同一个坎:模型会聊天,但不会干活。让它查数据、订会议室、发通知,你得把一个个真实能力封装成它“看得懂、调得对”的模块。这一层模块,在很多 Agent 框架里就叫做agent-skills。我刚开始接触时,心想这不就是一组函数吗,把几个方法挂上去就完事。后来踩了一堆坑才明白,技能系统的设计直接决定 Agent 能不能稳定复现结果,它远远不是函数清单那么简单。
这篇文章打算用我在一个模拟项目 X 里自建 agent-skills 的完整过程,把技能系统的核心问题讲清楚:技能在框架里的职责边界、描述和参数怎么写模型才不跑偏、注册路由调用的闭环怎么搭,以及线上运行后最让我头疼的三个问题。适合正在写 Agent 应用、又不想直接套重型企业框架的开发者参考。如果你还是新手,前两节也能帮你建立对“技能层”的整体认知,知道一个 Agent 从“能对话”到“能干活”之间到底缺的是什么。
1. agent-skills 在智能体框架里到底承担什么角色
1.1 它不是函数库,也不是插件市场
我一直觉得“技能”这个词被用得太泛滥。有人把 agent-skills 理解成一个 Python 包,有人理解成一个工具类目,还有人把它跟插件市场画等号。这些理解都沾点边,但都没有说到点子上。
技能的准确位置,应该在大模型和真实系统之间。你有一个会议室系统、一个库存系统、一个邮件服务,模型不可能直接连数据库,也不可能直接调用 SDK。它需要一个中间层,这个中间层把“能力的意图语义”翻译成“可执行的函数调用”,再把函数的执行结果翻译回“模型能读懂的自然语言”。
所以一个完整的技能,至少包含四部分:意图描述(这个技能什么时候该用)、参数契约(调用方需要给什么 JSON 结构的数据)、执行函数(真正干活的那段逻辑)、状态反馈(成功、失败、部分成功如何返回)。这四部分缺一个,模型在调用时就会开始“猜”。而模型一猜,离出错就不远了。
举个例子。你会写一个check_stock(warehouse_id)函数,这没问题,但这对模型来说是不够的。模型并不知道“库存不足时需要先查库存再走采购流程”这个业务逻辑,也不知道仓库 ID 的格式是什么。它只知道用户说“A 仓库还有货吗”,然后它到底该调哪个函数、参数该填什么,全靠你给它写的描述。技能层就是把这些“模型需要知道的业务知识”固化下来。
1.2 一个技能能做什么,不能做什么
我见过新手最容易犯的错误:把一个技能写得特别大,恨不得一个函数解决八种需求。技能是原子化的,它的理想粒度是“一个能力只做一件事”,但这件事要做得完整。
我通常用下面这张表来区分技能、工具和工作流,也是项目里评审时必过的第一道关:
| 维度 | 技能 | 工具 | 工作流 |
|---|---|---|---|
| 最小单位 | 单个能力原子 | 可立即执行的动作 | 多个技能按顺序编排 |
| 是否携带意图描述 | 是,模型靠它决策 | 通常是底层接口,不面向模型 | 描述步骤和条件分支 |
| 模型是否直接调用 | 是,通过技能路由 | 不直接调用 | 由调度器或技能编排器执行 |
| 失败处理 | 技能自身负责返回状态 | 抛出异常即可 | 工作流负责补偿和回滚 |
拿我项目里的会议室预订来举例。底层工具是“创建预订记录”这个 API 调用,它是一个工具。但 agent-skills 层做的book_meeting_room技能,要包含:判断会议室是否空闲、创建预订、捕获冲突异常、向模型返回结构化结果。这就是一个完整技能。如果再往上,用户说“帮我安排一场跨部门评审会”,那你就需要一个工作流:先查参会人忙闲、再找合适会议室、最后发日历邀请。工作流里可能会串三个技能,但模型不需要直接看到这三个技能的细节,它只需要看到工作流对外暴露的入口。
所以“技能能做什么”的答案很清晰:它是模型和真实系统之间的最小决策单元。“不能做什么”也很清晰:它不适合承载多步骤的编排逻辑,也不适合做底层通用接口。如果你发现一个技能内部开始写复杂的if...else...分支断言,那你就该把它拆成多个技能,或者升级成工作流了。
2. 设计技能描述:让模型看懂比让函数跑通更难
2.1 描述是给模型看的说明书
函数能不能跑通,取决于代码;技能能不能被正确调用,取决于描述。这是我的项目在第一个月踩的最大的坑。
早期我给一个库存查询技能写的描述是“查询库存信息”。结果模型在用户说“这个东西还能补货吗”的时候也调了这个技能,而不是去调用采购建议技能。后来我把描述改成这样,问题一下子缓解了大半:
{ "type": "skill", "name": "query_inventory", "description": "查询指定 SKU 在当前仓库的实时库存余量和在途数量。仅当用户询问“还有多少货/库存够不够/能不能发货”时使用。若用户询问补货建议或采购周期,请改用 recommend_replenishment 技能。", "parameters": { "type": "object", "properties": { "sku_ids": { "type": "array", "items": {"type": "string"}, "description": "要查询的 SKU 编码列表,最多 20 个" }, "warehouse_id": { "type": "string", "description": "仓库编码,可空,为空时查询所有仓库" } }, "required": ["sku_ids"] } }这条描述的价值不在于字多,而在于它给了模型三样东西:触发条件(什么时候用)、排除条件(什么时候不要用)、备选去向(类似需求应该调哪个技能)。
写技能描述的时候,有几个很实用的原则:
- 动词开头。描述的第一句必须是一个明确的动作,比如“查询”“预订”“生成”,不要让模型去琢磨这个技能是干嘛的。
- 给出触发场景。写清楚用户话术达到什么意图时使用,尽量贴近真实问法。
- 写明副作用。如果技能会发送邮件、修改数据、创建订单,必须在描述里说明。否则模型可能在用户只是“随口问一下”时,就把邮件发出去了。
- 写明限制条件。比如“仅支持工作日上午”“单次最多查 100 条”,这些限制不写,模型会按它的幻想去填参数。
2.2 参数 schema 的取舍
参数 schema 是另一个容易过度设计的地方。我和 A 同学一起做项目时,有人提出把所有技能的参数都做成“宽松模式”,也就是只用description描述字段含义,不设类型、不设必填。理由是“大模型不擅长严格遵守格式”。这个观点在某些受限场景下有一定道理,但在需要真实落库的技能上,完全是反效果。
我的经验是:模型对严格类型和必填约束的遵守能力,远超你的想象,前提是你给的字段定义本身要清晰。容易被模型误解的是语义模糊的字段名,而不是格式约束。
举一个实际的例子。会议室预订技能的start_time字段,早期定义是 “开始时间”。用户说“下午三点开会”,模型可能会填"下午三点",执行器的datetime.fromisoformat直接报错。后来我把description改成 “RFC3339 格式的开始时间,如 2025-03-10T15:00:00,用户若使用相对时间(如‘下午三点’),请先按当天上下文转换为绝对时间”,解析失败率立刻降下来了。
设计 schema 时有三个参考建议:
- 尽量用字符串而不是代码枚举。模型生成“
"MEET-302"”这种字符串,比在枚举值里纠结要容易。枚举尽量用在数量少且稳定的场景,比如状态值。 - 必填项宁少勿多。只把真正不可推导的字段设为必选,可通过上下文推导的字段放在可选项里加默认值。
- 语义归一化字段要写清楚格式。时间、日期、金额、SKU 码这些字段,优先按 ISO 标准或公司内部已有规范来约束。
2.3 命名规范的隐性价值
技能命名看起来是小事,但模型对命名的“感知”比对人类更直接。命名会影响 embeddings 的语义相关度,也会影响模型在候选技能中快速定位的准确率。
我项目早期有do_thing、process_data、run_task这类抽象灾难名,结果是模型经常在无关请求时选中其中一个,因为它们的语义向量互相重叠,而且跟用户请求的匹配度都低而不稳定。后来我统一了命名规范:领域动宾短语 + 明确对象。
| 推荐命名 | 不推荐命名 | 原因 |
|---|---|---|
| book_meeting_room | do_reserve | 前者说清了动作和对象 |
| query_inventory | get_stock_info | 前者明确是查询动作,后者混入了“info”泛义 |
| send_email_digest | process_email | 后者无法区分是发送还是统计 |
| recommend_replenishment | suggest | 后者没有领域信息 |
这个工作做完之后,技能被正确选中的比例有很明显的提升。由于技能名字是模型最先看到的内容,它承担的是“快速分类”的作用;描述承担的是“精确匹配”的作用。两者是先后配合的。命名阶段帮模型排除一半错误选项,描述阶段再帮模型做最终确认。
3. 注册、路由与调用:从技能表到模型决策的完整闭环
3.1 集中注册还是分散注册
技能系统的第一步是注册。注册方式一般有两种:模块内装饰器分散注册,或者启动时集中注册。两种我都试过,最后选的是“分散声明 + 启动时归集”的组合方案。
装饰器写法的好处是,新增技能时只需要在函数边上加一行@register_skill(...),不用额外维护注册表;坏处是整个技能系统分散在各个目录,时间一长很难一眼看出项目到底有哪些能力。所以我加了一道启动时的“归集校验逻辑”:扫描所有被装饰的函数,登记到统一的SKILL_REGISTRY字典,并在启动时重复技能名报错。
SKILL_REGISTRY: dict[str, Skill] = {} def register_skill(name=None, description=None, parameters=None): def decorator(func): def wrapper(*args, **kwargs): return func(*args, **kwargs) skill = Skill( name=name or func.__name__, description=description or func.__doc__ or "", parameters=parameters or {}, func=wrapper, ) _validate_skill(skill) SKILL_REGISTRY[skill.name] = skill return wrapper return decorator这里有个细节:_validate_skill不是简单的查重,它还会校验参数的 JSON Schema 是否合法、技能描述是否为空、名称是否违反命名规范。项目里曾经出现过有人注册了一个description为空的技能,模型每次都跳过它;那次问题查了很久,最后是归集校验环节加了一条“描述少于 20 个字符直接报错”的规则,之后就没再发生。
集中注册表的价值在于,你随时可以打印出一份“模型眼中看到的所有技能”列表。排查模型决策问题时,这份列表就是你的第一现场。
3.2 匹配机制:先粗筛后精排
模型一次看到所有技能的描述,是最简单的方式,但也是最差的方式。当项目里技能超过 30 个,把全部技能 schema 塞进上下文,一是 token 开销大,二是模型在长列表里选错的概率会明显上升。这跟人一样,选项太多就容易看花眼。
我最终采用的是“两级选择”的思路:先用向量召回粗筛,再让模型精排。
第一级,计算用户请求query和每个技能description的相似度。这个阶段不用特别精确,只要能稳定把 Top 10 召回出来就行。我用的候选池是技能名的拼接描述文本,每 10 分钟向量化缓存一次。到这一步不需要模型参与,速度很快。
第二级,拿粗筛出来的 Top 5 到 Top 8 个技能的结构化定义,放入模型上下文,让它选择唯一最适合的一个。如果你希望模型一次调用多个技能,也可以在这个阶段设定“最多选择 N 个”。
def select_skills(user_query: str, top_k: int = 5) -> list[Skill]: candidates = vector_rough_screen(user_query, top_k) prompt = [ {"role": "system", "content": "你是技能路由器,根据用户请求选择最合适的技能。"}, {"role": "user", "content": f"用户需求:{user_query}\n\n可用技能:\n" + format_skills(candidates)}, ] selected = planner_model.complete(prompt) return parse_selected_skills(selected, candidates)这个“粗筛 + 精排”架构的价值,不只是省 token。它让最终决策只在一个较小的、相关的候选集里做,模型的准确率会明显更高。项目上线前我做过一次模拟对比:全量列表选择的准确率约 78%,两级选择能稳定在 91% 以上。在关键技能上,这个差距是不可接受的。
3.3 参数注入与执行器
技能被选中后,下一个环节就是参数注入。这里最常见的坑是:你连模型都没决策错,却在参数解析阶段失败了。
模型输出的通常是一个 JSON 对象,你要把它映射到 Python 函数签名。我的建议是避免手动kwargs拼接,尽量用显式映射或借用现成的 JSON Schema 验证逻辑。手动拼kwargs的坏处是字段名一旦对不上,执行器会报 TypeError,而且错误信息完全看不懂。
我用了一个很轻量的执行器模式:
def run_skill(skill: Skill, arguments: dict) -> SkillResult: validated = validate_arguments(skill.parameters, arguments) try: raw_result = skill.func(**validated) return SkillResult(ok=True, data=raw_result) except SkillExecutionError as exc: return SkillResult( ok=False, error=exc.message, suggestion=exc.suggestion, ) except Exception as exc: return SkillResult( ok=False, error=f"{skill.name} 执行阶段发生未预期异常:{exc}", suggestion=None, )SkillResult是技能层和模型之间统一的结构化返回协议。它不会只抛一个异常让上层接住,而是会把“发生了什么、下一步建议做什么”一并带回去。这一点我们后面在“失败问题”里还会展开,它是整个执行器设计里最关键的分支之一。
3.4 结果回填:让模型“看到执行结果再说话”
很多 Agent 应用在技能执行完之后,就是把结果拼接进assistant消息,然后往模型一丢,让模型自由发挥。这个做法没有错,但有一个隐性问题:模型会美化格式化执行结果,它很可能把"ok": false, "error": "会议室已被占用"转述成“会议室可能暂时无法预订”,让用户以为还有希望。
我的做法是:反馈给模型的结果不直接塞原始数据,而是经过一层“结果压缩 + 状态标注”。状态只保留三种:SUCCESS、FAILURE、PARTIAL。后续对话模板按状态分支处理。比如FAILURE状态下,要求模型只能复述错误原因,不得推测成功可能性;SUCCESS状态下可以展开数据,但不得虚构未提供的字段。
这一步对整个系统稳定性的帮助非常直接。技能执行“有没有成功”这件事,用户比模型更关心;模型更需要的是确定性的状态作为回答基调,而不是拿到原始返回自己判断。
4. 落地时避不开的坑:自省、超时与上下文污染
4.1 技能失败的“沉默问题”
你可能会觉得“执行失败”是最容易处理的:无非是把错误信息返回给模型罢了。但真实场景中,技能失败经常不是抛异常,而是返回了一个成功状态但业务逻辑没有生效,比如创建预订时发现参会人列表为空,函数没有报错,但也没真正预订;或者更新库存返回了 0 行变更,底层连接了旧数据未刷新。
我把这种情况叫做“技能的沉默失败”。它的危害在于:模型完全不知道实际没干成,继续顺着流程往下走,用户最终收到一条看起来正常但实际是假的回复。比如“会议已预订成功,预订号是 5921”,其实会议室系统里根本没这条记录。
解决办法不是靠模型聪明,而是靠技能自身的“业务级状态校验”。每个技能执行完,必须显式判断结果是否满足业务预期,而不是只看代码执行得很流畅就返回成功。我在SkillResult里加了一个可选指标expected_effect,例如预订技能会校验返回的预订号是否非空、库存技能会校验查询结果条数是否大于 0。内部判断不满足时,把状态码归一化为FAILURE,带上suggestion字段,告诉模型接下来可以怎么做。
以下是项目里最典型的失败排查记录:
| 现象 | 根因 | 对策 |
|---|---|---|
| 模型说预订成功,系统里没记录 | 函数执行成功但业务未生效 | 技能内增加结果断言校验 |
| 模型反复调用同一技能 | 技能返回错误但没有下一步建议 | 在错误信息里加入 suggestion |
| 用户取消操作后模型仍调用了技能 | model 没看到技能执行的副作用说明 | 描述中显式标注“会产生写操作” |
4.2 超时与并发
真实的技能不是本地纯函数,它要访问外部系统。外部系统一旦变慢,模型可能在一个对话里同时触发两三个技能,整个会话卡住。我一开始没控制超时,测试时经常出现一个技能卡死 30 秒,偏偏用户还等着回答。给技能执行加超时控制,是成熟 Agent 应用的基本功。
执行器里我用的是concurrent.futures的run_in_executor包装:
def run_with_timeout(skill: Skill, arguments: dict, timeout: float = 8.0): executor = ThreadPoolExecutor(max_workers=4) future = executor.submit(run_skill, skill, arguments) try: return future.result(timeout=timeout) except TimeoutError: return SkillResult( ok=False, error=f"{skill.name} 执行超时,已中断", suggestion="请稍后重试,或缩小查询范围。", )超时值必须按技能类型设阈值:查询类可以给 10 秒,写操作类给 5 秒就够;如果技能内部有重试策略,要把重试时间也算在内。给每个技能单独标注超时预算,这个信息也可以写进技能的元数据里。
还有一点容易忽略:模型有可能在一次响应里同时决定调用多个技能,而且这些技能之间可能互相依赖(比如先订会议室、再发邀请)。我的策略是并行不依赖的技能、串行执行有依赖的技能。串行链路上,每步超时都要重新按剩余时间评估,不然整体会超时。
4.3 上下文污染与 Token 失控
技能系统上线后,我发现了一个所有文档都很少细说的坑:每次技能返回结果都塞进上下文的话,对话越到后面,模型越容易受到历史失败记录的影响。用户前期问了一个失败的操作,模型记住了“会议室预订有问题”,后面用户问别的,它回答时还会带着一种“刚才出了点问题”的语气,完全跑偏。
技能结果不应该以原始形式全部留在上下文里。我是这样处理的:调用记录写入独立的skill_events存储,上下文里只保留最近一轮的关键状态和精简摘要。如果模型后续需要更详细的信息,它可以再调用一个query_skill_events技能,主动去检索历史记录。
另外,长对话里 token 会持续膨胀,因为每次技能返回的数据都可能很大。我建议给每个技能设置“回填给模型的输出长度上限”,超出的部分截断或汇总,而不是完整进上下文。比如库存技能可能返回上百条明细,但我只保留“总数 + 前 5 条明细 + 异常项摘录”给模型看。用户要明细的时候,再走另一个技能拿。
4.4 会话末端的技能复盘
最后聊一个我比较坚持的功能:在每段会话结束时加技能复盘。简单说,就是让系统在对话收尾阶段自动生成一份“本次调用了哪些技能、每个技能是否达成预期、是否有误选或沉默失败”的日志摘要,并把这份摘要写回技能库,作为后续优化描述和生产监控的依据。
这个想法最开始不是来自什么高级架构理论,而是一次线上反馈。用户投诉说:“我让它推荐会议室,它给我推荐了一个已经被占用一小时的房间。”我查日志发现,推荐技能执行时返回的数据里确实包含了那间房的占用信息,但模型在最终回答里忽略了这个字段,只照搬了房间号。这属于典型的“结果回填之后再加工时丢失关键约束信息”。
复盘逻辑做了两件事:第一,比对“技能返回的关键约束”与“最终回复文本”,如果回复里遗漏了关键约束,自动标记一条风险记录;第二,跟踪同一个技能连续失败率,连续三次失败就自动生成预警,提醒负责人检查技能描述或底层接口。
你可能会说这已经超出 agent-skills 的范畴,但这恰恰是技能系统的“最后一公里”。一套技能不仅要能用、好用,还要能在上线后知道自己在真实流量里表现如何。技能调用的日志不是普通业务日志,它描述的是模型每一步决策背后的上下文,是后续所有调试工作的基础。项目上线后我几乎每天都在看这些复盘日志,大多数性能问题都是在日志里先露出的端倪。
我在这个项目里最大的体会是:不要一开始就纠结用多复杂的框架、多先进的路由算法,先把技能的“描述-参数-执行-反馈”闭环做扎实,Agent 的稳定性自然会提升一个台阶。如果你正准备给自己的 Agent 加能力层,可以从这四部分入手去检查一遍,把描述写够、参数定准、失败处理做厚,这一套下来,它就不再只是一堆函数堆出来的玩具了。