ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Agent Skills:为LLM打造可复用、可校验的稳定技能执行体系

Agent Skills:为LLM打造可复用、可校验的稳定技能执行体系

模型本身越来越聪明,这点大家都有体感,但真正把大模型放进业务流程里跑起来的人,恐怕都有同一种挫败感:它明明什么都知道,一上手干活就漏洞百出。让模型写一段单点代码、做一次翻译、总结一份文档,效果确实惊艳;可一旦任务变成“查询库存、对比价格、锁定供应商、生成采购单,再把异常情况邮件通知相关人”这种多步骤跨系统操作,它就频繁地丢三落四,不是忘了校验前置条件,就是调用完接口后不知道下一步该干什么。

我在做某个内部智能助手项目时,也被这个问题卡了很久。最初的方案很简单:把工具函数一股脑塞给模型,靠它在每次请求时临场发挥,自己决定先调用什么、后调用什么。结果线上跑了一周,成功率不到六成,而且失败原因千奇百怪,有些是模型压根没找到正确工具,有些是找到了但参数传错,还有些是调完一个接口后逻辑中断,直接给用户回了一句“我试试看,但好像失败了”。后来我意识到,问题不在模型聪明不聪明,而在于我完全没有给模型提供“熟练工种”的沉淀机制。模型像一个理论知识扎实但没有任何岗位培训的新人,你丢给他一堆工具,却没有告诉他完整的作业流程、操作规范和异常处理办法,他当然会手忙脚乱。

这也是“agent-skills”这套思路的出发点:把模型的临场推理能力,和可复用、可校验、可回滚的“技能”分开。模型负责理解意图和做决策,技能负责提供成熟的动作流程。这篇文章就围绕 agent-skills 聊聊我自己的理解、设计、落地过程和一些真实踩坑经验,希望能给正在做智能体应用研发、私有化 Agent 方案架构,或者说单纯想提高大模型应用稳定性的朋友一点参考。

1. agent-skills 想解决的核心问题:模型聪明,但不“会见招拆招”

1.1 为什么任务表现差,不能全赖模型

先说一个我反复验证过的现象。同一个模型,在同样的参数配置下,处理“把这段文字翻译成英文”这类单步任务时,成功率几乎百分百;但处理“帮我安排一个包含会议室、参会人、投影设备、午餐预订的跨部门会议”时,成功率就掉得很难看。模型对这类任务并非没有知识储备,它完全清楚“预订会议室应该调用会议系统 API”,也清楚“参会人需要查看空闲时间”,但在实际的推理链里,它经常跳过某个关键步骤,或者把两个步骤的先后顺序搞反。

这里有个很容易被忽略的差别:模型的“知识”和任务的“程序”是两回事。知识是松散的,程序是强约束的。真实业务流程里,步骤之间有依赖、有前置条件、有异常分支,这些约束不适合让模型每次请求时都重新推导一遍。人为什么会越做越熟?因为人会把重复性的流程固化成习惯,遇到类似场景直接按习惯执行,只在异常情况发生时切换回慢思考。agent-skills 想给模型的就是这种“习惯”。

所以我在项目里定了一个原则:凡是执行链路稳定的任务,一律沉淀成技能;凡是需要临场判断和探索的任务,才交给模型的自由推理。这个原则听起来简单,但实际落地时牵扯的问题很多,后面会展开。

1.2 agent-skills 里的“技能”到底指什么

在我最初的理解里,技能约等于“提示词模板”。后来发现这个想法太局限了。提示词模板只能改变模型的表达方式,管不住工具调用的参数格式,也管不住调用失败后的恢复策略。再后来我又想,技能是不是就是一组可调用的 API 封装?也不全是,API 封装只解决“能做什么”,解决不了“什么时候做、做完怎么确认、做坏了怎么办”。

最终我把技能定义为一个结构化的“动作包”,它至少包含四层信息:

  • 意图触发条件:什么情况下该技能被选中。可以是用户显式指令,也可以是模型根据上下文自动判断的条件。
  • 参数契约:技能需要哪些输入、每个输入的类型和取值范围、哪些是必填哪些是可选的、参数之间的依赖关系。
  • 执行流程:一个有序或带条件分支的步骤列表,每个步骤可以是工具调用、子技能引用、参数改写、条件判断或者一次模型自省。
  • 保障机制:后置校验规则、失败恢复策略、幂等要求、副作用声明。

