ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Agent-Skills设计指南:分层契约化与调度优化实践

Agent-Skills设计指南:分层契约化与调度优化实践

1. 从“agent-skills”这个标题说起:它到底在解决什么问题

第一次看到“agent-skills”这个标题,我脑子里蹦出来的不是某个具体框架,而是一类很实在的工程痛点:智能体(Agent)能跑起来不难,难的是让它稳定地、可复用地产出靠谱结果。很多人做智能体项目,Demo 阶段惊艳,一上真实场景就露馅——工具调用乱、上下文爆炸、任务拆解不清晰、失败后不会自我纠正。这些问题的根子,往往不在模型本身,而在于“技能”这一层没有被认真设计。

所谓 agent-skills,我把它理解为智能体可调用的、封装好的能力单元。它可以是调用某个 API 的函数、一段固定的处理流程、一个检索策略,甚至是一套“先规划再执行再校验”的行为模板。标题用复数 skills,说明重点不是单个能力,而是一整套技能体系怎么组织、怎么调度、怎么复用。这跟单纯写一个 prompt 或调一次接口完全不是一个量级的事。

这篇文章适合谁看?如果你正在做智能体相关的项目,不管是任务自动化、内容生成、数据分析还是流程编排,只要你遇到过“同一个任务换个输入就翻车”“工具一多就选错”“上下文越堆越乱”这类问题,那这篇就是写给你的。我会从整体设计思路讲到具体实现细节,再到踩坑排查,尽量把 agent-skills 这套东西拆到能直接抄作业的程度。需要说明的是,文中涉及的具体参数和代码是基于常见工程实践做的合理补全,你可以根据自己的技术栈调整。

2. 整体设计思路:为什么技能要“分层 + 契约化”

2.1 技能不是函数堆砌,而是有契约的能力单元

很多人一开始做 agent-skills,习惯性地把每个工具写成一个函数,然后一股脑塞给模型去选。结果就是:工具一多,模型选择准确率断崖式下跌。我实测过一个场景,工具数量从 5 个增加到 20 个时,选错工具的概率大概翻了三倍。原因很简单——模型在做选择时,靠的是对工具描述的理解,描述越相似、数量越多,歧义就越大。

所以我的核心设计原则是契约化:每个技能必须有清晰的输入 schema、输出 schema、适用场景说明、失败返回约定。这不是为了好看,而是为了让模型在“选技能”这一步有足够区分度。举个生活化的类比:你去餐厅点菜,菜单上如果只写“炒菜”“汤”“主食”,你很难选;但如果写清楚“宫保鸡丁-微辣-含花生”“番茄蛋汤-清淡-无辣”,选择就明确多了。技能描述就是智能体的菜单。

契约化带来的另一个好处是可测试。每个技能可以单独写单元测试,输入固定、输出可断言。这在智能体项目里特别重要,因为整体行为很难测,但技能层可以测。我一般要求每个技能至少有 3 类测试用例:正常输入、边界输入、异常输入。这样当模型行为异常时,我能快速定位是技能本身的问题还是调度的问题。

2.2 分层设计:把“选什么”和“怎么做”分开

agent-skills 的第二个关键设计是分层。我通常把它分成三层:路由层、执行层、校验层。路由层负责根据当前任务和上下文,决定调用哪个或哪几个技能;执行层负责真正干活;校验层负责检查结果是否满足要求,不满足就触发重试或降级。

为什么要分层?因为如果让一个模型同时做“选技能”和“执行技能”,它的注意力会被分散。我试过让模型在一个 prompt 里既判断意图又生成参数,结果参数错误率明显高于分开处理。分层之后,路由层可以用更小的模型或更聚焦的 prompt,执行层则专注在具体任务上,整体稳定性提升很明显。

这里有个经验:路由层不要追求一次选对,而要允许“候选 + 确认”。比如先让模型输出 top-2 候选技能,再用一个轻量规则或二次判断确认。这样比强行要求一次命中要稳得多。我在一个任务编排项目里用这个思路,技能选择准确率从大约 70% 提升到了 90% 以上。

2.3 技能粒度:太粗会失控,太细会爆炸

