说实话,我看到 agent-skills 这四个字的时候,第一反应不是“又叠了一个新词”,而是想起了做 Agent 工程以来最头疼的问题:模型回答得漂漂亮亮,真让它干活的时候却经常掉链子。技能层这件事,我前前后后折腾过好几版,越做越觉得它才是智能体能不能稳定落地的命门。
这个项目名字可以直接理解成“智能体的技能系统”,核心解决三件事:让模型知道你会什么,用统一的方式把真本事暴露出来,并且每次调用都可控、可回退、可评估。听起来像是个工程规范,但真做起来牵扯到协议设计、路由策略、参数校验、灰度评估,每一环都有坑。我把它全流程拆开写一遍,适合正在搭多智能体系统、或者想让单 Agent 真正能接手业务动作的朋友参考,里面所有方案都是我自己在真实项目里改过几轮才定下来的。
1. 为什么必须把“技能”从 Agent 里单独抽出来
1.1 模型需要的是行为能力,不是一本百科全书
很多团队一开始做 Agent 的方式,就是把所有工具说明塞进系统提示词里,然后寄希望于大模型“理解”一切。这个思路在小 demo 里跑得通,一旦工具超过十个、参数超过三层嵌套,系统提示词就会被撑爆,模型开始选择性“失聪”。
我习惯用一个类比来解释这个问题:你招了个很聪明的实习生,他专业知识扎实,但他没进过仓库,不知道货放在哪、不知道怎么用叉车、不知道验收单要签哪些字。你想让他干活,不是给他一本仓库管理制度背,而是要给他一张“操作卡”,写清楚什么情况下找谁、执行什么动作、完成了告诉谁。
Agent 也是一样。模型不是靠“知道很多”来干活的,它是靠“在合适的时机触发一个已经被确定编码好的动作”来干活的。这个动作就是技能。技能本质上是模型的“外部手脚”,把模糊意图变成确定动作、把自然语言变成结构化调用、把概率性输出变成可靠执行结果。
把技能从 Agent 内部抽出来,核心收益是边界清晰:模型只负责做决策——选哪个技能、传什么参数;真正的业务逻辑、幂等控制、异常兜底全部由技能层自己负责。这样出了问题你排查的是技能实现,而不是在大段提示词里找逻辑漏洞。
1.2 从“提示词堆工具”到“注册表驱动能力发现”
早期我做助手类应用,工具列表是写死在 prompt 里的:
你可以使用以下工具: 1. 查询订单(参数:订单号) 2. 查询物流(参数:订单号) 3. 查询库存(参数:SKU)看起来没问题,但实际使用中会出现两个毛病。第一,模型对工具描述的“注意力”是有限资源,工具一多它就记混,把查询物流错叫成查询订单是家常便饭。第二,新增一个工具要重新拼 Prompt、重新发布,灰度、回滚都无从谈起。
后来我在 agent-skills 里改成注册表模式:每个技能独立描述、独立注册、独立上线,Agent 运行时通过一个 manifest 文件看到当前可用的技能集合,模型只根据 description 和 input schema 来决定“要不要调用”“传什么参数”。技能不塞进 Prompt,而是按需加载、按需发现,调用的稳定性立刻上了一个台阶。
我还专门做了一个实验:同一个模型、同一套 prompt 模板,工具数量从 5 个增加到 20 个时,纯提示词模式的任务成功率从 86% 掉到 61%,而注册表模式只掉了 7 个百分点。原因很简单:描述信息不再占 Prompt 的宝贵窗口,模型每次只需要看跟当前意图相关的几个技能描述,决策精度自然高。
2. 技能描述与协议设计,这一层决定上限
2.1 一个技能描述文件里到底该写哪些字段
很多第一次做技能系统的人会把精力全放在“这个技能怎么实现”上,其实实现反而是最简单的部分,真正的门道全在描述协议。我在 agent-skills 里用的技能描述文件长这样:
{ "skill_id": "order.query_by_email", "name": "按邮箱查询订单", "description": "当用户提供注册邮箱时,查询该邮箱下的所有历史订单。仅支持已经完成注册的邮箱,不适用于手机号查询。如果查询无结果,返回空列表并提示用户检查邮箱是否正确。", "version": "1.2.0", "input_schema": { "type": "object", "properties": { "email": { "type": "string", "format": "email", "description": "用户注册时使用的邮箱地址,必须是完整格式,例如 user@example.com" } }, "required": ["email"] }, "output_schema": { "type": "object", "properties": { "orders": { "type": "array", "items": { "type": "object", "properties": { "order_id": {"type": "string"}, "status": {"type": "string"}, "total_amount": {"type": "number"} } } } } }, "timeout_ms": 5000, "retry_policy": "no_retry", "visibility": "public", "owner_team": "trade-core", "tags": ["order", "email", "customer-service"] }每个字段都不是白写的。skill_id 是全局限定名,路由和监控都靠它,不要随意改;name 是给人看的,方便排查问题;description 是给模型看的,它的质量直接决定模型能不能正确触发这个技能;input_schema 和 output_schema 是同时给模型和代码看的双重约束,模型理解参数结构,代码做严格校验;timeout_ms 和 retry_policy 是给运行时看的,不然一个技能卡住会拖垮整个编排。
这看起来像废话,但我在实际项目里见过太多技能描述文件只有一个 name 和一句话 description,结果就是模型瞎猜参数、调用频繁超时、出问题不知道是谁负责。描述文件不是写给人看的注释,它本身就是运行时的“契约”。
2.2 description 怎么写才不会被模型误解
我踩过最大的坑就是 description 写得太抽象。举个例子,最早我写查询物流的技能:
查询物流信息模型确实会调用,但它也会在用户问“订单到哪了”的时候调用,甚至在用户问“退款退到哪了”的时候也去调用物流接口,因为“到哪了”让它联想到了物流。后来我把描述改成:
根据订单号查询物流轨迹。当用户询问包裹当前位置、配送进度、预计送达时间时,使用此技能。不适用于退款进度查询,退款请使用 refund.status 技能。如果订单号不存在,返回错误码 ORDER_NOT_FOUND。改了之后,误调度率从 31% 降到了 9%。这里面有三个经验值得单独说:
第一是动词开头,直接说明要执行的动作;第二是写正向触发条件,列出用户可能怎么问;第三是写负向排除条件,明确告诉模型什么场景不要用。负向约束特别重要,模型很喜欢“就近联想”,你不拦着它,它就会在边缘案例里替你做出错误决定。
我后来还要求在 description 里补上失败返回语义,因为模型不知道技能返回的错误码是什么意思。如果技能能返回“ORDER_NOT_FOUND”,描述里却没有解释,模型就可能在回复里胡编一个错误原因。描述不只是触发条件,也是错误码的字典。
2.3 注册、发现与热更新:让技能像插件一样可插拔
技能协议设计好之后,还需要一套注册与发现机制。我在 agent-skills 里的做法是:技能代码和描述文件打到同一个发布包里,部署时自动向注册中心上报;Agent 运行时定期拉取 manifest,并缓存到本地。
这里有一个关键设计:不在运行时实时查询注册中心。因为在 Agent 的每次决策循环里,实时查注册中心意味着多一次强依赖网络调用,一旦注册中心抖动,整个 Agent 就瘫痪了。我采用“定时拉取 + 本地快照 + 版本比对”:
import json import hashlib import time from pathlib import Path class SkillRegistryClient: def __init__(self, base_url, cache_path="./manifest_cache.json"): self.base_url = base_url self.cache_path = Path(cache_path) self._manifest = self._load_cache() def _load_cache(self): if self.cache_path.exists(): return json.loads(self.cache_path.read_text()) return {"version": 0, "skills": []} def sync(self): resp = requests.get(f"{self.base_url}/skills/manifest") remote = resp.json() if remote["version"] != self._manifest["version"]: self._manifest = remote self.cache_path.write_text(json.dumps(remote, ensure_ascii=False)) return self._manifest def match_skills(self, intent_embedding, top_k=5): # 这里会根据意图匹配技能描述,返回最相关的几个候选 pass热更新的好处是:新增技能、下线故障技能、灰度切流量都可以在不重启 Agent 的情况下完成。我甚至会在 manifest 里加一个“force_disable”字段,一旦线上监控发现某技能调用异常,直接远程下线,比改代码发版快得多。
3. 编排中的调用链路与路由策略
3.1 一次技能调用的完整生命周期
协议定了、注册中心有了,接下来就是最关键的一环:一次技能调用在运行时到底是怎么走完的。我在 agent-skills 里把一次调用拆成八个阶段:
用户输入解析、意图候选匹配、技能路由决策、参数提取与校验、技能执行、结果校验、失败重试决定、结果回填到对话上下文。
每个阶段都有可能出问题,但最容易翻车的是中间三个。技能路由决策环节,模型会在候选技能里做选择,这里我建议不要一次性把所有技能都开放给模型,而是先通过 embedding 召回最相关的 5 个,再让模型在这 5 个里做选择。相当于先粗筛再精排,把模型的选择压力降下来。
参数提取与校验环节是另一个重灾区。模型经常把用户的原始说法直接塞进参数,比如用户说“我的邮箱是 abc 艾特 example 点 com”,模型可能真把这么一串原样传进来。所以一定不能信模型给的参数,必须以 schema 为准做严格校验,校验不通过就进入“澄清追问”,而不是硬调技能。
我用一段伪代码概括整个管线的骨架:
def execute_skill(skill, user_input, context): # 1. 从 user_input 中抽取结构化参数 params = extract_params(skill.input_schema, user_input, context) validation = validate_params(skill.input_schema, params) if not validation.valid: return SkillResult( status="NEED_CLARIFICATION", message=validation.reason ) # 2. 执行技能本体,记录耗时和状态 try: raw_result = skill.invoke(**params) except TimeoutError: return SkillResult(status="TIMEOUT", message="技能执行超时") except SkillBusinessError as e: return SkillResult(status="BIZ_ERROR", error_code=e.code) # 3. 校验输出结构,不符合 schema 也视为失败 if not validate_output(skill.output_schema, raw_result): return SkillResult(status="OUTPUT_SCHEMA_MISMATCH") return SkillResult(status="SUCCESS", data=raw_result)注意第 3 步,很多人漏了输出校验。技能是团队 A 写的,调用方是团队 B 的系统,一旦技能改了输出字段名,调用方完全不知道。我在技能描述里强制要求 output_schema 必须声明,运行时校验不过就降级报错,不让脏数据往上层流。
3.2 参数校验和澄清追问的处理细节
参数校验这块,我见过两种极端的处理方式。一种是什么都不查,模型传啥就是啥,结果线上全是报错;另一种是参数校验逻辑写得太死,用户少说一个字就僵在原地,体验非常生硬。
正确的姿势是分三个等级:必需参数缺失时,触发澄清追问;参数格式不合法时,尝试自动修复;可修复但拿不准时,让用户二次确认而不是直接猜。
比如查询订单,用户说“查一下我上个订单到哪了”,但没提供订单号。模型可能会在前面对话里找到订单号,也可能找不到。找不到的时候,技能层不应该报“参数缺失”就结束,而应该返回一个明确的澄清问题:
{ "status": "NEED_CLARIFICATION", "ask": "请提供订单号,或者告诉我您的注册邮箱,我可以帮您查找最近订单。" }这里有体验上的讲究:不要一次把所有缺失的参数都问一遍,而是先问最重要的那个。用户回答之后再补剩余参数,对话更自然。我在测试时发现,一次性问三个问题,用户大概率只回答最后一个,因为人的注意力会像漏斗一样漏掉前面的信息。
另一个细节是格式修复。模型传的 email 可能是“abc@ example.com”这种带空格的,技能层会自动 trim 掉空格再校验,而不是直接丢给用户重说。修复规则可以放在技能自身的 metadata 里,比如 email 字段配置一个“normalize”: "trim_lower",这让每个技能对参数的处理策略是显式的,可审计、可复用。
3.3 技能链、并行调用与回退策略
单技能调用稳定之后,就要处理更复杂的场景:多个技能按顺序执行,或者组合执行。我做客服工单自动处理时,一条典型链路长这样:意图识别技能先判断用户想干嘛,订单查重技能确认用户是不是重复提交,知识库检索技能拉取相关规则,最后自动回复生成技能结合前面所有结果产出答案。
这条链路里任何一个环节挂了,是直接放弃整条流程,还是走替代路径,必须在编排层明确定义。我在 agent-skills 里给每个技能节点都配了 fallback 声明:
nodes: - skill: intent.classify fallback: - skill: intent.rules_based - skill: order.deduplicate fallback: none - skill: knowledge.retrieve fallback: - skill: knowledge.similar_case这里有个经验:不是所有技能都需要 fallback。像“订单查重”这种核心动作,找不到替代路径时宁可终止流程转向人工,也不要硬接一个规则脚本顶替,因为规则脚本的结果会污染上下文,导致后面的生成技能输出错误信息。技能链的容错设计,本质是一张“什么时候必须停下来”的决策表,而不是“怎么继续跑下去”的无脑重试。
并行调用也要提一下。如果链路中某两步互不依赖,应该并行执行,节省大量时间。比如查用户会员等级和查订单列表可以同时发起。我一开始把链路写成纯顺序的,用户查一次订单状态要 7 秒钟,因为中间有三次串行网络调用。改成并行之后,总耗时降到 3.5 秒,体感差距非常明显。
4. 可观测性与技能质量门禁
4.1 一个技能“好用”的标准到底是什么
很多团队问我的第一个问题都是:怎么判断技能做得好不好?看代码逻辑是否清晰只是一个方面,我更看重运行数字。我在 agent-skills 里建立了一套技能健康度指标,每个技能上线前和上线后都要看这些数字:
| 指标 | 计算方式 | 健康线 | 说明 |
|---|---|---|---|
| 调用成功率 | 成功次数 / 总调用次数 | >= 95% | 排除因业务本身无结果的情况 |
| 参数校验通过率 | 首次参数校验通过次数 / 总调用次数 | >= 90% | 低于 80% 说明描述或参数 schema 有问题 |
| 误调度率 | 用户不满足触发条件却调用的次数 / 总调用次数 | <= 5% | 高了说明 description 有歧义 |
| 平均耗时 | 单次技能执行平均毫秒数 | <= 2s | 超过就要考虑拆技能或优化下游接口 |
| 人工介入率 | 技能完成后仍转人工的次数 / 总调用次数 | <= 10% | 高于 15% 说明技能“完成”定义有问题 |
| 输出 schema 合规率 | 输出校验通过次数 / 总执行次数 | >= 99% | 违规输出会直接污染对话上下文 |
这六个指标里,误调度率最容易被忽略,但它恰恰是体验崩坏的源头。技能本身实现得再好,如果模型在错误的场景触发它,用户拿到的就是答非所问。我每个技能描述更新后,都会拿着最近一周的真实用户 query 列表跑一遍离线批量测试,统计误调度率,而不是拍脑袋觉得“写清楚了一点”。
4.2 离线评测与在线灰度:技能上线前的最后两道闸门
技能系统改动的频率比想象中高得多,不可能每次改完都走全人工回归。我有一套固定的评测流程,分两层。
离线评测层,我会准备一批带标注的测试用例。每个用例包含用户输入、期望触发的技能、期望参数关键值、期望回复风格。改动任何一个技能后,跑一遍全集,看三个数字:触发准确率、参数抽取准确率、最终答案可读性评分。这套用例集要持续补充,每遇到一次线上误调度就加一条用例进去,防止同样的问题回归。
在线灰度层,离线评测通过不代表可以全量发布。我给技能设计了版本号与流量权重,比如新版本先放 10% 流量,观察 24 小时,再逐步扩大到 50%、100%。灰度期间,新旧版本的指标对比直接打在同一个监控面板上,特别是时长和来源表单上不能有幅度提升。
有一个非常容易踩的坑:灰度只看成功率,不看误调度率。新技能成功了 100%,但它把不属于自己的请求全抢过去了,成功率高得假。所以灰度看板必须同时展示“调用量占比变化”,如果一个技能灰度期间的调用量占比突然翻倍,十有八九是描述范围没写清楚,抢了别的技能的活。
4.3 全链路追踪怎么埋才不白埋
Agent 技能调用的调试比普通接口难,因为模型决策有随机性。同一句话,模型上一次选择了技能 A,下一次可能选择技能 B。所以日志必须记录完整链路,我用的追踪字段如下:
{ "trace_id": "a3f9c0e1-8a22-4f76-9b3d-2e1d81f0a4b5", "agent_id": "customer-service-prod-01", "user_query": "我的订单为什么还没发货", "intent_candidates": [ {"skill_id": "order.query_progress", "score": 0.86}, {"skill_id": "logistics.track", "score": 0.75} ], "selected_skill": "order.query_progress", "params_after_extract": {"order_id": "20250101-AB123"}, "execution_status": "SUCCESS", "execution_ms": 1340, "fallback_used": null, "response_summary": "已告知用户订单当前处于待发货状态" }这段日志看起来繁琐,但调试时价值极大。比如发现 user_query 和 selected_skill 不一致,说明模型决策错了;params_after_extract 不等于用户原始意图里的信息,说明参数抽取阶段丢了信息;execution_status 是 TIMEOUT 但 fallback_used 是空,说明编排层没配置回退。
我强烈建议把 trace_id 透传到前端页面或者企业微信机器人消息里,用户反馈“这次回答不对”的时候,你可以拿着 trace_id 直接查出当时所有上下文,而不是让用户复述一遍他当时说了什么。这个体验上的差别,做过线上客服的人一定懂。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
记一下我这一路来最常遇到的问题,按症状排好,方便你碰到同样的情况直接定位:
| 症状 | 可能原因 | 快速处理 |
|---|---|---|
| 模型把技能 A 误调成技能 B | description 没写负向排除条件 | 在描述里增加“不适用于 XX 场景”句子 |
| 参数总是缺邮箱 | input_schema 里没有声明 format | 在 schema 的字段 description 里写清格式实例 |
| 技能执行成功但回答不对 | output_schema 和实际返回不一致 | 打开运行时输出校验,强制暴露脏数据 |
| 同一句话两次结果不同 | 模型决策温度过高 | 将路由决策调用 temperature 设为 0 或 0.1 |
| 技能经常超时 | 下游接口响应慢但技能没设 timeout | 设置超时下限并暴露超时错误码,不要无限等 |
| 新增技能后老技能调用量暴跌 | 新技能 description 范围写太宽,抢走了流量 | 收窄新技能描述,重新统计触发分布 |
| 日志里有参数但查不到 trace | 未把 trace_id 写入子调用日志 | 用日志库的 MDC 自动附加 trace_id |
最容易被忽略的是温度参数。我见过太多团队把模型温度统一设成 0.7,结果用于工具路由时,模型在模棱两可的意图上多次“自由发挥”,选错技能。决策类调用的温度必须调低,最好接近 0,让模型每次看到同一段上下文都做出同样的选择,这是可观测性的地基——结果不可复现,谈什么排查问题。
5.2 三个印象深刻的 Debug 案例
第一个案例是物流查询的“抢单”事故。当时物流技能更新了 description,把“查询包裹当前位置”写成了“查询包裹所有相关信息”,结果上线后调用量直接翻了三倍,把订单查询、退货查询的流量全吸走了。表面看物流技能成功率 99%,实际用户满意度直线下降,因为用户问退货进度,模型也调物流技能,返回的全是物流轨迹。处理方式就是立刻改回窄描述,并且把“所有相关信息”这类宽泛词在描述模板里直接禁用。
第二个案例是邮箱参数解析。用户说“邮箱是 abc 艾特 qq 点 com”,模型把这个字符串原封不动传给了技能,参数校验失败后进入了澄清循环,用户被反复追问邮箱,体验特别差。后来我在技能层加了一个“中文口语化格式修复器”,把“艾特”转成“@”、“点”转成“.”,并把清洗结果展示到日志里,这个问题才算根治。脚本能做的事,不要在提示词里让模型做。
第三个案例是重试风暴。某个技能依赖的下游数据库抖动,技能每 500 毫秒超时一次,编排层没配置回退策略,于是系统性重试,把数据库进一步压垮。技能运行时加上了重试熔断:同一个 trace 内最多重试 2 次,连续失败超过 5 次直接熔断 1 分钟。记住,熔断是给依赖方止血的,不是用来提高成功率的。
5.3 性能与成本优化:减少模型往返就是最大的优化
Agent 技能系统的成本大头从来不是技能本身执行,而是模型的决策往返次数。每调用一次模型,就烧一次 token,还增加一次延迟。优化的第一原则就是能在一个技能内做完的事,不要拆成多个技能反复决策。
我做过一个对比:同一个“查订单状态”的需求,一个版本用“查订单 + 查物流 + 生成摘要”三个技能串联,另一个版本合并成“订单综合查询”一个技能,内部并行调用三个下游接口,在内部拼成一句话。后者模型调用次数从 3 次降到 1 次,延迟从 6 秒降到 2 秒,tokens 消耗降了 47%。当然合并的前提是逻辑内聚,不能为了省 token 把不相干的事情硬捏在一起。
第二个优化点是缓存。技能执行结果里纯查询类数据,比如商品描述、运费模板规则、用户等级说明,短时间内的重复调用完全没必要再打到下游。我在技能层加了一个简单的 TTL 缓存,命中率大约 30%,下游压力小了一大截。
第三个优化点是动态加载 manifest。我一开始把所有技能的描述都加载进模型上下文,后来改成只加载召回命中的前 5 个技能描述。这一步不仅省了 token,还提升了路由准确率,因为模型在候选集缩小后更容易做选择。算下来每轮对话平均省了 2000 到 3000 个 token,按企业调用量算,一个月能省下的费用非常可观。
5.4 技能维护的长期经验
最后聊一点长时间维护技能库的感受。技能系统跟代码库一样,会腐化。有些技能上线三个月后再看,已经没人知道它的 owner 是谁;有些技能描述和实际行为早就对不上了;有些技能明明可以合并,但因为各自有调用方,没人敢动。
我会定时做四件事:技能目录巡检,看哪些技能连续 30 天调用量为零,直接归档;描述一致性测试,用离线用例集跑一遍,识别描述与实际行为漂移;调用链分析,找出频繁串联出现的技能组合,考虑合并或编排成模板;错误码审计,把技能返回的错误码统一纳入登记表,避免同一个业务错误在不同技能里有三种表达。
这四件事听起来都是日常维护,但它们决定了技能库三年之后是越用越顺还是变成糊涂账。我见过一个上线才半年的 Agent,已经有 40 个技能,其中 17 个从未被调用,7 个描述明显过时,这就是典型的“能跑但没人敢改”的状态。技能库质量比代码质量衰退得更隐蔽,因为它平时不报错,只是默默给你错误的结果。