☰
Agent技能化改造:从堆提示词到可复用技能资产的完整实践
2026/10/14 9:34:19 网站建设 项目流程

从"一句话提示词"到"技能资产":Agent开发方式的这一轮转变

最近小半年,我和团队在做一个跨平台的智能客服Agent,需求从最初的"能回答常见问题"一路膨胀到"能查订单、能退换货、能做售后工单分类、能写总结报告"。最开始我们的做法很朴素:把每个能力写进system prompt,靠提示词硬撑。结果想必你也猜到了——提示词越堆越长,改一个功能要连带影响三个不相关的能力,模型经常张冠李戴,把退货流程说成退款流程。后来我们做了一次彻底的重构,把所有能力拆成了一个个独立的技能(Skills),整个Agent的开发节奏和稳定性都上了一个台阶。这篇文章就把这一整套"agent-skills"的设计思路、最小实现和踩坑复盘完整记录下来,给正在做Agent技能化改造的同学一个可以直接参考的样例。

1. 从"一句话提示词"到"技能资产":Agent开发方式的这一轮转变

1.1 提示词堆叠为什么撑不住

先说一个反直觉的结论:在Agent里,把能力写进提示词是最贵的做法。表面上看,提示词不用写额外代码,模型好像也"听得懂",但一旦能力数量超过十个,问题就全来了。

我拿一个具体场景说明。做电商客服Agent时,我们希望它既能查物流,又能算优惠,还能处理售后。如果全写在提示词里,一个prompt大概会长成这样:

  • 你是客服助手,你有以下能力:
  • 能力一:查询物流,输入订单号,输出物流轨迹
  • 能力二:计算优惠,输入商品ID和用户ID,输出最优优惠组合
  • 能力三:处理售后,输入订单号和售后类型,输出处理结果
  • 能力四:识别用户情绪,输入聊天文本,输出情绪标签
  • ...

模型不是做不好,而是每次都要从二十多个描述里定位一个"当前该用哪个能力"。实践中你会发现,能力描述越长,模型越容易把相似能力搞混——查物流和查订单这类相邻能力是重灾区。更麻烦的是,新增一个能力时,你永远不知道这个新描述会不会干扰旧的判断逻辑。

这就是技能化要解决的第一个问题:把"能力描述"从提示词里剥离出来,变成独立的、可注册的、可检索的模块。Agent每次只根据当前任务的上下文,动态选择一两个候选技能,再读取对应的描述和参数schema,而不是让大模型在每一轮对话里都"通读全文"。

1.2 技能和传统工具调用(Function Calling)的区别

可能有人会说:这不就是Function Calling吗?也有一部分类似,但技能封装的粒度更粗、更完整。一个工具函数通常是"给定参数,执行一次原子操作",比如"调用HTTP接口"、"查询数据库"。而一个技能封装的是"为了完成某个目标,所需的一整套推理与执行闭环"。

我举个例子。工具层面的做法是暴露两个接口:search_order(按订单号查数据库)和parse_status(把状态码翻译成用户能看懂的话)。而技能层面的做法是注册一个"查询订单进度"技能,它内部包含:从对话中提取订单号、判断是否需要调用数据库、调用查询工具、把结果翻译成自然的用户语言、在无法识别订单号时主动追问。

区别在哪里?工具是"执行单元",技能是"能力单元"。工具假设调用者已经想清楚要干什么,技能需要在不确定的输入中帮调用者想清楚该干什么。这个区别决定了技能描述必须包含"何时使用"和"何时不要使用"的边界说明,这也是后文反复强调的重点。

1.3 技能资产化:可复用、可评估、可组合

我之所以用"资产"这个词,是因为技能一旦成型,它的价值是可以在多个Agent之间复用的。我们团队现在维护了一个公共技能库,客服Agent能用里面的"订单查询"技能,工单Agent也能用同一个技能,甚至数据标注Agent也在用它做结果校验。

