前四篇把agent的基本骨架都给搭起来了:消息循环、推理模式、状态管理、记忆模块……但走到这一步的agent其实还只是“嘴上功夫”。你和它聊天它能接住,一旦让它查个数、拉个天气、执行点什么操作,它就原形毕露。原因很简单:大模型本质上是文本生成器,它能输出文字,但没法真的去触发程序里的函数。所以第五篇,我补上最关键的这块拼图——给agent接入skill能力。所谓skill,你可以把它理解成agent的“手和脚”:模型负责判断该用哪个技能,你的代码负责真正执行。
这篇适合已经把基础agent跑通、打算往工程化方向迭代的读者。我会从“为什么需要skill层”讲起,然后是接口怎么设计、主循环怎么改,再用计算器和天气两个示例做落地,最后把我实际调试中踩过的坑一条条列出来。整个链路走一遍,你的agent就能从“会聊”升级成“会干活”。
1. 先从“为什么需要skill层”说起
1.1 你现在缺的不是模型,而是一个“执行层”
如果你跟着前面的文章把agent写到今天,你会发现一个尴尬:你的agent做一个复读机很合格,但做不了任何实事。想要解决这个问题,通常有两条路。一条是在system prompt里穷举规则,比如“如果用户要求算数学题,你就输出CALC: 1+1”,然后再写一堆正则去解析。这条路的缺陷非常明显——只要模型的表达稍微换个说法,正则就失灵,而且随着规则变多,prompt会膨胀到一个没法维护的规模。
另一条路就是现在主流方案:让模型在生成回复时,按一套既定协议输出“我想调用哪个技能、参数是什么”,你的程序去执行,然后把结果交还给模型继续推理。这条路把“理解”和“执行”彻底分开,模型负责决策,代码负责落地。skill层就是这个“执行层”的抽象。
我在自己项目里一开始也是走的第一条路,花了一个周末写了几十个正则规则,结果模型随便换个句式就漏匹配。后面切到skill这套思路之后,整个人都轻松了。你不需要预测模型会怎么说话,你只需要让它按协议“点菜”,后厨照单做菜就行。
1.2 工具调用协议是怎么运作的
当前的大模型接口普遍实现了“工具调用协议”,注意这里说的是协议本身,不是某一家私有API。调用时,你额外传一个tools数组,每个元素描述一个技能:技能名、用途说明、参数schema。模型生成内容时有两个分支:
- 直接输出面向用户的最终回答;
- 输出一个tool_call请求,写明要调用哪个技能、参数是什么。
如果是第二个分支,你的程序就会拿到这个请求,去注册表里找到handler,执行它,再把执行结果以tool消息的形式塞回对话历史,然后再次请求模型。模型看完真实结果后,要么继续调下一个技能,要么回到第一个分支给出最终答案。
这套机制的价值在于:模型不需要真的“会做”,它只需要“会选”——这是工程上最务实的取舍。我用这个协议重写之后,模型触发技能的稳定率比正则方案高非常多。如果你的API暂时不支持这套协议,也可以用“让模型输出JSON指令”的方式模拟,但效果和稳定性都会差不少。
1.3 接入skill前后,同一句话的效果变化
先看一个非常实际的例子。用户问:“北京现在多少度?比昨天热还是冷?”
接入前,模块会怎么做呢?模型要么礼貌地告诉你“我无法获取实时天气数据”,要么凭训练数据里的印象编一个数字出来,热情但不可靠。
接入后,链路的走向完全不一样。模型先分析意图,认为是查天气,就调用weather_query这个技能,参数是city=北京,date=今天。你的代码把参数转发给数据源,拿到“北京晴,27摄氏度”之后,作为tool消息回传。模型基于这段数据再组织语言:“北京今天27度,比昨天高了3度,体感偏热。”同一个问题,前者是幻觉,后者是真实的执行结果。这个差别,就是skill层存在的意义。
当然,不是所有能力都适合做成skill。确定性高、参数清晰、结果结构化的能力适合做成skill;而“讲笑话”“润色文本”这类纯粹文本生成能力,留在prompt里让模型自己发挥就好,硬套skill反而增加出错面。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. skill的接口规范与注册表设计
2.1 一个skill的“四件套”
动手写代码前,先把skill的接口想清楚。我在项目里把skill定义成四件套:
- name:机器标识,小写加下划线,比如calculator、weather_query;
- description:一句话讲清技能用途,写给模型看;
- parameters:JSON Schema,描述入参结构;
- handler:执行函数,真正的实现逻辑。
为什么不是简简单单写个函数注册表?因为模型侧需要的“能力描述”和程序侧的“可执行函数”是两码事。函数只需要名字和签名,而模型需要知道“这个技能是什么时候用的”。所以description和parameters这两样,不是可有可无的注释,而是模型判断要不要调用、怎么调用的关键信息。它们决定了一次调用是精准命中还是瞎调一气。
在代码里,我用一个dataclass来承载这个结构:
python复制from dataclasses import dataclass
from typing import Any, Callable, Optional
@dataclass
class Skill:
name: str
description: str
parameters: dict
handler: Callable[..., Any]
required: Optional[list] = None
def to_tool(self) -> dict:
"""转换成工具调用协议要求的格式"""
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": {
"type": "object",
"properties": self.parameters,
"required": self.required or [],
},
},
}
2.2 参数schema是给模型看的说明书
很多同学写parameters的时候很敷衍,字段名一放就完事。但实际上,模型生成参数全靠这段描述,写得好不好直接决定参数合法性。
我举个计算器技能的schema示例:
python复制calculator_params = {
"expression": {
"type": "string",
"description": "要计算的数学表达式,例如 '(12+34)*5-20/4'。只支持 + - * / 和括号。",
}
}
注意这里连“只支持哪些运算符”都写进去了,因为模型不知道你的handler内部支持什么。如果handler支持取整函数,也要在description里写清楚round(3.7)的写法。总之,参数约束越多,description就要越具体。
再看天气技能:
python复制weather_params = {
"city": {
"type": "string",
"description": "城市名,例如 北京、上海、广州。",
},
"date": {
"type": "string",
"description": "查询日期,格式 YYYY-MM-DD,默认今天。",
"enum": ["today", "tomorrow", "2025-01-01"],
},
}
schema字段里,type、properties、required是必写的三件,能用enum约束的就尽量用。模型在生成参数时,越是明确的选项越不容易出错。
提示:如果某个技能存在“不适合调用”的场景,也要写在description里。比如“当用户只是闲聊天气感受,不要调用本技能”。这种反例信息对模型的约束效果非常好,实测能明显减少误调用。
2.3 注册表:把技能统一管起来
注册表我做成一个类,核心只有三个方法:
python复制class SkillRegistry:
def __init__(self):
self._skills = {}
def register(self, skill: Skill) -> None:
if skill.name in self._skills:
raise ValueError(f"skill {skill.name} 已存在")
self._skills[skill.name] = skill
def for_model(self) -> list[dict]:
"""生成模型接口需要的 tools 列表"""
return [s.to_tool() for s in self._skills.values()]
def execute(self, name: str, arguments: dict) -> str:
if name not in self._skills:
return f"错误:技能 {name} 不存在,可用技能有:{', '.join(self._skills)}"
skill = self._skills[name]
try:
result = skill.handler(**arguments)
return self._format_result(result)
except Exception as e:
return f"技能 {name} 执行失败:{e},请检查参数后重试。"
@staticmethod
def _format_result(result: Any) -> str:
if isinstance(result, str):
return result
if isinstance(result, dict):
return "; ".join(f"{k}={v}" for k, v in result.items())
return str(result)
集中管理带来的好处是:参数校验、异常捕获、日志埋点都在一处,不用散落到各个handler里。如果以后要做权限白名单,也只需要在execute里加一个判断。
execute返回的是字符串而不是结构化对象,这里有个小设计考量:因为最终这个结果要作为文本喂回给模型,所以统一转成简短的一行文本更合适。如果返回一坨Python对象,后续还要在组装消息时做序列化,反而多一层出错空间。
3. 主循环改造:模型决策、代码执行
3.1 循环里到底发生了什么
给agent接入skill,最核心的动作就是改造主循环。原来的循环是“用户输入→模型回复”,现在变成多轮“请求-执行”的循环。
整个循环可以用这几步描述:
- 把用户输入追加到messages;
- 调用模型接口,同时带上messages和registry.for_model();
- 检查模型返回内容里有没有tool_calls;
- 没有tool_calls,说明模型准备直接回答用户,循环结束,输出内容;
- 有tool_calls,就逐个执行对应技能,把执行结果作为role=tool的消息追加到messages;
- 回到第2步继续请求模型。
关键点在于:工具执行结果不能直接当成最终答案返回给用户,必须“回灌”给模型,让它基于真实数据完成最终的表述。这也是agent和使用者之间信息完整性的保证。
为了防止极端情况下模型反复调用技能不给出最终答案,我还会给循环加一个最大步数限制。比如最多允许5轮工具调用,超过就用后面的兜底逻辑强制给出回答。
3.2 核心代码:一个可运行的agent循环
下面这段是主循环最精简的形态,可以直接复制到你的项目里改:
python复制import json
def run_agent(user_input: str, messages: list, registry: SkillRegistry, max_steps: int = 5):
messages.append({"role": "user", "content": user_input})
for step in range(max_steps):
resp = call_model(messages, tools=registry.for_model())
if not resp.get("tool_calls"):
messages.append({"role": "assistant", "content": resp["content"]})
return resp["content"]
# 完整保留assistant的tool_calls消息
messages.append({
"role": "assistant",
"content": resp.get("content") or "",
"tool_calls": resp["tool_calls"],
})
for call in resp["tool_calls"]:
name = call["function"]["name"]
arguments = json.loads(call["function"]["arguments"])
result = registry.execute(name, arguments)
messages.append({
"role": "tool",
"tool_call_id": call["id"],
"content": result,
})
# 超步数兜底:让模型根据已有记录做最终总结
messages.append({"role": "user", "content": "请基于之前的工具结果,直接给出最终回答。"})
return call_model(messages, tools=[])["content"]
这段代码里有几个细节容易被忽略,我单独说一下。
第一,assistant的tool_calls消息必须完整保留,不能只存content。因为工具结果要靠tool_call_id和它一一对上,如果丢掉这个关联,模型接口会直接报错。
第二,tool消息里的tool_call_id必须来源于模型返回的call["id"],不能用索引或技能名替代。这个id是临时的,每次都不同,就好比取快递的取件码。
第三,参数解析用json.loads,但模型偶尔会返回两层转义的字符串,这里先按正常处理,后面踩坑篇我再讲加固写法。
3.3 动态依赖:handler不是永远无状态的
实际项目里,handler不可能全是纯函数。天气技能要访问数据源配置,数据库技能要拿连接池,这些依赖怎么灌进去?
我的做法是用闭包,在注册时把依赖绑定好,而不是在handler内部去读全局配置。举个例子:
python复制def build_weather_handler(config):
def handler(city: str, date: str = "today") -> dict:
# 从config里读API地址和key,而不是读全局变量
api_base = config["weather_api_base"]
# 实际请求逻辑……
return {"city": city, "date": date, "temperature": 28, "condition": "晴"}
return handler
注册的时候就变成:
python复制config = {"weather_api_base": "https://..."}
registry.register(Skill(
name="weather_query",
description="当用户提到某城市的温度、降雨、风力或天气状况时使用",
parameters=weather_params,
handler=build_weather_handler(config),
))
这样每个skill自带运行环境,测试的时候也可以很方便地替换mock配置。
4. 两个拿来即用的示例skill
4.1 计算器:能算数,但不让你注入代码
先上最常用也最容易踩坑的计算器。很多人第一反应是直接用eval(),我劝你千万别在生产环境这么干。eval会把整个字符串当成代码执行,用户输入恶意代码时,后果会很难看。所以这里用Python的ast模块,先把字符串解析成抽象语法树,只允许数字、四则运算符和括号节点,其他一律拒绝。
python复制import ast
import operator
ALLOWED_OPERATORS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
}
def _safe_eval(node):
if isinstance(node, ast.Expression):
return _safe_eval(node.body)
if isinstance(node, ast.BinOp) and type(node.op) in ALLOWED_OPERATORS:
left = _safe_eval(node.left)
right = _safe_eval(node.right)
return ALLOWED_OPERATORS[type(node.op)](left, right)
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
raise ValueError(f"不支持的计算表达式节点: {type(node).__name__}")
def calculator(expression: str) -> float:
"""
安全计算仅含 + - * / 和括号的数学表达式。
"""
try:
tree = ast.parse(expression, mode="eval")
result = _safe_eval(tree)
# 统一浮点数长度,避免出现 0.30000000000000004 这类结果
if isinstance(result, float):
result = round(result, 10)
return result
except ZeroDivisionError:
raise ValueError("除数为0")
except Exception as e:
raise ValueError(f"无效表达式: {e}")
这里返回统一保留10位小数,是为了避免把0.1+0.2=0.30000000000000004这种结果直接丢给模型,模型看到一串奇怪的数字还以为是bug。
4.2 天气查询:用mock数据打通流程再说
天气技能在真实项目里肯定要接第三方API,但在本地演示阶段,我强烈建议先用mock数据,先把“模型选技能→执行→结果回传→模型总结”这条链路跑通,再接真实数据源。否则你根本分不清是API的问题还是链路的问题。
python复制MOCK_WEATHER = {
("北京", "today"): {"temperature": 27, "condition": "晴", "humidity": 40},
("上海", "today"): {"temperature": 25, "condition": "多云", "humidity": 65},
}
def weather_query(city: str, date: str = "today") -> dict:
# 演示数据
data = MOCK_WEATHER.get((city, date))
if not data:
data = {"temperature": 22, "condition": "阴", "humidity": 50}
return {"city": city, "date": date, **data}
你可能会觉得这个handler太简单,但它对学习曲线来说刚刚好。真实天气API无非就是把这一段换成HTTP请求,返回值结构保持住,后面所有代码都不需要动。这就是“面向接口编程”在agent项目里的体现——模型的调用约定和底层数据源解耦。
4.3 联调验证:用两轮对话检查链路
写完代码别急着关编辑器,先做一轮联调。我在项目里用一个简单的打印函数来跟踪消息流:
python复制def debug_run(agent_func, user_input):
print(f"用户: {user_input}")
print(f"Agent: {agent_func(user_input)}")
print("---")
跑两个用例,重点看控制台输出中是否出现tool消息:
用例一:用户说“帮我算一下(12+34)*5-20/4”。预期流程是:模型返回一个tool_calls,技能名为calculator,参数expression=(12+34)*5-20/4;执行结果是226.0,作为tool消息回传;模型最终回答226.0。
用例二:用户说“上海明天天气怎么样”。预期流程是:模型返回tool_calls,技能名为weather_query,参数city=上海,date=明天;执行结果回传;模型回答包含温度、天气状况。
如果控制台里没有出现tool消息,说明模型没有选择调用技能,那就得回到第5节看排查方法。
提示:联调阶段建议把模型接口换成响应最快的轻量模型,而不是上来就用最强最贵的大模型。链路通了再换强模型,能省不少token和时间。
5. 实际调试中踩过的坑
5.1 模型就是不调用工具
这是接入skill后最常遇到的问题。你代码写好了,tools也传了,模型就是“上有政策,下有对策”,直接用文本回答,就是不调工具。
我排查的顺序一般是:
- 先确认tools参数真的传到了模型接口,而不是只定义没使用;
- 再打印一次模型的原始返回,确认resp里到底有没有tool_calls字段;
- 然后检查参数的schema,看有没有语法错误,比如properties写错缩进或层级;
- 最后也是最容易被忽略的,检查skill的description写没写明白“什么时候该用”。
description这个问题值得一提。很多人的description只有一句“这个技能可以查天气”,模型读完也不知道用户问“会不会下雨”时是不是该调它。我把description改成“当用户提到某城市的温度、降雨、风力或天气状况时使用;如果用户只是谈论天气感受,不要调用”,调用率立刻不一样。
5.2 arguments是字符串:JSON解析地狱
模型返回的tool_calls里,function.arguments是一个JSON字符串,不是对象。很多人第一次写就栽在这里:直接当成字典去取key,报错。
正确姿势是先json.loads,但问题又来了——模型有时会在字符串里嵌套转义,比如返回了"{\"city\":\"北京\"}"这种已经被序列化过一次的内容。json.loads一次解码出来还是个字符串,还得再loads一次。
我的健壮写法是写一个小工具函数:
python复制def parse_arguments(raw: str) -> dict:
try:
return json.loads(raw)
except json.JSONDecodeError:
return json.loads(json.loads(raw))
except Exception:
return {}
如果解析失败,返回空字典,然后让模型知道参数有问题。比直接抛异常让整个agent挂掉要好得多。
5.3 模型反复调同一个工具,陷入死循环
模型调了一次计算器,拿到了结果,按理说该总结了,结果它又调了一次,再调一次……我的agent一度能连续调七八次同一个技能,直到撞上max_steps。
这个问题有三个手段:
第一,设置max_steps,这是底线保障;
第二,在system prompt里加一句“如果已经拿到工具结果并且足以回答用户问题,请用自然语言直接总结,不要再次调用工具”;
第三,在代码里检测重复调用,比如同一个技能名和相同参数出现两次以上,就强制中断并把当前结果汇总。
重复调用的本质,是模型对“何时停止”没有感知。给循环加上终止条件,就像给自动驾驶加上限速标识,是必须写的防御逻辑。
5.4 工具返回结果太长,把上下文挤爆
有些技能返回一大段日志或数据库记录,直接回传会快速消耗上下文空间,甚至挤掉用户最初的意图。
我的经验值是:回传给模型的内容尽量控制在几句话以内,只保留对决策有用的摘要。可以在handler里直接截断,也可以在_execute阶段做统一后处理。比如数据库查询技能,就只返回“查询到3条记录,总金额=12540元”这类聚合结果,而不是把每条明细都倒给模型。
我把常见问题整理成了一张速查表,方便你直接对照排查:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 模型从不调用工具 | description不清晰、tools没传 | 打印原始返回,检查tools列表 |
| 参数解析报错 | arguments是字符串或转义 | 用健壮解析函数,失败后回传提示 |
| 反复调用同一个工具 | 缺少终止条件 | 增加max_steps、重复检测、改prompt |
| 工具结果挤爆上下文 | 返回内容过长 | 只回传摘要,控制单次返回的token量 |
| 模型调用不存在的技能 | 注册表与tools不同步 | 注册前检查,execute里做兜底提示 |
6. skill层的下一步演化
写到这里,一个能调用技能的agent已经跑起来了。但如果你的目标不只是“玩具”,下面这几个方向值得继续推。
第一个方向是权限和可观测性。在registry.execute里加一层访问控制,按用户角色过滤可见技能;同时把每次调用的技能名、参数、耗时、返回摘要落成日志。后者尤其重要,当agent行为出问题,翻日志能定位是模型决策错还是handler执行错。
第二个方向是多技能编排。当前循环虽然支持多轮工具调用,但还比较“被动”,是模型一步步试。你可以往“计划-执行”模式走:先让模型输出一个任务拆解,再按步骤调用技能,把中间结果串起来。比如“比较三个城市的天气并给出出行建议”,就变成查询三次天气再统一总结。
第三个方向是把记忆模块接进来。用户上次查询过北京天气,下次再问“北京明天还这么热吗”,skill层不需要重复传全部历史,而应该由记忆模块把默认城市填进去。技能调用参数来源从“用户这句话”扩展为“用户上下文+偏好”,agent的体验会明显上一个台阶。
最后再分享一个我自己的体会:这套skill系统看起来不难,但真正做到稳定,靠的不是哪个环节写得多炫,而是每一条边界条件都有人兜底。模型会输出错参数,那就加校验和重试;handler会抛异常,那就统一捕获并回传提示;循环会失控,那就设步数限制。把这些“意外”当成系统的一部分去设计,你的agent才从教学demo变成能用的工程。
以上是我这版实现里的完整思路和代码。踩过几次坑之后最大的感受是:接入skill真正的难点不在写几个handler,而在于把“模型决策”和“代码执行”这两件事之间的契约定清楚。契约清晰,后面加再多技能都只是注册列表变长而已;契约模糊,每加一个技能就要跟模型和参数搏斗半天。希望这篇能帮你少走这段弯路。
