☰
Agent技能包重构:从提示词混排到可插拔的能力模块架构
2026/10/14 8:33:44 网站建设 项目流程

最近在整理内部智能体项目时,我发现大量问题其实都出在同一个地方:能力没有边界,全部混在提示词和一次性工具函数里。于是我把这一层彻底重构了一套独立的“技能包”,也就是这个 agent-skills 项目。它不是某个框架的插件,也不是需要安装的库,而是一套组织思路:把 Agent 要执行的能力拆成像积木一样可插拔、可单独验证、可动态启用的单元,然后交给调度层按任务选配。如果你正在做办公助手、客服机器人、多系统数据汇总这类东西,这篇就是这次重构的完整记录,加载器、清单格式、选型逻辑、避坑清单都有。

1. 为什么要把 Agent 的“能力”重构成“技能”

1.1 从一股脑塞提示词,到把能力装进独立技能包

我早期做的几个 Agent 原型有个通病:把所有工具函数直接挂在一个大函数列表里,系统提示词里写几十行说明,让模型自己挑。看起来简单,一旦业务变复杂就完蛋。新增一个查询类型要改主逻辑,一个工具崩溃可能拖垮整个对话流程,功能与功能之间出现隐式耦合,出了问题只能靠人肉翻日志。

后来我把这层能力全部拆成独立技能包。每个技能包有名字、版本、输入输出契约、权限声明,内部实现完全不暴露给外部。调度层不需要知道技能怎么实现,只需要知道“这个技能在什么场景下用、需要哪些参数、返回什么结构”。这相当于给 Agent 的世界做了代码模块化。以前是“一大团可调用函数的集合”,现在是“一套有边界、有版本、可独立测试的能力库”。

也有人问,这和直接写好多函数有什么区别?区别在于契约和管理方式。普通函数面向程序员,技能面向 Agent 调度层,所以描述信息、参数校验、权限声明、超时控制都是运行时的一部分,而不是代码注释。技能包可以单独修 bug、单独升级、单独回滚,甚至可以跨项目复制,这才是它存在的意义。

1.2 哪些能力值得被技能化

不是所有函数都值得做成技能。拆得太碎,调度层会疯掉;拆得太粗,一个技能内部塞了十几条分支,又回到原来的问题。我的判断标准是四个条件:会被多个场景复用、有明确输入输出、可独立测试、背后可能涉及外部系统或复杂逻辑。四个条件至少满足三个,才考虑技能化。

我给团队列过一个简单的判断表,这里也贴出来参考:

候选能力是否建议技能化理由
查询订单状态是多个场景复用,输入输出稳定,依赖外部接口
计算销售额汇总是可独立测试,需要访问数据仓库,逻辑相对独立
生成随机确认码否简单工具函数,没必要增加一次调度开销
读取用户省份并生成问候语否场景单一,提示词就能搞定
发送审批消息是有权限边界,有外部副作用,必须单独管控
把长文本拆成段落否一次性的格式化逻辑,拆成技能反而增加调用层负担

我见过有人把字符串转大写也做成了 Agent 技能,结果模型为了转大写专门调一次工具,响应又慢又浪费 token。技能化不是越细越好。技能应该更像乐高积木里那种标准颗粒,而不是把一整块模型砸碎了再拼回去。

1.3 为什么技能之间不能互相调用,只能由调度层串联

这是重构过程中最反直觉的一条原则。技能内部要绝对保持纯粹:输入参数,返回结构化结果,不要在里面调用另一个技能。表面上看,A 技能需要 B 技能的结果,直接调用多方便。但一旦允许技能间互相调用,依赖关系会迅速变成一张复杂的图。你无法预测一个调用链会走多深,也无法从日志里还原到底是谁先调了谁,排查问题会变成一场灾难。

正确做法是:技能完成自己职责后,如果发现后续还需要别的能力,就在返回结果里给调度层一个提示字段。比如suggest_next_skill或者next_step。调度层看到提示后,在宏观层面决定下一步调用哪个技能。这样调用链永远保持线性可控,每个环节都有记录,出了问题可以精确回放。