技能资产化的三个特征,我们在实践中是这样理解的:

  • 可复用:技能与具体Agent解耦,同一个技能可以被不同Agent加载,不写死业务上下文。
  • 可评估:技能可以单独测试,不用等整个Agent链路跑完才知道好坏。我们给每个技能配了单独的评测集。
  • 可组合:复杂任务由多个技能编排完成,技能之间是松耦合的,可以像积木一样换装。

这三个特征,是后面所有工程手段的底层依据。

2. 技能清单的底层设计:先定义好"Agent能做什么",再谈"Agent怎么做"

2.1 技能注册表的核心字段

技能化的第一步不是写代码,而是设计技能注册表(Skill Registry)的数据结构。这个表决定了一个技能在上线、调用、维护整个生命周期里能不能被清晰地管理。

我给出的最小可用注册字段如下:

字段说明为什么必需
skill_id技能唯一标识,如order_query逻辑引用与日志追踪都靠它
name技能展示名,如"订单进度查询"面向阅读者的可读名称
description何时使用、何时不要用、典型示例LLM依赖它做技能选择,核心字段
parameters_schema参数标准格式(如JSON Schema)用于参数提取与校验
entrypoint执行入口(函数或HTTP接口)实际调用逻辑
preconditions前置条件,如"必须已完成登录"避免进入无效执行
cost_level成本等级:低/中/高编排时做预算控制
timeout超时上限,默认值避免挂死
version语义化版本号支持灰度与回滚

这几个字段里,description的设计是重中之重。我在下一节单独说。

2.2 技能描述怎么写:LLM是靠描述来"认路"的

一个常见的错误是,把技能描述写成功能说明书,比如"本技能用于查询订单状态、物流轨迹、发货时间"。这种描述太笼统,模型不知道什么时候该选它。

我们后来总结了一套描述模板,包含四块内容:

  1. 触发场景:在什么任务下应该使用此技能,要写具体,比如"当用户询问快递到哪了、发货了没、包裹状态时使用"。
  2. 明确边界:什么情况下不要用,比如"用户仅咨询退换货政策时不要用,请使用售后政策技能"。
  3. 输入要求:需要哪些关键参数,缺失时怎么处理。
  4. 运行说明:技能内部会做什么,输出什么格式的结果。

举个例子,"订单进度查询"技能的description经过几轮迭代后长这样:

当用户询问订单的物流进度、快递位置、包裹配送状态、是否发货、到货时间等问题时使用。 不要用于查询历史订单列表、不要用于退换货流程咨询,也不要用于查询商品详情。 需要从对话中提取订单号。如果用户没有提供订单号,应当追问。 技能会查询物流接口,并按用户可见的口语化文案返回结果。

这段描述经过我们的AB测试,技能命中准确率比第一版笼统描述提升了大约22个百分点。这是因为,对大模型来说,选择技能的过程本质上是一次few-shot分类任务,描述里的正例和反例就是分类边界。

2.3 技能应该拆多细:粒度决策的实用标准

粒度问题我们内部争论过很多次。一个技能如果太大,比如"处理所有售后问题",那它内部还是要写分支,退化成本质上的提示词;如果太小,比如"从字符串里去除空格",又显得琐碎,维护成本居高不下。

我最后采用的是三个判断标准,分享给大家:

  • 能否独立评测:一个技能能用一组独立的测试用例衡量好坏,说明它值得拆出来。
  • 是否被多个场景复用:被两个以上业务流程用到的能力,拆成技能是划算的。
  • 内部分支是否会随需求膨胀:如果一个"技能"里超过四五种差异很大的分支逻辑,就应该进一步拆分。

我们团队现在技能库里有六十多个技能,粒度大致落在"订单查询"、"优惠计算"、"售后工单分类"这一层,下层的基础操作如"调用物流API"反而是作为普通工具存在的,不暴露给Agent选择。技能的粒度尽量贴近业务概念,工具的粒度贴近技术操作,这两层分开治理,能避免技能数量爆炸。

