ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Agent技能系统设计:从提示词工程到可扩展的智能体能力架构

Agent技能系统设计:从提示词工程到可扩展的智能体能力架构 如果你最近在捣鼓大模型应用大概率绕不开一个词agent-skills。但这个词在社区里被用得非常混乱有人把它理解成一个工具函数有人把它当成一套插件规范还有人觉得它就是给Agent写提示词。我自己的理解更倾向于把它看成一种设计模式把智能体需要的能力拆成一棵“技能树”每个技能都有清晰的触发条件、入参定义、执行逻辑和失败兜底让模型不再是靠prompt硬撑而是真的“会调用工具”。这篇文章我会把一套从零搭建的agent技能系统完整拆开来讲从设计思路到代码实现再到我在真实项目里踩过的坑全部记录下来给正在做智能体应用、AI助手、自动化工作流的工程师和独立开发者做个参考。这个内容能解决什么问题最简单直白地说当你发现自己写的prompt越来越长、模型回答越来越不稳定、一个功能改动要连带改十处逻辑的时候就是该引入技能系统的时候了。它能让Agent的能力边界变得清晰、可扩展、可观测也能让团队里不同人负责的不同能力模块互相解耦。文章里涉及的东西不依赖特定平台OpenAI的函数调用、开源的Agent框架、或者你自己维护的一套工具链思路都是通用的。1. 技能系统的整体设计思路为什么不能只靠提示词1.1 没有技能系统的时候Agent会变成什么样子我去年接了一个企业内部知识助手的项目初期版本完全没有技能系统的概念所有能力都靠一段超长系统提示词告诉模型“你可以查天气”“你可以查公司内部Wiki”“你可以帮忙创建Jira工单”。结果上线第一周就频繁翻车。典型问题有几个。第一是prompt膨胀系统提示词从800字涨到5000字模型注意力被稀释连简单的意图识别都开始出错。第二是能力边界模糊模型经常在没有必要的情况下调用工具比如用户问“今天几号”它非要查一次日历再回答增加了延迟和费用。第三是排障极难用户说“帮我创建一个工单”模型可能返回一段格式错误的JSON也可能直接在文本里写了“我无法访问Jira系统”你根本不知道是哪一步出了问题。这些问题本质上是同一个根源把“能力”和“行为规则”混在了一起。提示词里写了太多“怎么做”却没有一个结构化的机制让模型知道“什么时候能做、怎么做、能做得多可靠”。技能系统要解决的就是把能力和行为解耦。1.2 技能系统设计的四个核心原则我后来重新设计这套系统时给自己定了四个原则这也是我推荐任何人开始做agent技能设计时最先想清楚的东西。第一原则单一职责。每个技能只做一件事。查天气的只管查天气创建工单的只管创建工单不要做一个“万能函数”把所有操作揉在一起。单一职责的好处是便于测试、便于复用也便于模型理解“这个技能到底在什么场景下该被触发”。第二原则可声明化配置。所有技能都有一份结构化的声明包含名称、描述、入参Schema、执行策略、超时和错误处理。这份声明既是给模型看的也是给调度系统看的同时还能自动生成开发文档。模型不需要读长篇人类语言去推断“这个函数怎么用”它拿到的是机器可读、无歧义的接口定义。第三原则可观测。每个技能的调用过程都要有完整的日志链路模型为什么选择了这个技能、传入了什么参数、执行结果是什么、耗时多少、是否有重试。没有可观测性Agent一旦在复杂多轮对话里做错决策调试成本会高到怀疑人生。第四原则可降级。任何一个技能都可能失败网络超时、鉴权过期、下游服务返回垃圾数据都可能是常态。技能系统必须设计兜底逻辑当某个技能失败时Agent是重试、换个技能还是直接告诉用户“这个操作暂时不可用”。我见过太多人只写了正常路径一旦出错整个对话流程就僵死了。1.3 技能的分类方式信息型、操作型和复合型在设计技能树的时候我会把所有技能先分成三类这分类方法帮我省了很多纠结的时间。信息型技能负责获取数据特点是只读、无副作用。典型例子是查库存、查天气、搜索网页、查询数据库。这类技能的重点是返回格式的规范化和去重因为模型可能为了一个简单问题触发多次查询。操作型技能会产生状态变更比如创建订单、发送邮件、修改配置。这类技能必须做参数校验、权限校验、幂等性和二次确认。复合型技能则是一个编排层把多个技能按流程串起来比如“下单”可能需要查库存、算价格、生成订单、发通知四个技能按序执行。分类的意义不只在概念上它直接决定了技能执行引擎怎么写。信息型技能可以并行、可以缓存、失败了可以重试操作型技能必须串行、不能自动重试、必须有明确的确认环节。这个区分不做好后面写调度器时一定会陷入混乱。2. 技能定义与注册怎么让模型理解并调用技能2.1 技能注册表一切能力的唯一入口我先说一个建议不管你是用OpenAI函数调用还是自己搭的Agent框架都建议维护一份独立的技能注册表而不是把函数直接散落在代码各处。注册表就是一个中心化的清单包含每个技能的唯一ID、版本号、启停状态、所属模块、负责人等等。我通常用一份YAML或Python字典来管理这个注册表然后通过装饰器自动注册到运行时。这样有几个实际好处新增技能时不用改调用方的代码技能下线时只需要改状态团队协作时每个人负责自己的模块不会互相覆盖。# 技能注册表的简化示例 SKILL_REGISTRY {} def register_skill(skill_id, description, version1.0.0): def decorator(func): SKILL_REGISTRY[skill_id] { func: func, description: description, version: version, enabled: True, } return func return decorator这个注册表还要承担一个任务生成模型调用时的function列表。也就是说每次请求模型时你不是手动写死有哪些函数可用而是从注册表里动态筛选出当前可用的技能转成JSON Schema格式传给模型。这样做的好处是调整技能状态时不需要改线上代码直接关掉开关就生效。2.2 JSON Schema写给模型看的接口说明书技能声明的核心是一份JSON Schema它决定了模型能不能正确“理解”这个技能。很多人在这一步容易踩坑写得过于抽象。比如描述写成“获取天气信息”模型很可能在其他需要天气数据的场景里绕过它或者错误地给它传参。正确做法是用“触发场景示例”来写描述让模型知道什么时候应该选这个技能、什么时候不该选。{ name: query_weather, description: 查询指定城市当前天气和未来3天预报。当用户询问天气情况、气温、降雨概率时使用该技能。用户只询问日期不涉及具体城市时使用用户历史默认城市若无法获取则要求用户提供。, parameters: { type: object, properties: { city: { type: string, description: 城市名称例如北京、上海。必须使用标准城市名不要使用缩写。 }, date: { type: string, description: 查询日期格式为YYYY-MM-DD。缺省时默认为今天。 } }, required: [city] } }这里想强调一个细节参数描述里要写清楚“什么情况不算”——比如“如果用户没有明确说城市名不要猜直接问用户”。很多时候模型选错技能不是因为能力不够纯粹是你描述里的歧义太多。把边界条件写清楚比多写十几个技能更管用。2.3 上下文注入与技能裁剪还有一个比较隐蔽的问题上下文窗口是有限的你不能把所有技能一股脑全部塞给模型。我在自己的系统里加入了一个“技能裁剪”模块根据对话的前几轮内容和用户当前意图预筛选出可能相关的技能再把这些技能的Schema传给模型。比如用户聊的是“快递到哪了”那天气、新闻之类的技能就不需要进入候选列表。裁剪策略的核心是一个轻量级的分类器可以是一个小模型也可以是一组关键词规则。我的做法是用一个embedding模型对用户query和技能描述分别做向量化每天离线算好技能向量上线时做一次相似度检索取Top N技能。这个方案的优点是快速且几乎零成本比每次请求都让大模型来选择“可能用到的技能”要便宜得多。3. 实操在真实项目里落地一套技能执行引擎3.1 技术选型手写调度还是用Agent框架这一节我来讲点实际操作。假设你现在要做一套技能系统摆在面前的第一个问题是手写还是用现成的框架我试过两条路都走。早期图省事用了某个重量级Agent框架确实很省力——基本的函数调用、消息循环都帮你做好了。但一进到线上就发现几个问题框架内部黑盒太多排障难自定义能力受限想加个“技能失败后自动换个思路”的策略需要在框架的缝隙里塞代码还有一个现实问题框架升级频繁每次版本更新都可能影响现有技能。后来我做了一套轻量级的技能执行引擎核心代码其实很少只有三部分技能注册表、调用循环、日志追踪。这个引擎不依赖于某个特定模型只要能解析模型返回的tool_call就能跑起来。如果你也是做垂直场景的Agent我建议先手写一套最小的执行循环把逻辑完全掌握在自己手里等确实需要复杂编排时再引入更重的框架。3.2 完整技能示例查天气与查订单状态来看两个实际技能一个信息型一个操作型正好能覆盖两类场景。查天气技能我们上面已经定义了Schema下面是它的执行函数。这个函数内部会做参数清洗、调用第三方天气API、解析返回结果最后把结构化的天气数据返回给Agent。register_skill( skill_idquery_weather, description查询指定城市当前天气和未来3天预报, version1.0.0 ) def query_weather(city: str, date: str ) - dict: city clean_city_name(city) # 把北京市清洗成北京 if not city: return {success: False, error: 缺少有效城市名} try: weather_data weather_api.fetch(city, date) return { success: True, data: { city: weather_data[city], date: weather_data[date], temperature: weather_data[temperature], condition: weather_data[condition], humidity: weather_data[humidity], } } except TimeoutError: return {success: False, error: weather service timeout}再来看一个操作型技能。查订单状态这个技能看似简单但涉及订单号校验、权限校验和状态快照返回必须要严谨处理。真实场景里订单号是强格式约束如果模型传了一个明显不合法的订单号你要在函数入口就拦住而不是等下游报错。register_skill( skill_idquery_order_status, description查询用户的订单物流状态。当用户询问订单、物流、快递信息时使用。需要用户登录态。, version1.1.0 ) def query_order_status(order_id: str) - dict: if not valid_order_id(order_id): return {success: False, error: 订单号格式不正确} if not current_user_has_permission(order_id): return {success: False, error: 无权访问该订单} status order_service.get_status(order_id) return {success: True, data: status}你有没有注意到两个函数的返回结构是一致的都有success字段来标记成功失败错误信息在error字段里成功数据封装在data里。这非常重要——统一的返回结构让Agent进行二次推理时不需要分情况解析减少幻觉。3.3 技能执行层的并发、超时与重试策略技能执行层是Agent调用真实世界的桥梁这里的健壮性直接决定用户体验。我分享一套自己在生产环境验证过的参数配置。超时设置信息型技能默认5秒操作型技能默认15秒。执行引擎用asyncio实现并发调用多个信息型技能可以并行发出比如用户同时问天气和新闻两个技能并行执行总耗时取两者最大值而不是串行叠加。操作型技能严格串行因为后一个步骤依赖前一个步骤的结果。async def execute_skill(skill_call): skill_meta SKILL_REGISTRY.get(skill_call[name]) if not skill_meta or not skill_meta[enabled]: return {success: False, error: 技能不存在或已下线} timeout 15 if skill_meta[type] operation else 5 try: result await asyncio.wait_for( skill_meta[func](**skill_call[arguments]), timeouttimeout, ) return normalize_result(result) except asyncio.TimeoutError: return {success: False, error: f技能执行超时{timeout}s}重试策略我给了差异化处理。信息型技能允许最多2次自动重试重试间隔递增操作型技能默认不自动重试因为自动重试一个“创建订单”的操作可能造成重复下单。这时候需要给模型返回错误信息让它判断是换个方式还是让用户确认后重试。幂等问题也在这里处理如果订单系统生成了幂等ID那操作型技能也可以安全地重试但这取决于下游系统能力不要一律默认。3.4 技能结果回填与多轮对话状态还有一个经常被忽略但影响很大的点技能执行完了怎么把结果放回对话上下文里。我见过最简单的做法是直接把技能返回的JSON字符串拼接进消息然后再次请求模型。问题在于这样会给上下文塞进大量冗余字段——比如查了个用户信息返回了一整行几十个字段的JSON里面大多数模型根本用不到。我在实践中的做法是所有技能返回前经过一个“结果裁剪器”只保留当前对话轮次可能用到的字段对长文本字段做摘要加上一个meta信息标注“这是技能query_order_status返回的结果真实可靠”帮助模型区分“这不是我自己推理的这是外部客观数据”。多轮对话状态的管理同样重要。比如用户先说“帮我查一下北京天气”你调用了query_weather下一轮用户说“那上海呢”如果你的技能系统没有记住上一轮的城市字段模型就不知道该用“上海”替换哪个参数。我的做法是维护一个轻量的记忆槽slot memory在每轮处理前把上一轮的语义槽注入模型的上下文这样模型能正确推导出“用户想查上海天气”并调用相同技能。这个机制让多轮对话的连续性提升非常明显比单纯堆历史消息更高效。4. 常见问题与故障排查那些让我凌晨爬起来改代码的坑4.1 模型选错了技能但不是模型的锅有一次线上反馈用户问“你们营业时间是几点”Agent竟然调用了查天气技能。我刚开始也觉得是模型理解出错后来把请求日志拉出来一看原因是技能描述里写了“当用户询问时间相关问题时可以查询天气”这行描述导致模型把“营业时间”的“时间”和天气时间关联了起来。这类问题在技能数量多了以后会频繁出现。排查思路应该是先看模型选技能时给它的“候选技能描述”到底长什么样而不是直接怀疑模型能力。检查候选技能列表是否加了无关技能检查每个技能描述的边角词是否会造成歧义把“不应该触发”的负样本写进描述中。我的一个经验技巧是给每个技能加一个“avoid_when”字段专门描述不该触发该技能的场景这个字段在生成模型prompt时会被明确标注为排除条件。4.2 参数幻觉模型编造了不存在的订单号参数幻觉比选错技能更令人头疼。用户说“帮我看看我上星期的订单到哪了”技能系统需要订单号才能查询但模型很可能直接编一个看似合理的订单号传进去然后得到一个“订单不存在”的结果体验非常糟糕。这个问题的根本解决思路不是让模型“别编”而是让模型“有权拒绝”。我在系统提示词里加了一条规则当技能的必要参数无法从用户对话中确定时必须向用户提问确认而不是自行推断。同时每个技能Schema的required字段要写清楚模型上手时就会明确“这个参数没有我不应该瞎填”。再配合我们对订单号的强格式校验即使模型传了非法参数也会在第一层被拦截返回“无法确认订单号请问您可以提供具体的订单编号吗”而不是把错误甩给下游。4.3 技能返回的格式不稳定导致模型理解困难同一个技能在部分场景下返回的数据结构不一致这种情况很隐蔽。比如查天气技能正常情况返回温度和天气状况但遇到极端天气时返回字符串“当前有暴雪预警”而不是结构化JSON模型在回答时就会措手不及甚至会把这句话误当成用户消息。解决思路我在前面的示例里已经做了铺垫所有技能必须返回统一结构error时也要保证字段存在。不仅是统一字段名还要对预发布版本做回归测试。我的项目里写着一条流水线脚本会把所有技能的关键输入跑一遍动态校验返回结果是否符合预定义的JSON Schema不符合就直接标注为红色告警阻止发到生产环境。# 技能返回格式回归测试示例 python scripts/validate_skill_responses.py --skill query_weather --env staging这个脚本看起来不起眼但它帮我拦截过很多次“只在某个小数据分支才会触发”的格式问题。自动化校验是Agent工程里性价比极高的投入。4.4 技能循环调用模型被同一个技能卡死技能循环是另一类灾难性问题。有一次用户问了句“你觉得我今天适合穿什么”Agent连续三次调用查天气技能每次都拿到一样的温度数据然后在结果里打转浪费了三次API调用和大量时间。这不是模型“笨”而是执行引擎缺少循环检测。我在执行循环里加了一个简单的计数器同一个技能在同一轮对话中最多被调用2次超过这个次数就强制中断该技能调用并让模型换一条推理路径。更高级一点的做法是记录每次技能调用的输入输出指纹如果两次调用的输入完全相同直接返回上一次结果并提示模型“该数据已经获取过不要重复查询”。这类机制在大模型应用里叫“工具调用防抖”一套几行代码的逻辑能省下不少成本。4.5 性能与成本问题技能调用频率失控技能调用在高并发场景下的成本控制是个容易被低估的问题。我见过一个团队上线了Agent知识问答每个问题平均触发4次技能调用其中两三次其实是冗余的比如用户问“苹果今天的股价”Agent先调搜索确认苹果是家什么公司再调股票API查询再调新闻API找相关新闻最后又调一次股票API确认价格。一次问答的模型成本直接翻倍。排查思路是分析调用序列日志找出相似模式。我当时发现“二次确认型冗余”最多就是Agent在拿到准确数据后出于“多验证一次更保险”的推理逻辑再调用同一个技能。我在系统提示词里明确写了一条“当你已经通过技能获得某个数据并且该数据足够回答用户问题时不要再次调用相同的技能。重复调用会增加延迟和成本。”就这么一句直白的话调用量降了约30%。另外对信息型技能加上结果缓存同一个用户短时间内重复查询同样内容直接走缓存不重新执行也是省钱的常见手段。问题现象常见根因排查方法兜底策略模型选错技能技能描述有歧义或候选列表污染查看调用日志中传给模型的技能列表和描述给技能加“avoid_when”排除说明参数幻觉必要参数缺失时模型自行推断检查函数Schema的required字段是否明确入口参数做强校验缺参数时返回提问引导返回格式不稳定技能执行分支未统一返回结构用脚本做回归测试校验返回JSON Schema强制所有技能统一success/error/data结构技能循环调用执行引擎缺少循环检测统计同一轮中技能调用次数单技能单轮最多调用2次输入指纹去重成本飙升冗余技能调用和无效查询分析技能调用序列日志提示词明确禁止重复查询信息型技能加缓存5. 技能系统的可观测性与评估体系5.1 从技能维度做全面日志追踪技能系统的可观测性不能只停留在“有没有报错”要从每次技能调用的完整生命周期去追踪。我给每次技能调用分配一个trace_id贯穿从模型决定调用技能、到参数生成、到函数执行、再到结果回填的全过程。这个trace_id会进入日志系统按技能ID、用户ID、会话ID分别建立索引。怎么利用这些日志做分析我给大家介绍几个实际有价值的分析维度。技能使用频率分布是基础维度能让你知道哪几个技能是高频核心哪几个技能上线半个月都没人触发。缓存命中率能反映信息型技能的结果复用情况。参数成功率看的是模型生成参数的合法率如果某个技能的参数合法性很低大概率是Schema设计有歧义。平均调用耗时则直接关系到用户体验如果一个技能经常超时你就需要考虑是优化函数本身还是提前给用户缓冲提示。5.2 回放复盘每个错误场景都是改进机会日志追踪除了监控用途还有一个核心能力是复盘回放。当用户反馈Agent表现不好时我能在日志系统里从trace_id出发完整回放当时的对话上下文、模型决策、技能调用和返回结果。我团队有个固定的复盘流程每周挑3个失败案例逐个拆解问三个问题——模型是否在信息不足时做了错误假设技能描述是否造成了误解执行链路是否在某个环节引入了噪音这个问题驱动的方式比单纯看指标更有成效因为技能系统的改进方向很大程度是由失败案例的共性决定的。坚持几个月之后你会发现自己写的技能描述越来越精准模型的选路成功率也会肉眼可见地提升。5.3 用A/B测试验证技能系统改进最后想聊一个心态上的问题技能系统不是写一次就能一劳永逸的它需要持续迭代。很多人改技能描述时是“拍脑袋式”修改改了之后也不知道是变好还是变坏。我建议给关键技能建立简单的A/B测试机制。比如同一个技能线上版本A描述偏简短新版本B描述增加了触发示例和边界说明那么可以把用户请求随机分流到A和B两组对比哪组的技能选择准确率更高。如果使用第三方的模型服务也可以用prompt版本管理去做这类测试。技能系统做久了真正比拼的就是谁的迭代速度快、谁更清楚每个小改动背后的因果。把评估体系搭好让每次改动都有数据反馈才能越做越顺手。我自己的体会是agent-skills这套东西真正难的不是写函数而是建立一套能让模型和工程双方都舒服的“契约”。技能描述本质上是在给模型写文档你写得越清晰模型表现就越稳定技能执行层是在给现实世界写接口边界定得越严格系统就越不容易在意外场景里倒塌。如果你现在正准备给自己的Agent加技能我的建议是先别急着写代码拿起笔把“这个技能在什么场景下触发、什么场景下绝对不能触发、需要什么数据、失败了怎么办”这四个问题想清楚后面至少能少走两个月的弯路。
RELATED READING

延伸阅读

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