ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

智能体技能化设计:从大模型对话到工程化实操的进阶指南

智能体技能化设计:从大模型对话到工程化实操的进阶指南 1. 先搞明白agent-skills 到底是什么1.1 从“会聊天”到“会干活”差的就是技能最近大半年我一直在捣鼓智能体项目越做越觉得单靠大模型的对话能力远远不够。你让模型写一首诗、总结一篇文章它表现得确实惊艳但一旦让它去操作一个数据库、调用一个接口、读一个本地文件它瞬间就露馅了不是因为模型笨而是因为它根本不知道该“怎么动手”。而“agent-skills”这个思路就是来解决这个问题的——它的核心想法非常简单把智能体要干的各种事情拆成一个一个可复用、可描述、可校验的“技能模块”再把这些技能以标准化的方式交给大模型调度。这样一来模型负责“想”技能模块负责“做”各司其职整个智能体才真正从“会聊天”进化到“会干活”。我第一次接触这个思路是在尝试做一个内部运维助手的时候。当时的需求是让智能体去查日志、看服务状态、执行一些预定义好的脚本。一开始我把所有逻辑全塞到提示词里结果模型经常误解命令参数出错的概率高得离谱。后来我换了思路不再让模型“凭空决定”怎么操作而是给它一份带清晰参数说明的“技能清单”模型只负责从清单里挑选合适的技能、填入正确的参数。这一改整个系统的可靠性直接上了一个台阶。agent-skills这个标题本质上就是这种工程化思路的集中体现。这个方案适合谁如果你也在做智能体相关项目或者想把大模型接入现有业务系统比如做一个自动处理工单的机器人、一个能查库的业务助手、一个能自动跑报表的分析工具那“技能化”这条路几乎是绕不开的。它跟LangChain里的工具、Function Calling、MCP这类概念是一脉相承的但agent-skills更强调的是“技能”这个抽象层的设计与管理而不是仅仅停留在“让模型调一次函数”。1.2 技能模块的底层结构很多人容易把“技能”理解成“一个函数”这个理解不能说错但不全面。函数只是“实现”技能还包含“描述”“参数协议”“校验规则”“执行上下文”等多个部分。我做了几个项目之后总结出一个相对完整的最小结构大概是这样的技能名称一个简短、语义明确的标识比如view_service_status它会被大模型“看到”所以命名必须直观。技能描述一句话说明这个技能能干什么、在什么场景下用。描述写得好不好直接决定模型能不能在关键时刻选中这个技能。参数协议定义需要哪些入参、每个参数的类型和取值范围一般用JSON Schema来描述。执行函数真正干活的代码接收上面定义的参数执行后返回结果。返回格式规定返回内容的结构让模型能稳定解析结果。这个结构看似简单但实际操作中特别容易出问题。描述写得太含糊模型就会在无关场景下调用这个技能参数协议定义得不严格就可能产生脏数据甚至危险操作。可以说agent-skills的准入门槛不高但要做好做到能稳定跑在生产环境里每一个字段都值得反复打磨。2. 技能库的设计思路2.1 技能粒度太大太小都麻烦在规划技能库之前最需要想清楚的问题就是一个技能到底应该拆多细这个是纯经验活没有绝对标准但根据我这几个项目的教训可以给出一些判断依据。如果技能拆得太粗比如把“查询工单”“修改工单”“关闭工单”捆成一个技能那表面上看清单很短很简洁但模型在调用时很容易“过度执行”——它只想查一条工单却发现技能附带了修改能力一旦参数被误填后果就是灾难。反过来如果拆得太细比如把“读取文件”和“解析文件”拆成两个技能又会陷入另一个困境模型需要多次调用才能完成一个本来很简单的任务不仅性能差而且每一步都可能出错错误叠加起来很难排查。我个人的经验是以“一个可独立验证的业务动作”为最小单位。比如“查询工单详情”是一个技能“修改工单状态”是另一个技能“生成工单报表”又是一个技能。每个技能完成一个动作动作的前置条件和后置结果都必须清晰。拆分完之后我自己会做一个“用户旅程测试”模拟几个典型请求看模型需要多少次调用才能完成如果超过三次才完成一个很直接的任务我会重新考虑是否有些技能可以适当合并。另外还有一点值得强调技能的抽象级别也和调用者有关。如果智能体面向的是普通用户技能可以稍微粗一点比如“查看本周天气”如果面向的是技术人员技能可以更底层更贴近操作原语。agent-skills的优势就在于它允许你在同一套架构里维护不同抽象级别的技能然后通过“技能分组”来约束模型的选用范围。2.2 技能描述写给模型看的说明书如果说代码是写给机器看的那技能描述就是写给模型看的。我早期吃过的亏几乎都跟描述写得不仔细有关。刚起步时我为了图省事描述只写一句“查询用户信息”结果模型在用户问“这个用户上次登录是什么时候”的时候完全没有联想到这个技能因为描述里没有任何跟“登录时间”相关的关键词。后来我把描述改成“查询用户的基本资料、注册时间、最近登录时间、账户状态等信息适用于身份核实、活跃度分析等场景”命中率立刻好了很多。写技能描述有几个可复用的套路列出这个技能适用和不适用的场景帮助模型排除错误选择。明确指出关键参数的含义和边界比如“日期格式必须是YYYY-MM-DD”。描述中带上常见的同义表达比如“查询订单”“查看订单”“订单状态”都指向同一个订单查询技能避免模型因为措辞差异选错工具。如果技能会执行危险操作比如删除数据描述里一定要加上警告词让模型在下手前再三确认。但同时也要防止描述过冗。我见过有人把技能描述写成几百字的论文结果模型在长上下文里根本抓不住重点选技能的准确率反而不如短描述。好的描述应该像一份电梯演讲用最短的篇幅把“做什么、什么时候用、注意什么”说清楚。我的经验是控制在100到200字之间比较合适特殊情况可以适当放宽。2.3 技能组合让基础技能编排成复杂流程单一技能解决单点问题而真正的价值在于组合。在agent-skills的架构里技能之间的编排有两种常见方式一种是由大模型动态决定调用顺序另一种是预先把多个技能编排成一个“流程技能”。两种方式各有适用场景。动态编排适合探索性、开放性任务比如“帮我分析一下最近一周的销售数据”模型可能需要先调用查询技能再调用统计技能再调用图表生成技能每一步都由模型根据中间结果决定下一步。这种方式的灵活性最高但稳定性和可控性相对弱任何一个环节出现错误后面的流程都会连锁出错。预先编排则适合确定性强的重复任务比如“每日例会纪要生成”一定是先拉取消息记录再提炼要点再写入文档。这种流程可以直接写死把三个技能顺序调用串成一个新的技能。好处是稳定、可测试、可观测缺点是灵活度低。我的建议是“混合编排”把核心链路做成预设流程把边界场景交给动态决策。打个比方预定流程是“标准生产线”动态决策是“特殊情况处理通道”两者结合才能在稳定性和灵活性之间找到平衡。这个思路你在设计agent-skills的调度层时一定要考虑进去不然技能多了以后编排逻辑会变成一团乱麻。3. 核心实现流程3.1 先定义技能注册表我实现agent-skills的第一步永远是建立“技能注册表”。注册表的核心作用就一个把散落在代码各处的技能统一收集起来让调度中心能清晰地看到有哪些能力可用。我用的是Python所以这里就按Python的生态来写。注册表的设计并不复杂关键是把“元信息”和“实现”解耦。我定义一个基础的数据结构from dataclasses import dataclass, field from typing import Callable, Any, Optional dataclass class Skill: name: str description: str parameters: dict handler: Callable[..., Any] tags: list[str] field(default_factorylist) timeout: int 30 requires_confirmation: bool False这个Skill类里的每个字段对应着一套运行时的行为约束。name会被大模型当作“工具名”来理解description是选择依据parameters则直接被序列化进Function Calling的JSON Schemahandler是真正被执行的那段代码timeout是执行超时限制requires_confirmation则标记危险操作是否需要二次确认。设计这个结构的时候我刻意让每个字段都贴近实际执行所需而不是为了“面向对象而面向对象”。然后是注册表的容器我习惯用一个全局的Registry对象来管理class SkillRegistry: def __init__(self): self._skills {} def register(self, skill: Skill): if skill.name in self._skills: raise ValueError(fDuplicate skill name: {skill.name}) self._skills[skill.name] skill def get_skill(self, name: str) - Optional[Skill]: return self._skills.get(name) def list_skills(self) - list[dict]: return [ { name: s.name, description: s.description, parameters: s.parameters, } for s in self._skills.values() ]为什么做注册表而不是直接写一堆函数因为有了注册表之后调度层就可以动态地拿到全部技能列表然后和大模型的Function Calling接口对接。大模型会先看到所有技能的名称和描述再根据用户的问题输出一个结构化的“技能调用意图”比如哪个技能、带什么参数。注册表的存在就是让这个“选型-调用”的过程变得透明、可追踪。3.2 技能描述与触发的设计实现技能描述怎么写前面已经讲了原则这里给出一个实际例子。假设我们要实现一个“查看服务状态”的技能那么注册时的内容可以是这样def view_service_status(service_name: str): # 这里实际去查询服务状态比如请求健康检查接口 result query_health_endpoint(service_name) return {service: service_name, status: result} skill_status Skill( nameview_service_status, description( 查看指定服务的当前运行状态包括健康检查结果、进程是否存活。 适用于排查服务故障、确认服务是否正常启动等场景。 如果不清楚服务名称先调用 search_service 技能确认。 ), parameters{ type: object, properties: { service_name: { type: string, description: 目标服务名称例如 api-gateway、user-service } }, required: [service_name] }, handlerview_service_status, timeout15 ) registry.register(skill_status)这段代码看起来简单但有几处细节值得展开。首先是描述里的“搜索服务”交叉提示这等于在告诉模型不确定参数值的时候先去调用另一个技能来澄清这个做法能明显减少因参数错误导致的失败。其次是parameters里的description也同样重要它帮助模型在填参数时理解应该填什么格式的内容。我建议参数的描述里都带上示例值效果比单纯说类型好很多。在触发阶段如果你的接入方式是基于OpenAI兼容的Function Calling那么你只需要把registry.list_skills()转成对应的tools数组即可。模型返回的tool_call会带function.name和function.arguments你再用这两个字段去Registry里找到对应的handler把arguments解析成字典后传进去最后把handler的返回值再作为“工具结果”回传给模型。这就是一个完整的技能调用闭环。3.3 执行链路与上下文传递很多初写agent-skills的人会忽略一个关键点技能执行完之后结果怎么回到大模型那里直接print出来显然不行必须通过返回值传递而且这个返回值会拼接到对话上下文里。所以执行链路的正确性是整个系统的命脉我一般会专门写一个调度器来处理。调度器核心逻辑不复杂但要注意几个分支情况def run_skill_with_tracking(registry, skill_call): skill_name skill_call[name] arguments json.loads(skill_call[arguments]) if isinstance(skill_call[arguments], str) else skill_call[arguments] skill registry.get_skill(skill_name) if skill is None: return {error: fSkill {skill_name} not found} if skill.requires_confirmation: # 这里插入人工确认流程 confirmed request_confirmation(skill_name, arguments) if not confirmed: return {error: User cancelled the operation} start_time time.time() try: raw_result skill.handler(**arguments) except Exception as e: return {error: fSkill execution failed: {str(e)}} finally: duration time.time() - start_time log_usage(skill_name, arguments, duration) # 对结果做一次性裁剪防止超长返回撑爆上下文 return truncate_result(raw_result, max_chars2000)这里有两个细节是运维级项目里必须考虑的。第一个是超时处理技能函数可能因为外部接口慢而卡住所以执行侧一定要用async或ThreadPoolExecutor包一层超时控制第二个是结果截断模型上下文窗口是有限的如果一个技能返回10万字的日志直接塞回去不仅浪费token还可能让模型“迷失”在无关信息里。我会在返回前做结构化压缩只保留摘要、错误码、关键字段完整结果写入外部存储通过摘要引用。另外日志记录是绝对不能省的。每一次技能调用谁调的、传了什么参数、花了多久、返回了什么都应该记录到日志系统里。我之前见过有项目出了线上事故却无法复盘就是因为日志里根本没有技能调用的详细记录。后来我强制要求所有技能入口都走调度器统一打日志排查问题的效率提升了一个量级。3.4 多技能协同的一致性问题当agent-skills涉及的技能越来越多尤其是需要在一次任务里调用多个技能时就会碰到“一致性问题”。举个例子一个“生成月度报表”的任务先要查订单数据再要统计收入最后要写文件。如果“查询订单”成功了但“统计收入”因为数据缺失失败了那这次任务算成功还是失败要不要重试重试的话从哪个步骤开始我的处理方式是引入一个“有状态任务上下文”。每轮技能调用都归属于一个任务ID任务里维护一个“已完成步骤”的记录。如果某个步骤失败我会让模型看看失败原因如果没有不可恢复的错误就尝试从失败点重试如果已经写入了部分数据先走“回滚技能”清理现场再重新执行。这个过程很像微服务里的Saga模式只是这里的“服务”换成了技能。刚开始做的时候我并没有这么严谨事实证明偷懒会付出代价。有一次技能在执行到一半时抛了异常系统没有回滚结果数据库里留下几条半成品数据后续统计全部乱掉。后来我专门为写操作类技能增加了“事务补偿”设计比如一个技能是先创建订单再扣库存那必须要配套一个“取消订单并回补库存”的补偿技能。补偿技能不一定每次都会执行但必须在架构上留好位置这样当主流程出错时补偿流程可以及时接管。4. 实际落地中被问得最多的几个问题4.1 模型就是选错技能怎么办模型选错技能通常不是模型本身太笨而是我们的技能设计给模型制造了太多干扰。我整理过三种最常见的情况你们可以对号入座。第一种是技能描述太相像。比如同时存在“查询当前活跃用户数”和“查询累计注册用户数”两个描述里都含“用户数”模型就很容易张冠李戴。解决方案是突出场景差异在描述里写明“当前活跃用户数用于实时监控累计注册用户数用于统计报表”这样歧义就大大降低。第二种是长尾技能被淹没技能清单一长模型对尾部技能的注意力就会下降。这个问题的解法是分组按业务域把技能分成“订单域”“用户域”“财务域”调度时先根据意图选域再在域内选择技能。第三种是模型因为上下文token限制根本没有接收到全部技能定义。这时候可以考虑动态裁剪技能列表只把和当前会话最相关的30个技能传给模型降低选择难度。如果以上都做了还是选错那就是技能命名本身的问题。我把“命名可预测性”看得很重名字里一定要包含领域高频动词和对象比如list_orders、refund_order要比do_thing_1这种含糊的名字可靠得多。4.2 技能调用结果不稳定格式总变大模型生成的参数是概率性的同一个技能可能这次参数格式对下次就错了。面对这个问题单纯靠“给模型写清楚JSON Schema”往往不够。我的做法是增加一个“参数校验与归一化层”在调用handler之前先做一层清洗。def normalize_arguments(schema, raw_arguments: dict) - dict: normalized {} props schema.get(properties, {}) for key, prop in props.items(): value raw_arguments.get(key) if value is None: if key in schema.get(required, []): raise ValueError(fMissing required argument: {key}) continue prop_type prop.get(type) if prop_type string: normalized[key] str(value) elif prop_type integer: normalized[key] int(value) elif prop_type number: normalized[key] float(value) elif prop_type boolean: if isinstance(value, str): normalized[key] value.lower() in (true, 1, yes) else: normalized[key] bool(value) elif prop_type array: normalized[key] value if isinstance(value, list) else [value] else: normalized[key] value return normalized这个小函数解决了我很多实战中的“蠢问题”。比如模型传了个字符串“2024-05-01 12:00:00”到一个需要时间戳的技能里直接报错但通过归一化层我可以统一解析成时间戳再传给handler。不要迷信模型它不会因为提示词写了“必须传int类型”就100%传int。所有的入参都必须经过校验、转换、再进入业务逻辑。此外我还会给技能返回值定一套规范格式比如成功返回{code: 0, data: ...}失败返回{code: 非0, error: ...}。这样无论是调度器还是回传模型看到的都是统一结构解析成本大幅降低。4.3 并发场景下技能互相踩脚怎么办智能体一旦变成服务就会被多个用户同时调用。这时候如果多个任务同时对同一个资源做“读改写”操作就会出现互相覆盖的问题。举个例子两个会话同时执行“给同一个用户加积分”的技能如果两个任务都先读出当前积分再各自加100分后写回那么最终结果不是加了200而是只加了100。这个问题的根源是读改写不是原子的。解决方案至少有三种一是给关键技能加分布式锁二是把写操作改为原子更新SQL而不是“先查后写”三是引入版本号做乐观锁写之前拿版本号写的时候检查版本号是否变化变化了就放弃重试。我比较推荐对agent-skills里的“写操作”类技能默认使用“原子操作优先”的策略。能用一个SQL完成的更新就不要拆成读和写两步交给模型去编排。因为模型那边的编排环节本身就容易出问题能减少一步就减少一步。等技术成熟之后再考虑把更大的流程开放给模型自主编排。4.4 技能执行失败后的重试策略失败重试绝不是一个简单的“再调一次”。这里面有个隐蔽的坑如果技能是“非幂等”的操作比如“扣款”“发送短信”“创建订单”同样的请求执行两次会产生完全不同的后果。所以设计技能时就需要在规划阶段把“幂等性”考虑进去。我的做法是给每个写操作技能增加一个request_id参数由调度器在每次任务开启时生成技能执行时检查这个request_id是否已经被处理过。如果处理过直接返回上次的结果不再实际执行。这样即使调度器因为网络超时重试也不会造成重复操作。幂等设计做完重试策略才敢放心写。重试的逻辑我一般放在调度器层面遵循“指数退避抖动”的原则第一次失败等1秒第二次等2秒第三次等4秒最多重试5次同时每次加入随机抖动防止多个任务在同一个时间点同时重试把下游接口打崩。这里还要特别提醒一点如果重试的是外部HTTP接口一定要给每个请求设置超时时间。我见过下游服务假死导致智能体线程池被打满的事故原因就是请求一直没有超时。5. 扩展方向从“能用”到“好用”5.1 技能的自学习与自动推荐agent-skills跑了一段时间后你会发现日志里沉淀了大量“用户意图-技能调用”的样本。这些数据完全可以反哺到技能库里做两件很有价值的事情技能埋点分析和技能推荐。技能埋点分析就是统计每个技能的调用频率、成功率、平均调用耗时、是否经常被修正参数。如果一个技能调用成功率持续偏低一般意味着描述不准确、参数协议让模型困惑或者技能本身设计有问题。这时候就应该启动“技能体检”根据日志反馈修改描述、调整参数甚至下线低频技能。另外把高频组合挖掘出来比如“查订单”和“查物流”经常被连续调用那我就可以把它封装成一个组合技能“查询订单及物流信息”减少一次模型决策提升响应速度。技能推荐则可以理解为“意图Pilot”当用户输入一个问题时系统先通过语义匹配给出最可能的三个技能候选让模型优先从候选里挑选而不是让模型在几百个技能里大海捞针。这个机制实现起来不复杂用一个embedding模型把用户query和技能描述都向量化然后算个余弦相似度准确率在中型技能库上已经非常可观。5.2 多智能体之间的技能共享最后一个想聊的扩展方向是“技能的市场化”。如果你所在的团队有多个智能体每个智能体都有各自的技能库那未来很自然会走向“技能共享”。就像手机上的应用商店技能可以被打包、发布、订阅智能体A可以调用智能体B发布的一个技能只要权限允许。这个方向实现时最需要解决的是“技能描述的可移植性”和“运行时依赖的隔离性”。技能描述是为了让任意智能体都能理解这个技能应该在什么场景下用所以必须约定一套通用规范。运行时隔离则是防止别的智能体调用技能时破坏宿主环境容器化或者进程隔离是比较稳妥的方案。我目前在自己的项目里已经试着搞过简单的技能跨智能体调用效果还不错但离真正完善还有距离。agent-skills这条路说新也新说传统也传统。它本质上就是把“让模型自己发挥”变成了“给模型搭好舞台、限定剧本”在可控和智能之间找一个平衡点。如果你也在做类似的智能体项目我的建议是先别贪多求全从10个核心技能起步跑通闭环再慢慢扩展。技能库不是越大越好而是越精准越好把一个技能做扎实胜过堆砌十个半吊子技能。
RELATED READING

延伸阅读

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