多智能体项目一旦跑起来,最先让人头疼的不是 Agent 怎么对话,而是怎么让 Agent 真正“动手做事”。我用 AgentScope 做内部工具这段时间,把它的 skills 机制从注册用到团队共享,踩过的坑比文档里写的翻了好几倍。这套框架的 skills 机制,简单说就是把一段业务逻辑封装成函数,挂到 Agent 身上,让模型在对话中按需调用。它能解决什么问题、怎么一步步接进去、哪些地方容易翻车,这篇文章一次性讲清楚。适合刚接触 AgentScope、想给多智能体加外部能力的开发者做参考。
1. AgentScope 和 skills:先解决两个问题
1.1 多智能体开发里最让人难受的几件事
先说说没有这套机制之前我经历过什么。第一个版本里,我为了让 Agent 能查库存,把所有查询逻辑都写进了 Prompt,结果每次规则变动都要重写一大段描述,还会和别的指令打架,模型经常答非所问。第二个问题是调用外部接口这件事完全没法验证,Agent 说“查到了”,但你不知道它到底调了哪个函数、传了什么参数、返回了什么。第三是职责混乱,多个 Agent 各写各的逻辑,同样的查询能力在两处各维护一份,改一处忘另一处。
这三个痛点,才是 AgentScope 这类框架要处理的核心。skills 机制正好把“决策”和“执行”分开:决策由模型负责,执行由技能函数负责。业务逻辑从文本描述里搬出来,变成可以运行、测试、复用的函数块。整个项目也从“全靠 Prompt 撑着”变成“代码为核心、模型做编排”的结构,Debug 的时候至少有一个明确的函数入口可以下断点。
我见过不少团队在 AgentScope 上直接裸写模型调用,Agent 只会聊天不会执行任何操作,那等于没用到框架的真正价值。等你在项目里维护一段时间就会发现,给模型一个可靠的外部执行环境,远比给它一堆华丽的 Prompt 重要。skills 就是这个执行环境的载体。
1.2 skills 在这套框架里的真实定位
技能在 AgentScope 里不是一个虚构概念,它实际上是 Tool 体系的上层封装。我习惯把底层对象叫 Tool,把对外暴露的能力叫 Skill,两者经常混着用,但意图一致:给模型一个可以调用的函数入口。在 AgentScope 的对话循环里,模型收到用户消息后不是直接找答案,而是根据上下文决定是否调用一个 Skill。它可以调用查库存的技能、查天气的技能,也可以调用一段固定的代码片段。
这种设计对 LLM 来说,暴露的是一组标准化按钮,模型只需要理解按钮的用途并按下它,而不用关心按钮背后的实现细节。从工程角度看,Skill 让能力和对话解耦:能力可以单独测试,对话逻辑可以单独调 Prompt,两者不互相污染。
| 对比维度 | 不用 skills 的写法 | 用 skills 的写法 |
|---|---|---|
| 改动成本 | 改 Prompt,整体重写 | 改函数,范围可控 |
| 可测试性 | 难,靠人工看回复 | 可单测,可断言返回值 |
| 复用性 | 复制粘贴 | 注册一次,多处挂载 |
| 权限控制 | 无法控制,模型自由发挥 | 按 Agent 注册最小技能集合 |
这套机制真正解决的问题,是把系统设计的问题提前到了模型交互之前。你写得越克制、边界越清楚,Agent 的表现就越稳定。下面进入具体用法。
2. skills 的底层设计与注册细节
2.1 把一个函数变成技能,本质上是给模型塞了一份说明书
把函数变成技能,对框架来说很简单,难的是让模型会用它。模型站在函数外面,它只能看到函数名、参数签名、docstring。所以这个函数从命名到注释,本质上是一份给模型的说明书。
命名要动词开头,比如get_stock_quantity、query_order_status、create_after_sale,一眼能看出这个技能是做什么的。不要用do_stuff、handle_data这种含糊名字,模型会忽略它。docstring 要写清楚三件事:什么时候用、参数是什么意思、返回值是什么结构。还有边界条件,比如查不到数据时返回什么格式,这直接影响模型在异常情况下的表现。
我当时犯过一个典型错误:函数名写得很规范,但 docstring 里没有说明返回字段含义,模型拿到了{"status": 0}却不知道 0 代表有货还是没货,最后就会胡编。技能的描述不是写给人看的注释,是写给模型看的接口文档,这个观念转过来之后,很多问题迎刃而解。
2.2 注册一个 Skill 的标准流程
注册过程在 AgentScope 里非常直白。典型做法是把一个普通函数用装饰器转成工具对象,再传给 Agent 的初始化参数。我实际项目里常写成这样:
from agentscope.func_to_tool import func_to_tool @func_to_tool def query_stock_quantity(product_id: str) -> dict: """查询某商品当前可用库存。 Args: product_id: 商品唯一编码,例如 SKU-1001。 Returns: dict: 包含字段 quantity、status、warehouse。 """ # 这里可以是数据库查询,也可以是外部 API 调用 return { "quantity": 100, "status": "available", "warehouse": "east-01", }装饰器把函数包装成框架可识别的 Tool 对象,Agent 初始化时传入 tools 列表。注册过程的核心是让函数签名被框架读取,所以类型注解不要省略。product_id: str和-> dict这种基础标注,既方便模型理解参数类型,也方便框架做参数校验。
不同 AgentScope 版本里,这个装饰器和类名可能有差异,我见过有些版本是用Tool类手动构造。重点不是 API 长什么样,而是“把纯函数转成框架能识别的技能对象”这一步。先在本地环境里跑通一个最小例子,再复制到项目里,比一次写完更稳。
2.3 参数描述、命名和加载顺序的小讲究
Skill 的参数描述直接影响模型能不能正确填参。我给自己定了几条规则:单个技能的入参尽量控制在 3 个以内,超出就拆分;参数名要贴近业务语言,比如order_id比oid好;能用基础类型就用基础类型,复杂结构用 TypedDict 或者直接返回 dict。
参数多了之后,模型很容易传错值。比如传商品编码的时候把订单号传进来,这种问题靠模型自身很难避免,最好的办法是从源头减少参数数量。一个查询技能如果同时需要商品 ID、仓库 ID、日期范围,考虑拆成两个技能,让模型按需分步调用。
技能加载顺序也要注意。Agent 的 tools 列表里,前面放被调用频率高、最关键的基础技能,后面放低频技能。模型在选择时会受到顺序影响,虽然不明显,但顺序靠前的技能被选中的概率更高。高频技能放前面,至少能少一些无谓的反复试错。
3. 实战:给 Agent 挂上一组可用的 Skills
3.1 一个很典型的业务场景和工程结构
用一个模拟项目 X 来说明:某智能售后助手 Demo,需要让用户通过自然语言查询商品库存、查询订单状态、创建售后单。这三个能力正好对应三个技能。工程结构我当时是按领域拆分的,这样每个文件只维护一类能力:
project_x/ skills/ __init__.py stock.py order.py aftersale.py agent_flow.py config.pystock.py里面只放库存相关函数,order.py放订单查询,aftersale.py放售后创建。文件拆分的意义不是为了让代码好看,而是为了后续维护方便。技能多了以后,一个文件里塞几十个函数,找起来很痛苦,而且容易在改动时误伤其他技能。
按领域拆分之后,每个文件可以被单独 import、单独测试。团队协作的时候,一个同事负责库存模块,一个同事负责订单模块,互相不会产生代码冲突。这就是把 agent 开发变成了普通的后端开发,每个人只需要关注自己那部分函数逻辑。
3.2 从定义 Skill 到初始化 Agent 的完整过程
完整流程分四步:定义技能、初始化框架、创建 Agent、发送消息。技能定义在上一节已经写了,这里从初始化开始。
import agentscope from agentscope.agent import ReActAgent from agentscope.message import Msg from agentscope.func_to_tool import func_to_tool # 1. 初始化框架,配置模型 agentscope.init( model_configs={ "config_name": "my_model", "model_type": "chat", "model_name": "your_model_id", "api_key": "xxx", } ) # 2. 注册技能(实际项目里从 skills 包导入) @func_to_tool def query_stock_quantity(product_id: str) -> dict: """查询某商品当前可用库存。""" return {"quantity": 100, "status": "available"} @func_to_tool def query_order_status(order_id: str) -> dict: """查询订单当前状态。""" return {"order_id": order_id, "status": "shipped"} @func_to_tool def create_after_sale(order_id: str, reason: str) -> dict: """为订单创建售后单。""" return {"after_sale_id": "AS-2001", "status": "created"} # 3. 创建 Agent,把技能挂进去 agent = ReActAgent( name="assistant", model="my_model", tools=[ query_stock_quantity, query_order_status, create_after_sale, ], max_iters=5, ) # 4. 发送消息 response = agent( Msg(name="user", content="SKU-1001 库存还剩多少,有货的话直接下单") ) print(response.content)这里我用的是 ReActAgent,它的核心是“观察-思考-行动”循环:模型先看用户消息和相关技能说明,决定要不要调用某个 Skill,框架执行函数拿到结果,模型再根据结果生成回复。max_iters=5表示最多循环 5 轮,防止模型陷入无限调用。
第一次跑通这个流程的时候有个很明显的感受:模型真的会自己去选函数。用户说“查库存”,它就调query_stock_quantity;用户说“查订单”,它就调query_order_status。这也是 AgentScope 的 skills 机制和普通函数调用最大的区别——模型在做决策,代码在执行。
3.3 调用链路与最小权限原则
技能被调用的完整链路是这样的:用户消息进入 Agent,ReAct 循环开始,模型分析后输出工具调用意图,框架解析意图并执行对应的 Skill 函数,函数返回值作为观察结果拼回对话上下文,模型基于结果生成最终回答。这条链路里任何一环出问题,Agent 的表现都会异常。
权限控制在多 Agent 场景里尤其重要。不是每个 Agent 都需要全部技能,给每个 Agent 分配最小技能集合,能减少很多误操作风险。我见过一个只负责问答的客服 Agent,被误挂了删除订单的技能,模型在用户抱怨时直接调用了它,这是非常危险的事。
敏感操作必须单独加确认步骤。比如创建售后单、修改库存这类写操作,不要直接放在默认技能列表里,可以考虑拆成“申请单”和“确认单”两步,或者由另一个专门 Agent 来审批。宁可流程上多一步,也不要让模型随随便便就执行破坏性操作。
4. 我把这些坑都踩了一遍,帮你整理好了
4.1 Agent 就是不肯调用 Skill,怎么排查
技能不生效是最常见的问题,表现是模型回答得很流畅,但完全没有调用函数,或者干脆胡编乱造。我的排查顺序基本固定:先看 docstring,再看技能数量,然后看参数类型,最后看函数内部异常。
docstring 写得太含糊是头号原因。模型看不懂“这个技能是干什么的”,自然不会调用。检查一下你的 docstring 有没有写清楚使用场景、参数含义、返回结构,如果还是不行,直接在描述里加上“当用户询问XX时,必须调用此工具”这种强制指令。
技能数量太多也会让模型选择困难。一次挂 10 个以上技能,模型很容易漏掉最合适的那个。我的做法是只挂当前对话可能用到的技能,其他技能放在后台服务里,由 Agent 动态调用。参数类型不匹配的问题通常表现为模型传了字符串、函数要求整数,这种可以通过在函数内部做类型转换来兜底。
最后,函数内部报错会让框架把异常信息当作返回值传回给模型,模型一看有结果,就以为任务完成了。所以每个技能函数里要加 try/except,返回结构化错误信息,比如{"error": "timeout", "retry": false},让模型知道这次调用失败了。调试的时候把原始 tool call 日志打出来,一眼就能看出模型到底选了哪个函数、传了什么参数。
4.2 多个 Agent 共用 Skill 时的并发问题
多 Agent 同时调用一个 Skill 时,首个容易出现的问题是线程安全。Skill 函数如果是无状态的,也就是所有数据都来自参数,那并发没有问题。但如果你在函数里用了全局变量、共享连接对象、或者模块级缓存,多个 Agent 同时调用时,数据会互相覆盖。
我踩过的典型坑是:一个技能里用了模块级的requests.Session(),多个 Agent 并发调用时偶现连接串号,返回的数据张冠李戴。解决方法是把 Session 做成局部变量,或者用连接池管理。数据库连接也是同理,不要在技能函数里写死一个全局 connection,要用连接池或者每次调用取新连接。
| 问题现象 | 可能原因 | 处理方案 |
|---|---|---|
| 返回值串号 | 模块级共享变量 | 技能函数改为无状态 |
| 超时阻塞 | 外部 HTTP 无超时设置 | 加 timeout 与重试机制 |
| 连接耗尽 | 每次新建连接不释放 | 改用连接池 |
并发场景下,技能函数还要设外部接口超时。我习惯在 HTTP 调用里显式加上timeout=5,避免一个外部服务卡住,导致整个 Agent 卡到超时。重试逻辑也要做好,但重试次数不要太多,两到三次就够,重试间隔递增。
4.3 技能顺序错乱,流程完全走偏
在一个 Agent 里塞多个 Skill,模型会选择执行哪个技能,但不一定能按业务预期顺序来。比如用户说“查一下库存,有货就下单”,模型可能直接调用下单技能,跳过了查库存这一步。业务流程明确时,不能指望模型自己排序。
我的解决方案是能力与流程分离:技能负责“能做什么”,流程在代码层控制“先做什么、后做什么”。具体做法有三种。第一种是拆成多个专用 Agent,比如“库存查询 Agent”和“下单 Agent”,用工作流串联;第二种是在系统提示词里强调流程顺序,效果有限但简单;第三种是把顺序判断逻辑写在代码里,由一个编排函数来判断应该先调用哪个技能。
最可靠的还是搭个工作流层。Agent 只管单步操作,流程编排放在代码里,这样顺序可控、可测试、可回滚。模型擅长的是理解意图和生成自然语言,严谨的先后顺序交给代码更稳。
5. 让 Skills 在团队里持续沉淀下去
5.1 把技能做成团队共享的能力中心
项目做久了,技能会越来越多。如果不做统一管理,每个 Agent 自己维护一份技能列表,早晚会失控。我后来把技能收敛成一个共享注册表,所有技能集中在skills/目录,通过一个注册函数统一导出。
一个简单的做法是写一个SKILL_REGISTRY字典,key 是技能名字,value 是对应函数对象,Agent 初始化时按需从注册表取。这样既控制了 Agent 的可见范围,又方便统一审计有哪些技能可被调用。新成员加入项目时,只需要看注册表就能了解全部能力。
团队共享还有一个好处是标准和规范能落地。要求每个技能必须写 docstring、必须返回 dict、必须有异常处理,代码评审时按这些规则检查。技能的质量上去了,Agent 的稳定性自然也会上去。
5.2 技能的版本维护与灰度上线
技能也是代码,也会有改动。我吃过一次亏:改动了一个查询技能的内部实现,没影响函数签名,但返回字段结构变了,Agent 的最终回答开始出错。从那以后,技能函数如果改了返回结构,我会同步更新 docstring,并在 changelog 里记录。
更稳妥的办法是给技能加版本号。每个技能函数在 docstring 里标注版本,或者返回结构里带一个version字段。内部接口改动时,先让一部分 Agent 用新版,其他 Agent 用旧版,跑一段时间没问题再整体切换。这就是灰度上线思路。
回滚也很重要。技能出问题时,最怕的是找不到上一个稳定版本。代码仓库的 commit 历史可以帮到这一点,每次技能改动单独提交,不要把若干无关修改混在一起,这样出了问题能快速定位和回滚。
5.3 从调用技能到动态加载技能
技能很多之后,全部挂到 Agent 上会让模型选择困难。更好的做法是动态加载:给每个 Agent 只暴露当前任务需要的技能子集。比如售后 Agent 只看到query_order_status和create_after_sale,库存查询 Agent 只看到query_stock_quantity。
动态加载的关键是有一个技能清单下发机制。先让一个入口 Agent 理解用户意图,再把对应的技能清单注入给具体执行 Agent。这样技能的规模不会影响模型的表现,还能顺带做权限控制——哪个角色能看到哪些技能,完全由上层决定。
我自己用下来最大的体会是,skills 机制的本质是把系统设计问题提前到了模型交互之前。你把它当成普通的函数库去维护,它表现得非常稳定;你把它当成“只要挂上去就能自动工作”的黑盒,它迟早会在某个关键时刻给你惊喜。先把一个技能做好,再谈多技能协作,这是最值得花时间的路径。