技能粒度是另一个容易踩坑的地方。粒度太粗,比如一个技能叫“处理用户请求”,那等于没拆,模型还是不知道内部怎么走;粒度太细,比如把“读取文件”“解析 JSON”“提取字段”拆成三个技能,那路由层要做的选择就太多了,而且这些细粒度操作其实更适合放在一个技能内部用代码完成。

我的经验法则是:一个技能应该对应一个“语义完整的动作”。什么叫语义完整?就是它有一个明确的业务含义,比如“查询订单状态”“生成周报摘要”“校验数据格式”。这些动作在人类看来是一件事,而不是一堆步骤。至于内部的步骤,用普通代码串起来就行,不需要暴露给模型。

判断粒度是否合适,我常用一个测试:如果这个技能的描述需要超过两句话才能说清楚,那可能就太粗了;如果两个技能的描述几乎可以互换,那可能就太细了。这个测试很土,但实测有效。

3. 核心细节解析:技能描述、参数与调度机制

3.1 技能描述怎么写才不容易被选错

技能描述是路由层做决策的唯一依据,所以它的写法直接决定选择准确率。我总结了一个“三段式”写法:做什么 + 什么时候用 + 不什么时候用。第三段最容易被忽略,但恰恰最重要。

举个例子,假设有两个技能:summarize_text和extract_keywords。如果只写“总结文本”和“提取关键词”,模型在某些模糊输入下会犹豫。但如果写成:

  • summarize_text:将长文本压缩为简短摘要,适用于用户需要整体理解内容时;不适用于只需要关键词列表的场景。
  • extract_keywords:从文本中提取核心词列表,适用于标签生成、检索增强;不适用于需要连贯叙述的场景。

这样区分度就出来了。我实测下来,加上“不适用”说明后,相似技能之间的误选率能下降一半左右。

另外,描述里尽量避免同义词堆砌。比如“查询、检索、查找”都写上去,看似覆盖广,实际上会让模型觉得这个技能很泛,反而更容易误选。用最准确的一个词就行。

3.2 参数设计:必填、选填与默认值

技能的参数设计要遵循一个原则:必填参数尽量少,选填参数要有合理默认值。因为模型生成参数时,参数越多、越复杂,出错概率越高。我一般要求一个技能的必填参数不超过 3 个,超过的话就考虑拆分或合并。

参数类型也要注意。字符串参数最容易出错,因为模型可能生成格式不对的内容。所以对于有固定格式的参数,比如日期、枚举值,我会在 schema 里写清楚格式,并在执行层做校验。枚举值尤其重要,比如format参数只允许json、csv、markdown三个值,那就要在描述里明确列出,执行层也要拒绝其他值。

默认值的设计也有讲究。默认值应该是“最安全”的选择,而不是“最常用”的选择。比如一个技能有mode参数,可选fast和accurate,那默认值应该是accurate,因为出错代价更高。这个思路在真实项目里能避免很多“为了快而翻车”的情况。

3.3 调度机制:串行、并行与条件分支

技能调度不是简单的“一个一个调”。根据任务类型,我通常会用三种模式:串行、并行、条件分支。

串行适合有依赖关系的技能,比如先检索再总结。并行适合相互独立的技能,比如同时查三个数据源。条件分支适合根据中间结果决定下一步,比如校验失败就重试,成功就继续。

这里有个实操心得:并行调度一定要设超时和降级。我踩过一次坑,三个并行技能里有一个卡住了,整个任务就挂在那里。后来我加了超时机制,超时的技能返回一个“不可用”标记,主流程根据标记决定是跳过还是用缓存结果。这样整体稳定性好很多。

条件分支的实现,我建议用显式的状态机而不是让模型自由发挥。比如定义几个状态:pending、running、success、failed、retry,每个状态之间的转移条件写清楚。模型只负责在特定状态下选择技能,不负责决定状态怎么变。这样可控性高很多。

4. 实操过程:从零搭一套可用的 agent-skills

4.1 环境准备与目录结构

假设你用的是 Python 技术栈,我一般会这样组织目录:

