☰
Agent技能层设计实战:从Function Calling到可维护的工具调用框架
2026/10/8 11:24:11 网站建设 项目流程

最近在调一版带工具调用的agent,把一堆API函数注册进去之后,模型开始各种“自由发挥”:参数传错、调错函数、甚至卡在一个技能里反复打转。折腾几天后我意识到,问题不在于模型不够聪明,而是我压根缺了一层叫agent-skills的东西。

所谓agent-skills,简单说就是把agent“能执行的动作”,从一行行裸奔的函数代码,升级成一套带描述、带参数协议、带返回规范、带安全护栏的完备技能层。练好这一层,模型才能真正“拿得稳、调得准、改得动”。这篇文章就是一次完整的复盘:从为什么需要技能层,到怎么设计技能,再到一个可以直接抄走的最小Python框架,以及我自己踩坑排雷的实录。适合正在做function calling、Tool Use、自定义Agent流程,却被各种奇怪调用行为折磨的开发者参考。

1. 为什么需要“技能层”:把“能做什么”从模型参数里拿出来

1.1 模型不是工具,是调度器

很多第一次做agent的朋友会默认一件事:只要模型够强,它就能自己完成“查天气→算温差→发短信提醒”这种完整链路。实测下来会发现,模型确实能写出一段像模像样的计划,但一执行就露馅——它没有数据库连接,不会发HTTP请求,连本地文件都摸不到。模型本质上是个“调度器”,它擅长的是判断“现在该做什么”,而不是亲自“把事做成”。

所以你得给它一双手。这双手就是技能。

每给我一个能力,我会把它注册成一个技能:给这个技能起一个唯一的名字,写清楚“什么时候用、怎么用、参数长什么样”,然后接一个真正干活的函数。模型会根据用户的请求和技能描述,自己决定要不要调用某个技能、传入什么参数。第一步先要把“能力”独立出模型本身,变成可维护、可生长的一套组件。

1.2 一个完整技能的最小组成

一个合格的技能不是“一个函数”那么简单,我通常要求自己写的每个技能至少包含四部分:

  1. 唯一名称:全局唯一,建议用“动词_名词”格式,比如query_stock_price、send_reminder。
  2. 清晰描述:告诉模型这个技能什么时候触发、什么时候别碰,稍后我会细讲这个的关键程度。
  3. 参数模板:用JSON Schema声明每个字段的类型、必填项、取值范围。
  4. 执行函数:真正干活的Python函数,入参从参数模板里来,出参走统一的返回格式。

我在下面起了个最小范例,能看到一个技能长什么样:

{ "name": "get_weather", "description": "查询指定城市的当前天气。当用户明确提到某地天气时使用;若未指定城市,必须先向用户询问。", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "城市名,如北京、上海"} }, "required": ["city"] }, "execute": "call_weather_api(city)" }

千万注意,这段JSON不是给人看的,是给模型“读”的。模型通过描述里的文字来匹配“用户意图”和“技能”。这也是为什么很多新手把技能做成纯函数后效果很差,因为函数定义和模型能理解的自然语言描述之间,缺了翻译层。

2. 设计技能的实用前提:命名、描述与返回规范

2.1 先写对description,再写代码

如果你只能花10分钟在一个技能上,我会劝你全花在description上。代码逻辑错了还能靠报错排查,描述写得模糊,模型会在调用时做出完全不可预期的行为。

我举个例子。某次我把“根据当前城市的PM2.5指数提醒用户要不要戴口罩”的能力封装成一个技能,描述最初写得极简:get_pm25_and_remind(city)。结果模型在用户问“今天出门要注意什么”的时候调用了它,用户说“帮我看看明天的安排”它也调用了它。因为描述没有限定触发场景,模型靠猜就会扩大适用范围。

后来我改成这样:

当用户询问空气质量、PM2.5、口罩建议,以及涉及户外活动健康提醒时使用。 必须提供city参数;如果用户没有说明城市,先向用户询问城市名,禁止默认使用北京。 返回内容包含污染物数值与对应的活动建议。

一段好的描述要回答三个问题:什么时候用、参数从哪来、返回什么。再加一条负面约束(“禁止默认使用北京”),能把误调用率直线拉低。条件允许的话,再加一个never_use_when的字段来显式写禁区,追求更极致的效果可以加上,实际经验里多写几句就能见效。

2.2 参数必须显式声明,别让模型瞎猜

很多人的技能函数是这么写的:

def send_email(to_addr, content, cc=None, attachments=None): ...

然后注册给agent时,就把函数的__doc__和inspect.signature直接传给了模型。这确实省事,但副作用是参数边界完全失控。模型可能会尝试把cc传成字符串,把attachments传成文件路径而不传文件内容,甚至会在没有附件时凭空捏造一个附件路径。

