☰
Agent开发实战:拆解可复用Skill,告别脆弱Prompt
2026/10/14 9:58:37 网站建设 项目流程

大概从去年开始,我发现自己维护的十几个Agent脚本越来越像一团乱麻:每个任务都在重新写提示词,每个脚本都有一套自己的工具调用逻辑,某个任务里验证过的参数格式,换到另一个任务里又要重新对齐一遍。最难受的是排错——模型一抽风,你根本分不清是提示词的问题、工具返回的问题,还是上下文拼接的问题。

后来我把这套东西彻底重构了一遍,核心思路只有一句话:把"Agent能干什么"从"Agent该怎么干"里彻底剥出来,前者沉淀成一个个可以复用的skill,后者留给编排层去动态决定。这篇文章就是这次重构的完整记录,包括为什么拆、怎么设计、怎么落地,以及实测中遇到的三个典型翻车现场。如果你正在做一个长期维护的Agent项目——不管是客服机器人、数据分析助手还是自动化运维脚本——并且已经感觉到提示词越来越长、越来越脆,那这篇笔记应该能帮你绕掉不少弯路。

1. 从"万能Prompt"到"可复用Skills":先搞清楚拆什么

1.1 单块提示词为什么撑不住真实任务

很多人刚接触Agent开发时,第一反应是"把需求写详细一点",于是提示词越写越长,动辄上千字,里面塞满了业务规则、输出格式、工具说明、历史对话样例。早期demo确实能跑,但维护三周之后问题就全冒出来了。

第一个问题是脆弱性。你给模型描述十个工具,它做简单任务还好,一旦任务步骤变多,模型经常忘掉某个工具的参数格式,或者在两个功能相近的工具之间选错。我实测过,单轮工具数量超过六个之后,选错工具的几率会明显上升,尤其是工具描述写得模糊的时候。

第二个问题是无法复用。A项目里精心调好的"查库存并计算缺货量"逻辑,B项目想用,只能整段复制过去,然后改几个字段名。复制三次之后,这些逻辑就开始分叉——某个分支修了bug,其他几个还留着老问题。

第三个问题是不可度量。一整块提示词没有边界,你没法定义"这个功能到底做得好不好"。每次调优都是整体试错,改一句话可能影响全部行为,排查成本极高。

而把能力拆成skill之后,问题就变成了另一个样子:每个skill有明确的输入输出、明确的测试用例、明确的失败模式,你可以在一个可控的范围内单独优化它,而不是对着一个几千字的Prompt抓瞎。

1.2 skill的最小定义:输入、执行、输出、验证

做了几次重构之后,我给skill定了一个最小的结构定义,四件事,缺一不可:

  • 输入Schema:这个skill需要哪些参数,每个参数的类型、约束、默认值。注意,这个Schema不是写给人看的文档,而是给模型看的"使用说明书",决定模型能不能正确填参。
  • 执行逻辑:真正做事的部分。可能是调用一个API、执行一段Python代码、查询数据库,也可能只是对文本做一次结构化处理。
  • 输出Schema:返回给模型和编排层的结果格式。关键是固定结构——宁可损失一点灵活性,也要让输出能被稳定解析。
  • 验证逻辑:这部分很多人会漏掉。验证指的是执行完以后对结果做一次程序化检查,比如"返回的JSON能不能解析""查询结果是不是空""数值是否在合理范围内"。验证不通过时,skill应该返回一个带有错误码的失败信息,而不是把原始报错甩给模型。

举一个很简单的例子。假设你要做一个"查天气并给出穿衣建议"的skill,最朴素的实现是"让模型直接调用天气API并自己生成建议",但这样模型经常把温度单位搞混。拆成skill之后,输入Schema是城市名和日期,执行逻辑负责调天气API并统一转换成摄氏度和"晴/雨/多云"这种离散状态,输出Schema是固定字段,验证逻辑检查返回状态码和日期格式。模型只负责填参和读结果,出错的范围被大幅压缩了。

