ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

AI Agent技能化封装:从提示词到可复用技能块的工程实践

AI Agent技能化封装:从提示词到可复用技能块的工程实践 做AI Agent开发这段时间我最大的感触不是模型多聪明而是工程侧的能力复用一直没有被善待。最开始我在一个项目里堆了几十段提示词效果还行可每次需求一改就要翻遍config和prompt后来我把工具函数和system prompt缝合在一起命名为agent-skills——一套技能化组织方式。简单说就是把Agent能执行的每一个动作都封装成带有元信息、参数契约和执行体的技能块让模型在运行时自主选择、组装。这篇文章就把我这套技能的协议设计、注册机制和完整落地案例摊开讲希望能给正在做Agent应用、同样被复用性和可维护性折磨的朋友一点参考。1. 我为什么要折腾技能化能力复用问题的三次重构1.1 第一次重构提示词全部收敛到配置文件当时我手上有个多场景客服Agent要处理退换货、物流查询、优惠券解释、售后投诉等一堆意图。第一版的做法很朴素把所有场景的说明和示例对话全部塞进system prompt然后在代码里用if-else判断意图再拼接不同的prompt模板。上线后跑了两周Bug数量还能忍但每次运营提新需求我都要从一大坨prompt里找到对应段落改完还要担心影响其他场景。为了缓解这个问题我做了一次重构把所有提示词抽成YAML配置文件按场景划分成块代码里通过场景名称加载。这次重构确实让找提示词变得简单了但问题只是从代码里翻变成了配置里翻。更麻烦的是不同场景的输入参数经常错位比如物流查询要订单号退款要订单号加退款原因这些逻辑散落在prompt里模型经常给我编一个不存在的字段。1.2 第二次重构引入function calling可复用性依然不够后来大模型平台开始支持function calling我立刻把客服系统中的查订单、计算退款金额、提交工单这些动作做成了函数让模型自己决定何时调用。第一次跑通的时候我觉得终于对了但很快发现新的问题函数数量一多命名和描述开始失控。我最初定义了三十多个函数有的是动词短语有的用名词有的参数用驼峰有的用下划线。模型经常选错函数比如用户问退款到账时间它去调了查询订单状态。最让我头疼的是一个函数往往只服务于一个场景换个项目根本没法复用。比如查询订单状态这个函数在客服项目里绑定的是自家商城API拿到另一个项目里就废了。函数是函数能力是能力中间缺了一层统一的封装。1.3 第三次重构技能化把能力当作可路由的模块真正让我下决心做技能化是因为同一个团队另一个项目要复用我的订单查询能力。如果直接把函数复制过去等于又把上面的坑踩一遍。于是我把这些能力重新梳理定义了一套技能概念每个技能是一个自包含的模块对外暴露统一格式的元信息和参数契约对内封装具体的执行逻辑。这套思路就是我说的agent-skills。它不绑定任何具体的Agent框架也不依赖任何一个模型厂商核心只有三个约定技能必须有清晰描述、参数必须用JSON Schema声明、返回值必须带上模型可判断的额外信息。有了这三个约定能力就从一个函数升级成了可被模型理解、可被业务复用、可被编排组合的模块。做完这次重构后我发现新增一个场景时大部分情况下只需要把已有技能重新组合一遍最多写一两个新技能开发成本明显下降。这也是我写这篇文章的原因技能化的思路本身不复杂难点在于协议怎么定、注册机制怎么设计、踩坑怎么避。下面按我实际落地的顺序展开。2. 单个skill的结构设计这段协议决定模型能否看懂你2.1 技能的三段式结构元信息、参数契约、执行体一个技能在我这套体系里由三部分组成元信息、参数契约、执行体。元信息包括技能的唯一标识、描述文本、版本号参数契约是对输入参数的JSON Schema声明执行体就是真正完成工作的Python函数。我通常用装饰器把它们组装在一起看起来大概是这样的skill( namelist_directory, description列出指定目录下的文件与子目录返回文件名称、大小和最后修改时间。适合在查看文件夹内容、搜索文件、整理文件等场景中使用。如果只需要统计文件数量请优先使用count_files技能。, parameters{ type: object, properties: { path: {type: string, description: 要查看的绝对路径例如 /Users/me/downloads。}, hidden: {type: boolean, description: 是否包含隐藏文件默认false。} }, required: [path] }, version1.0.0 ) def list_directory(path: str, hidden: bool False) - dict: # 实际执行逻辑 entries scan_dir(path, include_hiddenhidden) return {ok: True, data: {entries: entries}}我用这种方法定义技能已经跑了大半年。元信息是给模型看的决定了它在工具列表里能不能找到你参数契约是给模型填的决定了它调用时能不能生成合法参数执行体是给系统跑的决定了能力本身能不能稳定交付。这三者缺一个都会出问题没有元信息模型看不见你没有参数契约模型乱传参执行体不健壮一切白搭。这里有个容易被忽略的点参数契约不只是声明类型description字段同样重要。很多模型生成参数时依赖属性描述来理解这个参数应该填什么如果你的属性描述写的是路径两个字模型大概率会填一个相对路径然后你的执行体就炸了。我在属性描述里会写清楚格式、范例、默认行为这比在系统提示词里写一堆规则管用得多。2.2 description的真正作用它决定了模型在哪个分支里选你我把技能描述称作被模型检索的候选项。模型在决定调用哪个技能时会把你的描述和其他技能的描述放在一起做相似度比较。所以描述写得好不好直接决定命中率。我自己做过一次笨拙但有效的对比测试同一个列目录能力第一版描述就一句话列出目录内容第二版描述加上使用场景、排除场景、和相似技能的区别。在同一个小型Agent项目上各跑100次调用第二版的正确选择率从64%提升到89%。差距就是这么大。写描述有几个可复用的经验第一必须包含什么时候用也就是典型场景第二尽量写一句什么时候不要用这能大幅减少模型误选第三如果存在相似技能明确给出区分信号。比如list_directory和count_files我在前者描述里写如果只需要文件数量请使用count_files模型基本不会再选错。这相当于把路由信息直接嵌进候选列表里比任何后置校验都便宜。参数命名也同样重要。不要用p、src、des这种缩写也不要用中文拼音。模型API大多以英文训练语料为主参数名应当用完整的英文单词或短语。我习惯用source_path、target_dir、timeout_seconds这种带语义的名字配合属性描述模型生成的参数质量会明显更稳。2.3 返回值要带上判定信息而不是裸数据技能返回值这个细节决定Agent能不能形成调用-观察-决策的闭环。早期我写工具函数能return list就return list能return bool就return bool。结果模型拿到结果后经常一脸茫然因为裸数据本身不包含接下来该怎么办的线索。后来我统一了返回格式至少包含ok字段表示执行是否成功data字段放核心数据hint字段放机器可读的下一步建议。例如move_file技能成功移动文件后返回{ ok: True, data: {count: 1, from: /old/path/a.txt, to: /new/path/a.txt}, hint: 文件已就位可以继续整理下一个文件或调用report_summary生成汇总报告。 }hint不参与业务逻辑但会成为模型推理下一步动作的重要依据。执行失败时也要返回结构化错误信息最好带上修复建议。比如没有权限访问该目录请尝试切换到家目录下的其他路径模型看到这种信息后经常能自己修正参数再试一次而不至于反复调用同一技能撞同一堵墙。3. 注册表与动态加载让几十个技能做到即插即用3.1 一个简单的注册表装饰器实现单个技能定义好之后怎么让Agent运行时拿到全部技能列表我的做法是在内存里维护一个注册表SKILL_REGISTRY装饰器执行时自动把技能注册进去。核心代码很短SKILL_REGISTRY {} def skill(name, description, parameters, version1.0.0): def decorator(func): entry { name: name, description: description, parameters: parameters, version: version, func: func, } SKILL_REGISTRY[name] entry return func return decorator有了注册表之后生成供模型API使用的tools参数就非常简单了。直接把每个注册项转换成统一的schema循环遍历SKILL_REGISTRY.values()即可。这意味着每次新增一个技能就是在代码里加一个带装饰器的函数不用再手工维护一份工具清单清单和实现永远同步。注册表还有一个好处统一管理技能名称的冲突检测。如果两个技能重名装饰器可以抛异常阻止启动。我遇到过因为复制粘贴导致技能名重复的问题当时没有冲突检测模型有时选A技能却执行了B技能的代码排查了很久才发现。后来我在注册逻辑里加了一行重名判断这个坑就彻底堵上了。3.2 目录扫描与动态加载技能多到一个文件装不下时当技能数量超过二十个再全部堆在同一个skills.py里就有点拥挤了。我改成按领域拆分目录文件管理放一个模块网络请求放一个模块数据处理放一个模块。为了让每个新模块不需要手动import我用pkgutil和importlib做了目录自动扫描import importlib import pkgutil def load_skills(package_nameskills): package importlib.import_module(package_name) for mod in pkgutil.iter_modules(package.__path__): importlib.import_module(f{package_name}.{mod.name})在应用启动时调用load_skills()所有技能模块就会被加载装饰器自然执行注册表里就有了全部技能。这个方案的扩展性很直观团队新成员想加一个技能只要在skills目录下新建一个py文件写好装饰器函数系统就能自动发现其他什么都不用动。这比在配置文件里逐个声明要省心得多。不过动态加载也有一个必须注意的约束不要在技能模块的顶层代码里做重量级初始化比如连数据库、加载大模型、启动HTTP服务。因为import语句在加载时会执行模块顶层代码一旦某个技能初始化卡住整个Agent都启动不了。我习惯把需要初始化的资源放到技能首次执行时懒加载或者在模块里提供一个init的钩子由编排层显式调用。3.3 技能依赖与版本看起来麻烦但必须考虑的边界技能不是孤立的有些技能会依赖其他技能。比如move_file执行完后正常下一步是调用classify_by_extension或者report_summary。我处理这种依赖有两种方式一是只把建议下一步写进hint完全不强制二是在技能元信息里增加depends字段让编排层在启用技能前检查依赖是否齐全。我倾向于第一种方式。原因是技能之间的组合路径往往不是单向的同一个技能可能出现在多条链路上与其写死依赖关系不如把决策权交还给模型。depends字段可以用来做静态校验比如A技能代码里调用了B技能的注册名那注册B之前不应该启用A。我在启动加载时跑一遍这个检查能提前发现低级错误。版本问题更实际。技能的参数结构一旦变化已经在进行中的Agent对话如果继续用旧schema可能生成非法参数。我在技能元信息里加了version字段每次改动参数契约就递增版本号。当前对话何时占用技能版本、何时允许切换到新版本这个逻辑还没做到很完善目前的方案是新对话统一用最新版本长任务超过N轮时编排层重新拉取一次技能列表。对于大多数中小型项目这个粗粒度控制已经够用了。4. 落地案例用一组文件管理技能搭一个本地文件整理Agent4.1 场景拆解先列技能清单理论说了这么多不如直接看一个完整案例。需求很常见整理Downloads文件夹把超过7天没动过的文件按扩展名归档到不同子目录最后生成一份整理报告。我没有一上来就写代码而是先拆技能清单。基于我已有的技能库这个场景可以复用三个通用技能需要新写两个场景技能list_directory列出文件、read_file_meta读取文件的修改时间和大小、move_file移动文件这三个可以复用classify_by_extension根据扩展名归类、report_summary生成汇总报告这两个是新需求。整体技能清单如下技能名用途是否复用list_directory列出指定目录下的文件和子目录复用read_file_meta读取指定文件的修改时间、大小等元信息复用move_file将文件移动到目标目录复用classify_by_extension根据扩展名给文件分配目标子目录新增report_summary汇总本次整理的文件数量、移动路径、剩余文件新增技能拆分的原则是一个技能只做一件事。不要把按日期归档和按扩展名归档混在一个技能里否则模型在中间态就没法灵活调整。宁可技能粒度细一点让编排层通过多次调用组合出复杂行为也比一个大函数堵在那里强。4.2 关键技能的实现与注册classify_by_extension的核心逻辑其实很简单读取文件后缀映射到目标子目录比如.pdf去documents、.jpg去images、.zip去archives。但为了让模型能用好它我把不认识的扩展名也做了兜底统一放到others目录。实现如下skill( nameclassify_by_extension, description根据文件扩展名返回应归档到的目标子目录。适用于按类型整理文件时为单个文件确定目标路径。已支持常见文档、图片、压缩包格式未知类型归入others。, parameters{ type: object, properties: { filename: {type: string, description: 需要分类的文件名字例如 invoice.pdf。}, base_dir: {type: string, description: 归档的根目录最终结果会拼接在该目录下。} }, required: [filename, base_dir] } ) def classify_by_extension(filename: str, base_dir: str) - dict: import os ext os.path.splitext(filename)[1].lower().lstrip(.) or unknown mapping { pdf: documents, doc: documents, docx: documents, txt: documents, md: documents, jpg: images, jpeg: images, png: images, gif: images, zip: archives, rar: archives, 7z: archives, } target mapping.get(ext, others) return {ok: True, data: {target_subdir: target, target_path: os.path.join(base_dir, target)}, hint: 可以使用move_file技能将文件移动到target_path。}move_file我们复用已有技能但我特意更新过它的返回值在hint里告诉模型如果已经完成本轮所有文件移动建议调用report_summary生成整理报告。这样模型在完成多轮移动后自然会把流程推进到汇总环节而不是停在原地等新的用户指令。report_summary则负责收尾。它接收一个文件路径列表读取文件系统当前状态生成统计信息成功移动多少、剩余多少、目标目录分布如何。实现时我让它直接扫描base_dir下的子目录避免依赖前面技能执行过程中的内存状态这样即使重试或者中断报告依然是准确的。4.3 主循环让模型自己规划调用顺序技能都注册好之后Agent的编排层只需要做一个非常通用的事情把全部技能schema传给模型API然后在循环里处理模型返回的工具调用请求。以OpenAI兼容接口为例核心主循环大致是这样tools [entry[schema] for entry in SKILL_REGISTRY.values()] messages [{role: user, content: 整理我的downloads文件夹超过7天没动过的文件按类型移动到对应子目录最后给我报告}] for _ in range(MAX_STEPS): resp client.chat.completions.create( modelgpt-4o-mini, messagesmessages, toolstools, ) msg resp.choices[0].message messages.append(msg) if not msg.tool_calls: break for tool_call in msg.tool_calls: result execute_skill(tool_call.function.name, json.loads(tool_call.function.arguments)) messages.append({ role: tool, tool_call_id: tool_call.id, content: json.dumps(result, ensure_asciiFalse), })实际执行中模型会做出类似这样的决策链先调用list_directory查看downloads下有哪些文件然后对每个文件调用read_file_meta判断修改时间是否超过7天筛选出目标文件后逐个调用classify_by_extension和move_file最后调用report_summary输出整理结果。整个过程没有一个步骤是硬编码的完全由模型根据技能描述自主规划。MAX_STEPS这个参数非常关键。我一开始没设置上限结果有一次模型在检查文件-移动文件-再检查之间循环了三十多轮白白浪费token。现在我在所有Agent场景里都强制设置max_steps文件整理这种流程给8到10轮就足够。如果超过上限还没完成说明技能描述或者参数校验可能有问题需要人去排查而不是让模型继续空转。5. 实战中容易翻车的三个细节以及我的排查过程5.1 技能一旦超过20个模型就开始选择困难我把技能库从十几个扩展到三十多个之后发现工具调用正确率出现了肉眼可见的下降。具体表现是用户问帮我看看这个文件多大模型不去调read_file_meta反而调了list_directory用户说把图片归档模型调了move_file但我传的路径压根不对。我当时的排查步骤是这样的先把全部技能的描述打印出来逐条过了一遍发现不少描述的前半段高度相似都以列出/读取/获取开头。模型在面对这些相似候选时基本靠猜。然后我做了两件事一是改造描述让每个技能在开头就点明最典型的触发场景并补上不适用场景二是把高频通用技能放在tools列表靠前的位置因为不少模型在选择工具时对排在前面的候选有一定偏好。这两个改动上线后误选率明显下降。但我也意识到技能数量继续增长的话光靠描述优化是不够的更彻底的做法是先加一个意图路由技能由它判断当前请求应该走哪组技能子集再动态传对应的tools参数。这个方向我还在实验中目前二十多个技能的场景靠描述优化已经能维持90%以上的命中率。5.2 参数缺省导致的连锁错误一个死循环实例有一次我在测试环境跑技能编排发现模型反复调用move_file但每次都失败。打开日志看到错误信息全是destination目录为空。我再往前翻发现模型只传了source_path没传target_dir。排查根因花了点时间问题出在参数schema的required列表漏掉了target_dir。我定义move_file参数时把target_dir标成了可选本意是允许某些自动归档场景下由技能内部按规则推导目标目录。但模型在用户指令里没有明确目标路径时会倾向于忽略这个可选参数而我的技能代码在目标目录为空时又没有兜底只能报错。更麻烦的是我的错误信息只说了目标目录不能为空并没有告诉模型该换什么参数于是模型不停重试同一个错误参数。修复方案有两层。第一层在schema里把target_dir设为必填除非是在smartshelf这种内部调用场景否则不让模型跳过它。第二层错误信息里附上修复建议未提供target_dir时可以调用classify_by_extension生成目标目录然后用move_file移动。这样模型失败一次后就知道该补充什么信息而不是原地打转。这个案例让我总结出一条原则技能的每一个错误返回都应该是可行动的。所谓可行动就是模型读到之后能知道自己下一步改什么。如果只是返回抽象错误码比如E_INVALID_ARGS模型只能一脸懵然后要么放弃要么死循环。5.3 观测与日志没有trace就等着被问题淹没技能化开发进入多Agent协作阶段后最痛苦的事情就是排查模型为什么在那个时间点调用了那个技能。传统print调试在Agent场景下基本不可用因为你根本追不上模型的决策节奏。我后来给技能装饰器统一加了一层日志埋点每次技能被调用时记录时间、技能名、入参、出参、耗时、返回码并附加一个session_id用于串联整条链路。改造量不大但排查效率提升非常明显。下面是日志里一条典型记录的样子[tool-call] sessionf3a2... skilllist_directory params{path: /Users/me/downloads, hidden: false} oktrue duration0.012s [tool-call] sessionf3a2... skillread_file_meta params{path: /Users/me/downloads/invoice.pdf} oktrue duration0.003s有了这类日志当模型行为异常时我可以快速定位是哪一步决策出了问题是技能描述误导、参数校验太松还是模型本身乱来。如果你们项目用了更完整的可观测性框架可以在技能调用点手动上报span效果更好。但无论如何技能调用日志是Agent项目的基础设施越早加越省心。还有个容易被忽略的小技巧在Agent编排层我会把模型每次返回的tool_call列表原样记录下来包括模型自己写的思考过程。很多平台API能看到模型为什么选这个工具这条信息比任何日志都值钱分析误选问题时一定要保存下来。最后再分享一个我一直在用的习惯每次给技能库新增技能时我会在description末尾写一句不适用场景哪怕只有半句。这个动作花不了几秒钟但能在后面避免大量模型选错技能的排查。技能化的收益就是在这种一个又一个的小细节里积累出来的。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进