ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Agent Skills实战:从技能协议设计到编排避坑全解析

Agent Skills实战:从技能协议设计到编排避坑全解析

1. 从"会对话"到"会干活":Agent Skills解决的真实痛点

做AI应用这行当,这两年最明显的变化不是模型变聪明了多少,而是大家终于开始认真琢磨一件事:怎么让模型真正"干活"。单纯把大模型接上对话框,让它陪你聊几千字,这种Demo早就没人稀罕了。真正有价值的是让智能体去查库存、改配置、发消息、调接口,把一整套业务流程跑通。可一旦走到这一步,几乎所有团队都会撞上同一堵墙——大模型确实能理解你的意图,但它自己并不会执行操作。它不知道你们内部的订单系统长什么样,不知道那个老旧的ERP接口需要先登录再拿token,更不知道"把今天销售额超过十万的订单标记为异常"这种话背后对应的具体代码逻辑。

于是Agent Skills这个概念就火起来了。它的核心思路特别朴素:把智能体需要执行的每一个具体能力,做成一个结构清晰、可描述、可调用、可复用的"技能单元"。就像给一个新手员工写操作手册,每项技能就是一张卡片,上面写着"这个技能是干什么的""需要哪些输入""会返回什么结果""什么情况下不能调用"。大模型作为调度中枢,读到用户需求后,从技能库里挑出合适的技能,填好参数,触发执行,再把结果拿回来继续推理。

我在自己的项目里最早尝试这种方式时,第一个感觉就是"原来如此"。过去写一个能跟数据库对话的工具,要么把自然语言直接翻译成SQL,要么让模型输出一段Python代码再eval执行。前者对复杂查询基本投降,后者安全隐患大到离谱。技能化之后,所有数据库操作被封装成有限的几个技能:查询订单、更新状态、统计汇总。模型要做的是选择技能、生成参数,而不是直接碰代码。安全性、可控性、可观测性一下子全回来了。

这篇文章不打算讲什么宏观理论,就把我做"agent-skills"这套技能体系时的完整思路、接口设计、核心代码、编排逻辑,以及踩过的那些真金白银的坑,一条条写出来。适合谁看?如果你正在做智能体应用,纠结怎么让模型稳定调用工具;或者你已经被"模型乱填参数""技能调用串线""多步骤任务走到一半崩了"折磨过,那这篇东西应该能省你不少时间。

2. 技能协议与接口设计:我试过的三种定义方式

技能化改造的第一步不是写代码,是定协议。也就是你用什么格式告诉大模型:当前有哪些技能可用,每个技能长什么样,怎么调用。这一步没做好,后面一切全是摩擦。我前后试过三种方式,各有各的适用场景,直接摆出来对比。

2.1 函数调用风格的Skills Protocol

第一种是模仿OpenAI Function Calling的结构,把每个技能定义成一份JSON Schema。技能名是函数名,技能参数就是函数的参数列表,每个参数有类型、描述、是否必填、枚举值约束。模型在需要调用时会输出一个结构化的函数调用对象,而不是自由文本。

{ "type": "function", "function": { "name": "query_sales_order", "description": "查询销售订单列表,支持按日期范围和状态筛选", "parameters": { "type": "object", "properties": { "start_date": { "type": "string", "description": "起始日期,格式YYYY-MM-DD" }, "end_date": { "type": "string", "description": "结束日期,格式YYYY-MM-DD" }, "status": { "type": "string", "enum": ["pending", "paid", "shipped", "cancelled"], "description": "订单状态" } }, "required": ["start_date", "end_date"] } } }

这种方式的优点是对模型极其友好。主流模型都专门训练过函数调用格式,输出稳定、错误率低,而且你不需要自己写解析逻辑,SDK直接帮你把模型的输出转成结构体。缺点也很明显:技能一多,光把全量技能描述塞进上下文就是一笔巨大的token开销。一个技能的描述平均200个token,挂50个技能就是一万token没了,而且模型在决策时对排在后面的技能关注度明显下降。

2.2 自然语言指令加参数Schema的组合

