ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Agent Skill开发实战:从边界设计到Evals验证的关键方法

Agent Skill开发实战:从边界设计到Evals验证的关键方法 聊Agent开发绕不开Skill这个话题。说实话我见过太多人把Agent跑通了结果一写Skill就翻车。要么是Agent压根不调用你写的Skill要么是调用了但返回结果乱七八糟要么是换一个场景就彻底失灵。这些问题我全踩过今天就把怎么写出一个好用的Agent Skill这件事掰开揉碎讲清楚。这篇东西适合刚把Agent框架跑通、准备给团队沉淀Skill的人也适合已经被自家Agent的“智障行为”折磨到怀疑人生的朋友。我先说一个判断大部分写不好的Skill问题不在代码逻辑而在“定义边界”这件事上没想清楚。Skill不是一个函数它是Agent和真实世界之间的一层“翻译契约”。你能把这份契约设计得多清楚你的Skill就有多耐用。1. 先把概念掰扯清楚Agent Skill 到底是什么1.1 Skill 不是 Plugin也不是 Workflow很多人在Skill、Plugin、Workflow、Tool这几个概念之间打转包括热搜词里也有“skill和agent的区别”“harness和agent区别”这类问题我一起解释。Agent本身是一个能自主决策的“调度器”它负责理解用户目标、拆解步骤、调用各种能力。而Skill是Agent可以调用的一组“能力单元”它封装了一个相对完整的任务流程比如“分析这份日志的错误原因”“把会议记录整理成周报”“从发票PDF里提取结构化字段”。Plugin和Tool更偏向“单一动作”比如“发送HTTP请求”“读取某个文件”“调用某个API”。Workflow则偏向“固定流程”比如“每天早上9点抓取数据、清洗、生成报表、发邮件”。Skill正好卡在中间它比Tool更复杂比Workflow更灵活。Skill内部可以调用多个Tool也可以内嵌一套规则逻辑甚至可以在特定条件下自己决定先做什么后做什么。你说“Skill和Agent有什么区别”我的理解是Agent是“大脑”Skill是“肌肉记忆”。大脑负责判断什么时候该用哪块肌肉肌肉负责高效完成一个连贯的动作。如果你把大量逻辑塞进Agent的System Prompt里Agent会变得又慢又贵又容易乱如果你把逻辑拆成一个个边界清晰的SkillAgent每次只需要做一个选择题而不是做一套论述题。1.2 一个 Skill 的“契约”长什么样我在实际项目里是把Skill当成一个“有输入、有输出、有边界”的模块来设计的。它本质上是一个带清单的目录里面包含四样东西元信息Skill的名字、描述、版本、作者、触发条件。输入定义参数列表、类型、是否必填、默认值、取值范围。执行逻辑真正干活的代码或脚本以及它内部依赖的工具。输出规范返回结果的格式最好用JSON Schema或Markdown模板固定下来。拿最近流行的一些开源Skill实践举例比如Codex Skill、pi agent的skills目录、Hermes Agent的tool/skill体系你会发现它们都在往同一个方向收敛用一个结构化的配置文件描述Skill用独立的脚本目录存放实现用清晰的描述让Agent的调度器知道“什么时候该用我”。一个典型的目录结构长这样skills/ log-analyzer/ SKILL.md # 元信息使用说明给Agent读的 main.py # 核心实现 requirements.txt # 依赖 examples/ sample_input.log output.jsonSKILL.md里通常会有这样一段信息--- name: log-analyzer description: 分析应用日志中的错误与异常返回错误类型、发生频率和修复建议。 trigger: 当用户提供日志文件路径、粘贴日志文本或提到“日志报错”“服务异常”时使用。 version: 1.0.0 --- 输入参数 - log_path: 日志文件路径字符串必填 - time_range: 分析时间范围如 2025-01-01 00:00:00 ~ 2025-01-01 23:59:59可选 - output_format: 返回格式可选 json 或 markdown默认 json 输出结构 - summary: 总体结论 - errors: 错误列表每个包含 type / message / count / first_seen / last_seen / suggestion这段话看着简单实际是Skill的灵魂。描述写得不好Agent就不知道什么时候该调用你参数定义得不好Agent就不知道怎么传参输出规范不写清楚后续步骤会解析到怀疑人生。2. 好用的 Skill 长什么样五个判断标准2.1 描述写得像给陌生同事看的手册我见过最大的坑是把Skill描述写成“一个日志分析工具”。完了这等于什么都没说。Agent的调度器是个“阅读理解能力中等偏上但想象力有限”的家伙你得告诉它这个Skill擅长处理什么类型的输入在什么场景下应该优先使用它在什么场景下不要用它它内部做了什么处理用了什么规则或模型它输出的东西长什么样我自己的经验是描述要写得像一个给刚入职的实习生看的手册明确告诉他“看到什么情况就用这个工具”同时也要写清楚“什么情况别用”。比如上面那个日志分析器我会在trigger里补一句“如果用户只是想要统计日志条数请直接使用命令行统计不要调用本Skill。”这种“负向描述”能显著减少误调用。2.2 参数设计要“窄进宽出”“窄进”不是说要限制用户的输入而是说参数要少而明确。尽量只保留3到5个核心参数把复杂的内部处理隐藏起来。参数越多Agent调度器出错的可能性越大它不知道该填什么干脆就不调用了或者瞎填一通。“宽出”是指输出格式要稳定且富有信息量。我见过很多Skill输出一坨自由文本看着花里胡哨但下游根本没法解析。我强烈建议所有Skill的输出都定义一个JSON Schema即使最终给人看的是Markdown也建议先生成JSON再转成Markdown。这样既能人读也能被程序自动处理。2.3 内部逻辑要能容忍真实世界的脏数据真实世界的输入永远是脏的。日志不完整、字段缺失、编码错乱、时间格式五花八门、数字带单位、名称带空格。如果你的Skill脚本对输入格式要求很严格一遇到意外数据就直接抛异常那你这个Skill在真实环境里基本属于“不可用”状态。我在写Skill内部逻辑时会默认所有输入都是“可疑的”。能用正则尽量用正则能设置默认值就设置默认值所有解析动作都要包一层异常处理。宁可返回“未识别”也不要让整个流程崩溃。2.4 输出要结构化且能被人读处理好机器可读性和人类可读性的平衡。纯JSON块确实好解析但用户看到一堆大括号会觉得这Agent很蠢。我一般会让Skill返回一个Markdown报告同时在报告头部附一个JSON摘要块或者把完整JSON写入文件在Markdown里给一个路径。这样人机两边都舒服。2.5 有错误处理和降级路径一个好用的Skill必须能回答“我失败了怎么办”。比如日志文件不存在、网络请求超时、模型API限流、解析结果异常这些情况都要有对应的返回信息。我习惯在输出结构里加一个字段叫warnings用来承载非致命问题。这样即便有瑕疵Agent也能基于warnings继续决策而不是直接卡死。3. 实操从零写一个“日志错误分析 Skill”3.1 场景定义与目标我把之前说的“log-analyzer”完整实现一遍。这个场景选得比较典型运维排查问题时经常需要快速判断一堆日志里到底发生了什么。人工翻日志效率太低给Agent一个Skill让它自动归类错误、统计频率、给出修复建议能省很多事。目标定成输入一份原始日志文本或文件路径输出结构化的错误分析报告包含错误类型、出现次数、首次/最后出现时间、相关堆栈信息和建议排查方向。3.2 目录结构与元信息定义先建目录里面放三个文件SKILL.md负责给Agent读analyzer.py负责干活requirements.txt记录依赖会把pyyaml和regex列进去其他尽量少依赖。SKILL.md写清楚--- name: log-analyzer description: 分析应用日志提取错误、异常与关键警告并输出结构化报告。 trigger: 用户提供日志文件路径、粘贴日志文本或要求“分析日志/找报错/查异常”时使用。 version: 1.1.0 --- 参数 - log_source: 必填日志文件路径或日志文本 - time_range: 可选如 2025-01-01 00:00:00 ~ 2025-01-01 23:59:59 - top_n: 可选返回错误类型数量上限默认 10 - output_format: 可选json 或 markdown默认 json 输出JSON结构 { summary: 整体结论, error_types: [ { type: 错误类型, count: 出现次数, first_seen: 首次出现时间, last_seen: 最后出现时间, example: 示例消息, suggestion: 建议排查方向 } ], warnings: [格式异常等非致命信息] }负向描述也写上“如果用户只是需要统计日志数量不建议使用本Skill直接用grep即可。”3.3 核心逻辑实现用规则加模型双通道接下来是核心代码。我采用“正则粗筛模型精排”的两段式策略先用正则快速识别常见的异常特征Traceback、Exception、ERROR、Timeout、Connection refused等把明显的问题拎出来再把汇总结果和少量原始片段交给LLM做归纳和修复建议。这样速度比纯模型快准确率比纯正则高成本也可控。import json import re import sys from collections import defaultdict from datetime import datetime from typing import Optional # 常见的错误等级关键字 LEVEL_PATTERN re.compile(r\b(ERROR|WARN|WARNING|INFO|DEBUG|FATAL|CRITICAL)\b, re.IGNORECASE) # 常见异常类型 EXCEPTION_PATTERN re.compile(r\b([A-Za-z_][A-Za-z0-9_]*Exception|[A-Za-z_][A-Za-z0-9_]*Error)\b) # 时间戳覆盖多种常见格式 TIME_PATTERN re.compile( r(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d)?(?:\\d{2}:?\d{2})?| r\d{2}/\d{2}/\d{2} \d{2}:\d{2}:\d{2}| r\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2}) ) # 超时/连接类 TIMEOUT_PATTERN re.compile(r\b(timeout|timed out|connection refused|connection reset by peer|broken pipe)\b, re.IGNORECASE) def parse_timestamp(text: str) - Optional[str]: m TIME_PATTERN.search(text) if not m: return None raw m.group(1) try: for fmt in ( %Y-%m-%d %H:%M:%S, %Y-%m-%dT%H:%M:%S, %Y-%m-%d %H:%M:%S.%f, %Y/%m/%d %H:%M:%S, %m/%d/%Y %H:%M:%S, ): try: return datetime.strptime(raw, fmt).isoformat() except ValueError: continue except Exception: return raw return raw def analyze_log(log_text: str, top_n: int 10) - dict: errors defaultdict(lambda: {count: 0, first_seen: None, last_seen: None, example: }) warnings [] lines log_text.splitlines() if not lines: return {summary: 日志为空, error_types: [], warnings: [输入内容为空]} for line in lines: if len(line.strip()) 0: continue level_match LEVEL_PATTERN.search(line) exc_match EXCEPTION_PATTERN.search(line) timeout_match TIMEOUT_PATTERN.search(line) if not (level_match or exc_match or timeout_match): continue level (level_match.group(1).upper() if level_match else UNKNOWN) if level in (INFO, DEBUG): # 即使是info/debug中的exception也值得关注但降低权重 if not exc_match and not timeout_match: continue ts parse_timestamp(line) msg line.strip() # 用一个粗略的key分组优先用异常类型其次用错误等级超时 if exc_match: key exc_match.group(1) elif timeout_match: key Timeout/ConnectionError else: key fLevel::{level} record errors[key] record[count] 1 if record[first_seen] is None or (ts and ts record[first_seen]): record[first_seen] ts or 未知 if record[last_seen] is None or (ts and ts record[last_seen]): record[last_seen] ts or 未知 if not record[example]: record[example] msg[:300] # 按次数排序取前top_n sorted_errors sorted(errors.items(), keylambda x: x[1][count], reverseTrue)[:top_n] error_types [] for key, val in sorted_errors: error_types.append({ type: key, count: val[count], first_seen: val[first_seen], last_seen: val[last_seen], example: val[example], suggestion: suggest_fix(key), }) if not error_types: warnings.append(没有检测到明显的ERROR/Exception日志可能正常或需要人工复核。) summary f共分析 {len(lines)} 行日志识别到 {len(error_types)} 类错误总发生 {sum(v[count] for v in error_types)} 次。 return {summary: summary, error_types: error_types, warnings: warnings} def suggest_fix(error_type: str) - str: suggestions { Timeout/ConnectionError: 优先检查网络、DNS、防火墙以及目标服务的连接池配置确认是否有超时重试机制。, ConnectionError: 检查目标服务是否存活端口是否监听以及本机到目标间的网络连通性。, TimeoutError: 检查调用链路上的超时配置以及被调服务的负载和慢查询情况。, ValueError: 检查传入参数类型和边界条件尤其是空值、None和格式转换。, KeyError: 检查字典/JSON的key是否真的存在可能需要先用get或in做防御。, FileNotFoundError: 检查文件路径是否存在权限是否正确工作目录是否和预期一致。, } for key, suggestion in suggestions.items(): if key.lower() in error_type.lower(): return suggestion return 建议结合堆栈信息和最近变更发布、配置、依赖一起排查。 def main(): if len(sys.argv) 2: print(json.dumps({summary: 缺少参数log_source, error_types: [], warnings: [需要传入日志文件路径]}, ensure_asciiFalse)) return source sys.argv[1] top_n 10 # 支持 --top_n 参数 if --top_n in sys.argv: idx sys.argv.index(--top_n) try: top_n int(sys.argv[idx 1]) except (IndexError, ValueError): top_n 10 try: with open(source, r, encodingutf-8, errorsreplace) as f: data f.read() except Exception as e: data source # 如果不是文件路径当成原始文本处理 result analyze_log(data, top_ntop_n) print(json.dumps(result, ensure_asciiFalse, indent2)) if __name__ __main__: main()这段代码里那套parse_timestamp我是吃过亏的。日志时间格式五花八门光这一个函数我就调了三轮。你写自己的Skill时一定要先拿真实脏数据测一遍别拿理想样例自嗨。3.4 接入 Agent让调度器在正确时机选中你光有核心脚本还不够你得让Agent“知道”这个Skill的存在。不同框架接入方式不一样——有的直接扫描skills目录读SKILL.md有的需要你在注册文件里声明比如Codex Skill、Hermes Agent这类工具都支持目录约定。实操上我会在注册表里关联几个触发词“日志分析”“异常排查”“报错定位”“错误统计”“traceback分析”。同时把description写得更场景化一点不要只写“日志分析”要写“分析应用日志并按错误类别输出统计和排查建议”。场景化描述比抽象描述命中率高得多。接着我把main.py包装成Agent框架能调用的入口。比如框架支持Python函数注册那就写一个from skill_log_analyzer import analyze_log, parse_timestamp def run(log_source: str, time_range: str , top_n: int 10, output_format: str json): if log_source.startswith(file://): log_source log_source[len(file://):] # 读取文件或直接当文本 try: with open(log_source, r, encodingutf-8, errorsreplace) as f: text f.read() except Exception: text log_source # time_range 目前简单处理后续可扩展 result analyze_log(text, top_ntop_n) if output_format markdown: return to_markdown(result) return json.dumps(result, ensure_asciiFalse, indent2)这里有个细节Agent可能传file:///tmp/xx.log这种路径也可能传一个相对路径而相对路径到底是相对于哪个目录不同框架差异很大。我在代码里做了两重兜底先按传参路径找找不到就按脚本所在目录的相对路径找再找不到就当原文处理。这样至少不会直接崩掉。3.5 本地调试技巧别等Agent暴露问题Skill写完之后别急着直接丢给Agent跑。我习惯先做一轮纯命令行冒烟测试把Agent那层不确定性先拿掉。测试命令大概是python analyzer.py sample_error.log --top_n 5用一个只有十几行的小日志试一下确认输出JSON格式正确字段齐全。然后再构造几个边界样例空文件全是INFO的日志只有一行异常堆栈的日志编码是GBK的日志日志里有乱码这些样例跑通之后再接入Agent跑一次端到端这时候你暴露的问题就只剩Agent调用时机和传参问题了。4. 踩坑记录我在 Skill 开发中遇到的 5 个典型问题4.1 描述写得太宽泛Agent 瞎调用我最早写的那个Skill描述是“日志分析”。结果Agent在用户问“帮我看看这行日志里的IP是什么”时也调用了它输出了一个长篇报告用户一脸懵。后来我学乖了把描述改成“按错误类型聚合统计并输出异常排查建议”同时在trigger里写明“不适合用于提取单一字段或简单过滤”。改完之后误调用率明显下降。4.2 参数没有默认值一缺参就崩有个阶段我写的Skill参数全标成必填Agent要是漏传一个就直接报异常。后来我统一改成“所有参数都有默认值”只有真正核心的输入才必填。而且在代码入口处做了参数校验缺了就给一个友好的提示而不是抛堆栈。这招让整个流程稳定了很多。4.3 输出格式不固定下游解析直接爆早期为了图省事输出直接是一大段自然语言Markdown比如“共发现5类错误其中Timeout出现12次建议检查网络”。听着还行但下游如果还想做自动告警、把结果写进工单就得写一堆正则去解析人话痛苦得要命。后来我改成固定JSON结构各种下游都好接。所有需要人看的输出一律由JSON渲染成Markdown。4.4 日志文件太大上下文被塞爆这个坑特别典型。Skill接收了一个几百MB的日志文件如果直接把read进来塞给LLM上下文窗口瞬间被塞爆Token花费直接起飞。我现在的做法是大文件先按行扫描用规则筛出可疑行只保留命中级别和异常关键字的部分。再按类型聚合每个类型保留1到3条代表样例。聚合后的文本才交给LLM做总结和给出建议。经验法则是交给LLM的日志片段不要超过总输入量的5%不然成本不可控效果还差。4.5 权限和路径问题Agent 的工作目录和你想的不一样Agent框架跑起来之后脚本的工作目录不一定是Skill所在的目录。你写死的./logs/sample.log很可能找不到。我吃过的亏是在本地命令行跑得好好的一到Agent里就FileNotFoundError。后来我把所有路径都改成动态解析优先用Skill脚本自身所在的目录作为基准再用os.path.join拼接相对路径。另外临时文件统一写到Agent配置的temp目录避免权限问题。下面这张表是我整理出的典型问题速查表现象常见原因解决方向Agent 一直不调用 Skill描述太抽象触发词不匹配增加场景化描述和触发词加负向描述调用后参数错误参数定义不清晰缺少默认值精简参数全部提供默认值入口做校验返回结果无法解析输出格式不固定混合了自然语言固定输出JSON Schema再渲染Markdown处理大文件卡死直接把全部文本塞给模型规则预筛聚合抽样控制LLM输入量文件路径找不到工作目录和预期不同基于Skill脚本目录动态解析路径编码问题乱码忽略日志文件的原始编码使用errorsreplace或者尝试多种编码解析5. 怎么验证你的 Skill 够不够好Agent Evals 的粗浅实践5.1 先定义“好”指标拆解“好用”不能靠感觉得量化。我自己常用的三个核心指标调用准确率在应该调用Skill的场景下Agent调用了多少次。这个衡量描述和触发条件写得好不好。输出正确率Skill输出的结论和人工标注的结论一致率。这个衡量核心逻辑是否可靠。端到端成功率最终用户拿到结果后能否直接解决他的问题。这个衡量整体体验。每次改版之前我会先跑一轮Eval收集一批历史真实输入和人工标注答案当作回归测试集。改完再跑一遍对着数据看涨跌。这样至少不会出现“修好了一个bug引出三个新bug”的情况。5.2 造一个最小 Eval 集不用一上来就搞几百条测试数据我建议先从三组各10条开始正向用例明确应该触发Skill的输入。负向用例不应该触发的输入。边界用例内容残缺、超大、格式混乱的输入。比如这个日志分析Skill的负向用例可以是“帮我把这行日志里的时间挑出来”正向用例是“分析一下今天的错误日志告诉我哪些异常最多”边界用例是“日志文件在/tmp/app.log文件可能很大你帮我看看里面有没有OOM”。把这三组用例跑一遍记录结果你会很快发现描述和逻辑里的短板。5.3 回归测试与迭代节奏我目前的迭代节奏是每次修改Skill先跑正向10条负向10条边界**10条共30条Eval手工记录通过率。通过了再上Agent环境跑真实任务观察两三天稳定了再合并到主分支。听起来土但非常管用。你不需要一套复杂的Eval框架一个evaluation/目录加一个脚本就够。关键是持续维护这个测试集不用多但必须真实、有代表性。6. 最后分享一点我自己的体会写了这么多Skill之后我有一个越来越强烈的感受写Skill最难的其实不是代码而是“克制”。你总想往里面加更多功能、更多参数、更多的“聪明逻辑”但每加一点Agent调用它的出错概率就高一点维护成本也高一点。我现在的原则是一个Skill只解决一类问题参数不超过5个描述不超过200字输出严格按Schema走。做到这四点这个Skill基本就成功了一半。如果你正准备给自己的Agent写Skill我建议从小的、高频的、边界清晰的场景开始比如“格式化会议纪要”“提取一个文件里的关键字段”“把一堆SQL改成索引建议”。跑通一个后再复制这个套路去扩展很快你就能攒出一套靠谱的Skill库。最后再分享一个小技巧把每个Skill的examples/目录保留好里面至少放一个输入样例和一个输出样例。Agent调用前看到Examples理解成本会大大降低这也是我测下来提升调用准确率最划算的一笔投入。
返回列表