
做AI应用开发的同学最近应该没少跟“技能”“工具调用”“Function Call”这三个词打交道。模型再聪明它也没法自己查天气、查库存、调内部接口所以这两年涌现了一堆把“模型能力”和“真实世界操作”接起来的中间层方案。ponytail就是我在这个方向折腾了大半年后一直在用的一款轻量级技能编排插件。它做的事情很简单把“某个功能能不能被模型调用”这件事从写死在主程序里变成一套可注册、可配置、可热更新的独立技能单元。这篇文章不打算写成像官方文档那样的说明书而是把我从选型、安装、开发第一个技能、到接入真实业务、再到线上排错的全过程复盘一遍。里面包含了我实际踩过的坑、改过的配置、以及最后沉淀下来的工程习惯。如果你正在搭Agent应用、做对话机器人或者想把内部系统暴露成大模型能使用的API这篇内容应该能帮你省不少时间。1. 先搞清楚ponytail到底解决什么问题1.1 项目定位一套面向大模型应用的技能注册与调度框架ponytail从定位上就是一个插件化的技能管理框架核心解决的是“模型怎么知道该调用什么、参数怎么传、调完结果怎么回”这一整条链路的问题。你可以在没有ponytail的情况下通过硬编码if-else的方式让模型调接口代码大致会是下面这个模样if intent query_weather: city extract_city(user_input) result requests.get(fhttp://api.weather.com/{city}) elif intent create_todo: content extract_content(user_input) todo_service.add(content)这种写法在小demo里完全没问题但一旦技能数量上到十几个、几十个你会发现几个头疼的问题意图判断逻辑越堆越臃肿、新增技能必须改主程序还要重新发布、参数抽取规则各写各的完全没法统一维护。ponytail解决这些问题的方式是把每个技能定义成一个标准的目录结构里面包含元信息声明、参数Schema和执行逻辑然后通过一个统一的注册中心挂载到模型侧。模型在对话中会先读取所有已注册技能的描述再根据用户意图挑选匹配的那个并按照Schema自动生成参数调用。这个思路本质上跟OpenAI后来推的“Tools/Function Calling”是一脉相承的区别在于ponytail在机制上做得更薄、更轻。它不是一套完整的大模型应用框架不负责对话管理、不负责向量检索、也不绑定任何特定的模型供应商它就管“技能”这一件事。这种“只做一件事但做扎实”的定位反而让它跟市面上主流的Agent框架都能搭着用。1.2 为什么技能管理值得单独做成插件很多朋友拿到ponytail后的第一反应是我自己不也能写一个技能管理器吗干嘛要引入这个插件我的答案是如果你真的只有一个技能、两个技能自己写完全没问题但一旦技能数量突破两位数你没有框架约束的情况下必然走向混乱。举个具体的例子。我在项目里负责接入过一批内部业务系统包括工单查询、客户信息检索、订单状态跟踪、审批流程发起等等。如果每个业务方都按自己的喜好写技能注册逻辑有的写在Flask路由里有的挂在定时任务里有的直接写在Prompt里让模型硬记那最后这些东西连集成测试都没法做。ponytail给了所有技能一个统一的“长相”不管你的业务逻辑多复杂对外都要声明name、description、parameters这三个东西执行的时候都要走同一个run入口。这种强制约束看着不起眼实际带了一个巨大的好处你可以把技能的注册信息直接变成模型上下文的一部分也就是内容全部序列化成JSON后塞给模型。模型不需要理解每个技能背后的代码是什么只需要根据描述和参数Schema来决定“该调谁、怎么调”。反过来说后续你要做技能质量的统计分析、做权限控制、做灰度发布都有了一个统一的抓手。我自己用过一段时间之后最大的体会是这玩意儿的核心价值不在代码里而在“规范”上。它逼着团队里每个人都以同一种方式定义技能哪怕后来接手的人也能很快看懂之前的人做了什么。1.3 适合谁用、不适合谁用先说不适合的场景免得大家盲目上手。如果你的需求只是给一个极简的聊天机器人加一个查天气的能力你根本不需要ponytail直接写一个函数然后配置到模型平台的Tools里就够了多引入一层反而增加维护成本。如果你的应用已经重度依赖某家云厂商的整套Agent生态那也建议先用他们自带的技能机制等真的出现“多个供应商之间来回切、技能要跨平台复用”的诉求时再考虑更通用的方案。ponytail真正适合的场景有这么几类第一你在做一个独立的AI应用技能数量预计会超过五个以上希望有一套本地化的统一管理机制第二你的团队有多人协作开发技能需要一个统一的结构规范来做Code Review和集成测试第三你在做一套可以对外输出技能的工具链希望别人能像装插件一样扩展你的系统能力第四你想在多个大模型之间做切换而不同模型的Function Calling实现有差异你希望在上层做一层屏蔽。一句话总结ponytail适合的是那种“技能成体系后需要工程化管理”的团队和项目而不是“玩票试水”的玩具项目。这个定位你务必要先清楚后面所有操作才有意义。2. 安装与初始化从零跑起一个可用的环境2.1 安装前的环境检查清单在动手装之前我先列一下我这边的环境配置给大家做一个参考基准。我当时跑ponytail用的是Python 3.10操作系统是Ubuntu 20.04的服务器平时开发用的macOS做本地测试。如果你用的是Windows环境后面提到的一些路径和命令可能要微调但逻辑是一样的。需要提前装好的基础依赖主要有这几样Python环境、pip包管理工具这个一般Python自带、以及一个可用的大模型API连接配置。ponytail本身不内置模型客户端它只负责技能这一层所以你需要自己准备一个能跑的模型调用链路。我建议第一次跑通的时候选一个便宜的模型供应商先用最小成本验证链路别一上来就配一个高价的复杂模型。有一个检查动作很关键确认你的Python环境下没有跟ponytail冲突的包。我自己踩过的一个坑是项目里之前装了一个比较旧的Requests库结果跟ponytail的依赖产生了兼容性问题表现出来是技能执行时HTTP请求异常。后来我用虚拟环境解决了这个问题。这里建议不管你是不是老手都建一个独立的虚拟环境来跑ponytail不要把依赖直接装到系统级的Python里。2.2 安装步骤与初始化流程安装过程本身不算复杂用pip直接装就可以了python -m venv ponytail_env source ponytail_env/bin/activate pip install ponytail-framework这里提醒一下包名我特意写的是ponytail-framework因为PyPI上还有一个叫ponytail的包那个是另一个跟本主题无关的项目千万别装错了。装完之后跑一下版本确认ponytail --version如果正常输出版本号说明核心安装完成。接着做初始化我建议在项目根目录执行ponytail init my_agent这个命令会在当前目录下生成一个my_agent文件夹里面包含一套默认的目录结构。生成完之后你看到的目录大致长这个样子my_agent/ ├── ponytail.yaml # 全局配置文件 ├── skills/ # 技能存放目录 │ └── .gitkeep ├── logs/ # 运行日志目录 └── main.py # 入口文件我给第一次用的朋友的建议是先别急着改配置直接把默认的main.py跑起来确认环境本体没问题后再做其他定制。执行下面的命令python main.py如果能看到一个监听端口的提示说明整套环境已经跑通了。这一步很建议做因为后面所有的问题排查都要基于“基础环境是正常的”这个前提否则你会分不清错误到底是配置问题还是技能代码问题。2.3 配置文件逐项解读ponytail.yaml是整套机制的核心配置我建议花十分钟把它从头到尾理解一遍后面省很多事。我贴一个实际使用的配置片段app: name: my_agent host: 0.0.0.0 port: 8080 debug: false model: provider: deepseek api_key_env: MODEL_API_KEY model_name: deepseek-chat temperature: 0.1 skills: scan_path: ./skills auto_register: true hot_reload: true runtime: max_execution_time: 30 max_retries: 2 concurrency: 10逐项说几个重点。model.provider这一项配置的是你要接的模型供应商支持的值你可以看对应的文档但核心逻辑是你需要提供一个按照OpenAI风格兼容的API接口。api_key_env这里特别注意它不是让你直接写Key值而是指定一个环境变量的名字比如MODEL_API_KEY。这样做的原因是避免把密钥提交到Git仓库里我见过不止一个团队因为把Key直接写在配置里然后推到公共仓库结果被刷爆额度的事故。skills.scan_path配置的是技能扫描路径ponytail启动时会遍历这个目录下所有的子目录识别其中合法的技能定义并自动注册。auto_register和hot_reload这两个参数我还挺推荐的。auto_register意味着你不需要手动维护技能清单新增技能丢到目录里就能被发现hot_reload则让技能代码的变更不需要重启整个服务。这两个特性在开发期非常爽但在生产环境我建议谨慎开启hot_reload后面第五部分我详细说。runtime这块的参数好理解max_execution_time单次技能执行最长耗时max_retries执行失败重试次数concurrency技能执行的最大并发数。这些值不是拍脑袋定的我项目里实际是根据接口响应时间的P99分位来倒推的比如我们内部接口最慢5秒返回那超时时间定到10秒重试1次把整体占用控制在合理范围。3. 手把手开发你的第一个技能3.1 技能的标准目录结构理解了配置文件之后我们来写第一个真正的技能。先说清楚技能的标准结构是怎样的。在ponytail里一个技能本质上是一个符合特定规范的目录最小化结构如下skills/ └── say_hello/ ├── index.py └── meta.yamlmeta.yaml是技能元信息文件描述“这个技能是干嘛的、需要什么参数、有什么注意事项”。index.py是技能执行逻辑真正干活的地方。这种“描述与实现分离”的设计不是ponytail首创但它在规范上执行得非常干净。还有一种稍微复杂一点的结构就是技能目录下还可以放一个requirements.txt用来声明这个技能单独依赖的第三方库。这算是一个贴心的设计因为不同技能可能用到不同的库统一装在全局反而容易版本打架。比如一个技能要处理Excel一个技能要处理图片分开管理明显更合理。3.2 用meta.yaml定义技能的“说明书”meta.yaml是整个技能能被模型正确调用的关键。我见过很多新手在这里敷衍了事写出来的描述含糊不清结果模型在对话中完全不会主动调用这个技能或者调用了但参数传得乱七八糟。我写一个示例name: say_hello description: 向用户打个招呼问候用户并返回友好的问候语。 version: 1.0.0 enabled: true parameters: - name: user_name type: string required: false default: 朋友 description: 用户的名字如果没有提供则使用默认值。description这一项值得说道说道。它不是写给人看的注释而是写给模型看的“调用指引”。模型不知道你的函数内部是怎么实现的它只能靠这段文本来判断“用户刚才那句话是不是想触发这个技能”。所以这段描述一定要写清楚“什么时候用、什么时候不用、需要注意什么边界”。一个偷懒的写法是给用户打招呼一个更好的写法是当用户主动问候或询问你是谁时调用返回问候语日常闲聊不要调用后者明显给了模型更清晰的边界。parameters需要严格按照JSON Schema的语义来写。type支持string、number、integer、boolean、object、array这些基础类型。required标记这个参数是不是必填default在没传时用默认值兜底。为什么这套定义这么重要因为大模型生成参数时完全依靠这段Schema来约束自己。你把type写成string模型就会给字符串你写成integer模型就会给整数。如果Schema本身不清晰比如缺少对枚举值的约束模型就可能给你传一个完全不在预期范围内的值。3.3 编写执行逻辑与返回规范index.py的编写也有自己的讲究。先看一个最小实现from ponytail import Skill class SayHello(Skill): name say_hello def run(self, ctx, user_name朋友): return {message: f你好呀{user_name}很高兴认识你。}关键点说明一下。Skill是所有技能必须继承的基类name属性要跟meta.yaml里的name保持一致否则注册时会报错。run方法是执行入口第一个参数ctx是上下文对象里面包含了当前对话的会话ID、已经累积的上下文信息、以及一些挂载好的外部服务工具。后面的参数对应meta里声明的参数框架会自动帮你完成从模型传参到Python参数的映射。返回值方面值得特别强调要尽量返回结构化数据不要只返回一段渲染好的字符串。比如一个查询天气的技能最好返回{city: 北京, temperature: 26, condition: 晴}而不是返回北京天气晴朗26摄氏度。原因很好理解结构化数据后续可以被模型重新组织和润色生成更自然的回答而字符串已经是一锤子买卖的最终形态模型只能原样念出来想调整语气、补充信息都没法做。如果你的技能是耗时操作可以把它设计成异步执行。ponytail对异步技能也有支持写法上就是run改成async def run然后在内部用await等待IO结果。我自己建议所有涉及外部HTTP请求的技能都写成异步因为技能并发执行的时候同步线程模型非常容易把线程池耗尽异步方案在同一资源占用下能扛的并发量明显更高。3.4 如何验证技能是否被模型正确触发技能写完之后不要直接上生产。先启动服务然后在本地跑一轮验证。我用的是一个很原始但很有效的方法直接在对话接口里发几条不同意图的消息看模型选技能的准确率。以say_hello为例我会发这几类测试消息“你好”——期望触发技能“你会做什么”——期望触发技能“今天天气怎么样”——期望不触发技能因为没有对应技能模型应该回复不知道或引导“告诉我你的名字”——期望触发技能这四类消息对应了“典型触发”“模糊触发”“无关触发”“边界触发”四种情况。如果你想认真调技能描述这个测试集可以做得更细。比如在参数方面如果你给用户名的参数定义为必填但用户没说自己叫什么你可以观察模型是怎么处理的——是编一个名字出来还是直接不调用技能还是会先反问问用户。这三种行为对应的调试方向截然不同。我的经验是技能的描述和参数Schema至少要被真实的模型对话验证过三轮以后才算基本稳定。第一轮你大概率会发现描述措辞有歧义、参数遗漏了边界条件、或者模型压根不触发。这都正常迭代就是了。等你把验证集攒得足够多甚至可以做一个简单的自动化测试用例集每次改技能描述后批量跑一遍防止回归。4. 一个真实业务案例让ponytail接入日程管理4.1 技术选型与实际需求背景到这里纯粹的技能开发你已经能上手了但距离“用到真实业务”还有一段距离。这一节我用一个比较完整的案例来串一遍全流程接入一个日程管理功能支持三个技能——查询日程、创建日程、删除日程。为什么选日程管理做案例因为它涉及真实业务里最典型的几个技术点要查询数据库、要理解自然语言里的日期表达、要处理多轮对话中可能缺失的信息、删除操作还需要确认机制避免误删。比起那种“调用一个公开天气API就完事”的demo日程管理能把你平时会遇到的真实问题都覆盖到。技术背景说一下。我这边日程数据存在MySQL里表结构大概是event_id, user_id, title, start_time, end_time这几个核心字段。外部系统已经暴露了一套简单的REST API用来操作日程数据但都是底层CRUD没有自然语言能力。ponytail在这里扮演的角色就是把自然语言指令翻译成底层API调用。4.2 从创建日程技能看参数设计先看相对复杂的创建日程技能。这个技能的难点在于用户说“帮我明天下午三点安排一个产品评审会”模型需要准确提取出三个信息——标题、开始时间、结束时间。这里面有几个容易翻车的地方第一“明天”指的是哪天取决于今天几号第二“下午三点”要不要默认结束时间第三时间格式用ISO标准还是Unix时间戳。我最终敲定的参数Schema做过至少三轮调整最后一版是name: create_event description: 当用户要求创建日程/安排会议/预约时间时调用。需要提取标题和时间如果用户缺少结束时间默认开始后一小时。 parameters: - name: title type: string required: true description: 日程标题尽量简短。 - name: start_time type: string required: true description: 开始时间ISO 8601格式如2025-06-18T15:00:00。 - name: end_time type: string required: false description: 结束时间ISO 8601格式缺省时自动设定为开始时间后1小时。为什么用ISO 8601字符串而不是Unix时间戳因为时间戳对模型来说不够直观它很难猜出“1750230000”对应的是哪天而ISO格式模型能看明白。description里我明确写了“如果用户缺少结束时间默认开始后一小时”这句话等于是给模型一个兜底规则让它在信息不全时也能完成调用。然后执行逻辑里面还要再做一层校验不能完全信任模型传过来的时间字符串。我这里用到了dateutil.parser来容错解析from dateutil import parser as dt_parser class CreateEvent(Skill): name create_event def run(self, ctx, title, start_time, end_timeNone): start_dt dt_parser.parse(start_time) end_dt dt_parser.parse(end_time) if end_time else start_dt timedelta(hours1) if end_dt start_dt: return {error: end_time必须晚于start_time} resp ctx.get_service(event_api).create_event(title, start_dt, end_dt) return {success: True, event_id: resp[id], event_title: title}这段代码里有一个很容易被忽略的细节对end_time start_time的校验。模型有时会因为解析错误给出一个结束时间早于开始时间的参数如果你不在这里兜底数据写进数据库后就会变成一条脏数据。技能里的业务校验不是为了防模型的是为了防真实世界的各种意外。4.3 查询与删除技能多轮对话和二次确认的处理查询日程相对简单核心是解析用户说的时间范围。用户可能会说“我这周有什么安排”“下周一有什么会议”“今天下午有没有空”。我这里的处理方式是用模型提取出一个时间范围的起止剩下细节交给SQL。name: list_events description: 当用户询问日程安排、空余时间、会议列表时调用。返回时间范围内的日程列表。 parameters: - name: start_date type: string required: true description: 查询范围开始日期YYYY-MM-DD格式。 - name: end_date type: string required: true description: 查询范围结束日期YYYY-MM-DD格式包含当天。这里有个经验之谈模型不太擅长精确的日期计算尤其是“下周三下午三点有没有空”这种让模型自己算日期你基本都会发现它偶尔算错。更稳妥的做法是把“今天”“明天”“本周”这些相对时间直接在Prompt层面处理好或是由调用方把相对时间换算成绝对日期后再丢给模型。我在实际项目中是通过在对话输入前加一层预处理的如果用户消息包含“今天”我就把“今天”替换成当前日期字符串这样模型拿到的是具体日期就不需要自己推算。删除日程技能最有意思因为它涉及“虚拟确认”机制。让模型直接刪记录太危险了一定要加二次确认。实现方式也有讲究不是让模型自己判断“要不要确认”——而是把删除做成分两步第一步模型调用一个confirm_delete_event技能把拟删除的日程信息展示给用户询问“确定要删除吗”第二步用户明确回答“确定”后模型才继续走真正的删除流程。我见过有人把确认逻辑塞在同一个技能的内部实现里用auto_confirm参数来控制这种做法不是不行但很容易被用户的一句话直接绕过判断风险比较大。我在实际项目中用的简化方案是删除技能内部不执行真正的删除而是返回一个needs_confirm标记。模型看到这个标记后会组织一段自然语言向用户确认。如果用户回复是肯定的模型重新调用删除技能这次传入confirmedtrue才会真正删。注意confirmedtrue这个参数在技能的meta.yaml里我标注了默认值为false并且参数描述里写明了“仅当用户明确确认删除时才设为true任何模糊表达都不得设为true”。这种写法已经在我项目里跑了好几个月没有出现过误删事故。4.4 上线后的配置调整与灰度策略三个技能开发完成后接入线上之前我建议做一次配置梳理。首先是关掉hot_reload因为生产环境技能的自动重载是一个风险源——你可能刚改了一行代码还在调试流量就进来直接打到新代码上了。其次是按技能维度做好超时控制日程管理的查询操作我定的是5秒超时创建和删除因为涉及数据库写入我给到10秒。灰度上线方面ponytail没有内置复杂的流量路由能力但你可以利用它多实例部署的特点做到简单的灰度。我现在的方式是先在预发环境跑上一两天让产品同学和测试同学真刀真枪地用对话去调技能观察日志里模型选择技能和生成参数的情况尤其关注有没有参数异常。确认没有问题后再用滚动更新的方式把代码发布到生产环境的其他实例上。还有一个非常重要的习惯技能的每一次变更都要记录版本。ponytail在meta.yaml里提供了version字段我在项目里的约定是描述性改动升级小版本参数结构变更升级大版本同时每次变更都要附上changelog。线上出了问题时你能快速定位是哪个版本开始出现偏差。5. 实操中遇到的高频问题与排查思路5.1 模型“看不见”新注册的技能这是所有新手必然遇到的第一个问题明明技能注册成功了但你在对话里怎么问模型都不调用这个新技能。排查思路我建议按这个顺序来。先看注册中心的技能列表确认技能确实在列表里。再看技能描述是不是有问题。这里有一个我总结的经验公式如果模型在“明显该触发”的场景下没触发八成是description里缺少“什么时候用”的信息或者描述写得过于宽泛导致技能与技能之间边界模糊。如果你的Agent里已经注册了多个技能描述之间的区分度就更加重要。例如你同时有create_event和create_todo两个技能如果它们的描述都包含“用户可以创建内容”这类通用句模型在判断时就可能随机选一个。正确的做法是让每个技能的适用场景都尽可能窄、尽可能明确“当用户想安排在特定时间发生的事情时调用create_event当用户只是记录不需要时间约束的事项时调用create_todo。”描述越有区分度技能调用准确率越高。5.2 模型编造不存在的参数值第二种高频问题就是模型“幻觉”具体表现是它会在你的参数Schema之外编造值。比如你的参数枚举只支持[Beijing, Shanghai]结果模型给你传了个Guangzhou。这个问题看起来像是模型的锅但根因往往在你的Schema约束不够严。解决办法是充分利用枚举约束。JSON Schema层面就可以给参数加枚举- name: city type: string required: true enum: - 北京 - 上海 description: 用户要查询的城市名称仅限列表内的城市。加了enum后大多数情况下模型不会给出列表之外的值。但这不代表你可以完全信任代码里还是应当保留一层基础校验发现非法值就返回一个明确的错误而不是强制继续往下游传。还有一种幻觉很隐蔽模型会为了满足参数要求而“反推”出一个不存在的值。比如你的技能要求必须传用户ID但对话里用户压根没提模型可能直接给你塞一个编造的ID。这种情况我更推荐的做法是把参数设为非必填然后在技能内部通过用户的会话ID从上下文里反查用户身份而不是依赖模型从对话里抽取。整个思路是凡是系统自己拿得到的数据就别让模型去猜。5.3 参数缺失时技能直接报错与上面的问题相辅相成很多技能在参数缺失时直接抛异常。正确的设计思路是每个技能都要能优雅地处理参数不完整的情况。我自己的处理规范是技能内部先做参数完整性检查如果缺必填参数不要抛异常而是返回一个包含缺哪些参数的结构化结果让模型用自然语言追问用户。我把这个模式写成了代码示例def run(self, ctx, titleNone, start_timeNone, end_timeNone): missing [] if not title: missing.append(title) if not start_time: missing.append(start_time) if missing: return { status: need_more_info, missing_params: missing, message: f还缺少以下信息{, .join(missing)} }这段代码里的status: need_more_info是一个关键设计。它告诉模型不是执行失败而是需要用户补充信息。模型看到这个返回值后就会组织语言向用户反问。你千万别小看这个细节很多Agent做出来显得“笨”就是因为技能一遇到参数缺失就直接崩溃连个追问都不会。5.4 热更新与并发导致的状态混乱hot_reload这个功能开发时很爽但生产环境开着就需要特别小心。我遇到过的问题是技能文件保存到一半被重载结果新代码读到了不完整的语法导致skill实例状态跟预期不一致轻则报错重则把半成品逻辑暴露给线上用户。对此我的建议是把技能的发布做成两阶段。第一阶段是先把文件上传到服务器的一个stage目录第二阶段是执行一个原子的移动命令把文件切到真实技能目录。这样ponytail扫描的时候看到的永远是一个完整的文件。并发这边runtime.concurrency要根据下游系统的承受能力来定不是越大越好。我之前把并发调到50就曾打爆过下游数据库的连接池后来老老实实调回20。5.5 一份高频问题排查速查表最后整理一张速查表方便大家以后遇到问题时直接对号入座。现象可能原因排查动作技能认证成功但模型不调用description边界不清精简描述明确“什么时候用/不用”模型传了非法枚举值Schema缺enum约束在参数定义中补充枚举范围技能返回后模型答非所问返回非结构化字符串改返回结构化JSON让模型重新组织语言多个技能同时触发争夺技能描述重叠调整描述让边界互斥线上偶发技能超时并发被打满或下游慢看慢日志调低并发或增加重试技能更新后行为异常hot_reload读到半成品文件生产关闭热更新改用原子替换这张表我建议打印出来贴在工位上。每个问题都是我用实际流量“喂”出来的教训照着排查大部分情况都能快速定位。在技能工程这条路上的一点体会做完这一整套ponytail接入项目后我最大的转变是对“技能设计”这件事本身的认知。以前我以为技能开发就是把API包一层给模型调用工作量全在对接上。实际做下来才发现真正花时间的地方全在描述怎么写、参数怎么约束、边界情况怎么兜底、返回结构怎么设计这几个环节上。换句话说模型本身的能力已经摆在那里技能设计的好坏决定了它能不能把你的能力正确使出来。最后分享一个我到现在还在坚持的习惯每次新增或修改技能都要跑一遍标准测试集再收工。测试集包含十个左右的典型问题、模糊问题和异常问题人工检查模型触发技能的选择和参数质量。这个习惯帮我挡下了很多低级回归也让我对线上效果更有把握。技能工程没有多高深的理论就是一遍又一遍地做对细节。