Skill 异常处理实践:让 AI Agent 告别静默失败
最近在做一件事:让我的 AI Agent 系统更可靠。
我用 OpenClaw 搭了一套自动化工作流,其中有个 Skill 负责每天早上拉取 TAPD 数据、生成站会摘要报告。跑了一段时间后,某天报告里出现了一条惊悚的风险告警——“全部 28 个需求均未设置计划开始时间和计划完成时间”。
实际情况是大部分需求都有排期。
这次误报让我认真思考了一个问题:AI Agent 的可靠性,光靠框架层面的保障是不够的,Skill 自身必须有完善的异常处理机制,并且要能把异常清晰地透传到上层,让 LLM 做出正确判断。
一、一次真实的线上误报
排查下来,这次误报由两个技术问题叠加导致:
问题一:JSON 截断
内部 MCP 工具(mcporter-internal)有约 7KB 的输出大小限制。当我用 limit=200 拉取需求列表时,返回的 JSON 数据被截断,解析失败,于是返回了一个空列表。
问题二:脚本静默失败
脚本拿到空列表之后,没有报错,而是"正常"地往下执行——把 0 条需求当作"这个迭代没有需求"处理,然后生成了一份全是误报的风险分析。
LLM 收到这份输出后,无法区分"API 调用失败"和"真的没有数据",于是老老实实地生成了一份错误报告发给了我。
顺带在排查过程中还发现了一个隐藏 Bug:scan_risks.py 里有一个 continue 语句,后面紧跟着一段永远不会被执行的死代码。这类逻辑错误在 Code Review 中很容易被忽略。
这次事故的根因,一句话总结:Skill 脚本出错了,但 LLM 不知道。
二、核心设计原则
基于这次事故,我重新设计了 Skill 的异常处理体系,确立了四条原则。
原则一:永不静默失败
最危险的错误不是程序崩溃,而是程序"静悄悄地"返回了错误数据。
# ❌ 危险:失败时返回空列表,LLM 会误以为「没有需求」
def get_stories(workspace_id, iteration_id):
result = call_safe('stories_get', {...})
if not result['ok']:
return []
return extract_list(result['data'])
# ✅ 正确:失败时抛出异常,携带错误类型
def get_stories(workspace_id, iteration_id):
result = call_safe('stories_get', {...})
if not result['ok']:
raise TapdApiError(
f'获取需求列表失败: {result["error"]}',
error_type=result.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_truncated |
JSON 被截断 | 减少 limit 参数,使用分页 |
json_parse |
JSON 解析失败 | 检查接口返回格式 |
api_error |
接口返回业务错误 | 确认 workspace_id 和账号权限 |
process_error |
进程返回非零退出码 | 查看 stderr 获取详细原因 |
结构化的错误类型让调用方可以精确区分原因,而不是猜测。
原则三:致命错误 vs 非致命错误,分级处理
并非所有错误都需要终止执行:
- 致命错误:核心数据获取失败(需求列表),无法继续生成报告,直接退出
- 非致命错误:辅助数据获取失败(任务列表、迭代信息),影响部分功能,但可以降级继续
降级处理时,脚本会:
- 向
stderr输出警告(供调试查看) - 在输出 JSON 的
warnings字段记录降级信息(供 LLM 感知) - 继续执行,生成带有数据完整性声明的报告
原则四:输出 LLM 友好的错误 JSON
Skill 脚本的输出会被 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:结构化异常类
class TapdApiError(Exception):
# 错误类型常量
TIMEOUT = 'timeout'
NOT_FOUND = 'command_not_found'
AUTH_FAILED = 'auth_failed'
EMPTY_RESPONSE = 'empty_response'
JSON_TRUNCATED = 'json_truncated'
JSON_PARSE = 'json_parse'
API_ERROR = 'api_error'
PROCESS_ERROR = 'process_error'
UNKNOWN = 'unknown'
def __init__(self, message, error_type='unknown', raw=''):
super().__init__(message)
self.error_type = error_type
self.raw = raw # 原始输出,用于调试error_type 使用字符串常量,调用方可以用 e.error_type == TapdApiError.AUTH_FAILED 精确判断;raw 字段保留原始输出,便于调试。
call() 与 call_safe():双接口设计
# call_safe() 返回结构化结果,不抛出异常
def call_safe(tool_name, tool_args, timeout=30):
try:
data = call(tool_name, tool_args, timeout)
return {'ok': True, 'data': data}
except TapdApiError as e:
return {'ok': False, 'error': str(e), 'error_type': e.error_type, 'data': []}
except Exception as e:
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': hint or f'🔴 脚本执行失败({error_type}):{message}',
}
print(json.dumps(output, ensure_ascii=False, indent=2))
sys.exit(1)
# 在 main() 中使用
def main():
try:
stories = tapd_api.get_stories(workspace_id, iteration_id)
except tapd_api.TapdApiError as e:
exit_with_error(
error_type=e.error_type,
message=str(e),
hint=_error_hint(e) # 根据 error_type 生成针对性提示
)四、JSON 截断问题的根治:分页拉取
这次事故的直接触发点是 JSON 截断。解决思路很简单:分页拉取,每页数据量控制在安全范围内。
def get_stories(workspace_id, iteration_id, limit=200):
PAGE_SIZE = 10 # 经验值,10 条约 2-3KB,安全边际充足
all_items, page, first_page = [], 1, True
while True:
result = call_safe('stories_get', {**base_args, 'page': page})
if not result['ok']:
if first_page:
raise TapdApiError(...) # 第一页失败:致命错误
else:
print(f'⚠️ 第{page}页失败,停止分页', file=sys.stderr)
break # 后续页失败:降级处理
first_page = False
page_items = extract_list(result['data'])
if not page_items:
break
all_items.extend(page_items)
page += 1
if len(page_items) < PAGE_SIZE:
break # 最后一页
return all_items关键决策:第一页失败 = 致命错误(无法获取任何数据,必须终止);后续页失败 = 降级处理(已有部分数据,停止分页但返回已有数据,输出警告)。
截断检测:通过检查输出是否以 ... 或 … 结尾,以及长度超过 1000 且不以 } 或 ] 结尾,来识别截断情况。
五、完整架构
经过改造后,整个异常处理体系的分层如下:
┌────────────────────────────────────────────────────────┐
│ LLM(读取输出 JSON) │
│ ok=true → 正常处理 │
│ ok=false → 读取 llm_hint,向用户输出友好错误提示 │
└───────────────────────┬────────────────────────────────┘
│ stdout(JSON)
┌───────────────────────▼────────────────────────────────┐
│ 脚本层(fetch_standup / scan_risks 等) │
│ 致命错误 → exit_with_error() → ok=false 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(提交 PR):create_pr() 函数里调用 GitHub API,失败时直接抛出 urllib.error.HTTPError,调用方没有捕获,LLM 看到的是一段 Python traceback,完全无法判断是网络问题还是 Token 过期。
# 改造前:裸抛,LLM 拿到的是 traceback
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.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_GIT = "git_error"
ERR_AUTH = "auth_failed"
ERR_NETWORK = "network_error"
ERR_EXISTS = "already_exists"
ERR_NOT_FOUND = "not_found"
_HINT_MAP = {
ERR_AUTH: "🔴 GitHub 鉴权失败(401/403),请确认 git remote URL 中的 PAT 有效。",
ERR_NETWORK: "🔴 网络请求失败,请检查内网/外网连通性,稍后重试。",
ERR_EXISTS: "🟡 PR 分支已存在,请检查 GitHub 上是否已有对应 PR。",
# ...
}
def exit_with_error(error_type: str, message: str, detail: str = "") -> None:
output = {
"ok": False,
"error_type": error_type,
"error": message,
"detail": detail,
"llm_hint": _HINT_MAP.get(error_type, "🔴 未知错误"),
}
print(json.dumps(output, ensure_ascii=False, indent=2))
sys.exit(1)GitHub API 调用现在按 HTTP 状态码细化:
except urllib.error.HTTPError as e:
if e.code in (401, 403):
exit_with_error(ERR_AUTH, f"GitHub 鉴权失败(HTTP {e.code})")
elif e.code == 422:
if "pull request already exists" in detail.lower():
exit_with_error(ERR_EXISTS, "该分支已存在对应的 PR,请勿重复创建")
exit_with_error(ERR_PR_CREATE, f"PR 创建失败(HTTP {e.code}):参数错误")
else:
exit_with_error(ERR_PR_CREATE, f"PR 创建失败(HTTP {e.code})")deploy.py 加了前置校验和 SSH 错误分类:
# 前置校验:私钥文件存在(不等 SSH 进程崩溃再报错)
if not os.path.exists(SSH_KEY):
exit_with_error(ERR_SSH_KEY, f"SSH 私钥文件不存在:{SSH_KEY}")
# 超时单独捕获
except subprocess.TimeoutExpired:
exit_with_error(ERR_TIMEOUT, "SSH 命令超时(120s)")
# 根据退出码和 stderr 推断 SSH 错误类型
def _classify_ssh_error(rc: int, stderr: str) -> str:
if rc == 255:
if "connection refused" in stderr or "no route to host" in stderr:
return ERR_SSH_CONNECT # 连不上
if "permission denied" in stderr or "publickey" in stderr:
return ERR_SSH_AUTH # 密钥问题
return ERR_MAKE_FAILED # make 本身失败gen_banner.py 的字体静默失败改为降级警告透传:
# 改造前:静默 fallback,用户不知道中文会乱码
return ImageFont.load_default()
# 改造后:输出 WARNING,降级继续(非致命),LLM/用户可感知
if not _font_missing_warned:
print(
"WARNING: 未找到 CJK 字体,中文标题将显示为方块。"
" 请运行:dnf install google-noto-cjk-fonts",
file=sys.stderr,
)
_font_missing_warned = True
return ImageFont.load_default()正常输出时也带上 warnings 字段:
{
"ok": true,
"path": "/projects/hxblog/.../banner.jpg",
"warnings": ["CJK 字体缺失,中文标题可能显示为方块,请安装 google-noto-cjk-fonts"],
"llm_hint": "✅ Banner 生成成功 ⚠️ 注意:CJK 字体缺失..."
}6.3 两个案例的共同模式
把两次改造对比一下,会发现问题是同构的:
| 问题类型 | TAPD Skill | blog-writer Skill |
|---|---|---|
| 静默返回错误数据 | API 失败返回空列表 | 字体缺失静默生成乱码 Banner |
| 裸抛异常 | subprocess 异常未捕获 | HTTPError 未捕获 |
| 退出无输出 | sys.exit(1) 无 JSON |
同左 |
| 错误类型不细分 | 统一 Exception | HTTP 状态码未细化 |
解法也是同构的:exit_with_error() + error_type 常量 + llm_hint + warnings 降级字段。这套模式可以直接复用到任何 Skill 脚本里,不需要每次重新设计。
七、Skill 开发 Checklist
改造完成后,我整理了一份 Skill 开发的异常处理 Checklist,供后续开发参考:
- 异常类:定义结构化异常类,包含
error_type字段 - exit_with_error:实现标准化错误退出函数,输出
ok=falseJSON - llm_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 工作流,希望这篇文章能提供一些参考。