Agent技能化这件事,我琢磨了很久。早期做Agent时,我把所有能力都塞进System Prompt里,结果每个场景都要重写一遍,维护成本爆炸。后来我把能力拆成独立的、可注册、可发现的“技能”模块,整个系统才真正活过来。这篇文章就是围绕agent-skills的思路,聊聊我踩过的坑和沉淀下来的工程实践,希望对你搭建自己的Agent技能体系有参考价值。
1. 先想明白:Agent为什么要“技能化”
1.1 从“会聊天”到“会干活”的鸿沟
现在的LLM越来越会“说”,但“做”的能力始终隔着一步。我们可以让模型生成一段漂亮的文字、一份周密的计划,但要它真正操作数据库、调用接口、读写文件,单靠提示词是做不到的。你需要把“动作”包装成语义明确、参数规范、可被模型理解和调用的模块,这个模块就是技能(Skill)。
我在早期项目里,把工具调用直接写死在业务代码里,每个工具配一段大段的JSON Schema描述。一开始只有三五个工具,勉强能跑。到了十几个工具时,模型开始频繁“选错工具”,甚至自作主张拼接参数。后来我意识到问题不在模型,而在我的工具组织方式太原始了。把工具升级为“技能”,用统一的结构去描述能力边界、输入输出和生效条件,整个系统的准确率才拉回来。
1.2 技能化与工具调用的本质区别
很多人觉得“技能”就是“工具”的另一个说法,实际没那么简单。工具是一种无状态的能力暴露,比如“给我返回天气”、“执行这个SQL”,而技能至少包括三个层面的设计:
- 触发条件:什么场景下该用这个技能,什么场景下不该用
- 使用前提:调用前需要满足哪些前置条件,需要哪些上下文
- 结果处理:调用完成之后,如何把结果映射回对话或业务逻辑
举个例子,“查询订单状态”是一个工具,“处理用户查询订单诉求”是一个技能。后者不仅包括查询本身,还包含了意图识别、参数抽取、异常兜底、结果格式化这一整套逻辑。技能比工具更厚重,但它换来的是稳定和可控。
1.3 技能化与Prompt工程的分工
Prompt决定了模型“怎么想”,技能决定了模型“做什么”。如果你的Agent只做纯文本任务,比如写文章、做翻译,那Prompt工程基本够用。但一旦涉及多步骤操作、外部依赖、状态变更,就必须把动作抽成技能。我的经验是:凡是需要经过网络、文件、数据库或计算引擎的能力,一律技能化;凡是纯粹的文本风格转换,才继续留在Prompt层。这个边界划清楚之后,排错成本大幅下降。
2. Agent技能体系的核心设计思路
2.1 技能的本质:用自然语言包装可执行能力
技能的本质是“用自然语言包装可执行能力”。模型的推理引擎负责理解用户的模糊诉求,技能库负责把理解转化为精确的执行。关键是那个“包装”的接口,也就是描述和参数schema,一定要同时被人和模型读得懂。
一个被忽略的细节是:描述中一定要写清楚“何时不该用”。比如你有一个“python代码执行器”技能,如果你只写“可以运行Python代码”,模型会在任何涉及计算的问题上都优先选它,哪怕只是简单的加减法,结果反而绕了远路。如果描述里加上“仅当用户明确要求执行代码或需要复杂计算时使用,简单运算不要在代码执行器中做”,模型的行为明显收敛了。这算是我最早学到的技能工程教训之一。
2.2 技能的分类与边界划分
我在项目里把技能分成四层,每层的更新频率、稳定性要求都不一样:
- 基础原子技能:对应单个确定性的操作,比如读文件、写文件、数据库查询。这类技能追求单一职责,参数越少越好,可以被上层技能复用。
- 业务复合技能:把若干原子技能编排成一段流程,比如“导出月报”会依次执行查询、聚合、生成表格、发送邮件。复合技能内部有状态流转,需要设计好异常分支。
- 决策辅助技能:这类技能不直接改变外部世界,而是帮模型提升推理质量,比如“代码静态分析”“数学表达式验证”。它们的输出往往不直接展示给用户,而是喂回给模型。
- 交互确认技能:当操作有不可逆影响(删除数据、覆盖文件、对外发消息)时,需要独立的确认机制。这个层级的技能负责与用户完成确认对话,避免模型自作主张执行危险操作。
边界划分的原则只有一条:一个技能内部的步骤联系足够紧密,且它们对外呈现为同一个语义单元时,才应该合并;否则保持拆分。宁可拆得过细,也不要为了好看而粗粒度聚合。
2.3 技能与Memory、Workflow的关系
技能不是孤立的模块,它和Memory、Workflow紧密配合。简单的区分是:
- Memory负责“记住什么”,包括用户偏好、中间结果、长期事实
- Workflow负责“按什么顺序做”,定义流程节点和转移条件
- Skill负责“单个节点里怎么做”,是执行原子,是可替换的最小单元
我把Workflow理解为“技能编排的骨架”,把Memory理解为“技能运行时的共享白板”。技能在执行过程中可以读写Memory中的临时变量,但要注意读写权限的控制,否则技能之间容易互相污染。比如技能A写了一个result字段,技能B读的时候因为字段名冲突拿到脏数据,这个问题在复杂流程里非常容易出现,设计时要对Memory的key建立命名规范。
3. 技能库的工程化实现
3.1 技能注册:把能力变成可发现的资源
技能的“注册”环节,是技能库能否高效运转的起点。我在项目里的做法是定义一个基础的数据结构,然后统一注册到一个Registry里。
from dataclasses import dataclass, field from typing import Any, Callable, Optional @dataclass class Skill: name: str # 技能唯一标识,见名知意 description: str # 给模型看的自然语言描述,包含触发场景和禁忌 parameters_schema: dict # JSON Schema 风格的参数定义 execute: Callable[..., Any] # 实际执行函数 version: str = "0.1.0" # 版本号 tags: list[str] = field(default_factory=list) requires: list[str] = field(default_factory=list) # 前置技能/权限 timeout: float = 10.0 # 超时控制 safety_level: str = "safe" # safe / confirm / restricted参数Schema用JSON Schema的格式,好处是有现成的校验库,而且模型对JSON Schema的理解最稳定。早期我试过用类内注解自动生成Schema,后来还是改回手写,因为Agent技能场景下,description的措辞对模型选型的影响远超参数类型本身,手写的可控性更好。
注册动作本身很简单:
class SkillRegistry: def __init__(self): self._skills: dict[str, Skill] = {} def register(self, skill: Skill): if skill.name in self._skills: raise ValueError(f"Skill {skill.name} already registered") self._skills[skill.name] = skill def unregister(self, skill_name: str): self._skills.pop(skill_name, None) def get(self, skill_name: str) -> Optional[Skill]: return self._skills.get(skill_name) def all_skills(self) -> list[Skill]: return list(self._skills.values())注册阶段就要做完整性校验,别拖到运行时。比如execute必须是可调用对象、parameters_schema必须能被jsonschema库编译、description不能少于一定长度。这些校验规则看着琐碎,但在技能数量上来之后能救你一命,否则你会在上千个技能里排查一个“为什么模型总不调用”的问题,最后发现是描述里混入了乱码。
3.2 技能发现:让Agent在合适的时机看到合适的技能
技能库的规模直接决定了“发现”策略。概括来说,当技能少于20个时,把全部技能的描述塞给模型,让模型自行选择,问题不大。但是当技能数量超过50个之后,描述文本会挤占模型的有效上下文,导致选择准确性断崖式下降。
我目前实践的方案是三级过滤:
第一级是“关键词预筛”。用户在对话中产生的意图,会先经过一个轻量级的意图识别,比如模型先输出一个简短的结构化标签,再根据标签召回候选技能;也可以配合本地Embedding做语义检索,把技能的name、description、tags向量化,用余弦相似度召回到TopK。
第二级是“上下文相关性重排”。召回的候选技能过多时,再用模型按当前对话上下文重新排序。这里有个小技巧:把候选技能的name和description拼接成紧凑列表,让模型只输出排序结果,避免模型“在候选之外自己编新技能”。
第三级是“动态裁剪”。重排之后,只取前N个技能进入最终工具列表,其余全部隐藏。我在项目里的N通常设为6-8,视模型的上下文窗口和任务复杂度浮动。
三级过滤的意义在于:你在“让模型有足够选择空间”和“让模型不被过多选择干扰”之间做了工程妥协。实测下来,从“全量塞入”切换到三级过滤后,我的技能准确调用率大概提升了十几个百分点。
3.3 技能调度与动态加载策略
技能不是所有时候都要常驻内存。重量级技能,比如Python解释器、多模态识别服务,如果全部预加载,启动慢、占资源不说,还容易互相干扰。我采用“懒加载+按需运行”的策略:注册的时候只登记元数据和加载方法,真正使用时才实例化执行器,用完之后及时释放。
调度侧还要考虑技能之间的依赖关系。技能A依赖技能B时,并非简单地把A的execute里直接调用B,而是要通过Registry显式获取B的实例。这样可测试性更强,还可以在中间挂上日志、监控和审计。
另一个容易踩的坑是超时控制。模型等技能执行结果,通常只有几十秒的耐心。如果技能卡死,整个对话就僵住了。所以每个技能必须有明确的timeout,超时后主动返回“执行超时”给模型,让模型决定是重试还是换方案。timeout的值要按技能的实际耗时去压测设置,统一设一个值是不可靠的。
还有并发策略:有些技能是线程安全的,可以并行执行,比如两个独立的查询;有些技能内部有状态,必须串行。我在每个技能上增加了一个concurrency字段,用“shared”和“exclusive”标记,调度器根据标记决定是否允许并行,避免状态污染和资源竞争。
4. 实操:从零搭建一个mini技能库
4.1 定义技能数据结构
基于上面的设计,我先从数据结构开始。下面的代码是我实际在项目中使用的核心模型,做了简化方便讲解:
@dataclass class SkillParameter: name: str type: str # string / integer / number / boolean / array / object description: str required: bool = True default: Any = None @dataclass class SkillSpec: name: str summary: str # 一句话摘要,用于快捷筛选 description: str # 完整描述,用于模型选择 parameters: list[SkillParameter] returns: str # 返回值说明 dependencies: list[str] = field(default_factory=list) timeout: float = 10.0 tags: list[str] = field(default_factory=list)summary和description是分开的。summary用于关键词预筛和向量召回,语言尽量精炼;description用于最终的选择,语言可以更详细,包含触发场景和反例。很多项目只保留一个长描述,导致预筛阶段效率低下,这是我反复优化之后才形成的双字段结构。
4.2 实现注册与发现机制
接着实现注册器和发现器。注册器负责索引维护,发现器负责“给定用户查询,返回候选技能”。
class SkillLibrary: def __init__(self): self._specs: dict[str, SkillSpec] = {} self._executors: dict[str, Callable] = {} def add_skill(self, spec: SkillSpec, executor: Callable): if spec.name in self._specs: raise ValueError(f"duplicate skill: {spec.name}") self._specs[spec.name] = spec self._executors[spec.name] = executor def get_spec(self, name: str) -> SkillSpec: return self._specs[name] def execute_skill(self, name: str, **kwargs): spec = self._specs[name] # 参数校验 errors = self._validate_parameters(spec, kwargs) if errors: return {"error": errors} executor = self._executors[name] result = executor(**kwargs) return {"data": result}发现器的实现取决于你有没有向量检索设施。如果要轻量起步,可以直接用关键词重叠的方式做召回:
def candidate_skills_by_keywords(query: str, library: SkillLibrary, top_k: int = 8): query_terms = set(query.lower().split()) scored = [] for spec in library._specs.values(): text = (spec.name + " " + spec.summary + " " + " ".join(spec.tags)).lower() overlap = len(query_terms & set(text.split())) scored.append((overlap, spec)) scored.sort(key=lambda x: x[0], reverse=True) return [spec for _, spec in scored[:top_k] if _ > 0]关键词召回简单有效,但泛化能力差,换个说法就召回不到。等基础设施到位后,建议换成Embedding检索。开源的text-embedding模型跑在本地也能得到不错的效果,不必一上来就追求工业级。
重排阶段,我会把候选技能的描述交给模型,让模型输出排序:
def rerank_with_llm(query: str, candidates: list[SkillSpec]) -> list[SkillSpec]: lines = [] for i, spec in enumerate(candidates): lines.append(f"{i}. {spec.name}: {spec.summary} / {spec.description[:120]}") prompt = ( "根据用户问题,从下列技能中选择最需要的技能,按相关性从高到低输出编号," "只输出编号,不要解释。\n用户问题:{query}\n技能列表:\n{lines}" ) response = call_llm(prompt) # 解析编号...重排这一步不能省。实测下来,前两轮过滤之后的候选集仍然会有大量噪声,如果不重排而直接全量塞给主模型,主模型的选择准确率会受噪声干扰。重排可以做得轻量,用一个小模型足够。
4.3 把技能接入Agent主循环
技能体系最终要接入Agent的主循环。我现在的标准流程是:
- 接收用户消息
- 从历史消息中提取相关上下文
- 用发现器拿到候选技能
- 把候选技能描述注入当前的工具指令区
- 让主模型根据候选技能决定调用哪个,生成参数
- 执行技能,把结果返回给主模型
- 循环直到模型认为任务完成
写个最小伪代码:
def agent_loop(user_input: str): history = get_history() candidates = find_candidate_skills(user_input) system_prompt = build_system_prompt(candidates) for step in range(max_steps): response = llm_chat(system_prompt, history + [user_input]) if response.has_tool_call(): skill_name = response.tool_name args = response.tool_args result = skill_library.execute_skill(skill_name, **args) history.append(tool_result_message(skill_name, result)) else: return response.content这个循环里的关键点在于系统提示词中候选技能的格式。我习惯用类似这样的结构:
Available skills: - `search_products`: 搜索商品列表。参数: `keyword`(string, 必填), `category`(string, 可选)。当用户想找某个商品时使用。 - `get_order_status`: 查询订单状态。参数: `order_id`(string, 必填)。当用户询问订单进度时使用。格式并不需要过于复杂,但顺序上要注意:把summary和description最长的一段放在最后,把最常用的几个放在最前。模型对开头和结尾的内容更注意,这种格式层面的小优化也能产生影响。
4.4 给技能配一个最小化的可观察层
接入之后,你还要让技能“可观察”。我给每个技能加了统一的日志记录:调用时间、参数摘要、返回码、耗时。这个日志不仅用于排错,也是后续优化技能描述和数量的依据。
具体实现上,可以给executor包一层装饰器或者中间件:
def instrumented(skill_name: str, executor: Callable): def wrapper(**kwargs): start = time.time() try: result = executor(**kwargs) status = "success" return result except Exception as exc: status = "error" raise finally: duration = (time.time() - start) * 1000 logger.info("skill=%s status=%s duration_ms=%.1f params=%s", skill_name, status, duration, safe_params(kwargs)) return wrappersafe_params要特别注意:不能把用户的敏感信息、长文本、model dump直接打进日志。我在生产环境吃过亏,一次日志系统收集了用户身份证全文,差点引发安全整改。后来一律用字段名+字段类型+截断长度来记录参数。
5. 踩坑实录:技能工程中的典型问题
5.1 描述写不好,模型就是不调用
最常见的现象是:技能代码完全没问题,但模型就是视而不见。原因几乎都出在技能描述上。整理几条心得:
- 描述里不要只说“能做什么”,还要说“在什么诉求下用”。比如“当用户提到退换货时优先使用本技能”,这句话的引导作用远大于“该技能用于处理退换货”。
- 如果两个技能边界模糊,要在描述里互相“排他”。比如“查询订单”和“查询售后单”,描述里都要写上“不要与另一个混淆”。
- 参数描述同样重要。很多参数填错其实是描述不清楚导致的,比如“order_id”要写清楚是“平台的订单号,通常为12位数字,不是商户订单号”。
5.2 技能爆炸:数量太多怎么收敛
技能增长到一定数量后,召回和重排的计算成本都会上升。更重要的是,相似技能之间会互相干扰。我采取的收敛手段:
- 每季度做一次技能审计,合并重复度高的技能,删掉调用量为零的僵尸技能。
- 建立“技能上限”安全阀:单业务域内的技能数超过30时,强制要求拆分业务域或做更细的分层。
- 复合技能优先于原子技能呈现给模型。我默认把原子技能设置为“仅供复合技能调用”,不进候选列表。这样模型面对的候选数量直接从几百降到几十。
5.3 技能冲突与优先级
当多个技能都能完成同一个任务时,模型可能会随机挑选,导致行为不稳定。我在技能定义里增加了priority字段,明确“同等地处理时,优先使用高优先级技能”。并在描述里写清楚“不要使用其他技能来完成与本技能相同的事”。另外,技能执行结果的返回格式如果不统一,也会造成后续处理混乱。规定所有技能返回值都是同一套结构,宁可损失一些自定义能力,也要保证主循环的解析逻辑简单、可预测。
5.4 测试覆盖怎么做
技能的测试和普通单元测试不一样。主要原因是“参数组合”实在太多,不可能穷举。我目前的做法是:每一轮技能变更都跑三类测试——参数校验用例、正常路径用例、异常路径用例;再配合一组“黄金用例集”做回归,里面都是历史上真实触发过bug的对话,用脚本自动跑一遍完整Agent流程,看行为是否退化。这套机制帮我在改一个技能时,及时发现自己把另一个不相关技能搞坏了。
6. 技能质量评估与持续迭代
6.1 技能质量的度量维度
技能质量很难用单一指标衡量,我平时重点盯四个维度:
- 调用准确率:模型调用了某个技能,调用本身是否符合用户意图。这需要人工抽检标注。
- 执行成功率:技能执行过程中出现异常的比例,这一项最容易被自动化监控捕捉。
- 参数正确率:调用时传入参数是否完整、正确。我见过技能本身没问题,但参数抽取持续出错的情况,说明描述和Schema都有改进空间。
- 用户满意度:技能执行完成后,对话是否顺利结束,用户是否还要反复澄清。这个指标稍微滞后,但最能反映真相。
6.2 从日志和反馈中反推优化点
技能体系的优化是持续性的工作。我会定期做几件事:
- 每周导出调用日志,按技能维度统计成功率。
- 建立“无效调用”标记功能:人工在调试台看到模型调错了技能,一键标记,积累成负样本集,用于后续微调或检索排序优化。
- 对高频失败技能做“专项会诊”:往往是描述触达不够、参数设计不合理或者底层服务不稳定三者之一。
- 把用户的真实纠偏数据沉淀到测试集,防止同一个问题换个说法再次出现。
在这个过程中最核心的反思是:技能体系不是做一次就完了的,它应该像产品一样持续迭代。每一次用户反馈、每一次模型升级、每一次业务调整,都可能要求你增删改技能。设计时留下足够的扩展位、做好监控和治理,才算真正把Agent技能的潜力释放出来。
最后再分享一个细节:我习惯在技能描述末尾加一行“使用后建议向用户简要说明结果”,这行小小的引导能让Agent在技能执行后做一个简洁的收尾输出,用户体验会有肉眼可见的提升。技能体系里的很多优化都是这样,不花哨,但每一行都有实际回报。