
Agent技能层才是决定LLM应用上限的关键做Agent开发这两年多我最大的感触是模型选型固然重要但真正决定一个Agent能干什么、干得稳不稳的其实是中间那层技能层agent-skills的工程设计。同样是调用工具有的Agent上下文里塞了十几个function definition就能精准命中有的Agent一碰到多步骤任务就开始连环幻觉。这不是模型理解力的问题而是技能层做得够不够扎实。这篇文章我想把个人在技能层设计与实现上的整套思路完整拆一遍包括技能接口规范、注册机制、选择与编排策略以及生产环境里那些文档不会告诉你的坑。如果你是正在做Agent应用、或者准备把工具调用做成可复用技能的开发者这篇文章应该能帮你省下不少试错时间。我默认你了解LangChain、Function Call这类基础概念但如果你只是刚接触Agent也没关系涉及关键概念的地方我会用大白话展开讲确保你能跟上思路。1. 技能到底是什么先厘清概念再动手设计1.1 Agent为什么需要技能而不是单纯工具先明确一个核心问题工具Tool和技能Skill到底有什么区别很多团队在早期会把这两个词混用代码里一个装饰器标上tool就开始往LLM上堆结果堆到几十个工具时模型的选择准确率肉眼可见地往下掉。我的理解里工具是单一、不可再分的动作单元比如查询天气计算两个日期差多少天。这类动作通常是幂等的、边界清晰的模型只要根据描述就能正确地选用。但技能则是一个动作策略包它可能包含多个工具的顺序调用、分支判断、参数预处理、结果后处理甚至包含一套领域规则。举个例子查询某只股票是否值得买入这个能力如果做成工具它只能做一次API查询如果做成技能它可以拆成获取行情→计算市盈率分位→读取该行业的平均估值区间→输出结论四个步骤中间任何一步失败了还能根据规则做降级处理。一个Agent如果只挂工具本质上就是一个能动手但不会安排活的实习生挂了技能层之后它才具备接到模糊指令后自己拆解任务、按序执行、异常自愈的初级判断力。这也是结构上把agent-skills独立成层、而不是散落在Agent主逻辑里的根本原因——技能需要被统一注册、统一管理、统一观测。1.2 技能层解决的最核心的三个问题技能层不是把函数包一层好看的名字那么简单。我实际设计时核心盯着三个问题第一个问题是可复用性。同样一个发送飞书消息的能力可能在周报Agent、告警Agent、数据分析Agent里都会被用到。如果每个Agent里都复制一份调用代码那后续接口升级、鉴权变更就是一场灾难。技能层需要提供一套独立的注册与加载机制让不同Agent通过名称或ID来引用技能而不是各自维护一份实现。第二个问题是可编排性。真实任务几乎都不是单步调用而是先查数据、再清洗、再计算、再写报告这种流水线。技能层必须能支持组合也就是技能A的输出可以作为技能B的输入而这个过程最好通过配置或声明式描述完成而不是在Python代码里硬编码if-else。第三个问题是可观测性。模型调用技能时到底传了什么参数、技能内部走了哪个分支、耗了多少时间、是成功还是失败——这些信息必须在技能层统一采集。没有可观测性的Agent线上出了问题只能靠猜排查一次事故能熬掉半条命。所以技能层在我这里始终是一个半独立的中间件它不像一个微服务那样独立部署但它必须有独立的数据结构、独立的加载逻辑、独立的监控埋点。这样无论上层Agent怎么变技能层都能像乐高积木一样被任意拼装。2. 技能接口规范从能跑到好调2.1 技能描述用词的精确度直接影响模型命中率技能选型这步本质上是让LLM从多个候选中挑一个。这个过程不是靠模型理解你的功能实现的而是靠它阅读理解你的描述。描述写得模糊再好的实现也白搭。我早期写描述踩过一个特别典型的坑。当时做了一个汇率换算技能描述写的是汇率换算用于各种货币的转换。结果模型在用户问200美元能换多少人民币时有接近四成概率会去调用另一个单位换算技能因为单位换算的描述里有长度、重量、货币等字眼模型认为更匹配。后来把描述改成实时汇率查询与换算支持170多种法币及主流加密货币入参需携带base_currency、quote_currency、amount三个字段汇率数据源为第三方聚合接口命中率立刻从六成提到九成以上。这说明一个道理技能描述不只是给人看的更是给模型看的检索索引。描述里要包含以下信息技能功能是什么、适用场景的典型问法、关键参数、数据来源、边界条件。越具体模型检索时匹配的锚点就越多。当然描述也不宜过度膨胀我实践下来把描述控制在200字以内比较合适过长反而会让模型抓不住重点。2.2 技能Schema三层结构声明、参数、返回为统一所有技能的调用方式我设计了一套三层Schema规范所有技能都必须按这套结构注册否则无法被加载进技能中心。第一层是技能声明包含技能的ID、名称、版本号、描述、标签、超时时间、技能类型。其中版本号非常重要因为技能会迭代而上层Agent的Prompt可能对某个旧版本行为做了针对性优化只要技能实现变更就可能导致Agent表现突变。我在版本号上强制语义化主版本变更时Agent侧的Prompt缓存必须同步失效。第二层是参数Schema用来定义技能入参的格式和约束。结构上我会用JSON Schema标准每个字段标明类型、是否必填、枚举范围、描述、默认值。如果参数依赖另一个技能的输出则通过特殊标记source_from_prev_step声明。这层直接决定模型能不能生成合法的调用参数。第三层是返回结构规定技能的执行结果如何返回给Agent。返回值必须区分三个部分数据本体、执行状态码、诊断信息。数据本体就是实际结果执行状态码用于判断后续流程是否继续诊断信息则是给开发者看的日志素材避免把过分技术化的异常细节抛给模型。这套三层结构我推荐用Python的TypedDict加Pydantic实现既能做静态检查又能在运行时做严格校验。技能注册的时候先过一遍Pydantic校验不合规的直接拒绝注册从源头杜绝脏数据进入运行时。2.3 参数校验宁可拒绝也不要让模型带着坏参数执行参数校验这个环节很多人容易忽视默认模型应该能理解我的参数要求。但实际线上运行以后你会发现模型传错参数的情况远比你想象的多。最常见的错误有日期格式不对比如模型把2025-03-08传成了04/08/2025枚举值中的某个不在白名单里数值型参数传了字符串100而不是100。我在技能层加了一道统一的参数清洗器在技能真正执行之前先把模型传来的原始参数做一次标准化。清洗规则包括把常见的日期格式全部parse成统一的ISO 8601把数字字符串转成int或float枚举值做模糊匹配比如北京和北京市能映射到同一个城市编码。这样技能内部拿到的基本上就是规范数据省去每个技能自己写防御逻辑。碰到完全无法清洗的参数我的态度是果断拒绝执行并返回明确错误码而不是让技能内部硬着头皮跑下去。因为一旦技能执行了但结果明显不对模型基于错误的输出继续编排后续流程小错误会滚雪球变成大错误排查难度翻倍。宁可让用户看到一次参数错误请重新描述也比后台跑出一串鬼数据强。3. 技能注册与调度核心实现3.1 从硬编码到动态注册技能中心的设计思路第一版技能调用我确实没想太多就是在一个manager.py里写了十几个if-else根据模型返回的工具名去调用对应的函数。这种硬编码方式在小规模时没有任何问题但技能数量一多维护成本立刻爆炸。后来我重构成了技能注册中心思路借鉴了服务注册与发现那一套。技能注册中心维护一个全局技能表结构大概是skill_id到SkillHandler对象的映射。SkillHandler封装了技能的元信息、校验器、执行函数、重试策略。注册方式有三种启动时扫描注册包内自动发现、运行时动态注册通过注册接口、标记为废弃/灰度通过状态控制。运行时动态注册这个能力很关键。我们有两个业务方各自维护了一套独立的Agent应用但他们都需要用到企业微信消息推送技能。我们把推送技能做成一个插件包两个应用启动时各自加载不同环境的鉴权配置通过环境变量注入技能本身完全复用。这个改动直接把跨团队的工具调用统一了不再出现两个团队各写一套企微推送逻辑的荒唐局面。动态注册的代码大概长这样# skill_registry.py class SkillRegistry: def __init__(self): self._skills {} def register(self, skill: BaseSkill, force: bool False) - None: if skill.skill_id in self._skills and not force: raise SkillAlreadyRegistered(skill.skill_id) skill.validate() # 注册前的Schema校验 self._skills[skill.skill_id] skill def unregister(self, skill_id: str) - None: self._skills.pop(skill_id, None) def get_all_skills(self) - list[BaseSkill]: return [self._skills[sid] for sid in self._skills] def get_skill(self, skill_id: str) - BaseSkill: return self._skills.get(skill_id)注册时调用validate()检查技能声明的完整性提前暴露问题。生产环境里我还加了一个技能健康巡检定时任务每隔几分钟探测一批核心技能的心跳比如通过调用/health接口或做一次最小的只读查询一旦发现技能假死就自动标记为不可用让上层调度逻辑跳过它。3.2 技能选择流程意图识别、候选筛选、最终裁决技能注册好之后下一个核心问题就是给定用户的一句话如何从几十个技能里挑出最合适的我把这个流程拆成了三阶段。第一阶段是意图粗筛。这个阶段不用LLM做语义匹配而是用内置的检索器把用户原始输入和技能的关键词、标签做基于向量和关键词的混合检索召回Top K个候选。为什么不用LLM直接选因为把几十个技能的描述全塞进Prompt里token消耗大不说模型在这种分类决策场景下会受上下文长度影响而变得不稳定。检索器便宜、快、稳定在这个环节比LLM表现更可靠。第二阶段是LLM细粒度裁决。拿到Top K候选后把候选技能的精简描述拼进Prompt让模型从中选一个最匹配的。因为候选只有三到五个模型的压力小得多命中率会高很多。第三阶段是参数生成。选定了技能再让模型根据用户的输入生成结构化参数。这里有个细节参数生成的Prompt和技能选择的Prompt必须分开不能在同一个上下文里既让模型决定用哪个技能又怎么传参数。混合在一起时模型为了首尾呼应往往会在参数里夹带一些原本不属于业务数据的东西。三阶段的延迟分配大概是粗筛20毫秒以内LLM裁决200到400毫秒参数生成300到500毫秒。整体增加不到一秒的调度开销但换来的是技能命中率从82%提升到93%以上这个代价非常值。3.3 多技能编排做一个轻量级的技能执行引擎当任务比较复杂时单技能的调用链还不够需要编排层把多个技能串成一条流水线。我自己实现了一个轻量级执行引擎核心思路非常简单预定义的步骤列表 上下文传递 条件分支 循环上限。步骤列表定义在技能编排配置里每种技能组合对应一个编排ID。上下文用字典对象传递每个技能执行后把结果写入上下文后续技能可以通过字段引用。条件分支通过一个简单的when字段声明比如如果上一步返回的statusok则继续否则走fallback分支。循环则严格控制上限默认最多执行三轮防止Agent在同一个错误分支里打转。编排执行的核心伪代码大致如下# skill_orchestrator.py def execute_workflow(workflow: Workflow, user_input: dict): ctx {user_input: user_input} for step in workflow.steps: skill registry.get_skill(step.skill_id) # 参数来源可以取自用户输入也可以取自ctx中某节点结果 params resolve_params(step.param_mapping, ctx) result skill.execute(**params) ctx[fresult_of_{step.node_id}] result.dict() if not infer_branch(step.branch, ctx): break return ctx这套引擎我刻意没有引入任何外部工作流框架因为Agent编排的复杂度远没到需要重量级工作流的程度。自己维护两百行核心代码反而更容易控制细节。比如步骤间数据格式不匹配时我可以直接写一个轻量的transform函数做映射外部工作流框架虽然功能全面但为了接入它技能Schema反而要做各种适配得不偿失。3.4 超时、重试、降级不可控依赖的兜底策略技能实现中很大一部分是调用外部API而外部API的不稳定性是最让人头疼的。我在技能执行层统一内置了超时控制和重试机制每个技能在注册时可以声明自己的超时阈值和最大重试次数不声明的走默认值超时10秒、重试2次。重试不是简单的重复执行。我实现了带退避的指数重试第一次失败后等待1秒第二次失败后等待2秒第三次失败直接熔断短时间内不再尝试这个技能。同时技能内部如果识别到接口返回的是明确业务错误码比如用户不存在额度不足这不属于瞬时故障不会触发重试直接返回错误给上层编排。降级策略这块是最考验架构能力的。还是拿汇率换算举例主数据源是第三方实时接口如果接口挂了我在技能内部加了一层本地缓存兜底缓存里保存最近一小时内的汇率快照读不到实时数据时用缓存数据继续执行同时在返回结构里标记data_sourcecached让上层知道这次结果不是实时的。这个降级设计在演示Demo时看不出来有什么用但线上真实用户访问时帮我们挡过好多次故障工单。4. 实战拆解给财务分析Agent配一套技能体系4.1 需求梳理与技能拆解过程光讲框架比较抽象我拿之前做过的一个财务分析Agent来完整走一遍技能设计流程你可以直接参考这个思路套用到自己的场景。需求背景是业务方需要做一个面向内部管理者的对话式财务分析助手管理员用自然语言提问例如上个月华东区的营收情况怎么样哪些产品的毛利环比下降了。如果直接用LLM查数据库模型生成的SQL质量非常不稳定特别是涉及多表关联和时间窗口计算时。所以我们决定把分析流程沉淀成技能让模型只负责拆解意图和传参具体SQL和数据计算全部在技能内部完成。技能拆解会议开了两次最终把需求拆成五个技能营收查询、毛利分析、同环比计算、异常预警、报告生成。拆解原则是每个技能只做一件高内聚的事但允许技能之间互相引用。比如报告生成技能会调用营收查询和毛利分析两个子技能。拆解完技能以后还要做一次技能与能力矩阵核对避免技能范围重叠。重点看的是哪些自然语言会被多个技能同时命中如果命中场景过多说明技能的边界没有画清需要合并或重新定义关键词路由规则。4.2 技能实现与注册示例营收查询技能的核心实现大概长这样我把关键逻辑简化了但结构是完整的# skills/revenue_query.py from pydantic import BaseModel, Field from geo_utils import normalize_region from date_utils import parse_month_range class RevenueQueryParams(BaseModel): region: str Field(description销售区域支持华东、华南、华北等) start_month: str Field(description查询起始月份格式YYYY-MM) end_month: str Field(description查询结束月份格式YYYY-MM) class RevenueQuerySkill(BaseSkill): skill_id finance.revenue_query version 1.2.0 description 查询指定区域和月份的营收数据数据源为内部财务数仓返回明细表 tags [财务, 营收, 核心指标] def validate_params(self, params: dict) - dict: parsed RevenueQueryParams(**params) parsed.region normalize_region(parsed.region) parsed.start_month parse_month_range(parsed.start_month)[0] return parsed.dict() def execute(self, params: dict) - SkillResult: cleaned self.validate_params(params) raw query_warehouse( regioncleaned[region], start_monthcleaned[start_month], end_monthcleaned[end_month], ) return SkillResult.ok(dataraw)这里有两个细节值得展开。第一个是validate_params里用了normalize_region函数它会把上海魔都这类输入统一映射成华东。因为LLM传参时一个常见的错误就是区域粒度不统一同一句华东区营收里可能同时出现城市和区域两种粒度。归一化处理之后下层SQL查询就不用再处理这种语义错配。第二个细节是时间范围的解析parse_month_range会处理上月近三个月2025年1月到3月这种自然语言时间表达统一换算成数据库查询需要的起止日期。注册时只需要一行registry.register(RevenueQuerySkill())。但要确保技能类实现了BaseSkill定义的所有抽象方法否则无法通过校验。这个校验机制在生产环境真的帮我挡过不少低级错误比如某个技能忘了声明版本号或者参数Schema里字段名和execute方法的参数名对不上注册阶段直接就被拦下来了。4.3 编排层的具体串联逻辑单技能搞定不了整条分析链路真实场景里用户往往会连续追问上月营收多少、环比变化多少、找出下滑最严重的产品线。这类问题涉及的技能调用链是这样的营收查询 → 同环比计算 → 异常预警 → 报告生成。我把这条链路声明为一个小型工作流配置workflow_id: finance_monthly_review steps: - node_id: revenue skill_id: finance.revenue_query param_mapping: region: user_input.region start_month: user_input.start_month end_month: user_input.end_month - node_id: mom skill_id: finance.mom_compare param_mapping: baseline: result_of_revenue.current_period_total comparison: result_of_revenue.previous_period_total - node_id: alert skill_id: finance.bad_product_alert param_mapping: source_data: result_of_revenue.detail_by_product - node_id: report skill_id: finance.report_generation param_mapping: revenue_summary: result_of_revenue mom_analysis: result_of_mom alerts: result_of_alert这样编排的好处是业务链路完全可视化技能本身不感知上下游是谁替换或新增环节只需要改YAML配置。有一次数据团队说要临时在营收分析链路上加一个收入口径调整步骤我改完配置重启服务就上线了完全没动任何技能内部代码。这个维护便捷性在传统硬编码链路里根本不可想象。但也要提醒工作流配置别往复杂了搞。我见过有些团队硬把编排引擎做成了一个可视化拖拽平台功能看着很唬人实际用起来维护成本极高。技能编排的精髓是够用就好能用配置解决的问题绝不引入新的运行时依赖这是Agent工程里最容易被忽略的克制力。5. 常见问题与排查技巧实录5.1 模型就是不调用技能总在自说自话这是Agent开发里被问得最多的一个问题模型明明看到了技能描述却偏偏不调用而是根据自己的常识直接回答。遇到这种情况先别急着骂模型。先检查一下技能描述是否和Prompt里的条令冲突。我踩过的一个坑是系统Prompt里写了一句你是财务专家请基于常识回答财务问题这句话直接削弱了技能调用的优先级模型觉得自己懂的足够多就懒得调工具了。解决办法是把系统Prompt改成财务数据的判断必须基于实时数据任何涉及具体数字的回答都必须先调用数据类技能用强制性的表达明确工具调用的优先级。或者说给每个技能描述增加如果用户问到营收、成本、毛利等相关问题你必须调用本技能这样的硬性提示。另一个排查点是你所问的问题是否真的符合这个技能的能力范围。模型是有自我判断能力的如果用户问的是大概多少钱模型可能觉得不需要精确查询直接估算更自然。这种情况下要么调整用户侧的Prompt模板把需求往精确数据方向引导要么在技能描述里加上即使问题是估计性的只要涉及具体指标数值也请调用本技能这样的兜底话术。5.2 技能返回了正确答案但模型还是答错这个问题更隐蔽。技能把精确数据返回给模型了但模型在总结时可能自己臆造了几个数字混进去或者读数据时读串行。我曾经遇到一个案例技能返回了营收为1.2亿但模型最后输出时写成了1.2万亿整整差了一万倍。排查后发现问题出在返回结构的字段命名上。技能返回的字段名是total_revenue模型在长上下文里把这个字段和其他指标的数值搞混了。后来我把返回JSON的字段名改成更直白的revenue_yi_and_unit并在返回结构里增加了一层自然语言摘要就是由技能自己生成一句话结论例如华东区上月营收1.2亿元环比增长8.3%要求模型优先引用这句摘要而不是自己重新解析JSON里的数字。这里的原则是不要让模型去做从结构化数据里重新组织语言这件事模型做这种事的正确率和稳定性远低于技能内部模板生成的结果。技能返回的内容应该尽量是半成品答案模型只需要做轻微的改写和衔接。5.3 编排链路死循环或上下文爆炸在编排链路里最常见的两个故障模式是死循环和上下文爆炸。死循环通常是因为技能每次返回的错误信息都不一样模型就会反复尝试不同参数重试同一个技能。我在执行引擎里加了三层保险单技能最大执行次数、单工作流最大步骤数、上下文累积字符数硬上限。一旦触发任何一层立即返回任务执行失败并附上诊断摘要。上下文爆炸则发生在多技能串联时每个技能的结果都塞进对话上下文累积几轮之后上下文窗口就被撑爆了。我的处理方式是每轮技能调用的输出只保留结论摘要结构化数据指针而不是把原始大JSON完整放回上下文。用户需要看详细数据时再通过一个查询详情技能按指针去取。这个结论摘要数据指针的模式我强推它虽然增加了一点实现复杂度但能在很大程度上缓解长轮次对话的上下文膨胀问题让Agent的连续对话能撑得更久。5.4 可观测性建设技能调用日志与错误追踪最后聊一下可观测性。技能层的日志记录我做到了每一次调用都有迹可循埋点包括调用时间、Agent会话ID、用户输入、模型生成的技能ID、原始参数、清洗后参数、执行耗时、返回状态码、返回摘要。这些日志统一打到独立的索引里方便排障时用会话ID一键拉出完整调用链。除了日志在开发环境里我还搭了一个简易的技能调试面板。开发者可以手动选择一个技能、伪造一组参数、直接查看执行结果。这个调试面板看起来不像Agent高级技术但它带来的效率提升是实打实的。以前技能出了问题要先走模型调一遍完全没法判断是模型选错了技能、传参错了还是技能本身执行报错。有了调试面板一键就能把技能实现从模型链路里隔离出来单独验证。如果你的技能数量已经超过10个真的建议花半天时间做一个类似的调试工具投资回报率极高。我个人的一个额外体会是技能的版本管理要纳入严谨的发布流程。同一个技能ID线上跑的是1.1.0开发环境可能已经试了1.2.0的改动。如果不对版本进行强管控经常会出现我本地测得好好的一上生产就不行的尴尬局面。这些教训都是踩了坑以后才学到的写出来希望能帮你避掉这些不必要的折腾。