只有把这四层信息结构化地组织起来,技能才是可执行、可测试、可维护的。它本质上是在模型和工具之间垫了一层“作业指导书”,让模型不需要每次调用都从零思考,也让工程团队可以像 review 代码一样 review 技能定义。

2. 技能库的数据模型:一套能被模型读懂、也能被工程检查的规格

2.1 技能注册表结构拆解

agent-skills 的第一份核心资产,不是代码,而是技能注册表。我用一个统一的 JSON Schema 来描述所有技能,schema 里最关键的是skill_id、description、trigger、parameters、steps和guarantees这几个字段。看起来有点像 OpenAPI,但侧重点不太一样,OpenAPI 描述的是“接口长什么样”,技能注册表描述的是“这个技能在什么场景下、按什么流程、完成什么事”。

下面是我最初定义的一个简化版技能示例,以会议预定为例:

{ "skill_id": "meeting_booking", "name": "会议预定", "version": "1.3.0", "description": "根据参会人、时长和日期,查询忙闲状态、预定会议室并发送邀请。适用于内部会议,不适用于跨部门外部会议。", "trigger": { "types": ["explicit", "contextual"], "keywords": ["预定", "会议室", "开会", "meeting"], "required_context": ["user_id"] }, "parameters": { "type": "object", "properties": { "title": { "type": "string", "minLength": 1 }, "start_time": { "type": "string", "format": "date-time" }, "duration_minutes": { "type": "integer", "minimum": 15, "maximum": 240 }, "participants": { "type": "array", "items": { "type": "string" }, "minItems": 1 } }, "required": ["title", "start_time", "duration_minutes"] }, "steps": [ { "type": "tool_call", "tool": "room_query", "output_var": "rooms" }, { "type": "condition", "if": "rooms is empty", "then": "return no_available_room" }, { "type": "tool_call", "tool": "booking_create", "output_var": "booking_result" }, { "type": "tool_call", "tool": "invitation_send", "output_var": "invite_result" } ], "guarantees": { "idempotent": true, "side_effects": ["create_booking", "send_notification"], "postconditions": [ "booking_id exists", "booking status == confirmed" ] } }

这里有个细节我想特别强调:description字段不能随便写,它会被模型用来做技能选择。我最初写的是“预定会议室”,结果模型在用户只是想查一下会议室空闲情况时,也会选中这个技能,白白调用一堆写接口。后来我把 description 改成了包含明确边界感的描述,强调“适用于内部会议,不适用于跨部门外部会议”,误选率立刻降下来。

2.2 参数契约与副作用声明

参数契约是技能被正确执行的基础。很多失败的调用,根因不是模型笨,而是参数定义有歧义。比如start_time到底是本地时间还是 UTC 时间,participants里的用户 id 是用邮箱还是工号,这些如果不在 schema 里写清楚,模型只能靠猜。我的做法是除 type 和 format 外,每个参数都配description字段,用自然语言补充约束,比如“本地时间,格式为 ISO8601,默认按 Asia/Shanghai 时区处理”。

副作用声明容易被忽略,但它直接关系到执行器的安全设计。一个技能会调用哪些写接口、会不会发通知、会不会改数据,都需要显式声明。原因有两个:一是执行器可以根据副作用决定是否要求用户二次确认;二是失败回滚时需要知道哪些外部影响是无法撤销的。举个简单例子,booking_create之后如果invitation_send失败了,我们可以尝试重新发送邀请;但如果booking_create本身创建成功而校验失败,那必须先删除已创建的预定,再向用户报告失败,否则就会出现“用户以为没订上,实际系统里已经有一笔预定”的事故。

2.3 技能版本与幂等性

技能不是一成不变的。业务规则变了、底层接口升级了,技能定义也要跟着迭代。我在项目里给每个技能都加了version字段,并在执行日志里记录实际执行的版本号。这样排查问题的时候,能准确知道某次失败到底是因为旧版技能的缺陷,还是新版技能引入的回归。

幂等性是另一个必须考虑的工程问题。LLM 驱动的执行过程天然不可靠,模型可能在超时后重复发起同一个操作,也可能在第一次调用实际成功、但响应丢失的情况下重试,导致重复下单、重复建单。解决思路是给每个技能执行实例生成一个request_id,并让底层工具支持幂等键。比如创建会议预定前,先用request_id做一次查重,如果发现相同 id 的预定已存在,直接返回已有结果,而不创建新的。这个机制听着基础,但没有它,技能越跑越乱。