3. 用Python搭个最小技能注册与执行框架(附可运行代码)

3.1 核心数据结构与注册机制

不聊虚的,直接看代码。我们用一个极简的Python实现来说明技能注册与执行的核心链路。这段代码我刻意控制在两百行以内,方便你读懂骨架。

from dataclasses import dataclass, field from typing import Callable, Any, Optional import json @dataclass class Skill: skill_id: str name: str description: str entrypoint: Callable[..., Any] parameters_schema: dict = field(default_factory=dict) preconditions: list = field(default_factory=list) cost_level: str = "low" # low | medium | high timeout: float = 10.0 version: str = "1.0.0" def check_preconditions(self, context: dict) -> Optional[str]: for cond in self.preconditions: if cond not in context: return f"缺少前置条件: {cond}" return None def extract_parameters(self, llm_text: str) -> dict: # 实际项目中这一步由LLM完成,这里模拟一个JSON解析 try: return json.loads(llm_text) except json.JSONDecodeError: return {} def execute(self, params: dict, context: dict) -> Any: if not self.parameters_schema: return self.entrypoint(context=context, **params) # 简单schema校验:所有必填字段必须存在 required = [ k for k, v in self.parameters_schema.get("properties", {}).items() if v.get("required") or k in self.parameters_schema.get("required", []) ] missing = [k for k in required if k not in params] if missing: raise ValueError(f"缺少必填参数: {missing}") return self.entrypoint(context=context, **params)

注册机制我推荐用装饰器,写起来干净。

class SkillRegistry: def __init__(self): self._skills = {} def register(self, skill: Skill): if skill.skill_id in self._skills: raise ValueError(f"技能已存在: {skill.skill_id}") self._skills[skill.skill_id] = skill def get(self, skill_id: str) -> Skill: return self._skills.get(skill_id) def skills_prompt(self) -> str: """生成一份用于提示LLM选择技能的文本,所有技能描述汇总于此。""" lines = [] for sid, skill in self._skills.items(): lines.append(f"[{sid}] {skill.name}\n{skill.description}") return "\n\n".join(lines) def search(self, query: str, top_k: int = 5): """用关键词覆盖度做一次粗筛,返回候选技能ID列表。""" q_words = set(query.lower().split()) scored = [] for sid, skill in self._skills.items(): blob = f"{skill.name} {skill.description}".lower() score = len(q_words & set(blob.split())) if score > 0: scored.append((score, sid)) scored.sort(key=lambda x: -x[0]) return [sid for _, sid in scored[:top_k]] registry = SkillRegistry()

一个技能的注册样例:

def query_order(context: dict, order_id: str, user_id: str = ""): # 这里替换为真实接口调用 if order_id == "A1001": return {"order_id": order_id, "status": "shipped", "eta": "明天"} return {"order_id": order_id, "status": "unknown"} order_skill = Skill( skill_id="order_query", name="订单进度查询", description=( "当用户询问订单的物流进度、快递位置、包裹配送状态、是否发货、到货时间等问题时使用。" "不要用于查询历史订单列表、不要用于退换货流程咨询。" "需要从对话中提取订单号,如果用户没有提供订单号,应当先追问。" ), entrypoint=query_order, parameters_schema={ "properties": { "order_id": {"type": "string", "description": "订单号"}, "user_id": {"type": "string", "description": "用户ID,可空"}, } }, preconditions=["user_context"], ) registry.register(order_skill)

注意,我在parameters_schema里没有强制要求user_id,因为查询订单的场景里订单号就足够了。

3.2 LLM技能选择与执行链路

技能选择的本质是:把候选技能的描述拼进提示词,让LLM返回技能ID和参数JSON。这里有一个关键设计——不要让LLM从所有技能里选,先做一次粗筛,缩小候选集。等技能数量超过五十个之后,全量选择会让提示词变长且选择准确率明显下降。