agent_skills/ skills/ __init__.py base.py # 技能基类,定义契约 summarize.py # 具体技能 extract.py query.py router/ __init__.py selector.py # 路由层 executor/ __init__.py runner.py # 执行层 validator/ __init__.py checker.py # 校验层 config/ skills.yaml # 技能注册与描述 tests/ test_skills.py

这个结构的好处是职责清晰。skills目录只放技能实现,router只做选择,executor只做执行,validator只做校验。改哪一层都不会影响其他层。

技能基类我一般这样写:

class BaseSkill: name: str description: str input_schema: dict output_schema: dict def run(self, params: dict) -> dict: raise NotImplementedError def validate_input(self, params: dict) -> bool: # 根据 input_schema 做基础校验 ...

所有技能继承这个基类,强制实现run和validate_input。这样路由层和执行层都能用统一接口操作技能,不用关心具体实现。

4.2 技能注册与描述文件

技能注册我用一个 YAML 文件管理,而不是硬编码在代码里。这样改描述、调参数不用动代码,也方便做版本管理。

skills: - name: summarize_text description: > 将长文本压缩为简短摘要,适用于用户需要整体理解内容时; 不适用于只需要关键词列表的场景。 input_schema: type: object properties: text: type: string description: 待总结的文本 max_length: type: integer default: 200 description: 摘要最大字数 required: [text] output_schema: type: object properties: summary: type: string

这个文件在启动时加载,生成技能列表和描述文本,供路由层使用。我一般还会加一个version字段,方便追踪技能变更。

4.3 路由层实现:候选 + 确认

路由层的核心逻辑是:根据用户输入和当前上下文,从技能列表里选出最合适的技能。我用的方法是“候选 + 确认”:

def select_skill(user_input, context, skills): # 第一步:让模型输出 top-2 候选 candidates = model_select_top2(user_input, context, skills) # 第二步:用规则或二次判断确认 if len(candidates) == 1: return candidates[0] return confirm_skill(user_input, candidates)

model_select_top2的 prompt 里,我会把技能描述按“做什么 + 什么时候用 + 不什么时候用”的格式拼进去,并要求模型输出技能名和理由。理由很重要,因为当选择错误时,我能通过理由判断是描述问题还是模型理解问题。

confirm_skill可以用一个更小的模型,或者用规则判断。比如如果两个候选技能的输入 schema 差异很大,而用户输入明显符合其中一个,那就可以直接用规则确认。我实测下来,这个两步法比一步法准确率高不少,而且成本增加有限。

4.4 执行层与校验层

执行层负责调用技能并处理结果。我一般会加三个机制:超时、重试、降级。

def execute_skill(skill, params, timeout=10, retries=2): for i in range(retries + 1): try: result = run_with_timeout(skill, params, timeout) if validate_output(skill, result): return result except TimeoutError: continue except Exception as e: log_error(e) return fallback_result(skill)

校验层则负责检查输出是否符合预期。除了 schema 校验,我还会加业务校验。比如摘要技能,我会检查摘要长度是否在合理范围内、是否包含原文关键信息。这些校验规则用普通代码写就行,不需要模型参与。

这里有个经验:校验失败不一定要重试,有时候降级更合适。比如摘要太长,重试可能还是太长,不如直接截断。降级策略要提前想好,不要等出问题了再临时决定。

5. 常见问题与排查技巧实录

5.1 技能选择错误怎么排查

技能选择错误是最常见的问题。我的排查顺序是:先看描述,再看输入,最后看模型。

先看描述:两个技能的描述是不是太像?有没有写清楚“不什么时候用”?我遇到过好几次,加上“不适用”说明后问题就解决了。

再看输入:用户输入是不是本身就有歧义?比如“帮我处理一下这个”,这种输入换人来选也难。这时候需要在路由层加澄清机制,让智能体先问清楚再选。

最后看模型:如果描述和输入都没问题,那可能是模型能力不够。可以换更大的模型,或者把候选数量从 top-2 增加到 top-3,再确认。

5.2 参数生成错误的常见类型

参数错误我总结了几种典型:格式错误、类型错误、缺失必填、枚举越界。对应的排查方法:

错误类型典型表现排查方法解决思路
格式错误日期写成“明天”检查 schema 描述在描述里给示例
类型错误数字传成字符串执行层加类型转换schema 里写清类型
缺失必填没传 text 参数检查 required 列表执行层拒绝并提示
枚举越界format 传了 xml检查枚举定义执行层校验并报错

我一般会在执行层加一层“参数清洗”,比如把字符串数字转成整数、把日期统一格式。但清洗不能太激进,否则会掩盖问题。我的原则是:能安全转换的就转,不能确定的就报错。

5.3 上下文爆炸怎么处理

智能体项目做久了,上下文爆炸几乎必然遇到。技能调用记录、中间结果、历史对话越堆越多,最后模型要么超长,要么注意力被稀释。

我的处理方法是分层摘要 + 按需加载。把上下文分成“当前任务上下文”和“历史上下文”。当前任务上下文保留完整,历史上下文只保留摘要。当需要历史细节时,再通过检索技能按需加载。

具体实现上,我会给每个技能调用记录一个“重要性”标记。重要的调用保留完整输入输出,不重要的只保留摘要。重要性判断可以用规则,比如失败调用、用户明确反馈的调用标记为重要。

5.4 技能版本管理与灰度

技能是会迭代的。改了描述、调了参数,可能影响路由层的选择。所以我一般会给技能加版本号,并且支持灰度。

灰度的方法是:新版本技能先只对一部分流量生效,观察选择准确率和执行成功率。如果指标正常,再全量。这个思路在真实项目里能避免很多“改一个描述,崩一片”的情况。

版本管理还有个好处是可回滚。出问题时能快速切回旧版本,不用紧急改代码。我一般要求每个技能至少保留最近三个版本。

6. 技能组合与编排:从单技能到工作流

6.1 什么时候需要组合技能

单技能能解决的问题有限。真实任务往往是多步的,比如“查数据 + 分析 + 生成报告”。这时候就需要技能组合。但组合不是简单地把技能串起来,而是要定义清楚数据怎么流转、失败怎么处理、中间结果怎么校验。

我判断是否需要组合的标准是:如果任务需要超过一个语义完整的动作,且动作之间有依赖关系,那就需要组合。如果动作之间没有依赖,那并行调用就行,不需要复杂的编排。

6.2 工作流定义与执行

工作流我一般用 YAML 定义,而不是硬编码。这样改流程不用动代码,也方便可视化。

workflow: name: report_generation steps: - id: query_data skill: query_database params: query: "{{user_query}}" - id: analyze skill: analyze_data depends_on: [query_data] params: data: "{{query_data.result}}" - id: generate skill: generate_report depends_on: [analyze] params: analysis: "{{analyze.result}}"

执行时按依赖关系拓扑排序,能并行的并行,不能并行的串行。每个步骤的输出用变量引用,方便传递。

这里有个经验:工作流步骤不要太多,一般不超过 7 步。步骤太多,失败概率指数上升,排查也困难。如果超过 7 步,考虑把一些步骤合并成一个技能,或者拆成多个工作流。

6.3 失败处理与补偿

工作流执行中最怕的是“中间失败,前面白做”。所以我一般会加补偿机制:每个步骤记录状态,失败时根据状态决定是重试、跳过还是回滚。

重试适合临时性失败,比如网络抖动。跳过适合非关键步骤,比如日志记录。回滚适合有副作用的步骤,比如写入了数据。回滚的实现方式取决于具体技能,有的技能支持撤销,有的需要写补偿逻辑。

我踩过的坑是:回滚逻辑没测试。有一次线上出问题,触发回滚,结果回滚代码本身有 bug,导致数据不一致。后来我要求所有回滚逻辑必须和正常逻辑一样写测试。

7. 性能与成本优化:让 agent-skills 跑得又快又省

7.1 减少不必要的模型调用

模型调用是成本和延迟的大头。我的优化思路是能不用模型就不用。比如路由层,如果用户输入能通过关键词规则明确匹配到某个技能,那就直接匹配,不用调模型。只有规则匹配不了时,才走模型选择。

执行层也一样。有些技能内部如果只是简单的数据转换,用普通代码就行,不需要模型参与。我一般会把技能分成“模型技能”和“代码技能”,代码技能直接执行,模型技能才调模型。

