☰
agent-skills:将提示词能力封装为可复用技能,让Agent更稳定
2026/10/21 9:48:54 网站建设 项目流程

前阵子我重构了一个Agent应用,重构之前最头疼的就是:系统提示词已经写到快一万字,功能倒是列得清清楚楚,可模型的任务完成率反而越来越低。后来我把里面所有"让模型去做的事"全部抽出来,封装成一个一个可复用的技能,整个架构才算真正稳定下来。这个过程中慢慢沉淀出来的轻量组件,就是标题里要聊的 agent-skills。

agent-skills 不是一个复杂的Agent编排框架,它只专注一件事:把Agent需要执行的每一个可复用能力,做成有清晰描述、有参数约束、有标准化运行时、有日志追踪的"技能"。你可以把技能理解成一种能力插件——模型负责选技能、传参数,技能负责真正干活,最后把结构化结果交回给模型继续推理。这篇内容会从头到尾讲一遍这套技能系统的设计思路和踩坑记录,适合正在做Agent应用的后端开发者、AI应用架构师,还有被"prompt越来越长但效果越来越差"困扰的朋友。

1. 我为什么动手做 agent-skills:提示词堆不出来的能力边界

1.1 从"靠prompt硬撑"到"必须外挂技能"

第一版Agent应用特别天真,我把所有功能点全部塞进系统提示词,比如"当你需要查天气时,调用天气接口""当你需要创建日程时,调用日历接口"。前几个接口确实跑得挺顺,但等到技能数量超过二十个,问题就爆发了——模型开始在回答里"假装调用"接口,或者直接说"我已经帮你查好了"但实际什么也没执行。日志里能看到,模型生成了一段看起来像函数调用的文本,但根本没人去解析它、执行它。

踩过一次之后我意识到,模型本质上是一个文本生成器,不是一个可靠的流程执行器。你指望它从一大段自然语言描述里准确记住每个接口的调用时机和参数格式,这件事在技能少的时候勉强可行,技能一多必然失控。要让模型稳定地调用外部能力,必须把"每个能力是谁、什么时候用、参数是什么、返回什么"从自然语言描述变成结构化、可校验、可执行的注册信息。这就是 agent-skills 最初的核心动机。

1.2 技能系统的三个核心问题:是什么、怎么调、怎么管

我把技能系统拆成三个问题来设计。第一个是"是什么":每个技能必须有唯一ID、面向模型的能力描述、参数描述(JSON Schema)、还有真正干活的handler实现。第二个是"怎么调":运行时要有一个统一的入口,负责参数校验、找到handler、执行、超时控制、把结果标准化返回。第三个是"怎么管":技能要能独立升级、独立测试、独立下线,不能因为改了一个技能的描述就把整个Agent的对话风格全部带偏。

打个比方,技能就像工具箱里的工具,模型是工人。你要做的是给每个工具贴上清晰的标签和使用说明,每个工具独立存放、独立维护;而不是把整个工具箱的说明书写在一张纸上让工人全背下来,那样他翻工具箱的时候一定会抄错工具。这个朴素的类比后来一直指导着我的所有设计决策。

2. 技能描述与参数Schema:模型能不能选对技能,全看这两样

2.1 描述要"面向模型"而不是"面向人类"

我见过很多技能定义,最大的通病是description写得"太像给人看的API文档"。比如"获取指定城市的实时天气信息",这句话人看没问题,模型看却不够。为什么?因为模型做的是语义匹配,它需要在用户输入和技能描述之间做相关性判断,描述里如果没有覆盖足够的触发场景和边界条件,它就很容易选错或者犹豫不选。

我现在的写法是要求描述里包含三类信息:触发场景、边界条件、歧义处理方式。举个例子,同一个天气查询技能,我重写之后是这样的:

当用户询问"今天上海冷不冷""明天北京会下雨吗""周末去杭州适合穿什么"等涉及 某地天气情况的问题时,使用此技能。city参数必须是中文城市名,如"上海"。 当用户没有明确提到地点时,不得猜测,必须追问用户地点后再调用。

改完这个描述之后,选错率直接降了一半。原因也很简单:模型不再需要自己推断"用户说冷热跟天气有没有关系",描述里已经替它做了这个判断。

2.2 参数Schema的约束技巧与类型设计

