最近在整理一个Agent项目的时候,翻到了几个月前写的工具调用代码,说实话有点上头。最开始项目里只有两三个函数,比如查天气、设提醒,注册到模型那边也就几行JSON声明。到后来工具数量膨胀到几十个,情况就开始失控:模型经常选错函数、参数类型对不上、错误提示一长串没人接得住。我干脆把这一堆东西按统一规范重写了一遍,就成了现在的agent-skills技能库。这篇文章就聊聊这次重构里的设计取舍和实操细节,包括技能目录怎么搭、元数据怎么写、运行时怎么管参数和超时,以及我在真实项目里踩过哪些坑。适合正在做Agent编排、准备把工具调用规范化,或者想把function calling封装得更可靠的朋友参考。
1. 环境准备与整体设计
1.1 工具调用为什么会失控
先说说我一开始的那个项目。它本质上是一个跑在大模型上的数据处理助手,用户丢几句话,Agent就自己去调工具、做计算、再回答。最开始只有两三个函数时,事情很简单:把函数的名称、描述、参数JSON Schema塞给模型,它按照格式返回一个function call,我这边执行完把结果拼回去,任务就结束了。但工具到了二三十个之后,问题全跑出来了。
最明显的是描述和实际实现脱节。函数改过一次签名,漏改了给模型看的description,模型还在按旧参数调用,线上能跑才怪。另一个问题是参数Schema写得松,类型标注不严格。比如一个订票工具,start_time在schema里写的是string,但没说格式,模型就自由发挥,传进来“明天上午”“下周一”这种自然语言,后端解析直接炸。还有的错误处理全堆在函数内部,抛出的异常文本一长串,模型拿回去根本看不出是参数问题还是服务问题,只能瞎猜重新调用。到了这种阶段,靠人肉维护已经扛不住了。
1.2 Agent Skills到底在解决什么问题
把工具调用重构成技能库,本质上是把原来散落各处的函数声明、参数校验、错误处理、测试用例收拢到一个统一框架里。一个skill不是简单的函数,而是一个完整的业务能力单元。它包含元数据声明(名字、用途、参数说明)、实际执行逻辑、参数校验器、离线测试用例,还有独立的依赖清单。
用个简单类比:函数调用像是餐厅后厨有一堆原材料,各层厨师的做菜方法五花八门;技能库则是给每道菜写好了菜谱、食材清单和验收标准,客人(模型)按菜谱点菜,后厨按标准出菜。对模型来说,它看到的不再是一堆参差不齐的函数,而是接口一致的“菜单”。这样既降低了模型的判断难度,也让运维侧的维护边界变得清晰:每个技能独立开发、独立测试、独立上线。
1.3 技能库设计的四个关键原则
做这个重构之前,我给自己定了四个原则,后面所有代码都是围绕它们展开的。
- 单一职责:每个技能只做一件明确的事。不要搞“全能工具”,描述越精确,模型越容易选对。
- 自描述:技能的所有信息都能被模型直接读取。名字、用途、参数约束、返回值形态都写在机器可读的元数据里,而不是藏在源码注释里。
- 可测试:每个技能都必须有离线测试用例。没有测试的技能不准注册进Agent。
- 依赖隔离:技能的第三方依赖互不污染,至少做到版本声明清楚,必要时用独立虚拟环境运行。
这四条里面,自描述是最容易被低估的,也是最容易做砸的。接下来我把技能目录和元数据怎么写说细一点。
2. 核心细节解析与实操要点
2.1 技能目录的完整骨架
一个技能在我的项目里长这样:
skills/ csv_summary/ skill.yaml __init__.py core.py tests/ test_core.py fixtures/ sample.csv requirements.txt date_utils/ skill.yaml __init__.py core.py tests/ test_core.py requirements.txt每个组件的职责是:
- skill.yaml:技能的身份证,写清楚名字、版本、描述、输入输出Schema。这是模型唯一会见面的文件。
- core.py:实际执行逻辑,纯Python函数,不关心大模型,你甚至可以单独在命令行里跑它。
- init.py:做导入导出,暴露统一入口。
- tests/:放单元测试和测试数据,保证技能可以离线验证。
- requirements.txt:声明这个技能需要什么第三方库,装的时候按技能单独装。
我自己用了一段时间之后,发现把tests和fixtures放到技能目录里这步特别值钱。每次改动core.py,跑一遍测试就能确认没把旧行为改坏。相比以前集成在项目里的一堆工具函数,这样的组织方式干净很多。
2.2 元数据声明与描述写法
skill.yaml是整个技能库的灵魂,模型的判断基本全看它。我习惯用这样的写法:
name: csv_summary version: 1.2.0 description: > 读取指定路径的CSV文件,对用户关注的列或全部数值列执行统计摘要, 返回行数、均值、中位数、缺失值数量、最小值、最大值。适合数据体检、 表格快速概览和数据质量检查。 inputs: - name: file_path type: string required: true description: CSV文件路径,支持相对路径或绝对路径。 - name: columns type: array items: string required: false description: 需要统计的列名列表,默认统计全部数值列。 outputs: type: object description: 统计摘要字典,key为列名,value为包含各项统计值的对象。你可以看到description没有写成一句话带过,而是把适用场景也写了进去。这一点很重要。模型在选择技能时,描述越具体,越容易匹配到正确的那个。我见过把描述写成“CSV工具”的,结果连不上任何场景触发点,模型死活不调用。一个好的规则是:描述里至少包含“这个技能能做什么”+“什么情况下该用”+“参数大概长什么样”。
关于输入输出Schema,我的建议是类型能收窄就收窄,能加枚举就加枚举。你管得有多严,模型就越不容易自由发挥。下面这个表我摘录了一下好描述和坏描述的对比:
| 项目 | 坏描述 | 好描述 |
|---|---|---|
| name | tool_util | csv_summary |
| description | 读取CSV文件并分析 | 读取指定CSV文件,对数值列输出均值、中位数、缺失值统计,用于数据体检和表格概览 |
| 参数说明 | file_path: 文件路径 | file_path: 文件路径,支持相对或绝对路径 |
| 参数示例 | 无 | 示例: /data/sales.csv |
2.3 参数校验的边界控制
模型生成的参数,说穿了只是一种“接近于正确”的推测,永远不能直接当最终参数用。所以每个技能在正式执行前都要经过一道校验门槛。我通常写一个轻量的校验函数,schema校验不通过时,先尝试做一次修正,修正不了再返回明确的错误。
def validate_and_fix(arguments: dict, schema: dict) -> tuple[bool, dict | str]: # 缺必填字段 required = schema.get("required", []) for field in required: if field not in arguments: return False, f"缺少必填参数: {field}" # 类型纠正:很多模型会把int传成string for prop_key, prop_schema in schema.get("properties", {}).items(): expected = prop_schema.get("type") actual = arguments.get(prop_key) if actual is None: continue if expected == "number" and isinstance(actual, str): try: arguments[prop_key] = float(actual) except ValueError: return False, f"{prop_key}应该为数字,无法转换: {actual}" if expected == "array" and isinstance(actual, str): # 模型可能把数组传成逗号分隔的字符串 arguments[prop_key] = [item.strip() for item in actual.split(",")] return True, arguments这段代码解决了我遇到的最主要的两个模型传参问题:类型错和格式错。你可以在校验通过后再调用core.py里的真正函数,保证执行逻辑不会因为脏参数而半途出错。
3. 实操:从零写一个CSV摘要技能
3.1 案例选择与需求拆解
为了让你直接照着一套完整流程走通,我选了一个不需要外部服务、不依赖网络、还能体现参数校验和结构化返回的案例:CSV摘要技能。给它一句话:给定一个CSV文件路径,输出全部数值列的统计信息,包括行数、非空数、缺失数、均值、中位数、最小值、最大值。
这个技能非常适合作为第一个练手对象,原因是它边界清楚、数据可控、测试起来不费劲。你不需要申请API密钥,不用搭服务,放到任何一台机器上都能跑通。
3.2 核心代码实现
下面是core.py的完整实现,我把注释和错误处理都写进去了:
import csv from statistics import mean, median def _to_float(value): if value is None or value == "": return None v = str(value).strip().replace(",", "") try: return float(v) except ValueError: return None def _detect_numeric_columns(rows, fieldnames): numeric_cols = [] for name in fieldnames: values = [_to_float(r.get(name)) for r in rows] non_null = [v for v in values if v is not None] if non_null and len(non_null) * 5 >= len(values) * 4: numeric_cols.append(name) return numeric_cols def csv_summary(file_path: str, columns: list[str] | None = None) -> dict: with open(file_path, newline="", encoding="utf-8") as f: reader = csv.DictReader(f) rows = [row for row in reader] if not rows: return {"error": "empty_csv", "message": "CSV文件中没有数据行"} fieldnames = list(rows[0].keys()) numeric_cols = _detect_numeric_columns(rows, fieldnames) targets = columns if columns else numeric_cols targets = [col for col in targets if col in fieldnames] result = {} for col in targets: values = [_to_float(r.get(col)) for r in rows] valid = [v for v in values if v is not None] summary = { "row_count": len(rows), "non_null_count": len(valid), "missing_count": len(values) - len(valid), "mean": round(mean(valid), 4) if valid else None, "median": round(median(valid), 4) if valid else None, "min": round(min(valid), 4) if valid else None, "max": round(max(valid), 4) if valid else None, } result[col] = summary if not result: return {"error": "no_valid_columns", "message": "未找到有效统计列"} return {"result": result}有个小地方要说明:_detect_numeric_columns的判断标准是整列非空值覆盖率达到80%,同时这些非空值都能转成浮点数,才认为是数值列。如果一列里脏数据比例太高,我更倾向于让用户通过columns参数显式指定,而不是靠自动猜测。这样行为更可预期,模型也更好理解。
3.3 测试与本地调试
技能好不好使,先离线跑测试。我在tests/test_core.py里放了这样一个用例:
import tempfile, os from core import csv_summary def test_basic_summary(): content = "name,age,score\nalice,25,88.5\nbob,30,92.0\ncarol,,76.0\n" with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f: f.write(content) path = f.name try: summary = csv_summary(path) finally: os.unlink(path) assert "error" not in summary assert summary["result"]["age"]["mean"] == 27.5 assert summary["result"]["age"]["missing_count"] == 1跑一下:
cd skills/csv_summary python -m pytest tests/ -v如果只想快速看输出,我可以直接在命令行里调函数:
python -c "from core import csv_summary; print(csv_summary('/tmp/sample.csv'))"这一步的价值在于:把技能先当成一个普通Python模块调试,等它离线行为稳定了,再接进Agent。我见过不少项目把调试成本全堆在集成测试里,每次改完技能都要等整个系统跑一圈才能发现问题,回头改又很痛苦。先把离线测试补好,才是正确的顺序。
3.4 注册进Agent运行时
技能写好后,需要把它的元数据转成模型平台能识别的tools格式。不同大模型平台的tool calling接口大同小异,我习惯在内部保存技能库自己的yaml格式,注册时写一个适配器转出去。
import json def build_tools(skills): tools = [] for skill in skills: tools.append({ "type": "function", "function": { "name": skill.name, "description": skill.description, "parameters": json.loads(skill.input_schema.to_json()), }, }) return tools这里还有一步容易被忽略:模型的系统提示词里也要放一份技能索引,比如“你可以使用csv_summary技能来分析CSV文件,使用date_utils技能处理日期”。这是给模型的前置提示,帮它更快地想起有哪些技能可用。tools列表可以很长,但提示词里的索引一定要短,否则上下文会被烧掉太多。
4. 运行时调用的关键环节
4.1 一次技能调用的完整链路
当用户在对话框里说“帮我统计一下sales.csv的金额列”,整个链路是这样的:
- 模型先根据系统提示词和tools列表判断该用哪个技能。
- 模型返回一个structured call,里面带上技能名和参数。
- 运行时拿到参数后先做校验,不通过的尝试修正,修正不了的返回错误信息给模型,让它调整。
- 校验通过后,运行时把参数交给core.py函数执行。
- 执行结果返回给运行时,运行时将结果按约定格式拼回对话中。
- 模型基于结果组织最后给用户的自然语言回答。
第5步是你最值得花心思的地方。你可以直接把整个统计字典塞给模型,但模型很可能只挑其中几个数字说;如果你在结果旁边补一句“请结合用户原始问题组织回答,重点解释哪些数据与问题相关”,最后的效果会好非常多。
4.2 参数可靠性:模型传错之后怎么办
我在实际调试中遇到过三类最典型的模型传参错误,基本都有应对办法:
- 类型错:把数字传成字符串。校验函数里做一次float()转换就能解决,注意转换失败时要返回明确错误,不要静默吞掉。
- 格式错:日期传成“下周二”,数组传成“a,b,c”。这种比较难,只能在描述里尽可能写清格式,并在校验函数里做一套resolver兜底。比如数组字段检测到字符串就按逗号切分。
- 枚举错:可选范围明明只有“daily/weekly/monthly”,模型传了个“everyday”。解决办法是在schema约束里加上enum,并把合法值写进描述里。
一个容易踩的坑是:不要在参数校验失败后直接抛异常给上层。模型拿到一段异常堆栈,很难从中提取出“到底是哪个参数错了”。正确的做法是把校验失败点整理成一句话错误,例如“参数columns必须是数组,当前传来的值是字符串'age,score',请重新生成参数”。模型看到这句话通常能自我纠正。
4.3 运行时治理配置
技能多了之后,光靠写得好还不够,运行时也得有治理手段。我主要做了三件事:超时、并发控制、日志。
超时这块,每个技能的执行上限定在5到10秒,超过就直接返回业务错误,绝不能把Agent主流程挂死。
import asyncio async def execute_with_timeout(skill, arguments, timeout=5.0): try: return await asyncio.wait_for(skill.invoke(arguments), timeout=timeout) except asyncio.TimeoutError: return {"error": "skill_timeout", "message": "技能执行超时"}并发方面,当用户一次请求需要多个技能协作时,可以用asyncio.gather并行执行,但每个技能独立失败不影响其他技能。日志方面,我要求每个技能在入口和出口各打一条结构化日志,记录参数摘要、执行耗时、状态码和错误信息。这些日志在排查模型为什么选了某个技能、执行慢在哪一步时非常有用。
5. 常见问题与排查技巧实录
5.1 问题速查表
我把这段时间遇到的典型问题整理成了一张速查表,先看症状,再对原因:
| 症状 | 可能原因 | 排查思路 |
|---|---|---|
| 模型总不调用某个技能 | 描述太笼统、缺少场景触发词 | 重写description,补上使用场景和示例参数 |
| 模型调用了但参数总报错 | Schema类型和Python实际接收类型不一致 | 校验函数里加类型纠正,或统一schema声明 |
| 执行成功但回答离题 | 返回结果没做“人话”包装 | 在系统提示里加“结合用户问题组织回答” |
| 技能执行慢拖累整个对话 | 没有设置超时 | 给每个技能包一层asyncio.wait_for |
| 新技能上线后旧技能失效 | 目录结构变了,未更新注册清单 | 技能库启动时自动扫描目录,生成注册表 |
| 两个技能描述相似,模型选错 | 重叠度太高 | 拆场景,或合并成一个技能并加内部参数区分 |
5.2 描述质量对命中率的影响
这个我实测过。有一个阶段我把技能的description写得特别简略,像“CSV摘要”“日期工具”这种。当时在一个内部小测试集上跑,包含十几个技能、几十条用户指令,正确选中技能的比例大概在七成上下。后来我把每个技能的description补全,加入适用场景、典型参数示例、返回值说明,同一个测试集上命中率提到了九成以上。虽然我的样本量不大,不能当成严谨的统计结果,但趋势非常明显:模型在大量技能之间做选择时,靠的就是描述信息,描述越具体,选择越准。
5.3 版本升级的兼容策略
技能库一定会不断升级。我现在的做法是:每个技能维护自己的version字段,升级时遵循先扩展后删除的原则。比如csv_summary早期只返回mean,后来要加median和mode,那就先加字段,等下游调用方都适应了再考虑去掉旧字段。如果确实要破坏性变更,我会在技能库根目录维护一个CHANGELOG,并保证模型当前看到的tools列表一定和实际运行版本匹配。这个匹配检查可以在启动时自动做:扫描技能目录,加载所有skill.yaml,生成注册表,线上一旦发现不一致,立刻报警,不让旧版本继续误跑。
5.4 不同模型平台tool calling的差异适配
最后说一个让很多人头疼的点:不同大模型平台的tool calling接口差异很大。有的平台要求function name的命名规范更严格,有的对parameters的嵌套层级有特殊限制,有的又不支持某些JSON Schema关键字。我的做法是让技能库内部统一用自己那套yaml格式,外层写两个适配器,一个负责把内部yaml转换成平台A的格式,另一个转换成平台B的格式。这样技能作者只需要维护一份声明,不用关心最终是哪个平台在跑。等以后接入新平台,也只是多写一个适配器的事,技能本身完全不需要动。
这几件事看起来不复杂,但每条都是真实项目里踩出来的。尤其是参数校验和描述写法这两条,基本决定了Agent技能调用靠不靠谱。
我个人在实际操作中的体会是,技能库最值得投入的地方不是堆更多的函数,而是把元数据设计和参数边界约束做扎实。你写十条规则、写五个测试,可能比再写二十个技能更有价值。按照这个思路,后面的扩展方向其实很明确:把技能库做成一个可扫描、可测试、可版本化的公共层,Agent只是这个公共层的一层壳。最后再分享一个小技巧:每次新技能上线,用一个包含了成功路径和失败路径的固定测试集跑一遍全链路,比写再多文档都好使。它能让所有技能一直保持可验证、可回滚的状态,这样无论模型换版、接口调整,你都有底气说这回没改坏任何东西。