☰
Agent技能库实践:构建可控可观测的工具调用能力层
2026/10/13 19:49:44 网站建设 项目流程

让Agent的"手"真正可控:我维护 agent-skills 技能库这段时间的实践总结

最近大半年,我一直在折腾一件事:把各种杂七杂八的 Agent 能力沉淀成一个内部代号叫 agent-skills 的技能库。起因是手上有好几个项目都在做智能体,做着做着发现一个问题——模型选型、Prompt 调优这类事反而不是最费时间的,真正的痛点是每个项目都要重新接一遍工具、重新理解一遍"模型到底该怎么调用这些能力"。

这个技能库解决的是 Agent 开发里最容易被低估的一层:模型的"手"。也就是工具调用、技能编排、执行反馈、观测评估这整条链路。如果你也在做大模型应用,尤其是那种需要模型调用多个外部系统、处理真实业务的 Agent,这篇文章应该能帮你少走不少弯路。我会把我这段时间的设计思路、实现细节、踩过的坑,以及技能库怎么和评测数据形成闭环,一次性讲清楚。

1. 为什么说Agent开发最耗时的是"能力层"而非模型本身

1.1 从Chatbot到Agent,差的不是模型是工程

很多人以为 Agent 就是"对话模型 + 调接口",但实际从 Chatbot 进化到生产级 Agent,中间隔着的不是模型能力,而是工程化程度。

我见过好几个团队把单轮对话的 Demo 跑通后,就兴冲冲去做多轮 Agent,结果第一周就卡住了:模型不知道怎么从十来个工具里挑出正确的那一个;调完工具返回了非预期结果,模型也没法优雅恢复;更别提并发场景下技能间的状态互相污染这种低级但致命的 bug。

这里有个很根本的差距。单轮 Chatbot 是"模型生成文本",Agent 是"模型做出决策并执行"。一旦涉及执行,你就得操心三件事:模型能不能找到正确的技能,参数能不能填对,执行失败以后模型能不能自己兜底。这三件事没有一件是模型自己可以解决的,全部要靠外面这层工程代码来托底。

我自己总结过一张表,标出了三层应用对工程能力的要求差异:

层级核心任务对技能层的要求最常踩的坑
单轮回答生成文本基本没有幻觉、事实性错误
带工具调用一次调用返回结果能选对工具、填对参数工具太少还好,一多就乱
生产级 Agent多步规划、状态管理、失败恢复技能注册/路由/观测/评估全要技能调用不可控、无法复盘

到第三层的时候,"技能"就不是一堆函数了,它本质上是模型和环境之间的一个契约层。这个契约层如果做得乱,再强的模型也发挥不出来。

1.2 技能库的本质:把能力的"使用规则"显式化

那为什么不能每个项目各自接一遍工具?当然可以,成本低的时候没问题。但当你有几十个技能、多个 Agent 共用,你就会发现缺一个统一的抽象层。

agent-skills 想做的事情就一句话:把每一个能力封装成"模型看得懂、系统跑得稳、后期可观测"的标准技能单元。说得再直白一点,就好比你把家里所有螺丝刀、扳手、电钻从抽屉里倒出来,然后按照用途、规格、使用场景分门别类贴好标签放进工具墙,而不是每次要用的时候翻半天。

传统工具调用就是"抽屉里乱放螺丝刀"——函数散落在代码各处,参数定义只有程序员自己看得懂,模型能不能正确调用全靠蒙。而技能库则是"贴上标签的工具墙"——每个技能有标准化的描述、参数校验、执行规则、失败出口,模型只要会"读菜单",就能正确点菜。

这个抽象层带来的直接好处是:新项目接入时不用从零开始理解每个接口的怪癖,技能库本身就把"这个技能是干什么的、什么时候用、怎么用、失败了会怎样"全部描述清楚了。

2. 技能体系怎么设计:先分清楚哪些该沉淀成技能