3. 从零搭一个最小可用的技能执行内核

3.1 技能定义用 JSON 而不是自创 DSL

动手实现的时候,我第一个想法是给技能写一套自定义 DSL,比如用缩进表示步骤嵌套、用关键字表示条件分支,觉得这样更“智能”。冷静下来之后放弃了。自定义 DSL 的解析器、调试工具、编辑器的支持都得自己造轮子,收益远远抵不上成本。技能定义的本质是结构化数据,直接用 JSON 或 YAML 存储,校验用现成的 JSON Schema 库,解析也用现成的库,省事得多。

我最终选择 YAML 作为手写格式,因为可读性高于 JSON,写注释方便;程序运行时再统一转成 JSON 对象处理。解析器本身不到一百行,核心就是读文件、校验 schema、预处理默认值。真正复杂的部分在后面的执行器。

3.2 执行器的设计思路:步骤就是一个小型状态机

执行器是整个 agent-skills 系统里最核心的组件。它的职责很简单:按顺序执行技能里的 steps,维护一个上下文变量池,把上一步的输出传给下一步的输入。我在设计时把它当成一个极简状态机,而不是一个复杂的流程引擎。

每个步骤节点支持这么几种类型:

  • tool_call:调用一个外部工具,把结果写入上下文。
  • sub_skill:调用另一个技能,实现技能的组合复用。
  • condition:根据上下文中的某个条件决定走哪个分支。
  • prompt_llm:在步骤中间插入一次模型调用,做信息提取、格式转换或决策判断。
  • return:结束执行,返回最终结果。

下面是一个简化版的执行器核心循环:

def execute_skill(skill, input_params, context, executor): context.update(input_params) for step in skill["steps"]: step_type = step["type"] if step_type == "tool_call": result = executor.call_tool( step["tool"], args=resolve_args(step, context), request_id=context["request_id"] ) context[step["output_var"]] = result if not check_expected(result, step.get("expect")): return handle_step_failure(skill, step, result, context) elif step_type == "condition": branch = evaluate_condition(step["if"], context) context["branch"] = branch elif step_type == "sub_skill": sub_result = execute_skill( load_skill(step["skill_id"]), resolve_args(step, context), context, executor ) context[step["output_var"]] = sub_result elif step_type == "return": return build_return_value(step, context) return build_return_value(None, context)

这个循环看起来简单,但有几个细节决定了稳定性。一是resolve_args必须严格校验每个参数是否在上下文里存在,缺失时不要猜测,宁可中断并提示模型补充信息;二是每个tool_call步骤最好带expect字段,声明“接口返回后我们期望看到的结果是什么样的”,这相当于给每一步加一道断言;三是子技能调用时,要警惕递归或者无限嵌套,我加了最大嵌套深度限制,默认 5 层。

3.3 让模型在多步推理中主动引用技能

有了注册表和执行器,剩下的问题就是:模型怎么知道该用哪个技能?我的做法分两步。第一步,把用户请求和所有技能的 trigger 描述做一次检索匹配,选出最可能的几个候选技能;第二步,把候选技能的简要信息注入系统提示词,让模型在推理时通过结构化输出指定要执行的技能和参数。

检索匹配我一开始用的是纯关键词,准确率很差。后来改成 embedding 向量召回了,才勉强能用。技能数量少的时候,向量召回和关键词召回差别不大;一旦技能数量超过几十个,向量召回的优势就明显了。不过也要注意,向量召回不是万金油,后面我会专门讲它在多技能场景下的失效案例。

注入提示词时,我用的格式大致是:

当前环境已加载以下技能: 1. skill_id: meeting_booking name: 会议预定 description: 根据参会人、时长和日期,查询忙闲状态、预定会议室并发送邀请。适用于内部会议,不适用于跨部门外部会议。 2. skill_id: leave_request name: 请假申请 description: 创建请假单并通知审批人。支持事假、病假、年假。不支持调休申请。 你的任务:判断用户意图,如果需要执行技能,请输出 JSON 格式: {"skill_id": "...", "parameters": {...}}