技能的参数协议必须精确到“这个字段允许什么、不允许什么”。我在项目里统一用Pydantic做参数模型:

from pydantic import BaseModel, Field class SendEmailParams(BaseModel): to_addr: str = Field(description="收件人邮箱,要符合邮箱格式") content: str = Field(description="邮件正文内容") cc: list[str] | None = Field(default=None, description="抄送人邮箱列表,例如['a@x.com']") attachments: list[str] | None = Field(default=None, description="附件路径列表,路径必须以/data/reports/开头")

这样模型就能看到精确说明,再配合校验异常时的报错回传,误调参数的问题会好很多。记住一句话:你给的参数描述越精确,模型传参越稳定。别让任何参数靠模型猜。

2.3 统一返回结构,模型才不会精神分裂

技能调用完,结果要回到模型手里进行下一步推理。如果每个技能返回的格式都不一样,模型要花很多额外精力去“理解这一次到底返回了什么”,不仅变慢还容易出错。我直接推一个赌咒发誓好用的规范:不管内部执行成什么样,对外一律返回{"ok": bool, "data": ...}或{"ok": false, "error": "错误原因"}。

def run_skill(skill_name, params): try: result = SKILL_REGISTRY.execute(skill_name, params) return {"ok": True, "data": result} except Exception as e: return {"ok": False, "error": f"[{skill_name}] 执行失败: {str(e)}"}

统一返回值之后,Agent主循环只需要处理这两种情况,模型也只需要根据ok字段决定是要继续还是要把错误信息说给用户听。返错时把错误信息原样给到模型,模型能自己读完错误决定下一步。数据越规整,模型越能专心做“调度”而不是做“翻译”。

3. 从一个空目录开始:搭一套技能执行框架

3.1 注册机制:用一个装饰器把技能收拢起来

设计完单个技能,下一步是“收拢”。我不建议用一堆if-else去分发技能,那样每加一个新技能就要改主循环,很快就疯掉。我习惯用一个注册中心,让每个技能自报家门,主循环根本不关心技能细节。

下面这个几十行的注册器,是我个人一直在用的基础版:

from typing import Callable, Any from pydantic import BaseModel SKILL_REGISTRY: dict[str, dict[str, Any]] = {} def skill(name: str, description: str, params_model: type[BaseModel]): def decorator(func: Callable): SKILL_REGISTRY[name] = { "name": name, "description": description, "parameters": params_model.model_json_schema(), "handler": func, "params_model": params_model, } return func return decorator def get_skills_manifest() -> list[dict]: """返回给模型的技能清单,只保留声明信息,不暴露handler。""" out = [] for s in SKILL_REGISTRY.values(): out.append({ "name": s["name"], "description": s["description"], "parameters": s["parameters"], }) return out async def execute_skill(name: str, params: dict) -> dict: skill_def = SKILL_REGISTRY.get(name) if not skill_def: return {"ok": False, "error": f"skill not found: {name}"} try: validated = skill_def["params_model"](**params) result = skill_def["handler"](**validated.model_dump()) return {"ok": True, "data": result} except Exception as e: return {"ok": False, "error": f"[{name}] error: {e}"}

关键点有两个。一是清单和handler分离:模型只能看到“声明”,看不到底层实现,免得模型跑去调用你的Python内部函数。二是参数自动解析:模型传进来的是普通dict,pydantic直接完成字段校验和类型转换,执行函数拿到的就一定是干净数据。

3.2 Agent主循环:让模型“发言—调用—拿结果”闭环

有了技能注册中心,主循环就变成一件很机械的事情。我用最朴素的方式写了一个循环:把人类消息、技能清单、历史记录一起丢给模型,如果模型返回的是调用技能的指令,就执行技能再把结果回传,反复直到模型给出最终回复。

def agent_loop(user_query: str, max_steps: int = 5): messages = [] # 先把技能清单注入系统提示 system_prompt = f"你是任务调度助手,可调用以下技能:\n{json.dumps(get_skills_manifest(), ensure_ascii=False)}\n" messages.append({"role": "system", "content": system_prompt}) messages.append({"role": "user", "content": user_query}) for step in range(max_steps): resp = call_llm(messages) # 模型可返回文本或技能调用指令 if resp.get("type") == "final": return resp["content"] if resp.get("type") == "skill_call": skill_result = execute_skill(resp["skill_name"], resp.get("params", {})) messages.append({"role": "function", "name": resp["skill_name"], "content": json.dumps(skill_result, ensure_ascii=False)}) else: return "抱歉,我无法完成这个请求。" return "达到最大调用次数,提前结束。"

