Agent技能体系设计:从聊天到会干活的关键一步 📅 发布时间:2026/9/16 6:40:06 👁 浏览次数: 做 Agent 这一年多我踩过最大的坑不是模型选型也不是 Prompt 怎么写而是怎么让 Agent 真正会干活。聊天谁都会但让它去查数据库、调接口、操作文件、按流程办事的时候问题一个接一个冒出来。后来我把注意力从调模型转到设计技能上整套系统才慢慢顺起来。今天就想聊聊 agent-skills 这个方向——它不像模型参数那么玄乎却是决定 Agent 能不能落地的关键一环。先说清楚它解决什么问题一个只有大模型和 Prompt 的 Agent本质上是个嘴强王者你说什么它都能接但让它动手做事就露馅。而 agent-skills 要做的就是把这套动手能力标准化、模块化让 Agent 在需要的时候能自己找到工具、调用工具、拿到结果继续推理。这篇文章我会从技能体系的设计思路讲起再给你一套可以直接抄走的 Python 实现最后把我实际运行中遇到的那些坑逐一列出来。适合正在做 AI Agent、智能客服、自动化工作流的开发者也适合想从零搭一套 Agent 能力层的产品经理。1. 先搞懂 agent-skills 到底在解决什么问题1.1 从会聊天到会干活Agent 缺的就是技能如果你只是拿大模型做纯问答那确实不需要技能体系一个 System Prompt 加知识库就够用了。但一旦涉及操作比如让 Agent 帮你查一下订单状态、把一份 CSV 转成 Excel、给某个群发条消息事情就变了。大模型本身不联网、不读文件、不执行代码它唯一能做的是生成文本。那怎么让它完成操作呢业界绕了一圈又回到一个朴素的思路把操作封装成函数让模型决定调哪个函数、传什么参数。这就是 agent-skills 的雏形。早期的 OpenAI Function Calling 就是干这个的后来各家跟进把函数升级成技能维度更高了一个技能不只是单个函数它可以是一个工作流、一套多步骤操作、甚至是由多个子技能组合出来的复合能力。比如生成周报这个技能内部可能要拉数据、汇总指标、生成 Markdown、发邮件但它对外暴露的只有一个入口。我见过不少团队在第一步就输了他们直接让模型写 Python 代码来执行各种操作美其名曰代码即技能。结果模型生成的代码十个有八个跑不通安全性和稳定性完全没有保障。正确做法应该是把模型生成代码这件事控制在极小的范围内其余操作全部落到预先定义好的技能上。模型负责决策技能负责执行这个边界一旦模糊后面全是灾难。1.2 技能、工具、插件这几个概念别再混了说实话技能、工具、插件这几个词在社区里经常被混着用但做系统设计的时候必须分清楚否则 API 设计和数据结构会变得非常别扭。以我自己的理解工具Tool是最小可执行单元通常对应一个函数或一个 API 调用比如获取天气发送短信。它没有记忆输入输出都是即时的。技能Skill是面向任务的封装可以包含多个工具调用、决策逻辑、甚至内部的状态管理。比如旅行规划这个技能内部要调天气工具、查航班工具、做预算计算但对外只暴露一个自然语言描述的任务接口。插件Plugin更多是一种分发和集成形态把一组技能、配置、依赖打包起来让外部系统可以方便地装载。你可以理解为技能的发行包。这套分层非常重要。如果你把什么东西都塞进工具里Agent 的选择空间会指数级膨胀模型调用时经常选错如果全都做成大而全的技能又会导致复用性极差每个场景都得重写。我的经验是底层尽量原子化上层按业务场景组合中间留一层抽象给 Agent 做决策。以我个人接过的项目为例有个客户要做内部知识库问答 Agent一开始他们把搜索文档和读取文档内容拆成了两个工具模型经常搜完不读或者直接读一个没搜过的文件非常蠢。后来我把这两个封装成一个文档问答技能模型只需要给一个查询词剩下的它完全不操心。效果立刻好了很多。这就是技能封装的意义不是替模型做决定而是把模型不擅长的多步操作收敛成一步。2. 技能体系的整体设计思路2.1 先定技能边界再谈技能复用很多人上手就写代码这是最大的误区。技能设计的第一步是画边界不是写函数。你得先回答几个问题这个技能是给谁用的它需要哪些输入信息它会产生什么影响它的失败模式是什么举个例子发送邮件这个技能看着简单但边界不同复杂度完全不同。如果只是给指定地址发一封纯文本邮件三步就写完了但如果要支持附件、抄送、定时发送、失败重试那它的输入参数、内部逻辑、错误处理全都要重设计。我见过最离谱的代码是一个发送通知技能里面揉进了短信、邮件、钉钉、企微四种渠道结果没人敢动它改一行要测半小时。所以我的建议是每个技能只干一件事但把它干透。判断标准很简单——如果你需要加一个是否启用XXX功能的参数那大概率是这个技能边界没划好应该拆开。真正的技能应该是参数少、职责纯、可组合。参数多的技能不是不能用而是会让模型在生成参数时更容易出错。实测下来模型对五个以内参数的函数调用准确率明显高于十个以上参数的这个差距在复杂任务里会被进一步放大。另外还有一个容易被忽略的边界问题技能之间的依赖关系。A 技能的输出会不会作为 B 技能的输入B 技能的失败会不会导致 A 技能的无意义执行这些在单体系统里可能无所谓但一旦技能多了互相调用形成环你就得面对死循环和状态不一致的问题。我的处理方式是尽量让技能之间不直接调用所有跨技能的协调都交给 Agent 的规划层完成技能层保持扁平。2.2 技能描述写得好不好直接决定 Agent 上不上手这里我要强调一个很多人不重视的点技能描述description是给模型看的不是给人看的。你在函数注释里写本函数用于获取用户信息人能看懂但模型可能一脸懵。模型是通过描述来决定调哪个技能的描述写得太泛或太模糊它就会犹豫甚至选错。一个好的技能描述应该包含三要素触发场景、输入说明、输出说明。触发场景告诉模型什么时候该用我输入说明明确每个参数的含义、格式、取值范围输出说明预告调用之后会拿到什么结果。我通常还会加一句使用示例用一句话描述一个典型调用场景这样模型的匹配准确率能提升一截。拿我刚才说的文档问答技能举例糟糕的描述是文档问答好一点的描述是当用户询问关于上传文档内容的问题时使用输入为用户的自然语言问题输出为文档中的答案及引用段落还能更好用于回答与已上传的 PDF、Word、Excel 等文档相关的任何问题。输入 question 为用户提问输出 answer 为一段文字citations 为引用文档名和页码列表。示例用户问合同里违约金比例是多少应调用此技能并传入完整问题。描述和参数都对模型可见这两个字段的质量直接决定了模型调用的准确率。我建议你在写完每个技能后专门花十分钟站在模型的角度读一遍描述问自己如果我只看到这段描述我知道什么时候该用它吗如果答案是否定的重写。这一步没什么技术含量但回报极高。2.3 技能注册表与版本管理技能一多管理就成了问题。你不可能让 Agent 每次都在几千个技能里找也扛不住技能更新后旧逻辑全挂。所以从一开始我就在系统里加了一个技能注册表Skill Registry所有技能在上线前必须注册注册信息包括技能名称、版本号、描述、输入 Schema、输出 Schema、依赖项、权限级别。注册表的好处有三个一是统一入口Agent 调技能前先查注册表避免直接调用野函数二是可以按版本回溯出问题能快速回滚三是在注册表层面就能做权限控制比如某些技能只允许管理员触发某些技能在夜间禁止执行。这些都是后期加的话会非常痛苦初期建好就是顺水推舟的事。版本管理这块我想多说一句。技能的版本不同于代码的版本它更接近协议。因为你不仅要保证代码能跑还要保证 Agent 的决策逻辑在新描述下仍然正确。我遇到过的情况是技能 v2 优化了参数结构但 Agent 的规划层还在按 v1 的描述传参结果全线报错。后来我规定技能参数变更必须保留一个兼容层旧参数映射到新参数而不是直接删。同时每一个版本的技能描述都会作为一条记录存下来这样如果模型在新版本下表现异常还能回退到旧描述让 Agent 重新决策。3. 手把手实现一套可用的技能框架接下来是实操环节。我会用一个极简但完整的 Python 示例带你从零搭一个 agent-skills 的核心框架技能定义、注册、调度、容错。这个框架可以直接用到你的项目里也可以作为原型快速验证思路。3.1 技能定义用 JSON Schema 约束输入输出技能的第一步是定义长什么样。我推荐用 JSON Schema 来描述输入输出因为大模型生态对 JSON 的支持最好而且 JSON Schema 本身就支持类型、必填、枚举、默认值等约束拿来当校验规则完全够用。SKILL_CALCULATOR { name: calculator, version: 1.0.0, description: 当用户需要数学运算时使用如加减乘除、幂运算等。输入表达式中只能包含数字、运算符和括号不支持变量和函数。, parameters: { type: object, properties: { expression: { type: string, description: 需要计算的数学表达式例如 (1 2) * 3 } }, required: [expression] }, output: { type: object, properties: { result: {type: number}, error: {type: string} } } }注意 description 里那句不支持变量和函数这就是在给模型划边界。如果你不写模型可能会传一个x1进来让人很头疼。模型是概率系统它在模糊的地方倾向于自由发挥所以你要用描述把自由发挥的空间压缩到最小。输出也用 JSON Schema 定义但不是为了强校验而是为了让后续逻辑更稳定。比如不管成功失败输出都是 JSON包含result或error字段这样 Agent 规划层拿到结果后可以直接判断这次调用到底成没成功而不是去解析一段自然语言。3.2 技能注册与调度一个极简 Python 实现有了定义接下来就是把技能注册到注册表并写一个统一的调度入口。这个调度入口是 Agent 与技能层之间的唯一桥梁所有调用都走这里方便统一做日志、权限、限流和错误处理。import json import traceback class SkillRegistry: def __init__(self): self._skills {} self._handlers {} def register(self, skill_def, handler): if not callable(handler): raise ValueError(fhandler for {skill_def[name]} must be callable) self._skills[skill_def[name]] skill_def self._handlers[skill_def[name]] handler def list_skills(self): return [ {name: s[name], description: s[description], version: s.get(version)} for s in self._skills.values() ] def get_skill_def(self, name): return self._skills.get(name) def invoke(self, name, **kwargs): skill_def self._skills.get(name) handler self._handlers.get(name) if not skill_def or not handler: return {success: False, error: fskill not found: {name}} try: # 简单的参数校验实际项目里可以用 jsonschema 库 required skill_def[parameters].get(required, []) for field in required: if field not in kwargs: return {success: False, error: fmissing required parameter: {field}} result handler(**kwargs) return {success: True, result: result} except Exception as e: traceback.print_exc() return {success: False, error: str(e)} registry SkillRegistry() def calc_handler(expression): # 实际项目里建议用受限的eval或者专门的表达式解析库 return eval(expression) registry.register(SKILL_CALCULATOR, calc_handler)框架很像一个字典查找没什么高深的但有几个细节值得说明一下。首先invoke方法统一包了异常处理。技能执行过程中的任何异常都不会穿透到上层而是包装成一个结构化的失败对象返回给 Agent。Agent 拿到失败对象后可以决定是换个参数重试、换一个技能、还是直接告诉用户做不了。这比让异常直接抛到 Agent 循环里好处理得多因为后者很容易让整轮对话崩溃。其次我把list_skills和invoke分开。list_skills返回的是模型可见的技能清单只挑关键字段invoke是实际执行入口。这两者在系统设计上必须隔离否则你可能会不小心把内部 handler 对象直接暴露出去那安全就别谈了。3.3 让 Agent 学会挑选技能基于描述匹配的调用注册表建好之后下一步是怎么让 Agent 在对话中决定调用哪个技能。最简单的方式是用大模型的功能调用Function Calling能力把技能注册表里的每个技能定义转成模型能理解的 function schema让模型在生成文本的同时输出一个结构化调用请求。def build_openai_tools(): tools [] for skill in registry.list_skills(): skill_def registry.get_skill_def(skill[name]) tools.append({ type: function, function: { name: skill_def[name], description: skill_def[description], parameters: skill_def[parameters], } }) return tools但这里有一个核心问题如果技能很多一次性把所有技能都塞给模型提示词会非常长而且模型的选择准确率会下降。我在一个项目里试过把 40 多个技能全塞进去结果模型开始错误调用某些不相关的技能被反复触发。后来我换了一个思路先粗筛再精调。粗筛层可以用关键词匹配或向量检索。比如用户说了算一下 35*17你把所有技能描述拿去做关键词匹配发现 calculator 的描述里含运算再加上意图分类基本就能把它锁定为候选。然后只把候选的几个技能一般不超过 5 个交给模型做最终选择。这样既减少了上下文长度也大幅提升了准确率。如果你想更省事可以直接让模型输出一个 JSON包含技能名和参数你再手动解析和调用。这种方式不需要平台特殊的 Function Calling 支持兼容性更好。代价是模型的输出可能不规范需要你多做一步校验和修复。def agent_decision(user_query: str): # 粗筛简单用关键词匹配演示实际可用向量检索 candidates [] for skill in registry.list_skills(): skill_def registry.get_skill_def(skill[name]) if 运算 in skill_def[description] or 数学 in skill_def[description]: candidates.append(skill_def[name]) # 将候选技能交给模型做选择伪代码请替换为真实模型调用 messages [ {role: system, content: f用户说{user_query}。请从候选技能中选择一个并给出参数。}, {role: system, content: f候选技能{candidates}}, ] decision llm_call(messages) # 返回 JSON如 {name: calculator, arguments: {expression: 35 * 17}} return decision3.4 技能执行中的参数计算与容错参数计算听着简单但做起来全是细节。比如表达式计算直接eval在真实项目里是绝对不能用的安全性太差。我用过一个方案是ast.literal_eval配合operator虽然写起来麻烦点但能有效防止注入。下面是一个相对安全的四则运算实现import ast import operator _ALLOWED_NODES (ast.Expression, ast.BinOp, ast.UnaryOp, ast.Constant) _ALLOWED_OPS { ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv, ast.Pow: operator.pow, ast.Mod: operator.mod, } def safe_eval(expression): tree ast.parse(expression, modeeval) for node in ast.walk(tree): if not isinstance(node, _ALLOWED_NODES): raise ValueError(fnot allowed node: {type(node).__name__}) if isinstance(node, ast.BinOp) and type(node.op) not in _ALLOWED_OPS: raise ValueError(fnot allowed operator: {type(node.op).__name__}) return eval(compile(tree, string, eval), {__builtins__: {}})这个例子想说明的核心思想是技能的每一步都要做护栏。你不能假设模型的输出一定是对的也不能假设用户的输入一定是善意的。技能执行前的参数校验、执行中的异常捕获、执行后的结果规整这三步一个都不能少。再有就是超时控制。有些技能调用外部 API 或执行耗时操作如果一直挂着不返回Agent 的整个循环都会卡住。我在框架里给每个技能都加了超时配置超时后默认返回失败。别小看这个设计线上事故里十有八九是因为某个技能调用超时把整条链路拖垮的。4. 给技能装上记忆状态管理与持久化4.1 为什么技能必须考虑状态纯函数式的技能好设计但真实业务里很多技能天然带状态。比如一个订单处理技能第一次调用创建了订单第二次调用要在这个订单上追加商品第三次调用要确认支付。如果三次调用之间不共享状态那你只能把订单 ID 来回传传丢了就完蛋。我的方案是给技能增加一个会话上下文参数。每次调用技能时除了技能本身的入参还把当前会话的状态对象传进去。技能内部可以读写这个状态对象调用结束后把更新过的状态返回给框架由框架统一持久化。这样技能的多个调用之间就能通过状态进行协作但又不需要技能自己管数据库。class SkillContext: def __init__(self, session_id, storageNone): self.session_id session_id self.storage storage or {} self.changed False def get(self, key, defaultNone): return self.storage.get(key, default) def set(self, key, value): self.storage[key] value self.changed True看起来很简单但里面有个很重要的设计决策技能不能直接访问所有会话数据只能通过 context 的 get/set 接口。这样做是为了避免技能之间互相污染数据。你有多少个技能就有多少双可能乱改数据的手不控制好权限后期排查问题会想死。4.2 一个带状态技能的实现示例我拿一个多轮问答抽奖技能来演示用户先参与活动再抽奖抽完查看结果。这个流程天然需要状态。def lottery_handler(context: SkillContext, action: str, **kwargs): if action join: context.set(joined, True) return {message: 参与成功} if action draw: if not context.get(joined): return {error: 请先参与活动} if context.get(drawn): return {error: 您已经抽过奖了} import random prize random.choice([一等奖, 二等奖, 参与奖]) context.set(drawn, True) context.set(prize, prize) return {prize: prize} if action view: prize context.get(prize) if not prize: return {error: 您还没有抽奖} return {prize: prize} return {error: funknown action: {action}}这个例子最有价值的点在于它把业务状态和技能实现解耦了。context负责状态读写handler只负责业务逻辑。当业务规则变化时——比如抽过奖的用户可以再抽一次——你只需要改 handler 里的判断条件不需要动框架也不需要在数据库里加字段。持久化层可以直接接 Redis 或 MySQL。Redis 适合做短时会话状态MySQL 适合长时业务数据。我现在的做法是一律先写 Redis设置好过期时间重要数据再异步落库。原因很简单Agent 的会话天然是短时高频的频繁读写数据库会拖慢响应而 Redis 的过期机制恰好可以对应会话失效。5. 常见问题与排查技巧实录5.1 技能调用混乱Agent 老选错工具这是我在社区答疑时被问得最多的问题。Agent 明明有好几个技能它偏选最蠢的那个。排查下来八成是技能描述写得太像了。比如你有两个技能一个叫查天气一个叫查日历如果它们的描述都是查询用户需要的信息模型不选错才怪。我的经验是技能描述的第一句话就直击它最适合的场景。不要写本技能提供综合查询能力这种废话。另外如果两个技能确实有重叠你要在描述里写明不要把它用于 XX 场景。模型对否定句的理解虽然不一定完美但比模糊表达强很多。还有一种情况是模型根本不知道该用技能直接自己编了个答案。这个问题的根源往往在于你对模型的 system prompt 或工具约束没有做好。你需要明确告诉模型当任务涉及具体数据操作时必须先调用技能不能凭空捏造。可以加一句强约束如果某个信息只能通过技能获取但你没有调用相应技能请直接回答你不知道。5.2 上下文爆炸技能结果太长技能输出的内容如果是个 10 万字的文档全塞回对话上下文里模型很快就会被冲昏头脑而且 API 费用暴涨。这里要分两种情况处理。如果技能的中间结果只是给模型做参考的你可以只把关键摘要放回上下文原始数据存在外部存储里等模型需要明细时再按需调用。如果技能的输出是给用户看的那就更简单了——直接渲染成卡片或附件不进对话上下文。我实际项目里就是这么干的一个报告生成功的技能内部生成完整 PDF但返回给模型的只有一句话报告已生成路径为 xxx.pdf用户端的展示完全不经过模型。这样模型只负责决策要不要生成报告不用处理报告内容准确率和速度都上来了。5.3 技能升级后旧场景全部失效技能升级翻车的事我遇到过不止一次。翻车的原因几乎都是同一个升级只改了 handler 代码忘了同步更新技能描述和参数 Schema导致模型还在按旧描述传参或者被新描述误导。所以我现在有一条铁律任何一次技能升级必须是定义描述实现三者同步提交。技术实现上我把技能的 JSON Schema 和 handler 代码放在同一个目录下版本号一致CI 里检查两个文件的版本是否匹配不匹配就不允许发布。另外就算定义和描述都改了模型在短期内的行为也可能有惯性。稳妥起见升级完成后我会保留 24 小时的影子模式新旧技能同时运行但只有旧的对外生效新的只记录日志。对比一天数据确认新的没有异常再切换。5.4 并发与幂等技能被重复调用怎么办Agent 的调用并不总是串行的有时为了效率规划层会并行发起多个技能调用。这就带来了两个问题并发安全和重复调用。并发安全的核心是状态的原子性。比如扣减库存这个技能如果两个请求同时读到库存还剩 1 件都执行扣减那就超卖了。解决办法是引入分布式锁或者更简单点在数据库层面做条件更新UPDATE storage SET count count - 1 WHERE count 0。技能层不要自己做复杂的并发控制交给存储层才是最稳的。重复调用的问题在于 Agent 的重试机制。模型调用技能时网络超时Agent 会自动重试但第一个请求其实已经执行成功了这就造成重复操作。解决的办法是幂等设计每个调用都带一个全局唯一的 request_id技能在处理之前先去查一下这个 request_id 是否处理过处理过就直接返回上次结果不再执行。幂等是技能设计中比较容易被忽略但线上价值极高的点。写在最后的几句实在话做 agent-skills 做到后面我最大的体会是这活儿没有太多高精尖的东西更多的是把工程经验老老实实落到每个技能的定义、描述、校验和容错里。它不像调模型那样有新鲜感也不像写 Prompt 那样能快速见效但它是一个 Agent 系统能不能从 demo 走向生产的决定性因素。如果你正准备开始搭技能层我的建议是别急着写代码先拿一张纸把你所有需要的技能列出来逐个写清楚场景、输入、输出、失败模式再动手。这套前期的笨功夫会在后面省下你十倍的时间。