第二种方式是弱化"函数名"概念,让每个技能更像一份给模型看的"使用说明书"。描述里不仅写参数,还写清楚使用条件、前置检查、典型场景、反例。模型先用自然语言理解该调用哪个技能,再根据Schema补参数。

我实际项目中是这么写的:

技能名称:标记异常订单 适用场景:当用户提到"异常""有问题""需要人工复核"时 前置条件:必须先调用query_sales_order获取订单列表 参数说明: - order_ids: 需要标记的订单ID数组,必填 - reason: 异常原因,可选,默认"unspecified" - priority: 优先级,枚举值,默认"medium" 执行结果:返回被标记的订单数量 明确禁止:不能直接删除订单;不能修改金额字段

这种方式最大的好处是模型对"什么情况下用这个技能"的判断准确率高很多,因为描述里全是业务语境,而不是冷冰冰的API说明。代价是描述文本更长,而且对描述质量要求很高。写不清楚的话,模型会把技能边界理解成你意想不到的样子。

2.3 技能注册表与运行时发现机制

第三种是我后期做多Agent系统时才引入的,就是给每个技能注册表加运行时发现机制。每个技能模块启动时向注册中心上报自己的元信息,组成一个动态技能列表。模型侧看到的不是全部技能,而是根据当前任务上下文动态检索出的Top K个候选技能。

当时我用的是一个非常轻量的方案:把所有技能描述向量化存起来,每次拿到用户请求后做一次语义检索,选出最相关的8~10个技能拼进上下文。这样既控制了token量,又保证了模型看到的都是跟当前任务高相关的技能。

三种方式我现在的选择标准是这样的:

场景推荐方式原因
技能数量小于20,且调用链固定函数调用风格简单稳定,生态支持好
业务性强,技能边界模糊自然语言指令+Schema模型对业务语境理解更准
技能数量大,动态变化频繁注册表+检索发现控制token,提升命中率

另外提一句,无论用哪种协议,都要保证技能描述里的"词"跟用户可能用的"词"尽量重叠。模型做语义匹配很依赖词汇重合度,你写"查询订单"但用户说的是"看看最近买了什么",匹配效果就会打折。实战中我会多写几组同义词和常见问法。

3. 动手实现一套Skill Engine:核心模块与关键代码

协议定完之后,就要开始写真正的执行引擎了。我实现的这套Skill Engine分四个核心模块:技能加载器、参数校验器、上下文管理器、执行器。每个模块解决一类问题,下面逐个展开。

3.1 技能描述与元信息结构

所有技能以Python模块形式放在skills/目录下,每个技能是一个类,继承自统一的BaseSkill基类。基类定义了元信息字段、校验方法和执行入口。技能加载器在启动时遍历目录,自动导入并注册所有技能,不需要手动维护注册列表。

from typing import Any, Dict, Optional from pydantic import BaseModel, Field class BaseSkill: name: str = "" description: str = "" parameters: Dict[str, Any] = {} required_params: list[str] = [] async def execute(self, params: Dict[str, Any], context: Any) -> Dict[str, Any]: raise NotImplementedError class QuerySalesOrderSkill(BaseSkill): name = "query_sales_order" description = "查询销售订单列表,支持日期范围、状态、客户ID筛选" parameters = { "start_date": {"type": "string", "pattern": r"\d{4}-\d{2}-\d{2}"}, "end_date": {"type": "string", "pattern": r"\d{4}-\d{2}-\d{2}"}, "status": {"type": "string", "enum": ["pending", "paid", "shipped", "cancelled"]}, "customer_id": {"type": "string", "optional": True} } required_params = ["start_date", "end_date"]

把技能做成独立类的好处后面会越来越明显。每个技能自带完整的参数声明、错误码、调用说明,而不是散落在一堆if-else里。团队协作时,新增一个技能只需要在目录里加一个文件,根本不用动主流程代码。

3.2 参数校验与类型转换