模型输出后,执行器会对参数再做一次 JSON Schema 校验,不合规就报错并让模型重新生成。这一步看似绕了一圈,但非常重要:系统提示词里的描述只负责帮模型做“选择题”,真正执行时的参数合法性,必须靠执行器兜底。

4. 实战复盘:最容易翻车的五种情况

4.1 检索召回的问题:技能太多时,比没有技能更糟

技能库膨胀得比我预期快得多。项目前两个星期还只有十几个技能,一个月后就堆到了七八十个。因为每接入一个新系统、每适配一个新的业务流程,大家的第一反应就是新建一个技能。这时候问题来了:模型面对太多看起来相似的技能,误选率急剧上升。

最典型的一个例子:我们同时有了“查询库存”“查询价格”“查询订单物流”“查询供应商信息”四个技能,它们的函数签名完全不同,但描述都很接近,都含“查询”“信息”这类词。模型经常把“查一下这个订单送到哪了”误判成“查询订单物流”之外的技能。我一开始以为是 embedding 没训练好,后来仔细看日志才发现,问题是候选技能数量太多,模型被一堆弱相关的描述干扰了。

解决思路有三个方向:第一,给技能打标签并增加权限过滤,先按用户身份和当前业务域缩小候选集,而不是每次都全量检索;第二,设置召回数量上限,默认只保留 top-5,不再把更多技能塞给模型;第三,也是最重要的一步,在系统提示词里明确写出“如果没有任何技能与用户意图匹配,直接拒绝,不要让用户绕圈子”。加上这三条之后,误选率从接近三成降到了不到一成。

4.2 看不见的状态副作用:工具返回成功,不等于任务成功

这是我踩过最深的一个坑。有一次我们接了一个工单系统的 API,ticket_create接口返回了 200 状态码,技能就认为创建工单成功,直接给用户报“已提交”。结果用户在界面上根本看不到这张工单。查了很久才发现,那个接口的 200 只是“请求已被接收”,实际上异步流程要做完状态同步之后,工单才真正可见可用。也就是说,工具调用成功和业务目标达成之间,隔着一层“状态一致性”的鸿沟。

从那以后,我在技能定义里强制要求每个有状态变更的步骤都要配postcondition校验。不能只是检查接口返回码,而是要主动发起一次查询,确认业务状态真的变成预期值。比如创建工单后,必须再调用一次ticket_get或ticket_list,确认工单状态不是草稿、不是处理中,而是已提交。多这一步查询,虽然增加了调用次数,但换来的是结果可信。

4.3 重试陷阱:无脑重试会让系统从“偶发失败”变成“批量故障”

大模型应用里,工具调用失败是常态。网络抖动、第三方接口限流、token 过期,都可能让一个本来能成功的任务失败。我一开始图省事,在工具调用环节统一加了“失败重试三次”的逻辑。结果某个下午,第三方日历服务刚好出了故障,我的系统对着同一个接口疯狂重试,把对方服务打到更不稳定,反过来拖慢了所有依赖该服务的技能。

后来我把错误分成了三类。第一类是可重试错误:超时、网络抖动、5xx 这类瞬态错误,按指数退避重试,最多三次,间隔从 512ms 起步;第二类是不可重试错误:参数校验失败、业务规则拒绝、权限不足这类,重试多少次都不会成功,应该直接失败并把错误信息返回给模型做下一步决策;第三类是人工介入错误:比如订单金额超限、需要用户授权等,不要自动重试,直接挂起并通知用户。这个分类写进了执行器,每个tool_call步骤都可以声明自己的错误分类,默认按全局策略走。

4.4 权限模型缺失:调用链越长,越需要统一鉴权

技能会组合。一个技能可以调用另一个技能,比如“生成采购单”技能内部就引用了“查询库存”“查询供应商信息”“创建审批单”三个子技能。刚开始我们没有做调用链层面的鉴权,只检查了用户是否有权限触发最外层技能。结果有一个普通员工通过“生成采购单”技能间接调用了“查询供应商历史报价”这个子技能,而这个子技能的权限本来是只开放给采购经理的。事情发生得很安静,日志里看起来一切都是正常调用,直到审计时才暴露问题。

这件事让我明白,技能的权限模型必须在调用入口统一收口,而不是在子技能内部各自为政。每个技能声明自己需要的权限域,执行器在展开技能树时,先做一次全链路的权限预检:当前用户是否对技能树上所有节点的权限域都有授权。没有的话,直接拦截,不允许走到某个子技能再报错。