我把这个原则写成团队规范:技能不调技能,技能只调外部服务和数据源。所有技能串联动作都发生在调度层。这条规范强制执行之后,线上问题的平均定位时间从几小时降到了半小时以内。

2. 核心细节拆解:一份可落地的技能接口长什么样

2.1 Manifest 里每个字段都不是摆设

我习惯给每个技能配一个manifest.yaml,作为它的身份证。字段不多,但每一个都经过了实战检验。直接看一个例子:

name: analyze_sales version: 1.3.0 description: | 销售数据汇总与趋势分析。支持按日、按周、按渠道查看销售额, 可返回同比和环比变化。 当用户问“最近销量怎么样”“这个月销售额多少”“哪个渠道增长最快”时使用。 不要用于库存、缺货、补货类问题。 input_schema: type: object properties: date_from: type: string description: 开始日期,格式 YYYY-MM-DD date_to: type: string description: 结束日期,格式 YYYY-MM-DD dimension: type: string enum: [day, week, channel] default: day required: [date_from, date_to] output_schema: type: object properties: status: type: string summary: type: string rows: type: array permissions: data_scope: read_only timeout_ms: 5000

这里每一个字段都不是摆设。description是给模型看的检索文本,不是给人看的团队文档。它决定了模型在众多技能里会不会选中这个。所以描述里要写清楚“什么时候用”和“什么时候不要用”,否定条件往往比肯定条件更重要。input_schema用于参数校验,也用于在调度层组装调用参数。output_schema虽然很多时候不参与选型 prompt,但我会拿它做结果开关校验,避免技能实现走样。permissions决定这个技能在运行时使用什么权限的服务账号。timeout_ms防止某个技能把主流程拖死。

字段的取舍也很讲究。我故意不把author放进去,因为技能是团队资产,不是个人作品。:etag这类缓存字段也没有必要,版本号足够。

2.2 参数 Schema 别把模型逼疯:三个小技巧

参数 Schema 设计直接决定了模型能不能稳定调用技能。我踩过不少坑,总结了三个非常实用的小技巧。

第一,required越少越好,能用默认值解决的不要逼模型填。比如查询销售数据,date_from和date_to必填没问题,但dimension完全可以用day当默认值。模型在填空时压力越小,参数幻觉出现概率越低。第二,枚举比自由文本稳得多。如果某个参数只有几种合法值,直接写成 enum,模型很少会自己发明一个不存在的选项。第三,日期和数字格式别指望模型自觉。模型经常把“2025年1月1日”传成2025-1-1,把“上周”留空。我的做法是在调度层加一个normalize_args函数,把日期、数字、枚举值做一次归一化处理后再传给技能内部。

这层归一化看起来不起眼,实际上能拦下大量常见错误。比如把“今天”转成具体日期,把“上个月”展开成起止日期范围,把大小写混用的枚举值统一映射。没有这层保护,技能本身就没必要判断参数是否可信,因为模型给的东西不可能百分百规范。宁可调度层多做一步,也不要在每个技能内部重复处理脏参数。

2.3 权限与错误处理:先想清楚技能会带来哪些副作用

技能化之后,权限边界必须跟着细化。我会把技能权限分成三档:read_only、biz_write、system_admin。只读技能访问数据仓库用最小权限账号;写操作技能必须走服务账号,不能复用操作员 token;系统级技能默认关闭,需要单独配置白名单。所有写操作技能上线前都要过一遍审批,确认不会产生跨租户数据泄漏或者误删风险。

错误处理设计也不能马虎。技能内部出错时,不要只抛一行堆栈给模型,模型看不懂,也不知道下一步该怎么办。我给技能输出定了一个统一的结果结构:

{ "status": "error", "error_type": "timeout", "message": "上游查询服务超过3秒未响应", "suggestion": "建议缩小日期范围后重试" }

status告诉调度层成功还是失败,error_type用于分类统计,message给人看,suggestion给模型看,让它可以直接组织下一步行动。有了这个结构,Agent 在遇到失败时能自己调整策略,而不是反复用同样的错误参数重试。

3. 实操过程:从零搭一套 Agent Skills 运行库

3.1 注册表与加载器:装饰器实现,半个小时能跑起来