2.1 技能的三级分类:原子技能、复合技能、策略技能

刚开始做技能库的时候,我犯过一个错误:把所有能力都平铺成一长串函数列表,结果模型一面对 30 多个技能就开始选择困难。后来我把技能分成了三层:

类别定义典型例子编排方式
原子技能只做一件事,不可再拆发邮件、查天气、创建工单模型直接调用
复合技能多个原子技能的固定序列会议纪要归档:解析录音→生成纪要→建文档→发通知内部编排,对外是一个技能
策略技能根据条件动态决定怎么执行"优先走成本低的通知渠道"由模型决策,内部路由

这个分类的前两个都好理解,重点说下策略技能。它是我在实际项目中被迫拆出来的。最初我把"通知用户"做成一个技能,参数里有渠道、模板、夜间免打扰开关,结果模型经常传错参数,比如明明应该走站内信却传了短信渠道。

后来我把"发通知"拆成了策略层:模型只需要说"告诉用户他的审核通过了",策略技能会根据通知类型、用户偏好、当前时段自动选渠道。这里的关键原则是:凡是可以通过规则确定的事情,不要交给模型来解决。模型的强项是理解模糊意图,弱项是按固定规则做选择。策略技能的设计就是把这个边界画清楚。

2.2 技能描述是给模型看的"菜单",不是API文档

这是我在整个技能库里收获最大的一点认知。技能描述不是给人看的程序注释,而是给模型看的推荐内容。同一个技能,用两种写法,模型调用的准确率可以差出 20 个百分点以上。

我的经验是,一份有效的技能描述只需要包含四项:触发场景、入参说明、约束条件、一个典型请求示例。像下面这样:

{ "name": "create_review_ticket", "description": "当用户反馈内容需要人工审核处理时创建审核工单。适合纠纷、举报、异常内容上报等场景。不要在用户仅询问进度时使用。", "parameters": { "type": "string, 可选值: content_report | payment_dispute", "reason": "string, 必填,描述具体问题", "urgency": "string, 可选值: low | high", "attachments": "array, 可选,相关文件ID列表" }, "examples": [ {"type": "payment_dispute", "reason": "用户A反馈扣款成功但服务未开通"} ] }

注意我加了"不要"这种负向描述。实测下来,负向描述比正向描述更能提升路由准确率,模型经常会因为语义相近而误调用,一个明确的否定约束能拦掉相当多的误判。

另外描述不要太长。后面我会专门讲这个坑,但这里先说结论:描述超过 150 字,模型对它的注意力反而下降,路由准确率不升反降。宁可精简一点,把最关键的触发场景说清楚。

2.3 执行层:注册中心 + 意图路由 + 结果标准化

技能库的运行时我分成了三个组件:

注册中心:所有技能启动时向这里登记元信息,包括 schema、版本号、健康检查接口。新技能上线就是一次注册,不需要改动既有代码。

意图路由:把模型给出的调用意向映射到具体的技能和执行参数。刚开始可以直接依赖模型原生的 function calling,技能少的时候完全够用;但当技能数量超过 20 个,我建议在路由层做一次预筛,比如按用户请求里的关键词先过滤掉明显不相关的技能,再让模型从剩下的候选里选。这个优化能把 token 消耗降 30% 以上。

结果标准化:所有技能执行完,统一包装成固定的返回结构,这是我强烈建议的规范:

{ "status": "success | business_error | system_error", "data": {}, "error": {"code": "EMPTY_RESULT", "message": "查询无结果"}, "context": {"request_id": "req_123", "retry_count": 2} }

为什么强制统一返回结构?因为模型在执行完技能后,要根据返回结果决定下一步动作。如果返回结构五花八门,模型就不知道是成功了还是失败了,更不知道失败了该怎么处理。有了这套标准结构,模型看到status: system_error就知道应该向用户解释故障而不是假装成功,看到EMPTY_RESULT就知道应该换个思路而不是原地重试。

