Agent技能层设计实战:从Function Calling到可维护的工具调用框架

发布时间:2026/10/8 11:24:58
Agent技能层设计实战:从Function Calling到可维护的工具调用框架 最近在调一版带工具调用的agent把一堆API函数注册进去之后模型开始各种“自由发挥”参数传错、调错函数、甚至卡在一个技能里反复打转。折腾几天后我意识到问题不在于模型不够聪明而是我压根缺了一层叫agent-skills的东西。所谓agent-skills简单说就是把agent“能执行的动作”从一行行裸奔的函数代码升级成一套带描述、带参数协议、带返回规范、带安全护栏的完备技能层。练好这一层模型才能真正“拿得稳、调得准、改得动”。这篇文章就是一次完整的复盘从为什么需要技能层到怎么设计技能再到一个可以直接抄走的最小Python框架以及我自己踩坑排雷的实录。适合正在做function calling、Tool Use、自定义Agent流程却被各种奇怪调用行为折磨的开发者参考。1. 为什么需要“技能层”把“能做什么”从模型参数里拿出来1.1 模型不是工具是调度器很多第一次做agent的朋友会默认一件事只要模型够强它就能自己完成“查天气→算温差→发短信提醒”这种完整链路。实测下来会发现模型确实能写出一段像模像样的计划但一执行就露馅——它没有数据库连接不会发HTTP请求连本地文件都摸不到。模型本质上是个“调度器”它擅长的是判断“现在该做什么”而不是亲自“把事做成”。所以你得给它一双手。这双手就是技能。每给我一个能力我会把它注册成一个技能给这个技能起一个唯一的名字写清楚“什么时候用、怎么用、参数长什么样”然后接一个真正干活的函数。模型会根据用户的请求和技能描述自己决定要不要调用某个技能、传入什么参数。第一步先要把“能力”独立出模型本身变成可维护、可生长的一套组件。1.2 一个完整技能的最小组成一个合格的技能不是“一个函数”那么简单我通常要求自己写的每个技能至少包含四部分唯一名称全局唯一建议用“动词_名词”格式比如query_stock_price、send_reminder。清晰描述告诉模型这个技能什么时候触发、什么时候别碰稍后我会细讲这个的关键程度。参数模板用JSON Schema声明每个字段的类型、必填项、取值范围。执行函数真正干活的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, ccNone, attachmentsNone): ...然后注册给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(defaultNone, description抄送人邮箱列表例如[ax.com]) attachments: list[str] | None Field(defaultNone, 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: fskill 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内部函数。二是参数自动解析模型传进来的是普通dictpydantic直接完成字段校验和类型转换执行函数拿到的就一定是干净数据。3.2 Agent主循环让模型“发言—调用—拿结果”闭环有了技能注册中心主循环就变成一件很机械的事情。我用最朴素的方式写了一个循环把人类消息、技能清单、历史记录一起丢给模型如果模型返回的是调用技能的指令就执行技能再把结果回传反复直到模型给出最终回复。def agent_loop(user_query: str, max_steps: int 5): messages [] # 先把技能清单注入系统提示 system_prompt f你是任务调度助手可调用以下技能\n{json.dumps(get_skills_manifest(), ensure_asciiFalse)}\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_asciiFalse)}) else: return 抱歉我无法完成这个请求。 return 达到最大调用次数提前结束。这里有个几乎没被新手重视的点一定要把上一步的结果以明文JSON回传给模型模型靠它理解“刚才调成功了吗、数据是什么”从而决定下一步是继续调下一个技能还是向用户汇报。主循环自己不需要做业务判断真正的判断全交给模型。很多开源框架在工具循环里会加各种复杂路由我觉得前期完全没必要。先跑通这个“裸循环”把突出的问题一个个修完再引入路由编排不迟。3.3 动手加一个真实技能实时汇率查询到目前为止全是框架得用个真实技能验证一下。我这里拿“汇率查询”做示例因为它的逻辑足够清晰模型必须从用户话里抽取出“原币种”和“目标币种”然后调用一个外部API完成换算。若币种缺失技能要引导模型追问用户。技能本体skill( namecurrency_convert, description当用户要求汇率换算例如“100美元等于多少日元”“港币兑人民币”使用该技能。 必须同时提供from_currency和to_currency如果缺少任一币种禁止猜测应提示用户补充。, params_modelCurrencyConvertParams, ) def currency_convert(from_currency: str, to_currency: str, amount: float 1.0): url fhttps://api.frankfurter.dev/v1/latest?base{from_currency.upper()}symbols{to_currency.upper()} resp requests.get(url, timeout10) 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_currencyUSD, to_currencyJPY, amount100“帮我算算港币兑人民币” → 模型没有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: {...}}。模型看到示例后格式跟随的稳定度会高出一大截这也是我多次实测下来投入产出比最高的一项微调。