一旦每个skill都长这样,你就会发现整个Agent系统从一个"巨大的猜测器"变成了一个"可控的流水线"。下面我具体说说skill怎么分层、怎么设计。

2. 按场景给skills分层归类:通用层、领域层与编排层的边界

2.1 通用skill:检索、解析、代码执行

拆完以后我发现自己手里的skill大概能分成三层。最底下的是通用层,跟具体业务无关,任何Agent都可能用到。这一层我强烈建议最先建,因为它们的复用率最高,你后面每做一个新Agent,都能省一大块时间。

我日常最常用的通用skill有这些:

  • 网页抓取与正文提取:输入一个URL,输出干干净净的正文文本。注意这里必须包含编码探测,否则遇到GBK的老站会满屏乱码。
  • 结构化检索:在知识库或文档集合里做向量检索加关键词兜底,输出Top-K条带来源和得分的片段。
  • 代码执行沙箱:输入一段Python代码,在隔离环境里执行,返回stdout、stderr和执行时间。这个skill做数据分析类Agent时是刚需。
  • 表格理解:把CSV/Excel解析成统一的DataFrame描述,包括列名、类型、前五行样例、缺失值占比。

通用层的设计标准就一个:不参业务。代码执行沙箱不需要知道你在算销售数据还是做科学计算,它只需要把代码跑起来,把结果按固定格式返回。一旦你在通用skill里塞了业务逻辑,它就不"通用"了,后面维护的时候会非常难受。

2.2 领域skill设计示例:把"数据分析"拆成可组合的原子能力

领域层是最能体现设计水平的部分。以数据分析这个场景为例,很多人会做一个巨大的"数据分析skill",输入一个DataFrame,输出完整的分析报告。我一开始也这么干,结果发现这个skill几乎无法调试——模型一会儿做描述统计,一会儿做趋势预测,一会儿又画图,哪个环节出错了都只能整锅重来。

后来我按"分析动作"而不是"分析目标"来拆,拆成下面这一组原子skill:

skill名称输入要点输出要点
数据概要表名、目标列集合描述统计、类型分布、缺失统计、样例行
相关性计算表名、列A、列B、方法相关系数、样本量、显著性(如有)
分组对比表名、分组列、指标列、聚合方式每组指标值、组间差异摘要
时间趋势表名、时间列、指标列、粒度趋势点列表、峰值谷值标注
分布检验表名、数值列分布形态描述、偏度峰度、异常值数量

每个skill只回答一个窄问题,但组合起来就能覆盖大部分数据分析需求。模型拿到用户问题后,先调用"数据概要"了解数据结构,再决定接下来调"分组对比"还是"时间趋势"。

这样就实现了一个关键性质:每个skill都能单独测试。我可以拿固定的样例数据跑"相关性计算"一百遍,确认它对异常输入的处理是否稳定,而不需要搭一个完整Agent才能验证。

2.3 编排层:理解意图、选择skill、动态拼装

有了通用层和领域层的skill,接下来要解决的是"让模型知道用哪个skill、按什么顺序用"。这个决策逻辑就是编排层。

编排层的工作可以拆成三步:

  1. 意图识别:把用户的问题映射到一个或多个skill的组合需求上。这一步通常不需要额外训练,让模型看着所有skill的输入输出描述做一次选择就行,关键是skill描述要写清楚"什么时候该用我"。
  2. 参数收集:模型根据对话历史和用户输入,填充当前skill需要的参数。这里要特别留意:没有收集到必填参数时,宁可停下来向用户追问,也不要硬填默认值。我见过很多Agent在这个环节瞎猜参数,导致后续结果全错。
  3. 执行与调度:按依赖关系执行skill。比如做数据分析,先跑"数据概要"再跑"时间趋势";如果中间某一步验证失败,编排层要决定是重试、换skill还是向用户说明。

编排层跟skill层最重要的区别是:编排层不写死任何业务逻辑。它像是一个"调度员",根据模型的选择把不同的skill串起来。这样某个技能升级或者换实现,都不会影响调度流程。