参数设计是另一个关键。模型生成参数是自由的,但你的执行端是严格的,两者之间需要靠Schema来拉齐。我在 agent-skills 里总结出几条比较稳的经验:

  • 能用枚举就别用自由字符串。比如"时间范围"这个参数,写成"近一小时"、"近一天"、"近一周"的枚举,模型生成时几乎不会跑偏,能省掉大量清洗逻辑。
  • 能用布尔值就别让模型猜。比如"是否需要带伞提醒",直接定义成boolean,模型从"要不要带伞"这类表述里提取true/false比提取自定义字符串稳定得多。
  • 必填参数越少越好。模型有个习惯,面对缺失信息时会倾向于编造,尤其是日期、地点这类高频字段。我遇到过模型在没有用户明确地点时自己填了"北京",所以非必填就不填,必填字段必须保证能从对话里找到依据,找不到就先追问。
  • 参数里的 description 要写"这是什么含义、取值范围、示例值"。尤其是单位,比如温度是摄氏度还是华氏度,必须在 description 里明确写出来。

2.3 一个可直接参考的技能定义示例

agent-skills 里的技能定义用 YAML 描述,格式大概是这样的:

name: weather_query description: > 当用户询问"今天上海冷不冷""明天北京会下雨吗""周末去杭州适合穿什么" 等涉及某地天气情况的问题时,使用此技能。city参数必须是中文城市名, 如"上海"。当用户没有明确提到地点时,不得猜测,必须追问用户地点后再调用。 parameters: type: object required: - city properties: city: type: string description: "中文城市名,如:上海、北京、广州。必须从用户输入中提取。" need_umbrella: type: boolean description: "用户是否在询问要不要带伞、会不会下雨。默认为false。" version: 1.2.0 handler: skills.weather.handler

字段不多,但每个都有用途:name是技能唯一ID;description是模型选技能时看到的说明书;parameters是模型生成参数的约束;handler是真实执行的函数入口;version是技能版本号,后面讲版本管理时会重点说它。

这里要提醒一点:如果你用的是某些模型自带的函数调用或者工具调用能力,description和parameters这两个字段往往会被直接映射成它的工具描述,所以这两个字段的严谨程度,直接决定了模型工具调用的成功率。

3. 注册、发现与调用:技能系统的运行时设计

3.1 注册中心与技能清单的维护策略

技能定义本身是一堆文件,运行时需要有一个地方把它们都装起来。我在 agent-skills 里做的是一个非常朴素的注册中心:启动时扫描技能目录,解析YAML,把技能的元数据和handler函数放进一个映射表。然后向模型展示技能清单时,就从这个注册中心取。

这里有个很实际的优化点:技能清单不是越多越好。模型上下文窗口是有限的,你把二十个技能的长描述全塞进去,一方面会稀释模型对真正相关技能的注意力,另一方面会挤占对话历史的空间。我的做法是:如果技能总数超过十五个,就按会话主题做一次粗筛,只把最相关的五到八个技能描述传给模型。粗筛的方式可以是基于模型生成的话题标签,也可以做一个简单的关键词命中,成本低很多。

3.2 调用分发与上下文隔离

运行时最重要的部分是统一的调用分发入口。我在 agent-skills 里的逻辑大致是这样:

def dispatch(skill_name, params, trace_id): skill = registry.get(skill_name) if skill is None: return error_result("SKILL_NOT_FOUND", f"技能 {skill_name} 不存在") try: validated = schema_validator.validate(skill.parameters, params) except ValidationError as e: return error_result("INVALID_PARAMS", format_validation_error(e)) result = execute_with_timeout(skill.handler, validated, timeout=5) return normalize_result(result)

这段代码解决了几个关键问题。第一,参数校验必须在执行之前完成,模型生成的参数经常多字段少字段,不能直接丢给handler。第二,handler执行必须有超时控制,否则模型等一个外部API十秒,整个对话节奏就全乱了。第三,返回结果必须标准化,无论handler内部是抛异常还是返回怪结构,最终给到模型的一定是统一格式。

上下文隔离也很重要。我让每个handler接收一个独立的上下文对象,里面只有需要的参数和日志工具,不能读写模型对话历史,也不能直接访问其他技能的状态。这样可以避免技能互相污染,比如一个技能改了全局变量导致另一个技能行为异常,这类问题在联调时非常难排查。

