
最近在整理一个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我的建议是类型能收窄就收窄能加枚举就加枚举。你管得有多严模型就越不容易自由发挥。下面这个表我摘录了一下好描述和坏描述的对比项目坏描述好描述nametool_utilcsv_summarydescription读取CSV文件并分析读取指定CSV文件对数值列输出均值、中位数、缺失值统计用于数据体检和表格概览参数说明file_path: 文件路径file_path: 文件路径支持相对或绝对路径参数示例无示例: /data/sales.csv2.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, encodingutf-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(modew, suffix.csv, deleteFalse) 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, timeout5.0): try: return await asyncio.wait_for(skill.invoke(arguments), timeouttimeout) 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只是这个公共层的一层壳。最后再分享一个小技巧每次新技能上线用一个包含了成功路径和失败路径的固定测试集跑一遍全链路比写再多文档都好使。它能让所有技能一直保持可验证、可回滚的状态这样无论模型换版、接口调整你都有底气说这回没改坏任何东西。