从零构建AI Agent技能系统:模型工具调用的注册、编排与实战
做AI Agent开发这几年有一个问题始终绕不开模型很聪明但你要它落地干活它就抓瞎。你问它“帮我生成一份销售周报”它说一堆漂亮话可真要它去翻数据库、拉指标、算增长、写标题十有八九卡在第一步——不是不会写是缺乏一把趁手的“扳手”。这就是我折腾“agent-skills”这个项目的原因。简单说agent-skills是一套给大模型智能体装“技能”的框架。它解决的核心问题有两个第一把模型不擅长的确定性操作查库、调用API、操作文件、执行命令从“口头输出”变成“真实动作”第二让Agent的能力边界变得可管理、可插拔、可复用。通俗点讲就是给一个聪明但还是“躯壳”的智能体装上手脚。这篇文章面向的读者是正在做Agent落地、或者想搞明白“模型怎么真正操作工具”的开发者看完你就能从零搭一套自己的技能系统。1. 项目整体设计与核心需求拆解1.1 从一次失败的演示说起我在做这个项目之前先做了一个很简单的实验让一个接入大模型的聊天机器人帮我把某个目录下的日志文件按大小排序然后输出前10条文件名。模型回复得非常好——它告诉我应该用ls -lS还贴心地解释了什么管道符怎么组合。可然后呢没有然后了。它只能给我命令建议不能替我把命令执行了。我当时的想法很朴素如果我能给这个模型配一套“技能”让它有能力自己执行命令、看到结果、再基于结果继续推理那才叫Agent。这就是agent-skills项目的出发点。它本质上是把“模型能做决策”和“系统能执行动作”这两层缝起来的一层胶水。模型负责理解意图、拆解任务、选择技能技能系统负责真刀真枪地把事儿干了再把结果回传给模型让它继续决策。这套模式并不新鲜但真正做的时候坑远比想象中多。1.2 想清楚技能到底是什么动手之前我先给“技能”做了定义。这个定义很关键它直接影响后面所有的设计。我的理解里技能不是简单的“函数”而是一个带有元信息、输入约束、执行逻辑和反馈格式的完整单元。函数只关心“给我参数我返回结果”技能还得多做三件事告诉模型它是干嘛的让模型在决策时能判断“现在该不该用它”。告诉模型怎么调它参数怎么填、有哪些前置条件。告诉模型怎么处理结果返回的东西是什么意思、下一步可以怎么走。你可以把技能理解成一份带说明书的工具箱。模型是拿着说明书选工具的人技能系统的注册中心是放工具箱的架子。架子上的每个抽屉都贴了标签说明这是什么工具、什么场景用、怎么用。模型决策的过程就是挑抽屉的过程。1.3 选型时的关键取舍技术选型上我对比了好几条路。最开始考虑过直接给模型一个Python执行容器让模型自己写代码自己跑这就是很多“Code Interpreter”的玩法。但仔细一想这种方案有两个致命问题一是安全隐患极大模型生成的代码可能执行任何操作二是可控性差模型写代码的成功率并不稳定尤其在复杂依赖场景下。我选择的是更保守的“预定义技能”路线提前把常用操作封装好模型只能在这些技能里做组合不能自己做新工具。第二个关键取舍是技能的组织方式。一开始我想把所有逻辑塞进一个超大函数里后来发现维护起来极其痛苦。最终我采用“注册制”的架构每个技能是一个独立模块对外只暴露元信息和一个execute方法。用一个注册表管理器统一收纳所有技能。这样做的好处是新增技能时不需要改动调度器代码只需要写一个新模块并注册进去。这个架构对后期迭代太重要了后面实操部分你会看到它的便利。2. 技能系统的基础架构与运行链路2.1 三条核心链路各司其职搭建这套体系时我把整条数据流拆成三条链路注册链路、编排链路和反馈链路。这三条链路各管一段合在一起构成完整闭环。注册链路负责把技能“收编”进来。系统启动时注册表会扫描指定的技能目录读取每个技能的元数据文件把技能名、描述、参数结构、执行入口挂载到统一索引里。这个过程跟插USB设备差不多设备拔进来系统识别它的能力登记到设备管理器之后所有程序都能用这个设备的能力。编排链路是核心处理“模型选择了哪些技能、按什么顺序调用、参数从哪来”。模型感知技能的形式是一段描述文本和一个参数Schema它在对话中输出自己的“意图调用”格式通常是JSON。编排链路拿到这个JSON后先做合法性校验再找到对应技能的具体函数入口把参数传进去执行。反馈链路是把技能执行结果处理后重新喂给模型。执行结果往往是原始的、包含大量噪声的。比如一个查询数据库的技能返回了表格HTML直接扔给模型会让上下文爆炸。反馈链路会做截断、格式重排、摘要提取确保模型拿到的是简洁且有决策价值的信息。2.2 数据结构的顶层设计这一层是整个项目的“地基”。我设计技能元数据时参考了JSON Schema规范但不是照搬中间做了不少适配。核心的数据结构长下面这样skill_metadata { name: database_query, description: 查询业务数据库支持标准SQL返回查询结果列表。仅用于只读查询。, parameters: { type: object, properties: { table: {type: string, description: 要查询的数据表名}, conditions: {type: string, description: WHERE条件不带WHERE关键字}, limit: {type: integer, description: 返回行数上限, default: 10} }, required: [table] }, handler: skills.database:execute_query, timeout_seconds: 30, permission_level: read_only }这里有几个我后来才知道很重要的细节description不能写得模棱两可。模型会根据这段描述决定“要不要用这个技能”写得越明确、越具体模型的选择准确率越高。比如“数据库查询”不如“查询业务数据库只读支持SQL”效果好因为后者直接告诉模型“这是个只读能力你不该在这里做写入”。parameters的description字段是给模型看的不是给开发者看的。每个参数写上用途相当于给模型一本填表指南能显著降低参数构造错误率。permission_level是我自己加的用来做粗粒度的权限控制。比如“写文件”技能的级别是write“删数据”是dangerous在SDK层面就可以拦截高危险等级的操作。2.3 注册表与调度器的实现要点注册表管理器是整个系统的心脏。它维护一张内存字典key是技能名value是技能实例。注册时要做的一件事是“包装”原始技能函数不能直接暴露给调度器得套一层统一的执行壳。这层壳专门处理异常捕获、超时控制、日志记录。调度器不关心技能内部怎么实现它只按标准流程走接收模型传来的JSON → 解析并校验参数 → 调技能 → 收结果 → 规范化输出。这种设计让新增技能变成纯粹的“写业务代码”不碰框架逻辑。class SkillRegistry: def __init__(self): self._skills {} def register(self, metadata: dict, handler: Callable): name metadata[name] if name in self._skills: raise DuplicateSkillError(name) self._skills[name] { metadata: metadata, handler: handler } def dispatch(self, skill_name: str, params: dict) - SkillResult: if skill_name not in self._skills: return SkillResult.failure(f未知技能: {skill_name}) skill self._skills[skill_name] try: # 参数校验 validator validate_params(skill[metadata][parameters], params) if validator.has_errors(): return SkillResult.failure(f参数校验失败: {validator.errors()}) # 执行 start_time time.time() raw_output skill[handler](**validator.validated_params()) elapsed time.time() - start_time # 结果规范化截断 normalized truncate_output(raw_output, max_length2000) return SkillResult.success(normalized, elapsedelapsed, skill_nameskill_name) except SkillTimeoutError: return SkillResult.failure(f技能执行超时 {skill[metadata][timeout_seconds]}s) except Exception as e: return SkillResult.failure(f技能执行异常: {type(e).__name__}: {e})从这段代码你能看到几个关键设计点dispatch完全不接触具体业务逻辑所有技能都走同一个入口。参数校验在调用前完成避免脏参数进入执行层。超时控制和异常捕获统一收纳不会让一个技能崩溃拖垮整个Agent。结果截断是必须的不然模型上下文会被撑爆。3. 模型与技能的连接方式从决策到执行的完整链路3.1 把技能说明书喂给模型模型本身看不到也不理解任何技能代码它只接收“技能说明书”。当前主流LLM都支持函数调用或叫工具调用Agent系统需要把注册表里的技能元信息转换成模型API支持的格式。以OpenAI风格的工具定义为例转换逻辑大致是这样def build_tool_definitions(registry: SkillRegistry) - list[dict]: tools [] for name, skill_data in registry.list_skills(): meta skill_data[metadata] tools.append({ type: function, function: { name: meta[name], description: meta[description], parameters: meta[parameters] } }) return tools这段代码本身很简单但有一个非常坑的地方技能数量不能无上限地堆。我把50个技能一股脑塞给模型后发现模型的选择准确率明显下降。一开始以为是模型不行后来做了细致的对照实验才发现问题出在描述重叠上二十多个技能的描述里都有“查询”两个字模型很难区分微妙差别。我的处理方式是不把技能一次性给模型而是做一个“技能预筛选器”。先根据用户问题的关键词做一次粗粒度检索从上万个技能里挑出最相关的3-5个再交给模型精排。这一步的准确率贡献巨大。3.2 发起一次技能调用的完整流程拿一个实际场景走一遍用户问“昨天订单量是多少和上周相比涨了还是跌了”模型收到问题后会经过如下步骤意图理解模型意识到需要查询订单数据可能还需要做比较/计算。技能选择模型在收到的工具列表里看到query_order_stats和compare_period这两个技能与任务相关。参数构造模型根据参数描述生成JSON调用——{name: query_order_stats, parameters: {date: yesterday}}。系统拦截并执行Agent框架收到模型返回的tool_call请求把请求发给注册表注册表执行函数拿到结果。结果回填给模型把技能结果当作一条新消息附加到对话历史里模型继续推理要么再调下一个技能要么直接生成最终回答。这个流程里有几个容易被忽略的细节模型不一定一次只调一个技能它可能在单次响应里输出多个tool_calls框架得支持并行或按序执行。结果显示模型“调用了技能”不代表它已完成任务执行完以后还要让模型基于结果做摘要、做判断、形成最终回答。很多人做完第4步就直接把原始结果扔给用户体验很差。3.3 参数提取失败与重试策略参数提取是玩Agent最折磨的一环。模型不是人它不会等你说“等等这个参数别传错”它自己觉得匹配就匹配。最常见的情况是它把用户话里的日期字符串传给了期望整数时间戳的参数或者把本来应该拆成两个技能的动作硬塞到一个技能里。我总结了一套兜底策略实测下来能救回不少失败调用参数容错层在做参数校验时加类型转换尝试。比如字符串“2024-01-01”可以转成时间戳格式再放入参数。缺失参数重试如果模型漏了必填参数不要直接报错而是构造一条错误信息返回给模型明确告诉它“缺了哪个参数、应该填什么格式”让模型自己补。这比框架强行用默认值更智能。技能选择纠偏如果模型调用了根本不该用的技能——比如用户问“天气”它去查了数据库——就把错误信息和可用的技能列表重新给它让它重新决策。这一步其实就是把模型当成一个“会犯错但会自我纠错”的远程操作员。给它清晰的反馈它下一次大概率能改对。4. 实操过程从零搭建一套可用的技能系统4.1 环境准备与基础框架搭建我建议直接用Python 3.10以上版本配合一个简单的事件循环库就够用了不需要引入太重的异步框架。我的项目依赖很轻核心只有pydantic做参数校验、openai做LLM调用、structlog做结构化日志。先初始化项目结构agent-skills/ ├── main.py # 入口调度循环 ├── registry.py # 注册表管理器 ├── schemas.py # 参数校验与定义 ├── tool_bridge.py # 与LLM API对接的桥接层 ├── skills/ │ ├── __init__.py │ ├── database_query.py # 示例技能数据库查询 │ ├── file_operations.py # 示例技能文件操作 │ └── web_search.py # 示例技能网络搜索 └── logs/注册表管理器直接在启动时自动加载skills/目录下所有模块这个扫描动作可以通过pkgutil.iter_modules实现。我写过一版手动逐个导入的代码后来技能一多就烦了还是自动化舒服。# registry.py 中的技能加载部分 import importlib import pkgutil def auto_load_skills(registry: SkillRegistry, package_name: str skills): package importlib.import_module(package_name) for _, module_name, _ in pkgutil.iter_modules(package.__path__): module importlib.import_module(f{package_name}.{module_name}) if hasattr(module, SKILL_METADATA) and hasattr(module, execute): registry.register(module.SKILL_METADATA, module.execute)触发条件很简单模块必须导出SKILL_METADATA字典和execute函数框架才能认它。不用继承任何基类也不用实现什么接口这让我写技能时的心理负担小很多。4.2 实操示例一数据库查询技能这块我把一个真实项目里用的数据库查询技能简化放上来可能不够生产级但足够帮你理解完整套路。核心是两点连接管理放在启动阶段不要在技能执行时才去建立连接否则每次模型调用都要握手一次慢得不行。SKILL_METADATA { name: database_query, description: 查询业务只读数据库使用标准SQL。返回查询结果列名和最多20行数据。, parameters: { type: object, properties: { sql: {type: string, description: 完整SQL查询语句必须以SELECT开头}, database: {type: string, description: 目标库名称可选默认business} }, required: [sql] }, timeout_seconds: 15, permission_level: read_only } def execute(sql: str, database: str business) - dict: # 安全检查拒绝非SELECT语句 if not sql.strip().lower().startswith(select): raise ValueError(只允许执行SELECT查询) with get_connection(database) as conn: cursor conn.cursor() cursor.execute(sql, timeout10) columns [desc[0] for desc in cursor.description] if cursor.description else [] rows cursor.fetchmany(20) result { columns: columns, rows: [list(row) for row in rows], row_count: len(rows), } return result这里我埋了一个自认为很关键的安全护栏SQL必须以SELECT开头才能执行。这没法阻止所有的注入方式但至少能把“模型误输入删除语句”这种事拦下来。在Agent反复调试的阶段模型可能因为幻觉而写出DELETE FROM或UPDATE语句这种护栏能保住开发环境的数据库。4.3 实操示例二文件操作技能文件操作是另一个高频技能。模型经常需要读取配置、写中间结果、生成报告。这里的难点是路径安全——模型如果被恶意提示词引导可能尝试读写任意系统路径。所以我用了一个白名单根目录机制。import os SKILL_METADATA { name: read_file, description: 读取工作目录下的文本文件内容路径必须位于/workspace目录内。, parameters: { type: object, properties: { path: {type: string, description: 相对于/workspace的路径如reports/summary.md} }, required: [path] }, timeout_seconds: 10, } WORKSPACE_ROOT /workspace def execute(path: str) - dict: # 路径规范化后检查前缀 abs_path os.path.realpath(os.path.join(WORKSPACE_ROOT, path)) if not abs_path.startswith(WORKSPACE_ROOT /): raise PermissionError(路径越界了) if not os.path.exists(abs_path): return {error: 文件不存在} with open(abs_path, r, encodingutf-8) as f: content f.read(3000) # 限制读取长度 return {path: abs_path, content_length: len(content), preview: content[:1000]}路径越界检查是真的会救命。我在一次测试中让模型“读取某份配置”正赶上技能里拼路径的逻辑写得不严谨模型生成的路径直接跑出了工作目录。虽然那些配置都是不敏感的但这个小教训让我明白技能不是玩具该做的防御性编程一个都不能少。4.4 事件循环把模型和技能串起来调度循环是让Agent活起来的那个中枢。它的逻辑不复杂但要注意避免无限循环和死锁。我的实现用一个max_iterations上限限制模型与技能之间的轮次。def run_agent(user_query: str, max_rounds: int 8) - str: messages [{role: user, content: user_query}] for round_idx in range(max_rounds): tools build_tool_definitions(registry) response llm.chat_completion(messagestools_aware_messages(tools, messages), toolstools) if response.has_tool_calls(): # 执行模型选择的技能 for call in response.tool_calls: result registry.dispatch(call.name, call.arguments) result_message { role: tool, tool_call_id: call.id, content: json.dumps(result, ensure_asciiFalse)[:3000] } messages.append(result_message) continue # 没有工具调用说明模型觉得可以回复了 return response.content return 达到最大轮次停止运行实测的体感是大多数日常任务3-5轮就能完成超过8轮基本就是模型在绕路或者技能调用陷入死循环。设定轮次上限不只是保护算力更是逼着模型收敛结果。4.5 技能冲突与模型误选的处理策略多技能在手必然遇到“撞车”时刻。比如你有query_customer_info和query_customer_orders两个技能用户说“帮我查下客户A最近买了什么”模型可能先调第一个又调第二个绕了一圈也没拿全数据。我用来缓解这个问题的办法有三个描述区分度最大化强制要求技能描述里包含“该技能只用于…不用于…”的边界说明比如“查客户订单历史不返回客户联系方式”。技能拆分重组有时候不是模型选错而是技能粒度设计不合理。把“客户综合查询”拆成“查基本信息”和“查订单历史”让模型调用路径更清晰。这一步要配合测试看效果不能凭感觉拆。结果记忆如果模型连续调用多个技能前一个技能已经获取了客户ID后一个技能可以复用不必重新问模型。我在系统设计里加了一个轻量级会话缓存记录关键中间结果减少重复查询。5. 常见问题与排查技巧实录5.1 模型报错“这个技能不存在”这类报错绝大部分不是框架Bug而是模型从历史消息里“捡”到了一个已经不在本轮工具列表里的技能。我排查时的第一动作是查看模型发起tool_call前收到的最新tools参数如果tools里没有该技能模型却输出了它那就是上下文污染——模型的回复里带上了早期轮次的记忆。解决办法很简单给每一轮的messages做截断只保留最近的几轮工具交互或者把tools只传给最新那轮不保留在历史中。5.2 技能明明执行成功模型却答非所问这个问题的根源通常在结果回填格式。模型看完技能结果后不太容易从结构化JSON里提取关键信息。比如查询返回了100个字段模型可能把摘要写偏。我的经验是不要直接把原始JSON丢给模型而是用一个result_formatter包装一层主动提炼出“这次查询的目的、是否查到、关键数字是多少”再把这些文字拼到上下文中。效果立竿见影回答准确率大概提升了两成。5.3 上下文被撑爆怎么办技能返回结果如果太长上下文窗口很快就满了。我的方案是双管齐下后端截断技能在返回前就已经把输出截到合理长度一般2000字符以下。前端压缩框架在拼回对话历史时对结果做摘要把表格转成三五行关键指标而不是保留全量。遇到還真需要全量数据的场景就把长数据写入临时文件技能返回值里只保留文件路径再给模型一个read_file技能去按需读取。这个思路跟数据库的分页查询异曲同工控制的是每次进入上下文的信息量。5.4 排错的核心监控每个环节耗时我后期在项目里加了性能埋点每个技能的调用耗时、失败率、参数平均错误次数都能在日志面板上看到。有一次排查“Agent回复很慢”的问题查日志发现数据库查询技能平均耗时1.2秒而模型推理一次只要0.8秒这下优化方向很明确不是换模型而是优化连接池和SQL执行计划。另外强烈建议给技能调用加上traceId一个用户问题会触发多个技能调用没有统一的traceId你很难把“模型选错技能”和“技能执行超时”这两类问题分开来看。5.5 安全与稳定性不能只考虑功能最后一部分分享下安全层面的经验。技能系统的权力比普通API大得多因为模型可以自动调用技能人类审查往往滞后。我的安全基线有三条最小权限原则每个技能只拿完成任务所需最小权限数据库技能只读不写文件技能限定在一个目录。人工确认界面对permission_level为“dangerous”的技能比如删除、覆盖、发邮件强制要求人工点击确认后才执行。这个功能上线后我实际拦截过两次由于模型误导导致的误删操作。审计日志所有技能调用记录下用户原话、模型生成的调用参数、执行结果、耗时。这个日志的价值每次线上出问题你都会感谢当时的自己。6. 经验总结与实用心得我踩过的坑和收获的经验浓缩成几条。第一技能系统的成败不在技术多炫而在“技能设计的粒度”和“描述文案”。我见过很多人把系统堆到两百个技能最后模型连选哪个都犹豫。倒不如五十个精心打磨、描述互相不干扰的技能好使。每隔一段时间我会把技能描述全部打印出来对照看一遍发现有重叠就去重或合并。第二参数校验别太严也别太松。太严模型一次一次补参数耗轮次太松执行阶段各种类型错误。我在实际测试里找到一个平衡点必填参数严格校验可选参数尽量做宽容转换允许基本类型互相转。第三一定要做回归测试。模型和技能都处于迭代之中今天改了某个技能描述可能影响3个其他技能的命中率。我写了一个模拟问题集每次改动后全量跑一遍对比输出结果。类似的测试脚本不复杂实际上要排的雷都在这里提前炸出来了。最后Agent研发的节奏问题。这个领域唯一不变的就是一直在变技能接口的设计我会尽量贴行业通用范式避免绑定某一家模型API的特殊格式。这样以后换模型、换框架技能层不用大改。你做技能系统的前两周可能会不断推翻重来但一旦接口和架构稳定了后面就只管往里面添加具体技能越做越顺手。这个项目还有不少可以扩展的方向比如多Agent协同时的技能委派、技能自学习根据历史调用记录自动优化描述、更细粒度的权限管控等。不过这些都是后话了先把基础框架跑通后面再一步步长肉。