如果你最近也在折腾智能体,应该能感受到一个分水岭:一个只会聊天的 Agent,和一个能真正干活的 Agent,差别不在模型参数,而在它手里有没有趁手的工具。我在项目里把高频能力收成了一个叫 agent-skills 的技能库,相当于给 Agent 装了一组标准接口的“外挂”,这篇文章就把我踩过的坑、最终结构和落地方法完整捋一遍。用这套思路,不管是让 Agent 查数据、算报表、发消息,还是操作内部系统,都能快速扩展。
这不是什么高深理论,也不是那种“收藏了就等于会了”的空洞概念,适合正在做 AI 应用开发、想把大模型接入实际业务的人。如果你发现自己给 Agent 写的工具调用越来越乱,或者模型经常在一个简单问题上答非所问,那这篇内容应该能帮上忙。我会从概念拆解开始,一直讲到可落地的代码骨架、参数规范和问题排查。
1. Agent Skills 到底解决什么问题:从“会聊天”到“会干活”的关键一跃
1.1 技能、工具、插件,概念别混
很多刚开始做 Agent 的人会把这三个词混着用,最后代码里一团乱麻。我的理解是这样的:
- 工具(Tool/Function):模型可以直接调用的一个外部函数,负责完成一次原子操作,比如“计算两个数相加”“查一条订单”。
- 技能(Skill):围绕某个业务能力做的完整封装。它不止包含执行函数,还包括这个函数什么时候被调用、参数怎么填、返回结果长什么样、出错怎么办。简单说,技能 = 工具 + 使用说明书 + 异常处理。
- 插件(Plugin):一组技能打包后的分发单元,可能还包含配置、依赖和权限声明。一个插件可以装几个技能,也可以引用其他插件。
这个区分不是抠字眼。如果你把每个函数都当成“技能”直接塞给模型,上下文会被撑爆,模型也会因为说明太碎而频繁选错。我自己早期就是每个 Python 函数都加个 description 扔给模型,结果技能数量一多,触发准确率直线下降。
把技能单独抽出来管理,本质上是在模型和真实世界之间加了一层“服务目录”。模型不需要知道函数内部怎么实现,只需要知道“有这样一个能力、满足什么条件时用它、参数怎么填”。这层抽象让技能可以被复用、测试、做权限控制,也为后面做动态加载打了基础。
1.2 为什么一定要单独抽一层
直接对所有工具函数做一次“大乱炖式调用”,当然也能跑通 Demo,但一旦进入真实业务,问题会接踵而来。
首先是上下文失控。把几十个函数描述全塞进系统提示词,会挤占模型有限的注意力窗口。更麻烦的是,描述之间可能互相干扰。我见过一个统计报表 Agent,同时接了“查询销售额”和“查询用户数”两个技能,描述里都带“统计”两个字,模型经常调用错。
其次是安全和审计。如果技能只是散落的函数,权限控制只能靠代码内部写死。一旦某个技能的入参被模型猜错,可能触发不该执行的操作。而把技能作为独立单元管理后,可以在执行前统一做参数校验、白名单检查和操作留痕。
第三是可测试性。散装的函数很难做回归测试。技能模块化之后,每个技能都能单独用一组“触发场景”和“参数用例”去跑测试,确认新改动的描述或参数不会让旧场景失效。
1.3 一个技能模块的标准组成
我在 agent-skills 里要求每个技能必须包含五个部分:
- 技能元信息:技能名、一句话描述、适用场景、禁用场景。
- 参数 Schema:用 JSON Schema 描述每个参数的类型、是否必填、取值范围、默认值。
- 执行函数:真正干活的部分,入参是经过校验后的参数,出参是统一结构。
- 返回协议:无论成功失败,都返回固定格式的结果,方便模型继续推理。
- 测试用例:至少两组触发测试和两组参数边界测试。
这五个部分缺一不可。尤其是“禁用场景”,很多人会忽略。模型不是人,它不知道“查天气这个技能不要用于查询历史气候”,如果你不写清楚,它就会在奇怪的时候调用这个技能。
我在实际使用中最大的体会是:技能边界写得越明确,模型就越少犯错。你与其反复调 prompt 去纠正模型,不如在技能定义里把边界一次说清楚。
2. 技能定义与参数约束:一份好描述胜过十个补丁
2.1 描述怎么写,模型才愿意调用
技能描述直接决定模型能不能在正确的时间想起这个技能。我刚开始写的描述很糟,比如“获取天气数据”,然后就没有然后了。模型面对一个模糊的描述,很容易在用户问“明天适合爬山吗”的时候不调用天气技能,而是凭空回答。
后来我总结出一套写法:动词开头 + 包含触发场景 + 说明不能做什么。比如:
获取指定城市的实时天气信息。 当用户询问天气、气温、降雨、风力、出行建议时,可以使用该技能。 不要用该技能查询历史天气或气候统计,那是另一个技能。这段描述比“获取天气数据”好在哪里?它告诉模型两个关键信息:什么时候用、什么时候不用。模型选择技能本质上是做语义匹配,你的描述越接近用户问法的表达,匹配成功率越高。
再看一个更复杂的例子。我做一个内部工单系统 Agent 时,有“创建工单”和“更新工单”两个技能。最初把“创建工单”描述写成“创建一个工单”,结果用户说“帮我改一下工单标题”,模型也会去调创建接口。后来改成:
创建一条新的工单记录。 当用户描述了一个需要解决的问题、请求、任务,且当前系统中不存在对应工单时,可以使用该技能。 如果用户提到修改、更新、补充已有工单,请使用 update_ticket 技能。这个写法把业务边界、触发条件和相邻技能的关系都交代清楚了。测试下来,误调用率从 30% 降到了 5% 左右。
2.2 参数 Schema:能约束就不依赖 prompt
技能描述解决的是“要不要用”,参数 Schema 解决的是“参数怎么填”。我强烈建议用 JSON Schema 而不是在描述里写“参数有 city, date”。因为模型解析 JSON Schema 时会更稳定,而且 Schema 能表达枚举、格式、默认值等复杂约束。
下面是我常用的一个参数定义:
{ "name": "get_weather", "description": "获取指定城市的实时天气信息。当用户询问天气、气温、降雨、风力、出行建议时,可以使用该技能。不要用该技能查询历史天气或气候统计。", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,使用标准中文名,例如:北京、上海、广州", "minLength": 1 }, "date": { "type": "string", "description": "查询日期,格式为 YYYY-MM-DD,默认今天", }, "unit": { "type": "string", "enum": ["celsius", "fahrenheit"], "default": "celsius" } }, "required": ["city"] } }这段 Schema 里有几个细节值得注意:
city设置minLength: 1,防止模型传个空字符串。date允许不传,但我在后端会默认填当天,因为模型很容易把“明天”理解错。与其让模型算日期,不如让它把用户原话里的日期描述带出来,由后端做日期解析。unit用enum限制了取值范围,模型更愿意从候选里选,比让它自由发挥稳定得多。
参数命名也要花心思。我之前用d表示日期、loc表示地点,结果模型经常不知道填什么。后来全改成city、date、unit这种一眼就能看懂的词,准确率立刻上来了。参数名本身就是一种提示,别用缩写。
2.3 设计统一的返回协议
技能执行结果要返回给模型,让模型基于结果组织最终回答。如果你的技能有时候返回{"weather": "晴"},有时候又返回200 OK,那模型就不知道该拿哪段信息来回答用户。
我在 agent-skills 里统一用这个协议:
{ "status": "success", # success / error "data": { ... }, # 成功时的结构化数据 "error": { # 失败时的错误信息 "code": "INVALID_PARAM", "message": "city 参数不能为空" } }成功时data里放干净的结构化字段,不要放一堆人类阅读用的格式化文本。失败时error.message要写给模型看,而不是写给用户看。比如:
model_friendly_message: "未找到名为 XX 的城市,请询问用户是否指的是 XX 附近的区域"这样模型拿到错误后,还能继续反问用户或自动修正,而不是干巴巴地说“系统错误”。
常用错误码我也固定了几个:INVALID_PARAM、NOT_FOUND、TIMEOUT、PERMISSION_DENIED、INTERNAL_ERROR。统一错误码的好处是上层可以针对特定错误做重试或兜底,不必解析自然语言。
3. 实操记录:搭一个可落地的 agent-skills 框架
3.1 项目结构和注册机制
我看过不少技能库项目,要么用一堆 if-else 来匹配技能,要么把每个技能写成一个类再手动注册。我最后选的是“装饰器注册 + 全局注册表”,代码量最少,扩展新技能时不用改调度逻辑。
目录结构大概长这样:
agent_skills/ ├── registry.py # 技能注册表 ├── core.py # 调度与执行核心 ├── skills/ │ ├── __init__.py │ ├── weather.py # 天气技能 │ ├── calculator.py # 计算器技能 │ └── todo.py # 待办管理技能 └── tests/ ├── test_trigger.py └── test_params.py注册表本身不复杂,核心就是一个字典:
# registry.py from typing import Callable, Dict class SkillRegistry: def __init__(self): self._skills: Dict[str, dict] = {} def register(self, skill): self._skills[skill["name"]] = skill return skill def get(self, name: str) -> dict | None: return self._skills.get(name) def all(self) -> list: return list(self._skills.values()) REGISTRY = SkillRegistry() def skill(spec): return REGISTRY.register(spec)有了这个基础,新技能可以这样定义:
# skills/weather.py from ..registry import skill import datetime def _get_weather_data(city: str, date: str, unit: str = "celsius"): # 这里只是演示结构,真实场景可以对接天气服务 return {"city": city, "date": date, "condition": "sunny", "temperature": 25} @skill def get_weather(): return { "name": "get_weather", "description": "...省略...", "parameters": {...}, "execute": _get_weather_data, }装饰器函数返回的不只是执行函数,而是一个完整的技能配置对象。这样注册表和调度器都能拿到一份统一结构,后面做参数校验、权限控制都有地方下手。
3.2 让模型“看到”技能:两种匹配方案
技能库建好之后,下一步是让模型在合适的时机“看到”合适的技能。我试过两种方案。
方案 A:全量注入。把所有技能的描述和参数 Schema 都塞进系统消息。适用于技能数量少于 20 个的场景,优点是实现简单,模型选择准确率高。缺点是 token 消耗大,技能多了以后互相干扰。
方案 B:前置检索。把用户问题先做一次检索,选出 TopK 个候选技能,再把候选技能的完整定义交给模型。这个方案适合技能数量多的场景。
我现在的路由逻辑是两段式:
- 先用 embedding 把用户 query 和每个技能的“描述”做相似度计算,取 Top 5 作为候选。
- 把候选技能的完整定义(描述 + 参数 Schema)交给模型,让模型决定调用哪个。
这样做的好处是:嵌入检索负责粗筛,模型负责精排,两个环节分开,诊断问题也方便。如果某次调用错了,先看粗筛是否命中,再看模型是否选错,定位很快。
下面是一个简化版的粗筛逻辑:
import numpy as np def search_candidates(query_embedding, skill_embeddings, top_k=5): scores = [ np.dot(query_embedding, skill_emb) / (np.linalg.norm(query_embedding) * np.linalg.norm(skill_emb)) for skill_emb in skill_embeddings ] top_indices = np.argsort(scores)[-top_k:][::-1] return top_indices这个相似度分数不需要特别精确,因为后面还有模型精排。粗筛只要别把正确答案漏掉就行。我一般把 TopK 设成 5 或者 6,太小容易漏,太大则模型还是会陷入选择困难。
3.3 调度执行和结果回填
技能路由核心调度循环,我习惯写成下面这样:
async def run_agent(query: str): system_prompt = build_system_prompt() messages = [{"role": "system", "content": system_prompt}, {"role": "user", "content": query}] # 第一步:让模型选择技能,传入候选技能定义 candidate_specs = search_candidates(query) messages.append({ "role": "user", "content": f"候选技能:{to_json(candidate_specs)},请根据用户问题选择并返回参数。" }) response = await llm_complete(messages) # 假设模型返回的是一个结构化动作 action = parse_action(response["content"]) if action["type"] == "call_skill": skill = REGISTRY.get(action["skill_name"]) # 第二步:参数校验 clean_params = validate_params(skill["parameters"], action["params"]) # 第三步:执行技能 result = await execute_skill(skill, clean_params) # 第四步:把结果回填给模型 messages.append({"role": "user", "content": f"技能执行结果:{to_json(result)}"}) final_answer = await llm_complete(messages) return final_answer["content"] return response["content"]这里有几个关键点:
validate_params必须在执行前做,不能完全信任模型给的 JSON。模型有时会漏参数、多参数、或者把数字写成字符串。execute_skill最好统一为异步函数,方便做超时控制和并发限制。- 技能结果回填给模型后,要再让模型“翻译”成用户能听懂的回答,而不是直接把 JSON 丢给用户。
这个循环看起来简单,但实际要处理很多边界情况。比如模型返回了不存在的技能名、参数校验失败、技能执行超时,每一条都要有兜底逻辑。不要假设模型每次都能给出完美调用。
3.4 安全的执行边界
技能是 Agent 的“手”,但它也可能变成不受控的“手”。我给技能执行加了三层约束:
- 超时控制:每个技能最大执行时间,超过即中断,不能让一个外部 API 卡死整个 Agent。
- 参数白名单:文件路径、URL、命令等敏感参数必须通过白名单或正则校验。
- 权限声明:每个技能声明自己需要哪些权限,运行时按最小权限执行。
看一个超时控制的例子:
import asyncio async def execute_skill(skill, clean_params): try: return await asyncio.wait_for( skill["execute"](**clean_params), timeout=5.0 ) except asyncio.TimeoutError: return { "status": "error", "data": None, "error": { "code": "TIMEOUT", "message": "技能执行超时,请稍后重试" } }不要小看这个 5 秒超时。早期我做报表技能时,底下一个数据库查询偶尔会跑到 20 秒,用户问一次问题就等一次,体验极差。加上超时后,配合重试和降级策略,整体回复速度稳定了很多。
4. 常见问题与排查技巧实录
4.1 模型就是不调用技能,怎么办
这是被问到最多的问题。我的排查顺序固定是:
- 看技能定义有没有进上下文。有时路由代码写错,候选技能没有拼进系统消息,模型根本没看到。
- 看描述有没有歧义。把技能描述读一遍,想象自己是普通用户,这句话能不能让我知道该什么时候用。
- 看技能名是不是太抽象。比如
general_util_v3这种名字,模型很难理解。 - 看是不是候选没排进去。如果用了 embedding 粗筛,检查相似度阈值是不是太高了。
我遇到过很典型的一次:技能名叫做execute_python,描述写的是“执行 Python 代码计算并返回结果”。结果用户问“帮我算一下 123 乘以 456”,模型偏偏不肯调用,反而自己接了一个算式。后来把描述改成“当用户需要数学计算、数据处理、运行代码时使用,特别适合表达式计算”,模型就开始正常调用了。
核心问题是:模型触发技能的依据是语义相似,不是“能力名称”,你的描述必须贴近真实用户问法。
4.2 参数总是传错、漏传、类型不对
参数问题本质上是因为模型在拿有限的上下文信息做“推理式补全”。它会根据参数名猜值,比如city参数可能是“shanghai”,也可能是“上海”,甚至可能是“上海市”。
我常用的几道防线:
- Schema 里写示例值:给每个参数加
examples,比在 description 里长篇大论更有效。 - 服务端做类型宽松转换:字符串形式的数字转成 int,空值用 default 兜底。
- 对枚举值做模糊映射:如果模型传了“华氏度”而你枚举值是
fahrenheit,后端做一个同义词表。
举个例子:
def coerce_params(params, schema): cleaned = {} for key, spec in schema.get("properties", {}).items(): value = params.get(key, spec.get("default")) if value is None: continue if spec["type"] == "integer": try: cleaned[key] = int(float(value)) except (TypeError, ValueError): cleaned[key] = 0 elif "enum" in spec: cleaned[key] = normalize_enum(value, spec["enum"]) else: cleaned[key] = value return cleaned这段代码虽然简单,却解决了我 70% 以上的参数错配问题。模型大多不是真错,只是表达宽泛,后端做一次容错就够了。
4.3 技能超时、失败,拖垮整个会话
技能一旦执行失败,最怕的是 Agent 直接崩溃或者把错误堆栈丢给用户。后来我规定每个技能返回统一错误协议,调度层捕获所有异常并转成结构化错误:
try: result = await execute_skill(skill, clean_params) except Exception as exc: result = { "status": "error", "data": None, "error": { "code": "INTERNAL_ERROR", "message": f"{exc.__class__.__name__}: {str(exc)}" } }这样模型拿到的是一个可理解的错误说明,它可以决定是重试、换一种说法,还是主动告知用户失败原因。对于瞬时类错误(比如网络抖动、API限流),我还会在调度层做一次自动重试,但最多重试两次,且每次间隔递增。
4.4 技能数量变多之后互相干扰
技能从 10 个涨到 50 个,你会发现最明显的问题是:描述重叠。比如“查销售订单”和“查订单物流”都包含“订单”两个字,模型容易选错。
我处理这类问题有两种手段:
- 给技能加触发关键词标签。路由时先做关键词匹配,相同业务域的技能再比较语义。技能描述本身就写了“当用户提到物流、快递、配送时使用”,这样和“下单、订单”区分开。
- 把相似技能合并成一个。例如把“查销售订单”“查售后工单”“查物流”合并成一个大技能,参数里增加
type枚举。这减少了模型的选择范围,反而更稳。
我自己通常倾向于合并。技能粒度不是越细越好,而是每一个技能必须有足够差异化,模型才容易选对。太细了,反而像是在考模型的阅读理解。
5. 技能收敛、测试与版本管理:从小玩具到大工具
5.1 技能库健康度检查
技能不是写完就完了,上线后必须盯着调用情况。我做了几个最基础的指标统计:
| 指标 | 怎么看 | 动作 |
|---|---|---|
| 调用频率 | 高频/低频/零调用 | 零调用技能要么删掉,要么改描述 |
| 触发准确率 | 调用技能是否与用户意图相符 | 错误率高就细化描述或合并技能 |
| 参数校验成功率 | 校验失败次数/总数 | 失败率高就加强 Schema 约束和后端容错 |
| 执行成功率 | 成功/总调用 | 失败率高就该检查底层依赖 |
| 平均耗时 | P50/P95 | 耗时过高要加缓存或异步化 |
这套统计我直接在日志里埋点,每轮调用都记录技能名、耗时、校验结果、执行结果。一周之后拉一张表格,哪些技能是鸡肋一眼就能看出来。我常发现某些技能上线两周调用量为 0,说明它的触发场景非常窄,要么是用户没这个需求,要么是模型根本不知道它存在。
5.2 技能回归测试
每次改技能描述或参数 Schema,都要跑一遍回归测试。我会给每个技能维护一份“触发测试集”,里面是模拟的用户问法。比如天气技能至少包含:
- “北京今天冷不冷”
- “明天去上海需要带伞吗”
- “帮我查下雨情”
然后跑一个测试脚本,判定模型是否选中了预期技能、参数是否合理。这种测试不一定需要自动化,哪怕手动跑一遍也很有价值。我自己是把这些用例写成 JSON 文件,接入 CI 里每天自动跑一次。
5.3 版本兼容与灰度发布
技能也要做版本管理。我采用的做法很简单:每个技能信息里加一个version字段,改动时提升版本号,同时保留旧版本的兼容配置。对下游使用方来说,他们只感知技能名,不看版本号;但调度层可以根据线上配置决定走哪个版本。
灰度发布时,我会先在测试环境把新技能和旧技能双跑一遍,对比调用结果。等确认新版本的参数清洗、错误处理都没问题,再逐步放开到生产流量。技能描述的一个字改动都可能影响模型触发行为,绝对不要直接扔到线上。
5.4 我迭代三版后的一点体会
这套 agent-skills 框架我从第一版的大杂烩工具列表,演进到现在的注册表 + 候选检索 + 统一协议 + 回归测试,中间踩过的坑基本都是同一个根源:我总想让模型理解我的系统,而不是让系统去适应模型。
实际上模型对技能的感知非常“字面”。它不关心你的函数封装得多优雅,只看描述和参数名是否清晰。与其花大量时间去调 prompt,不如先把每个技能的描述、参数、返回协议写得像一份给新同事看的接口文档,这比什么都重要。
现在新项目里加一个技能,我的固定流程是:先写 Schema,再写测试用例,最后才写执行函数。顺序反过来的话,很容易把注意力都放在实现逻辑上,反而忽略了模型接入层最重要的人机接口设计。如果你也正在做 Agent,建议从今天开始就把技能当独立产品来对待,后面省下来的调试时间会远超预期。