Skill 实践之异常处理
一个互联网技术玩家一个爱聊技术的家伙。在工作和学习中不断思考把这些思考总结出来并分享和大家一起交流进步。最近在做一件事让我的 openclaw skill 可以更可靠的运行。起因是我用 OpenClaw 搭了一套自动化工作流其中有个 Skill 负责每天早上拉取 TAPD 数据、生成站会摘要报告。跑了一段时间后某天报告里出现了一条惊悚的风险告警——“全部 28 个需求均未设置计划开始时间和计划完成时间”。实际情况是大部分需求都有排期。这次误报让我认真思考了一个问题AI Agent 的可靠性光靠框架层面的保障是不够的Skill 自身必须有完善的异常处理机制并且要能把异常清晰地透传到上层让 LLM 做出正确判断。一、一次真实的线上误报排查下来这次误报由两个技术问题叠加导致问题一JSON 截断内部 MCP 工具mcporter-internal有约 7KB 的输出大小限制。当我用limit200拉取需求列表时返回的 JSON 数据被截断解析失败于是返回了一个空列表。问题二脚本静默失败脚本拿到空列表之后没有报错而是正常地往下执行——把 0 条需求当作这个迭代没有需求处理然后生成了一份全是误报的风险分析。LLM 收到这份输出后无法区分API 调用失败和真的没有数据于是老老实实地生成了一份错误报告发给了我。顺带在排查过程中还发现了一个隐藏 Bugscan_risks.py里有一个continue语句后面紧跟着一段永远不会被执行的死代码。这类逻辑错误在 Code Review 中很容易被忽略。这次事故的根因一句话总结Skill 脚本出错了但 LLM 不知道。二、核心设计原则基于这次事故我重新让openclaw设计了 Skill 的异常处理体系确立了四条原则。原则一永不静默失败最危险的错误不是程序崩溃而是程序静悄悄地返回了错误数据。# ❌ 危险失败时返回空列表LLM 会误以为「没有需求」defget_stories(workspace_id,iteration_id):resultcall_safe(stories_get,{...})if not result[ok]: return[]return extract_list(result[data])# ✅ 正确失败时抛出异常携带错误类型defget_stories(workspace_id,iteration_id):resultcall_safe(stories_get,{...})if not result[ok]: raiseTapdApiError(f获取需求列表失败: {result[error]}, error_typeresult.get(error_type,TapdApiError.UNKNOWN))return extract_list(result[data])区别在于前者让错误消失了后者让错误浮出水面交由调用方决定如何处理。原则二区分错误类型给出针对性提示“出错了对用户没有帮助“鉴权失败请重新登录才有帮助。我为 TAPD API 调用定义了 8 种错误类型错误类型触发场景用户可采取的行动timeout调用超时检查内网连通性稍后重试command_not_found工具未安装运行 npm install 安装auth_failed鉴权失败401/403确认已完成 tapd 授权empty_response返回空响应检查内网服务是否可达json_truncatedJSON 被截断减少 limit 参数使用分页json_parseJSON 解析失败检查接口返回格式api_error接口返回业务错误确认 workspace_id 和账号权限process_error进程返回非零退出码查看 stderr 获取详细原因结构化的错误类型让调用方可以精确区分原因而不是猜测。原则三致命错误 vs 非致命错误分级处理并非所有错误都需要终止执行致命错误核心数据获取失败需求列表无法继续生成报告直接退出非致命错误辅助数据获取失败任务列表、迭代信息影响部分功能但可以降级继续降级处理时脚本会向stderr输出警告供调试查看在输出 JSON 的warnings字段记录降级信息供 LLM 感知继续执行生成带有数据完整性声明的报告原则四输出 LLM 友好的错误 JSONSkill 脚本的输出会被 LLM 直接读取。如果脚本只是sys.exit(1)而不输出任何内容LLM 完全不知道发生了什么。我设计了统一的错误输出格式包含llm_hint字段作为专门给 LLM 的决策提示{ok:false,error_type:auth_failed,error:TAPD 鉴权失败请确认 mcporter-internal 已完成 tapd 授权配置,workspace_id:70132312,llm_hint: TAPD 鉴权失败请确认 mcporter-internal 已完成 tapd 授权配置。}LLM 读取llm_hint后可以直接向用户输出友好的错误说明而不是一段技术性的堆栈信息。三、工程实现TapdApiError结构化异常类classTapdApiError(Exception):# 错误类型常量TIMEOUTtimeoutNOT_FOUNDcommand_not_foundAUTH_FAILEDauth_failedEMPTY_RESPONSEempty_responseJSON_TRUNCATEDjson_truncatedJSON_PARSEjson_parseAPI_ERRORapi_errorPROCESS_ERRORprocess_errorUNKNOWNunknowndef__init__(self,message,error_typeunknown,raw): super().__init__(message) self.error_typeerror_type self.rawraw# 原始输出用于调试error_type使用字符串常量调用方可以用e.error_type TapdApiError.AUTH_FAILED精确判断raw字段保留原始输出便于调试。call() 与 call_safe()双接口设计# call_safe() 返回结构化结果不抛出异常def call_safe(tool_name,tool_args,timeout30): try: datacall(tool_name,tool_args,timeout) return {ok:True,data:data} except TapdApiErrorase: return{ok:False,error:str(e),error_type:e.error_type,data:[]} except Exceptionase: return{ok:False,error:f未预期的异常: {e},error_type:TapdApiError.UNKNOWN,data:[]}call()抛出TapdApiError适用于需要精确控制异常处理的场景call_safe()返回{ok, error_type, ...}适用于主流程避免未捕获异常导致输出混乱exit_with_error()标准化错误退出def exit_with_error(error_type,message,workspace_id,hint):output{ok:False,error_type:error_type,error:message,workspace_id:workspace_id,llm_hint:hintorf 脚本执行失败{error_type}{message},}print(json.dumps(output,ensure_asciiFalse,indent2))sys.exit(1)# 在 main() 中使用def main(): try: storiestapd_api.get_stories(workspace_id,iteration_id) except tapd_api.TapdApiErrorase: exit_with_error( error_typee.error_type, messagestr(e), hint_error_hint(e)# 根据 error_type 生成针对性提示 )四、JSON 截断问题的根治分页拉取这次事故的直接触发点是 JSON 截断。解决思路很简单分页拉取每页数据量控制在安全范围内。关键决策第一页失败 致命错误无法获取任何数据必须终止后续页失败 降级处理已有部分数据停止分页但返回已有数据输出警告。截断检测通过检查输出是否以...或…结尾以及长度超过 1000 且不以}或]结尾来识别截断情况。五、完整架构经过改造后整个异常处理体系的分层如下┌────────────────────────────────────────────────────────┐│ LLM读取输出 JSON ││ oktrue → 正常处理 ││ okfalse → 读取 llm_hint向用户输出友好错误提示 │└───────────────────────┬────────────────────────────────┘ │ stdoutJSON┌───────────────────────▼────────────────────────────────┐│ 脚本层fetch_standup / scan_risks 等 ││ 致命错误 → exit_with_error() → okfalse JSON 退出 ││ 降级错误 → 记录 warnings继续执行 │└───────────────────────┬────────────────────────────────┘ │ 抛出 TapdApiError┌───────────────────────▼────────────────────────────────┐│ tapd_api.py公共 API 模块 ││ call() → 识别 8 种错误类型抛出 TapdApiError ││ call_safe() → 捕获异常返回 {ok, error_type, data} ││ get_*() → 分页拉取首页失败抛出后续页降级 │└───────────────────────┬────────────────────────────────┘ │ subprocess┌───────────────────────▼────────────────────────────────┐│ mcporter-internal内网 MCP 工具 ││ timeout / not_found / auth_failed / json_truncated… │└────────────────────────────────────────────────────────┘这个分层设计的核心思想是每一层都对上层负责把自己能识别的错误翻译成结构化信息传递给上层而不是让问题在本层消失。六、第二个案例blog-writer Skill 的改造TAPD Skill 改造完之后我顺手把自己另一个常用 Skill——blog-writer——也过了一遍。它负责生成博客文章并自动提交 PR、部署到远程服务器。一看之下同样的毛病三个核心脚本失败时要么裸抛异常要么只是sys.exit(1)什么都不输出。6.1 改造前的问题submit_pr.py提交 PRcreate_pr()函数里调用 GitHub API失败时直接抛出urllib.error.HTTPError调用方没有捕获LLM 看到的是一段 Python traceback完全无法判断是网络问题还是 Token 过期。# 改造前裸抛LLM 拿到的是 tracebackwith urllib.request.urlopen(req,timeout15) as resp:datajson.loads(resp.read())return data.get(html_url,)# HTTPError 直接往上飞main() 没有 try/exceptdeploy.py远程部署SSH 连接失败、make 命令失败都只是logging.error()打日志然后sys.exit(1)。LLM 不知道是连不上服务器、密钥问题还是 make 本身报错。gen_banner.py生成 Banner找不到 CJK 字体时静默 fallback到系统默认点阵字体——中文标题直接变成方块但脚本正常退出LLM 和用户都不会收到任何警告。这是个典型的成功了但结果是错的”。6.2 改造后统一接入 exit_with_error()三个脚本都引入了同样的模式以submit_pr.py为例# 错误类型常量ERR_GITgit_errorERR_AUTHauth_failedERR_NETWORKnetwork_errorERR_EXISTSalready_existsERR_NOT_FOUNDnot_found_HINT_MAP{ERR_AUTH: GitHub 鉴权失败401/403请确认 git remote URL 中的 PAT 有效。,ERR_NETWORK: 网络请求失败请检查内网/外网连通性稍后重试。,ERR_EXISTS: PR 分支已存在请检查 GitHub 上是否已有对应 PR。,# ...}GitHub API 调用现在按 HTTP 状态码细化excepturllib.error.HTTPErrorase:ife.codein(401,403):exit_with_error(ERR_AUTH,fGitHub 鉴权失败HTTP {e.code})elife.code422:ifpull request already existsindetail.lower():exit_with_error(ERR_EXISTS,该分支已存在对应的 PR请勿重复创建)exit_with_error(ERR_PR_CREATE,fPR 创建失败HTTP {e.code}参数错误)else:exit_with_error(ERR_PR_CREATE,fPR 创建失败HTTP {e.code})deploy.py加了前置校验和 SSH 错误分类# 前置校验私钥文件存在不等 SSH 进程崩溃再报错ifnotos.path.exists(SSH_KEY):exit_with_error(ERR_SSH_KEY,fSSH 私钥文件不存在{SSH_KEY})# 超时单独捕获exceptsubprocess.TimeoutExpired:exit_with_error(ERR_TIMEOUT,SSH 命令超时120s)# 根据退出码和 stderr 推断 SSH 错误类型gen_banner.py 的字体静默失败改为降级警告透传# 改造前静默 fallback用户不知道中文会乱码return ImageFont.load_default()# 改造后输出 WARNING降级继续非致命LLM/用户可感知if not _font_missing_warned:print(WARNING: 未找到 CJK 字体中文标题将显示为方块。 请运行dnf install google-noto-cjk-fonts,filesys.stderr,)_font_missing_warnedTruereturn ImageFont.load_default()正常输出时也带上warnings字段{ok:true,path:/projects/hxblog/.../banner.jpg,warnings:[CJK 字体缺失中文标题可能显示为方块请安装 google-noto-cjk-fonts],llm_hint:✅ Banner 生成成功 ⚠️ 注意CJK 字体缺失...}6.3 两个案例的共同模式把两次改造对比一下会发现问题是同构的问题类型TAPD Skillblog-writer Skill静默返回错误数据API 失败返回空列表字体缺失静默生成乱码 Banner裸抛异常subprocess 异常未捕获HTTPError 未捕获退出无输出sys.exit(1)无 JSON同左错误类型不细分统一 ExceptionHTTP 状态码未细化解法也是同构的exit_with_error()error_type常量 llm_hintwarnings降级字段。这套模式可以直接复用到任何 Skill 脚本里不需要每次重新设计。七、Skill 开发 Checklist改造完成后我整理了一份 Skill 开发的异常处理 Checklist供后续开发参考异常类定义结构化异常类包含error_type字段exit_with_error实现标准化错误退出函数输出okfalseJSONllm_hint每个输出 JSON 都包含llm_hint指导 LLM 决策warnings降级处理时记录warnings声明数据局限性分页拉取对可能超出大小限制的 API 调用使用分页致命/降级分级区分核心数据和辅助数据制定不同失败策略语法检查提交前运行python3 -m py_compile验证语法死代码检查检查continue/return/break后是否有永远不执行的代码八、总结这次事故给我的核心启示AI Agent 的可靠性是分层的。框架层OpenClaw负责 Skill 的调度和执行保障但 Skill 自身必须有完善的异常处理——异常要向上透传而不是被内部消化成错误数据悄悄返回。一个 Skill 如果对 LLM 说我执行完了没有数据”和说我执行失败了原因是鉴权失败请检查配置对 LLM 最终生成的输出影响是天壤之别。静默失败是 AI Agent 系统中最危险的模式因为 LLM 不会主动质疑数据的来源是否可靠它只会诚实地基于你给的数据输出结果。所以数据源Skill 脚本的防御性设计是整个系统可靠性的基础。这套异常处理模式不只适用于 TAPD Skill对所有通过 subprocess 调用外部命令、结果会被 LLM 解析的 Skill 都适用。如果你也在构建类似的 AI Agent 工作流希望这篇文章能提供一些参考。看完本文有收获请分享给更多人完美之道,不在无可增加,而在无可删减