class AgentRuntime: def __init__(self, registry: SkillRegistry): self.registry = registry self.history = [] def run(self, user_input: str, context: dict): # 第1步:粗筛候选技能 candidate_ids = self.registry.search(user_input, top_k=5) # 第2步:将候选技能描述交给LLM做最终选择 skill_prompt = self.registry.skills_prompt() # 实际工程中用真实LLM调用,这里是说明性示例 llm_choice = self._llm_select(user_input, skill_prompt, candidate_ids) # llm_choice: {"skill_id": "order_query", "params": {"order_id": "A1001"}} skill = self.registry.get(llm_choice["skill_id"]) if skill is None: return "未找到合适技能" # 第3步:前置条件检查 precondition_err = skill.check_preconditions(context) if precondition_err: return f"触发失败: {precondition_err}" # 第4步:提取参数并执行 params = skill.extract_parameters(llm_choice.get("params", {})) result = skill.execute(params, context) # 第5步:记录执行日志 self.history.append({ "skill_id": skill.skill_id, "params": params, "result": result, }) return result def _llm_select(self, user_input, skill_prompt, candidate_ids): # 这里用伪代码表示LLM调用逻辑 prompt = f""" 用户问题: {user_input} 可用技能: {skill_prompt} 请从候选技能 [{', '.join(candidate_ids)}] 中选择最合适的技能并返回JSON。 如果都不合适,返回 skill_id 为 empty。 """ # 实际项目中调用你的LLM SDK return {"skill_id": "order_query", "params": {"order_id": "A1001"}}

这段代码没有暴露长尾细节,但骨架是完整且可以直接扩展的:粗筛用关键词覆盖度,精选用LLM,前置条件和schema校验夹在中间保证执行安全。粗筛+精选的串行链路,是技能数量膨胀后依然保持选择准确率的关键工程手段。

3.3 参数提取的容错设计

参数提取是整个链路里最容易被低估的一环。LLM就算选对了技能,可能把参数名写错、格式写错、或者参数类型不对。比如把"order_id"写成"orderId",把数字参数写成字符串。

我们的做法是在extract_parameters里做一层"宽松映射":先尝试JSON标准解析;失败后进行一次字段名模糊匹配,把常见的同义字段映射到规范的字段名。另外,对每个参数做一次类型校验,能在执行前兜住大部分格式错误。

记得一件事:对缺失参数的处理,优先让Agent追问,而不是硬编一个默认值。有些默认值会让执行链跑出错误结果,比不执行还糟糕。

4. 技能编排的两种姿势:固定管道与动态路由怎么选

4.1 顺序管道:适合稳定、确定的任务链

并不是所有任务都需要LLM来编排。很多业务流程是固定不变的,比如"先查询订单,再计算是否超时,最后生成催单文案"。这种流程用代码写死就好,稳定、可测试、不消耗额外的LLM调用。

我们的实现很简单,就是一个OrderedPipeline:

class Pipeline: def __init__(self, steps: list): self.steps = steps # [(skill_id, param_mapping), ...] def run(self, context: dict): output = {} for skill_id, mapping in self.steps: skill = registry.get(skill_id) params = {k: output.get(v) if v.startswith("$") else v for k, v in mapping.items()} output["result"] = skill.execute(params, context) return output

固定管道的优势是可解释性和可测试性。它的每一步都可以单独验证,整条链路跑挂了也容易定位——就是第几步出的问题。凡是业务流程能够预先确定的场景,优先用固定管道,不要硬上"全动态编排"。

4.2 动态路由:让LLM按任务进展逐步选择技能

开放性问题没有固定路径,比如"用户说'我想投诉,但是记不清订单号了'"。此时Agent需要先通过"身份校验"技能确认用户,再用"订单列表查询"技能找到订单,再走投诉工单创建流程。这个决策链不是预先定死的,而是根据对话状态实时变化的。