3. 亲手搭一套skill调用链:注册、上下文拼接与工具执行

3.1 用统一接口封装skill

理论说完了,来点能直接抄的。我给skill封装了一个统一的Python接口,核心是一个数据类加一个注册表:

from dataclasses import dataclass, field from typing import Any, Callable, Optional import json import inspect @dataclass class Skill: name: str # skill唯一标识,如"data_profile" version: str # 版本号,便于效果追踪 description: str # 给模型看的描述,写明用途和触发条件 parameters: dict # 参数schema,JSON Schema格式 execute: Callable # 执行函数,参数通过**kwargs传入 validate: Optional[Callable] = None # 可选验证函数,返回(ok, message) required_permissions: list = field(default_factory=list) # 预留权限控制 _SKILL_REGISTRY = {} def register_skill(skill: Skill): if skill.name in _SKILL_REGISTRY: raise ValueError(f"skill {skill.name} 重复注册") _SKILL_REGISTRY[skill.name] = skill def get_skill(name: str) -> Skill: return _SKILL_REGISTRY[name] def list_skills() -> list[Skill]: return list(_SKILL_REGISTRY.values())

外部挂在API后面的Skill,我在文章里统一用这套注册模式来管理(如果你用的框架自带工具装饰器,也可以直接套用)。这套接口最重要的地方在于:注册表本身就是模型的"能力清单"来源,模型看到什么就能用什么,你只需要保证description和parameters写得足够清楚。

3.2 让模型"知道"该用哪个skill:描述与参数schema

工具描述写得好不好,直接决定模型选型准确率。我总结了三条经验,都是踩坑踩出来的。

第一条:description里说"什么时候用",而不是只说"能干什么"。

比如"时间趋势"这个skill,如果只写"计算时间趋势",模型在用户问"这个季度销量涨了还是跌了"的时候,经常会犹豫到底用哪个。如果改成:

"当用户需要查看某个指标随时间的变化情况(如月度、周度趋势、涨跌对比)时使用。输入需要时间列和指标列。如果用户只想看当前快照或分组对比,不要使用本skill。"

选型准确率会明显提升。因为你在告诉模型它的边界,而不是让它猜。

第二条:参数schema用JSON Schema,字段名和描述要直白。

{ "type": "object", "properties": { "table_name": { "type": "string", "description": "数据表名称,必须来自当前会话中已存在的表" }, "time_column": { "type": "string", "description": "时间列名,列内值应可解析为日期" }, "metric_column": { "type": "string", "description": "要分析的数值列名" }, "granularity": { "type": "string", "enum": ["day", "week", "month", "quarter"], "default": "month" } }, "required": ["table_name", "time_column", "metric_column"] }

注意里面两个细节:一是"table_name必须来自当前会话中已存在的表"这种描述,能有效防止模型凭空编造表名;二是granularity用enum枚举,而不是让模型自由输入,否则你会收到"monthly""per month""按月"等各种变体。

第三条:每个skill的参数不要超过五六个。参数一多,模型填错的概率就飙升。如果某个skill确实需要很多参数,考虑把它拆成两到三个更细的skill,或者让其中一个参数接收一个预先结构化的对象。

3.3 执行与反馈回环:一次带验证的真实调用

封装好skill之后,实际的调用链长这样:

def run_skill_with_validation(skill_name: str, params: dict, max_retries: int = 2): skill = get_skill(skill_name) for attempt in range(max_retries + 1): try: result = skill.execute(**params) if skill.validate is not None: ok, message = skill.validate(result) if not ok: if attempt < max_retries: continue # 这里可以加入修正逻辑,比如补抓数据或映射字段 return {"status": "validation_failed", "error": message} return {"status": "success", "result": result} except Exception as exc: if attempt < max_retries: continue return {"status": "error", "error": str(exc)} # 执行完后,把结果拼回对话上下文