3.3 错误返回的规范化

错误返回是留给模型看的,所以信息必须让模型能理解、能执行修复动作。我把错误分成四类:

错误码含义模型应该怎么做
INVALID_PARAMS参数校验不通过根据错误信息重新提取参数,或向用户补充提问
EXECUTION_FAILEDhandler内部执行失败换一种实现方式,或明确告知用户操作失败
TIMEOUT执行超时告知用户系统繁忙,建议稍后重试
UNAVAILABLE技能依赖的服务不可用明确告知用户当前无法完成该能力

错误信息要拼成一段模型能直接读的自然语言,而不是只给一个错误码。比如参数错误,我会生成"参数 city 为必填项,请从用户对话中提取城市名",模型看到之后就知道下一步该干嘛。这一步在调试工具调用时特别重要,很多Agent"卡住"就是因为模型收到了一个看不懂的错误结构,只能原地打转。

4. 技能编排:从单技能到多技能协作

4.1 顺序编排:让Agent按步骤拆解任务

单个技能只能干一件事,真实任务往往需要好几个技能接力。最典型的场景是"帮我规划明天去上海的行程"。这个任务需要依次调用天气查询、路线规划、日程创建。如果希望模型一次性想清楚所有步骤再全执行,生成结果往往前后矛盾;正确做法是让模型走一步看一步:先查天气,根据天气结果决定推荐什么活动,再查路线,最后把行程写进日程。

所以 agent-skills 的编排策略不是预先定义死板的工作流,而是提供一种"下一步做什么"的交互模式。模型每完成一次技能调用,就把结构化结果拿回来,结合对话目标决定下一个动作。这其实更接近人类做事的思维方式,也更容易保持灵活性。

4.2 并行与条件分支的处理

有些技能之间没有依赖关系,并行调用能大幅降低总耗时。比如用户问"对比一下北京和上海明天的天气",天气查询显然可以同时发两个。我的实现里给技能加了一个可选的parallel_group字段,同一个 group 的技能可以并行执行,等结果都回来了再合并成一个列表返回给模型。

条件分支则更依赖模型本身的推理能力。比如查询天气之后,如果结果是"下雨",模型应该接着调用"创建雨天室内活动推荐"技能,否则调用"户外活动推荐"技能。这种分支逻辑没必要写死在代码里,让模型根据上一步结果自己选就行。你要做的只是在返回结果时保留足够的信息,别让模型看到的是被压缩得面目全非的结果摘要。

4.3 技能版本管理与灰度切换

技能不是写一次就完事的,描述和实现都会持续迭代。但我发现一个很隐蔽的问题:技能的修改会影响模型行为。你只是把某个技能的描述改得更完善,整个Agent的任务完成率就可能波动。所以版本管理绝不能省。

agent-skills 的做法是给技能加语义化版本号,注册表里同一技能可以同时存在多个版本,但只有一个是默认版本。灰度的时候,我让百分之五的流量先命中新版本,对比新旧版本的技能调用成功率和任务完成率,数据稳定后再把默认版本切过去。整个过程可以全部灰度做,不会影响线上大多数用户。这个习惯帮我避免了好几次"改完描述第二天收到一堆报错"的尴尬。

5. 上线后被问最多的五个问题:agent-skills 的实战踩坑记录

5.1 模型总是选错技能,问题出在描述里

第一个高频问题是模型选错技能。症状是用户明明在问天气,模型却调用了日程创建技能,或者直接不调用任何技能,凭空编了个答案。我排查后发现根因基本都是技能描述里缺少触发边界。比如某个技能描述写的是"查询天气信息",那用户说"明天冷不冷"模型就匹配不上,因为描述里没提"冷""热""下雨""带伞"这些词。修法很直接:把常见的问法、边界情况、甚至反例都写进描述。我一般会特意在描述里加一句"当用户询问与天气无关的话题时,不要使用此技能",这样模型反而能更准确地区分。

5.2 技能执行超时导致Agent"卡死"

第二个坑是超时。有一个技能调外部API,响应偶尔会超过十秒,模型就一直等着,用户那边看起来就像Agent死了一样。日志显示模型生成的参数早就校验通过了,handler也进去了,但一直没有返回。根因就是我前面说的,缺少超时控制。修法是在执行层统一加timeout,超时之后主动中断并返回一个明确的TIMEOUT错误,让模型有机会回应用户"系统正在处理,请稍等"。这不算什么高级设计,但没有它,线上事故真的会一个接一个。

