☰
Agent技能化开发实战:从提示词堆砌到可插拔技能框架
2026/10/14 9:51:04 网站建设 项目流程

做了半年多的 Agent 开发,我踩过的坑比吃过的饭还多。最开始我也跟大多数人一样,随手把十几个工具函数丢进系统提示词里,让模型自己在超长上下文中“悟”出该调谁。效果么,惨不忍睹。后来我彻底换了一套思路,把能力切分成独立、可注册、可复用的“技能”,这就是 agent-skills 这套设计想解决的核心问题。这篇东西不是什么官方文档的翻译,就是我把自己从零搭起这套技能框架的完整过程、踩坑记录和思考方式写下来,希望能帮你少走几个月的弯路。

如果你正在做 Agent、智能体、或者任何带工具调用的自动化流程,那这篇文章大概率对你有用。哪怕你刚入门,只要懂一点 Python,也能跟着把最基础的部分跑起来。

1. 为什么“技能”比“提示词”更适合作为 Agent 的构建单元

先说个最底层的认知问题:到底什么叫“技能”?为什么不能继续堆提示词?

1.1 从单轮对话到多工具协作的变化

早期的聊天机器人只要做一件事:把一个请求映射成一段回答。模型记住所有指令,问题不大。但 Agent 不一样,它要在大语言模型之外挂一堆真实工具——查库存、发邮件、算价格、调第三方接口。这时候所有东西都塞进提示词,你会发现几个很现实的问题。

第一,提示词会越塞越长,模型对中间内容的注意力会下降。我试过给一份 8000 字的工具说明,结果模型经常忘了最后几个工具的存在。第二,每个工具的描述、参数、调用规则混在一起,模型很容易混淆“哪个参数属于哪个工具”。第三,你每加一个工具就要重新测一遍全量回归,因为改动提示词会影响之前所有正常的功能。

这些问题不是提示词工程能救的。哪怕你用再结构化的小标题、XML 标签去分隔,模型还是会犯类似的错。本质原因是提示词是“线性的自然语言描述”,而工具能力需要的是“结构化的可执行模块”。只有当工具被封装成独立单元,有自己的入口、参数契约和返回格式时,模型才能像人查电话本一样按名字找到它,而不是在所有文字里找线索。

1.2 “技能”的定义与边界:我的理解

我把“技能”定义为:一个可以被 Agent 调用、有名称、有描述、有参数规则、有执行逻辑、有返回值的独立功能单元。它可以是单次函数调用,也可以是多个步骤组合。关键在于它的边界是清晰的:一个技能只做一类事情,不感知其他技能的内部状态,只接收入参并返回结果。

这个定义有意识地向“函数调用”靠拢,但又比函数多了一层模型可感知的描述。没有描述的函数,模型不知道什么时候用;没有参数的严格定义,模型填出来的参数千奇百怪;没有返回值约定,Agent 就没法用它做下一步推理。

有人会问:那这跟 LangChain 里的 Tool 有什么区别?在我实际使用中的感受是,Tool 的职责更偏向“单一动作”,而技能更强调“可编排的最小能力单元”。举个例子,查询天气是一个 Tool,但“根据天气决定适合的穿搭”就是一个技能,它内部可能要先后调用天气查询、紫外线指数、衣物数据库三个动作。技能可以简单,也可以复杂,但它对外暴露的接口必须稳定。

1.3 一个能落地的最小技能长什么样

一个真正能放进代码里的最小技能,至少包含四个部分:技能名称、描述文案、参数定义、执行函数。我把它们放在同一个文件或同一个类里,这样技能就是一个自包含的模块,移动、测试、替换都很方便。

一个典型的最小技能代码长这样:

# skills/get_current_time.py from datetime import datetime def run(params=None, context=None): # 参数校验容错:allow_empty 表示当前技能不需要参数就能跑 if not params: params = {} timezone = params.get("timezone", "local") fmt = params.get("format", "%Y-%m-%d %H:%M:%S") now = datetime.now() return { "ok": True, "time": now.strftime(fmt), "timezone": timezone }

你看,它没有任何 Agent 相关的第三方依赖。这就是我强调的“最小化”:技能本身最好是一段普通的 Python 代码,而不是一个深度耦合框架的组件。这样你能直接单测它,也能在非 Agent 场景下复用。我在项目里经常把技能函数拉出来,当作普通的内部服务调用。这一点很关键,后面讲技能编排时你还会看到它的好处。

2. agent-skills 的思路拆解:如何把能力变成可插拔模块