模型输出的参数永远是字符串,直接拿去做业务查询必炸。比如日期字段,模型可能输出"2024-1-5",但你的数据库查询要求"2024-01-05"。又比如数字字段,模型输出"13,000",带着千分位逗号,转int直接抛异常。

我的校验器统一做三件事:类型转换、格式归一、枚举校验。转换失败时不是直接抛错,而是返回一个"可修复"的信号,提示模型重新生成参数。

import re from datetime import datetime def normalize_date(value: str) -> str: # 统一格式为YYYY-MM-DD value = value.strip() try: dt = datetime.strptime(value, "%Y-%m-%d") except ValueError: try: dt = datetime.strptime(value, "%Y/%m/%d") except ValueError: raise ValueError(f"无法解析日期: {value}") return dt.strftime("%Y-%m-%d") def validate_params(skill: BaseSkill, params: dict): validated = {} errors = [] for key in skill.required_params: if key not in params or params[key] in ("", None): errors.append(f"缺少必填参数: {key}") for key, value in params.items(): schema = skill.parameters.get(key) if not schema: continue if schema.get("type") == "string": if "pattern" in schema and not re.match(schema["pattern"], str(value)): errors.append(f"{key}格式错误: {value}") else: validated[key] = str(value) elif schema.get("type") == "integer": try: validated[key] = int(str(value).replace(",", "")) except ValueError: errors.append(f"{key}不是合法整数: {value}") elif schema.get("enum") and str(value) not in schema["enum"]: errors.append(f"{key}只能在{schema['enum']}中选择, 当前: {value}") return validated, errors

刚开始做的时候我觉得校验这步可以省,模型输出直接透传给业务接口就行。后来被坑了两次就老实了。一次是模型把日期传成了"明天",一次是把客户ID传成了"最近的大客户"。参数校验不光是格式检查,还承担着"纠偏"的职责,发现语义不对要立刻反馈让模型重新来。

3.3 上下文拼接与记忆窗口管理

技能执行不是孤立的,它需要依赖当前会话状态。比如用户说"把刚才查出来的那批订单都标记为已发出",其中"那批订单"指的是上一条消息里查询技能返回的结果。如果每个技能之间完全无状态,这种承接就不可能实现。

我的做法是在引擎里维护一个状态池,技能执行结果会按时间顺序存入池中,每条结果带一个唯一的引用ID。下一次技能执行时,可以从引用ID精确取数,也可以让引擎从池中自动匹配最贴近的描述。

class ContextManager: def __init__(self, max_entries=10): self.entries = [] self.max_entries = max_entries def add_result(self, skill_name: str, params: dict, result: dict): entry_id = f"res_{len(self.entries) + 1}" self.entries.append({ "id": entry_id, "skill_name": skill_name, "params": params, "result": result, "ts": datetime.utcnow().isoformat() }) if len(self.entries) > self.max_entries: self.entries.pop(0) return entry_id def resolve_reference(self, text: str) -> Optional[str]: # 简化版匹配:按时间倒序找包含相同技能名的结果 for entry in reversed(self.entries): if entry["skill_name"] in text or "结果" in text: return entry["id"] return None

这个记忆窗口要特别注意长度控制。技能执行结果往往是大段JSON,整个塞进上下文很快就把窗口撑爆。我一般会对结果做摘要化处理,比如保留前10条记录,后面用"[共N条记录,省略...]"代替。模型只需要知道"查询成功,数据量很大",需要细节时再单独调用查询技能。

4. 多技能编排:从"单个调用"到"工作流复用"

技能引擎跑通单个调用只是第一步。真实业务几乎都是多步流程:先查库存,再根据库存状态补货,补货之后通知仓库。如果每一步都靠模型自由发挥,路径稳定性和失败率会让你怀疑人生。所以我后来加了一层编排层,把常用的多步流程固化成"剧本",让模型按照剧本走,而不是每次都从零推理。

4.1 顺序编排与条件分支

最简单的编排是顺序执行,脚本里写清楚一个技能的输出如何作为另一个技能的输入。