3. 从零实现一个"会议纪要归档"技能

3.1 定义技能Schema:每个字段都要有"为什么"

跑通全链路最好的方式,就是完整做一个技能。我挑"会议纪要归档"来说,因为这个技能跨了内部编排、文档写入、消息通知三个系统,覆盖了不少典型问题。

先定义技能 schema:

{ "name": "meeting_minutes_archive", "description": "将会议录音或文字稿归档为结构化纪要并存入知识库,归档完成后通知相关人员。适用于会议结束后需要沉淀结论和待办事项的场景。", "parameters": { "transcript": "string, 必填,会议完整文字稿或录音转写文本", "title": "string, 必填,会议名称", "participants": "array, 选填,参会人邮箱列表", "notify": "boolean, 默认true,是否发送归档通知" }, "required_skills": ["transcript_to_summary", "knowledge_base_write", "send_message"] }

注意我加了required_skills字段,这表示这是一个复合技能。调用它的时候,内部引擎会依次执行三个原子技能,任何一步失败都能精准定位。

设计入参的时候有一个容易被忽略的点:入参要尽量贴近用户的表达习惯,而不是贴近底层系统的输入格式。比如"录音转写文本"是一个用户自然能提供的字段;如果你把字段设计成audio_file_id,那就强制模型去理解文件系统,凭空增加了模型出错的空间。

3.2 实现执行函数:异常出口比主流程更重要

执行函数我建议遵循一个原则:主流程写简单,异常出口写详细。因为主流程就是按顺序调三个技能,但每个技能的异常都要有对应的恢复策略。

# 技能执行函数骨架 def execute_meeting_minutes_archive(ctx, params): transcript = params["transcript"] title = params["title"] # 第一步:生成结构化纪要,失败可重试1次 summary = call_skill("transcript_to_summary", {"text": transcript, "format": "structured"}) if summary.status == "system_error": summary = call_skill("transcript_to_summary", {"text": transcript, "format": "structured"}, retry=True) if summary.status == "system_error": return error_result("SUMMARY_GEN_FAILED", "纪要生成服务暂时不可用") # 第二步:写入知识库,失败则降级为保存到草稿区 doc_id = call_skill("knowledge_base_write", {"title": f"[纪要] {title}", "content": summary.data}) if doc_id.status != "success": doc_id = call_skill("knowledge_base_write", {"title": f"[草稿] {title}", "content": summary.data, "folder": "draft"}) # 第三步:通知,失败不阻塞主流程 if params.get("notify"): call_skill("send_message", {"to": params.get("participants", []), "content": f"{title} 纪要已归档,文档ID: {doc_id.data}"}) return success_result({"doc_id": doc_id.data})

这里我做了三件值得细说的事:

第一,纪要生成失败可以重试一次,因为这类服务偶发超时概率不低,重试成本低收益高。知识库写入失败则降级到草稿区,保证用户至少有一个可访问的产物。通知失败干脆不阻塞主流程。这里体现了一个原则:每个异常出口都必须明确回答一个问题——用户感知应该是什么。纪要生成彻底失败,告诉用户"稍后再试";只是通知没发出去,主流程照常成功,顶多日志记一笔。

第二,内层技能调用统一走call_skill函数,它负责超时控制、重试策略、日志埋点。避免每个技能自己写一遍超时和重试逻辑。

第三,返回值统一用success_result/error_result包装,这就是前面说过的结果标准化在实际代码里的具体落地。

3.3 注册技能并验证模型能正确触发

技能实现完,需要在注册中心登记。这一步的目的是让模型在下一轮对话前就能"看到"这个新技能:

# 注册中心登记 registry.register( name="meeting_minutes_archive", version="1.0.0", handler=execute_meeting_minutes_archive, schema=meeting_minutes_archive_schema, timeout_seconds=30, health_check=lambda: knowledge_base.health() == "ok" )

