1. 从“会说话的助手”到“会干活的助手”技能系统出现的必然性我自己做智能助手相关开发也有几年了早期绝大部分时间都在跟提示词较劲。你让助手查个天气、写段文案、做个体检报告解读这些都还好本质上是“把用户问题转化成一段文字回答”。但一旦用户问的是“帮我查一下上个月华东区的销售数据做成折线图顺便把环比增长超过20%的省份标出来”事情就完全不一样了。这不是靠提示词能稳定搞定的你需要让助手真的去调数据库、跑统计脚本、生成图表甚至把图表再贴回对话流里。这就是 agent-skills 这套项目要解决的问题也是我这两年里觉得最值得分享的一段工程实践。简单说它是一套面向智能代理也就是 Agent的技能库设计把“模型能做什么”这件事从提示词层面下沉到结构化、可注册、可调度的能力模块层面。你不再给模型写“你现在是一个数据分析专家请帮我查询数据库”而是给它一个明确的技能清单每个技能有名字、有参数约束、有调用逻辑、有返回结构模型的任务变成“看懂用户意图选对技能填好参数”真正干活的是技能。这篇文章写给谁写给那些正在做智能助手、Agent 应用或者已经在用大模型 API 但发现“上下文越塞越满、效果越来越飘”的团队。我会把技能库从零到一的设计过程、接口规范、调度路由、测试治理全部摊开讲包括我们实际踩过的坑。内容偏工程落地不涉及复杂的强化学习或模型微调只要你有基本的 Python 经验和一点大模型调用经验就能跟着走通。1.1 靠提示词堆功能的路走到了尽头先说说我们为什么必须自建一套技能层。2023年下半年项目里已经接了语言模型基础问答效果还行但业务方开始提各种“动作类”需求——查库存、下单、生成周报、推送消息。我们的第一反应很朴素每个功能写一段提示词在系统提示里塞一大堆规则再给它几个工具函数。结果是灾难性的。系统提示词从 500 字膨胀到 2000 字再从 2000 字膨胀到 5000 字。模型确实能记住一部分但经常顾此失彼今天新加了一个复核操作明天它就把之前的审批流程忘了你修好了 A 场景的误判B 场景又开始瞎调用。而且最痛苦的是没法测试提示词是概率性的你不能断言“这种情况下它一定会调用技能X”只能靠人工一轮轮回归。每周发布新功能都像在走钢丝。还有个更隐蔽的问题多个智能体场景下同样的能力在每个 agent 里都要复制一份提示词。我们的工作台里同时有三个 agent分别对接客服、数据分析和内部行政它们都要用“查排班表”这个能力。于是同一份逻辑被抄了三遍改排班表的接口时得同时改三处提示词改漏一个就出事故。我真正决定搞技能库是因为一次线上事故。某个深夜数据分析 agent 在生成销售周报时忽然把一条“查询敏感客户表”的功能点燃了——它其实只是看到了系统提示词底部的一行说明误以为用户要求导出全量客户数据。虽然权限系统拦住了但这件事让我意识到把能力描述混在自然语言里本身就是不可控的。我们需要一种机制让模型在“可被允许的技能集合”内做选择而不是在“所有它听说过的事”里自由发挥。1.2 另一种思路把能力做成一块块可插拔的乐高后期我反复想Agent 和普通程序最大的区别不在于它更“聪明”而在于它把自然语言和程序世界之间的缝隙填补了。但填缝隙不能靠反复祈祷模型表现稳定得靠工程约束。技能库的思路其实特别朴素就像后端服务拆成微服务前端组件做成 npm 包我们把 Agent 能干的每一件事做成一个个独立注册、独立实现、独立测试的技能模块。每个技能对外暴露三个东西触发条件说明、入参模型、出参模型。模型要调用技能必须按照注册时的规范来不能即兴发挥。这套设计带来的好处比我想象的多得多。首先是边界清晰每个技能只对自己负责的那一段逻辑负责提示词不再需要知道“怎么查数据”只需要知道“技能‘query_sales_data’能查销售数据参数是时间和区域”。其次是可测试性技能本身是纯函数式的给同样的参数应该返回同样的结果可以用单元测试和集成测试去卡。最后是安全问题技能像一扇扇独立的门每扇门都可以配置权限。agent-skills 本质上就是这样一套“乐高系统”。下面我会按照我们当时的构建顺序从核心抽象开始讲然后是调度机制、开发流程、治理经验把整套东西完整还原出来。2. 给技能建仓库核心抽象与接口规范的取舍过程做技能库的第一步不是写代码而是定义“技能”这个东西到底长什么样。这个抽象如果搞复杂了后面每个技能开发者都会骂娘搞太简单又没法支撑复杂的调用链。我们前后改了三版接口规范这里直接说最终版本过程中踩过的坑我会穿插着讲。2.1 一个技能到底该长什么样我们的技能定义分为两层描述层和执行层。描述层是给大模型看的。它必须用自然语言写清楚三件事这个技能是干什么的什么时候应该调用它什么时候千万不能调用它。这第三点是很多人容易忽略的但你写成“当用户只询问天气时不要调用创建订单技能”之后误调用率能下降一个数量级。总之描述层的信息密度要足够大但语言必须精炼到模型一眼就能看懂。执行层是给程序用的。包含一个 Python 文件里面实现一个统一的入口函数入参是一个字典返回也是一个字典。中间你可以调数据库、调外部 API、算指标、画图表全部随意。但边界必须是这个函数不能让你在技能里偷偷再启动一个对话循环。# 这是我们的标准技能模板所有技能开发都必须按这个骨架来写 from typing import Dict, Any, Optional class BaseSkill: # 技能元信息名称、版本、所属域 name: str base_skill version: str 1.0.0 domain: str common description: str disallow_description: str # 入参 JSON Schema用于校验和约束模型填充的参数 input_schema: Dict[str, Any] {} # 出参 JSON Schema用于校验返回结果 output_schema: Dict[str, Any] {} def validate_input(self, params: Dict[str, Any]) - Dict[str, Any]: # 这里用 jsonschema 库做校验不合法直接抛异常 # 实现略后面会专门讲 return params def execute(self, params: Dict[str, Any], context: Dict[str, Any]) - Dict[str, Any]: raise NotImplementedError你看这就是一个基础类任何人新写一个技能继承它、填上元信息、实现 execute 方法就算完成了一个技能。但这里有几个点我要特别解释一下它们是后来我们反复踩坑才定下来的入参必须是扁平的字典。不要搞嵌套对象大模型填充嵌套 JSON 时容易出错扁平结构配合 JSON Schema 的 required 列表成功率最高。技能内部不能直接读系统提示词也不能反向操作对话历史。只允许读取 context 里显式传入的内容比如用户ID、当前时间、时区、权限token。否则技能之间会互相污染上下文。出参必须有稳定的结构。哪怕只是返回一句话也要包成{status: ok, result: ...}这种格式后续调度器才能统一处理。2.2 为什么最终选了 JSON Schema 来描述参数技能参数定义方式我们在最初几个 Demo 里用过三种Python 类型注解、Pydantic 模型、JSON Schema。最终留下 JSON Schema是有充分理由的。第一种方案直接用 Python 函数签名比如def query_sales(start_date: str, end_date: str, region: str 全国)看起来最省事。但它的问题是自描述能力太弱。你没法告诉模型“start_date 格式是 YYYY-MM-DD不能早于 2020 年且必须晚于 end_date 的语义不存在”这些约束天然应该写在描述字段里而 Python 类型注解写不进去。第二种方案Pydantic 模型很强大尤其在 FastAPI 生态里用得熟。但在 Agent 场景下它有个致命伤大模型的工具调用并不总是严格遵循类型。模型可能会给你传一个unknown给一个布尔字段或者把日期传成时间戳。Pydantic 的强校验会直接抛错但你需要的是“友好地告诉模型哪里错了让它重新填”这需要一个可序列化、可拼接、可动态修改的校验文本不是异常对象。JSON Schema 刚好解决这些问题。它本身是纯声明式的可以完整、递归地描述字段类型、格式、枚举、最小值、依赖关系甚至可以用$ref做组合。最关键的它本身就是 JSON可以直接塞进工具调用的定义里也可以转换成自然语言掺入提示词。我们实际用下来大模型对这种结构化的参数定义理解得最好填参的正确率比自由文本描述要高不少。# 一个销售查询技能的输入 schema 示例 sales_query_input_schema { type: object, properties: { start_date: { type: string, format: date, description: 开始日期格式 YYYY-MM-DD }, end_date: { type: string, format: date, description: 结束日期格式 YYYY-MM-DD不得早于 start_date }, region: { type: string, enum: [华东, 华南, 华北, 西南, 全国], description: 销售区域不传则默认全国 }, metrics: { type: array, items: {type: string, enum: [销售额, 订单量, 客单价, 环比增长]}, description: 需要返回的指标列表 } }, required: [start_date, end_date] }开发一个技能的时候先写这个 schema再写 execute 函数。schema 写得越精细模型填参越准。这也是我给你的第一条实操建议别在描述里写“大约最近一个月”这种模糊词模型真的会传一个诡异的日期。2.3 技能的注册与发现机制技能写完之后接下来要让整个系统“知道”它存在。我们做了一套注册表机制本质上是一个带搜索索引的技能目录服务。每个技能在启动时向注册表提交自己的元信息包括name,version,description,disallow_description,input_schema,output_schema,domain,permission_level等。注册表会对描述做一次向量化同时建立关键词倒排索引。这样当需要为某个用户、某个对话、某个 Agent 动态筛选可用技能时可以先用规则过滤比如权限等级、领域范围、技能状态再用向量相似度 or 关键词匹配做预筛。这套机制要解决的核心问题是“技能数量一多全量塞进上下文就失控”。我们当时技能数量到 30 个左右时把所有技能描述塞给模型的 token 成本已经很高而且模型注意力会被稀释调用准确率明显下降。注册表配合一个轻量 ranker就能做到让每个对话只看它真正需要的 5-8 个技能。排序的逻辑不复杂。第一轮是硬过滤只保留当前 Agent 启用的、权限等级足够的、未被下线的技能。第二轮是召回用用户消息的 embedding 和技能描述 embedding 做相似度取 top 20。第三轮是精排一个小规则函数加权关键词完全命中的加 0.2技能最近 7 天被成功调用率高的加 0.1被 disallow 语境命中过的减 0.3。最后取前 8 个作为候选集随对话发给模型。这里有个细节我们起初搞错了不应该把系统提示词里塞所有技能而应该把“候选技能清单”动态渲染为每次请求的一部分。系统提示词只放角色的基础设定、输出约束和“请根据可使用的技能列表完成任务不要自行编造不存在的功能”。把两者分离之后技能的新增和下线变得非常轻量不需要更新系统提示词也不需要重新发布 agent。3. 调度与编排让大模型从“想到”变成“做到”技能库建好之后真正让系统“智能化”的关键在调度层。模型接收到用户问题之后需要完成三件事判断要不要调技能选哪个/哪几个技能填什么参数。这一节是整个项目的核心也是我认为坑最深的地方。3.1 技能选择的上下文构建我们最终采用的方案是用函数调用模式。每次请求时把候选技能通过工具列表的形式传给模型由模型返回结构化的工具调用指令而不是先让它输出一段自然语言“我需要调用查询销售数据的技能参数是……”。初期我们试过让模型先自由输出自然语言再用结构化解析器去解析结果非常痛苦。模型经常说“我现在将调用技能来获取信息”然后就没下文了或者一次调多个技能时中间掺杂着废话。换成函数调用模式之后模型输出天然结构化解析稳定可靠而且能同时输出多个调用请求。具体流程是这样用户输入问题 → 注入系统提示 → 注入候选技能定义 → 调用模型 → 如果返回tool_calls逐个执行技能 → 把技能返回结果拼回消息历史 → 再次调用模型 → 循环直到模型不再请求工具。用户: 帮我对比一下华东和华南上个月的销售额哪个更高 第一次模型调用: - 工具调用请求: query_sales_data({start_date: 2024-05-01, end_date: 2024-05-31, region: 华东}) - 工具调用请求: query_sales_data({start_date: 2024-05-01, end_date: 2024-05-31, region: 华南}) 系统执行两个技能返回结果: - 华东: 销售额 2,340,000 元 - 华南: 销售额 1,980,000 元 第二次模型调用: 最终回答: 华东上个月销售额 234 万高于华南的 198 万。这个例子看起来简单但如果你直接跳过技能层让模型自己去算它很容易把数字搞错或者一本正经地编一个“华东比华南高 20%”。我们的每一个数值类查询都要求必须通过技能返回真实数据。模型的角色是“组织语言 判断意图”不是“计算”。3.2 从单技能调用到多技能组合单技能调用学会之后很快会遇到组合场景。典型场景是数据分析类任务用户要“看下上季度分区域的销售趋势异常区域帮我标红”。两种直觉方案我们都试过。第一种是图编排。预先定义好分析流水线比如“查数据 → 算指标 → 生成图表 → 写结论”每一步对应一个技能模型每一步走一个节点。这个方案的问题在于组合的路径太多不可能为每个业务场景穷举一条流水线而且当某个技能失败时图编排的节点重试逻辑非常僵硬。我们花了三周维护这种编排图后来直接砍掉了。第二种是让模型自由并发调用多个技能然后系统层做聚合。就像上面那个例子模型一次发出两个 query_sales_data 请求。但问题在于这两个请求之间的依赖关系怎么办比如“先取数据然后对数据做异常检测再根据异常结果生成总结”这存在天然的先后次序。我们最终采用的方案是给技能系统加一层“阶段划分”的概念。具体做法是在技能元信息里加一个stage字段取值为read读取/查询类、compute计算/处理类、write写入/操作类、present呈现/汇报类。调度器在第一次拿到模型返回的工具调用列表时按 stage 分组如果同一 stage 内的多个调用相互独立就并发执行如果模型跨 stage 发起请求比如还没读到数据就想写结论调度器会拒绝并追加一条系统提示“当前处于 read 阶段请先完成数据读取后再进行结论生成”。这个方法并不完美模型有时候还是不按顺序来。但加上这条阶段约束之后组合调用的成功率从 63% 提升到了 87%这个提升是实打实的。更复杂的依赖关系比如“第二个技能要用第一个技能的输出作为输入”我们做了一层简单的数据管道上一个技能返回的结果会被写进 context下一个技能可以通过预留参数reference_data读取。# 调度器中一次模型调用后的技能执行管线简化版 def execute_tool_calls(tool_calls, context): results {} sorted_calls sort_by_stage(tool_calls) for stage, calls in sorted_calls.items(): stage_results run_in_parallel(calls, context, stage) results.update(stage_results) # 把执行结果写进 context供后续阶段使用 context[tool_results].update(stage_results) return results当然这个方案也有限制。对于真正需要复杂分支因果的逻辑比如连续二次查询后根据条件决定下一步查什么我们实际上最终还是会退化为人工预设一个 work_flow 技能让模型在 workflow 内部执行子步骤。但 80% 的场景下上面这套“阶段并行”已经够用了而且对模型的要求低得多稳定性也高得多。3.3 带约束重试与失败回退模型调用技能不可能每次都成功。参数校验失败、外部接口超时、权限不足这些都会导致技能执行失败。我们最初的做法是技能抛异常调度器记录日志然后把空结果返回给模型。结果模型会一本正经地编一个结果出来“华东销售额 1000 万”哪怕它根本没拿到数据它也会给你补全。这个现象必须从机制上扼制。我们后来的流程是技能失败时不返回空而是返回一个结构化的错误信息并且把这个错误信息作为一个“工具调用结果”回合重新追加到消息历史。系统: 工具调用 query_sales_data 失败: 参数校验失败start_date 格式应为 YYYY-MM-DD实际收到 2024.5.1。请修正参数后重试。 模型: 用户抱歉日期格式我传错了修正后重新查询。 - 工具调用请求: query_sales_data({start_date: 2024-05-01, end_date: 2024-05-31, region: 华东})只有拿到成功的技能结果或者尝试三次仍然失败模型才会被允许做“我正在遇到问题”的收尾。同时重试次数上限是我们写死在调度器里的不会让模型无限循环。实践下来大部分参数类错误在第二次重试时都能修正因为错误信息里已经把问题指得很明白了。还有一类是权限错误。我们会把这类错误单独标记为permission_error重试时直接给出“你没有权限访问该数据已通知管理员”不让模型再做无谓尝试。权限类错误反复重试除了浪费 token还可能让模型学会“绕过去”的坏习惯——有一次它居然尝试用“查询全国数据再自行筛选华东”的方式绕过区域权限限制这个先例后来成了我们安全审计里的重点样例。4. 开发一个技能要走完的七步流程模式搭好之后最需要规范化的就是“技能开发”这件事本身。很多团队做 Agent 项目前期搭架子很快后期维护翻车基本都是死在无序扩张上——每个人都往系统里塞技能没有准入门槛。我下面会分享我们内部定下来的七步流程每一步都有对应产出物缺一不可。4.1 需求分析与边界划定第一步不是写代码而是写“技能定义文档”。内容是这个技能给用户提供什么价值、触发场景是什么、明确不做什么。这里最重要的一条是“拒绝做不属于本技能的事”。举个例子。我们最初要做一个“周报助手”技能结果开发者一图省事顺手把“查询销售数据”“生成图表”“发送邮件”全塞进同一个技能里。能跑但糙得很用户如果说“把周报发给李总”它根本分不清该发哪份、发给谁逻辑全揉在一起任何一个环节变化都得改整个技能。后来我们定了一个准则一个技能只做一件事。如果要发送周报那发送邮件就单独做send_email技能create_report技能只负责生成报告内容。模型需要组合时让它通过多技能调用来完成而不是让某一个技能包揽一切。边界划得越细技能复用性越高测试也越容易写。4.2 实现、自测与回归需求文档过审后才进入编码环节。开发者按 BaseSkill 模板实现 execute 方法。这里要特别注意隔离性技能只能调用传入的 context不能直接访问全局变量、系统配置文件。我们甚至要求技能文件里禁止出现read_global_config这样的调用目的就是让技能能独立测试、独立部署。自测阶段每个技能必须携带不少于三组测试用例正常用例预期范围内的输入验证输出结构是否正确边界用例比如空值、极值、格式异常拒绝用例不属于该技能的场景输入验证技能不能被误触发为什么特别强调拒绝用例因为技能被模型误触发的代价往往比不触发更大。你大模型做一个delete_order技能如果平时不测试它的触发边界可能会出现用户只是问了句“这个订单还能取消吗”模型直接调用技能把订单删了。我在生产环境里真的见过这种事损失惨重。另外输出 schema 的自检也很重要。我们会在技能执行结束后对返回结果做一次 schema 校验。如果结果不符合声明直接按失败处理。这是为了确保下游的“展示层”不会因为结果结构混乱而渲染出错。4.3 灰度发布与可观测设计技能上线前先在小范围灰度。我们内部的做法是每个技能加一个confidence_weight参数默认 0.6。调度器排序时会把这个权重乘进去权重低的技能入选候选集的概率低。灰度期权重逐步从 0 提到 1同时监控它的调用次数、成功率、用户反馈。可观测设计是很多团队最容易忽略的。如果你不能回答“上周这个技能被调用了多少次、成功率多少、失败原因分布是什么”那你就不具备持续演化的基础。我们为每个技能强制埋了三种日志调用日志时间、用户、上下文摘要、入参、出参决策日志模型是在什么候选集下选择了这个技能、候选集里还有哪些技能被筛掉了失败日志失败阶段校验失败/执行失败/输出校验失败、具体异常、重试次数有了这些日志后期我们才能定位“技能 A 总是被模型误选”这类系统性偏置问题。我印象很深的一次某个新技能刚发布还没有任何调用记录排序系统里给它 0.6 的默认权重。结果它入选候选集后模型总是优先选它因为它的描述写得太像万金油了覆盖了很多其他技能的语义。如果没有决策日志这种问题根本无从查起。5. 维护技能库的隐藏成本测试、权限与演化技能库真正运行三个月之后问题就不再是怎么加新技能了而是“存量技能怎么保持健康”。这一节聊聊隐藏成本。很多人做 Agent 项目都会死在半路不是因为功能不够而是因为维护不动了。5.1 技能的正确性测试技能是给模型用的所以它的测试不只是“函数输出对不对”还要测试“描述是否与实际行为一致”。描述写得模棱两可模型就可能用错。我们引入了基于模拟对话的回归测试集把过去三个月真实用户请求脱敏后挑出有代表性的 500 条作为每周回归语料。每次技能库有更新就跑一遍这 500 条看技能选择、参数填充、调用成功率是否有变化。这套回归的价值在于能捕获“描述改动引发的隐性影响”。有一次我们优化了query_sales_data的描述把‘环比增长’的说明改得更细结果回归测试发现query_customer_data技能被调用的比例莫名其妙掉了 4 个百分点。查决策日志发现新描述里的‘增长率’字眼和客户数据技能描述里的‘增长分析’撞了语义边界模型开始混淆。这类问题不通过全量回归光靠单测根本发现不了。我还想提醒一点每一条回归失败都必须追查到底不许“本轮豁免”。因为豁免一次后面就会豁免无数次整个测试基线就废了。5.2 权限与审计技能一旦涉及写操作权限体系必须单独设计不能全听模型调度。我们的原则是只读类技能可以经过提示词约束执行写操作类技能一律在调度层配一道鉴权网关。鉴权依据是用户身份、令牌权限、技能预设的最低权限等级三者取交集。以发送邮件为例。send_email技能的 execute 函数里可能什么都不做直接调一个require_permission(email.send)的中间件。如果用户没有这个权限调度器会直接拦截甚至不会把技能列入候选集。这样可以减少模型做无谓的尝试也能避免“界面看不到但模型能调用”的越权通道。审计方面每个写操作都会被记成事件流包含发起人、原始用户问题、上下文、技能入参、执行结果。这是为了事后追溯和纠纷判定。有一次用户反馈“我明明没说让发短信助理怎么发了”查事件流发现是模型把“发个消息”理解成了“发短信”而上下文里正好有该用户的手机号。这类事免不了但审计能让你快速定位原因、追究责任、调整描述。5.3 技能版本演化与废弃技能需要版本管理这可能是所有 Agent 工程里最容易被拖延的一块。我们一开始觉得没必要后来被坑过一次运营同学临时改了一个技能的行为没有发版直接在线上改了。结果其他依赖这个技能输出的下游逻辑全部错乱。后来我们把每个技能纳入独立版本控制接口规范里强制加入兼容性标记。当技能的输入输出结构发生变化时只允许新增可选字段如果要删除字段或修改必填项必须走一个“双版本并行”流程新版本技能上线旧版本标记 deprecated保留一个过渡期观察调用量降到阈值后再下线。技能废弃也要有“退役仪式”先把技能的候选集权重降为 0观察一段时间没有调用后再从注册表移除。禁止直接删除一个还在被使用的技能否则模型会把原本的概念映射到其他技能上去出现“查销售额变成了查库存”的诡异行为。这一段单独拎出来讲是因为它真的不性感但决定生死。一个没有版本管理、没有权限规划、没有回归测试的技能库三个月后就是一个定时炸弹。你可能天天修问题但永远不知道下一刻哪个版本匹配错了、哪个权限被漏管了。6. 个人体会与后续扩展方向分享几个我个人在这个项目里比较深的感觉。第一个是Agent 系统的智能程度取决于你给它的技能质量和约束密度而不是模型本身有多聪明。把同一个模型用在裸提示词系统和结构化工具体系里效果差异是天上地下。任何团队想上 Agent别一上来就追求“让模型自己规划一切”先把技能底座打牢后面的事都会顺很多。第二个是调模型和调技能是两门手艺。之前我们团队花了很多精力调 prompt后来发现把同样的精力花在调技能描述、参数 schema、候选集排序上产出高得多。Prompt 是概率性的而技能定义和注册表逻辑是确定性的。确定性系统永远比随机系统容易优化。第三个是技能库的“生态”意识。当你的技能数量超过一百个能不能高效查找和组合很大程度上取决于描述语言的统一。我们后来建了一个“技能描述词汇表”强制所有技能描述使用统一业务术语。比如销售域统一用“销售额”而不是一会儿“营收”一会儿“GMV”。模型最怕同义反复统一描述之后误选率又降了一截。后续我打算扩展的方向有三个。一是把技能库的候选集排序从规则函数升级成轻量模型直接基于行为日志训练二是给技能加一个“偏好对齐”机制根据每个用户的语言习惯动态调整描述权重三是做技能间的依赖冲突检测在注册阶段就自动检查两个技能是否语义重叠或权限矛盾。这些还在实验中跑通了会继续写文章分享。如果你现在正准备做 Agent 应用我的建议很简单不要先急着接模型聊天先把你业务里那些高频能力列出来逐个设计成技能。等你有了二三十个靠谱的技能Agent 的体验自然就立起来了。反过来如果你已经有 Agent 在跑但总觉得它“不太聪明”大概率是技能层太薄而不是模型选得不对。