5.3 参数里的中文和换行符让JSON解析频频失败

第三个隐蔽坑是参数编码。模型生成参数时,会在字符串里带上换行符、中文引号,甚至是大段未转义的文本。你硬用json.loads去解析,经常直接抛异常。我的经验是:在进入Schema校验之前,先做一次宽松的JSON清洗,比如把字符串里孤立的换行转成转义符,把中文引号换成英文引号,再尝试解析。即使解析失败,也绝不能让异常冒泡到模型层,要转成INVALID_PARAMS让模型自己重试生成一次。

5.4 技能定义冲突:两个相似技能互相抢活

第四个坑是雷同技能。项目中期同时存在"简单计算"和"财务计算"两个技能,描述都写了"计算",导致模型经常把复杂财务计算交给简单计算去处理,结果自然是错的。这里的教训是:技能之间要有清晰的边界,要么合并成一个技能加细分参数,要么在描述里明确各自的适用范围。我最终把两个技能合并成一个计算技能,用参数里的domain字段区分场景,选错率立刻下降。

5.5 技能列表太长把上下文挤爆,模型直接忘了有技能

第五个坑和上下文长度有关。技能列表加对话历史加检索结果,很容易接近模型上下文上限。一旦超限,模型最自然的反应是忽略工具调用,直接凭记忆回答,结果就是Agent退化成一个编答案的聊天机器人。我的应对办法是动态裁剪技能列表,只保留与当前话题相关的技能描述,同时控制单次技能结果返回的文本长度,不要一股脑把大段JSON全回给模型。很多技能成功后的大字段,我会先摘要成几句话再回传。

6. 可观测性设计:技能调用的全程追踪

6.1 链路ID贯穿每次技能调用

技能系统一旦跑起来,最怕的就是出了问题不知道是模型选错了技能还是技能本身执行失败了。所以可观测性从一开始就要纳入设计,不能事后补。我的做法是给每次对话会话分配一个链路ID,技能调用日志里必须带上这个ID。每次调用记录下面几个字段:

字段说明
trace_id会话或请求的链路ID
skill_name模型最终选中的技能名
params_raw模型生成的原始参数,保留未清洗前的样子
params_validated校验后的参数
dispatch_statussuccess / invalid_params / timeout / error
latency_ms从分发到handler返回的总耗时
model_feedback返回给模型的结果摘要

其中params_raw我特别看重。因为很多问题要复盘才能发现,比如模型固定把日期格式传错,如果不保留原始参数,你根本找不到规律。有了这个字段,复盘时一查一个准。

6.2 技能评分与效果回溯

日志攒起来之后,我还要定期做技能评分。统计维度包括:技能被选中次数、参数校验失败率、执行成功率、平均耗时、超时率。这些数据能直接指导优化优先级。假如某个技能被选中次数很高但参数校验失败率超过百分之三十,说明它的描述和参数Schema设计有问题,优先重写。假如某个技能执行成功率很低但基本没被选中,说明描述可能太窄,模型都找不到它。

我自己的复盘节奏是一周一次,导出技能调用日志跑一遍统计。这个过程就像看体检报告,看起来平淡无奇,但坚持几周之后,Agent的整体稳定性会有肉眼可见的提升。给描述做改动之前,我也会先记录改动前后的技能评分,用数据验证改动到底是正向还是负向。

尾声:几个坚持到现在的小习惯

做了几个月 agent-skills 之后,我越来越确定一件事:Agent应用稳固与否,往往不取决于模型多聪明,而取决于你把技能边界划得多清楚、日志埋得多细。之前那种把所有逻辑都堆进提示词的做法,本质上是在赌模型理解能力;而技能系统把这件事变成了工程问题,每一步都可校验、可回滚、可优化。

如果你也想在自己项目里落地这套思路,我的建议是别急着做编排引擎,先把每个技能本身的描述、参数、运行时、日志做扎实。编排是锦上添花,单技能的可靠性才是地基。最后一个实用习惯是:把技能描述当作文本代码一样纳入版本管理,每次重写描述后用固定测试集跑一遍对比,这件事我坚持了三个月,Agent的行为可控性提升了一个量级。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询