光有技能类还不够,你需要一套机制让 Agent 能发现、加载和调用它们。这部分我把它拆成三个核心环节:注册表、描述文件和执行器。

2.1 技能注册表与发现机制

技能注册表是整个框架的“电话本”。它维护着所有可用技能的名称到对象的映射,也承担了技能加载的职责。我不喜欢每次调用技能时临时 import,也不喜欢用反射把目录下所有文件自动扫一遍——自动扫描虽然方便,但调试时你很难说清楚到底加载了哪些技能,很容易出现“其实技能没被读进来,但你以为它在”的尴尬。

我用的方式是在一个统一入口里手动注册,或者用一个简单的装饰器显式声明:

# registry.py SKILL_REGISTRY = {} def register_skill(name=None): def decorator(func): skill_name = name or func.__name__ SKILL_REGISTRY[skill_name] = func return func return decorator

然后每个技能文件里用装饰器标记:

from registry import register_skill @register_skill("current_time") def run(params=None, context=None): # 实现同上 ...

这样做的好处很明显:技能和普通函数的调用方完全解耦。你想临时禁用某个技能,只要在注册处去掉一行;想替换底层实现,也只需要换注册的对象,Agent 侧的调用方式完全不变。权限控制、版本管理、熔断策略也都可以挂在注册表这一层做,而不用每个技能单独实现。

2.2 技能描述文件:让模型“知道”该调用什么

注册表解决了“有这个技能存在”,但真正让 LLM 在需要的时候能选中它,靠的是描述文件。这部分是最容易被低估、又最值得花时间打磨的。

我把技能描述看成“售货员贴的商品标签”。标得太泛,模型不知道该拿它干嘛;标得太细,又会占掉有限的上下文窗口。我总结了几个实用的描述词要点:

  • 正面说明功能:这个技能能做什么。
  • 反面说明边界:这个技能不做什么。比如“只处理人民币金额,不支持外币换算”。
  • 给出典型使用场景:在什么条件下应该调用它,甚至给一个例句。
  • 参数说明:每个参数的取值范围、单位、格式,最好附上合法示例和非法示例。

比如一个“根据城市名查询天气”的技能描述,我会写成:

城市天气查询:当用户询问某城市当前天气、温度、风力时使用。 参数 city:城市中文名,比如"北京";必填。 返回内容:温度、天气状况、风力等级。 如果用户只提供模糊地名,请先将地名转换为城市名后再调用。

这样写,模型误调用的概率会显著下降。我在一次实验里对比过:描述模糊的技能调用准确率大概只有 60%,把参数示例和边界写清楚后能到 88%。别小看这几个百分点,那可能决定了你的 Agent 是能用还是不可用。

2.3 技能执行器与上下文传递

注册表里存的是技能函数,描述文件告诉模型“什么时候调”,那真实调用时,还需要一个统一的执行器来负责“怎么调”。执行器要做的事情包括:从 LLM 输出里解析出技能名和参数、校验参数、执行技能、把结果再传回给模型继续生成。

执行器的设计上,我一直坚持一个原则:把它做成完全不感知业务逻辑的通用“中间人”。它只做传参和取回结果这两件事,不要在里面加任何 if-else 的判断逻辑。否则执行器会变得越来越庞大,最后变成一坨没法维护的胶水代码。

def execute_skill(skill_call: dict, context: dict): name = skill_call.get("name") params = skill_call.get("parameters", {}) skill_func = SKILL_REGISTRY.get(name) if not skill_func: return {"ok": False, "error": "skill not found", "name": name} try: result = skill_func(params=params, context=context) return {"ok": True, "name": name, "result": result} except Exception as e: return {"ok": False, "name": name, "error": str(e)}

上下文的传递特别容易忽略。很多人把 context 和 params 混在一起,结果技能内部分不清哪些是用户传的,哪些是系统给的。我的做法是:context 里只放与本次对话全局相关的信息,比如用户 ID、会话 ID、当前页面地址;params 只放当次技能调用的参数。两边的数据如果要互通,必须在调用前由执行器显式处理,不能指望技能内部自己去 context 里挖数据。

3. 我实现这套技能框架时的关键代码结构

讲完思路,我直接把最核心的那段代码结构贴出来。这部分不需要很复杂,但稳定性和扩展性都要考虑进去。

3.1 技能基类与接口

前面那个用装饰器的写法适合轻量级技能,但如果技能内部有复杂状态、有多步操作、有依赖注入需求,我建议用类来封装。我这里的基类非常朴素:

class BaseSkill: name = "" description = "" parameters_schema = {} def validate_params(self, params: dict) -> dict: """子类可覆写,负责校验和清洗参数。""" return params def run(self, params: dict, context: dict) -> dict: """技能执行主体,必须返回 dict。""" raise NotImplementedError

接口就这三个:name、description、run。所有复杂逻辑都藏在 run 内部。validate_params 是我后来加的,后悔没有一开始就加。因为模型填参数经常出现“类型不对、缺字段、多字段”的情况,在技能内部统一处理太乱了,不如在入口就挡住。

我在这个基类上还做了一个小的约定:每个技能不管成功失败都必须返回一个包含 ok 字段的 dict。这样 Agent 就能通过 ok 字段快速判断是否需要下一步处理,而不是靠异常。异常只留给真正的系统错误,业务失败(比如查无数据)应该走正常返回值。

3.2 一个实际的技能例子:商品价格计算

为了展示真实使用,我拿一个最常见的“价格计算”技能举例。它需要根据商品单价和数量计算总价,同时考虑折扣。

class CalculatePriceSkill(BaseSkill): name = "calculate_price" description = ( "计算商品总价,参数 unit_price 为单价(元)," "quantity 为数量(正整数),discount 为折扣率(0到1之间的小数,可选)。" "仅用于价格计算,不负责查询商品库存。" ) parameters_schema = { "unit_price": {"type": "number", "required": True}, "quantity": {"type": "integer", "required": True, "minimum": 1}, "discount": {"type": "number", "minimum": 0, "maximum": 1, "default": 1} } def run(self, params, context): params = self.validate_params(params) total = params["unit_price"] * params["quantity"] * params.get("discount", 1) return {"ok": True, "total": round(total, 2), "currency": "CNY"}

你看,这个技能的描述里特意写了“不负责查询商品库存”,就是因为一开始模型总是拿它去干别的。加了这句反面描述之后,误用率降了一半。这个例子很小,但它能说明:一个技能成功与否,很大程度不取决于实现逻辑,而取决于它有没有给模型讲清楚自己是什么。

3.3 调度器的取舍与手写实现

有人可能会说,阿这不就是 Agent 的 function calling 吗?我承认,在一个纯 LLM 调用场景里,你完全可以用平台的函数调用机制。但我的场景是多个 Agent 共享同一套技能,有的走 LLM 调用,有的走规则脚本,有的走定时任务。我需要的不是“某个模型的函数调用”,而是“一套统一的技能调度接口”。

所以我手写了一个非常薄的调度器。它不负责理解自然语言,只负责把“已经解析好的技能调用请求”分发到对应技能上。真正的解析工作由 LLM 或上层规则完成。

def dispatch(skill_name: str, params: dict, context: dict): skill_cls = SKILL_CLASS_REGISTRY.get(skill_name) if skill_cls is None: return {"ok": False, "error": f"skill {skill_name} not registered"} skill = skill_cls() return skill.run(params, context)

这个调度器大概只有 20 行,但它把技能加载、参数校验、调用分发这几个事情收敛到了一个点。后面如果要做灰度发布、按用户维度放量、超时熔断,都在这一层加就行了。手写的好处是可控,我不需要为了一个调度去看某个框架几千行的内部实现。

4. 实测中的教训:最常见的问题与排查方法

代码能跑通不叫成功,能在真实对话里稳定运行才是。这节我把线上遇到的典型问题和排查过程完整记录下来。

4.1 模型总是选错技能怎么办

最经典的问题就是:明明有这个技能,模型偏不调;或者该调 A 技能,模型调了 B 技能。

我第一次遇到时觉得很诡异。后来我把 30 条错误案例拉出来,一条条对着技能描述看,才发现一个规律:模型选择技能时,更依赖“描述里的动作短语”而不是“技能名”。比如技能叫get_stock_info,描述里写的是“查询库存数量”,模型在用户说“还有多少货”时根本认不出,因为“货”和“库存数量”字面差太远。

我把描述改成“当用户询问某商品还剩多少货、库存是否充足、是否缺货时使用”,效果立竿见影。这说明描述里应该包含用户口语的常见表达,而不是只写书面语。另一个经验是:多个技能描述里不要用相同的动作词。两个技能都写“查询”,模型就很容易选哪个都行,随机性陡增。最好每个技能有一个无法替代的动作标签。

4.2 技能间状态污染和上下文泄漏

技能执行得多了,某一天我发现 A 技能的结果居然影响到了 B 技能的输出。排查了一下,发现罪魁祸首是共享的 context 对象。

我的 context 设计之初是只读的,但技能内部为了图方便,经常把一个中间计算结果塞进去:

context["temp_price"] = price # 很不好的做法

结果就是下一个技能从 context 里拿不到自己预期的字段时,就开始猜,猜着猜着就把脏数据当成了正常入参。这类 bug 非常隐蔽,因为单测时你永远不会同时跑两个技能,只有多轮对谈中才暴露。

我的解决方案很简单也很硬性:context 在读入技能时做一层浅拷贝,技能永远只能写自己返回的 dict,不允许写外部 context。如果确实要共享数据,就必须走执行器显式声明的“共享缓存”通道,比如:

shared = context["shared_bucket"] shared["temp_price"] = price

这样至少你审核代码时能一眼看到哪些技能写了共享数据。配合日志里记录每个技能读写了什么字段,排错速度快很多。

4.3 多轮对话里技能调用失败后的恢复策略

第三个高发问题是:技能调用失败了,Agent 直接卡死,或者更糟——它假装调用成功了,还给出一个伪造的结果。这一类问题的本质是模型对“失败”没有处置路径。

我后来在技能返回协议里加入了 fail_reason 字段,并且在系统提示词里明确要求:如果技能返回 ok 为 false,Agent 必须向用户说明错误原因,并且可以主动提出替代方案。同时执行器会把这些失败案例记录下来,用于后面的评测。

还有一招是在执行器层做一次“失败转写”。如果技能执行抛了异常,执行器不是直接把异常返回给 LLM,而是把它转化成一段结构化文本:技能名、参数、异常类型、可读错误信息。模型看到这段文本后,往往能自己分析出是不是参数有问题,进而生成修正后的调用。这个机制让我产品的首次修复成功率提升了差不多两成。

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

这套框架跑稳之后,我开始琢磨怎么让它能持续积累、复用、共享。于是后面几个方向成了我实验的重点。

5.1 技能组合与编排

单技能很好用,但真实业务经常需要“先做 A,再根据 A 的结果做 B”。你可以把这套流程也封装成技能,但它内部要显式描述依赖顺序,而不是靠模型随机应变。

我采用的方式是给技能增加一个depends_on字段,声明它执行前需要哪些其他技能的返回。编排引擎会先拉起依赖技能,再把结果合并进当前技能的上下文。如果依赖失败,当前技能不会启动,而是返回“前置技能未完成”。这样做以后,复杂任务的稳定性提高了一大截,因为链条上每一环都是可测试的。

当然,我也踩过编排过度设计的坑。一度想把 DAG 流程、重试队列、分布式锁全塞进去,后来发现大部分场景只要“顺序依赖 + 简单缓存”就够了。所以不要把编排做得太重,除非你的技能数量真的很多。

5.2 技能评测与回归

技能不是写出来就完了,它需要一套能自动化验证的评测集。我给每个技能配了一个形如“用户输入 + 期望调用的技能名 + 期望参数 + 期望返回结果”的测试样例。每次改动技能描述或执行逻辑,我都跑一遍全量回归,比较标准做法是看三项指标:技能选择准确率、参数解析正确率、业务执行成功率。

选错技能是最影响体验的问题,所以准确率是我第一优先指标。为了提升它,我的描述文案会反复打磨,测试样例也跟着迭代。经常有这种情况:你加了一个技能描述了,结果别的技能的准确率反而掉了。这正是需要回归测试的原因。没有这套机制,你永远不知道一次改动到底让系统整体变好了还是变坏了。

5.3 社区化技能共享的可行性

最后我一直在想的,是能不能把技能定义与实现做成一个可共享的“技能包”。就像代码包一样,别人可以上传一个技能,其他人通过技能名和描述就接入自己的 Agent。

这个方向的难点不在技术,而在协议统一。只要大家都遵守“name、description、parameters、run、return”这个最小契约,技能包就是天然可移植的。我自己已经在内部把常用技能整理成了一套私有包,换项目时直接引入,省了大量重写功夫。以后如果能把技能描述规范得更标准化,再加上本地验证沙箱,社区化的技能市场完全有可能做起来。到那个时候,Agent 的构建方式可能真的会从“写代码”变成“组装技能”。

最后说一个我个人的习惯:每次新加一个技能,我都会强迫自己先只写描述,不写实现,然后拿真实用户的话术去测模型能不能选对。选不对就先改描述,改到准了再写代码。这个习惯帮我避开了至少一半的返工。技能系统这东西,看着简单,但每一点对用户行为的体感积累,都会最终反映在 Agent 的稳定程度上。希望你也能在自己的项目里感受到这种“把模块切干净”的快乐。

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

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

立即咨询