这里有个几乎没被新手重视的点:一定要把上一步的结果以明文JSON回传给模型,模型靠它理解“刚才调成功了吗、数据是什么”,从而决定下一步是继续调下一个技能还是向用户汇报。主循环自己不需要做业务判断,真正的判断全交给模型。

很多开源框架在工具循环里会加各种复杂路由,我觉得前期完全没必要。先跑通这个“裸循环”,把突出的问题一个个修完,再引入路由编排不迟。

3.3 动手加一个真实技能:实时汇率查询

到目前为止全是框架,得用个真实技能验证一下。我这里拿“汇率查询”做示例,因为它的逻辑足够清晰:模型必须从用户话里抽取出“原币种”和“目标币种”,然后调用一个外部API完成换算。若币种缺失,技能要引导模型追问用户。

技能本体:

@skill( name="currency_convert", description="当用户要求汇率换算,例如“100美元等于多少日元”“港币兑人民币”,使用该技能。" "必须同时提供from_currency和to_currency;如果缺少任一币种,禁止猜测,应提示用户补充。", params_model=CurrencyConvertParams, ) def currency_convert(from_currency: str, to_currency: str, amount: float = 1.0): url = f"https://api.frankfurter.dev/v1/latest?base={from_currency.upper()}&symbols={to_currency.upper()}" resp = requests.get(url, timeout=10) data = resp.json() rate = data["rates"][to_currency.upper()] return {"from": from_currency.upper(), "to": to_currency.upper(), "rate": rate, "converted_amount": round(amount * rate, 4)}

然后去真实环境里跑这几条用户输入:

  • “100美元是多少日元?” → 技能收到from_currency=USD, to_currency=JPY, amount=100
  • “帮我算算港币兑人民币” → 模型没有amount,按默认1.0处理,返回汇率本身
  • “100块能换多少欧元?” → 模型会猜测“100块”是人民币,如果技能的description里没有写默认币种,这里就全靠模型常识兜底

注意最后一个例子:描述里如果明确说了“当用户只说‘块’而没有明示币种时,默认视为CNY”,模型行为会稳定非常多。别嫌这啰嗦,技能描述本来就是用来消灭歧义的。

我在这个基础上又加了一个“汇率反向换算”的小技巧:当用户说“50欧元的菜贵不贵”,我需要先把50欧元换算成人民币,再对比本地人均消费。做法是把currency_convert拆成get_exchange_rate和convert_money两个原子技能,让模型自己组合。实现后你会发现,模型在大多数情况下能准确串联这两个技能。这就是“原子技能”的价值:把词根拆得足够小,组合才灵活。

4. 技能多了之后:冲突路由、权限与护栏设计

4.1 技能的原子化与组合

技能少的时候怎么设计都行,一旦超过15~20个,模型的选择困难就会开始暴露。它可能在“查天气”和“查空气质量”之间反复横跳,也可能在“发邮件”和“写邮件草稿”之间选错。我的解法是给技能分两层:原子技能和复合技能。

原子技能是最小可执行单元,例如get_stock_price、get_user_location、send_email。复合技能是把多个原子技能按固定剧本编排成的新技能,比如“收盘播报”= 查持仓 → 查行情 → 生成文字 → 推送,流程完全固定,不需要模型临时决策。

在技能注册表里我加了一个depends_on字段,复合技能执行时自动依次调起依赖的原子技能:

SKILL_REGISTRY = { "daily_portfolio_report": { "handler": daily_report_handler, "depends_on": ["get_positions", "query_stock_price", "make_markdown_table"], } }

这样模型面对复合技能时,不用一次性想出全部细节,只需要一个“按钮”就能触发一条固定流程。既省token,又降低决策出错率。

我踩过的最深的坑,是把“生成报告”和“发送报告”写成了一个技能。结果模型在一次用户说“把报告发我”的请求里,直接重复调用了“生成报告”三四次,就是不调用“发送报告”。拆开之后,模型的行为才恢复正常。复合技能适合固定编排,原子技能适合灵活决策,混淆这两者会让模型行为充满随机性。

4.2 不让模型乱来:护栏与确认机制

能力越多,风险越大。如果技能里有delete_file、transfer_money这类高危动作,一定不能在模型“想调就调”的范围内。我一直建议在高危技能外部包一层确认机制:模型调用该技能时,不直接执行,而是返回一个“需要用户确认”的信号,等用户在对话里输入“确认”后再真正跑。

SENSITIVE_SKILLS = {"delete_file", "batch_send_emails", "apply_for_leave"} def execute_skill_safe(name: str, params: dict, user_confirmed: bool = False): if name in SENSITIVE_SKILLS and not user_confirmed: return {"ok": False, "as shall_ask": True, "data": "该操作会影响数据,需要用户确认;请向用户展示确认信息并征得同意,不要自行执行。"} return execute_skill(name, params)