注册完成之后,我习惯用一组固定用例跑一次回归,确认三件事:模型能否从模糊的用户表达中识别出该用这个技能;参数能否正确抽取;异常场景下模型能否看懂错误码并给出合适的用户话术。

我常用的一个测试用例是这样:

用户输入: "下午的产品评审会开完了,你帮我把结论和待办整理一下存起来,再提醒一下老李他们"

期望的模型行为:选中meeting_minutes_archive,title抽成"产品评审会",participants抽成"老李他们"的邮箱列表。如果模型在抽取participants时卡住了,大概率是 schema 里的描述不够明确,这时我会把participants字段说明改成"参会人的邮箱地址列表,如果没有明确邮箱可用姓名查找通讯录"。

跑通这一个技能之后,后面的技能就都是同样的套路了。我后来总结出一个写技能的检查清单:schema 描述的触发场景是不是够明确?有没有负向约束?异常出口有没有覆盖系统错误和业务空结果?返回结构是不是标准的?注册中心里有没有健康检查?这五条全过,技能就算合格了。

4. 上线后踩过的三个真实坑

4.1 技能描述越长,模型反而越不会调用

这个坑最出乎我意料。初版技能库上线前,我担心模型看不懂技能,于是把描述写得很详细,甚至把底层接口的字段都抄进去了。结果上线后模型调用准确率掉了一大截。

后来我做了个对比实验,把同一个技能分别用 200 字和 80 字的描述各跑 50 个测试用例,结果如下:

描述长度正确触发率误触发率平均决策耗时
200字(含实现细节)76%18%1.9s
80字(只含触发场景+约束)92%7%1.2s

原因也不复杂:模型的注意力窗口就那么大,描述里全是底层实现细节,真正决定"什么时候该用这个技能"的关键信息反而被稀释了。技能描述是"给模型看的菜单",不是"给人看的接口文档"——底层怎么实现,模型不需要知道。

我后来定了一条铁律:技能描述里禁止出现代码、字段级细节、内部架构信息,只保留触发场景、入参一句话说明、负向约束、一个典型示例。这条规矩救了我后面所有新技能。

4.2 复合技能里的共享状态,并行时崩了

做会议纪要归档的时候,transcript_to_summary会把生成的临时纪要写到共享缓存里,之后knowledge_base_write会读这个缓存去取内容。单个请求没问题,但一旦多个会话同时跑归档,缓存里的数据就互相覆盖了,经常出现纪要内容是上一个会议的。

说实话这个问题的根子是我自己的设计偷懒了:技能之间共享状态,却没有做隔离。修的时候我做了两层改造。

第一层是会话级隔离,每个请求的request_id独立命名空间,写缓存时 key 带上这个 ID,读的时候只读自己的 key。第二层是防止复合技能内部不同原子技能之间的数据传递,我不再用共享缓存,而是改成显式传参——上一步的返回值直接作为下一步的入参传进去,完全去掉隐式状态。

改造完之后,我意识到一个更本质的原则:复合技能的原子技能之间,只允许通过函数参数传数,不允许通过外部存储传数。状态一隐式,必出鬼。这个改动顺手解决了一系列之前没暴露的并发问题,也让我后面排查问题变得极其简单。

4.3 模型编造参数,技能层必须自己守门

上线一个月左右,我发现日志里有一批奇怪的失败记录:技能调用了,但参数明显是编的。比如用户压根没提文件名,模型却给title传了个"默认标题文件 v2(最终版)"类似的话。模型在不确定参数值的时候,倾向于自己合理"脑补"一个值填上,而不是说"这个参数我不知道"。

针对这个问题,我在执行层加了一道"守门"逻辑:入参不是模型说什么就是什么,而是先过一遍校验器。校验规则很简单:

  • 必填字段缺失,直接返回MISSING_PARAM错误码,同时把缺失字段名放进错误信息里;
  • 字段值的枚举不在可选范围内,返回INVALID_VALUE,并且把合法的可选值列表返回给模型;
  • 完全不确定的值,允许技能层返回一个"人工确认待补充"状态,由外层向用户询问。