这里有个很多人不重视的点:执行完skill之后,到底把什么内容放回上下文?我的做法是:不放原始返回体,而是放一个"摘要+结构化结果"的组合。摘要让模型快速理解结果大意,结构化结果让模型在需要精确数值时能查到。两者都放进上下文,但严格控制总量——比如一个大表的结果,只放前20行加统计信息,避免上下文被撑爆。

还有,失败信息本身也要结构化。如果skill返回"查询超时",模型可能会猜测是网络问题;如果返回一个错误码"E_TIMEOUT"加"建议重试"的动作,模型就能做出更合理的决策。把失败模式归类,是让Agent从"瞎猜"变"有策略"的关键一步。

4. 实测翻车记录:返回格式、幻觉、超时三个坑逐一定位

再完美的设计,落地时也会翻车。下面这三个坑是我在实际项目中真实遇到过的,每一个都花了不少时间定位,写出来希望你能避掉。

4.1 坑一:工具返回乱结构,模型开始胡编

现象:某个skill返回的是一个嵌套深层JSON,里面有四五层dict和list混在一起,甚至有些字段是null。一开始模型还能勉强处理,但连续调用几次之后,模型开始在后续回复里编造字段名,比如把"total_revenue"写成"total_sales"。

排查过程:我先以为是上下文太长导致模型遗忘,于是做了两件事:一是把上下文里的完整返回改成摘要,二是写日志跟踪每次调用模型的原始输出。结果发现,模型不是"遗忘",而是面对不稳定结构时倾向于用自己的"常识"补齐——它看到深层JSON里有几个字段缺失,就直接脑补了一个合理的字段名。

修复方案:把skill的输出统一拍平成两层结构,所有字段都有明确的默认值,null必须被转换成"unknown"或0。然后给输出Schema加一个"字段说明表",让模型不再需要猜测含义。改动之后,同样的场景连续跑了五十个测试样本,编造字段的情况归零。

这个坑给我的教训是:模型的幻觉,很多时候是你给它的数据结构逼出来的。如果工具返回给模型的信息不完整、不一致,模型就会自动"补全",而补全往往就是幻觉的来源。

4.2 坑二:模型"以为"自己执行了工具

现象:日志显示某次对话里,模型应该调用"代码执行沙箱"去算一个分组平均值,但返回的内容里没有工具调用记录,模型直接在回复里给出了一组数字。翻开上下文发现,前一轮对话里有一个类似的表格数据,模型就"借鉴"了那些数字。

这是我见过最隐蔽的问题。模型不是故意造假,而是在它的训练模式下,"回答一个计算问题"和"调用工具再回答"都可能是合理的,它选了更省事的那条路。如果你不做工具调用强制约束,模型就会时不时跳过工具,直接给答案。

排查过程:因为是离线日志分析时发现的,一开始还以为是有个skill没注册。后来把对话渲染成"每一步是否是工具调用产生的"之后,定位到是模型决策问题,不是工程问题。

修复方案:在编排层的系统提示里加了一条硬性规则:"当且仅当问题需要当前上下文之外的信息或精确计算时,必须调用工具;禁止直接使用上下文中的旧数据进行新的计算。"同时,我把"代码执行沙箱"的输出设计成带时间戳和水印,模型一旦引用旧结果,验证逻辑能识别出数据版本不匹配,主动报错。

这个坑提醒我:编排层不能只做"能用",还要做"必须用"的约束。Agent的可靠性,很大一部分来自对模型行为的强制收敛,而不是期望它自觉。

4.3 坑三:串行调用太慢,并发又互相踩数据

现象:做数据分析时,模型一下要调用四五个skill,我最初是串行执行的,每次平均耗时四秒多,用户等得很不耐烦。改成并发执行后快了两倍,但出现了新问题:两个并发skill同时在往同一个临时表里写中间结果,导致数据错乱,而且日志里出现竞态。

排查过程:看日志发现,两个skill都调用了同一个"数据缓存"服务,都以为自己是唯一写者。我当时的中间表设计是全局的,没有做会话隔离。