4.5 没有终止条件:模型陷入“越失败越重试”的怪圈

还有一个必须处理的问题:循环。LLM 在自由推理时可能会陷入一个死循环,反复调用同一个工具、得到相同的结果、然后继续尝试同一个操作,看起来像是在“努力”,实际上没有任何进展。我见过最夸张的一次,一个模型在 12 次调用里,有 9 次在重复查询同一个订单状态接口,参数一字不差。

解决方法很简单:给执行器加上硬性步数限制和预算上限。每个技能执行实例默认最多 15 步,超过就强制终止,并返回“执行步骤超出限制,建议简化需求或联系管理员”。上下文里的 token 消耗也要监控,超过预算就直接熔断。另外,我在执行器里加了一个简单的回环检测:如果某个工具调用在本次执行中已经用完全相同的参数出现过两次,就判定为无效循环,不再执行第三次。

5. 质量度量与持续迭代:怎么证明技能库真的在变强

5.1 指标设计:完成率、恢复率、平均调用步数

我踩过的另一个误区是:只看“最终是否成功”。在这个标准下,一个任务失败后靠人工兜底处理,也算成功了,指标完全失真。后来我建立了一套稍微细一点的评估指标,核心是下面三个:

指标计算方式关注什么
任务完成率真实达到业务终态的比例,不含人工兜底技能库整体质量,是否真正解决用户问题
自动恢复率首次失败后,不靠人工干预、靠技能内部恢复策略成功的比例技能容错设计是否有效
平均调用步数每个成功任务消耗的工具调用次数效率是否在恶化,是否出现无效试错

我还给每个指标加了一个“趋势”维度:同一批评估用例,每周跑一遍,对比完成率是提升还是下降。只要某个技能改完后,完成率没有提升或者平均步数变长了,就说明改动可能是负优化,需要回滚或者重新设计。

5.2 离线评测与线上追踪的搭配

Agent 系统的评测是个老大难问题,因为任务结果经常是开放式的,无法用简单的断言判断对错。我的做法是建设和维护一个“黄金评测集”,大概一百多道真实用户请求,覆盖了主要技能的高频路径和边界情况。每次技能定义更新后,我都用同一套评测集离线跑一遍,人工核对结果,看有没有回归。

离线评测的好处是稳定可复现,但代价是它抓不到线上的长尾问题。所以我还搭了一套线上追踪:给每个执行实例记录完整的调用链,包括技能 ID、参数快照、工具返回、每一步耗时、最终结果。每周花半天时间抽样看这些 trace,重点找三类问题:参数异常、步骤异常、结果不符合预期。离线评测和线上追踪各有各的价值,一个负责回归,一个负责发现未知问题,缺一不可。

6. 下一步方向:把技能沉淀成资产,而不是代码碎片

做了一段时间之后,我发现 agent-skills 的价值天花板不在“写技能”上,而在“把技能变成可沉淀、可共享、可持续演进的资产”。如果我们只是把每个业务需求又快又糙地堆一个技能出来,那和以前写一堆不可维护的脚本没什么区别。

我现在的做法是:为技能定义增加“质量门槛”,包括但不限于必须有后置校验、必须有失败恢复策略、必须有明确的边界描述。新技能提交时,要经历和代码审查类似的评审流程,重点看参数契约是否严谨、副作用声明是否完整、有没有违反幂等性要求。版本更新按灰度方式推进,先切一小部分流量,观察一段时间再全量。

另外,跨项目复用是我接下来重点探索的方向。很多技能其实有通用性,比如“创建待办”“发送通知”“查询审批状态”,不同项目里只是参数不同。把这些基础技能抽出来做成公共层,上层业务技能引用它,整个技能库的维护成本会大幅下降。更进一步,如果能做一个内部的“技能市场”,让不同团队把自己的技能打包上传,带上文档和测试用例,新项目直接检索引用,那才算真正把技能从个人经验变成了组织能力。

最后再分享一个小技巧:给技能写文档的时候,除了写清“能做什么”,一定要写清“做不到什么”。一个明确声明自己不适用于跨部门外部会议的会议预定技能,和一个含糊地写着“预定会议”的技能,对模型来说引导作用完全不同。边界描述越清晰,模型选错的概率越低。这个文档习惯让我少收拾了很多烂摊子。

RELATED READING

延伸阅读

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