ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

agent-skills框架实战:让LLM Agent从会聊天到会干活的技能编排之道

agent-skills框架实战:让LLM Agent从会聊天到会干活的技能编排之道 作为一个常年和 LLM Agent 打交道的人我最近一直在折腾一个名为agent-skills的项目。它算不上什么宏大架构却把我在实际业务里踩过的坑、绕过的弯都串了起来。这个项目表面上是给智能体挂载“技能”但做深了你会发现它其实在解决一个很本质的问题怎么让语言模型从“会聊天”变成“会干活”。如果你正在做 AI 应用集成、写工具调用逻辑或者想让自己训练的 Agent 不再像个只会说不会做的“嘴强王者”那这篇文章里的思路和代码你应该能直接用上。先别急着搜框架、看论文。我从一个非常朴素的需求出发我想让 Agent 能自主完成“查天气、算数学、读本地文件、调用公司内部 API”这四类事情。看似简单真做起来却涉及到技能的表示、注册、编排、容错等一系列问题。下面是我整个拆解和落地过程。1. 拆解“agent-skills”为什么叫技能而不是功能或插件在开始写代码前我花了很长时间想一个问题如果只是给 Agent 加几个函数那叫“工具”就够了为什么要叫“技能”后来想明白了技能这个词背后藏着三层含义可复用、可组合、可进化。这三层含义直接决定了代码怎么写。1.1 技能、工具与插件的边界到底在哪很多人把工具Tool、插件Plugin和技能Skill混为一谈但实际落地时差别很大。工具是最底层的原子操作比如“执行一段 Python 代码”“发起一次 HTTP 请求”。插件是工具的打包形态通常由第三方提供比如一个“GitHub 插件”内含多个 API 调用。而技能更偏向于“某个场景下的完整解决路径”。举个例子工具“读取文件内容”插件“读取/写入/列出目录文件”打包组合技能“分析一份日志文件并总结异常”它内部可能要先读文件、再过滤关键词、再调用大模型总结这种分层在 agent-skills 项目里非常关键。如果一开始就把技能拆得太碎Agent 的每一步决策都要调一次工具推理链会变得很长既费 token 又容易出错如果拆得太粗Agent 又无法灵活组合遇到新场景直接傻眼。我的判断标准是一个技能应该能独立完成一个“用户可感知的完整目标”。比如“查天气”算一个技能而“获取经纬度”只是它的内部步骤。1.2 技能体系的三个核心层次描述层、执行层、评估层我把整个 agent-skills 框架拆成了三层第一层是描述层。这一层解决的是“Agent 怎么知道自己在什么时候该用什么技能”。每个技能必须有清晰的名字、描述、参数说明和触发条件。这里有个常被忽略的点技能描述是写给大模型看的不是写给人看的。你写“执行数学运算函数输入两个参数”模型大概率会犹豫你写“当用户提出任何涉及计算、数值比较或公式推导的问题时调用此技能”模型就能精准触发。第二层是执行层。这一层负责把大模型生成的参数映射到真实代码调用。需要处理参数解析、类型转换、错误重试。我在项目里用 JSON Schema 做参数校验后面会展开说。第三层是评估层。技能不是挂上去就万事大吉你需要知道它到底有没有被正确使用。我记录了每次技能调用的输入、输出、耗时和成功标志并定期回看。没有评估层你就无法判断到底是技能定义写得差还是模型理解能力不行还是代码本身有 bug。三层结构立住了后面的代码编写和迭代就有了方向。接下来聊设计思路中最关键的几个环节。2. 技能注册与编排让 Agent 知道自己“会什么”和“先用什么”在设计 agent-skills 的早期版本时我把 20 多个技能一股脑塞进系统提示词里。结果模型经常漏掉后面的技能尤其当用户请求涉及多个技能时调用顺序和依赖关系完全失控。痛定思痛我决定把所有技能搬到一个显式的注册表里并引入简单的编排引擎。2.1 技能注册表一份给模型看的“简历”注册表本质上是一个有序的技能列表每项包含元信息。我强烈建议用不可变的数据结构来定义而不是直接写在提示词模板里。这样做的好处有三个可以做参数校验、可以动态增删技能、可以在日志里完整回溯。这里给出一个技能注册项的示例结构{ name: calculator, description: ( 当用户请求进行数学计算包括加减乘除、幂运算、平方根、 百分比乃至简单的方程求值时使用。 如果是复杂表达式请将公式按运算法则拆解后传入。 ), parameters: { type: object, properties: { expression: { type: string, description: 要计算的数学表达式例如 3 * 4 12 / 2 } }, required: [expression] }, version: 1.2.0, enabled: True }注意几个设计细节description 必须用“当...时使用”的句式明确触发条件。我试过很多风格最终发现这种句式在模型检索时的命中率最高。参数描述要配示例。模型对字符串格式的敏感度极高给出示例能显著降低参数生成错误率。version 字段要保留。技能代码更新后如果旧 session 还在用旧描述容易产生诡异错误。有了版本号排查时一眼就能看出是不是新旧技能混用。我把注册表实现成了一个 Dict[str, SkillSpec]key 是技能名value 是完整的规范。2.2 技能编排先粗后细别一开始就上复杂 DAG提到编排很多人第一反应是上图数据库、DAG、状态机。但我个人的经验是业务初期的技能编排用“分步提示 显式调用记录”就足够了。真正的编排发生在两个层面第一个层面是技能内部的多步执行。我引入了一个“内部步骤链”的概念。比如天气查询技能它内部可能包含“获得城市代码”和“调天气 API”两步。这两个步骤不需要 Agent 参与决策完全由技能代码内部串起来。这能减少大模型的决策次数也更容易定位错误。第二个层面是跨技能的顺序调度。当用户说“帮我查下明天北京天气然后计算下温差”时严格来说只有“查天气”是技能调用计算温差是后续的自然语言动作。但如果用户说“帮我把昨天的销售数据和今天的对比”就需要 Agent 先后调用“读取数据文件”和“数据分析”两个技能。这种跨技能调度我用一个很轻量的方案解决维护一个session_skill_memory记录本次会话中已经调用过的技能。当 Agent 决定调用新技能时我会把前序技能的输出摘要拼进当前上下文。这样 Agent 自然就知道该基于哪个先决条件继续操作不需要额外引入复杂的编排引擎。3. 实操从零搭一个轻量的 agent-skills 框架理论聊完了现在进入动手环节。我不会贴一个庞大的框架代码而是把核心机制拆开给你看一套基于 Python JSON Schema 的轻量化实现代码量不大但麻雀虽小五脏俱全。3.1 技术选型为什么是 Python JSON Schema选择 Python 没什么悬念生态里大模型相关的库基本都是 Python 首选。真正需要解释的是为什么用JSON Schema做参数校验而不是手写 if-else 或者用 Pydantic。JSON Schema 的好处在于它本身就是一份“机器可读的说明文档”。你可以直接用同一份 Schema 做两件事填进提示词让模型按格式生成参数对模型的输出做严格校验。Pydantic 当然也能做但它的强项是把校验后的结果直接转成 Python 类型。如果你希望技能的定义是纯数据驱动的甚至希望以后能让用户自定义上传技能JSON Schema 是更中立的格式。我的做法是两者结合用 Schema 做描述和边界校验用 Pydantic 做内部类型转换。3.2 技能解析与执行模型只负责出参数剩下的交给代码核心逻辑可以拆成四步解析模型输出、校验参数、执行技能函数、返回结果。先看解析与校验部分。大模型工具调用的返回格式通常是一个 JSON 字符串不同框架略有差异我的处理方式是交给一个小函数统一解析import json import jsonschema from typing import Any, Dict, Optional def parse_and_validate(raw_output: str, schema: Dict[str, Any]) - Optional[Dict[str, Any]]: 解析模型输出的参数字符串并按照 schema 校验。 如果解析失败或校验失败返回 None。 if not raw_output or not raw_output.strip(): return None try: # 很多模型会额外套一个代码块标记这里先清洗掉 cleaned raw_output.strip().removeprefix(json).removesuffix().strip() params json.loads(cleaned) except json.JSONDecodeError as e: print(f[param] JSON 解析失败: {e}) return None # 校验参数是否符合技能定义 try: jsonschema.validate(instanceparams, schemaschema) except jsonschema.ValidationError as e: print(f[param] 参数校验失败: {e.message}) return None return params这段代码里有几个我踩坑后加进去的细节。removeprefix和removesuffix不是多余操作——实测部分国产模型在返回参数时很爱把内容塞进 markdown 代码块里不清理掉直接json.loads必挂。校验失败时返回None比抛出异常更适合 Agent 场景因为你可以把这个None传给上一层的重试逻辑。接下来是技能执行器的骨架。每个技能本质是一个可调用对象我把它们组织成一个注册表class Skill: def __init__(self, name: str, description: str, func, schema: Dict[str, Any]): self.name name self.description description self.func func self.schema schema # 供大模型读取的技能描述 def spec_text(self) - str: return ( f### {self.name}\n f描述: {self.description}\n f参数Schema: {json.dumps(self.schema, ensure_asciiFalse)}\n ) def run(self, params: Dict[str, Any]) - Any: # 执行前再校验一次防止内部代码走了非预期分支 parse_and_validate(json.dumps(params), self.schema) return self.func(**params)有同学会问为什么执行前还要再校验一次因为有些时候技能会从内部上下文里拿参数去调另一个技能这些参数不是大模型直接给的而是中间层自动拼装的。多校验一道能防住不少“内部变量污染”的诡异问题。3.3 技能调度的主循环让技能调用“可记忆、可回退”调度逻辑是整个框架里最能体现实战经验的地方。我的实现里多轮对话中技能调用不能只做一次它要维护一个会话状态的缓存并且支持“参数不全时反问用户”而不是直接报错。核心主循环如下from typing import List, Dict, Any, Optional class AgentWithSkills: def __init__(self, skills: List[Skill]): self.skills {skill.name: skill for skill in skills} # 会话级技能调用历史 self.skill_history: List[Dict[str, Any]] [] # 记录上一次工具调用的结果用于多轮追问 self.last_output: Any None def call_skill(self, skill_name: str, raw_params: str) - str: if skill_name not in self.skills: return f错误未知技能 {skill_name}可用技能为 {list(self.skills.keys())} skill self.skills[skill_name] # 校验后的参数 params parse_and_validate(raw_params, skill.schema) if params is None: # 不直接返回失败而是提示模型补充纠错 return ( f技能 {skill_name} 的参数解析或校验失败。 f请参考以下参数格式重新提供完整参数{json.dumps(skill.schema, ensure_asciiFalse)} ) try: self.last_output skill.run(params) self.skill_history.append({ skill: skill_name, params: params, output: self.last_output, }) # 把结果转成字符串回填给模型继续推理 output_str self.last_output if isinstance(self.last_output, str) else json.dumps(self.last_output, ensure_asciiFalse) return f技能 {skill_name} 执行成功结果为{output_str} except Exception as e: # 记录异常方便排查 print(f[skill-error] {skill_name} 执行异常: {e}) return f技能 {skill_name} 执行出错{e}请根据错误信息修正参数或更换方案。 def build_system_prompt(self, extra_intro: str ) - str: sections [extra_intro, 你是一个可以通过调用技能来解决问题的智能体。以下是可用技能及其触发条件说明] for skill in self.skills.values(): sections.append(f---\n{skill.spec_text()}) # 把历史调用情况拼进去让模型知道已经执行过什么 if self.skill_history: sections.append(\n当前会话已执行的技能调用记录) for record in self.skill_history[-5:]: # 只保留最近5条避免上下文过长 sections.append(f - {record[skill]}({json.dumps(record[params], ensure_asciiFalse)}) - {str(record[output])[:200]}) return \n\n.join(sections)这个主循环里有三点值得单独拎出来说。第一通过构建 system_prompt 而不是强行注入历史。很多新手喜欢在 user 消息里拼历史技能记录这样会破坏对话的自然语义。我更推荐把所有已调用的技能结果拼进 system prompt 的“当前会话状态”里这样模型在推理时天然会把它当成已知上下文而不是把它当成一次额外的用户指令。第二错误信息里带上纠错指引。当参数校验失败时我返回的不是“调用失败”而是“请参考以下参数格式重新提供”。这个微小的措辞差异能让大模型在下一轮正确地把缺失参数补齐。在一次测试中用了纠错提示后模型在 3 轮内自动修复参数的成功率从 60% 提升到了 92%。第三历史记录只保留最近 5 条。这条是我用 token 成本喂出来的经验。技能调用记录往往很长尤其是读文件类的技能会把内容原样回填。如果全量塞进上下文很快就会把对话窗口撑爆。只保留后 5 条既能维持当前任务的连贯性又不会让历史成为噪音。到这里一个完整的 agent-skills 框架已经能跑通了。从技能注册、参数解析、执行调度到结果回填。但跑通只是第一步真正让你头疼的问题往往出现在“看着能跑一用就废”的那一瞬间。4. 常见问题与排查实录技能失效时我在查什么折腾 agent-skills 的过程大部分时间不是花在写代码上而是花在排查各种莫名其妙的“模型不听话”上。我把最常遇到的几种问题和对应的排查路径整理成了一张表照着查能省不少时间。现象可能原因排查步骤解决方案模型完全没发起技能调用技能描述写得太抽象或触发条件没写清楚1. 查看完整 system prompt 是否有技能定义2. 检查技能描述里是否有“当用户...时使用”的句式重写描述加触发场景示例模型调用了技能但参数全是错的参数 schema 描述不清晰或缺失默认值说明1. 打印模型生成的原始参数2. 对照 schema 检查是否有 required 参数缺失在每个参数字段中补充格式示例和默认值语义同一场景下有时调用有时不调用技能在注册表里的排列位置太靠后查看系统提示词长度后半部分容易被忽略将高频技能往前放或分组压缩描述技能执行成功但结果不对模型返回的参数被内部代码二次篡改检查 run 方法里是否对参数做过额外修改执行前打印一次参数快照确认传入完整度多轮对话后技能越来越“懒”历史记录里塞了太多无关内容检查 skill_history 的长度和内容只保留与当前任务相关的最近调用记录新技能上线后旧技能全部失效新技能描述格式与旧版不兼容检查是否所有技能仍遵循相同的 spec_text 格式增加技能 schema 版本校验统一模板这些问题的共性根源只有一个你给模型的信息不够“结构化”。大模型天生擅长理解自然语言但它对模糊的格式极其敏感。你在技能描述里多用一点精确的触发条件、参数示例、语义边界它的表现就会立刻上一个台阶。下面挑三个最典型的场景详细复盘。4.1 案例一技能描述太“文艺”模型压根不知道该出手我曾经给“文件搜索”技能写过一句描述“深入文件系统定位用户请求的特定资源”。听起来很高级但模型完全不买账。用户说“帮我把上周的日报找出来”Agent 一个技能都不调直接开始编。后来我把描述改成当用户请求查找本地或远程文件系统中的文件时使用。 典型的触发词包括查找、搜索、定位、找一下、列出...文件。 参数 path 是起始目录keyword 是文件名或内容中包含的关键词如果用户给出了明确文件名keyword 必须设置为该文件名。改完后再测触发率直接飙升。这个例子说明一个道理描述要服务于“匹配”而不是服务于“理解”。你写得再准确只要和用户习惯的表达不一致模型就匹配不到。4.2 案例二参数校验失效我把大模型逼成了“复读机”有一版技能代码我为了省事在参数校验失败后让 Agent 重新生成同一套参数。结果它像复读机一样连续三次生成完全一样的错误参数循环死锁。排查后发现问题出在错误信息里——“请提供参数a 和 b”这句话不够具体。模型不知道 a 和 b 到底该填什么。我的解决方法是在错误回报里直接贴出带示例的 schema 片段参数不符合要求。当前技能需要的参数结构为 {expression: 3 * 4 12 / 2, precision: 2} 请对照上述结构重新生成完整参数。因为我在解析时已经拿到了完整的 schema所以很容易直接反序列化一个默认示例模板回填到报错里。模型看到实例样板修正速度比我预想中快得多。4.3 案例三一个隐性 bug丢参数导致工具调用“幽灵失败”这里分享一个排查了最久、最隐蔽的问题。技能调用记录里明明显示参数对了但真正执行时函数却拿到空值。最后发现是内部序列化时None值被过滤掉了。某个技能在解析参数时使用了{k: v for k, v in params.items() if v is not None}这个操作把precisionNone这类“显式空值”直接忽略导致后续算法走了默认分支。解决办法很简单在run方法里禁止任何形式的参数空值过滤把原始字典原样传入函数。宁可让函数内部报错也不要在中间层悄悄改参数。隐式篡改参数是技能系统最可怕的隐形杀手。5. 经验之外进阶注意事项与未来补充方向如果你已经跑通了上面这套框架再往后走有几个细节值得提前留意。它们不一定马上用到但用到时候会很救命。5.1 技能的统一观测性日志里藏着大半的真相我一开始只在技能调用成功或失败时打一条日志。后来发现排障时要看的信息远不止这些。完整的观测至少应该包含模型原始输出、清洗后的参数字符串、校验结果、最终传入函数的数据、函数返回原始值、回填给模型的字符串。每一层都可能埋雷。尤其是“回填给模型的字符串”如果不小心用了str(obj)把字典打印成了带引号的形态模型后续推理极易被带偏。我现在为每个技能配套一个TraceRecord数据类每次调用都会把它写入结构化日志。排查问题时直接按 trace_id 一拉全链路的状态一目了然。5.2 技能的版本管理别让旧 Session 用新技能这个问题是我在给技能升级时踩到的。线上老用户的会话还是用旧版技能定义发起的但后台代码已经换成了新版函数。结果新版函数要求字符串参数旧版描述要求数字参数直接导致大量异常。后来的做法是在技能注册表里加入min_version与max_version字段并且在会话启动时锁定该会话使用的技能版本快照。模型描述是旧版就老老实实用旧版函数执行。这样虽然多占了一点存储但换来了极高的稳定性。5.3 从“调用”走向“习得”agent-skills 的下一步当前框架还停留在“预置技能 模型调用”的阶段。真正的 agent-skills 应该具备某种程度上的“技能习得”能力当用户反复用某段复杂操作完成某个目标时系统能把这段操作沉淀成一个新技能。我目前在试验一种很朴素的办法把一次完整的“思考 - 调用 - 结果 - 反馈”链路保存下来定期用离线模型对被高频重复的链路做抽象生成新技能的描述和参数 schema。比如用户连续十次让你“读取销售表、筛选上月数据、计算环比变化”系统可以自动把它合并成一个analyze_sales_trend技能。这个方向还很不成熟但我觉得它才是 agent-skills 这个名字最有想象力的部分。技能不应该是死的它应该能像人一样在干活的过程中长出新的能力来。写到这里回头看这个项目最核心的收获其实不是那些代码和框架而是理解了一个简单的事实Agent 的强大与否不取决于它背后接了多少 API而是取决于你如何把“能力”翻译成它听得懂的语言。技能描述、参数 Schema、编排策略、错误反馈每一处都是你和模型之间的一场隐性谈判。谈得好Agent 就是你的得力干将谈不好它只是个夸夸其谈的聊天机器人。这套 agent-skills 框架我还在继续迭代。如果你也正在做类似的尝试建议从最小闭环跑起先接两三个高频技能把调用链路和日志体系打磨顺了再慢慢扩大技能库。别一上来就追求大而全那只会让你在问题堆里打转。
返回列表