修复方案:做了三件事。第一,每个会话一个独立工作目录,临时表和中间结果全部按session_id隔离;第二,skill执行加入依赖声明,没有依赖关系的skill才允许并发,有依赖关系的必须等前一skill完成;第三,给并发写操作加锁,同一资源同时只允许一个写者。

另外,并发执行时还要考虑token消耗和限流。我把并发上限控制在三个,并且每个skill执行前检查一下目标API的剩余配额,避免多个skill同时撞上同一个第三方接口的限流阈值。

这个坑最后让我认识到:skill拆分得越细,调度层需要考虑的并发和隔离问题就越复杂。skill的粒度不是越细越好,而是要在可组合性和调度成本之间取平衡。

5. 让skill库越用越顺手:版本、评价与沉淀

5.1 每个skill都要有"测试集"

skill库建起来之后,最大的风险是"改了一个skill,坏了三个不提"。所以从第一天起,我给每个skill建立了一个最小测试集,一般三到五个测试用例,覆盖正常路径、边界路径、失败路径。

比如"相关性计算"这个skill,测试集长这样:

  • 正常数据:两列存在线性关系,期望相关系数落在合理区间。
  • 含缺失值数据:期望返回缺失占比,并给出"已剔除缺失值"的标注。
  • 非法列名:期望返回错误码E_COLUMN_NOT_FOUND,而不是抛一堆Python traceback。
  • 常量列:期望返回"相关系数无法计算"并说明原因。

每次修改skill,先跑测试集,全部通过再考虑上线。这套做法看起来笨,但效果立竿见影——有一次我调整了输出Schema的字段命名,测试集立刻抓出三个用例不通过,避免了模型在线上产生一连串错误。

5.2 效果对比与回滚机制

Agent系统的优化往往不是"对了或者错了",而是"好一点还是差一点"。我采用的办法是:给每次skill变更打标签,记录变更前后的对比测试结果,存成一个简单的效果日志。日志内容包括:skill名、版本号、变更说明、测试集通过数量、还有一个小样本线上回放的指标——比如"工具选型准确率""失败重试率"。

有了这些数据,回滚就不再是拍脑袋了。某个skill上线后发现线上表现变差,我可以直接对比效果日志,快速回到上一个稳定版本。注意,这里说的回滚一定要带上上下文兼容性:如果新版本依赖的新字段,旧版本的系统不认识,回滚时需要一并处理编排层的参数映射。

5.3 多人协作时的命名与文档约定

如果有多个人一起维护skill库,命名和文档必须提前约法三章,否则三个月后就没有人敢动别人的skill了。

我目前使用的约定很简单:

  • 命名:动词打头,kebab-case,如fetch_page、compute_corr、gen_chart,禁止出现含糊的do_stuff、process_data。
  • 描述:必须写明边界条件,即"什么时候不要用我",这条跟前面说的一样。
  • 变更记录:每次修改必须更新version和变更说明,不能在原地无痕修改。
  • 所有权:每个skill指定一个负责人,改动前在文档里挂个issue,避免两个人同时改同一个skill。

另外,我会定期做一次"skill健康度检查":统计每个skill在线上被调用的次数、失败率、平均参数补齐率。调用次数很低且失败率高的skill,要么是描述不清晰导致模型总选错,要么就是已经被更好的skill取代了,这时候就该合并或者下架。skill库跟代码库一样,不维护就会腐烂。

最后分享一个我个人的实操体会:给Agent做skill拆分,本质上是在降低模型的自由裁量权,把它从"什么都能干"收窄到"在限定范围内干好"。这个收窄过程一开始会让人觉得不痛快——开发效率似乎变低了、代码变多了、抽象变复杂了。但跑过几个真实项目后你会发现,可控性带来的信心增益远超那点开发成本。如果你正准备重构自己的Agent项目,我建议你先从最常被调用的那个功能开始,把它改造成一个带验证逻辑的skill,跑两周对比一下效果。大概率你会同意我的结论:Agent真正从"玩具"变成"工具",靠的不是更强的模型,而是更明确的能力边界。

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

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

立即咨询