这种“软护栏”让模型在对话层面完成确认,而不是在代码层强制中断,用户的体感会自然很多。实测下来,高危操作只有不到两成的误触率通过这层机制被拦截下来,剩下八成正是在描述里没写清负面约束导致模型不该调却调了。

正规项目里可能还会加权限令牌、调用频率限制、可溯源日志等。早期我建议至少做两层:一层是会话级确认,一层是操作级审计日志。任何技能调用都要留下痕迹:谁调的、什么参数、什么时间、返回什么。不然出了事故你连复盘的机会都没有。

4.3 技能版本化与回归测试

最后聊聊技能多了之后的日常维护。每改一个技能描述、每加一个参数,都可能改变模型的行为。我第一次改“汇率换算”的参数说明,把amount的默认值从1改成了“必填”,结果模型在用户只问“今天汇率多少”时直接拒绝回答,还一本正经地说:“您没有提供金额,我无法查询汇率。”这就是描述约束和实际语义不匹配的后果。

为了避免这类事,我现在维护一个轻量回归集:把过去一段时间内真实用户的高频问题整理成30到50条,每次改完技能,就全量跑一遍,看一眼行为有没有劣化。不用做自动化断言,只要肉眼检查输出就能发现九成的问题。因为核心不稳定因素本来就不是逻辑,而是模型对描述语义的“理解漂移”。

回到版本化上来。每次上线的技能改动我都打一个tag,并在技能描述里顺手带一个version字段。这样一旦发现线上行为不对,能快速判断是哪个版本引入的回归,也可以让Agent在运行日志里记录版本号,回滚不用改代码,改配置文件就行。

5. 调Agent时必踩的坑与排查技巧

5.1 常见现象与修复办法

做技能化Agent过程中我收集了一张“故障速查表”,基本都是自己踩过的坑。遇到问题时先对照一遍,比无头绪调试管用得多。

现象根因处理方式
模型不调用任何技能,只会聊天技能清单没注入系统提示词;或技能描述与用户请求语义关联太弱检查主循环是否传了技能清单;在描述里增加典型的用户问法例句
模型调用了不相关的技能两个技能描述有重叠,语义边界模糊拆技能、删冗余描述;给其中一个显式写never_use_when
模型反复调用同一个技能,不退出上一步调用结果没回传给模型,或返回结果的error信息不明朗,模型陷入重试死循环设置最大步数;把返回结果以function role回传;错误信息要具体
参数传错类型或格式JSON Schema里缺少类型和格式约束用Pydantic强校验;在字段描述里给出具体的示例值,不要只写抽象说明
技能返回结果太长,模型上下文爆了技能返回了完整大文本,比如整份PDF内容在技能内做摘要、截断,或只返回“条数+前几条摘要+文件路径”
换了新模型版本后,行为变怪模型对描述语义的敏感度变化,同一套prompt不一定适配回归集重跑;重新措辞描述;必要时升级技能版本号

其中“参数传错类型”是出现频率最高的,而且往往是描述里偷懒造成的。拿“城市名”举例,你只写city: string,模型会老实传“北京”,但你写成“城市名,如‘北京’、‘上海’,不要带‘市’字后缀”,它的传参准确率和稳定度会明显提升。

5.2 排查工具与调试习惯

最后说说长期能省大力的三个调试习惯。

第一,把模型和技能之间的交互全程打出来。主循环里每产生一次技能调用,都把完整入参、返回、模型下一步的原始输出落日志。不要只记摘要,摘要往往丢掉关键细节。我见过太多人排查半天,最后发现问题是“模型传参时多了一个空格”。

第二,给每个技能单独做一个最小测试脚本。只调模型不调技能,或只调技能不调模型,把故障点隔离开。如果技能本身能用示例参数正确返回,模型又调得不对,那就是描述问题;反过来就是技能自己的bug。

第三,建立一套“召唤词”测试集。挑几个用户最典型的问法,不去纠结模型要不要调技能,只看最终结果对不对。比如“帮我把今天新到的邮件归档到项目文件夹”,“汇率换成美元看看”,这些句子覆盖常见意图,每次上线前跑一遍,跑完再发布。

这轮做下来后,我对“agent不听话”这件事的心态彻底变了。过去我总想靠更复杂的prompt把模型“压住”,现在更愿意花精力把技能层打磨得像一份产品需求文档:每个能力都有明确的触发场景、参数边界、返回规范,让模型去当那个读需求的人。你喂给它的说明书越像人话,它做事就越像样。

再送你一个小技巧:给每个技能描述末尾加一段“典型调用示例”,比如正确用法:get_weather(city="北京") -> {"ok": true, "data": {...}}。模型看到示例后,格式跟随的稳定度会高出一大截,这也是我多次实测下来投入产出比最高的一项微调。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询