我们用动态路由时,简化的循环是:

  1. 每轮执行前,检查任务是否已完成(调用一个专门的完成判断技能)。
  2. 未完成时,根据当前对话上下文选择下一个技能。
  3. 每个技能执行后,把结果写入会话状态(context),供下一轮决策使用。
  4. 设置最大步数(比如8步),超过则强制终止并总结。
class DynamicExecutor: def __init__(self, max_steps=8): self.max_steps = max_steps def run(self, user_input, context): for step in range(self.max_steps): is_done = self._is_done(context) if is_done: break next_skill_id = self._decide_next(user_input, context) if next_skill_id == "empty": break skill = registry.get(next_skill_id) params = skill.extract_parameters(self._llm_params(user_input, context, skill)) result = skill.execute(params, context) context["last_result"] = result self.history.append(result) return self._summarize(context)

动态路由最大的坑是执行步数失控和技能来回横跳。模型可能反复执行"订单查询"和"用户信息补全"两个技能,死循环。我们的应对方案是:在context里维护"最近执行的技能ID列表",决策时如果检测到某个技能连续重复执行三次,强制让LLM选择其他路径,或者直接终止并转人工。

4.3 上下文剪裁:技能之间不该共享全部信息

技能编排时,上下文(context)的传递方式非常影响效率和准确率。很多团队图省事,所有技能共享同一个超大context,结果技能执行时被无关信息干扰,而且token消耗巨大。

我们演化出一套按需注入的策略:每个技能声明自己需要哪些上下文键,执行前从全局context里挑出这部分注入。比如"订单查询"只需要user_id和order_id,"情绪分析"只需要聊天文本,"工单创建"需要客服操作权限标记和用户ID。这种方式也让技能的前置条件检查有了实际抓手——检查context里有没有对应的键。

5. 技能上线后的三件大事:评测、缓存和失败兜底

5.1 给每个技能建一个独立评测集

技能化改造最大的收益之一,就是终于可以给能力做单元测试了。以前整个Agent是一个黑盒,出了问题只能调prompt碰运气。现在每个技能可以单独喂测试用例,单独看准确率。

我们为每个技能维护的评测集包含以下条目:

评测维度说明示例
触发命中该触发时是否选择此技能用户问"快递到哪了"应命中订单查询
负例拒绝不该触发时是否不误选用户问"怎么退货"不应命中订单查询
参数提取参数是否提取正确且完整从"查一下A1001这个订单"提取order_id
执行结果技能输出是否符合预期返回状态码与文案正确
边界输入缺参、异常输入的处理订单号为空时是否追问

评测集的维护本身要版本化。每次技能描述或逻辑变更,都先跑一遍全量评测集,确保没有回归。我们的经验是,技能数量到三十个以上之后,每次改动如果不跑评测集,隔几天就会冒出一个关联性的bug,而且很难定位。

5.2 技能结果缓存:别让同样的查询反复烧钱

技能执行往往要调用外部API或LLM,成本不低。对于时效性要求不高的技能结果,我们加了一层缓存,命中率相当可观。

缓存键的设计很关键,不是简单拼接参数。我们把技能ID、参数、context里的用户身份级别、以及一个可选的"时效因子"一起作为缓存键。比如订单查询结果十分钟内有效,商品详情缓存一小时,情绪分析结果不缓存(因为实时性很重要)。TTL(生存时间)也在技能注册表里维护,作为一个字段default_timeout。

这里提醒一个容易忽略的细节:副作用类技能的缓存要格外小心。比如"创建工单"这类技能,绝不能因为缓存而跳过实际执行。

5.3 失败兜底:技能不是万能的,兜底链要提前设计

技能执行失败比不执行更常见。可能的原因包括:外部接口超时、参数被用户中途撤回、权限校验失败、结果语义不符合预期。