playbook: 自动补货流程 step1: skill: query_inventory params: sku_ids: {user.sku_list} next: step2 step2: skill: create_purchase_order params: sku_ids: {step1.results.low_stock_skus} quantity: {user.default_quantity} next: step3 step3: skill: notify_warehouse params: order_no: {step2.results.order_no}

我在脚本引擎里用了一个简单的模板变量替换机制,形如{step1.results.low_stock_skus}的字段会自动去状态池里取值。这样做的好处是步骤间的数据流对开发者完全可见,调试时一眼就能看出是哪一步的数据没接上。

条件分支也不复杂,每个step可以带一个when表达式,引擎执行前先用轻量规则引擎判断。比如库存充足时跳过补货步骤,直接结束流程。

4.2 技能间的数据传递

技能与技能之间传数据最怕的是"字段名对不上"。查询库存返回的是low_stock_skus,创建采购单要的参数叫sku_list,两个名字不一样,模板替换就失败了。我的解决方案是每个脚本里显式写字段映射,而不是依赖名称恰好一致。

def apply_mapping(source: dict, mapping: dict): result = {} for target_key, source_path in mapping.items(): # source_path 形如 "step1.results.low_stock_skus" keys = source_path.split(".") val = source for key in keys: val = val.get(key, {}) if isinstance(val, dict) else None result[target_key] = val return result

这种显式映射还有个额外收益:模型提示词里可以明确写上"补货数量取step1结果里的low_stock_count字段",减少模型生成参数时凭感觉乱填。

4.3 循环、重试与兜底策略

多步骤流程里面循环很常见,比如给一批订单逐个发送通知。脚本引擎我加了schema里的foreach关键字,对某个列表字段做遍历执行。每个子项执行失败时,可以选择终止整个流程,也可以记录错误后继续处理下一个。

重试策略是另一个不可忽视的细节。模型调用技能时可能因为上下文窗口截断导致参数缺失,这时候直接报错给用户非常不体面。我的策略是:第一次参数校验失败,把错误信息拼回去让模型重新生成;第二次再失败,才走兜底分支。实测下来,这个"再试一次"的动作能把技能调用成功率从80%拉到95%以上。

兜底分支一般有两种:一种是调用一个通用的人类接管技能,把当前状态打包发给运维人员;另一种是给用户返回一个友好提示,告诉他当前无法自动处理,并列出已经完成的前序步骤。这两种都比直接抛异常要专业得多。

5. 实测中踩过的坑:越界调用、幻觉参数与并发冲突

写代码的时候以为设计已经天衣无缝了,一上真实环境才发现到处都是暗礁。下面这几个坑,每一个都让我花了不少时间排查,写出来给你当参考。

5.1 模型把技能名"编"出来了

有个场景,用户问"这个月的退货率是多少",技能库里有query_return_rate这个技能。但模型没走技能调用,直接生成了一段自然语言回答,说"根据我的计算,这个月退货率是15.6%"。问题是这个数字完全是大模型编的,系统里根本没有这个数据。

这种情况比不调用技能更危险,因为它看起来像真话。排查后发现根因是技能描述里没有写清楚"该技能返回的才是真实数据",而且模型认为凭对话历史能推断出答案。我的解决办法是在全局系统提示词里加了硬性约束:凡是涉及统计数据、账户信息、订单明细的请求,必须先调用技能获取结果,严禁直接作答。同时在技能描述里强调"最终回答必须基于本技能返回的数据,不得使用模型内部知识补充具体数值"。

5.2 参数幻觉:模型填了不存在的枚举值

枚举值校验我明明写得死死的,status字段只允许pending、paid、shipped、cancelled四种。结果模型给传了个delivered。查日志发现,模型在生成参数时受到了用户原话的影响——用户说"已送达的订单",模型想当然地映射成了delivered。我们业务系统里"已送达"对应的状态码其实是shipped。

这种问题靠参数校验能拦截住,但拦截之后生成的错误提示如果过于技术化,模型会一头雾水。后来我把错误信息改成了"你选择了不存在的状态值,可选值为pending/paid/shipped/cancelled,请根据用户意图重新选择",并且对于用户说法和枚举值不一致的场景,在技能描述里额外标注了同义词映射:已送达=shipped,待付款=pending。加了这层之后,类似错误明显少了一大半。

