Agent 能干活的秘密,全藏在“技能系统”里
这两年只要聊到 AI Agent,大家最关心的一个问题基本是:怎么让它真正“干活”,而不是只会输出一堆正确但没用的废话。我见过太多团队把 Agent 做成了“高级聊天机器人”——能回答问题、能写文案,但让它去查数据库、操作文件、调用外部接口,就各种翻车。翻车的根源,绝大多数不是模型不够聪明,而是整个 Agent 缺了一层可靠的“技能系统”。
我所说的“agent-skills”,其实是我在项目里沉淀的一套思路:把 Agent 能执行的动作,从“写在 Prompt 里的零星指令”变成“有定义、有参数、有校验、有异常处理的独立技能模块”。简单说,就是给 Agent 装上一套可插拔的“手和脚”,而不是每次都用嘴去指挥空气。
这篇文章我会把整套方案拆开讲清楚:为什么需要技能系统、一个技能模块要包含哪些东西、具体怎么实现一个轻量级框架,以及我在实操中踩过的一堆坑。适合正在做 Agent 应用开发、或者想搞明白工具调用(function calling / tool calling)底层逻辑的朋友,新手也能跟着流程跑通,因为所有原理我都会用大白话和真实例子讲明白。
1. 为什么 Agent 需要技能系统,而不是更长的 Prompt
1.1 从“会说话”到“会做事的”关键一跃
大模型本身是个“大脑”,它有推理能力,但你让它去执行某个具体操作时,它没有手。所谓的 function calling,本质上就是让模型输出一个“我想调用某个函数,参数是什么”的结构化结果,然后由代码去真正执行这个函数。这是 Agent 从“脑力劳动者”变成“脑手并用”的核心机制。
不过问题来了:如果你只是把工具的描述堆在 Prompt 里,早期 demo 还能跑,一旦工具超过五个、十个,模型就开始乱了——它不知道该选哪个、参数传错、返回结果解析失败,各种问题层出不穷。我见过一个项目在 Prompt 里硬塞了二十多个工具的说明,结果模型有一半时间在幻觉一个不存在的工具名。所以我坚持的一个观点是:Agent 的能力边界,不是由模型参数决定的,而是由你给它配的那套技能系统决定的。
1.2 技能系统到底解决了什么问题
它解决的第一个问题是“可控”。技能系统把每个能力封装成标准化的模块,有名字、有描述、有参数定义、有执行逻辑、有返回格式。模型只能在这些已经定义的技能里做选择,不能凭空捏造,这就从机制上消灭了“幻觉出某个函数”这类的低级错误。
第二个问题是“可维护”。以前工具逻辑分散在代码各处、或写在 Prompt 里,改一个参数说明要翻半天。技能系统把所有能力集中管理,每个技能是一个独立模块,后续增加新技能只需要新增一个定义,不影响其他部分。我做项目时最深的体会是:没有技能系统的 Agent 项目,越往后越像一个“狗窝”;有技能系统的,则像一个“工具箱”,每一件工具都有固定位置。
第三个问题是“可复用”。技能定义一次,就能在多个 Agent、多个项目里复用。比如我写了一个“网页抓取并转成 Markdown”的技能,以后做舆情分析、做知识库采集,直接带着技能包过去就行,不用重写。
第四个问题容易被忽视——安全边界。技能系统让“Agent 能做什么”变成了白名单机制。我不需要告诉模型“你不能删库”,我只需要不提供删库这个技能即可。在这个体系里,权限控制粒度可以非常细,后面我会专门讲。
1.3 这套东西适合谁
如果你正在做任何形式的 AI Agent 应用——不管是客服机器人、自动化办公助手、代码审查工具,还是个人知识库管家——你都值得停下来,先把技能系统设计好再写业务代码。如果你想读懂现在主流 Agent 框架(无论是开源的还是商业的)里的 function calling 机制,这套拆解也能帮你从底层看透它们的实现思路。哪怕你只是想给自己的 ChatGPT 类应用加几个自定义动作,这里的描述词写法、参数设计、异常处理思路,都能直接用上。
2. 技能系统的核心拆解:每一个模块都别偷懒
2.1 一个技能的基本构成要素
一个合格的技能模块,应该是下面这五个要素的完整组合:
- 技能名称:唯一标识,最好是动词开头,例如“search_web”、“send_email”、“create_task”。
- 技能描述:给大模型看的说明文本,告诉它“什么场景下应该调用这个技能”。
- 参数定义:结构化描述这个技能需要哪些输入,通常用 JSON Schema 表示。
- 执行逻辑:真正跑代码的那部分,负责把参数转化为操作并返回结果。
- 返回格式:固定的输出结构,方便上层逻辑解析。
这五个要素缺一个都不行。我看到很多初学者只关注执行逻辑,觉得“能跑就行”,结果模型完全不知道该在什么时候调用这个技能。打个比方:你的工具箱里放了一把锤子,但没人告诉工人“看到钉子就用它、看到螺丝就找螺丝刀”,工人当然会乱来。技能描述就是贴给模型看的那张标签。
2.2 技能描述的质量,直接决定 Agent 的“智商”
在 agent-skills 这套体系里,技能描述是最容易被低估的东西。许多人随手写一句话:“查询天气。”然后上线发现 Agent 就是不调用,或者乱调用。问题出在哪?描述太抽象,模型不知道你这个“查询天气”到底包含了哪些城市、是查询当前天气还是预报、需不需要历史数据。
我推荐在实践中把技能描述写成一段话,交代清楚“是什么、何时用、何时不用、注意事项”,甚至可以附上一个简单示例。比如:
通过城市名称查询该城市当前天气状况和未来3天预报。 当用户询问“今天/明天/本周天气”“下雨吗”“气温多少”等问题时使用。 如果用户只提到日期没有提到具体城市,先询问用户所在城市再使用。 支持中文城市名、拼音、经纬度三种输入方式。这段描述看起来啰嗦,但它给了模型足够多的“决策锚点”。我实测过,光是把技能描述从一句话扩写成这种风格,调用准确率就能提升至少三成。描述的目的不是给人类看,而是给模型做“意图匹配”,它本质上是在给模型补习“什么情况该触发这个工具”的隐性知识。
2.3 参数定义:JSON Schema 的严与宽
参数定义是技能系统里最容易出问题的地方。大模型输出参数时经常犯错,如果我们的 schema 定义得不清晰,执行阶段必然炸。我一般要求三个关键字段:type、properties、required,尤其是 required 不能漏。
举个例子,文件整理技能里的 schema:
{ "type": "object", "properties": { "folder_path": { "type": "string", "description": "需要整理的文件夹绝对路径" }, "dry_run": { "type": "boolean", "description": "是否只打印计划而不真正移动文件,默认 true" } }, "required": ["folder_path"] }我踩过的一个坑是:模型经常把 dry_run 传成字符串 "false" 而不是布尔值 false,导致代码判断出错。所以后来我有个习惯:在执行逻辑入口做一次严格的类型校验和转换,而不是相信模型传来的东西一定是合法 JSON 且类型正确。
另外一个建议是“宽容接收、严格校验”。参数里能设置默认值的就设置默认值,不要把每个字段都变成必填;但如果某字段会影响安全或资金操作(比如转账金额、删除操作的目标路径),再严格都不为过。
2.4 技能注册与发现机制
技能系统还需要一个注册表(Registry),把散落的技能集合起来,并且在每个请求到来时,把“当前可用的技能列表”注入到模型的上下文里。注入的方式有几种:最常见的是把技能清单转成 OpenAI 风格的 tools 数组;也有的框架用自然语言描述拼进 system prompt。我推荐用标准 tools 格式,因为模型的工具选择准确率通常更高。
这里有一个经验细节:不要每次把全部技能注入。技能太多会占用上下文,也会让模型产生选择困难。正确做法是做一个简单的粗筛,比如按关键词或业务场景把技能分组,每次只注入该场景相关的技能子集。这个思路和“召回-精排”很像,是先粗选再精确调用,既省 token 又提升准确率。
3. 实操:从零搭建一套轻量级 agent-skills 框架
3.1 技术选型和整体规划
为了让这套思路落地,我建议不要一上来就上重型框架,先用最朴素的 Python 写一个核心原型。原因很直接:把原理搞通了,再迁移到任何现成框架上都是一件轻松事。我用的栈极其简单:Python 3.11 + Pydantic(负责参数校验)+ FastAPI(可选,如果要做 HTTP 服务)。如果你只想在本地命令行里跑通,Pydantic 就够了。
整体架构分三层:
- 技能层:每个技能是一个 Python 文件,内部定义注册函数和真正的执行逻辑。
- 注册表层:负责收集所有技能、存储到字典里,并提供“获取全部技能”“生成 tools 列表”的接口。
- 调度员层:接收大模型返回的工具调用请求,根据技能名称找到对应函数执行,并把结果格式化成可反馈给模型的文本。
这样的分层让每个模块的职责非常单纯,调试起来也不费劲。
3.2 实现技能注册表:一个装饰器搞定
注册表我用装饰器来实现,这样定义新技能时可以写得很干净,先看代码:
# registry.py from typing import Callable, Dict, Any SKILL_REGISTRY: Dict[str, Dict[str, Any]] = {} def skill(name: str, description: str, parameters: dict): def decorator(func: Callable): SKILL_REGISTRY[name] = { "name": name, "description": description, "parameters": parameters, "func": func, } return func return decorator def get_tools_list(): """把技能列表转成大模型能识别的 tools 格式""" tools = [] for name, meta in SKILL_REGISTRY.items(): tools.append({ "type": "function", "function": { "name": name, "description": meta["description"], "parameters": meta["parameters"], } }) return tools def run_skill(name: str, arguments: dict) -> str: if name not in SKILL_REGISTRY: return f"错误:未找到技能 {name}" try: result = SKILL_REGISTRY[name]["func"](**arguments) return format_result(result) except Exception as e: return f"技能执行失败:{str(e)}" ``` ```python # example_skill.py from registry import skill @skill( name="organize_files", description="按文件扩展名整理文件夹,将图片、文档、压缩包分别放入子目录。当用户说‘整理一下某个文件夹’时使用。", parameters={ "type": "object", "properties": { "folder_path": {"type": "string", "description": "待整理的文件夹路径"}, "dry_run": {"type": "boolean", "description": "为 true 时只预览移动计划,不真正移动", "default": True} }, "required": ["folder_path"] } ) def organize_files(folder_path: str, dry_run: bool = True): # 实际执行逻辑... return {"moved": 12, "plan": [...] }这套装饰器方案是我目前觉得最舒服的写法:新技能就是“写一个函数 + 加一行装饰器”,几乎没有模板代码。实际项目中,你还可以给装饰器增加“技能分组”“访问权限”“是否默认启用”等元字段,按需扩展即可。
3.3 调度循环:让模型和技能转起来
有了技能集,接下来就是核心的“agent 循环”:模型决策、调度执行、结果回填、再次决策。核心伪代码如下:
def agent_run(user_query: str): # 1. 准备会话消息和可用技能 messages = [{"role": "user", "content": user_query}] tools = get_tools_list() # 2. 请求模型,允许它选择工具 response = llm.chat(messages=messages, tools=tools, tool_choice="auto") msg = response.choices[0].message # 3. 判断模型是否要求调用工具 while msg.tool_calls: tool_results = [] for tc in msg.tool_calls: # 提取调用细节 fn_name = tc.function.name fn_args = json.loads(tc.function.arguments) # 执行技能,捕获错误 result = run_skill(fn_name, fn_args) tool_results.append(result) # 4. 把工具结果回填给模型,让它生成最终回答 messages.append({ "role": "tool", "content": str(result), "tool_call_id": tc.id }) response = llm.chat(messages=messages, tools=tools) msg = response.choices[0].message return msg.content这个循环里有一个关键设计:执行完技能后,不是直接返回给用户,而是把结果作为新的上下文再送进模型一轮。为什么?因为工具返回的往往是原始数据(文件列表、API 响应、数据库记录),模型需要把原始数据翻译成用户要的答案。这个“翻译”动作很值钱——用户问“我的桌面乱不乱”,技能返回 20 个文件路径,模型最终回答“你的桌面有 12 张图片、3 个文档,建议清理一下”,这种体验是完全不一样的。
3.4 异常处理:坏技能绝不能拖垮整个 Agent
技能系统里最烦人的一个问题:某个技能抛异常了,整个对话直接报错,用户体验极差。所以 run_skill 函数里我用 try / except 包住了每个技能的执行。不仅如此,我在实际项目里还会往下分几层处理:
- 参数错误(如缺少必填参数):返回一段说明,告诉模型“参数不对,请补充 xxx 后再调用”。模型看到这个提示通常会自动修正参数并再次调用,这个特性在连续对话里尤其好用。
- 业务性错误(如文件不存在、网络请求失败):同样把错误信息原样返回给模型,模型会用自然语言向用户解释失败原因,并给出建议。
- 系统级异常(如代码 bug):我方捕获后记录日志,同时给模型返回模糊提示,避免内部错误细节泄漏给用户。
这里有个手段很有意思:把错误信息本身“喂回”模型,让模型进行自我修正。大多数模型在收到“工具调用失败 + 失败原因”之后,会选择重新组织参数再调用一次。这和人类的习惯一模一样:我拧螺丝没拧进去,看一眼是不是方向反了,再试一次。这种“重试-反馈”机制,让系统看起来非常智能,但实现成本几乎为零。
4. 常见问题与排查技巧实录
4.1 模型就是不肯调用技能,怎么办
这是出现频率最高的问题。面对用户合理请求,模型却直接基于自己的“常识”回答了,根本没有调用你的技能。我在排查时有一套固定流程,建议按顺序走:
第一,检查技能描述是不是过于抽象。很多人的描述就一句话,模型不清楚合适触发条件,我把上面说的扩写方法用上,90% 的问题能缓解。第二,检查 tools 参数是否真的传进去了。有些框架在你传 tools 时会偷偷忽略,你可以在请求日志里确认 tools 数组非空。第三,看看模型的温度设置。temperature 太高,模型会更“自由散漫”,不严格遵循工具调用指令,我项目里一般把 temperature 设置在 0.1 到 0.3 之间,工具调用类的任务,温度越低越稳。
第四,确认当前场景的技能是否真的在注入列表里。如果你做的是“按需注入”,很可能因为分类条件写错,把应该注入的技能过滤掉了。这个问题隐蔽性高,我建议在日志里打点输出“每个请求注入的技能列表”,一眼就能看出来。
4.2 参数类型总是错?比你想的还要多
模型在生成 JSON 参数时,经常会有手滑。最典型的:数字字段传了带引号的字符串(比如把 3 传成 "3")、布尔字段传了字符串 "true"、数组字段传成了逗号分隔的字符串。这些错误如果在解析 JSON 阶段用 jsno.loads 就抛异常,整个对话会断;如果你用 .get() 去取值,说不定能“侥幸”通过,但后续执行逻辑就会出错。
我现在的做法是:不信任原始参数,一律过校验层。用 Pydantic 定义一个入参模型,把原始字典喂进去,让 Pydantic 去做类型强制和校验。比如把 "3" 转成 3,把 "true" 转成 True,它会自动处理这些转换。如果实在转不了(比如把 3 传给 bool 字段),它会抛一个 ValidationError,我再对这个错误做处理,让模型修正参数。这种机制帮我挡住了大量因为参数不合法导致的执行失败。
4.3 工具返回结果太长,上下文被你自己的技能撑爆了
这个问题一旦开始用技能系统就会立刻出现:技能返回了一大段文本(比如网页正文、API 全量响应),你原封不动塞回 messages,然后模型再生成一轮回答,上下文轻轻松松几千 token,多轮对话之后直接把窗口撑爆。
我的经验是:在技能执行层就做“返回瘦身”。比如“网页抓取”技能,返回前先提取核心内容,保留标题、摘要、正文前 1000 字;如果原始内容特别重要,不要全量回传,而是把内容存到一个临时文件或数据库里,然后把“文件路径 + 行数 + 摘要”返回给模型。模型需要更多细节时,再调用另一个“按文件路径读取指定行”的技能。这种“分肥瘦调用”的设计,让我每个会话的 token 消耗下降了将近一半。
4.4 几个不太容易解释但特别实用的坑
坑一:并发环境下的技能注册表污染。如果你的 Agent 服务是并发的,且技能内部用了全局变量或在类属性上存状态,就会出现数据串台。我建议技能本身设计成无状态函数,所有需要持久化的东西都通过参数传入,或者把技能放在每次请求独立的上下文里。
坑二:重试循环失控。模型反复调用同一个技能,每次都失败,然后不停重试,白白浪费你的 API 预算。我加了一个“连续失败计数器”,同一个技能如果连续失败 3 次,就停止重试,直接回到用户,并提示“该操作暂时无法完成”。
坑三:技能的隐式副作用。有些技能看起来是“查询”,实际会改数据(比如先建缓存再查询)。我在定义技能时坚持一个原则:如果技能有写操作,描述里必须明确说明“此操作会修改数据”,并提供一个 dry_run 或 confirmation 参数。尤其是未来要做多步任务的场景,模型可能在无意识中反复调用写技能,后果很严重。
5. 从单个技能到多 Agent 的技能编排
5.1 多 Agent 场景下的技能隔离与权限
当你的系统从单 Agent 变成多 Agent(比如一个负责写文案、一个负责执行发布、一个负责数据分析),技能系统的设计就需要引入权限和隔离机制。我建议给每个技能增加两个元字段:allowed_agents 和 allowed_roles。allowed_agents 定义了哪些 Agent 能够调用这个技能,它解决了“编辑助手不该有发布权限”这类问题;allowed_roles 则是更细粒度的用户角色控制,比如“仅管理员可调用删除类技能”。
实现上其实不复杂,在 run_skill 执行前加一个授权检查函数即可。但它的意义非常大:它把“模型能做什么”和“某个 Agent 能用什么”分开考虑,相当于给 Agent 集群装上了一套白名单权限网关。我在实际部署中遇到过的严重事故,就是因为一个隐私查询技能被所有 Agent 共享,导致某 Agent 在无意的对话中查询了不该查询的数据。技能加上权限约束后,这类风险立刻被挡在门外。
5.2 技能库的版本管理与升级策略
技能不是写好就完事了,它需要持续迭代。我们经常要调整一个技能的描述或参数,如果直接改全局技能,所有依赖它的 Agent 都会受影响。我的做法是把技能库做成一个带 version 字段的包:每个技能定义里有 version,升级时不直接覆盖,而是注册成新版本,并在注册表里做透明切换。
比如 v1 版本的搜索技能描述不够准确,导致调用率不高,我升级成 v2 时,会在旧版基础之上增加一条“废弃提醒”:告诉模型优先使用 v2。实际执行时,如果模型还调用 v1,run_skill 会拦截并重定向到 v2,但返回给模型的信息保持不变。这种“平滑升级”机制让我在不中断服务的情况下持续优化 Agent 行为,非常值得做。
5.3 安全边界:给技能加“保险栓”
最后必须严肃地说一下安全。技能系统一方面提升了 Agent 能力,另一方面也放大了风险。一个能写文件、能发邮件、能调用支付接口的 Agent,如果技能设计不当,后果不堪设想。我给自己定了几条铁律,供大家参考:
第一条,敏感操作必须二次确认。任何删除、修改、支付、发送类技能,默认都要带 confirm 参数或经过人工审批流程,不能靠模型自觉。第二条,技能的返回结果不能信任外部数据中的指令。如果你的技能会抓取网页或解析用户输入,返回的内容里可能带有“忽略之前指令”之类的对抗性提示,必须对技能返回做净化,或者严格限制返回给模型的那部分内容。第三条,运行环境隔离。默认情况下,给 Agent 执行的代码应该跑在受控容器或沙箱里,即使技能存在漏洞,也不至于让整个系统沦陷。
在整个 agent-skills 实践里,安全设计不是后置的补丁,而是和技能定义同步到位的。我现在的习惯是:每新增一个技能,先问自己三个问题——这个技能是否越权?如果被恶意利用会造成什么后果?模型在这个场景下是否真的需要这么强的能力?这三个问题全通过,才允许注册。
最后再分享一点个人感触:做 Agent 技能系统,最大的成就感不是“写出一个复杂的框架”,而是看到原本只会聊天的模型,突然有了动手能力,能真的帮你把文件夹整理好、把数据库查好、把报告生成好。那一刻你会觉得,之前踩过的所有坑,都值了。如果你的项目正卡在“Agent 不干活”的阶段,不妨先把技能系统搭起来,相信会有不一样的效果。