def validate_params(schema, raw_params): missing = [f for f in schema["required"] if f not in raw_params] if missing: return error_result("MISSING_PARAM", f"缺少必要参数: {missing}") for field, rule in schema.get("field_rules", {}).items(): if field in raw_params: if "enum" in rule and raw_params[field] not in rule["enum"]: return error_result("INVALID_VALUE", f"字段 {field} 可选值应为: {rule['enum']}") return success_result(raw_params)

最关键的一点是,错误码的设计要让模型能看懂并且能据以修正。MISSING_PARAM和INVALID_VALUE配合上明确的修复提示之后,模型第二轮就能补对参数。实测下来,加上这道校验后,技能调用的成功率从 82% 提到了 95% 以上。

5. 让技能库活起来:数据反馈与迭代闭环

5.1 日志字段怎么设计,才能支撑复盘

技能库上线只是开始,真正重要的是让它在日常使用里不断变好。而变好的前提是有数据。我见过很多团队做完 Agent 就完事了,调用日志打了几十万条但从来不看,出了问题只能靠猜。这等于白做。

我设计的技能日志记录了这么几个维度:谁在哪个会话里、用了哪个版本的技能、传了什么参数、花了多长时间和多少 token、结果如何、模型决策过程是什么。每次调用都落一条记录,后面分析问题全靠它。

比如排查"为什么审核类工单创建变少了",先看调用量趋势,再看触发模型的决策轨迹,很可能是某次 Prompt 调整导致路由偏好变了。没有这些字段,你只能对着空屏幕干瞪眼。我的建议是日志宁可多记录一些,也别省,运行期多记的字段是排查问题时的救命稻草。

5.2 用三个指标决定技能的去留

我完整跑了一个月之后,总结出对技能库最有用的三个指标:

第一个是技能调用量,看这个技能在真实场景被需要的频率。调用量极低的技能,要么是描述写得太隐蔽模型找不到,要么是业务本身不需要,得优化或者下线。

第二个是执行成功率,指的是技能跑完且返回success的比例。成功率低说明技能本身不稳定。要注意区分系统错误(服务挂了)和业务错误(查无结果),前者修程序,后者改描述。

第三个是模型纠错率,也就是第一次调用失败后,模型根据错误码自行修正并第二次成功的占比。这个指标直接反映技能错误信息设计得好不好,也反映技能和模型的"配合"状态。

我每周会拉一次这三个指标,对着列表把问题技能抽出来处理。这套循环跑起来之后,技能库才算是有了生命,才敢说它是可维护的。

5.3 技能也要版本化和灰度

最后一个容易踩的坑:技能更新直接改动线上逻辑,一旦改挂了,连带所有调用这个技能的 Agent 一起遭殃。后来我把技能库也做成了版本化发布,和业务应用一样走灰度流程。

比如会议纪要技能,从1.0.0升到1.1.0,我会在注册中心同时保留两个版本,路由层按一定的比例切流量,新版本跑几天数据正常了再全量切换。一次发版后再回滚,成本高且容易出二次故障。技能灰度虽然不能完全避免问题,但至少能把爆炸半径控制在 10% 的流量内。

我维护 agent-skills 这段时间,最深的感受是:Agent 能不能做好,大概率不取决于你选的模型,而是取决于模型外面这层能力层是否稳、是否可控、是否可观测。技能库不是一个"有没有"的问题,而是一个"能不能持续变好"的问题。里面每个技能都在和真实业务对话,也在和模型的能力边界互相磨合。如果你也在搭类似的技能层,建议一开始就把日志和评估指标做进去,别等技术债攒到不可收拾的时候再补。

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

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

立即咨询