这段时间我在推进“闪学it”的Agent Skills多平台应用实战项目,核心目标就一句话:让同一套AI能力拆成独立的技能单元,在Web后台、办公IM机器人、移动端和定时任务里都能稳定跑起来。做过这类事的人应该都有体会,单独写一个Agent Demo不难,难的是当你发现Web端要一个结果、机器人要一段富文本、移动端要一个流式返回的时候,同一个逻辑被迫复制了三份,prompt各改各的,工具配置越来越乱。这篇文章我会把整个拆解过程和实操路径完整讲清楚,适合那些已经把Agent跑通但想上多平台、又不想被维护成本拖死的开发者参考。
1. 为什么Agent项目必须拆成Skills:一次多平台重构的复盘
1.1 单体Agent在多平台场景下的失控过程
先说一个我踩过的典型场景。最初我做了一个完整的Agent应用,把大模型对话、上下文记忆、工具调用、权限判断全揉在一起,接在办公IM机器人上。当时想得很简单:用户发消息,Agent自动理解意图,调用工具,返回回答。单平台跑通之后,需求立刻来了——管理后台要一个操作界面,移动端要一个快捷入口,日报定时任务还要自动汇总信息。
这时候问题全部浮出来了。首先是状态管理,IM机器人的对话上下文和Web页面里的会话上下文根本不是一回事,强行复用导致同一个用户在两边的记忆互相错乱。其次是prompt膨胀,为了适配不同的输入格式和输出要求,我在同一个Agent里堆满了条件分支,最终那段system prompt超过两千字,改动一处就牵一发动全身。最要命的是工具调用,有些平台需要异步回调,有些平台只接受同步返回,单体Agent根本没法对不同平台分别处理超时和重试。
复盘下来,问题的根源不是模型不行,而是能力单元的粒度错了。一个完整的Agent是“带状态的对话系统”,但Web按钮、机器人指令、定时任务根本不需要对话状态,它们需要的是“把一段输入变成一段输出”的确定性能力。如果把这些能力从Agent里剥离出来,各自定义好输入输出,再让不同平台去调用,维护复杂度会断崖式下降。这就是我决定把项目重心转向Agent Skills的原因。
1.2 Skills的定义与生活化类比
Agent Skills的核心思想是:把Agent具备的某种能力,封装成一个独立的、可复用的、声明式描述的技能单元。一个Skill不关心调用方是聊天窗口还是HTTP接口,它只接收标准化的输入参数,执行内部逻辑,返回标准化的输出结果。说得直白一点,一个Skill有点像“函数”,但它比普通函数的边界更严格——不仅要有清晰的入参出参,还要有对应的自然语言描述、参数校验规则、超时策略和运行配置。
用一个生活化的类比来理解:做一桌菜需要菜谱,菜谱是完整的应用流程,它规定了先做什么后做什么、放多少盐、焖几分钟;但刀工、火候控制、酱汁调制这些基本功,是可以独立存在的技能。你换了餐厅、换了菜单,这些基本功照样能用。Agent项目同理,完整流程可能因平台而异,但“文本摘要”“关键字提取”“意图识别”“数据查询”这些基本功,应该沉淀成Skills,在任何平台都能被直接调用。
这个思路带来的直接好处是:开发模式从“每个平台单独做一个Agent”变成了“先建设技能库,再在各个平台调用技能”。当有新平台要接入时,不需要重写Agent逻辑,只需要做适配层,把平台的输入格式转成技能标准参数,再把技能输出转成平台展示格式。
1.3 三层架构:技能层、接入层、表现层
实际操作时,我把整个系统分成了三层,这个分层对控制复杂度非常关键。
- 技能层(Skill Layer):所有Skills的注册与实现,完全平台无关,只处理业务逻辑。
- 接入层(Bridge Layer):统一的调用入口,负责鉴权、参数校验、路由分发,以HTTP/SSE接口对外暴露。
- 表现层(Channel Layer):面向具体平台的适配器,把平台的消息格式解析成技能参数,再把技能结果渲染成平台支持的格式。
之所以把平台差异隔离在表现层,是因为平台差异主要集中在消息格式、渲染语法、回调方式、超时限制这些“外围”属性上。技能层一旦被平台细节污染,复用性就没了。所以我在项目里立了一条规矩:技能层代码里不允许出现任何平台关键字,连“IM”“小程序”“Web”这样的词都不准出现在业务逻辑里。
接入层选择HTTP/SSE而不是重型RPC框架,原因很简单:技能调用需要兼容的客户端形态太多了,浏览器、机器人服务器、移动端SDK、脚本工具,HTTP是最通用的协议。流式返回用SSE处理,可以解决长耗时任务在部分平台上的等待超时问题。这套分层方案经受住了后续多个渠道的接入考验,是目前我觉得最稳妥的起点。
2. 技能定义与运行机制:把“会做什么”变成机器可读的契约
2.1 技能注册表:为什么用JSON Schema做契约
技能要能跨平台复用,第一步就是把它描述清楚。我参照软件接口设计的原则,为每个Skill定义了一份注册清单,用JSON Schema描述输入输出。这样无论是人工查看,还是让代码做自动化校验,都能基于同一份契约。
下面是一份技能注册表的示例,我把关键字段都写了注释:
{ "skill_name": "meeting_minutes", "version": "1.2.0", "description": "将会议语音转写文本整理为结构化会议纪要", "input_schema": { "type": "object", "properties": { "transcript": { "type": "string", "description": "会议转写的纯文本内容", "maxLength": 200000 }, "language": { "type": "string", "enum": ["zh", "en"], "default": "zh" }, "include_action_items": { "type": "boolean", "default": true } }, "required": ["transcript"] }, "output_schema": { "type": "object", "properties": { "summary": { "type": "string" }, "action_items": { "type": "array", "items": { "type": "string" } }, "risks": { "type": "array", "items": { "type": "string" } }, "timeline": { "type": "array", "items": { "type": "object" } } }, "required": ["summary", "action_items", "risks", "timeline"] }, "timeout_ms": 30000, "is_streamable": true, "capabilities": ["text_chunking", "semantic_analysis"] }每个字段都不是随意设计的。timeout_ms看起来只是数字,但它直接影响多平台体验:Web端用户可以接受等待,IM机器人如果超过响应时限可能会直接报错,移动端弱网环境下就更敏感。is_streamable对应是否支持SSE流式返回,不支持流式的Skill在IM和移动端会被迫使用等待轮询方案,体验差距很大。input_schema里的maxLength也重要,大模型接口通常有Token上限,提前在契约层声明,比在调用端报错要友好得多。
还有一点需要特别注意:注册表的description不要写得含糊,比如“处理会议文本”这种描述就没有意义。要写清楚这个Skill适合什么输入、不适合什么输入、输出中的字段各代表什么含义。因为这个描述不仅给人看,也会被上游的意图识别模块使用。描述不好,后续做技能组合时路由就会出错。
2.2 运行上下文:Skill不记忆、不感知用户
这是整个项目里最反直觉也最重要的一条原则:Skill不应该维护对话历史,也不该感知用户的身份和会话状态。它就像一个公开的API函数,你给我参数,我还你结果,不带任何记忆。一旦Skill内部悄悄记住了“上次的调用结果”,跨平台复用就彻底完蛋了。
我见过一个团队踩了坑:他们在技能内部缓存了用户的上一轮输入,希望实现“上下文自动连接”的效果。结果同一个用户在Web上问过的问题,跑到IM机器人那边再次触发同一技能时,返回了完全无关的内容。原因是Web端和IM端的会话状态没有共享,Skill内部自以为是地“补全”了上下文。所以我的铁律是:所有上下文信息必须显式作为参数传入Skill,Skill自身保持无状态。需要多轮对话?那是Agent编排层的事,不是Skill的事。
无状态设计的另一个好处是便于缓存和并发控制。相同输入可以安全地复用缓存结果,不需要担心不同用户之间的数据串扰。并发请求也只需要按Skill维度做限流,不需要按用户会话加锁。
2.3 Prompt与模型参数的管理策略
Skills跑在模型之上,prompt和模型参数的管理直接影响输出质量。我的做法是:prompt模板内嵌在Skill里,作为技能的默认配置;但调用方可以通过参数临时覆盖部分内容,比如输出语言、语气风格等。模型参数如temperature、max_tokens则放在调用层控制。
为什么这样拆分?因为不同平台对输出要求的严格程度差异很大。Web后台可以接受模型自由发挥,富有个性的表述反而加分;但定时任务生成的报表如果格式不稳定,下游脚本解析就直接失败。所以我在定时任务调用Skill时,会把temperature压到0.2左右;在IM对话场景则允许0.7以上。
prompt模板的设计也有讲究。我习惯把prompt拆成四部分:角色描述、输入数据、输出格式约束、附加规则。附加规则里明确写“不要输出list以外的字段”“内容超过X字时截断”这类机器可检查的约束。这样模板改动时可以准确定位,不会因为一个业务规则变动而重写整个prompt。
绝对不要做的事:允许客户端直接传“system prompt”覆盖。很多人图省事,让调用方把自定义指令拼进请求里,结果就是技能行为完全不可控,被人注入一段提示词后,输出格式就崩了。我的做法是定义少量可覆盖的“参数化指令槽”,比如output_style、tone,但底层的角色和约束固定。这样既保留了灵活性,又不至于让Skill变成一个谁都能改的裸奔prompt。
3. 从零实现一个跨平台技能:会议纪要整理全流程
3.1 场景设定与需求拆解
为了让整个流程更有实感,我拿一个实际做过的功能举例:会议纪要整理技能。需求来源是团队里每周都有大量例会,语音转写文本少则几千字、多则几万字,读一遍要十分钟,整理纪要又要二十分钟。我们希望做一个Skill,输入会议转写文本,直接输出结构化纪要,包含结论摘要、待办事项、风险点和时间线。
最初的需求描述就一句话:“帮我总结会议内容”。这句话信息量太小。我拆解之后,明确了四个输出板块:
- 结论摘要:全会的核心结论,不超过300字;
- 待办事项:每项待办包含负责人、截止时间、具体任务;
- 风险点:讨论中暴露的风险或阻塞点;
- 时间线:会议关键事件按时间顺序排列。
这四类输出对应到业务侧的实际使用场景。摘要给管理层快速浏览,待办下发给执行人,风险点同步给项目组,时间线用于回溯。需求拆到这个粒度,Skill的输入输出契约就能定下来了,也就是上一节那张注册表。
3.2 输入预处理:文本切片的实现逻辑
大模型对单次输入长度有限制,几万字的转写文本不能直接塞进去。所以这个Skill的第一步必须做文本切片。这里的核心不是死板地按字数切,而是按语义边界切。
我实现了一个简单的切片方法:先把文本按标点符号切分成句子,然后累计句子长度,当累计长度接近预设阈值时,优先在段落边界或语义完整处切开。这里有一个关键参数——切片重叠。如果完全无重叠,相邻切片之间的语义关系会断裂,影响后续汇总的质量。我通常设10%到15%的重叠比例,让前后两个片段在衔接处有部分重复信息。下面是参考实现:
def chunk_text(text, max_chars=8000, overlap_ratio=0.1): sentences = split_by_punctuation(text) chunks = [] current = [] current_len = 0 for sentence in sentences: sentence_len = len(sentence) if current_len + sentence_len > max_chars and current: chunk_text = "".join(current) chunks.append(chunk_text) overlap_len = int(max_chars * overlap_ratio) current = trim_tail_to_len(current, overlap_len) current_len = sum(len(s) for s in current) current.append(sentence) current_len += sentence_len if current: chunks.append("".join(current)) return chunks切片之后,先让模型对每个切片生成独立摘要和要点,再把所有切片的摘要汇总成一份最终纪要。这种“先分段总结再汇总”的流程,比一次性塞入全部文本稳定得多,也让输出信息密度更高。代价是Token开销会多出约20%,但对纪要质量要求高的场景完全值得。
3.3 模型调用封装与流式输出
模型调用我走的是一个统一模型网关,没有在Skill里直接写死某个SDK。这样切换模型版本、做降级方案都更灵活。Skill通过一个call_model函数向网关发送请求,网关根据配置决定路由到哪个具体模型。
对于长文本处理,耗时通常超过30秒,所以技能接口从一开始就支持SSE流式输出。流程是先返回一个事件流,逐步推送“切片处理中”“汇总生成中”“完成”等阶段状态,最后推送完整结果。这样做有三个实际好处:第一,移动端和Web端可以通过进度状态给用户反馈,而不是干等;第二,IM机器人可以在超时前先回复一句“我正在处理,预计需要一分钟”,再异步推送结果;第三,服务端可以实时记录处理进度,排查问题时不需要盲猜卡在哪个阶段。
// 前端通过SSE接收技能执行状态 const source = new EventSource('/api/skills/meeting_minutes/invoke?task_id=abc123'); source.onmessage = (event) => { const data = JSON.parse(event.data); if (data.status === 'complete') { renderMinutes(data.result); source.close(); } else { updateProgress(data.status); } };有一点值得提醒:SSE在部分网络代理环境下会被缓冲,导致客户端迟迟收不到事件。我在接入层对SSE响应加了显式的刷新头和禁止缓冲配置,同时在客户端做兜底——如果连续15秒没有收到任何事件,就自动切到轮询接口查询任务状态。这个兜底在移动端弱网环境下帮了大忙。
3.4 技能注册与自动化验证
实现完毕之后,把Skill注册到注册中心,同时写一份自动化验证集。验证集包含三类样本:短文本、超长文本、空输入和异常输入。每次更新Skill后,跑一遍验证集,比较输出结构是否符合schema、关键信息是否丢失、处理耗时是否在预期范围内。这一步非常值得投入,因为它能在多平台接入之前就把大部分回归问题挡在门外。
我还加了一个快照对比机制:把验证集的输出结果存下来,下一次运行后自动对比关键字段的差异。模型输出天然有随机性,所以对比不追求逐字一致,而是检查结构完整性。一旦发现某个必填字段缺失或者出现非法枚举值,就说明prompt或解析逻辑出了问题。
4. 多平台接入的四种典型形态:从Web到定时任务
4.1 接入层的统一网关设计
平台接入的第一步,是搭建一个统一网关。这个网关做的事情看起来简单,实际很容易做错:它不只做请求转发,还负责鉴权、参数校验、配额控制、请求日志。我在网关层为每个调用方生成一个trace_id,整个链路上的日志都带着这个ID。多平台排查问题的时候,请求日志一拉,就能看到同一个任务从哪个平台进来、耗时多少、哪一步出错。
网关对外暴露两类接口:同步调用接口和异步任务接口。同步调用适合耗时短、用户在线等待的场景;异步任务接口返回一个task_id,适合处理耗时长、需要轮询或回调的场景。会议纪要这个Skill两类接口都需要,所以我为它单独设计了“提交任务→查询进度→获取结果”的接口组合,而不是简单只用同步POST。
4.2 Web后台:最标准但也最容易忽略超时
Web后台的接入看起来是最简单的,前端直接用AJAX或者Fetch调用网关即可。但有一个问题我一开始没注意:部分Web服务器和网关的默认超时设置只有30秒,而会议纪要处理经常超过这个时间。如果你用同步接口,前端就会收到一个网关返回的超时错误,但实际任务还在后端跑。
解决办法有两种,一种是用异步任务加轮询,另一种是把同步接口的超时时间调大。我的建议是优先用异步任务模式,因为同步长连接会占用网关的连接池,高并发下容易拖垮整个网关。Web后台配合进度条展示任务状态,体验反而更好。
4.3 办公IM机器人:格式转换是最大坑点
办公IM机器人的接入是我踩坑最多的环节。表面上流程很清晰:机器人收到消息→解析指令和参数→调用网关→把结果返回聊天窗口。但实际做的时候,平台之间的富文本语法差异让我头疼了很久。有的平台用Markdown,有的平台用私有标签,还有的平台对消息长度有严格限制。
给一个具体教训:我在某个平台群里发会议纪要,直接用了统一生成的Markdown表格,结果在客户端里表格完全乱掉,变成一行行挤在一起的文本。排查到最后发现,那个平台要求表格必须用特定的卡片语法,而且单条消息不能超过一个定长范围。解决方式是在表现层增加一个按平台注册的渲染器,每个平台选择自己的渲染模板,技能输出的JSON结构保持不变,渲染完全隔离在表现层。
IM场景还有一个特殊需求:异步主动推送。用户提交任务后,机器人先回复“已收到,正在整理”,处理完成后通过WEBHOOK或者主动消息接口把结果推送到聊天窗口。这个能力需要在接入层注册回调地址,并保证回调消息能对应到原始会话。我建议在所有异步任务结果里都携带原始消息的ID,这样即使回调延迟也不怕找不到上下文。
4.4 移动端与小程序的轻量接入
移动端和微信小程序的接入思路相似,核心限制是网络环境不稳定、前后台切换频繁。我把移动端调用方式设计成:发起异步任务→本地轮询或通过推送通知接收结果。移动端不做SSE长连接,原因是公共Wi-Fi和运营商网关对长连接不友好,断线恢复的成本比轮询高得多。
我给移动端封装了一个轻量SDK,核心只有三个函数:提交任务、查询状态、获取结果。SDK内部自动处理token刷新和失败重试。重试策略用的是指数退避:第一次失败等1秒,第二次等2秒,最多到10秒,连续重试三次后放弃,并明确提示用户检查网络。这套逻辑不复杂,但很实用,避免了一哄而上的重复请求打爆网关。
还有一个细节:移动端请求往往以用户的身份发起,服务端需要根据用户身份做权限控制。我在网关层把用户身份和Skill的授权绑定,确保用户只能调用他有权限的技能。这个在Web端也许无所谓,但移动端一旦放开,很容易出现越权调用的问题。
4.5 定时任务:把技能当成命令行工具
最后一类平台是定时任务。比如每天早上九点自动总结昨天所有未读会议。定时任务其实是最简单的接入方式,因为不需要交互界面。我写了一个命令行工具,直接传递JSON参数调用技能接口,把结果写入指定文档或数据库。
特殊之处在于定时任务对输出格式的稳定性要求极高,因为后续管道程序要解析结果。我把定时任务的调用参数固定设置成低temperature、严格schema校验,并且关闭所有自由发挥的选项。同时增加了失败重试和告警:如果技能连续三次调用失败,就发一条告警到运维群。不要觉得这是小题大做——定时任务失败往往意味着一批数据缺失,越早发现损失越小。
5. 踩坑实录:跨平台技能的五个高频问题与排查思路
5.1 问题速查表
我把这个项目里遇到的典型问题整理成了速查表,方便直接对照排查:
| 问题现象 | 可能根因 | 排查方法 | 解决建议 |
|---|---|---|---|
| 同一技能在Web正常,在机器人里输出乱码 | 平台富文本渲染语法不兼容 | 对比原始JSON输出和平台渲染后文本 | 按平台注册独立渲染模板 |
| 移动端请求频繁中断,技能结果丢失 | 异步任务状态没有持久化 | 查看任务表中task状态变迁 | 任务状态落库,支持断点续查 |
| 长文本输入偶尔触发网关超时 | 同步接口连接占用过长 | 查看网关超时设置和后端耗时统计 | 切换到异步任务+轮询模式 |
| 定时任务产出格式不稳定 | temperature过高或prompt约束不足 | 对比多批次输出结构完整率 | 压低temperature,加强输出schema校验 |
| 某个平台占用全部技能调用配额 | 缺少分平台限流 | 查看网关访问日志平台分布 | 按平台维度设置独立配额与优先级 |
5.2 三个印象深刻的实战复盘
第一个印象深刻的问题是IM平台的换行吞掉事件。现象是长文本纪要发送到聊天窗口后,所有换行符消失,整个内容揉成一大团。排查发现不是技能的问题,是平台在渲染时对连续换行的处理规则不同。解决方式是针对该平台把结构化输出改成卡片式分段渲染,而不是单条长文本。这个问题的教训很简单:越是看起来“应该通用”的文本格式,越要在每个平台上实测渲染效果。
第二个问题是移动端弱网环境下,SSE流被运营商网关缓冲了整整四十秒,用户那边完全看不到任何进度。后来我增加了客户端15秒事件监听兜底,一旦发现长时间没有消息,就切轮询。这个案例告诉我们,技术方案的取舍要结合真实网络环境,不能只看实验室状态。
第三个问题比较隐蔽:某天定时任务忽然大量失败,查了半天发现是有人在另一个平台上手动调用了大量高耗时技能,把模型网关的并发配额耗尽了。定时任务这种“夜间管道”优先级不高,直接被排队超时。解决方式是给网关加上按平台和按时段的双层配额控制,定时任务使用独立配额池,确保核心管道不受突发流量影响。
5.3 关于稳定性的三条心得
经过这些实战,我沉淀出三条稳定性经验。
第一,所有Skill调用必须记录trace_id,并且日志里包含输入长度、耗时、输出结构校验结果。这套日志体系在问题排查时远比看代码高效,因为多平台问题往往不是逻辑错误,而是环境差异。
第二,输出结构必须做硬性校验。即使模型返回的字段不全,也要由接入层补全成一个合法结构,缺失字段用空值或者错误码代替,而不是让异常直接抛出。这样上游组件不会因为字段缺失整个崩溃。
第三,任何新平台接入之前,先做一次“最小可用链路”验证。只跑一个最简单的技能,比如输入一句话返回一句话,确认整条链路通了之后,再接入复杂技能。这能避免把平台适配问题和技能逻辑问题混在一起排查。
6. 往后怎么走:技能组合、内部技能市场与成本优化
6.1 从单个技能到技能编排
当技能数量多起来之后,下一个自然的方向是技能编排。比如一个“智能会议助理”流程,需要会议纪要技能、待办提取技能、风险提醒技能按顺序配合。编排层可以简单做成一个工作流定义文件,声明每一步调用哪个Skill、输入输出如何衔接。
我建议编排结果本身也应该是一个“技能”,它拥有自己的输入输出契约,内部调用底层技能。这样对上层完全透明,比如我编排了一个“周报自动生成”技能,内部调用会议纪要、任务统计、日报汇总三个技能,但调用方只需要传一个时间范围,拿到的就是完整周报。这种递归分层让能力复用的粒度可以一直往上生长。
6.2 内部技能市场与可观测性
技能数量达到几十个之后,团队内部需要一个技能市场来管理。每个技能有独立的版本号、维护人、调用量统计和健康状态检查。这个市场不一定要做得特别复杂,一个支持检索和查看详情的内部管理界面就够了。核心是强制所有技能经过注册才能被调用,避免有人绕过网关直接调用底层模型。
可观测性三件套要配齐:日志用于排查单次调用问题,指标用于监控技能成功率和耗时趋势,链路追踪用于分析一次编排调用涉及哪些技能。我特别推荐关注两个指标:p95耗时和输出结构校验失败率。前者反映用户体验,后者直接对应模型输出质量。只要这两个指标稳定,技能整体就不会出大问题。
6.3 成本与性能的优化空间
Skill架构还有一个隐形优势:成本优化可以按技能粒度做。会议纪要这种高Token消耗技能,可以优先使用高性价比模型;而意图识别这种简单技能,用小模型就能跑得很好。在模型网关层配置好每个Skill可接受的模型范围,并支持按调用方区分,就能在成本和效果之间做精细平衡。
另外一个实用优化是结果缓存。对于相同输入、相同参数、版本未变化的调用,直接返回缓存结果。我发现会议纪要这种技能,同一个会议转写文本经常被多个人重复调用,缓存命中率能到三成以上。缓存的key设计很简单,就是技能名加参数哈希。缓存时注意设置合理的失效时间,避免模型升级后旧版本结果一直在被用到。
最后是流式处理和并行切片的优化空间。切片之间相互独立,可以并行调用模型网关,减少总体耗时。我在实际项目中把串行切片汇总改成了并行处理,再统一汇总,会议纪要技能的整体耗时从近一分钟降到了三十秒左右,体感提升非常明显。并行度设置需要根据模型网关的并发配额来定,一般控制在2到3个并行请求比较稳妥。
关于这个项目,我个人最大的体会是:Agent项目烂尾的常见原因往往不是模型能力不够,而是能力单元没有边界。如果你一开始就追求“一个智能体干所有事”,那后期每个平台都要维护一坨自动化程度很高但没人敢动的代码。先把一个技能做到能跨平台稳定运行,再考虑编排和组合,这条路跑起来会顺很多。另外有个小技巧分享给准备动手的人:每个Skill的输出结构里都加一个status字段,成功是success,失败就返回结构化的错误码和原因。这个字段能让你在多平台排查时少浪费大量时间,因为上层渠道只需要按状态字段做展示分支,不需要自己try catch解析各种异常文本。