做AI Agent方向的开发已经有段时间,我越来越确信一件事:模型本身的推理能力再强,如果没有一套设计得当的"技能"体系让它去调用真实的工具和数据,Agent 就只是一个精致的聊天框。这也是我启动 agent-skills 这个项目的直接原因。它本质上是一套面向 LLM Agent 的轻量技能框架,核心解决三件事:怎么把业务能力抽象成模型能看懂、能调用的技能,怎么让这些技能可以复用、热插拔,以及怎么让 Agent 在复杂的多步任务里稳定地编排技能而不是中途跑飞。
这篇文章我会把 agent-skills 的整体设计、核心机制、关键代码和踩坑记录都摊开来讲。不管你是正在摸索 Agent 工程化的开发者,还是已经在做企业内部 AI 工具链、希望给团队沉淀一套可复用能力层的人,这篇内容应该都能给你一些比官方文档更实在的参考。我会尽量把每个决策背后的"为什么"也说清楚,而不是只丢一堆代码给你。
1. 项目定位:agent-skills 到底在做一件什么事
1.1 从"能聊天"到"能干活",Agent 缺的是一层技能抽象
先聊一个很常见的问题。很多人第一次做 Agent 应用时,习惯直接把所有工具函数塞给模型,Prompt 里写"你有这些工具可用,需要时调用"。Demo 阶段没问题,但一旦工具数量过了 10 个、20 个,模型就开始乱了:要么选错工具,要么参数填得驴唇不对马嘴,要么干脆自己脑补一个函数名出来。
问题出在哪?出在你只给了模型一堆零散的函数,却没有给它一个结构化的、自描述的"技能层"。函数签名是给编译器看的,不是给 LLM 看的。模型需要的是:这个技能是干什么的、什么时候该用、参数应该怎么填、可能的边界条件是什么。agent-skills 的全部工作,就是把这一层东西补上。
简单说,这个项目定义了一个技能(Skill)的标准结构,提供注册、发现、调度、执行、回滚的完整生命周期管理。业务团队只需要按照约定写技能,Agent 就能在运行时动态感知并调用这些技能。它不是一个重框架,不绑定特定的大模型厂商,也不强制你用某种 Agent 编排器,它只做技能管理这一层最核心的脏活累活。
我在设计时有一个明确的原则:技能必须是描述性的、自包含的。描述性意味着每个技能都带有一套完整的元信息,模型光靠读描述就知道怎么用;自包含意味着技能不能依赖外部的隐式状态,所有需要的信息要么通过参数传入,要么技能自己负责获取。
1.2 这个项目聚焦并想解决的四个核心问题
具体拆解下来,agent-skills 的定位很聚焦,主要就是为了解决四类问题,这也是我这段时间做企业级 Agent 项目时被反复折磨的痛点:
第一是技能的复用问题。同一个"发邮件"能力,A 项目里写了一套,B 项目里又写了一套,参数风格还不一样,最后模型在两边表现完全不一致。agent-skills 希望把技能做成标准件,像乐高积木一样即插即用,一份定义到处跑。
第二是模型与工具之间的"翻译"问题。原始 API 的参数往往是工程师风格的:to、cc、bcc、htmlBody,模型未必理解htmlBody和textBody在什么场景下应该用哪个。技能层要做的是把这些东西转换成模型容易理解、不容易想歪的描述和约束。
第三是技能的编排问题。单技能简单,多技能协作难。Agent 经常需要"先查库存,再算价格,最后生成订单"这种多步操作,每一步该调哪个技能、参数怎么上下游传递,如果没有一套编排机制,最后代码会变成一团乱麻。我在 agent-skills 里组合了一套轻量链式调度的实现,后面会详细说。
第四是技能的可观测性。生产环境里 Agent 调用了什么技能、每个技能花了多长时间、参数和返回是否符合预期,这些必须有完整的轨迹记录。不然出了问题都没法排查——模型说它调了,但你不知道它具体传了什么参数进去。
这个项目适合谁?如果你正在自己搭 Agent 架构,不想被某个封闭的厂商框架绑死,或者你需要为企业内部沉淀一套统一的"AI 可调用能力清单",那么 agent-skills 的思路和代码都会对你有用。
2. 技能体系的整体设计与架构思路
2.1 技能的三种粒度:原子技能、组合技能、工作流
我最早设计技能结构时只分了两类:简单技能和复杂技能。后来在真实场景里跑了一段时间发现,这种二分法太粗糙,无法描述真实的业务复杂度。最后收敛成三种粒度:原子技能、组合技能和工作流技能。
原子技能是最小粒度的能力单元,通常直接封装单个 API 或函数调用。比如"根据订单号查询订单状态""计算两个日期之间的工作日天数""生成 UUID"。原子技能要求做到职责单一、无副作用或副作用可控。它们是整个技能体系的基石。
组合技能是在原子技能之上的封装,内部会调用多个原子技能,但对调用方暴露的仍然是一个简单接口。典型的例子是"生成周报"这个技能,内部可能依次调用了"获取本周待办""聚合工时数据""调用模板渲染"三个原子技能。组合技能的关键在于内部编排逻辑要稳定,不能让模型干预太多中间步骤,否则输出随机性太大。
工作流技能是更复杂的场景化能力,强调状态流转和分支判断。比如"处理退款申请"就是一个工作流技能,里面要根据订单状态、支付渠道、金额大小走不同分支,有些分支需要人工审批。这种技能我一般建议尽可能把它内部的判断逻辑收敛到代码里,只给模型暴露明确的决策点,而不是让它自由发挥。
这三层结构带来的好处是:不同的技能有不同的测试策略和稳定性要求。原子技能要 100% 稳定,组合技能要 95% 以上稳定,工作流技能允许人工介入兜底。这种分级思路也直接影响了后面调度器的设计——等会儿会讲到。
2.2 为什么用 JSON Schema 描述技能,而不是直接写死函数
这是一个我在社区里被问过很多次的问题。你要是直接用 Python 或 TypeScript 写一个send_email(to, subject, body)函数给 Agent,代码当然是类型安全的,但模型看到的是什么?它看到的是函数名、参数名,以及你在 Prompt 里可能补的一句概述。这信息量太少了。
agent-skills 里每个技能都附带一个完整的 JSON Schema,用来描述参数结构、类型、必填项、枚举值、依赖关系等。模型(尤其是支持 function calling / tool use 接口的模型)会在运行时读取这份 Schema 来决定是否调用以及如何填参数。JSON Schema 是模型和工具之间的通用语言,它不是给人看的那种接口文档,而是给 LLM 做结构化决策用的。
举一个实际例子。有一个技能是"查询销售数据",参数里有个granularity字段,如果只在类型里写string,模型很可能填成 "month" 或者 "monthly" 或者 "月",每次都不一样。但在 JSON Schema 里你可以明确定义enum: ["day", "week", "month"],并加一段描述说明什么场景该用哪个值。这个约束效果立竿见影,参数错误率会明显下降。
除了字段类型和枚举,JSON Schema 还支持oneOf、anyOf、嵌套对象和数组结构。这意味着技能可以描述非常复杂的业务对象。比如创建订单这个技能,它的参数可能涉及customer、items、shipping_address、payment等多个嵌套结构,每个结构内部还有各自的校验规则。这些信息全部通过 Schema 传给模型,模型就能准确地把自然语言指令映射成结构化参数。
我还在 Schema 里加了一个自定义字段x-experience,专门用来写"这个参数在实际使用中的常见坑"。比如某个时间参数,描述里会写"注意:此处需要 UTC 时间,不要传本地时间;如果调用方在 UTC+8 时区,请先做转换"。对模型来说,这种自然语言的提醒往往比干巴巴的类型定义更有效。
2.3 技能描述的艺术:模型能不能选对,一半看命,一半看描述
有句话说得很对:给模型写的技能描述,本质上是在给一个聪明但没有常识的实习生写工作手册。你必须假设它对你们公司的业务术语一无所知,同时又假设它能力很强,只要描述到位就能正确执行。
技能描述我总结了一套固定模板,每条描述都包含四部分:执行条件(什么时候该用这个技能)、执行后果(调用后会发生什么,有没有副作用)、参数语义(每个关键参数怎么填,有什么坑)、边界情况(什么条件下不要用这个技能)。
举个例子,我之前设计过一个"发送营销短信"的技能。第一版描述只写了"给指定用户发送营销短信,参数包括手机号和文案",结果模型在用户查询"给我发个验证码"的时候也调用了这个技能。这显然是灾难级的误用。后来我把描述改成:"本技能用于发送批量营销短信,仅适用于用户已明确授权接收推广信息且当前会话语境为营销推广的场景。不要在身份验证、安全提醒、事务通知场景下使用本技能,这些场景应调用发送验证码或发送通知类的技能。"改完之后,误用率几乎降到零。
这里有一个容易被忽视的设计原则:技能之间要有清晰的边界描述,而不仅仅是各自独立的正向描述。你不仅要告诉模型这个技能是干什么的,还要告诉它这个技能不是什么、和哪些技能容易混淆、什么情况下不要选它。模型在多个候选技能中做选择时,这种负向描述的作用往往比正向描述更大。
另一个经验是:技能描述要学会用业务场景的语言,而不是技术语言。比如"检查 API 配额"这种描述,模型能理解,但在真实业务里用户会说"为什么今天不能发请求了"——这时候模型需要联想到"检查 API 配额"这个技能。所以描述里可以加一句"当用户反馈发不出消息、接口报错或被限流时,可使用本技能查看当前项目的 API 调用余量"。
3. 核心机制实现:注册、调度与执行
3.1 技能注册中心:让模型动态感知可用的能力集
agent-skills 里有一个全局的技能注册中心(SkillRegistry),所有技能在应用启动时或者运行中动态注册进来。注册中心维护了一份完整的技能清单,并且会实时生成一份压缩后的"技能目录",注入到每次模型调用的上下文里。
为什么要做动态注册而不是写死配置?因为在实际项目中,技能列表往往是不断变化的。新业务上线要加技能,旧接口下线要摘除技能,不同客户有不同权限需要看到不同技能集。如果每次变更都要改代码发版,那开发效率就太低了。动态注册配合权限过滤,能实现"同一个 Agent 实例,对不同用户暴露不同的技能集合"。
看一下注册的关键代码实现(TypeScript 版本):
type SkillHandler = (params: Record<string, any>, context: ExecContext) => Promise<SkillOutput>; interface SkillDefinition { name: string; version: string; description: string; tags: string[]; parameters: JSONSchema; handler: SkillHandler; timeout?: number; requiredPermissions?: string[]; } class SkillRegistry { private skills = new Map<string, SkillDefinition>(); register(def: SkillDefinition) { if (this.skills.has(def.name)) { throw new Error(`Skill already registered: ${def.name}`); } validateJsonSchema(def.parameters); this.skills.set(def.name, def); } unregister(name: string) { this.skills.delete(name); } listForUser(user: User, permissionService: PermissionService): SkillSummary[] { return [...this.skills.values()] .filter(s => permissionService.canUse(user, s.requiredPermissions ?? [])) .map(s => ({ name: s.name, description: s.description, parameters: s.parameters })); } get(name: string): SkillDefinition | undefined { return this.skills.get(name); } }这里有个细节值得说明:注册时我会做一次 JSON Schema 的预校验,如果 Schema 本身格式不合法,当场抛错而不是等到运行时报。这个预校验成本很低,但能把大量低级错误拦截在开发期。比如某个参数类型写错了、某个枚举值不是数组,这些错误一上线就会导致模型选技能时解析失败甚至崩溃。
权限过滤这一层也很关键。企业内部很多技能涉及敏感操作,比如"发送对外邮件""删除生产环境数据""获取客户隐私信息"。这些技能不应该对所有用户开放。权限最小的实现是在注册中心查询时直接过滤,配置管理都交给权限服务。这一层做干净了,后续做多租户、审计、合规都会省很多事。
3.2 调度器选型:什么时机让模型自己选,什么时候走规则路由
技能调度是整个系统里最容易"翻车"的环节。我最早天真地把所有技能都开放给模型自由选择,结果遇到一个问题:当技能池超过 20 个时,模型的选择准确率会肉眼可见地下降,经常把"查用户信息"和"查用户的订单"搞混,或者把一个专业性很强的技能描述理解偏了。
后来我把调度策略改成了分层路由。具体来说是这样的:系统先过一个轻量级的意图分类器,把用户意图大致分到几个域(比如"数据查询""交易操作""内容生成""系统管理"),然后每个域再向模型开放对应的技能子集。分类器本身可以用小模型,也可以直接用规则匹配,成本很低,但能把技能选择范围一下子缩小到原来的三分之一以下,准确率提升非常明显。
在实现上,agent-skills 的调度器支持三种模式:
第一种是模型自主选择模式,适用于开放域问答和通用助手场景。系统把完整技能目录塞给模型,由模型决定调用哪些技能以及调用的顺序。这种模式最灵活,但需要技能描述写得非常清楚,且技能数量不能太多。
第二种是规则路由模式,适用于流程稳定的业务场景。比如客服系统里,用户说"我要退款",规则路由直接把请求转到"退款处理工作流"技能,不经过模型决策。这种模式牺牲了灵活性,但换来了稳定性和可预测性,而且能显著降低响应延迟。
第三种是混合模式,也是我在大部分生产项目里实际采用的方案。先用规则匹配一次,命中就走固定流程;没命中再走模型自主选择。如果模型选择的结果置信度低于阈值(通常在首次选择时会返回一个confidence字段,不过很多模型不提供这个字段,我会要求模型先输出一个"plan"再执行),就退回人工兜底或者让模型再次确认。
这套调度策略的代码核心其实很短,但要把整个决策过程都记录下来非常关键。我实现了完整的 trace 日志,每次调度的输入、候选技能列表、路由结果、最终技能选择都会落盘。生产环境排查问题的效率完全靠这些日志撑着。
3.3 执行器设计:上下文管理、超时控制与幂等回滚
执行器是真正干活的地方,也是踩坑最多的地方。第一个坑是上下文传递。技能和技能之间经常需要共享数据,比如"查询订单"技能返回了订单号,"生成发票"技能才能用。如果用全局变量传,并发一高就串数据了;每次全量传上下文又会导致 token 消耗爆炸。
agent-skills 的做法是维护一个SkillContext对象,它会记录当前执行链路的关键中间结果,并且支持两种读取方式:精确 KEY 读取和语义化搜索读取。精确 KEY 读取适合上游技能和下游技能之间有明确的数据契约的场景;语义化搜索则是在没有明确契约时,让调度器从上下文中检索相关信息,再决定如何传给下一个技能。这两种读取方式可以组合使用,代码里大概是这样的:
class ExecContext { private state = new Map<string, unknown>(); private eventLog: TraceEvent[] = []; private maxContextLength = 4096; set(key: string, value: unknown) { this.state.set(key, value); } get<T>(key: string): T | undefined { return this.state.get(key) as T | undefined; } // 语义化搜索:适合不确定 key 是否存在时的兜底 semanticGet(prompt: string, maxResults = 3): Array<{ key: string; value: unknown }> { // 实际实现中会调用 embedding 模型进行相似度检索 return this.searchInState(this.state, prompt, maxResults); } trace(event: string, data?: unknown) { this.eventLog.push({ event, data, ts: Date.now() }); } }这个上下文对象里我会定期清理不用的数据,避免无限膨胀。通常每一轮技能调用结束后,会保留当前链路中最新的 N 个关键变量,超过maxContextLength的部分按照 LRU 策略淘汰。这里要小心的是,不能把模型后续决策需要的敏感数据淘汰掉,所以我会给部分变量加一个persist标记,这样的变量在整条链路结束前都不会被自动清理。
第二个坑是超时控制。LLM 推理本身就慢,一个技能如果还要调外部 API、查数据库,很可能整体超过 10 秒。不同技能的耗时预期差异极大,有的技能 200ms 就应该返回,有的技能要跑好几个小时。agent-skills 允许在技能定义里单独的timeout配置。超时后执行器会取消当前技能的 Promise,并且立即触发一个错误事件,通知调度器进行降级处理。
第三个坑是幂等回滚。凡是涉及写操作的技能,都需要考虑"如果这个技能被调用了两次会发生什么"。比如"给用户账户增加 100 积分"这个操作,如果模型因为网络重试或者推理重复执行了两次,用户的积分就被加了两次,这是不能接受的。我在技能定义里增加了一个idempotencyKey的可选参数,建议所有写操作技能都要校验幂等键,并在执行器层面做了去重:同样的幂等键在同一个会话里只允许执行一次,重复请求直接返回第一次的执行结果。
4. 实操演示:从零到一写一个可用的业务技能
4.1 定义一个"销售数据报表生成"技能
前面讲了不少抽象设计,可能有点干,这一节我用一个完整的业务技能来串一遍。假设我们接到一个需求:要让 Agent 能够根据自然语言问题,自动从销售数据库里查询数据,并生成一张 Markdown 格式的报表。这个技能我们在 agent-skills 框架里可以这么定义。
首先分析这个技能的组合属性:它其实是组合技能,内部涉及三个子任务——解析查询条件、执行 SQL、渲染报表。前两个子任务如果让模型一步到位去写 SQL,查复杂业务库时错误率会很高,所以我会把"查询条件规范化"这一步拆出来,用规则来做,而不是靠模型自由发挥。
先定义参数 Schema:
const generateSalesReport = { name: "generate_sales_report", version: "1.2.0", description: `根据用户的需求生成销售数据报表,支持按时间范围、区域、产品线、渠道四个维度过滤。 当用户问"最近一个月的销售额是多少""华东区 Q3 卖了多少台"或要求"生成一份本周销售周报"时使用本技能。 注意:本技能只读数据,不执行任何写入操作。如果用户要求修改/删除销售数据,请改用 modify_sales_data 技能。`, parameters: { type: "object", properties: { date_range: { type: "object", properties: { start: { type: "string", description: "开始日期,格式 YYYY-MM-DD,UTC 时间" }, end: { type: "string", description: "结束日期,格式 YYYY-MM-DD,UTC 时间" } }, required: ["start", "end"], description: "查询时间范围,必填。如果用户只给出相对时间,请根据当前日期推算。" }, region: { type: "array", items: { type: "string", enum: ["华北", "华东", "华南", "西部"] }, description: "区域过滤条件,可选。不传则查全部区域。" }, product_line: { type: "array", items: { type: "string", enum: ["手机", "电脑", "配件"] }, description: "产品线过滤条件,可选。不传则查全部产品线。" }, group_by: { type: "string", enum: ["day", "week", "month", "region", "product_line"], description: "汇总粒度,可选。决定报表的分组维度。如果用户要求按月份看趋势,填 month。" } }, required: ["date_range", "group_by"] }, handler: async (params, ctx) => { // 实施步骤: // 1. 解析并校验参数 // 2. 拼接 SQL(这一步用固定的模板,不允许模型直接传 SQL) // 3. 执行查询 // 4. 将结果渲染为 Markdown 表格 } };这个定义里有几个细节是经过很多次调试才定下来的。第一,我在描述里明确了"只读"属性和混淆技能提示,这就避免模型把"生成报表"和"修改销售数据"搞混。第二,date_range我在描述里强调了"UTC 时间",因为实际项目里这个问题至少出过三次事故,模型总是喜欢把本地时间直接传进来。第三,group_by字段是必填的,但模型经常不知道怎么填,所以我在描述里给了很具体的示例:"如果用户要求按月份看趋势,填 month",实践证明这样的提示效果比干巴巴的枚举值列表好很多。
4.2 技能内部实现与模型意图解析的配合
handler内部的实现,最容易被低估的部分是把自然语言查询转化为 SQL 的中间层。最初我尝试让模型直接生成 SQL,然后丢到数据库执行,结果在生产环境出了大事:模型生成的 SQL 存在语法错误、把表名写错、甚至有一次生成了DELETE FROM语句差点把数据清掉。虽然框架层可以加只读约束,但更稳妥的做法是从源头上就不让模型直接操作 SQL。
我在 handler 里做的事情是:把模型需要决策的空间缩小到"选参数"这个层面,而不是"生成代码"这个层面。最终执行的 SQL 是从一套预先写好的模板里拼出来的。比如按日期范围过滤订单明细,模板长这样:
SELECT trade_date, SUM(amount) as total_amount FROM sales_orders WHERE trade_date >= '{start}' AND trade_date <= '{end}' AND region IN ({region_list}) AND product_line IN ({product_line_list}) GROUP BY trade_date ORDER BY trade_date;{region_list}和{product_line_list}是从参数里转义后拼进去的,所有值都必须经过白名单校验,凡是不在枚举值列表里的输入直接丢弃。这保证了即使模型填了奇怪的东西,最终执行的 SQL 也是安全的。
这一步完成后,把 SQL 执行结果传给渲染模块。渲染模块我直接用了一个模板函数,把数据转成 Markdown 表格,再追加一行总结:总销售额、环比变化率、同比变化率。这部分逻辑用一行额外的模型调用会带来不确定性,所以我把它做成纯代码计算。数据报表这个场景,计算结果必须 100% 精确,不适合让模型来做算术。
4.3 技能调试:离线测试、Mock 外部依赖与 echo 模式
技能写完之后,最痛苦的部分来了:调试。一个大模型应用里,技能本身有 Bug 和模型调用方式有问题经常混在一起,难以区分。为了把这两类问题剥离开,agent-skills 里我实现了三种调试模式。
第一种是纯离线模式。跳过真实的 LLM 推理,直接用预先录好的用户请求做输入,然后把 handler 跑一遍,只检查技能自身的逻辑是否正确。这等价于传统软件开发里的单元测试。很多技能逻辑问题——比如 SQL 拼接错误、空值处理不当、分页参数错误,都能在这一层发现。我在项目里会为每个技能强制要求至少一个离线测试用例,用例覆盖正常路径、边界参数、异常参数三类场景。
第二种是Mock 外部依赖模式。技能大多要调外部 API 或数据库,调试时不可能每次都连真的环境。agent-skills 支持在技能定义里声明它依赖的外部服务,并通过依赖注入的方式在测试环境替换成 mock 实现。比如上面的报表技能,测试环境里我会把数据库客户端 mock 成固定返回三行数据,这样跑一遍就能确认渲染逻辑没问题,而不用真的连库。
第三种是echo 模式,也是我最常用来排查"模型到底干了什么"的模式。在这个模式下,所有技能的 handler 不会真实执行,而是把收到的参数原样返回,同时记录一份完整的请求日志。这样我可以快速验证:模型有没有在正确的时候调用正确的技能?参数填得对不对?是不是反复调用同一个技能?这类问题靠观察日志就能定位,不需要真的去查数据库或者发邮件,排查效率非常高。
5. 常见问题复盘
5.1 模型总是选错技能,怎么排查
这是被问得最多的问题,没有之一。遇到"模型选错技能",我一般不会急着去调模型参数,而是先看 trace 日志,把模型当时的输入上下文完整拉出来,回答三个问题:模型在这个时刻看到了哪些技能描述?用户完整的对话历史是什么?模型实际选了哪个技能、为什么它会觉得这个技能合理?
排查的时候有个非常容易被忽略的点:技能描述的先后顺序会影响模型的选择。有些模型对排在前面的技能描述有偏好,如果两个技能描述语义比较接近,排在后面的往往被忽略。我做过一个实验,把"生成退款单"和"生成发票"两个技能调换顺序,模型的选择结果也跟着调换,而且模型自己完全感知不到这个问题。这个问题的解法是在技能描述里故意增加互相区分的负向描述,而不是指望调整顺序。
如果确认描述没问题、顺序也调整过,模型还是选错,那就要检查是不是技能的参数 Schema 存在歧义。比如"日期参数"在 A 技能里是字符串格式,在 B 技能里是时间戳格式,模型的输入又都是自然语言,它可能就猜不准哪里该填什么。这种时候我会把相关的描述统一改写,全部规范成同一种格式,并在描述里写清楚。
还有一种场景是模型"知道自己该调用某个技能,但不知道技能的准确名字"。有些模型的 function calling 能力比较弱,名字稍微长一点就截断了,或者干脆自己拼一个相似的名字出来。这种情况下不要怪模型,而是在技能名设计上做文章。我给技能命名有一个约定:动词开头 + 下划线 + 业务对象,比如query_user_profile、create_refund_order,全小写,不加多余的前缀后缀。名字保持在 28 个字符以内,实测这种命名方式在主流模型上的识别成功率最高。
5.2 参数校验和类型对齐:为什么模型填的参数总是带着多余空格
模型填参数的时候经常出现一些"人类不会犯"的低级错误,比如在字符串首尾加上多余空格、把日期格式从2024-05-01写成2024年5月1日、数字填成字符串。这些问题很小,但积累起来会触发很多难排查的隐性 Bug。
我的做法是做一个参数标准化管道,在技能 handler 被真正调用之前,先把模型原始的 JSON 参数过一遍清理和归一化:
function sanitizeParams(raw: Record<string, unknown>, schema: JSONSchema): Record<string, unknown> { const result: Record<string, unknown> = {}; for (const [key, propSchema] of Object.entries(schema.properties ?? {})) { let val = raw[key]; if (val === undefined) continue; if (typeof val === 'string') { val = val.trim(); if (propSchema.format === 'date' && /^\d{4}年\d{1,2}月\d{1,2}日$/.test(val)) { // 手动转为 YYYY-MM-DD val = val.replace(/(\d{4})年(\d{1,2})月(\d{1,2})日/, '$1-$2-$3'); } } if (propSchema.type === 'number' && typeof val === 'string' && !isNaN(Number(val))) { val = Number(val); } result[key] = val; } return result; }这段代码看起来简单,但实际解决问题的数量远超预期。以前模型传日期经常是中文格式,SQL 直接拼接进去就会出语法错;现在先归一化成标准 ISO 格式,后面所有环节都省心。值得一提的是,参数清理绝不能做过度转化,如果一个参数字符串是用户提供的原文(比如搜索框里输入的内容),就不要动它,不然会改变语义。所以我在 Schema 里加了一个x-raw自定义标记,标了x-raw: true的字段不做 trim 以外的任何处理。
5.3 并发安全和技能间的资源竞争
最后聊一个比较进阶的问题:当 Agent 多个实例同时跑,技能内部如果有共享资源(比如数据库连接池、Redis 连接、内存缓存),很容易出现资源竞争。我印象最深的一次事故是:一个查报告的技能内部用了模块级的全局缓存对象,上线后发现偶尔会出现 A 用户看到 B 用户的数据。排查了半天才发现是缓存对象被多个实例共享,没有做会话隔离。
agent-skills 里对这个问题做了几个约束。第一,技能 handler 不允许使用全局可变状态,所有状态必须放在ExecContext里,这样天然做到了按会话隔离。第二,技能与外部系统的连接(数据库连接、HTTP 客户端)统一通过依赖注入传入,每个会话可以拿到自己独立的连接实例。第三,对于需要共享的资源(比如限流器、计数器),提供了原子操作的原语封装,避免多个并发调用同时读写导致数据不一致。
另外一个常被忽略的问题是技能重入。工作流技能在编排过程中可能会调用自身(比如递归处理嵌套结构),如果不做重入保护,就会无限循环。我在执行器里给每个技能加了一个最大调深限制,默认是 10 层,超过这个深度强制抛错。这个限制平时用不到,但一旦模型设计了一条错误的循环链路,它能帮你保住服务器的 CPU。
6. 实战经验分享:这个项目最让我意外的几个结论
做 agent-skills 这几个月,有几个结论是完全超出我最初预期的,这里分享给正在做同类项目的朋友。
第一,技能描述的投入产出比极高。我粗略统计过,花在优化技能描述上的时间和最终模型调用准确率之间的关系接近线性。写技能时多花二十分钟把描述精修一遍,能把生产环境的错误率降低一半以上,这远比调 prompt 模板或者换模型版本来得有效。描述不是一次写对的,是要在 trace 数据的反馈里持续迭代的,我建议把它当做一个持续优化的过程,而不是一次性交付物。
第二,技能不是越多越好。技能池越庞大,模型的选择压力越大,准确率下降得就越快。我现在遵循一个原则:优先组合,而不是新增。如果一个操作可以由两个已有技能组合完成,就不新建第三个技能;如果两个技能描述语义太接近、经常被混淆,我会考虑合并成一个技能,用一个参数来区分具体操作。保持技能数量在 15 到 20 个以内,模型的表现通常最稳定。
第三,给模型做决策的"辅助笔记"非常重要。agent-skills 的x-experience字段一开始只是随手加的,后来发现这是全项目里对准确率提升最大的功能点。模型本身缺少很多业务领域的隐性常识,你在参数描述里把这些常识写清楚,比如"下单金额超过 5000 需要走审批流程",模型就能在配置参数时自动调整结构,这种效果是纯靠优化主 prompt 很难达到的。
最后说一句:Agent 项目的复杂度,很大程度上是一种"隐形复杂度"。一个单独的技能看着简单,但技能之间的边界划分、上下文传递、错误恢复、权限控制,全都要在设计层面提前想好。这个项目不会让你的 Agent 直接"变聪明",但能让你在 Agent 变复杂时不至于崩溃。如果你正在做类似的尝试,欢迎到 GitHub 搜 agent-skills 一起交流,也建议直接从你自己的一个真实业务技能开始,把它落到框架里,跑通了再慢慢扩展。