个项目名字很朴素就叫“agent-skills”。但我实际把它当成了一套给 AI 代理用的“技能仓库”模型负责理解人话仓库负责把理解变成可执行的动作。上个月我重构了自己手头一个答疑机器人把里面天女散花一样塞在提示词里的小工具全部拆成了独立技能模块效果比预期好不少——至少机器人不再经常把参数读错了。这篇就聊聊我为什么做这套东西、怎么设计、代码怎么落以及踩过的几个真实坑。内容偏实操适合正在做 LLM 应用开发、想给对话模型接上工具能力但还不太清楚从哪下手的人。1. 为什么需要 agent-skills模型有嘴但没手1.1 一个尴尬的现场先讲个最早期的翻车经历。某次我跟一个测试用的对话机器人说“帮我记一下明天上午十点给设备做维护。”模型嘴上答应得特别好回了一句“好的已为你记录”然后就没有然后了。它根本不知道日历在哪也不知道怎么把事件写进某个存储文件里。大模型的表现力越来越强但能力边界很明显它没有持久记忆之外的感官也没有操作外部系统的入口。要让它“干活”必须围着一个外部执行器来做也就是把模型的自然语言输出翻译成一段真实可运行的操作。这段“翻译层”怎么组织决定了整个应用是稳定可用还是动不动就抽风。1.2 技能碎片化的痛最早的方案特别粗暴把所有能力写成系统提示词一长串文字塞给模型。比如用户提到天气时你要调用天气接口。用户提到计算时你要调用计算器。用户提到闹钟时你要…此处省略几百字这样写肯定能跑但在项目稍微变大时问题全来了。第一提示词越长模型对指令的注意力越容易被稀释它经常忽略后面的工具说明第二想新增一个技能得改一大段提示词还要担心破坏原有的行为第三没法复用换个新项目这些被埋在提示词里的逻辑又得重新复制一遍。后来我把思路翻转了一下既然模型不擅长长文本里的夹带说明那就把技能做成独立模块每一个技能都自带说明文件、参数结构和执行函数。模型面对的永远是结构化描述程序面对的永远是清晰接口。1.3 这套方案适合谁我建议这几类人重点参考刚接触 LLM 工具调用、函数调用想给对话机器人加动作能力的开发者已经在用超长系统提示词发现维护成本越来越高的个人开发者还有想把某个能力反复复用到多个项目里的自动化爱好者。核心价值就是一点把“模型该做的事”和“程序该做的事”之间那层胶水整理干净。2. agent-skills 的整体设计2.1 一个技能的四件套我在项目里规定每个技能就是一个目录里面必须包含四个部分说明文件用自然语言告诉模型“这个技能什么时候用怎么用”相当于产品说明书。参数结构一份 JSON Schema约束模型应该输出哪些字段、字段类型、哪些必须提供。执行函数真正跑代码的地方接收参数返回结果。观测结果执行函数回传的文本会被再喂回给模型作为下一步判断的依据。一句话总结说明文件给模型看参数结构约束模型的输出执行函数干真实的活观测结果让模型知道活干得怎么样。整个过程形成一个闭环。2.2 一个最小技能长什么样以“计算器”技能为例目录结构如下。agent-skills/ ├── skills/ │ └── calculator/ │ ├── SKILL.md │ ├── schema.json │ └── run.pySKILL.md 可以极其短但必须把触发条件讲明白# Calculator 当用户给出一个数学表达式或者上下文中有明确的数值计算需求时使用本技能。 不要尝试自己心算必须调用技能完成计算。 示例输入12 * (3 4) 示例输入每月房租 2800连续交 6 年一共多少钱schema.json 负责定义参数边界{ type: object, properties: { expression: { type: string, description: 用户提供的数学表达式支持 - * / 和括号, examples: [12 * (3 4), 2800 * 12 * 6] } }, required: [expression] }run.py 是执行函数现实项目里不能直接用 eval我用了 AST 做安全的表达式求值import ast import operator _OPERATORS { ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv, ast.Pow: operator.pow, ast.USub: operator.neg, } def run(arguments: dict) - dict: expression arguments[expression] code compile(expression, calc, eval) result _safe_eval(code) return {status: ok, result: result} def _safe_eval(node): if isinstance(node, ast.Expression): return _safe_eval(node.body) if isinstance(node, ast.Constant): if isinstance(node.value, (int, float)): return node.value raise ValueError(unsupported constant) if isinstance(node, ast.BinOp): left _safe_eval(node.left) right _safe_eval(node.right) op _OPERATORS[type(node.op)] return op(left, right) if isinstance(node, ast.UnaryOp): operand _safe_eval(node.operand) op _OPERATORS[type(node.op)] return op(operand) raise ValueError(unsupported expression)这里有个值得记住的原则执行函数必须永远不裸抛异常。不管用户输入多离谱、参数多错误run都要返回一个可读的文本结果错误也一样包装成{status: error, message: ...}。原因很简单模型只能消费文本你给它一个堆栈它大概率只会复读错误信息。2.3 为什么不让模型直接“看一眼”函数代码有人会问我直接在系统提示词里写好“函数签名”不就完了为什么非要搞独立的 schema 和 run.py因为提示词本质上是不可测试的代码。改一个标点都有可能影响模型的输出而你很难在发布前自动化测试提示词本身。独立技能模块则不一样run.py 可以写单测schema.json 可以校验输出SKILL.md 可以单独维护版本。模型、参数、执行逻辑三者解耦出错时能快速定位到底是哪一环出了问题。这个取舍换来三层收益可替换性——想换一个天气数据源只改 run.py不用动提示词可测试性——执行函数可以被直接调用不经过模型也能验证可复用性——同一套技能搬去另一个项目目录复制过去即可。3. 核心机制从自然语言到机器动作3.1 意图识别不训分类器让模型自己选实现 agent-skills 的时候我没有为每个技能做独立意图分类而是把决定权交给模型本身。系统提示词里会拼出全部技能的说明然后要求模型输出一段固定结构的 JSON{ thought: 用户想计算房租总额这是一个数学计算场景, skill: calculator, arguments: { expression: 2800 * 12 * 6 } }“thought”字段是我私心加上的它让模型在选技能之前先做一次推理显著降低选错技能的概率。而代码侧会做一个强校验首先检查 skill 字段是否在注册表里其次用 schema 校验 arguments任何一个环节失败就当作模型输出异常处理而不是硬着头皮去执行。3.2 参数提取最容易翻车技能选对了参数填错是最常见的事故现场。模型天然对数字不敏感也不会主动区分单位。我见过它把“明天”填成本地时间戳也见过把“北京”填成“北京市”。所以 schema 里的 description 一定要包含单位、取值口径和示例。比如天气技能的 schema{ type: object, properties: { city: { type: string, description: 城市名称使用中文全称例如北京市、上海市, examples: [北京市] }, days: { type: integer, description: 查询未来天数范围为 1 到 7, minimum: 1, maximum: 7, examples: [3] } }, required: [city, days] }字段描述写“使用中文全称”还是“使用拼音缩写”结果差很多。模型非常吃这一套 explicit 的约束把口径写死能少掉一半的返工。3.3 执行与观测回传文本才是闭环执行之后技能返回的结果不能直接丢给模型就算完。要把它做成一段简短的观测文本再拼接回对话消息里让模型根据实际结果生成最终回复。这一步非常重要。举个例子。用户问“北京明天会下雨吗”执行函数返回{status: ok, result: {city: 北京市, date: 2025-07-03, will_rain: true}}观测文本可以是技能计算结果北京市 2025-07-03 预计有雨概率 80%。模型看到这句话后会组织最后一段回复“北京明天八成会下雨出门记得带伞。”你看从用户提问到最终回复模型经历了一次“决策-执行-观测-总结”的循环这不是一次性工具调用而是 agent 的标准动作闭环。我在某个多轮任务里反复用到这个闭环效果非常稳。4. 实操过程从零搭一套 agent-skills4.1 准备环境我默认你有 Python 3.10 以上环境另外需要一个支持“工具调用”或“结构化输出”的模型接口。现在市面上主流的大模型服务基本都开放了这类能力选一个你熟悉的就行。另外准备一个命令行工具后面调试技能 run.py 时可以直接跑单测。核心依赖其实就一个能请求模型接口的 HTTP 库。项目里我封装了一层llm_client这里为了演示直接用伪代码表示。4.2 写出注册表注册表负责扫描 skills 目录把每个技能加载成一条描述记录。# core/registry.py import json from pathlib import Path SKILLS_ROOT Path(skills) def load_all_skills(): skills {} for skill_dir in SKILLS_ROOT.iterdir(): if not skill_dir.is_dir(): continue skill_name skill_dir.name skill_md (skill_dir / SKILL.md).read_text() schema_path skill_dir / schema.json schema json.loads(schema_path.read_text()) skills[skill_name] { name: skill_name, prompt: skill_md, schema: schema, } return skills4.3 构建系统提示词把刚才加载好的技能说明拼进系统提示词顺便告诉模型输出格式。我用的模板大致长这样def build_system_prompt(skills): lines [ 你是一个可以调用外部技能完成任务的助手。, 当用户的需求匹配某个技能时你必须输出以下 JSON 格式不要加多余说明, {thought: 你的推理过程, skill: 技能名, arguments: {参数名: 参数值}}, , 如果当前需求没有任何技能匹配输出, {skill: none, reply: 你的普通回复}, , 可用技能如下, ] for skill in skills.values(): lines.append(f---\n{skill[prompt]}) return \n.join(lines)系统提示词的末尾一定要给示例这是成本最低的校准手段。我在某个项目里连续给模型吃三个“输入-输出”对选技能准确率直接肉眼可见地提升。4.4 执行器执行技能并回传结果执行器做的事情不多但每一步都要干净# core/executor.py import importlib import json def execute_skill(skill_name: str, arguments: dict) - dict: try: module importlib.import_module(fskills.{skill_name}.run) result module.run(arguments) if not isinstance(result, dict): result {status: ok, result: result} except Exception as exc: result {status: error, message: f{type(exc).__name__}: {exc}} return result注意只吞掉异常还不够要把异常转成模型能看懂的文本。我给execute_skill加了一层强制包装不管是执行函数内部抛出的异常还是返回值格式不对最后都会统一成{status: ...}字典然后序列化成字符串扔回给模型。4.5 主循环装配最后是代理主循环也就是 agent 的“大脑”def run_agent(user_input: str, history: list) - str: skills load_all_skills() system_prompt build_system_prompt(skills) messages [{role: system, content: system_prompt}] messages.extend(history) messages.append({role: user, content: user_input}) response llm_client.complete_text(messages) parsed parse_structured_json(response) if parsed is None or parsed.get(skill, none) none: return parsed.get(reply, 我没法回答这个问题。) skill_name parsed[skill] arguments parsed.get(arguments, {}) if skill_name not in skills: return 识别到的技能不存在请重新组织你的请求。 observation execute_skill(skill_name, arguments) messages.append({role: assistant, content: response}) messages.append({role: tool, content: json.dumps(observation, ensure_asciiFalse)}) final_answer llm_client.complete_text(messages) return final_answer这段代码可以继续扩展的地方很多技能超时限制、观测文本压缩、多轮记忆管理但这些是后话。先保证最小闭环跑起来再逐步加复杂度是比较稳妥的做法。5. 常见问题与排查我踩过的那些坑5.1 模型根本不调用技能症状是用户说“算一下 25 的平方根”模型直接回答“约为5”根本没走计算器。大多数时候不是模型笨而是 SKILL.md 写得太一言难尽。我最初写的描述是“用于数学运算”模型根本没法把这句话和具体需求连接起来。解法很简单描述里写清楚触发条件和反面条件。我改成“当用户给出数值表达式、需要精确数值结果时使用本技能涉及逻辑推理、文本理解时不要使用本技能。”模型对“精确实数”和“估算”的边界判断会清楚很多。5.2 参数幻觉数字和单位对不上有一次我让机器人查“未来七天的气温”它把 days 填成了 7.0schema 里 integer 校验就直接报了错整个链路崩掉。后来我在 schema 里加了 description“整数天数范围 1 到 7”同时在 examples 里放上周五的合法值幻觉概率才降下来。经验是能写示例就别只写描述能列枚举就别放开放字段。5.3 技能报错信息被模型复读记得刚搭好的时候某个技能内部接口超时run.py 返回了HTTPError: timeout之类的内容。模型看到后最后回复变成了“抱歉遇到了 HTTPError: timeout”。这显然不是用户想看到的。我后来强制所有技能都返回可读的观测文本比如“天气服务暂时不可用请稍后再试”模型就会顺着这个文本组织一句像人话的回答。5.4 代理陷入死循环最隐蔽的坑是模型在执行结果不理想时会不停重试同一个技能把自己锁在循环里。比如连续调用三次天气接口就因为结果里有一句“夜间有雨”它反复查同一个城市。方法是在主循环里加一个最大轮次计数器默认 3 轮超出之后强制转人工默认回复。虽然粗暴但非常有效。下面我把几个高频问题整理成速查表现象常见原因处理后效果模型不调用技能SKILL.md 缺少触发场景描述补上触发与不触发的边界参数是错误类型schema 的 description 没写单位写明单位与合法取值给示例执行报错又复读异常文本直接暴露给模型统一转成友好观测文本多轮重复执行缺循环上限加最大轮次并强制结束这四条不是纸上谈兵都是我真实遇到并按上面的方法修复的。现在项目里 20 多个技能生命周期超过三个月稳定性明显比最早那版提示词大杂烩高出很多。6. 一点后续思考把技能当积木用6.1 技能组合成工作流单个技能干一件事多个技能组合起来就能干复杂的事。比如“安排明天上午的会议”听起来是一个需求实际是查日历、查会议室占用、写备忘三个能力叠在一起。我不建议一次性让模型跨三个技能同时执行那会失控。我更习惯的做法是先把“查日历”和“写备忘”分别做成独立技能再用一层编排逻辑把它们串起来失败时定位到具体环节。6.2 给未来的自己留后路这个项目从一开始就保持了一个习惯每次新增技能都严格补全 SKILL.md、schema.json 和准备几个单测用例。短期内确实多花几分钟长期看收益非常大。因为三个月后你可能完全不记得某个接口到底要怎么调、字段代表什么但 SKILL.md 会替你记住。6.3 个人体会做 agent-skills 这段时间我最深的感受是大模型应用真正的工程瓶颈往往不在模型本身而在模型和外部世界之间那层脆弱的接口。把这层接口做成“有说明书、有参数约束、有错误回传”的技能模块比费劲调提示词更值得投入。这个思路在个人项目里适用再往后走到多代理协作或多场景复用也一样适用——你现在拆出来的每一个技能都是未来那块积木。