我最早是用配置文件驱动技能加载,后来发现写配置比写代码还累,就改成了装饰器方案。核心代码很简洁,一个注册表类加一个装饰器就能工作。先看注册表:

# skill_registry.py from dataclasses import dataclass from typing import Callable, Optional @dataclass class Skill: name: str description: str version: str input_schema: dict permissions: str fn: Callable class SkillRegistry: def __init__(self): self._skills = {} def register(self, name, description, version="0.1.0", input_schema=None, permissions="read_only"): def wrap(fn): self._skills[name] = Skill( name=name, description=description, version=version, input_schema=input_schema, permissions=permissions, fn=fn, ) return fn return wrap def get(self, name): return self._skills.get(name) def list_skills(self): return [ {"name": s.name, "description": s.description, "version": s.version} for s in self._skills.values() ]

然后在技能文件里直接用装饰器注册:

registry = SkillRegistry() @registry.register( name="analyze_sales", version="1.3.0", description=( "销售数据汇总与趋势分析。用户问销量、销售额、增长率、渠道占比时使用。" "库存和补货问题不要使用。" ), input_schema={ "type": "object", "properties": { "date_from": {"type": "string", "description": "开始日期,格式 YYYY-MM-DD"}, "date_to": {"type": "string", "description": "结束日期,格式 YYYY-MM-DD"}, "dimension": { "type": "string", "enum": ["day", "week", "channel"], "default": "day" } }, "required": ["date_from", "date_to"] }, permissions="read_only", ) def analyze_sales(date_from: str, date_to: str, dimension: str = "day"): # 实际实现里在这里查业务库,这里只示意返回结构 return { "status": "ok", "summary": "7月销售额环比增长12%,线上渠道贡献最大", "rows": [{"date": "2025-07-01", "amount": 120000, "channel": "online"}], }

装饰器方案的好处是,技能开发者只需要写一个普通函数,对外部来说和写普通业务函数没有区别,注册是自动完成的。如果团队对目录结构有洁癖,也可以维持技能目录 + manifest 的方式,启动时统一扫描加载。我目前是混合用:基础技能用装饰器直接注册,外部技能包用目录扫描加载,扫描时读 manifest 生成技能对象,这样两边都能覆盖。

3.2 Agent 选技能:用检索式索引避免上下文爆炸

技能多了以后,把全部技能描述塞进 system prompt 会让模型犯迷糊。我实测下来,技能描述超过五十条,选型准确率反而下降,上下文也浪费得厉害。所以我在调度层加了一层技能检索:提前把描述文本向量化,用户提出问题后先检索最相关的 top 个技能,只把这几个候选塞进选型 prompt。

候选技能不能只看相似度。有些技能描述里包含同样的关键词,但含义完全不同,比如“查询订单”和“创建订单”,用户说“帮我看看订单状态”,两个技能都可能被召回。我的解决方法是让召回阶段返回 8 到 10 个候选,选型阶段再由模型从里面挑一个真正合适的。检索层是粗筛,模型是精筛,两段式设计比单靠检索或者单靠模型都要稳。

选型循环参考下面的伪代码,实际项目中会再加日志、限流、安全校验,但核心逻辑是一样的:

def run_agent(user_input, max_turns=5): history = [] for turn in range(max_turns): candidates = registry.retrieve(user_input, top_k=8) prompt = build_skill_prompt(candidates, user_input, history) resp = llm_complete(prompt) decision = parse_json(resp) if decision is None or decision.get("finished"): return decision.get("answer", "无法处理,需要人工介入") skill = registry.get(decision.get("skill")) if skill is None: history.append({"error": "技能不存在,请从候选技能中重新选择"}) continue args = normalize_args(skill, decision.get("arguments") or {}) observation = safe_execute(skill, args) history.append(observation) return "超过最大轮次,需要人工介入"

这里normalize_args负责把模型给的参数做归一化,safe_execute负责捕获异常、限制超时、记录执行日志。模型输出本身是不可信的,调度层必须把每个环节接好。

3.3 热更新、版本管理与安全加载

技能库做大之后,不可能每次更新都重启服务。我实现了基于文件变化的动态加载:技能目录里新增或修改 manifest,调度层会自动感知,重新生成技能对象。听起来方便,但有个隐蔽的坑:更新过程中模型可能仍拿着旧技能描述发起调用,导致旧参数和新实现不匹配。

