写了好几篇agent系列之后,后台一直有人问:技能(skill)到底是什么?跟我说的工具(tool)、函数调用(function calling)有什么区别?这篇就专门把“接入skill能力”这件事讲透,用最简单的方式把一条完整的技能链路搭出来。
这篇适合已经跑通了基础对话循环、想要让agent真正“动手做事”的人。就算你之前只写过几行demo,只要照着下面的设计一步步来,也能在半个下午把技能系统接进去。
1. 技能体系设计:先搞清楚skill到底是什么
1.1 技能、工具、函数调用是不是一回事
先说结论:不是一回事,但很多人混着用。工具是更宽泛的概念,函数调用是模型侧的接口协议,而技能是agent框架内部对“一组可复用能力”的封装。你的agent可以只对接一次函数调用协议,却注册十个八个技能进去,这中间靠的就是技能层的抽象。
打个比方,函数调用是插座标准,技能是你家插上去的电器。同一个插座标准,插烤箱也行、插电吹风也行,电器内部怎么工作框架不管,框架只关心“通电”这个接口对不对得上。技能层做的事情,就是把“电吹风”“烤箱”这些具体能力整理成统一清单,交给模型,让它知道用哪个。
很多入门项目直接把函数列表塞给模型,靠模型自己翻,这在技能一两个的时候没问题。但技能到五个以上就有麻烦了:描述文档开始打架,命名容易撞车,参数格式每个函数各写各的,维护成本直线上升。技能层解决的就是这个局面——把所有能力统一注册、统一描述、统一调用。
1.2 技能层应该具备的四个职责
一个合格的技能层至少要管四件事:登记、描述、执行、回收。
登记是让框架知道有哪些技能可用;描述是把技能列表翻译成模型能理解的schema;执行是收到模型调用意图后真正干活;回收是拿到执行结果后做格式化、错误处理,再把干净的结果喂回给模型。有些框架还有第五件事——鉴权和限流,但个人项目先不用管,后面业务复杂了再补。
我见过不少人一上来就写一大堆技能实现函数,结果没做统一登记,最后调用的时候一堆if-else分支去匹配函数名。这个做法在演示demo里能跑,但加技能的时候你会疯掉——每加一个技能要改两处地方:一处是调用分支,一处是描述列表。技能层要做的就是消灭这类散落的逻辑,把“新增技能”这个事情收敛成一次注册、自动生效。
1.3 为什么模型需要一份结构化的技能清单
这里有个关键点:模型本身不会“知道”你能干什么。它只知道你写在上下文里的内容。你要是不告诉它能查天气,它就算面临一个非查不可的问题,也只能硬着头皮编一个答案。所以每个技能都必须有一份足够清晰的描述,让模型判断什么时候该用它。
这份描述不是给人看的,是给模型看的。描述写得太虚,比如“用于获取相关信息”,模型根本不知道什么时候触发;写得又长又臭,会白白消耗上下文窗口。好的技能描述是短句子里带足触发条件,比如“当用户询问某城市当前天气或未来几天天气时使用,非天气问题不要使用”。这样模型一目了然。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技能注册与描述协议:让模型“看见”能力清单
2.1 定义一个最小可用的技能抽象层
我在项目里通常用Python来实现技能层,核心是一个抽象基类。每个技能只需要回答三件事:叫什么、干什么用、需要什么参数。
python复制# skill_base.py
from abc import ABC, abstractmethod
from typing import Any, Dict, Optional
class Skill(ABC):
# 技能唯一名称,格式建议: 领域.动作,如 "weather.current"
name: str = ""
# 给模型看的描述,必须写清触发条件和场景
description: str = ""
# 参数声明,JSON Schema 格式
parameters: Dict[str, Any] = {}
@abstractmethod
def execute(self, params: Dict[str, Any]) -> str:
"""执行技能并返回字符串结果"""
raise NotImplementedError
def to_schema(self) -> Dict[str, Any]:
"""生成给模型用的工具描述结构"""
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description[:512],
"parameters": self.parameters,
},
}
这个抽象层是我试过好几版之后稳定下来的。早期版本我把技能定义成一个普通函数加装饰器,看起来更“轻量”,但后来发现技能之间经常需要共享状态(比如同一个Token池、同一个会话上下文),用类实现就舒服很多,可以在__init__里注入依赖,也可以在子类里做更细的控制。
2.2 用注册表统一管理技能清单
设计模式里有个注册表模式,放在技能层特别合适。全局只有一个注册表,技能注册进去之后,框架内部自动完成描述收集、名称去重、调用路由。
python复制# skill_registry.py
from typing import Dict, List, Optional
from skill_base import Skill
class SkillRegistry:
def __init__(self):
self._skills: Dict[str, Skill] = {}
def register(self, skill: Skill) -> None:
if not skill.name:
raise ValueError(f"技能缺少名称: {skill.__class__.__name__}")
if skill.name in self._skills:
raise ValueError(f"技能名重复注册: {skill.name}")
self._skills[skill.name] = skill
def get(self, name: str) -> Optional[Skill]:
return self._skills.get(name)
def skills(self) -> List[Skill]:
return list(self._skills.values())
def list_schemas(self) -> List[Dict]:
return [skill.to_schema() for skill in self._skills.values()]
def execute(self, name: str, params: Dict) -> str:
skill = self.get(name)
if skill is None:
return f"错误:找不到名为 {name} 的技能"
try:
return skill.execute(params)
except Exception as e:
return f"技能 {name} 执行失败: {str(e)}"
注册表的好处是,新增技能不需要改agent核心循环的任何代码。写一个新技能类,在初始化时注册进去,模型调用时自动就能路由到。这段逻辑是整个技能系统的地基,后面所有功能都在这个地基上盖。
2.3 技能描述里最容易踩的三个坑
第一,命名不规范。我踩过一次:两个技能一个叫query_weather,一个叫weather_query,模型在调用时混淆过好几次。后来我强制所有技能名用“领域.动作”格式,比如weather.current、note.create,模型犯错率明显下降。
第二,描述里用了否定句式但没说清楚什么时候该用。模型对自然语言的理解虽然好,但你在描述里写“不要用于计算问题”而不写“当用户需要数学运算时使用”,模型还是会困惑。正面的触发条件永远比反面的排除条件有用。
第三,参数必填项和模型预期不一致。你声明参数必填,但用户在对话里没有提供足够信息,模型只能瞎猜。这是常见问题,后面第四部分我会说怎么处理,这里先记住一个原则:在技能描述里写清楚“参数信息不完整时,应先向用户询问”。把询问动作交给模型,而不是让模型硬造参数。
提示:
to_schema里我把描述截断到了512字节。这是实测下来的折中值,描述太短模型用不好,太长又浪费内容窗口。如果你的模型支持更长的上下文,可以适当放宽,但超过1024字节的skill描述基本就是写作水平问题,不是长度问题。
3. 技能调度核心:从模型意图到真实执行的完整链路
3.1 主循环里怎么接入技能调用
现在到了整个文章的核心,也是最容易写乱的地方。模型返回的不一定是一个纯文本回复,可能是一条函数调用指令。主循环必须能识别这种“中间状态”,执行完技能之后把结果拼回去,再重新交给模型。
python复制# agent_core.py
from typing import List, Dict
from skill_registry import SkillRegistry
class Agent:
def __init__(self, llm, registry: SkillRegistry):
self.llm = llm
self.registry = registry
self.messages: List[Dict] = []
def run(self, user_input: str, max_rounds: int = 5) -> str:
self.messages.append({"role": "user", "content": user_input})
for _ in range(max_rounds):
response = self.llm.chat(
messages=self.messages,
tools=self.registry.list_schemas(),
)
# 如果模型没有发起技能调用,直接作为最终回复
if not response.tool_calls:
content = response.content
self.messages.append({"role": "assistant", "content": content})
return content
# 处理模型发起的技能调用
assistant_msg = {"role": "assistant", "content": response.content, "tool_calls": []}
self.messages.append(assistant_msg)
for call in response.tool_calls:
tool_name = call.function.name
tool_args = call.function.arguments
assistant_msg["tool_calls"].append({
"id": call.id,
"type": "function",
"function": {"name": tool_name, "arguments": tool_args},
})
# 安全解析参数 JSON
import json
try:
args = json.loads(tool_args) if isinstance(tool_args, str) else tool_args
except json.JSONDecodeError:
args = {}
# 执行技能
result = self.registry.execute(tool_name, args)
# 技能结果回填
self.messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": result,
})
# 循环继续,模型会基于技能结果生成下一轮回复
continue
return "已达最大轮次限制,无法完成请求"
这里面有个细节值得展开:在主循环里我把每个tool调用结果都单独追加为tool消息,并和对应call id对齐。这样才能让模型正确区分多个调用各自的结果。要是调用了两个技能,一个查天气一个记笔记,结果却塞在同一条消息里,模型很容易混淆。
3.2 多技能并行调用与结果对齐
有的模型支持一次返回多个tool_calls,这个能力非常实用。比如用户说“帮我看看巴黎和东京的天气,挑一个合适的出差日期”,模型可能会同时发起两个天气查询。如果你按顺序一个个执行,总延迟就是两个技能延迟之和;并行执行的话,延迟约等于最慢的那个。
实测下来并行处理能把多技能场景的响应时间压缩40%左右。不过要小心:并行执行意味着技能之间不能有依赖关系。如果你的技能B依赖技能A的结果,那必须等A执行完再调B,这时候就要把多轮tool_calls放到不同循环里,让模型看到A结果之后自己决定要不要调B。
技能执行顺序的设计方式我在实际项目里是这么判断的:先看技能之间有没有数据依赖,有依赖就一定串行;没有依赖就先并行。判断依赖不光靠看代码,还要想清楚模型对话逻辑——如果它是一个分步骤的推理过程,串行更稳,模型能看到前一步结果再决定下一步动作。
3.3 让模型决定轮次:max_rounds的边界
没有人喜欢无限循环。我在早期版本里没设轮数限制,结果有一次模型在找不到工具时反复重试同一个技能调用,直接跑死了。加一层max_rounds限制之后稳定很多。这个值根据场景配置,一般的问答场景3轮就够了,复杂的操作型场景可以放到6到8轮。
限制轮数不是简单粗暴地截断,而是给模型一个体面的退路。超过轮数时返回一条“已达最大轮次限制”的消息,用户至少知道发生了什么,不会等半天莫名其妙没回复。有些框架会在轮数耗尽后让模型生成一个“抱歉我没能完成”的话术,这个体验更好,但实现复杂度高一些,看你的项目阶段性取舍。
注意:主循环里assistant消息里我是先追加了带
tool_calls的消息,再追加tool结果。这里顺序对你用任何模型API都基本是硬要求。因为模型需要看到自己之前说过什么、请求过什么,才能正确理解后续的tool结果。漏了哪怕一步,模型后续回复大概率前言不搭后语。
4. 实操:实现三个常用技能并完成联调
4.1 技能一:获取指定城市当前天气
先用一个无外部依赖、完全靠本地模拟数据的技能来跑通链路。这个技能看起来简单,但可以帮我们把注册、调用、结果回填的完整流程验证一遍。
python复制# skills/weather_current.py
import random
from datetime import datetime
from skill_base import Skill
class WeatherCurrentSkill(Skill):
name = "weather.current"
description = (
"当用户询问某个城市当前的天气状况、温度、风力或是否适合出行时使用。"
"参数city为城市中文名,若用户未指定城市,请先向用户询问城市名称。"
)
parameters = {
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市中文名,如 北京"},
},
"required": ["city"],
}
def execute(self, params):
city = params.get("city", "").strip()
if not city:
return "参数缺失:未提供城市名称"
# 演示用假数据,实际项目可替换为天气API调用
temp = random.randint(-5, 35)
wind = random.choice(["微风", "3-4级", "5-6级", "无持续风向"])
desc = random.choice(["晴", "多云", "阴", "小雨", "中雨"])
now = datetime.now().strftime("%Y-%m-%d %H:%M")
return f"{city} 当前天气:{desc},温度 {temp}℃,风力 {wind}(数据时间 {now})"
这个技能执行体里我故意没有做真实请求,只用了随机数据。因为第一步我们要验证的是链路通不通,而不是天气数据准不准。链路跑通之后再替换成真实API,一分钟的事情。先拿假数据跑通全流程,再替换真实数据源,是我做技术验证时的固定套路。
4.2 技能二:四则混合运算计算器
第二个技能解决的是模型的数学短板。大模型算简单算术偶尔翻车,但通过调用计算器技能就能稳定拿到准确结果。这个技能的价值在于让模型“不硬算”,而是把计算任务外包给确定性的工具。
python复制# skills/calculator.py
import ast
import operator
from skill_base import Skill
ALLOWED_OPS = {
ast.Add: operator.add, ast.Sub: operator.sub,
ast.Mult: operator.mul, ast.Div: operator.truediv,
ast.Pow: operator.pow,
}
class CalculatorSkill(Skill):
name = "math.calculate"
description = (
"当用户需要进行数值计算、四则运算、幂运算时使用。"
"参数expression为数学表达式字符串,如 '3.5 * (2 + 4)'。"
"不要用于非数值计算问题。"
)
parameters = {
"type": "object",
"properties": {
"expression": {"type": "string", "description": "合法的数学表达式,如 '12 * 5 + 8'"},
},
"required": ["expression"],
}
def execute(self, params):
expr = params.get("expression", "").strip()
if not expr:
return "参数缺失:未提供数学表达式"
try:
tree = ast.parse(expr, mode="eval")
# 只允许白名单运算,防御恶意表达式
for node in ast.walk(tree):
if isinstance(node, ast.BinOp):
if type(node.op) not in ALLOWED_OPS:
return f"不支持的运算符: {type(node.op).__name__}"
elif isinstance(node, ast.Name):
return "表达式包含不允许的变量名"
result = eval(compile(tree, "<calc>", "eval"))
return f"计算结果:{expr} = {result}"
except Exception as e:
return f"表达式解析失败: {str(e)}"
这段代码里ast白名单很重要。为什么不用eval(expr)直接算?因为直接eval字符串可以执行任意代码,这是安全大忌。用AST先解析表达式,白名单只放行加减乘除和幂运算,能挡住绝大多数注入脚本。自己写这种工具型技能时,永远默认“输入不可信”。
4.3 技能三:本地备忘录存储
第三个技能带着状态,可以把模型“记住”的内容落地到本地文件。有了这个技能,agent才能真正跨会话保留信息。
python复制# skills/note_store.py
import json
import os
from pathlib import Path
from skill_base import Skill
class NoteStoreSkill(Skill):
name = "note.save"
description = (
"当用户要求保存一句话备忘、待办事项、临时记录时使用。"
"参数content为需要保存的内容,参数tags为可选的标签列表。"
"当用户说'记一下''帮我保存''备忘'等时触发。"
)
parameters = {
"type": "object",
"properties": {
"content": {"type": "string", "description": "要保存的备忘内容"},
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "可选标签列表",
},
},
"required": ["content"],
}
def execute(self, params):
content = params.get("content", "").strip()
if not content:
return "参数缺失:没有可保存的内容"
note_dir = Path("notes")
note_dir.mkdir(exist_ok=True)
note_id = str(len(list(note_dir.glob("*.json"))) + 1)
note_path = note_dir / f"note_{note_id}.json"
note_data = {
"id": note_id,
"content": content,
"tags": params.get("tags", []),
}
with open(note_path, "w", encoding="utf-8") as f:
json.dump(note_data, f, ensure_ascii=False, indent=2)
return f"已保存备忘 #{note_id}:{content}"
这里再补一个配套的查询技能,没有它备忘录存了也看不到。
python复制# skills/note_list.py
import json
from pathlib import Path
from skill_base import Skill
class NoteListSkill(Skill):
name = "note.list"
description = (
"当用户询问自己保存过的备忘、待办、记录列表时使用。"
"可选参数tag用于按标签过滤;无参数时返回全部备忘。"
)
parameters = {
"type": "object",
"properties": {
"tag": {"type": "string", "description": "按标签过滤,可选"},
},
"required": [],
}
def execute(self, params):
note_dir = Path("notes")
if not note_dir.exists():
return "暂无备忘记录"
tag_filter = params.get("tag")
results = []
for path in sorted(note_dir.glob("*.json")):
with open(path, encoding="utf-8") as f:
note = json.load(f)
if tag_filter and tag_filter not in note.get("tags", []):
continue
results.append(f"#{note['id']}: {note['content']}")
if not results:
return "没有符合条件的备忘"
return "\n".join(results)
4.4 把技能装进agent里跑起来
所有技能写完之后,组装就很简单了。写一个入口文件,把技能实例化并注册进全局注册表,然后启动agent。
python复制# main.py
from agent_core import Agent
from skill_registry import SkillRegistry
from skills.weather_current import WeatherCurrentSkill
from skills.calculator import CalculatorSkill
from skills.note_store import NoteStoreSkill
from skills.note_list import NoteListSkill
def build_agent(llm) -> Agent:
registry = SkillRegistry()
registry.register(WeatherCurrentSkill())
registry.register(CalculatorSkill())
registry.register(NoteStoreSkill())
registry.register(NoteListSkill())
return Agent(llm=llm, registry=registry)
这四行注册代码看着不起眼,其实把这套系统的可扩展性体现得淋漓尽致。以后要加技能,复制一个类文件,改三行注册代码,agent的“能力”就自动扩展了。这就是为什么一开始要做抽象层,前期的设计成本会在后期持续回报。
联调时建议按这个顺序验证:
- 先问纯对话问题,确认不走技能也能正常回复。
- 再问“北京天气怎么样”,确认模型会发起weather.current调用。
- 接着问“127*89等于多少”,确认模型把计算交给math.calculate而不是硬算。
- 最后说“帮我记住明天下午三点开会”,再追问“我有哪些备忘”,确认note.save和note.list链路通畅。
如果第2步模型没有发起调用,优先检查技能描述是不是在系统提示词里被正确拼出来了。你可以把registry.list_schemas()的返回内容打印出来,看看格式是不是和模型API要求的一致。
5. 常见问题与排查思路实录
5.1 模型就是不调用技能,怎么办
这个问题我遇到太多次了。最常见的原因是技能描述写得太模糊,模型根本判断不出该在什么场景下触发。我一般做这几步排查:
先问自己一句:假如我是一个完全不懂内部实现的小白用户,看到技能描述能知道何时该用吗?如果答案是否定的,就重写描述。
再检查一下有没有把list_schemas()的结果传到模型请求的tools参数里。有些人代码里写了tools=self.registry.list_schemas(),但registry还是空实例,技能没注册进去。把list_schemas()直接打印出来,一目了然。
最后一个容易忽略的点:部分简单模型不支持函数调用能力。你用的模型如果本身没有tool calling的能力,再怎么调都是白搭。要么换支持工具调用的模型版本,要么退回到“模型生成结构化文本,自己解析”的兼容方案。
5.2 技能返回结果太长,把上下文窗口塞爆了
备忘录查询这个技能就有这个问题,备忘越多,返回的字符串越长。上下文窗口是有限的,一次对话塞了二十条备忘记录,后面模型就没有余量了。
针对这个问题,我在项目里做了三个处理:
第一,技能返回之前先做摘要截断。超过一定长度就只返回前N条和总数信息,让模型基于摘要继续对话。
第二,给技能加分页参数。比如note.list里加一个limit参数,默认返回前十条。
第三,对大块内容做落盘处理。技能保存全量内容到文件,返回给模型的只有文件路径或短摘要。需要时再通过另一个技能读取详情。
这个思路可以推广到任何技能。模型需要的往往不是完整内容,而是内容的关键信息和“下一步可以做什么”。
5.3 技能执行报错时,怎么让模型更体面地兜底
早期版本里技能执行报错时返回一长串堆栈,模型看到的就是一堆看不懂的英文报错。后来我统一做了归一化处理:把任何异常都转成简短的中文描述,比如“查询失败,请稍后再试”。
更进阶的做法是给技能错误分类。参数错误是一类,环境错误是一类,业务错误是一类。参数错误可以引导模型补充缺失参数后重试,环境错误只能告知用户暂时无法使用,业务错误则需要模型换个思路。分类越清晰,模型的兜底回复越自然。
实测下来,把异常字符串限制在200字以内,模型能更好地理解并生成面向用户的回复。长篇报错不但消耗上下文,而且会让模型陷入更深的困惑。
5.4 技能之间互相冲突或状态串扰
如果你的两个技能都写文件,且文件名生成逻辑撞了,结果就是后写的覆盖先写的。这个问题我在note.save里就遇到过,后来在文件名的生成逻辑里加了时间戳和序号双重保障,才稳定下来。
技能层设计时最好约定一条规矩:各个技能之间不允许直接访问或修改彼此的状态。需要共享数据时,通过框架暴露的共享存储接口。这个约束能在技能数量快速增长后守住整个系统的边界。
心得:共享状态越少越好,能无状态就无状态。每次技能执行都重新从入参里上下文重建状态,这样出问题时随时可以单独重跑某个技能,而不需要牵扯其他技能的状态。
最后再分享一个实际心得:技能系统的调试方式和普通业务代码不太一样,它调试的是“模型的理解”和“模型的调用”这两层抽象。你在单测里把技能本身跑通,只是第一步;真正需要花时间的是反复调整技能描述的措辞,让模型在真实对话里用对技能、用准参数。我每次新加一个技能,都会跑去和模型聊十几轮,专门构造各种边界问题看它能不能正确触发。这个“对话式验收”环节花的时间,往往比写技能代码本身还多。但恰恰是这个过程,决定了你的agent到底是“会回答”还是“会干活”。