我们给每个技能设计了三级兜底链:

  1. 重试:对偶发超时,自动重试一次,间隔很短。
  2. 替代技能:A技能失败后,寻找功能相近的B技能。比如"订单进度查询"失败时,可以退化到"基础订单信息查询",给出用户一个粗略答复。
  3. 如实告知+转人工:如果兜底也失败,把决策权交还给用户,而不是让Agent硬编一个答案。

第3级兜底看起来简单,却是整个系统可信度的关键。用户不怕Agent说"我现在查不了",怕的是Agent若无其事地编一个假物流状态。所以兜底链的终局一定是诚实的降级。

6. 半年踩坑复盘:技能化改造最容易翻车的几个地方

6.1 技能描述写得太"文艺",模型理解出现偏差

我们最早有个"生成感谢信"技能,描述写了"以温暖真诚的语气表达感激"。结果用户只是在闲聊时说了句"谢谢客服",Agent居然也触发了这个技能,生成了一封三百字的感谢信。问题就出在描述没有给出精确的触发边界。后来我们把描述改成"当用户对人工客服或机器人客服表达明确谢意,且用户意图仅为结束对话时,可以用简短文字回应,不要生成长篇感谢信"。改完就好多了。描述要具体到能当测试用例的程度,而不是当宣传文案。

6.2 参数Schema和LLM输出之间,永远有一层"翻译"

我们早期认为LLM既然是按JSON schema提取参数的,结果应该很规范。实际上LLM总会给你意外,字段名驼峰和下划线混用、枚举值多了空格、日期格式走了三四套。后来我养成了一个习惯:每个技能的参数校验器里都做一层兼容映射,把常见的变体格式归一化。

这个兼容层不能偷懒,一旦用户输入千奇百怪的格式,LLM提取结果也会跟着千奇百怪。所有参数校验器都按"宁可多容忍一步,也不让执行层报错"的原则来写。

6.3 技能越拆越细,结果LLM反而不会选了

技能拆到很细以后,候选技能数量上升,描述重叠率也会上升。"查订单金额"和"查订单优惠明细"这两个技能,在我们的技能库里一度让模型频繁选错。后面我们做了一轮技能合并:把区分度不足的技能合成一个,内部再通过参数分支处理。

如果候选技能之间的描述重叠度过高,说明它们不应该分成两个技能。合并为主、细拆为辅,这是我们现在的原则。技能数量控制在人类大脑可以浏览一遍的规模(六十个以内),不仅对LLM选择友好,对我们自己维护也是必要的。

6.4 技能执行产生副作用,重放了才发现问题

改造初期,我们低估了技能副作用的可怕。比如"订单锁定"技能,执行后会把订单状态改成锁定。有次评测重复跑了两次同一用例,也不知道怎么触发了一次重放,结果把测试环境的订单锁定了一大堆。

后来我们给所有技能增加了is_mutating标记:读操作可以随意重试和缓存,写操作必须在日志里记录完整的幂等键,并且在重试前检查是否已执行过。技能设计阶段就要明确它是幂等的还是非幂等的,这决定了它在整个编排链中的位置。

6.5 技能版本管理混乱:上线了三个"订单查询"

技能多了以后,很容易出现改了一个技能逻辑,却在另一个Agent里引用了旧版本的情况。我们的教训是:每个Agent必须锁定SkillRegistry的版本快照,而不是引用一个全局动态变更的注册表。现在我们的做法是,每次发版生成一份lockfile,里面记录所有技能的版本号和参数schema哈希值。这样线上Agent不会因为你更新了技能库而意外改变行为。

最后分享一个小技巧

技能描述写完之后,花十分钟做一次"反向测试":把每个技能的描述反过来想,列出它不应处理的输入,然后喂给模型看它会不会误选。我们在这一步里至少发现过十几次边界问题,每次都能提前省下上线后的返工成本。技能清单本质上就是Agent的岗位说明书,它写得多清楚,Agent干活就有多靠谱。

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

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

立即咨询