做智能体应用一年多了,坦白说,让LLM聊起来从来不是问题,真正折磨人的是让它稳定把活干完。早期我的Agent一直处于一种每天都在打架的状态:工具列表越长,翻车概率越高;Prompt越改越长,最后连我自己都说不清它到底会怎么理解哪一段。直到我把整个体系按技能(Skills)的形态重构了一遍——也就是业界常说的 agent-skills 思路——情况才明显好转。
我把这个过程中的核心落地方案整理出来,围绕 agent-skills 讲清楚几个问题:技能到底解决了什么;一个合格技能长什么样;我是怎么手写一个“会议纪要结构化”技能并挂进Agent的;以及那些翻过车之后才总结出来的排查经验。适合正在做Agent应用、被多工具调度搞到焦头烂额、或者刚开始接触技能工程的开发者往下看。
1. 先搞清楚一件事:Agent Skills到底解决什么问题
1.1 技能是什么:把“会聊天”变成“会干活”的关键一层
很多人理解Agent = LLM + 工具调用,但实际落地时你会发现,中间缺了一层东西。LLM强在推理和语言,弱在没有稳定的“肌肉记忆”。人做PPT之前不需要重新学习什么是PPT,因为那套流程已经内化成本能了;LLM如果没有技能层,每次都要在上下文里重新组织完整步骤,稍有干扰就变形。
技能就是把“完成某类任务的方法、步骤、输入输出契约、示例和注意事项”打包成一个完整单元。模型只做一次决策——这个任务需要调用哪个技能、输入什么、输出怎么接——剩下的执行交给确定性很强的代码。这层设计让LLM从“每次即兴发挥”变成“按套路出牌”,稳定性的提升不是一点半点。
我整理过一张对比表,用来跟团队解释为什么非要做技能层不可:
| 维度 | 全靠Prompt硬写 | 纯Tool原语调度 | 技能层Skills |
|---|---|---|---|
| 执行路径 | LLM每次现场生成 | LLM临场组合原语 | 固定代码执行,LLM只做路由 |
| 稳定性 | 低,改一处崩全局 | 中,工具多了易选错 | 高,可测可回归 |
| 调试成本 | 高,改Prompt影响全部任务 | 中,要查调用链路 | 低,问题收敛在技能内部 |
| 可测试性 | 基本不可测 | 部分可测 | 可以完整写单元测试 |
| 复用性 | 差,全靠复制粘贴 | 有,但太碎 | 强,文档+代码+测试一体 |
表格里“固定代码执行”这几个字是关键。Agent技能的运行不依赖模型在关键时刻“人品爆发”,它把最需要稳定性的那部分从模型中剥离出来,前置到代码层。
1.2 为什么堆Prompt和叠Tool解决不了稳定性问题
我见过两个极端。一种是什么都往系统Prompt里塞,任务步骤、输出格式、业务规则、few-shot示例全堆进去。这种做法在单任务时看起来很爽,但任务一多,Prompt每一处微小改动都是全局性的,很可能这个任务修好了,另一个任务悄悄坏掉。更隐蔽的是,写Prompt的人容易不自觉地把它当成“解释器指令”,可LLM本质上不是线性的解释器,它会自由发挥。
另一种极端是把所有能力都做成Tool,send_email、query_db、search_web、generate_report……工具列表越来越长。Tool是原语级能力,Agent需要自己编排调用序列,工具越多,选择空间就越大。我实测过,工具数量超过15个之后,模型选错工具的概率明显上升,有时候它甚至把两个功能相似的Tool混着用,输出驴唇不对马嘴。
技能层刚好卡在中间。它把多个原语和固定步骤组合成一个高层操作,比如一个“会议纪要结构化”技能内部可能要调用文本切分、LLM抽取、去重合并三个步骤,但对Agent来说,它就是一个名字为 meeting_minutes 的黑盒。15个工具压成5个技能,选择熵大幅下降,模型只需要在抽象层级上做决策,做对的概率自然高得多。
1.3 什么时候需要技能层:三个信号
如果你还不确定自己要不要上技能层,我列几个信号,满足任意一条都值得动手:
- 同一个流程你已经第三次用大段Prompt写了,而且每次写都还要重新调。
- 你发现输出格式怎么正则都修不稳,每次都要额外写后处理代码去补漏。
- 工具调用序列非常固定,但Agent依然每次临场编排,偶尔编排错。
我自己的经验是,只要任务边界清晰、有人反复在做,它就应该被固化成一个技能。固化得越早,后续调试成本越低。
2. 拆解一个成熟技能:SKILL.md、实现代码与测试三件套
业界比较有代表性的技能工程方案,是Antropic提出的“技能即目录”思路:一个技能不是一个孤立函数,而是 SKILL.md + 实现代码 + 测试三件套的组合。我沿用了这个骨架,稍微做了工程化扩展,下面把每一块的作用讲清楚。
2.1 SKILL.md:写给模型看的说明书,比代码注释重要一百倍
先明确一件事:模型不能直接执行你的代码,它只能读文本做决策。所以技能的“路由说明书”必须是一个模型能读懂的Markdown文件。SKILL.md的质量,直接决定模型何时调用、怎么调用、调用后能不能用对。
我写SKILL.md有一套固定模板:
# meeting_minutes(会议纪要结构化) 将一段无序的会议转写文本整理成结构化会议纪要,输出JSON。 ## 何时使用 - 用户提供了会议录音的文字转写、访谈记录,要求整理纪要 - 用户说“帮我记一下会议结论”“整理行动项”“提炼待办” ## 何时不要使用 - 用户只是闲聊,没有提供任何转写文本 - 用户要求调用日历、发送邮件(那是其他技能的职责) ## 输入 - raw_text:字符串,必填,原始转写文本 - meeting_date:字符串,可选,格式YYYY-MM-DD ## 输出 严格JSON对象,字段包括title、date、attendees、summary、decisions、action_items。 ## 示例 输入:...(一条真实转写片段) 输出:...(对应JSON) ## 注意事项 - 不要编造原文不存在的人和事 - 负责人不确定时填“待定”模板里每一块都有目的。“何时使用”和“何时不要使用”是给模型做路由判断的;模型会根据用户输入和这里的描述做语义匹配,触发描述写得越贴近真实用户口语,路由越准。“示例”是给格式化做参考的,模型会把示例当成输出模板,一定要放一个完整且正确的例子。“注意事项”则是行为红线,防止模型在自由生成的环节乱来。
如果你发现技能经常不被调用,八成是SKILL.md写得不清楚,不是模型傻。
2.2 实现代码:纯函数优先,少搞状态
技能的实现代码,我的原则是三个:纯函数优先、无状态、异常信息可读。
纯函数意味着输入输出都是JSON可序列化的数据,不依赖全局变量和外部副作用。为什么?因为技能会被Agent框架以各种方式调用,可能有状态包装、可能有并发,一个带着隐式状态的技能很难排查。把状态外置成参数,调用方想传就传,不传就走默认值。
LLM调用建议隔离成一个单独的内部函数,不要散落在主流程里。这样测试时可以轻松把LLM调用mock掉,只测逻辑。另外错误消息一定要可读,比如“模型输出中未找到JSON”和“字段attendees缺失”,这类信息在技能重试时会被重新喂给模型,写得清楚能帮模型自我纠正。
2.3 测试:给“不太可控”的LLM部分划一个可控边界
有人跟我说Agent技能没法写测试,因为LLM输出随机。这话只对了一半。LLM输出确实随机,但技能的绝大多数代码逻辑是确定性的——分块、解析、校验、合并,这些完全可以单测。真正随机的只有模型调用那一小步,把它mock掉之后,整个技能就是可测试的。
我会写三类测试:单元测试覆盖分块和合并逻辑;集成测试mock掉LLM,给定固定输入断言输出结构完整;再加一组golden test,把一组真实转写文本跑完后的结果存成期望快照,技能升级时对比,防止行为悄悄退化。技能迭代最怕的不是不进步,而是改一个输出格式,把之前能正确抽取行动项的能力给弄没了。
2.4 一个技能一个目录:文件怎么组织
技能的目录结构我推荐这样:
skills/ meeting_minutes/ SKILL.md skill.py test_skill.py assets/ example_input.txt example_output.json为什么不用单文件?因为技能会长大。SKILL.md是文档,测试是保障,代码是主体,assets存放真实样例,各归其位。一个目录就是一套完整的、可复制的能力单元,团队协作时直接把整个目录丢给对方,什么都不用解释。
3. 实战手写一个“会议纪要结构化”技能,从零到挂载
3.1 需求拆解:哪些任务适合做技能
我每周有大量会议语音转写出文本,原始转写用词口语化、逻辑跳跃、多人说话混在一起,直接丢给模型让它“写个纪要”,结果经常时好时坏。后来我决定把它做成一个正式技能。
判断任务适不适合做技能,我的标准是四连问:边界清不清楚?有没有高频重复需求?输入输出能不能明确定义?结果需不需要稳定结构?会议纪要结构化完全满足这四条。反例是“陪用户聊产品方案”这类开放对话,它更适合作为Agent的常规能力,而不是技能——技能需要可验证的具体产出。
这个技能的边界我一开始就划死了:只做转写文本到结构化纪要的转换,不负责自动发送邮件、不自动创建任务卡、不推进度。边界越清晰,Agent编排越简单,后续接其他技能也越顺。
3.2 定义输入输出:边界越清晰,失误越少
输入我把raw_text定为必填,meeting_date和language定为可选。这个设计很朴素,但关键是输出Schema,它直接决定模型抽什么、怎么抽。我定义的JSON结构如下:
{ "title": "会议主题", "date": "2025-01-15", "attendees": ["张伟", "李娜"], "summary": "三句话概括会议内容", "decisions": ["决策1", "决策2"], "action_items": [ {"owner": "张伟", "task": "完成XX方案", "due": "2025-01-20"} ] }字段不多,但每个都有讲究。action_items是结构化对象,owner和due都允许为空字符串,这比让模型硬编一个“未知”要稳定。decisions单独列出,是因为会议纪要里“决定了什么”和“要做什么”经常被混在一句话里,分两个数组能让后续自动化更好处理。输出必须是JSON而不是Markdown,原因很实际:JSON可以直接被下游流程消费,也不用再去解析。
3.3 核心实现:分块、抽取、合并
代码部分我直接给一个可运行的版本,里面包含三个核心环节:分块、LLM抽取、合并去重。
import json import re from typing import Any, Callable, Optional # LLM 调用函数抽象。你需要传入一个签名为 # def llm_fn(system_prompt: str, user_text: str) -> str # 的函数,返回值是模型输出的文本,且要求是 JSON 字符串。 CompletionFn = Callable[[str, str], str] SYSTEM_PROMPT = """你是一名会议纪要助理。你的任务是把用户提供的会议转写原始文本整理成结构化的 JSON。 要求: 1. 输出必须是最外层合法的 JSON,不要输出任何解释文字。 2. JSON 结构必须严格符合以下 schema: { "title": "string,会议主题,10字以内", "date": "string,会议日期,YYYY-MM-DD", "attendees": ["string,参与人姓名"], "summary": "string,3句话概括会议内容", "decisions": ["string,会议明确做出的决策"], "action_items": [{"owner": "string,负责人", "task": "string,待办事项", "due": "string,截止日期,不知道就填空字符串"}] } 3. 如果原文中没有明确说出某项的负责人,owner 填"待定"。 4. 不要编造原文中不存在的信息。""" def chunk_text(text: str, max_chars: int = 12000) -> list[str]: """按段落把长文本切成块,尽量在语义完整处切断。""" paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()] chunks: list[str] = [] current = "" for para in paragraphs: if len(current) + len(para) + 1 > max_chars: chunks.append(current) current = para else: current = f"{current}\n{para}" if current else para if current: chunks.append(current) return chunks def _parse_json(raw: str) -> dict: """从模型输出中提取 JSON,兼容模型喜欢加代码块标记的情况。""" start = raw.find("{") end = raw.rfind("}") if start == -1 or end == -1 or end <= start: raise ValueError(f"模型输出中未找到 JSON:{raw[:200]}") return json.loads(raw[start:end + 1]) def _extract_one(chunk: str, llm_fn: CompletionFn) -> dict: raw = llm_fn(SYSTEM_PROMPT, chunk) return _parse_json(raw) def merge_results(results: list[dict]) -> dict: """合并多个分块的抽取结果,按内容去重。""" merged = { "title": results[0].get("title", ""), "date": results[0].get("date", ""), "attendees": [], "summary": "", "decisions": [], "action_items": [], } seen_people: set[str] = set() seen_decisions: set[str] = set() seen_tasks: set[str] = set() summaries: list[str] = [] for r in results: for att in r.get("attendees", []): if att and att not in seen_people: seen_people.add(att) merged["attendees"].append(att) for dec in r.get("decisions", []): if dec and dec not in seen_decisions: seen_decisions.add(dec) merged["decisions"].append(dec) for item in r.get("action_items", []): key = (item.get("owner", ""), item.get("task", "")) if key not in seen_tasks: seen_tasks.add(key) merged["action_items"].append(item) if r.get("summary"): summaries.append(r["summary"]) merged["summary"] = " ".join(summaries) return merged def run_meeting_minutes( raw_text: str, llm_fn: CompletionFn, meeting_date: Optional[str] = None, max_chars: int = 12000, ) -> dict: """会议纪要结构化技能主入口。""" chunks = chunk_text(raw_text, max_chars) results = [_extract_one(c, llm_fn) for c in chunks] merged = merge_results(results) if meeting_date: merged["date"] = meeting_date return merged几个实机经验补充一下。
分块时我按空行切段落,再拼块,这比按固定字符数硬切要稳得多,模型不容易在半句话上断掉。块大小默认12000字,这个数字要看你的上下文窗口,别卡太满,要给输出预留空间。
_parse_json里先找第一个“{”再从后往前找最后一个“}”,是因为很多模型输出会在JSON外面包一层json代码块标记,直接json.loads必然失败。这个坑特别常见。
merge_results的去重用的是(owner, task)二元组,因为同一个负责人的同一件事在长会议上可能被重复提多次。summary我简单做了拼接,实际生产我会改成语义合并,但作为技能1.0,拼接够用了。
真实使用时,llm_fn参数可以换成任何厂商SDK。比如用Anthropic SDK就是:
from anthropic import Anthropic client = Anthropic() # API Key 通过环境变量 ANTHROPIC_API_KEY 提供 def anthropic_completion(system: str, user_text: str) -> str: response = client.messages.create( model="claude-sonnet-4-5", # 换成你自己可用的模型ID max_tokens=4096, system=system, messages=[{"role": "user", "content": user_text}], ) return response.content[0].text result = run_meeting_minutes( raw_text=long_transcript, llm_fn=anthropic_completion, meeting_date="2025-01-15", ) print(json.dumps(result, ensure_ascii=False, indent=2))把LLM调用抽象成一个函数,好处是明显的:想换模型、想加缓存、想埋点都只改一个地方,技能主体完全不用动。
3.4 挂载进Agent:给模型一份足够的调用指引
技能写完,还要让Agent知道它的存在。如果你的Agent框架支持动态技能加载,通常只需要把技能目录注册进去,框架会自动读SKILL.md并注入上下文。但不支持的话,就需要在系统Prompt里手动加一段路由指引,我习惯这样写:
你有以下技能可用: - meeting_minutes:把会议转写文本整理成结构化会议纪要。 触发条件:用户提供会议录音转写、访谈记录等原始文本,并要求整理纪要时使用。 不要使用:用户在闲聊、单纯询问建议时不要启动。 输出:JSON对象。注意这里的关键词和SKILL.md保持一致。模型可能同时读到系统Prompt和技能文档,两边描述冲突,它会不知所措。保持一致不是可选项,是必须项。
3.5 验证结果:定性不定量的检查清单
挂载完成后,我建议按这个清单验证技能是否真的“稳”:
- 同一份输入跑5次,确认输出Schema完全一致。
- 随机抽3条行动项对比原文,确认没有编造内容。
- 空字段检查:负责人未知时是不是“待定”而不是瞎填。
- 多块文本合并后有没有重复项残留。
- 如果改了分块参数,跑一遍既有测试组,防止回归。
这套验证不追求跑分,追求的是“能放心让它自动干活”。技能的价值不在惊艳,在可预测。
4. 常见问题与排查:技能不生效时先看这四个地方
4.1 技能没被调用:先查说明文档,再查触发词
最常遇到的问题就是:技能写好了,Agent死活不调用。我的排查路径是固定的。
第一步,确认技能确实已经加载,很多框架加载的是旧副本,清缓存再看。第二步,打开Agent的推理trace,观察模型在路由决策阶段有没有看到这个技能;如果看到了但不选,大概率是SKILL.md的触发描述和真实用户输入对不上。比如你写的是“会议纪要”,用户说“帮我记一下刚才那半小时聊了点什么”,模型可能不觉得是同一件事。解法是给SKILL.md补充口语化触发词和反例,示例越贴近真实用户用语,路由越准。
这类问题不是模型问题,是文档工程的细节问题。
4.2 技能调用了但结果畸形:输出schema校验和重试机制
症状是解析失败、字段缺失、枚举值乱。根因通常是LLM没有严格遵守Schema——小模型尤其明显。
我给的方案是三层防护。第一层,解析后做Schema校验,缺什么字段直接抛错。第二层,做小规模重试,把错误信息原样回喂给模型,让它自己修,最多重试两次,避免死循环。第三层,在System Prompt里用“输出必须是最外层合法的JSON”这类负面约束,把格式问题前置拦截。
动手改之前,先跑一次调用,看是模型抽不动还是格式乱,两个问题改法完全不同。
4.3 长文本处理翻车:分块策略和上下文窗口的关系
分块会让模型丢失跨块上下文。比如某段发言说“刚才张伟提的那个方案我同意”,但张伟的方案内容在上一块。我的对策是重叠分块,每块尾部带上一块末尾200到500字符,这样关键指代词不会悬空。生产环境里我还会把第一块抽出的attendees列表作为额外上下文传给后面的块,实体就一直在视野内。
还有一点:合并重复项时,action_items的“负责人+任务”二元组去重只能处理完全相同的文本,语义重复处理不了。这个问题目前没有完全自动的办法,我在技能里加了一个review步骤,让模型在合并后做一次轻量去重。
4.4 多技能互相干扰:命名空间和加载顺序的教训
技能多了以后,新的问题会出现:技能A不触发,技能B反而抢着触发。我踩过最狠的一个坑,是两个技能描述里都用了“总结”这个词,模型经常把需要完整会议纪要的请求路由到简洁摘要技能上。
解法有三个。第一,技能命名带领域前缀,例如meeting_minutes、report_writer、data_cleaner,不要用summarize、process这种过于通用的名字。第二,按任务域分组加载,用户在做会议相关操作时只加载会议域技能,别把所有技能全塞给模型。第三,高优先级技能排在描述列表前面,模型对列表前部的内容敏感度更高,这个我实测属实。
4.5 避坑清单速查表
| 症状 | 可能根因 | 排查顺序 | 推荐对策 |
|---|---|---|---|
| 技能完全没被调用 | SKILL.md触发描述与真实输入不匹配 | 1.看trace路由日志 2.检查描述用词 | 补充口语化触发示例和反例 |
| 调用后输出仍然畸形 | Schema约束不严或模型抽取能力不足 | 1.单测解析逻辑 2.观察原始输出 | 加校验与重试;考虑换更强模型 |
| 长文本结果遗漏后半段 | 分块后上下文断裂 | 1.对比单块结果 2.检查merge逻辑 | 重叠分块;透传实体摘要 |
| 多个技能互相抢调用 | 描述宽泛、命名冲突 | 1.检查各技能描述 2.看加载列表 | 加前缀;按域分组加载 |
| 改动某技能导致整体行为变化 | 改了SKILL.md影响路由 | 1.对比版本diff 2.跑golden test | 变更前记录技能行为快照 |
这张表基本覆盖了技能工程落地期能遇到的80%问题,剩下的都是具体业务逻辑问题,靠日志就能定位。
5. 从单技能到技能系统:编排、复用与演进
5.1 复杂任务拆解:把“写周报”拆成三个技能
单技能解决单任务,组合技能才能解决复杂工作流。以“写周报”为例,我拆成三个技能:log_fetcher负责从项目系统拉取本周提交记录,meeting_minutes负责整理本周会议纪要,report_writer把前两者输出汇总成结构化周报。三个技能各自独立、各自可测,组合起来就是一个完整的周报流水线。
拆分的判断标准是“变化频率”。如果三个环节经常单独变化——比如提交记录的数据源变了、会议纪要的格式升级了,那它们就应该拆开。如果周报整体很少变化,也可以合成一个大技能,少做一次编排。拆还是不拆,看技术债的积累速度,不要为了拆而拆。
5.2 技能组合的两种编排模式:串行和管线
技能组合最朴素的模式是串行:技能A的输出作为技能B的输入。实现上就是几行代码的事,可靠,可调试,断在任何一环都知道去哪查。
更复杂一点的是并行编排:互不依赖的抽取任务同时跑,最后做一个汇总merge。比如会议纪要技能和关键数字抽取技能可以并行执行,最后合并成一份完整报告。并行模式能缩短延迟,但对技能的无状态要求更高,别在技能里偷偷改共享文件。
当技能数量超过5个,路由判断建议用显式的路由技能来做:先让一个轻量LLM调用分析用户意图,再决定走哪条技能串,而不是把所有技能描述一股脑塞给主模型。这样做的代价是多一次模型调用,换来的是路由准确率的大幅提升。
5.3 技能复用与演进:版本化、模式库、内部市场
技能是可复用的资产,就值得用资产的规格来管理。我在SKILL.md里加version字段,每次改动行为都升版本号,并在技能目录里放一份CHANGELOG记录变更。这个动作直接救过我一次:有一次我改了分块参数,周报技能跟着坏掉,回头看CHANGELOG才定位到是参数调整连带影响了另一个技能的输入格式。
团队里时间久了会沉淀出一批通用技能,比如“翻译校对”“数据清洗”“URL内容抓取”。我会把这些技能集中放到一个技能仓库,新项目直接复制目录使用,而不是重新开发。有条件的话,给技能写一行“适用场景”的索引README,让团队搜索技能而不是重造技能。
说点我自己的体会。踩过几次坑之后,我越来越觉得Agent的技能工程本质上是在给模型做减负。你不用教会它所有步骤,只需要让它知道有什么技能、什么时候用、怎么确认用对了。技能层不是万能的,它解决的是稳定性和复用问题,解决不了模型本身能力不足的问题。但如果你正在被“工具一多就乱、Prompt改一处崩全局”折磨,我建议先从手写一个20行的小技能开始,把“定义边界、写说明、写测试、挂载Agent”这条链路完整跑通,再慢慢扩张技能库。这是我做了这么久Agent应用之后,最想回头重做的一步。