5.3 并发状态下状态污染的排查

还有一个特别隐蔽的坑,出现在多Agent并发场景。两个用户同时触发任务,其中一个任务A把状态池里写入了某些中间结果,另一个任务B在做数据匹配时读到了A写入的数据,导致B执行了错误的分支。排查过程很痛苦,因为问题不是必现的,只在并发量高的时候偶发。

定位根因用了一整天,最后发现是状态池设计成了全局单例,所有会话共享同一个列表。修复方案也简单:把状态池改成按会话ID隔离。每个会话独立维护自己的ContextManager实例,互不可见。同时所有技能执行时的上下文对象显式携带session_id,禁止访问其他会话的数据。这里提醒你,如果你也做多Agent并发,从一开始就要贯彻会话隔离,不要等到出问题再改。

6. 技能评测与迭代:如何量化"这组技能好不好用"

技能库不是写好就完了,它得持续迭代。但有个现实问题:你凭什么说这个技能好?那个技能该改?靠感觉是不行的。我后来搭了一套相对粗糙但有效的评测机制,核心思路是把"模型调用技能的质量"拆成几个可统计的指标。

6.1 构造场景集与期望行为

评测的前提是有一批标准化场景。我维护了一个场景集文件,每个场景是一组用户请求加期望行为。期望行为不是期望的具体回答文本,而是期望的技能调用序列和参数取值。

[ { "query": "帮我查一下上周发的那些订单现在是什么状态", "expected_skill": "query_sales_order", "expected_params": { "start_date": "上上周一的日期", "end_date": "上周日的日期", "status": "shipped" } }, { "query": "把客户12345的所有未付款订单取消掉", "expected_skill": "cancel_order", "expected_params": { "customer_id": "12345", "reason": "user_request" } } ]

场景集要覆盖三类用例:标准调用、边界调用(缺参数但能推断出来)、异常调用(表达含糊,应该主动追问而不是硬来)。我目前维护了200多个场景,每次改技能描述或调整提示词,就全量跑一遍。

6.2 成功率、召回率、误调用率的定义与统计

我统计四个指标,不多不少:

指标定义说明
技能选择准确率正确技能被选中且参数完全正确最严格,必须全对
技能选择召回率正确技能被选中(允许参数有点偏差)关心"选没选对"
参数正确率在技能选对的前提下,参数全部校验通过关心"填没填对"
误调用率本不该调用技能却调用了如用户只问建议,模型却去查库

评测时还有个容易被忽略的点:技能执行结果本身可能是错的。比如技能本身有bug,返回了错误数据,但模型调用流程完全正确。所以我会把"调用质量"和"执行质量"分开统计,前者看模型,后者看后端逻辑。

6.3 回归测试与版本管理

技能库的迭代必须配套回归测试。每次改动可能让A场景变好、B场景变差,没有跑全量用例根本发现不了。我把评测脚本挂在CI里,每次合并技能描述改动或提示词改动时自动跑一遍,生成一份diff报告,列出哪些场景得分上升、哪些下降。

版本管理上,我把技能描述和技能代码一起放进Git仓库,每次改动打一个版本号。线上运行的Agent固定加载某个版本,新版本先在评测环境跑一周,看指标稳定再灰度。别小看这个流程,它让我避免了好几次"修了一个坑,埋了三个雷"的事故。

最后分享一个我觉得最值得回味的发现:技能库的迭代方向,很多时候不是让模型更聪明,而是让模型更少犯错。把描述里容易引起歧义的话删掉,把参数约束写得再具体一点,把反例补得再多一点,比换更大的模型更能提升实际效果。所以如果你也在做Agent相关的东西,别急着追新模型,先把"技能"这层地板夯实,你的Agent会立刻从"看着能跑"变成"怎么跑都不翻车"。

RELATED READING

延伸阅读

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