
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个候选技能。当时我用的是一个非常轻量的方案把所有技能描述向量化存起来每次拿到用户请求后做一次语义检索选出最相关的810个技能拼进上下文。这样既控制了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_entries10): self.entries [] self.max_entries max_entries def add_result(self, skill_name: str, params: dict, result: dict): entry_id fres_{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会立刻从看着能跑变成怎么跑都不翻车。