Agent技能层设计:从提示词到稳定工具调用的工程实践 如果你最近在搭 AI Agent大概率会碰到一个让人心烦的现象提示词写得越来越长模型却越来越不听指挥。让它查资料它悄悄编了几个不存在的链接让它调工具它把参数传成一个根本不存在的东西。我在排查这类问题时最后几乎都会回到同一个关键点agent-skills也就是智能体的技能层。它解决的不是“模型会不会聊天”而是“模型能不能稳定地把活干完”。坦白说做 Agent 应用的前期很容易让人觉得“模型已经很聪明”真正做起来才会发现聪明和可靠之间隔着一整层工程。技能层就是用来填补这层差距的它把模型原本靠临时理解去做的事情变成一组预先定义、可复用、可验证的能力单元。这篇文章适合正在做客服机器人、自动化助手、报告生成这类项目的开发者也适合刚接触智能体开发、想搞明白为什么“提示词越加越多、效果越来越差”的新手。整篇不追求大而全只围绕一个最小可用的技能层实现把设计逻辑和容易踩的坑讲清楚。1. agent-skills 到底是什么先把概念还原成工程问题1.1 从“角色提示”到“技能体系”的演进早期我做智能体时习惯把所有能力都写进系统提示词类似“你是一个助手你可以搜索、读文件、写代码”。这个阶段的模型确实知道自己有什么权限但并不知道什么时候该用、用什么参数、失败了怎么办。就像给新员工一份岗位职责清单却没有对应的操作手册遇到复杂任务就只能自由发挥。自由发挥的结果有两种一种是模型把工具描述重新解释一遍然后自己造一个调用格式另一种是模型为了“完成任务”开始编造结果。有一次我需要模型读取一个本地 CSV 文件并做简单统计结果它在没有调用读取技能的情况下直接根据文件名猜了一组数据出来。不是模型笨而是它没有清晰的“技能使用边界”。要解决这个问题后面我把每个能力单独封装附上触发条件、输入输出、失败策略。模型不再需要从一大段提示词里推断规则而是在技能清单里做选择题。这样调整之后调用准确率提升得很明显至少它知道什么场景下该找哪把钥匙。1.2 技能、工具、插件和工作流四个词别混着用项目里经常听到有人把工具、插件、技能、工作流混着说结果方案设计得很混乱。我的理解是这样的工具是最小原子动作比如“打开网页”“读指定文件”“执行一段代码”技能是由一个或多个工具组合出来的完整能力比如“抓取网页并生成摘要”工作流则是多个技能按固定顺序的编排。插件更像分发载体它把若干技能打包起来方便安装和共享。如果只有工具没有技能等于每次都要让模型临时决定怎么组合工具这很考验上下文长度和模型推理。如果只有技能没有工作流碰到多步骤业务还得靠提示词硬扛。按这个分层去设计后面的维护成本会低很多。所以我的项目里技能是“描述 校验 实现 回退”的完整闭环而不是一句工具说明。每加一个新能力我都会先判断它到底该放在哪一层避免把工作流逻辑硬塞进技能描述里。1.3 技能的颗粒度为什么“会搜索”不是一个好技能这是我调整最多的部分。把技能定义成“搜索”看起来没错实际调用时会发现模型不知道该用哪个搜索入口也不知道结果要保留多少。颗粒度太粗等价于没有技能定义颗粒度太细技能列表会膨胀到模型都看不过来。我习惯把一个技能描述成“在什么场景下、为了解决什么问题、需要哪些参数、产出什么结果”。例如“按站点分类的网页信息抓取与摘要”明显比“网页抓取”更适合模型判断。命名、描述、参数、返回结构四者必须配套缺一个都会导致模型在关键时刻犹豫。颗粒度还有一个判断标准一个技能是否对应一个可以被验证的产出。如果调用完技能你无法判断结果是好是坏那说明技能边界还没划清楚。这时候不要急着加描述先回去把产出定义明确。2. 设计一个能被“稳定调用”的技能层本质是在设计约束2.1 技能描述的第一句必须回答“什么时候该用我”模型决定是否调用技能基本靠描述匹配。描述写得太抽象模型会忽略写得太具体其他相似场景又匹配不上。我试过几种描述风格后比较稳定的是“第一句写触发场景第二句写能力边界第三句写典型价值”。举个例子描述写成“当用户需要获取某个网页的正文内容并整理成结构化摘要时使用。适用于新闻、博客、文档类页面不适用于视频或交互式页面。”和“一个抓取网页并生成摘要的工具”相比前者明显更容易被模型命中。触发条件放在最前面因为模型做匹配时通常会先读开头部分。描述末尾最好加一句“不要在其他场景使用本技能”。这句提示看似多余却能挡住很多误调用。模型在上下文过长的时候也会出现“抓到一个词就触发”的情况反面限制能有效刹车。2.2 参数设计用类型、默认值和枚举去抵挡幻觉技能参数是模型幻觉的重灾区。字段类型写得不清楚模型可能把max_chars填成字符串把枚举值填成一个不存在的选项。我现在的做法是参数必须有明确类型可选参数必须有默认值枚举参数必须罗列合法值并且所有字段都要有中文注释。加上“required”标记也很关键。有些参数看似可选实际缺失后技能会执行失败不如直接改成语义明确的必填参数而那些真正可选的参数则在框架里做默认值兜底不让模型自由发挥。在实践中我会额外给每个参数设置一个“典型值示例”。比如url字段就写https://example.com/page这比单纯写“目标网页完整链接”更直观。模型看到示例格式后传参时会自动模仿参数跑偏的情况少很多。2.3 失败回退技能出错时模型不能原地崩溃技能执行一定会出错网络超时、页面结构变化、权限不足这些都会发生。如果没有回退策略模型就会陷入反复调用同一个技能的死循环或者干脆硬编一个结果。我现在会在技能定义里内置一个失败策略说明例如“当抓取失败时返回空结果并说明失败原因不要猜测内容”。在调用层我还会区分“技能执行失败”和“参数校验失败”。前者返回结构化错误码后者直接提示模型重新提供参数。这样模型能把错误信息回传给用户而不是自己脑补一个成功结果。这个设计有个额外的好处日志排查变得容易。以前模型调用失败后你不知道是工具本身挂了还是模型传参不对。现在错误码一区分问题定位时间能缩短一大半。2.4 上下文回流工具结果不能变成“外来物”工具结果返回主对话后如果格式和对话风格不一致模型经常会迷失。我在封装技能时会让结果统一成 JSON并让字段名保持一致的命名习惯。核心状态固定用result、error、data三层结构业务数据统一放在data里。另外要控制回流的信息量。之前有个抓取技能把整个网页原文丢回上下文结果后面的对话全被无关信息淹没。后来改成“只返回标题、摘要和三个关键词”上下文占用少了模型后续发挥也稳定得多。所以每个技能定义里我都会专门写一个result结构限制模型可读取的字段。这既是为了稳定也是为了省 token。长上下文的成本不止是钱更是模型注意力的稀释。3. 从零搭建最小可用的 agent-skills 运行体系3.1 用 JSON 定义可被模型解析的技能清单技能定义放在独立文件里而不是硬编码在提示词中是我最推荐养成的习惯。我常用 JSON 格式因为它天然支持嵌套结构也方便后续做版本管理和权限控制。每个技能至少包含id、name、description、params、result五个部分。下面是一个以“网页信息抓取与摘要”技能为例的最简清单{ skills: [ { id: web_fetch_summary, version: 1.0.0, name: 网页信息抓取与摘要, description: 当用户需要获取某个网页的正文内容并整理成结构化摘要时使用。适用于新闻、博客、文档类页面不适用于视频或交互式页面。, params: [ { name: url, type: string, required: true, description: 目标网页完整链接 }, { name: max_chars, type: integer, required: false, default: 2000, description: 摘要最大长度 }, { name: lang, type: string, required: false, default: zh, description: 输出语言 } ], result: { type: object, fields: [title, summary, keywords] } } ] }把描述写清楚模型在多数场景下都能做到不额外追问就直接调用。这个文件就作为“技能的单一事实来源”后续所有适配都从它生成避免同时维护多份不一致的文档。3.2 注册中心与调用分发把“描述”和“实现”解耦定义文件只负责描述真正的执行逻辑应该在注册中心里对应到函数。这样技能清单可以被模型读取并选择同时实现细节不暴露在提示词里。我维护了一个很轻的SkillRegistry类class SkillRegistry: def __init__(self): self._skills {} def register(self, skill): self._skills[skill.id] skill def list_skills(self): return [ { id: s.id, name: s.name, description: s.description, params: s.params, } for s in self._skills.values() ] def get(self, skill_id): return self._skills.get(skill_id)注册时就把技能描述和函数绑定在一起列表接口给模型看执行接口给内部调用。这么做还有一个好处未来在技能列表里新增或者下线能力只需要改注册表不用动对话主逻辑。3.3 一个完整案例实现“网页信息抓取与摘要”技能这个技能的核心流程是接收 URL抓取正文清理 HTML生成摘要。为简化我重点展示参数校验和调用分发部分。所谓校验不是只检查字段是否存在而是检查类型和枚举是否合法。我的校验思路大致是这样def validate_params(params_def, raw_params): cleaned {} for p in params_def: value raw_params.get(p[name]) if value is None: if p.get(required): raise ParamMissingError(p[name]) value p.get(default) if p[type] integer: value int(value) # 顺手做类型转换 if enum in p and value not in p[enum]: raise ParamEnumError(p[name], value) cleaned[p[name]] value return cleaned参数经过规范化后再交给执行函数返回统一 JSON。调用层还需要捕获两类异常并转成语义明确的错误码。下面是一个简单的分发逻辑def invoke(skill_id, raw_params): skill registry.get(skill_id) if not skill: return {error: {code: SKILL_NOT_FOUND, message: funknown skill: {skill_id}}} try: params validate_params(skill.params, raw_params) data skill.execute(params) return {result: ok, data: data, error: None} except ParamMissingError as e: return {result: error, error: {code: PARAM_MISSING, field: str(e)}} except ParamEnumError as e: return {result: error, error: {code: PARAM_ENUM, field: str(e)}}实际抓取部分通常由独立的fetch_html函数完成。抓回来后先去掉 script、style 等无用节点再按语义标签提取正文如果页面结构不标准就用正则清理空白符和痕迹文本。生成摘要时我会传给模型一个限制长度的prompt并把前面定义的max_chars作为硬上限传进去防止模型输出超长摘要。返回结构保持三层result表示整体状态data放业务数据error放错误信息。模型收到这种结构后既能直接读取结果也能根据错误码做下一步动作。如果希望更贴近真实框架可以把execute改成异步函数但整体调用模式不变。3.4 接入主流 Agent 框架时的参数映射细节把上面这套体系接到实际 Agent 框架时最常遇到的问题就是工具描述格式不一致。有的框架用 JSON Schema有的让模型直接看函数签名还有的需要在提示词里注入说明。我的做法是保留一份以技能定义文件为准的数据源再用适配器转换成不同框架需要的格式。适配器要做三件事把params里的type映射成框架原生类型把description按需要截断把result.fields转成框架的返回结构定义。例如 JSON Schema 的type用string、integer、object表示另一些框架则用str、int、dict表达。这部分映射完全可以自动化没必要手写两份提示词。值得注意的是有些框架会限制技能总量或单条描述长度。技能一多描述很容易超限。我的处理是给每个技能准备一个“短描述”字段只保留触发场景和核心价值用于框架层面的展示完整的详细描述只在需要时传给模型。3.5 技能清单的版本管理与多环境切换技能定义会随着模型能力一起演进版本管理必须从一开始就考虑。我会在每个技能文件头部保留一个版本号并在注册中心里支持按版本加载。改动不兼容的描述时升一个大版本仅修文案就升小版本。这样一边提升模型调用率一边还能用历史版本做对比。多环境切换在团队协作中也很常见。开发环境、测试环境、生产环境往往需要不同的技能配置。比如生产环境可能关闭调试类技能测试环境允许访问一些内部测试页面。我会在注册中心外面包一层环境选择器启动时按环境变量加载对应的清单目录底层执行逻辑不变只是暴露给模型的能力集会变化。这个设计后期能省下很多运维时间。4. 实战问题排查模型不听话时先检查技能定义我在调技能层时踩过不少坑下面这些现象是出现频率最高的我整理成一张速查表方便你对照排查。现象常见原因优先检查项模型完全不调用技能触发条件描述含糊描述首句是否点明具体场景模型反复调用同一个技能失败回退缺失错误码是否有对应的后续动作参数传错缺少类型定义和默认值校验层是否做了类型转换多个技能选错场景边界重叠描述里是否写明反面场景调用后上下文混乱结果回流信息过大是否限制返回字段数量4.1 症状模型完全忽略技能或者过度调用技能最常见原因就是技能描述的触发条件写得不够明确。模型本身倾向于做一个“全能角色”如果描述模糊它更愿意用自己的知识直接回答。把触发场景放到开头并把“何时不该用”也写清楚能明显改善识别效果。反过来过度调用一般有两个原因一是用户问题本身就停留在技能覆盖边界二是技能描述把结果说得太万能模型觉得什么都需要调一下。这时我会给描述增加反面场景比如“不适用于视频类页面”让它有明确的分类依据。4.2 症状参数重复、错位、出现幻觉字段这几乎都是参数定义不严格导致的。模型有时候会把多个技能的参数合并有时候把数组类型的字段传成字符串。我的排查顺序是先看校验函数有没有做类型转换再看参数定义里有没有默认值最后看调用示例是不是给了两次。一个有效的技巧是在技能描述后面附上一个调用示例。模型看到url: https://example.com/page, max_chars: 2000的范式后再传参时会自动模仿该格式。示例不需要多一个足够但必须和参数定义完全一致。4.3 症状多个技能高度相似模型选错如果两个技能描述里出现相同的关键词选错的概率会大大增加。比如“网页信息抓取与摘要”和“文档内容分析与摘要”功能上确实有重叠。这种时候我会从场景角度拉开差异一个面向网页 URL一个面向本地文件路径同时明确边界让双方描述里都写上“不负责另一个场景”。另一个处理方式是调整技能在清单里的顺序把更常用的技能排在前面。因为模型在多数实现里会按顺序扫描技能列表靠前的位置有天然的优先级优势。排序优先级按照历史调用频次来就好不用过度设计。4.4 三条实测下来的技能设计心得第一命名用“前置场景 动作”的方式表达。例如“网页抓取摘要”“定时任务调度”而不是简单的“摘要”“调度”。这样技能列表可扫读模型也能更快找到目标。第二每次只改一个变量。把描述、参数、返回结果三个维度分开调整一次只改一个不然你根本说不清楚到底是哪个改动带来了效果提升。第三建立技能回归集。我留了一批典型测试用例任何技能改动都先跑一遍回归。别小看这一步它能拦住大量“改了 A 技能却搞坏 B 技能”的底层问题。回归集不用很复杂记录输入、期望输出、允许误差范围就行。如果你也打算搭一套 agent-skills我的建议是先从 3 到 5 个核心技能开始而不是一开始就做上百个技能库。技能层面不存在“一步到位”的设计它是在真实调用中长出来的。我在实际项目里最重要的体会是大部分调用失败不是模型问题而是技能描述和参数约束没有做到位。把技能当成一份给外部协作方看的接口文档来维护你会少踩很多坑。后面等到技能数量多起来再逐步把权限、审计、版本回滚加上那又是另一个值得展开的话题了。