
Skill 体系这四个字我在过去一年里被问过不下二十次。问的人有做 AI Agent 的、做低代码平台的、做流程编排的还有纯粹想把内部工具接口统一管理起来的。大家的痛点非常一致业务能力散落在各个 Service、各种 Controller 里想调用没标准想组合走不动想开放给大模型更不知道从哪下手。一套设计良好的 Java Skill 体系本质上解决的是业务能力如何被标准化描述、注册、发现、执行和组合这一整套工程问题而不是简单抽象一个接口了事。这篇文章我想把这套体系的完整设计思路、模型定义、代码骨架和落地过程中的坑一次性讲清楚。1. 业务能力为什么非要技能化三个最典型的痛点场景1.1 没有技能化的日子接口无法描述、能力不可发现很多人对 Skill 体系的第一反应是不就是再包一层接口吗。真不是。我先描述一个没有技能化之前的日常公司内部有个工单系统A 团队要做查询工单状态直接在 Controller 里写了一个/ticket/statusB 团队要做批量催办复制了一大段工单查询逻辑自己加了个循环C 团队做智能客服想查工单信息拿到的是完全不一样的参数结构和返回格式。这三个团队各写各的接口无法被统一描述能力不可被自动发现。每当有人想复用某个功能唯一的方式是翻代码、看文档、问同事。而真正逼我动手设计 Skill 体系的是三个叠加的硬需求AI 大模型开始进入内部系统需要一个统一入口让模型工具调用落到真实业务功能上低代码流程引擎需要把原子动作沉淀为可拖拽节点多个业务线要求统一操作口径不能同一件事十种写法。所以技能化的第一价值不是好看而是让业务能力从代码里的私有方法变成平台上的一等公民。它必须自带名字、描述、参数定义、权限要求、执行逻辑这套完整信息才能支撑后续的发现、调用和编排。1.2 Skill、Tool、Command 的区别别把概念搞混设计之前先把概念边界划清楚。现在业界对技能的称呼特别杂OpenAI 那边叫 Function CallingClaude 生态叫 Tools 和 Skills工作流引擎里叫 Action我们内部统一叫 Skill。我理解的定位是这样Skill一个完整的、可独立执行的能力单元自带描述和参数契约比如查询工单状态发送告警通知计算两个业务指标的相关性。Tool偏底层的基础操作比如 HTTP 请求、读取数据库、执行脚本。Skill 可以基于 Tool 构建但业务方一般不直接面对 Tool。Command / Action更偏具体命令词或流程中的一个步骤通常是 Skill 的一次实例化调用。这个概念划分直接影响代码设计。如果上来就把所有东西都叫 Skill最后会发现既有业务级技能又有基础设施级技能两者的参数规范、执行约束、权限模型都不一样硬塞进同一套体系只会不断打补丁。我的建议是体系内核统一抽象为可执行单元但对外分别暴露 Skill业务和 Tool基础两套注册入口。后面的模型设计我都按这个思路展开。2. 技能的本质是契约核心模型与接口定义2.1 Skill 接口只留一个 execute 方法其他信息全部交给描述符先给一个最简版本的核心接口这是整套体系的基石public interface Skill { String name(); SkillDescriptor descriptor(); SkillResult execute(SkillContext context); }这里最关键的设计决策是execute的入参和出参都是统一上下文对象而不是强类型业务参数。为什么要这么做因为 Skill 体系服务的场景太多样了——AI Agent 传过来的参数是 JSON Map流程引擎传来的参数是 Key-ValueHTTP 进来的可能还要绑定表单。如果每个 Skill 都定义自己的强类型入参注册中心就没办法做统一校验、统一日志、统一权限过滤。代价也很明显强类型安全没了每个 Skill 内部要自己做参数解析。这个矛盾我在第 4 章专门讲怎么缓解通过注册时的参数 Schema 描述 框架层统一校验把类型安全从编译期保障换成注册期保障 执行期兜底。关于接口要不要支持泛型我也纠结过SkillT, R看起来更酷实际落地很痛苦因为组合编排时你根本不知道下一个 Skill 需要的具体类型是什么泛型只会让注册表失去统一性。所以我最终选了约定优于类型参数进 SkillContext结果出 SkillResult格式在 descriptor 里声明。业务侧真正需要强类型的地方用 Spring 管理的具体 Skill 子类去封装。2.2 SkillDescriptor一份给人看、给机器看、给大模型看的能力说明书如果说 Skill 接口是骨架SkillDescriptor 就是说明书。它至少应该包含这些字段public class SkillDescriptor { private String name; // 技能唯一标识如 ticket_query private String displayName; // 展示名用于低代码画布 private String description; // 能力描述给大模型和开发者理解用 private String version; // 技能版本用于灰度与回滚 private String category; // 分类业务/基础设施/数据查询 private boolean idempotent; // 是否幂等影响重试策略 private long timeoutMs; // 执行超时 private ListParamSchema params; // 参数模式 private ListString requiredRoles; // 所需权限 private String outputSchema; // 输出结构说明JSON Schema 或可读文本 }params里的ParamSchema尤其重要它定义了每个参数的名字、类型、是否必填、默认值、枚举范围、描述。这段信息是后续所有能力的源头校验靠它、文档生成靠它、大模型工具描述也靠它。第一次设计时我犯过两个错误一个是把description写成了人类术语比如查询工单状态的接口大模型根本分不清它和查询工单详情的接口有什么区别另一个是params只写了参数名和类型没有写参数的业务含义导致模型经常把userId和operatorId搞混。后面专门定了一条规则描述必须写清楚这个技能做什么、什么时候用、传入的参数在业务上指什么、输出是什么格式越像写产品说明书越好。2.3 执行上下文 SkillContext把环境能力和业务参数分开SkillContext 的设计直接关系到技能要不要携带 Spring 容器。我采用的方案是上下文里只放两类东西——业务参数表MapString, Object和字典域MapString, Object后者放当前用户、请求 ID、租户 ID、追踪 ID、国籍时区等环境信息。public class SkillContext { private final MapString, Object params new HashMap(); private final MapString, Object attributes new HashMap(); private final MapString, String headers new HashMap(); public String getTraceId() { return (String) attributes.get(traceId); } public Object getParam(String key) { return params.get(key); } public T T getParam(String key, ClassT type) { /* 类型转换 兜底 */ } }这样设计的收益很直接Skill 内部不再到处从 ThreadLocal 或者 RequestContext 拿用户信息执行引擎在调用前统一填充参数表中也不会混入环境变量避免出现用户传了一个 userId结果把框架注入的 userId 给覆盖了的经典事故。这个事故我真实遇到过某次线上技能执行时业务参数把租户 ID 覆盖掉跨租户数据差点漏出去从那以后强制分离参数和环境字典。2.4 参数校验不应该写在 execute 里面把校验提到注册中心很多人的第一版 Skill 是每个技能在自己内部if (param null) throw这样写一百个技能就有一百份校验代码而且大模型在调用前根本不知道哪些参数必填。正确做法是SkillDescriptor 的参数 Schema 驱动注册中心的统一校验器在执行前把参数过滤一遍。for (ParamSchema ps : descriptor.getParams()) { Object value context.getParam(ps.getName()); if (ps.isRequired() value null) { throw new SkillParamException(descriptor.name(), ps.getName(), missing required param); } if (value ! null !ps.getType().matches(value)) { throw new SkillParamException(descriptor.name(), ps.getName(), type mismatch); } }这个前置校验非常关键对于 AI Agent 场景参数错误可以在调用前就被捕获并返回给模型模型就能根据错误提示自行修正参数再重试而不是让技能执行到一半才报错。实际观测下来加上统一参数校验后Agent 场景的首次调用成功率提升了近 30 个百分点。3. 技能注册与发现新增一个技能要像写注解一样简单3.1 注册表模型读多写少用 ConcurrentHashMap 版本覆盖技能注册中心本质是一个名字到技能定义的映射。运行期技能数量通常几十到几百远没到需要专门存储的程度一个 ConcurrentHashMap 就能扛住。但有几个细节必须处理注册冲突同名技能重复注册时是覆盖还是拒绝我建议启动期严格校验运行期动态注册允许覆盖并记录审计日志。分类索引技能多了之后查找和分组需要按 category、tag 做索引不能让调用方把全表拉下来自己过滤。数据版本每次注册带上 version注册表维护当前生效版本和历史版本列表便于回滚。Component public class SkillRegistry { private final ConcurrentMapString, SkillDefinition skills new ConcurrentHashMap(); private final ConcurrentMapString, ListSkillDefinition history new ConcurrentHashMap(); public void register(Skill skill) { SkillDefinition def SkillDefinition.from(skill); SkillDefinition old skills.putIfAbsent(def.name(), def); if (old ! null) { throw new SkillConflictException(duplicate skill: def.name()); } history.computeIfAbsent(def.name(), k - new CopyOnWriteArrayList()).add(def); } public SkillDefinition get(String name) { return skills.get(name); } }注意上线初期我图省事重复注册时直接覆盖结果某个测试技能把生产技能覆盖掉线上数据查出来全是错的。后来一律改为 putIfAbsent只有显式声明 replace 才允许覆盖。3.2 注解扫描注册Spring Boot 场景下最舒服的姿势有 Spring 环境时让开发者写一个注解加一个实现类就能注册是体验最好的方案。定义一个SkillMeta注解放在核心 Skill 实现类上Target(ElementType.TYPE) Retention(RetentionPolicy.RUNTIME) public interface SkillMeta { String name(); String displayName(); String description(); String version() default 1.0.0; boolean idempotent() default false; }然后在启动器里扫描容器中所有带SkillMeta的 Bean自动装配进注册中心。装配时用 Spring 的ApplicationContext.getBeansWithAnnotation即可不需要自定义类扫描器Component public class SkillAutoRegistrar implements ApplicationRunner { Autowired private ApplicationContext ctx; Autowired private SkillRegistry registry; Override public void run(ApplicationArguments args) { MapString, Object beans ctx.getBeansWithAnnotation(SkillMeta.class); beans.values().stream() .filter(Skill.class::isInstance) .map(Skill.class::cast) .forEach(registry::register); } }有的团队喜欢用 Spring 的Component自带别名Component(ticket_query)来做技能名我不推荐。组件名和技能名语义不同而且Component名如果冲突会直接导致 Spring 启动失败无法给注册中心留出自定义冲突处理空间。3.3 不依赖 Spring 的 SPI 方案让 Skill 体系保持内核中立如果 Skill 体系要下沉成中间件不能要求所有接入方都依赖 Spring。这时候用java.util.ServiceLoader做 SPI 是更干净的选择定义一个SkillProvider接口由各业务方在META-INF/services下声明实现类注册中心负责加载。public interface SkillProvider { ListSkill provide(); }public class SpiSkillLoader { public ListSkill load() { ListSkill result new ArrayList(); ServiceLoaderSkillProvider providers ServiceLoader.load(SkillProvider.class); for (SkillProvider p : providers) { result.addAll(p.provide()); } return result; } }实际项目里我推荐的架构是核心 Skill 引擎完全零依赖Spring Boot 集成作为独立模块存在。这样普通 Java 项目可以单独引用核心包Web 项目再引入 Spring 适配包。这个分层让体系的推广阻力小很多毕竟不是所有下游项目都愿意为了技能化而强行引入 Spring。3.4 静态注册 vs 动态注册哪部分用启动期哪部分用运行期技能注册时机有两种启动期静态注册和运行期动态注册。我落地时的策略是核心业务技能全部启动期静态注册必须启动失败时暴露同名冲突等问题平台型、插件型技能允许运行期动态注册但要经过审计、版本校验和限流保护。静态注册牺牲了一点灵活性换来的是稳定性。所有技能在应用启动时一次性加载注册表只读并发问题最少如果某个技能没注册成功启动直接报错不会出现线上请求到了才发现技能不存在。动态注册服务单独给我处理一类场景运营同学通过配置平台上报了一个新的计算规则我们不希望为每个规则重新发版重启而是通过一个 Admin 接口把技能推送到各节点。这种场景下推送接口需要做多节点同步否则只有一台机器的注册表里有新技能负载均衡会把请求打到没有该技能的节点上。这块我们最终是走配置中心广播机制解决的。4. 技能执行引擎统一入口的并发、超时、重试与安全4.1 SkillExecutor所有技能只从一个门口进出既然技能被注册到了注册中心执行就应该统一收口到SkillExecutor。这个类干了四件事根据技能名取出定义、做前置校验、执行技能、做后置处理。它的存在让所有横切逻辑有了挂载点Component public class SkillExecutor { private final SkillRegistry registry; private final SkillValidator validator; private final SkillMetrics metrics; private final ThreadPoolManager poolManager; public SkillResult execute(String skillName, SkillContext context) { SkillDefinition def registry.get(skillName); if (def null) { throw new SkillNotFoundException(skillName); } validator.validate(def, context); // 参数校验 权限校验 SkillCallable callable () - def.skill().execute(context); FutureSkillResult future poolManager.getPool(def).submit(callable); try { return future.get(def.timeoutMs(), TimeUnit.MILLISECONDS); } catch (TimeoutException e) { future.cancel(true); throw new SkillTimeoutException(skillName, def.timeoutMs()); } } }注意这里用def.timeoutMs()而不是全局统一超时。不同技能耗时差别巨大查缓存可能 50ms查大数据分析可能要 5 秒统一超时要么误杀长任务要么让短任务长时间挂起。把超时写入技能定义里每一种技能按自己的能力申请时间预算是技能设备化之后自然而然的能力。4.2 线程池隔离绝对不要让技能之间互相拖垮技能执行用线程池但不能用同一个线程池。否则一个写文件的技能被磁盘卡住所有查询类技能都得排队。我的方案是给每个技能按category分配独立的线程池组至少分三类分类线程池配置示例适用场景fast-querycore 20, max 50, queue 200, timeout 500ms缓存查询、配置读取slow-iocore 8, max 20, queue 100, timeout 5s数据库报表、外部调用audit-writecore 4, max 10, queue 50, reject 策略日志落库、审计记录这种隔离的问题是多占了一点线程资源但对稳定性收益巨大慢技能撑爆自己线程池时只会触发它自己的拒绝策略不会阻塞整个系统的查询能力。4.3 幂等设计和重试策略读操作可以重试写操作只能提醒技能自动重试一定要区分幂等和非幂等。SkillDescriptor里的idempotent字段这时候起作用幂等技能如查询在超时或异常时可以自动重试 2~3 次非幂等技能如创建工单转账扣款原则上不自动重试而是返回错误提示并记录上下文由上层决定是否人工介入。这里再强调一个细节判定幂等不能只看代码有没有返回值要看业务含义。同一个技能创建工单如果没有用 requestId 做幂等键调两次就会产生两张单子这时候哪怕源码再天然幂等也不能自动重试。我建议所有非查询类技能在设计时必须声明一个idempotencyKey的隐含参数由执行引擎统一向业务层透传业务侧在 DB 表上用唯一约束去兜底。4.4 我踩过的坑异步技能返回结果的三种方式技能执行有的是一次性同步调用有的要异步轮询或回调。比如发起大数据计算任务这个技能同步执行两秒后返回的是任务已提交任务 ID 为 xxx而不是最终计算结果。很多新手把这类技能设计成内部阻塞轮询直接把执行线程挂 N 分钟。正确做法是定义三种出参语义直接结果技能执行完就是最终答案如 查询工单状态 - 已关闭。任务引用技能返回任务 ID 和查询方法名由上层再调一个查询任务结果的技能。异步回调参数里带一个 callbackUrl技能执行完框架自动回调。在 SkillResult 里加一个kind字段标识这三种语义并在描述文档里写清楚。这样 Agent 场景下模型看到任务引用返回就能自己提出我来查一下任务结果的下一步动作整个协作链路才通。5. 技能编排从单技能调用到复杂任务的组合执行5.1 最简单的编排顺序执行和条件分支光有单个技能还不够实际业务往往是多步动作。比如告警自动处理这个流程先查询告警详情再根据告警级别决定是发通知、创建工单还是直接忽略。用代码写编排的话最直接的是在编排器里按顺序调用三个技能public class AlarmHandleFlow implements SkillFlow { Override public SkillResult run(SkillContext ctx) { SkillResult detail executor.execute(alarm_query, ctx); if (!detail.isSuccess()) { return SkillResult.failure(alarm_query failed: detail.getMessage()); } String level detail.getData(level); if (P0.equals(level)) { return executor.execute(ticket_create, ctx); } else if (P1.equals(level)) { return executor.execute(notify_send, ctx); } return detail; } }但这种原生代码编排有两个问题一是流程逻辑藏在代码里业务人员看不到二是每加一个分支都要改代码。如果你只需要内部用代码编排完全够用如果要低代码化和可视化就得做配置化编排。5.2 配饰化编排Flow DSL 的字段设计思路做配置化编排不一定非要上 DAG 引擎这种重武器。很多团队只需要一个简单的节点数组就能解决 80% 场景{ name: alarm_auto_process, nodes: [ { skill: alarm_query, next: node_2 }, { skill: branch_level, type: condition, rules: [ { when: ctx.level P0, next: ticket_create }, { when: ctx.level P1, next: notify_send }, { else: end } ]}, { skill: ticket_create, next: end }, { skill: notify_send, next: end } ] }字段设计上最重要的原则是每个节点的输出都要有明确的变量名不能靠隐式上一个节点的输出。多分支场景下两个不同分支的输出可能都会进入同一个下游节点如果不显式声明bind: { level: $alarm_query.level }编排引擎根本无法知道该拿哪个参数。5.3 网关节点的作用技能编排和流程引擎的边界做编排时没必要自己实现完整的流程引擎。判断标准很简单你的节点类型是否包含排他网关、并行网关、子流程如果有直接引入成熟流程引擎比如集成 Activiti 或 flowable 的思路更稳Skill 体系只需要提供服务任务节点的适配器。如果只是顺序、分支、聚合自研轻量编排器就够了。我把边界划得很清楚Skill 负责做一件事Flow 负责按顺序做多件事FlowEngine 负责怎么从 A 节点走到 B 节点。三者各司其职不要想着让一个 Skill 内部去编排别的 Skill。嵌套编排引子编排器即技能是一种很诱人的架构但调试时痛苦程度翻倍——技能 A 的日志里永远找不到技能 B 的执行细节。5.4 编排模板的版本管理Flow 也要有版本很多团队给 Skill 做了版本却忘了 Flow 也要版本。一次告警处理流程的变更可能是业务流程变化也可能是各地域执行策略不同。Flow 定义里必须带version并且在流程实例化时锁定版本一旦有流程实例开始执行它一直按启动时刻的版本走完不能被中途更新篡改。这个机制和技能版本回滚配合起来流程版本指向技能版本技能回滚后该流程版本下的执行行为保持一致。这样排查问题的时候能得到完全确定的执行路径而不是今天跑的是新版本明天又变老版本。6. 面向 AI Agent 的适配把 Skill 自动变成大模型能看的工具描述6.1 SkillDescriptor 到 Function Calling 的自动映射这套体系搬到 AI Agent 场景时有一个巨大优势SkillDescriptor里的字段几乎就是为了生成大模型工具描述而设计的。一个技能对应一个 function definition{ type: function, function: { name: ticket_query, description: 根据工单 ID 查询工单当前状态、负责人和最近一次流转记录。适用于客服快速核单、流程查询等场景。, parameters: { type: object, properties: { ticketId: { type: string, description: 工单的唯一编号格式以 TK 开头例如 TK20240513001 }, includeLogs: { type: boolean, description: 是否包含流转日志默认 false仅填 true 时会返回最新一条日志 } }, required: [ticketId] } } }如果团队自己对接大模型直接拿SkillDescriptor生成 JSON Schema如果用了 Spring AI 这类封装框架也只需要做一个FunctionCallback适配器把工具描述转换成框架需要的格式即可。6.2 类型映射的坑Long、枚举、Date、LocalDateTimeJava 对象和大模型工具的 JSON Schema 映射有个很容易踩的坑。Java 里的Long尤其是超过 2^53 的雪花 ID在 JSON 里如果不加处理传给大模型之后就是个有精度问题的数字LocalDateTime在默认序列化后是数组大模型看了根本不知道怎么填枚举只在定义时写enumClass但不展开枚举值模型就会花样传错。我的适配规则是Java 类型映射为 JSON Schema 类型备注Long / longinteger但额外标注format: int64必要时用字符串承载雪花 ID避免精度丢失LocalDateTimestringformat: date-time统一 ISO8601示例值写2024-05-13T10:00:00枚举stringenum: [...]从枚举类反射读取所有枚举值写进 schemaMapString, Objectobject内部字段如果不确定只写描述不要写死 properties这个映射不是一次性写完就结束每次技能注册时都应该自动重新生成不能手工维护。手工维护工具描述的场景我见过太多最终下场都是技能改了描述没改模型按旧描述传参执行器报参数错误。6.3 技能描述怎么写大模型是读说明书用工具不是猜你的业务把 6.1 的例子和最开始那段查询工单状态的接口对比一下就知道描述差在哪。好描述至少包含三层信息技能做什么、在什么时机使用、参数在业务上的含义。此外写清楚不要在什么时候用同样重要。比如一个技能支持查询工单但不支持修改描述里就明确写一句本技能只读用于查询如用户要求修改工单请告知无权操作。这能显著减少模型误调用。我建议在技能描述规范里做一份模板要求技能提供者回答五个问题功能是什么输入必填什么输出有什么典型错误场景是什么和它相近的技能有什么差别第 5 个问题尤其能治模型分不清 query 和 list 两个技能的老毛病。6.4 组合技能在 Agent 场景的两种用法当 Agent 场景开始复杂化之后编排的价值就出来了。我的经验是有两种用法第一种叫技能包就是把一组技能打包成一个高维技能暴露给模型模型一次调用技能包引擎内部自动执行一串子技能。比如客户全流程服务这个技能包内部实际是按告警详情 - 工单历史 - 客户偏好分析 - 生成服务建议四个子技能顺序执行的。模型看到的只是一个技能不用关心内部步骤。第二种叫反馈式组合引擎不做组合而是把一个技能的输出结构返回给模型让模型自己决定下一步调哪个技能。这个模式更贴近原生 Agent 思考方式。比如模型先调alarm_query拿到了levelP0模型自己去调ticket_create。此模式下技能的输出描述必须写清楚否则模型拿到结果也不知道接下来该干嘛。两种模式没有绝对优劣技能包更可控、更容易流量管理反馈式组合更灵活、但结果不确定性更高。我们现在的策略是默认技能包遇到规划自由度要求高的场景再开放反馈式组合。7. 上线三个月后我才想明白的几件事粒度、治理与度7.1 粒度太细是灾难一个技能应该是一件有业务语感的事技能粒度是最难拿捏的。往细了分查询工单 ID“查询工单标题”查询工单状态都是独立技能看似灵活实际大模型会在三个技能之间反复试探开发者也不知道该用哪个往粗了分一个查询工单体技能把 30 个参数全接收模型更蒙。我的经验法则是一个技能对应的应该是一个用户会用自然语言说出来、并且能被一句话描述明白的动作。查询工单状态是一句自然的话查询工单 ID也是一句自然的话但查询工单 ID 并顺带看看标题就有点模糊。技能粒度带来的工作量增长不是线性的从 50 个技能涨到 200 个技能治理成本至少翻四倍。所以宁可先粗一点等出现真实拆细需求再拆分。7.2 技能治理谁有权限注册、审核流程怎么走技能注册开放之后必须配套治理规则。我们现在的流程是技能开发者提交注册 - 参数 Schema 格式校验 - 负责人审核描述是否清晰 - 测试环境验证 - 发布到生产注册表。每一步都有时间记录违规注册比如名字冲突、缺少权限声明会被拦截并通知提交人。这听起来很繁琐但没有治理的话注册表会迅速劣化重名抢注、描述残缺、参数含义不清、权限声明错误。最终没人敢用体系就废了。7.3 度量体系让注册中心告诉你哪个技能没人用最后想说监控。技能体系上线后能统计出每个技能的执行次数、失败率、平均耗时、超时次数、被哪个来源调用Agent / 流程 / 手动。这些指标的价值不仅是运维更是治理连续 60 天零调用的僵尸技能会被清理下线失败率高于 5% 的技能会被标记为低健康度倒排研发整改。可用一个简单表格拉出关键监控指标指标采集方式告警阈值技能执行耗时执行引擎统一埋点p99 2s 告警技能失败率按技能名聚合 5% 告警技能调用量按技能名聚合连续 30 天为 0 提醒下线参数校验失败数前置校验阶段统计单日 100 排查超时取消数执行引擎统计单技能 20 次/日 告警这些埋点在做 SkillExecutor 时就能统一接入不需要 Skill 内部再打点。7.4 最后说一个被很多人忽略的策略技能体系不要吞掉业务异常细节执行引擎把所有技能包一层统一调用最常见的副作用是异常信息被规范化后抹掉了细节。比如技能内部真实异常是数据库连接池连接数耗尽引擎只返回一个技能执行失败。这对下游排障非常不友好。我的建议是 SkillResult 里保留三层信息code引擎统一错误码、message面向调用方的友好提示、cause完整异常堆栈或原因透传。在返回给大模型时只给 message 和前几行 cause 摘要避免模型被一堆堆栈搞疯在返回给开发者排障时透传全部 cause。同一个结果对象不同受众看到不同层级。这个细节体现了技能体系的成熟度也是我调试三个月排障顺手了许多的关键原因。写到这里再回头看那套体系其实已经在团队里稳定跑了两个季度从最初 20 个技能扩充到了 100 多个技能覆盖了工单处理、告警响应、数据报表、用户画像查询等核心场景。如果说有什么最重要的经验那就是Skill 体系的设计先别急着写代码把能力描述标准和执行边界想清楚后面所有事情都顺了。技能粒度拿不稳就从最核心的两三个场景动手跑通之后再逐步扩展比一次性铺很大的盘子更值得。希望这篇文章能帮你少踩一些我已经踩过的坑。