7.2 缓存策略

缓存能显著降低成本。我一般会在两个地方加缓存:技能结果缓存和路由决策缓存。

技能结果缓存适合幂等技能,比如查询类技能。同样的输入,短时间内可以直接返回缓存结果。路由决策缓存适合重复输入,比如用户反复问同一个问题,路由结果可以直接复用。

缓存要注意失效策略。我一般用“时间 + 版本”双维度:超过一定时间失效,技能版本变更也失效。这样既能享受缓存收益,又不会用到过期结果。

7.3 模型选择与降级

不同技能对模型能力要求不同。路由层需要理解能力强的模型,执行层有些技能用轻量模型就够。我一般会按技能配置模型,而不是全局用一个模型。

降级策略也很重要。当大模型不可用或超时时,自动降级到小模型。小模型效果差一些,但至少能返回结果。我实测下来,降级策略能显著提升整体可用性,虽然部分结果质量会下降,但比直接失败好。

8. 测试与监控:让 agent-skills 可观测、可回归

8.1 技能单元测试

每个技能都要有单元测试。我一般用固定输入 + 断言输出的方式。对于模型技能,输出不完全确定,所以断言要宽松一些,比如检查关键字段是否存在、长度是否在范围内。

测试用例我要求覆盖:正常输入、边界输入、异常输入。边界输入比如空字符串、超长文本、特殊字符。异常输入比如格式错误、类型错误。这些用例能提前发现很多问题。

8.2 路由准确率测试

路由层需要单独测试。我一般会构造一个测试集,包含用户输入和期望技能,然后计算准确率。测试集要覆盖各种场景,包括容易混淆的技能对。

准确率不达标时,我会分析错误案例,看是描述问题还是模型问题。描述问题就改描述,模型问题就换模型或加候选。这个迭代过程一般要跑几轮才能稳定。

8.3 线上监控指标

线上我一般监控几个指标:技能调用成功率、路由准确率(通过用户反馈估算)、平均延迟、模型调用次数。这些指标能反映整体健康度。

成功率下降可能是技能本身问题,也可能是依赖服务问题。延迟上升可能是模型变慢或上下文变大。模型调用次数异常可能是路由层出了问题,比如频繁重试。这些指标我一般做成看板,方便随时查看。

9. 一些实操心得与避坑建议

做 agent-skills 这几年,踩过的坑不少,挑几个最有价值的说说。

第一,不要过早追求通用。我一开始想做一个“什么都能干”的技能体系,结果每个技能都不精,路由层也难选。后来改成先做垂直场景,把几个核心技能做扎实,再逐步扩展。这样反而更快出效果。

第二,描述比实现更重要。很多人把精力花在技能实现上,描述随便写。但实际上,路由层能不能选对,主要看描述。我现在的做法是,描述写完后先做一轮路由测试,不达标就改描述,改到准确率满意为止。

第三,失败要留痕。技能失败时,一定要记录足够的上下文:输入参数、错误信息、调用栈、当时的上下文状态。这些信息在排查时非常关键。我一般要求失败日志至少包含这些字段。

第四,定期清理技能。技能会越加越多,但有些可能已经不用了。不用的技能要定期清理,否则会干扰路由层选择。我一般每季度 review 一次技能列表,把长期未使用的下线。

第五,别忽略人工兜底。再好的智能体也会有搞不定的时候。我一般会加一个人工兜底入口,当智能体连续失败或用户明确要求时,转人工处理。这个机制在真实业务里能避免很多投诉。

最后再分享一个小技巧:技能描述可以用 A/B 测试。同一组技能,写两版描述,分别跑一段时间,看哪版路由准确率高。这个方法虽然土,但很有效。我在一个项目里用这个方法,把路由准确率从 82% 提升到了 91%。

这个内容后续还可以这样扩展:把技能体系做成可插拔的插件机制,支持动态加载和热更新;或者引入技能之间的依赖图分析,自动发现可以合并或拆分的技能。这些方向我还在探索,有进展再分享。

RELATED READING

延伸阅读

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