恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
agent-skills实战:从Function Calling到结构化技能库,让LLM Agent真正会干活
首页
资讯中心
/
agent-skills实战:从Function Calling到结构化技能库,让LLM Agent真正会干活
agent-skills实战:从Function Calling到结构化技能库,让LLM Agent真正会干活
发布时间:2026/10/7 4:09:12
从模型只会聊天到真正“干活”中间隔着一整套技能体系。我过去一年都在折腾 agent-skills——也就是给智能体配一个结构化的技能库让它能调用代码、查数据、操作文件、组合复杂流程。这个项目让我对 LLM Agent 的工程化有了很不一样的理解今天把整个思路、代码骨架和踩过的坑一次讲清楚希望对想自己搭 Agent 系统的人有帮助。1. 从“能聊天的模型”到“会干活的智能体”技能就是那座桥1.1 为什么现在大家开始谈 Skills 而不是 Tools前两年做 Agent 的时候大家的做法很直接给模型挂几个函数叫 Function Calling让它在对话里决定调哪个函数。这个模式在小 Demo 里跑得非常顺模型知道“用户想查天气就调 get_weather”一切看起来都很好。但一旦任务复杂度上来Tools 模式就会露馅。原因很简单一个真实业务场景里工具数量轻松超过几十个甚至上百个模型的上下文窗口塞不下所有工具的详细描述。即便塞得下模型在面对“查数据、清洗、建模、出报告”这种多跳任务时也没有能力把工具串成一条合理的流水线它只是每次都选一个孤立的工具去调用。Skills 的思路完全不同。它把“工具”这个概念升级为“技能”工具是原子操作比如“读取文件”“发送请求”。技能是带目的、带步骤、带约束的完整能力比如“生成本周销售分析报告”这个技能内部会编排读数据、做统计、画图表等多个步骤。我在实际项目里最直观的感受是Tools 是给模型单手用的螺丝刀Skills 是给模型配好的一套工具箱加说明书。模型不需要理解每个螺丝刀头的型号只要知道“我要修椅子”就应该去打开“木工维修”这个技能包技能包内部自己决定用哪几种工具。1.2 Skills 的核心本质把不可控的模型行为变成可控的工程资产驱动我认真搞 agent-skills 的还有一个更现实的理由纯靠 prompt 让模型自由发挥结果太不稳定了。同一个任务今天跑通了明天同样的输入可能就跑到另一个分支里出不来。技能化的本质是把“模型自由发挥的部分”压缩到最小把“确定性的部分”扩大。一个设计良好的技能包含清晰的触发条件什么时候该用这个技能固定的执行流程内部先做什么后做什么明确的输入输出 schema需要哪些参数返回什么结构错误处理策略哪一步失败之后该重试还是放弃这样模型扮演的角色从“执行者”降级为“调度者”。它不需要知道如何一步步做统计分析它只需要判断“用户这个需求应该激活哪个技能”。具体的统计分析过程由技能内部的确定性代码完成。我用一个类比来解释技能体系和早年软件工程里的“微服务”很像。每个技能是一个边界清晰、独立部署的服务模型是网关负责把用户请求路由到正确的服务上。这套思想落地到 Agent 上就形成了 agent-skills 这个项目的整体架构。2. agent-skills 的底层骨架注册表、描述规范与执行器2.1 技能注册表让 Agent 知道“你有什么可用”一个技能系统首先要解决的是“Agent 到底知道自己有哪些能力”。这个信息必须放在一个模型能快速读取、准确理解的地方我称它为技能注册表Skill Registry。注册表最简单的形态是一个 JSON 文件每个技能占一个条目。设计上我会坚持几个原则技能 ID 全局唯一且命名要符合“动词对象”的格式比如 generate_report、query_database、validate_config。每个技能必须包含 name、description、parameters、required_permissions 四个字段。description 控制在 60 到 120 个词之间太长模型读不进来太短模型理解不了边界。示例{ skills: [ { id: generate_sales_report, name: 生成销售报告, description: 当用户需要查看、汇总或分析销售数据并输出可视化报告时使用。内部会自动读取销售数据库、计算同比环比、生成柱状图和趋势图最终输出 Markdown 格式报告。如果用户只需要原始数据不要调用此技能。, parameters: { type: object, properties: { period: { type: string, enum: [daily, weekly, monthly] }, region: { type: string, description: 大区名称不传则默认全国 } }, required: [period] }, required_permissions: [database:read, chart:generate] } ] }我强烈建议把注册表单独做成一个可热加载的模块而不是写死在 Agent 主代码里。后面你会经常改技能描述、调参数 schema如果每次都要重新部署整个 Agent效率太低。热加载的方式就是一键刷新注册表缓存Agent 下次请求时就会带上最新技能列表。2.2 技能描述怎么写才不会被模型误用技能描述是 agent-skills 系统里最容易被低估的部分。我踩过最重的一次坑就是技能描述没写清边界导致模型把“生成销售报告”的技能用在“查询单个订单详情”这种简单查询上绕了一个大圈子结果返回了一堆没用的数据。后来我总结了一套描述模板每个技能的描述都按下面这个结构写触发场景什么时候用这个技能开头第一句就要点明。能力范围这个技能能做什么列出主要动作。内部过程简要说明内部会执行什么步骤让模型对耗时和副作用有预期。不适用场景明确说哪些情况不要用这是防止误用的关键。比如当用户需要查看、汇总或分析销售数据并输出可视化报告时使用。内部会自动读取销售数据库、计算同比环比、生成柱状图和趋势图最终输出 Markdown 格式报告。如果用户只需要查询单条数据或原始明细请优先使用 query_database 技能不要调用本技能。这样写有一个额外好处模型在规划阶段就会先做一次“技能筛选”把明显不适合的技能过滤掉减少无效调用。实测中准确率能提升十几个百分点。2.3 执行器内部的状态管理与超时控制注册表只是索引真正执行技能的是 Skill Executor技能执行器。执行器是每个技能运行的沙箱环境我在设计时重点关注两件事状态隔离和超时熔断。状态隔离的意思很简单——每个技能的运行环境不能共享可变状态。技能 A 运行到一半创建的临时文件技能 B 绝对不能看到技能 A 设置的全局变量技能 B 也不能读取。原因不是技术洁癖而是模型可能并发调度多个技能一旦共享状态结果就会互相污染而且这种污染极难排查。超时熔断则是保命设计。模型调用技能时经常会出现“技能内部死循环”或者“外部 API 长时间无响应”的情况。如果没有超时机制整个 Agent 的主循环会被一个技能卡死后续所有任务都陪葬。我在执行器里给每个技能加了三层控制单次技能调用超时默认 30 秒技能内部单步操作超时默认 10 秒整体执行内存上限防止技能读取超大文件撑爆进程一个执行器的简化逻辑import asyncio from concurrent.futures import TimeoutError async def execute_skill(skill_id: str, params: dict, registry: dict): skill_def registry.get(skill_id) if not skill_def: return {status: error, message: fskill {skill_id} not found} # 从独立进程池里执行技能避免状态污染 loop asyncio.get_running_loop() try: result await loop.run_in_executor( None, skill_def[handler], params ) return {status: ok, data: result} except TimeoutError: return {status: error, message: skill execution timeout} except Exception as e: return {status: error, message: str(e)}我第一次跑通这个逻辑的时候最大的体会是技能执行器的可靠性比技能本身的“智能程度”重要一个量级。因为模型会试错、会重试但只要执行器不稳定模型表现就会雪崩式变差。3. 手把手搭一个最小可用的 agent-skills 系统3.1 技能定义文件长什么样整个 agent-skills 系统的落地从定义技能文件开始。我把每个技能独立成一个 Python 文件放在 skills/ 目录下每个文件里包含三件套schema参数定义description给模型看的描述run核心执行函数以查询数据库技能为例子# skills/query_database.py import sqlite3 SCHEMA { type: object, properties: { sql: {type: string, description: 要执行的 SQL 语句}, limit: {type: integer, default: 100} }, required: [sql] } DESCRIPTION ( 在内部 SQLite 数据库中执行查询并返回结果。 支持 SELECT 语句返回结果集为 JSON 格式。 若需要写入或修改数据请使用 update_database 技能。 禁止执行 DROP、DELETE 等危险操作。 ) def run(params: dict) - dict: sql params.get(sql, ) limit params.get(limit, 100) if not sql.strip().lower().startswith(select): return {error: only SELECT statements are allowed} conn sqlite3.connect(app.db) try: cur conn.cursor() cur.execute(sql) rows cur.fetchmany(limit) columns [desc[0] for desc in cur.description] return {columns: columns, rows: rows} finally: conn.close()结构刻意做得简单因为核心目标不是让每个技能写得多漂亮而是让整个库可以统一被加载、统一被调度、统一被监控。3.2 技能注册与动态加载实现有了单个技能文件下一步就是写一个自动扫描和注册机制。我常用的实现是启动时扫描 skills/ 目录下所有 .py 文件导入模块提取 SCHEMA、DESCRIPTION、run写入注册表。这个过程中有几个细节值得注意模块名冲突技能文件名不能随意起建议统一用小写下划线风格比如 query_database.py避免类和模块名混在一起。导入失败要能单独隔离某个技能文件如果因为依赖缺失导入失败不应该影响其他技能加载。注册器要捕获异常并把技能标记为 disabled。注册表缓存到内存并提供 refresh 接口方便开发时改了技能描述立即生效。加载代码的骨架# skill_registry.py import importlib import pkgutil import skills def load_all_skills(): registry {} for module_info in pkgutil.iter_modules(skills.__path__): module_name module_info.name try: module importlib.import_module(fskills.{module_name}) registry[module_name] { schema: module.SCHEMA, description: module.DESCRIPTION, run: module.run, name: module_name, } except Exception as e: print(f[skill registry] failed to load {module_name}: {e}) registry[module_name] {disabled: True} return registry加载完成后Agent 侧只需要做一件事把 registry 里所有 description 拼成一个技能列表塞进 system prompt 或作为 tool definition 传给模型。这也是 agent-skills 和传统 Function Calling 最像的地方接入成本并不高。3.3 Agent 调度循环里如何选技能技能选得对不对直接决定任务成败。我用的调度循环分两步第一步粗筛。模型基于所有技能的 description 生成一个候选技能列表比如 Top 3。这一步只要求“别漏”不要求精准。第二步细选。把候选技能的完整 schema 和详细文档传给模型由模型确定最终调用哪个技能、参数怎么填。这一步要求精准。这种两阶段筛选的效果比直接让模型从 50 个技能里选一个要好很多。原理也简单第一步的搜索空间太大模型容易受相似描述干扰第二步只剩两三个候选模型可以仔细对比。调度循环的伪代码def agent_loop(user_input, registry): # 第一阶段粗筛模型返回候选技能ID列表 candidates llm.extract_candidate_skills(user_input, registry.descriptions) # 第二阶段细选基于完整schema决定最终调用 for skill_id in candidates: skill registry[skill_id] decision llm.verify_skill_selection(user_input, skill) if decision.confirmed: result execute_skill(skill_id, decision.params) return format_response(user_input, result) # 所有候选都不合适则直接告知用户能力边界 return 抱歉当前没有合适的技能处理这个请求。这个循环跑得久了之后你会发现一个很有意思的现象真正影响 Agent 成功率的根本不是模型有多大而是注册表里的技能描述与真实任务的对齐程度。模型再聪明面对一份含糊的技能列表也只能瞎猜。4. 真实跑起来之后踩过的坑4.1 技能描述含糊导致模型调用错工具第一个坑是我在技能库里加了“数据概览”和“数据深度分析”两个技能时遇到的。当时前者的描述写的是“展示数据的基本情况”后者的描述写的是“对数据进行深入分析并生成报告”。结果模型在面对“看看这个月的销售怎么样”这种问题时反复在两个技能之间横跳有时候选这个有时候选那个输出的内容完全取决于模型心情。这个问题我花了三天才定位到不是模型不稳定而是两个技能的描述区分度不够。对模型来说“基本情况”和“深入分析”之间的语义边界太模糊了。后来我把描述改成强边界版本数据概览仅输出总销售额、订单量、客单价三个指标不进行任何趋势分析和图表展示。数据深度分析输出按维度拆分的趋势变化、Top/Bottom 排行、异常检测结果包含至少两张图表。改完之后模型几乎不再选错。这个案例给我的教训是技能描述表面上是给模型看的本质上是在定义你的业务语义边界。描述里每一个含糊的词都会成为模型跑偏的种子。4.2 并发环境下共享状态被污染第二个坑出现在我把 agent-skills 从单用户 Demo 改成多用户服务的时候。当时我为了省事在技能模块里用了模块级缓存变量想着“反正就是存个临时结果”。结果上线第一天就出事用户 A 触发了一个耗时的报表生成技能用户 B 同时触发了一个短查询技能B 的查询结果居然混进了 A 的报表里。排查链路让我记忆深刻我首先怀疑是数据库读取的问题查了半天没用。然后怀疑是模型上下文污染把请求日志翻出来对发现模型给两个技能的参数完全正确。最后我静下心来看技能代码才发现问题出在模块级变量上。skills/report_generator.py里有个_cache {}A 技能运行时往里写了中间状态B 技能运行完也往同一个变量里塞东西A 后面的步骤读到的就是 B 写入的脏数据。修复方式很粗暴所有技能内部禁止使用模块级可变变量需要缓存就通过参数显式传递。如果技能执行器支持工作目录隔离那更好。我最终把执行器改成了每个技能独立进程运行问题彻底消失。4.3 技能相互调用出现循环依赖第三个坑比较隐蔽是在技能编排阶段出现的。我设计了一个“生成销售报告”的技能内部会调用“查询数据库”技能和“生成图表”技能。这在逻辑上没什么问题但后来我加了一个“自动巡检”技能它内部会检查所有技能的健康状态其中就包括“生成销售报告”。问题来了自动巡检检查“生成销售报告”时会真实触发一次报告生成而报告生成内部又查了技能列表其中就包含“自动巡检”。如果不加控制这两个技能可能互相等待形成一个死循环。解决思路是给技能调用链路加上深度限制和去重标记每个技能调用时带上调用链 ID。注册表里记录每个技能的上游调用来源。如果检测到技能已经在当前调用链里直接拒绝重复调用。这个坑的本质是技能之间的依赖关系不能是隐式的必须在注册表里显式声明。我后来在技能定义里加了一个dependencies字段专门列出该技能会调用的其他技能 ID配合运行时校验彻底解决循环问题。4.4 模型硬编造技能返回值最后一个坑也是所有做 Agent 的人都会遇到的模型在没有真实调用技能的情况下直接编造了一个技能返回结果。场景是这样的用户问“帮我查一下订单 A123 的状态”模型在对话历史里发现之前有一个技能调用返回过类似的 JSON于是这次根本没触发技能调用直接照葫芦画瓢生成了一段看似合理的状态数据。但它生成的订单状态和真实数据库里完全不一致。这个问题不能在代码层彻底解决因为模型是否调用技能是概率行为。但我摸索出一套降低概率的组合拳每次模型需要展示技能结果时强制格式化没有真实返回值的字段不许填充。在 system prompt 里明确告知任何涉及实时数据的回答必须先完成技能调用。在 Agent 主循环里加一个校验器如果模型的回复里出现了疑似技能输出的 JSON 结构但调用日志里没有对应记录就打回重写。组合拳实施后编造返回值的情况大幅减少但从没归零。这说明只要用概率模型做调度幻觉就会一直存在工程上能做的就是压制它而不是消灭它。5. 把技能库当产品来经营版本、评估与安全边界5.1 技能版本管理与灰度上线技能库做大了之后会自然出现一个需求新版本技能先在小范围试试别直接推给所有用户。这就是技能版本管理和灰度上线。我给每个技能增加版本号字段并在注册表里维护一个路由规则表{ skill_id: generate_sales_report, version: 2.3.0, route_rules: { default: 2.3.0, user_group_alpha: 2.4.0-rc1, user_group_beta: 2.3.0 } }灰度时我把特定用户组指向新版本技能观察成功率、耗时、用户反馈。如果新版本的效果指标显著优于旧版就把 default 切到新版本。如果新版本出现异常只改路由规则就能回滚不用重新部署 Agent 主程序。这个机制后来救过我一次我曾经把一个技能的描述改短了三分之一本地测试全部通过但灰度到真实流量后发现模型出错率涨了两倍。如果当时没有灰度机制直接全量上线线上问题会立刻爆发。5.2 怎么评估一个技能“好用不好用”技能质量不能靠感觉评估我建了一套自己的度量指标每两周对全部技能做一次体检调用成功率技能实际执行中返回成功状态的比例。低于 85% 的技能优先排查。参数合格率模型调用技能时给出的参数通过 schema 校验的比例。这个指标低说明技能描述没写清楚。误用率不该被调用却被调用的比例。这个指标通常需要人工抽检。平均耗时技能从触发到返回的耗时超过阈值的技能会影响整体体感。体检结果直接反映在注册表状态里不合格的技能会被降级不再出现在候选列表里直到修复完成才恢复。这相当于给技能库建立了一个质量闭环。5.3 权限与安全边界只给技能最小必要权限最后是安全设计。技能系统的杀伤力比单个工具大得多因为技能往往串联了多个敏感操作。一个“生成报告”技能内部可能要读数据库、写临时文件、触发对外请求权限边界必须清晰。我在技能定义文件里强制增加 required_permissions 字段并在执行器入口统一做鉴权。权限的最小集指的就是只读数据库的技能声明 database:read绝不授予 database:write。需要操作文件系统的技能限定在特定工作目录内用绝对路径拼装时必须做路径校验。需要访问外部 API 的技能必须显式声明域名白名单执行器统一拦截。安全边界的本质是技能越强大越要限制它的活动范围。宁可让一个技能因为权限不足而失败也不能让它带着过大的权限四处乱跑。我在早期犯过一个低级错误在技能里放了直接拼接字符串并执行系统命令的代码当时只是图省事。后来审查时惊出一身冷汗因为一旦模型误生成了一段恶意命令整个技能就会变成执行器。所以现在我强制规定所有系统调用必须走封装函数禁止在技能代码里出现任何裸的 shell 拼接。围绕 agent-skills 搭完整套系统之后我最大的体会是技能化不是给模型加功能而是给不可预测的模型行为建立工程边界。技能的描述要精细状态要隔离权限要最小化版本要可灰度——每一条都在压缩模型的自由度、扩大系统的确定性。这套思路不挑具体模型今天换一个更强的模型技能库依然能跑模型只需要做好调度这一件事就行。如果你也开始搭技能库我建议从三个技能开始一个只读查询、一个文件处理、一个内容生成把注册表、执行器、调度循环跑通之后再扩展这样踩坑的成本会小很多。