我的方案是版本平滑切换。manifest.yaml里的version用语义化版本号,调度层只认精确版本。要升级技能时,先往目录里放新版本技能,等注册表确认加载成功,再把旧版本下线。整个切换过程对模型透明,它看到的始终是一个稳定可用版本。

安全加载是另一个重点。动态加载意味着外部代码可能被引入进程,如果直接信任文件内容,等于给自己开了一个后门。我的做法是只从受信任的技能目录加载,外部技能包必须带签名校验,加载后跑一遍沙箱测试,确认没有越权访问系统路径、没有莫名其妙的网络请求,才允许正式注册。对个人小项目来说这些可能过度设计,但只要技能库要开放给别人使用,这些步骤一个都不能少。

3.4 三层评测法:单技能、选型、端到端分开测

把技能库做出来之后,评测是我最后补上的一环,也是收益最大的一环。很多团队只做端到端测试,拿几十个用户问题跑一遍看回答正不正确。这种做法有个问题:任务失败了,你分不清是技能实现坏了,还是模型选错了技能,还是参数传错了,还是最终话术组织得不行。

所以我改用三层评测。第一层只测技能实现:给定合法和非法参数,验证技能返回结构正确、错误处理符合预期、执行时间在预算内。第二层只测技能选型:给一批用户 query,看模型是否选中正确的技能,这一步不关心技能实现细节,只看选型结果。第三层才是端到端任务测试,把前两层合起来看最终效果。

分层评测能大幅减少定位问题的时间。有一次端到端任务连续失败,第一层评测全过,第三层里把选型结果打印出来才发现,模型每次都选了名字相近但语义完全不对的技能。问题出在描述上,根本不用去查技能内部代码。还有一次选型全对但任务结果不对,问题就锁定在参数归一化逻辑里。三层分开测,解决问题像剥洋葱一样一层一层来。

4. 我踩过的坑:问题排查与避坑实战

4.1 技能描述决定选型准确率,反面教材和正写法

技能描述写得好不好,直接决定模型会不会选错技能。我最早的描述写得很省事,比如“查询销售数据”,结果模型在用户问“营收占比”时选了一个名叫“查询订单”的技能,因为描述里没有明确触发场景。后来我总结出一套写法,正面描述加否定描述结合,效果立竿见影。

先看反面写法:

description: 查询销售数据

再看改动后的写法:

description: | 查询订单明细和销售汇总,支持按日期、渠道过滤,返回销售额、订单量、同比环比。 当用户问销量、销售额、增长率、渠道占比时使用。 不要用于库存、缺货、补货、进货类问题。

主要改动是三处:说清楚这个技能能算出什么指标;列出典型触发问题;明确排除不该用它的场景。给每个技能写描述时脑子里要过一个“模型检索”的过程,想象用户问题里可能出现哪些词,把这些词自然放进描述里。描述是给人看的还是给模型看的?答案很明确,首先是给模型看的检索文本,其次才是给队友看的说明。

4.2 模型参数幻觉不全是模型的问题

模型在生成参数时经常出现“幻觉”,典型表现是日期格式错乱、枚举值自己造、必填字段缺一个。有一段时间我几乎每天都能看到date_from=“2025-7-1”这种明显错误。为这事骂模型没有用,因为问题往往出在参数设计和归一化不足。

我在调度层加了normalize_args之后情况好了很多。这个函数做的事情包括:日期格式统一转成YYYY-MM-DD;把“今天”“昨天”“上周”“本月”这类相对时间展开成具体日期;枚举值做大小写归一化,比如Daily和daily都映射成day;如果缺了非必填参数,自动补默认值。这些逻辑听起来很琐碎,但能拦下绝大部分参数问题。

再往后我甚至不要求模型直接传日期了。对于带外部副作用的技能,我会先让模型输出一套高层意图,比如{"period": "last_month", "dimension": "channel"},由调度层把意图翻译成具体参数。模型在高层意图上不容易出错,翻译逻辑由我来保证稳定,反而比让模型直接填复杂 JSON 更可靠。

