做 AI 应用开发这一年多,我最大的体会是:真正拉开项目差距的往往不是模型选得有多新、参数调得有多花哨,而是你手里的skills(技能库)有没有成体系。同样是让助手查天气、算运费、做汇总,有人写出来的东西每次都能稳定复现,有人却总在“改提示词—再试一次—又出错”的循环里打转。这个项目记录的就是我从零搭建一套可复用技能库的完整过程,包含接口设计、落地代码、验收方法和排坑记录,适合正在做智能体应用、想摆脱“一次性提示词式开发”的开发者参考。
1. 先搞清楚:这里的 skills 到底是什么
1.1 模型能力与技能能力:一条容易混淆的边界
很多人在刚接触这个概念时会问:模型本身已经会那么多东西了,为什么还要额外挂一层 skills?这里的关键在于“会”和“能执行”是两回事。
大模型的知识和推理能力,本质上是在海量文本上训练出来的“理解和生成”能力。你问它运费怎么算,它能给你讲一堆公式和行业规则;但如果你要的是一个真实可用的计算器,它做不到精确执行。它不知道你的计费规则是什么,不知道你的数据库里有哪些记录,更没法真的去调用你的内部系统。skills 解决的就是这个“外挂能力”的问题:把一组可执行、可验证、可复用的操作封装成标准模块,让模型在需要时“调用”而不是“凭空发挥”。
我习惯用一个类比来理解这件事:模型就像一个非常聪明但两手空空的人,skills 就是递给他的一套标准化工具箱。每一件工具都贴着标签,写着“此工具用于什么场景、需要什么输入、会返回什么结果”。他不需要重新发明工具,只需要在合适的场景选对工具、按说明使用。
1.2 为什么非要“技能化”而不是“写死在提示词里”
这是我在实际项目里被反复教育过的一课。早期做助手类应用时,团队习惯把所有规则都堆在系统提示词里:“如果用户问运费,就按首重8元、续重1.2元/公斤、每公里加0.05元计算……”
表面上看,这样做最简单,不需要额外代码,模型也能回答个大概。但问题很快暴露:
- 修改成本高。调价或者改规则时,得改主提示词,而主提示词影响的是所有对话的基调和行为,牵一发动全身。
- 稳定性不可控。模型对规则的理解和遵循程度每次都可能不一样,今天算对明天算錯,格式也不统一。
- 无法验证。所有交互都发生在大模型的黑盒推理里,你没法针对“计算运费”这一个动作单独写自动化测试。
- 复用性为零。换个项目、换个机器人,同样的规则得再写一遍、再调一遍。
做成技能库之后,情况完全不同:调价只需要改一个函数;测试只需要对着这个函数灌参数、看结果;跨项目复用只需要把技能包复制过去注册一下。这里有一张对比表,我每次给团队做培训都会用:
| 维度 | 写死在系统提示词里 | 做成技能库 |
|---|---|---|
| 修改成本 | 改主提示词,影响全对话 | 只改对应技能,影响范围可控 |
| 执行稳定性 | 依赖模型临场发挥 | 有 schema 约束和代码校验 |
| 可验证性 | 只能靠多轮对话体验 | 可对每个技能做独立自动化测试 |
| 跨项目复用 | 基本不可复用 | 复制注册即可用 |
| 出错定位 | 难判断是规则问题还是模型理解问题 | 日志直接定位到技能函数 |
所以真相是:技能化不只是一个工程偏好,而是把不可控的模型输出变成可控的系统行为的必经之路。
2. 一套能用的 skills 库,先定好四件事
2.1 接口约定:输入输出 JSON Schema 先行
做技能库的第一步不是写函数,而是先把接口约定定死。我在项目里要求所有技能都必须有明确的 JSON Schema,包括参数名、参数类型、是否必填、取值范围和字段说明。
以“运费试算”为例,一份合格的 schema 看起来是这个样子:
{ "name": "calc_shipping_fee", "description": "根据包裹重量和目的地距离估算运费。当用户咨询寄件费用、运费多少钱、快递费怎么算时使用。", "parameters": { "type": "object", "properties": { "weight_kg": { "type": "number", "minimum": 0.1, "description": "包裹重量,单位公斤,必须大于0" }, "distance_km": { "type": "number", "minimum": 1, "description": "寄送距离,单位公里,必须大于0" } }, "required": ["weight_kg", "distance_km"] } }注意这里的几个细节:第一,每个字段都有单位说明,避免模型把公斤理解成斤、把公里理解成米;第二,必填字段明确列出,防止模型只传一个重量就开始计算;第三,加了最小值约束,从接口层面挡住明显不合理的参数。
这个 schema 不光给模型看,后端校验也要基于它做。我的建议是:用 JSON Schema 作为唯一事实来源,前后端都用同一份定义,避免出现“前端说参数是整数、后端实际要浮点数”之类的扯皮。
2.2 描述文本怎么写:给模型看的“说明书”
如果说 schema 是接口的“类型签名”,那 description 就是给模型看的“使用说明书”。这一块看着不起眼,实际却是决定调用准确率的最关键因素。
写描述有一个基本公式:触发条件 + 参数含义 + 返回结果 + 失败行为。光是“根据重量和距离计算运费”这句话远远不够,要写清楚“什么时候该用这个工具”。比如“当用户咨询寄件费用、运费多少钱、快递费怎么算时使用”就是触发条件;“如果参数缺失或非法,返回 error 字段而不是计算”就是失败行为。
我踩过的一个典型坑是:把描述写得像产品文档,追求全面但缺少触发信号,结果模型经常在用户只是闲聊“运费真贵”时误触发计算,或者在真正该算的时候反而没调用。后来改为每个描述都内置两到三个典型触发示例后,误调用率明显下降。
2.3 注册与路由:模型怎么知道该用哪个
当技能数量从几个涨到几十个之后,光靠模型自己从工具列表里挑是不够的。模型需要在一大堆工具描述里做选择,每个描述都越长,选择的负担越重、出错率越高。
我的做法是分两层路由:第一层是粗粒度分类,比如“查询类”“计算类”“写入类”;第二层才是具体技能。给模型的工具列表里先呈现分类入口,等模型确认分类后再返回该分类下的具体技能列表。这相当于给模型配了一个“技能目录”,而不是让它在一堆说明书里翻找。
另外,工具描述要控制长度。单个技能的描述尽量控制在 80 字以内,把最关键的触发条件和参数要点写清楚就行,细节放到函数内部的注释和文档里。模型不需要知道你的计费公式是哪一个,它只需要知道“什么时候调用、传什么参数”。
2.4 边界与安全:哪些动作必须人工确认
技能库不只有查询和计算,还会涉及发邮件、写文件、改配置这类敏感操作。我的原则是:所有存在外部副作用的技能,一律采用“确认后执行”模式。
具体来说就是技能分为两个阶段:第一阶段生成“执行意图”,包含操作对象、操作内容和预期影响;第二阶段由用户确认或由策略引擎做权限校验后,才真正执行。在代码层面,所有写操作技能的返回值里都要带上requires_confirmation: true的标记,让上层应用能据此拦截并弹出确认提示。
这一条在多人协作时尤其重要。不同角色对“某个操作是否危险”的判断可能完全不同,把安全策略收口到技能层,至少能保证基础底线不会因为某个人的临场大意被突破。
3. 动手实现一个最小可用的技能库
3.1 定义一个实用技能:用“费用试算”练手
理论说再多,不如跑一个完整例子。下面我以“费用试算”为示例,演示从函数定义到注册调用的全过程。
先写核心函数,注意返回值统一为 JSON 字符串,这样模型的后续输出更容易对齐:
import json def calc_shipping_fee(weight_kg: float, distance_km: float) -> str: # 参数合法性校验 if weight_kg <= 0 or distance_km <= 0: return json.dumps({ "error": "invalid_parameter", "message": "重量和距离必须大于0" }) # 计费规则:首重8元,续重每公斤1.2元,每公里加0.05元 base_fee = 8.0 weight_fee = weight_kg * 1.2 distance_fee = distance_km * 0.05 total_fee = base_fee + weight_fee + distance_fee return json.dumps({ "fee": round(total_fee, 2), "currency": "CNY", "detail": { "base_fee": base_fee, "weight_fee": round(weight_fee, 2), "distance_fee": round(distance_fee, 2) } })这里有两个经验点:第一,所有返回都走统一的 JSON 结构,成功返回到fee、currency等字段,失败返回到error、message字段,这样模型无论拿到什么结果都能按固定模式组织回答;第二,计算逻辑要包含明细,模型在回答时能引用这些明细,给用户的解释会自然很多。
3.2 接入应用:把技能注册到大模型调用流程里
有了函数和 schema,接下来的关键步骤是把它们变成模型可调用的工具。以常见的 LLM API 调用方式为例,工具注册和调用循环的骨架是这样的:
tools = [ { "type": "function", "function": { "name": "calc_shipping_fee", "description": "根据包裹重量和距离估算运费。当用户咨询寄件费用时使用。", "parameters": { "type": "object", "properties": { "weight_kg": {"type": "number", "description": "重量,公斤"}, "distance_km": {"type": "number", "description": "距离,公里"} }, "required": ["weight_kg", "distance_km"] } } } ] def run_with_tools(user_input: str): messages = [{"role": "user", "content": user_input}] max_rounds = 5 for _ in range(max_rounds): response = client.chat.completions.create( model="your-model-name", messages=messages, tools=tools ) message = response.choices[0].message messages.append(message) # 模型没有发起工具调用,直接返回 if not message.tool_calls: return message.content # 逐个执行工具调用 for tool_call in message.tool_calls: result = execute_tool(tool_call, tools) messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": result }) return "我已经尝试了多次,但未能完成你的请求。"这里最核心的是那个循环:模型先决定是否调用工具,如果调用了,就把每个工具调用的结果作为tool角色的消息回填给模型,让模型基于结果生成最终回答或继续下一步调用。max_rounds是必须加的保险丝,防止模型在工具调用上空转。
实际项目中,execute_tool还需要做参数解析和异常捕获。模型传进来的参数不一定合法,要做一层防御式转换:
def execute_tool(tool_call, tools): name = tool_call.function.name args = json.loads(tool_call.function.arguments or "{}") if name == "calc_shipping_fee": try: weight = float(args.get("weight_kg", 0)) distance = float(args.get("distance_km", 0)) return calc_shipping_fee(weight, distance) except (TypeError, ValueError): return json.dumps({"error": "invalid_parameter", "message": "参数必须是数字"}) return json.dumps({"error": "tool_not_found", "message": f"未知工具: {name}"})这一层看起来简单,却能挡住大量线上问题。模型偶尔会把数字写成字符串、把单位直接塞进参数值里,通过类型转换和默认值兜底,比直接把错误抛给模型要稳得多。
3.3 验收清单:上线前至少跑通这五类场景
技能写完不是结束,上线前必须有一份验收清单。我要求的底线是五类场景全部通过才能发布:
- 正确触发:用户说“帮我算一个2公斤的包裹寄500公里要多少钱”,模型准确提取
weight_kg=2、distance_km=500并调用技能。 - 非触发:用户只是闲聊“最近运费好像涨了”,模型不调用任何工具,直接正常对话回复。
- 参数缺失:用户只说“寄个东西多少钱”,不说重量和距离,模型要么追问缺失信息,要么给出估算并明确说明前提,不允许瞎编参数。
- 参数异常:用户说“重量是零公斤”或“距离是负数”,技能能返回错误信息,模型能基于这个错误信息给出合理解释。
- 调用失败兜底:故意让技能抛异常,确认上层能正常捕获并回退到可读的错误提示,而不是让整个对话崩溃。
这五类场景中,“非触发”是最容易被忽略的,但恰恰是最能暴露描述问题的测试。如果模型在日常闲聊里频繁误调用工具,用户体验会非常差。
4. 把技能写“薄”:命名、版本与依赖管理
4.1 技能单一职责与命名规范
当技能库慢慢变大,最先失控的往往是命名。最早我见过有人起名tool1、func_a,后来根本没法维护。现在我对技能命名有两条硬规定:一是动词开头、对象明确,比如get_weather_by_city、calc_shipping_fee,而不是weather、fees;二是每个技能只做一件事,如果一个函数内部出现了“先查天气再根据天气推荐穿衣”这种嵌套逻辑,就拆成两个技能。
单一职责的好处不只是好命名,更重要的是好测试、好组合。模型在回答“今天穿什么”时,可以先调天气技能拿到气温,再基于结果生成建议,而不需要有一个专门的“穿衣技能”把逻辑写死。
4.2 Schema 变更会连环爆炸,要有版本控制
技能接口的变更比普通接口变更更容易引发连锁问题,因为除了后端代码,还牵涉到模型对工具的理解。一个字段从必填改成选填,或者一个参数单位从“公斤”改成“斤”,都可能导致模型在线上突然行为异常。
所以我把技能的 schema 和函数实现一起做版本管理,采用技能名@主版本.次版本的格式。在不破坏兼容性的前提下(新增选填字段、扩展枚举值),保持主版本不变;一旦出现破坏性变更,就新建一个不同名称的技能入口,而不是直接改老接口。
配套的还有测试夹具:每个技能版本都保留一组标准的输入输出样例,用于回归验证。改 schema 前先跑一遍旧样例,确认破坏点在哪里;改完后用新样例验证,确保模型在新接口下行为正确。
4.3 技能之间的依赖:尽量做成无状态调用
我见过一些团队把技能做成了互相调用的状态机,技能 A 调技能 B、B 再依赖 A 的某个内部状态,看着灵活,实际上调试时痛苦不堪。
实践下来最省心的模式是无状态调用:每个技能只依赖自己收到的参数,不读写共享变量,不依赖调用顺序。这样做的好处是任何一个技能都可以单独测试、单独替换、单独回滚。如果确实需要组合多个技能,更推荐的方式是让模型在多轮对话中依次发起多次工具调用,利用消息上下文来传递状态,而不是在代码层把技能硬串起来。
5. 调试实战:模型不调用技能、参数传错、反复重试的典型问题
5.1 模型根本不调用工具,只会“自由发挥”
这是接入初期最容易遇到的现象。我调试的第一个技能时,模型明明看到工具列表里有calc_shipping_fee,用户也在问运费,它却偏偏自己用文字编了一个答案。
排查下来基本是两种情况。第一种是描述不够明确,模型没有把用户的问题和工具关联起来。解决方式很简单:在描述里加入更贴近口语的触发词,比如“帮我算一下运费”“寄个快递要多少钱”;第二种是模型本身没有养成“先调用工具再回答”的习惯,这种情况下可以在系统提示词里加一句固定引导:“当问题涉及实时数据或精确计算时,必须先调用对应工具。”
另外还有一个容易忽略的参数:temperature。设得太高(比如 1.5),模型更倾向于自由发挥而不是遵循工具调用行为。我的经验是工具调用场景下 temperature 设置在 0.2 到 0.4 之间比较稳。
5.2 参数老传错:字符串当数字、单位直接塞进数值
模型调用工具时,参数解析偶尔会离谱。我遇到过把"2公斤"直接当作weight_kg的值传来的情况,也遇到过把距离和重量弄反的。
排查思路分三层:第一,检查 schema 描述里是否写清楚了字段的单位和语义,比如在weight_kg的 description 里明确写“数字,单位公斤,不要包含单位文字”;第二,代码侧要兜底,用正则或类型转换把参数里的常见单位清理掉;第三,记录每次工具调用的原始参数和执行结果,调不动的时候翻日志找规律,比靠猜高效得多。
import re def clean_weight(raw_value): if isinstance(raw_value, (int, float)): return float(raw_value) # 处理 "2kg"、"2公斤" 等格式 match = re.search(r"(\d+(?:\.\d+)?)", str(raw_value)) if match: return float(match.group(1)) raise ValueError("无法解析重量参数")这种做法不完美,但能在绝大多数常见说法下正常工作,而且不阻碍模型学习正确传参。
5.3 工具调用失败后模型反复重试,形成死循环
错误处理要比想象中更重要。有一次测试中,我们故意让一个查询技能返回错误,结果模型收到错误信息后没有放弃,而是继续发起同样的工具调用,反复重试了十几次,白白消耗了大量 token。
这个问题的根源是错误信息本身没有给出“下一步该怎么做”的引导。解决方法是让所有技能的错误返回统一包含两个字段:message(说明发生了什么)和suggestion(建议模型如何向用户解释或是否更换方案)。同时在上层循环里加上最大轮数限制(前面的示例里是 5),超过次数直接结束对话。
我最终用的错误返回模板长这样:
{ "error": "service_unavailable", "message": "查询服务暂时不可用", "suggestion": "请告知用户系统正在维护,请稍后重试" }模型拿到这样的信息后,大多数情况会顺着suggestion组织回答,而不是继续盲目重试。
6. 面向团队的 skills 协作:把技能库当产品来维护
6.1 技能提交前必须过的检查项
当技能库不再只是个人项目,而是多人协作的公共资产时,一套评审机制是不可省的。我要求所有技能在合并前过一遍检查清单,每一条都要能给出明确答复:
- 描述是否写清了触发条件和典型示例
- 参数是否有类型、范围、单位说明
- 失败时是否返回统一的错误结构
- 是否包含敏感操作,是否加了确认机制
- 是否按当前 schema 更新了测试样例
- 命名是否符合“动词+对象”规范
- 是否与其他技能存在功能重叠
这些检查项看着啰嗦,但确实能挡住大部分线上事故。有一个教训:某次新同事提交了一个“查天气”技能,因为描述里没有写清楚触发条件,导致模型在用户问“今天冷不冷”时完全不调用它。后来补上触发示例才恢复正常。
6.2 埋点与观测:调用率、失败率、参数分布可视化
技能库上线只是开始,观测才是持续优化的依据。我给每个技能加了三个基础埋点字段:调用次数、调用来源会话、执行结果状态码。用最简单的方式记录日志,每周做一次汇总分析。
观测要重点看的是三个比率:
- 调用失败率:异常波动往往意味着业务规则变化了,但技能没同步更新。
- 参数分布:如果某个技能的某个参数频繁触发兜底逻辑,说明描述里对单位的定义还不够清晰。
- 触发正确率:通过抽样日志,人工确认模型调用的时机是否合理。误调用和漏调用都要记录。
这些数据积累下来,你就能判断哪些技能需要优化描述、哪些技能可以直接下架、哪些技能其实应该合并。
6.3 从库到平台:当技能数量超过几十个之后的演进方向
技能数量在几十个以内时,一个简单的注册表和文档就能管理。但超过这个量级后,纯手动的列表管理会变成灾难。我的建议不是一个劲儿堆人力,而是考虑三件事:给技能自动生成描述索引、按场景给技能打标签、用检索模型做技能路由推荐。
这个方向的一个粗糙实现思路是:给每个技能维护一份摘要向量,用户请求进来时先做一次语义检索,筛选出最相关的 3 到 5 个技能,再把这些技能作为候选注入模型调用列表。这样既能控制工具列表的长度,又能让模型在更大技能库规模下保持稳定表现。
做得再好一点,还可以在技能库之上叠加权限、配额、灰度发布等能力。但这已经超出“最小可用技能库”的范畴了,属于团队到了一定规模之后自然生长的需求,在此点到为止。
最后再分享一个小技巧。我在实际维护中发现,真正的瓶颈往往不是技术而是“技能的描述质量”。每次加新技能前,我会拿一条真实的用户提问去测模型是否能稳定触发;如果没有触发,我不会急着改代码,而是先打磨那一两句描述文本。很多时候,一行好的描述比二十行参数校验代码更值钱。技能库这件事的底层逻辑,其实就是不断把“模糊的意图”翻译成“精确的调用”,这个功夫下足了,后面所有环节都顺了。