个项目名字很朴素,就叫“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-skills
4.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(f"skills.{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_ascii=False)}) final_answer = llm_client.complete_text(messages) return final_answer这段代码可以继续扩展的地方很多:技能超时限制、观测文本压缩、多轮记忆管理,但这些是后话。先保证最小闭环跑起来,再逐步加复杂度,是比较稳妥的做法。
5. 常见问题与排查:我踩过的那些坑
5.1 模型根本不调用技能
症状是:用户说“算一下 25 的平方根”,模型直接回答“约为5”,根本没走计算器。大多数时候不是模型笨,而是 SKILL.md 写得太一言难尽。我最初写的描述是“用于数学运算”,模型根本没法把这句话和具体需求连接起来。
解法很简单:描述里写清楚触发条件和反面条件。我改成“当用户给出数值表达式、需要精确数值结果时,使用本技能;涉及逻辑推理、文本理解时,不要使用本技能。”模型对“精确实数”和“估算”的边界判断会清楚很多。
5.2 参数幻觉:数字和单位对不上
有一次我让机器人查“未来七天的气温”,它把 days 填成了 7.0,schema 里 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 这段时间,我最深的感受是:大模型应用真正的工程瓶颈往往不在模型本身,而在模型和外部世界之间那层脆弱的接口。把这层接口做成“有说明书、有参数约束、有错误回传”的技能模块,比费劲调提示词更值得投入。这个思路在个人项目里适用,再往后走到多代理协作或多场景复用,也一样适用——你现在拆出来的每一个技能,都是未来那块积木。