4.3 技能输出太多,把上下文撑爆

技能执行成功只是第一步,返回内容怎么处理同样重要。有一次查询销售明细,技能把 5000 行记录全部塞进返回结构里,模型看完直接失去判断能力,后续回答又慢又混乱。从那以后我规定:技能返回结果必须先做聚合。

默认聚合规则是,明细类技能只返回前 10 条记录加一个汇总字段;分析类技能返回一句话结论加关键指标。完整明细写进 trace 日志文件,用户明确要明细时,由调度层再调用一个专门的get_sales_detail技能按页拉取。这样既保住了执行结果的完整性,又不会把上下文撑爆。

这里还有一个附带好处:模型拿到的信息更精炼,反而更容易组织高质量回答。很多开发者习惯认为返回越多信息越好,实际上大模型的注意力是有限的,信息过载和完全没有信息一样致命。

4.4 一个容易忽略的权限坑

技能化之后,权限设计容易漏掉一个细节:技能用哪个身份去访问外部系统。早期我把所有技能配置成同一个服务账号,权限很大。结果某次运维排查发现,一个本应只读的“查询库存”技能,因为服务账号带写权限,理论上可以修改营销活动的投放状态。虽然没出事故,但这个隐患让人后怕。

现在每个技能都在 manifest 里声明permissions,调度层启动时根据声明为技能绑定最小权限的服务账号。写操作技能还会有独立的审批环节,运行时如果检测到权限超出声明范围,直接拒绝执行并告警。权限声明不是写给人看的规范,而是运行时必须强校验的约束。

4.5 常见问题速查表

问题表现常见原因排查方向
模型总是选错技能描述缺少触发词或否定条件检查描述里有没有用户问题的同义表达
参数格式错误缺少归一化层查看 normalize_args 是否覆盖日期、枚举、相对时间
技能执行超时外部服务响应慢或技能内部未做超时控制检查 manifest 里的 timeout_ms 和技能内部调用
上下文不够用技能返回了过多原始数据改为聚合返回,完整明细写日志并提供分页技能
更新后行为不一致新旧技能版本混用检查热更新平滑切换和版本下线逻辑
动态加载后再现漏洞未做签名校验和沙箱测试限制技能来源,加载前执行安全测试

5. 从技能包到技能市场:后续可以怎么扩展

5.1 可共享的技能包格式

技能库稳定下来之后,下一步自然会想把它开放出来,做成团队内甚至更大范围的共享能力。我的打算是把技能包格式标准化:目录结构统一,manifest 里加许可证、维护者、依赖声明,然后打包成一种可安装的格式。这样不同团队之间可以互相复用成熟技能,不用每次从零写。粗看起来类似于通用包管理器的思路,但技能包的核心是描述和契约,代码反而不是最需要共享的部分。共享一个技能包,本质上是共享一个“行为约定”。

5.2 沙箱化与成本预算

随着技能变多,我开始关心两个问题:执行成本和安全性。写操作技能或者执行任意逻辑的技能,最适合放进沙箱容器里跑,隔离文件系统和网络访问。每一个技能执行都会产生 token、耗时、外部调用费用,需要一个额度系统来管理。我计划给每个技能加一个“成本预算”,比如每用户每天查询次数上限、每月写操作执行次数上限。超过预算自动降级为只读模式或者直接拒绝执行。这件事做成了,技能库才真正适合进入生产环境。

5.3 技能描述自动迭代

还有一个我很想做的扩展:通过执行记录自动迭代技能描述。现在描述靠人肉维护,用户问题一变,描述就可能过期。我把选型评测里的失败样本收集起来,定期对比哪些 query 选错了技能,把漏掉的触发词自动补进描述候选。这个做对之后,技能库的选型准确率可以持续提升,不用每次靠人去猜模型在想什么。

最后分享一个小技巧:每个技能的 manifest 里我都会加一个examples字段,放两三条真实用户问题。这个字段不一定参与选型,但用来生成选型评测集非常方便。我每次改动描述,就拿这些样例跑一遍,很快能看出是变好还是变坏。就这个习惯,省掉了大量肉眼对比的时间,也让技能库的维护从“碰运气”变成了“有反馈循环”。

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

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

立即咨询