hermes-error-detection-plan.md 15 KB

Hermes Agent 调用失败感知方案(B + C + D)

1. 背景与问题

用户观察到的现象:

节点 envelope = {
  message: "Hermes Agent 执行成功",
  data: { result: "API call failed after 3 retries: HTTP 429: 该模型当前访问量过大..." }
}
工作流最终状态 = SUCCESS

LLM 实际调用失败(HTTP 429),但工作流报告成功。根因是 跨语言契约缺失 + envelope 双重语义混淆,问题分布在 4 个层级:

层级 文件 问题
1. Python Agent backend/hermes-agent/run_agent.py AIAgent.run() 在 LLM 失败时不抛异常,错误描述作为 final_response 字符串返回
2. Python Bridge backend/hermes-bridge/hermes_bridge.py::_run_agent_stream 把 Agent 返回的错误描述当作正常 done 事件发出
3. Java Client backend/.../engine/hermes/HermesBridgeClient.java::parseSseResponse case "done" 分支把错误文本当作正常回复累积到 finalResponse
4. Java Executor backend/.../engine/executor/HermesAgentExecutor.java 只判断 finalText 是否空白,非空就返回 NodeExecutionResult.success + envelope.success
5. Engine backend/.../engine/WorkflowLevelExecutor.java::onNodeCompleted 只读外层 result.getStatus(),不看内层 envelope.status;外层 SUCCESS 时即便 envelope.status=400 也不进入失败分支

2. 方案概览

方案 改动半径 是否在本期实施
A: Python Bridge / hermes-agent 显式区分 done / error 跨语言 + 第三方代码 ❌ 不做(hermes-agent 是上游代码)
B: Java Client 做错误文本嗅探,命中则抛异常 HermesBridgeClient 单文件 ✅ 实施(含可配置菜单)
C: Executor 同步外层 NodeExecutionResult 与 envelope.status HermesAgentExecutor + HermesSmartActionExecutor ✅ 实施
D: 引擎层 envelope.status=400 自动升级为节点 FAILED WorkflowLevelExecutor 单文件 ✅ 实施

整体目标:只要业务结果实质失败,无论失败在哪一层被识别,工作流都必须感知

3. 详细设计

方案 D:引擎层 envelope 失败自动升级(最先做

改动文件backend/src/main/java/com/agent/management/engine/WorkflowLevelExecutor.java

改动位置onNodeCompleted 方法的 synchronized (ctx.recordsLock) 块(约 309-330 行)

当前逻辑

if (result.getStatus() == NodeExecutionResult.Status.FAILED) {
    // ... failStrategy 处理
}

改造后逻辑

NodeExecutionResult effectiveResult = result;
// D: envelope 失败自动升级 —— SUCCESS 但 envelope.status=400 时,按 FAILED 处理
if (result.getStatus() == NodeExecutionResult.Status.SUCCESS
        && NodeOutputEnvelope.isEnvelope(result.getOutput())) {
    NodeOutputEnvelope env = NodeOutputEnvelope.fromObject(result.getOutput());
    if (env != null && env.isFailure()) {
        String originalMsg = env.getMessage();
        String errMsg = String.format("节点 %s 业务执行失败(envelope.status=400): %s",
                nodeId, originalMsg == null || originalMsg.isEmpty() ? "未提供失败原因" : originalMsg);
        effectiveResult = NodeExecutionResult.failed(nodeId, errMsg, result.getLogs());
        // 关键:把失败 envelope 写入上下文,让下游节点能通过 envelope.status 感知(不能丢)
        context.setNodeOutput(nodeId, effectiveResult.getOutput() == null
                ? NodeOutputEnvelope.failure(errMsg).toMap()
                : effectiveResult.getOutput());
    }
}
// 后续判断与记录都用 effectiveResult

关键点

  1. 必须在 executeOneNode 之外(即 onNodeCompleted)做这个升级,因为 executeOneNode 已经写入 output 到 context,这里只补"如果 envelope 是 failure,按 FAILED 走 failStrategy"
  2. 失败 envelope 仍写入 context,下游节点通过 workspace.getOutputStatus(prevId) 感知
  3. 不影响 output 类型节点(其 envelope 永远是 success/failure,failure 时也按失败传播)
  4. 对原本就返回 Status.FAILED 的节点零影响(路径不变)

方案 C:Executor 同步外层状态

改动文件

  • backend/src/main/java/com/agent/management/engine/executor/HermesAgentExecutor.java
  • backend/src/main/java/com/agent/management/engine/executor/HermesSmartActionExecutor.java

当前问题

} catch (Exception e) {
    return NodeExecutionResult.success(nodeId,
            NodeOutputEnvelope.failure("Hermes Agent 调用失败: " + e.getMessage()).toMap());
}

外层 success + 内层 failure 是语义不一致的。WorkflowLevelExecutor 当前只看外层,所以这种节点失败不会触发 failStrategy。

改造(两处对称修改):

} catch (Exception e) {
    log.error("[HermesAgent] 节点 {} 调用失败: {}", nodeId, e.getMessage());
    // C: 外层状态与 envelope.status 保持一致 —— 抛给引擎的 NodeExecutionResult 也 FAILED
    return NodeExecutionResult.failed(nodeId,
            "Hermes Agent 调用失败: " + e.getMessage());
}

同时把"返回空结果"分支也改为 failed(原来的 success+failure envelope 也是反模式):

if (runResult.getFinalText() == null || runResult.getFinalText().isBlank()) {
    return NodeExecutionResult.failed(nodeId, "Hermes Agent 返回空结果", runResult.getLogs());
}

兜底机制executeOneNoderesult.getStatus() == FAILEDoutput == null 时自动构造 failure envelope 写入 context(已有逻辑,不变),下游节点可通过 envelope.status 感知。

"Agent 节点未关联 Skill"、"加载 Skill 失败" 等早期失败分支 也一并改为 failed

方案 B:Java Client 错误文本嗅探 + 可配置列表

B.1 后端:错误模式数据模型

新增实体HermesErrorPattern

public class HermesErrorPattern {
    private Long id;
    private String name;          // 模式名称,如 "LLM 429 限流"
    private String pattern;       // 匹配文本(contains,大小写不敏感)
    private Boolean enabled;      // 启用/禁用
    private Boolean isBuiltIn;    // 内置不可删除标记
    private String description;   // 可选说明
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
}

匹配规则:使用 String#toLowerCase().contains(pattern.toLowerCase()),简单稳定,避免正则转义陷阱。匹配范围限定在 finalText 的前 N 个字符(默认 500)以避免对超长回复做无效扫描。

设计权衡:曾考虑正则表达式,但 (a) 用户配置正则易出错;(b) 错误描述大多是固定前缀("API call failed after""HTTP 4"),子串匹配已足够;(c) 子串匹配对前后台一致性更友好。如果未来需要更复杂匹配,可在 pattern 增加前缀 regex: 区分。

B.2 后端:Repository / Service / Controller

按现有项目模式(AiModelRepository + AiModelService + AiModelController)实现:

  • HermesErrorPatternRepository extends AbstractJsonRepository<HermesErrorPattern>,文件名 hermes-error-patterns.json
  • HermesErrorPatternService 提供:list()create(req)update(id, req)delete(id)getActivePatterns()isErrorResponse(String finalText)
  • HermesErrorPatternController,路径 /api/hermes-error-patterns

Controller 风格:对齐 AiModelController(POST /{id}/edit/{id}/delete/{id}/toggle 动词式 path)。

B.3 后端:HermesBridgeClient 嗅探集成

改动文件backend/src/main/java/com/agent/management/engine/hermes/HermesBridgeClient.java

改动位置parseSseResponse 方法的 case "done" 分支后、return new HermesRunResult(...) 前。

private HermesRunResult parseSseResponse(...) {
    // ... 现有逻辑
    String finalText = finalResponse.toString();

    // B: 错误响应嗅探
    if (errorPatternService != null) {
        ErrorDetectionResult detection = errorPatternService.isErrorResponse(finalText);
        if (detection.isError()) {
            String errMsg = String.format("Hermes Agent 返回的响应被识别为错误(命中模式「%s」): %s",
                    detection.getMatchedPatternName(),
                    truncate(finalText, 200));
            LLM_HTTP.warn("[Hermes-RESP] 错误响应嗅探命中: pattern={}, response={}",
                    detection.getMatchedPatternName(), truncate(finalText, 500));
            throw new HermesErrorResponseException(errMsg, detection.getMatchedPatternName());
        }
    }
    return new HermesRunResult(finalText, logs);
}

为什么抛 HermesErrorResponseException 而非 RuntimeException

  • HermesAgentExecutor 的 catch 块能区分"网络/进程异常"(重试类)与"业务错误响应"(不可重试)
  • 当前先统一处理为 failed,后续可以做精细化重试策略

依赖注入HermesBridgeClient 构造器新增 HermesErrorPatternService 参数(可为 null,方便测试)。

B.4 后端:内置默认模式

通过 HermesErrorPatternService.@PostConstruct initBuiltInPatterns() 实现:

  • 启动时若 hermes-error-patterns.json 不存在或为空,写入以下内置模式(isBuiltIn=true):
    • "API call failed after" — LLM SDK 重试耗尽
    • "HTTP 4" — 4xx 客户端错误
    • "HTTP 5" — 5xx 服务端错误
    • "rate limit" — 限流(英文)
    • "访问量过大" — 智谱 AI 限流(中文)
    • "该模型当前访问量过大" — 智谱 AI 限流(更具体)
    • "quota" / "额度" / "余额不足" — 配额耗尽
    • "authentication" / "invalid api key" / "无效的 api" — 认证失败
  • 已有数据时不覆盖(用户自定义优先)

为什么不用 application.yml 作为内置来源:用户的诉求就是"做一个菜单页进行列表配置",所以默认值放 JSON 仓库里更直接。yml 配置适合运维固定参数,不适合动态列表。

B.5 前端:菜单 + 路由 + 页面 + API

菜单:在 frontend/src/components/layout/AppSidebar.vue 的"系统管理"或底部独立分组追加:

// 在 menuItems 中追加
{ key: '/hermes-errors', label: '错误模式管理', icon: WarningOutline }

路由frontend/src/router/index.js 追加:

{
  path: '/hermes-errors',
  name: 'HermesErrorPatterns',
  component: () => import('../views/system/HermesErrorPatterns.vue'),
  meta: { title: '错误模式管理' }
}

APIfrontend/src/api/hermesError.js

import request from '../utils/request'

export function getHermesErrorPatterns() {
  return request.get('/hermes-error-patterns')
}
export function createHermesErrorPattern(data) {
  return request.post('/hermes-error-patterns', data)
}
export function updateHermesErrorPattern(id, data) {
  return request.post(`/hermes-error-patterns/${id}/edit`, data)
}
export function deleteHermesErrorPattern(id) {
  return request.post(`/hermes-error-patterns/${id}/delete`)
}
export function toggleHermesErrorPattern(id) {
  return request.post(`/hermes-error-patterns/${id}/toggle`)
}

页面frontend/src/views/system/HermesErrorPatterns.vue

主要功能:

  • NDataTable 列表展示,列:启用状态(switch)、名称、匹配模式、说明、内置标记(tag)、操作(编辑/删除/复制)
  • 顶部"新建模式"按钮 + "重置为默认"按钮(清空自定义、恢复内置)
  • 编辑对话框:NForm 含名称、匹配模式、说明、启用
  • 删除:内置模式禁止删除(前端灰掉按钮 + 后端校验)
  • 复制:将内置模式复制为自定义,便于基于内置扩展

UI 风格:与 ModelManagement.vue 一致(NDataTable + NModal + NForm + useMessage)。

4. 实施顺序

按依赖关系:

1. 方案 D:WorkflowLevelExecutor envelope 失败自动升级
   (独立改造,为后续方案兜底)
2. 方案 C:HermesAgentExecutor + HermesSmartActionExecutor 外层状态对齐
   (依赖 D 把 SUCCESS+failure envelope 也视为失败)
3. 方案 B 后端:实体 + Repository + Service + Controller + Client 集成
4. 方案 B 前端:菜单 + 路由 + API + 页面
5. 验证:编译 + 单元测试 + 手工触发 429 场景

5. 单元测试

新增测试覆盖:

  • WorkflowLevelExecutorEnvelopeUpgradeTest:D 方案的核心测试
    • SUCCESS + envelope.success → 不升级
    • SUCCESS + envelope.failure → 升级为 FAILED + failStrategy=abort 触发
    • SUCCESS + envelope.failure + failStrategy=skip → 不 abort,继续下游
    • FAILED(无 output) → 走原兜底路径(不影响)
  • HermesErrorPatternServiceTest:B 方案的匹配测试
    • 命中内置模式(429 / rate limit)
    • 大小写不敏感
    • 禁用模式不参与匹配
    • 空 finalText 不匹配
  • HermesBridgeClientErrorDetectionTest:B 方案的集成测试
    • mock SSE 响应含错误描述 → 抛 HermesErrorResponseException
    • mock SSE 响应正常 → 返回正常 HermesRunResult

6. 风险与权衡

风险 缓解
错误模式误判(用户合法回复里恰好包含 "rate limit" 等关键词) (a) 模式由用户精细管理;(b) 内置模式偏激进可禁用;(c) 嗅探只看 finalText,不影响 tool 中间步骤
D 方案改动影响其他节点 只对 SUCCESS + envelope.status=400 生效,FAILED 路径不变;output 类型节点失败时也合理传播
C 方案改变历史行为 原本"Hermes 调用失败但工作流继续"的行为本就是 bug,对依赖此 bug 的工作流是 break-change。但比起继续假装成功,让 failStrategy 接管是更正确的语义
Python Bridge 仍可能漏报错误 方案 A 留作后续;B 方案的列表可动态扩展,遇到新错误描述可立即配置

7. 验收标准

  • 单元测试全绿(新增 3 个测试类)
  • 触发 429 时,节点状态 = FAILED,工作流按 failStrategy 传播
  • 前端"错误模式管理"页可 CRUD,启用/禁用立即生效(下次 Hermes 调用即生效)
  • 内置默认模式首次启动写入,用户修改后不被覆盖
  • 误判时用户可在前端禁用对应模式

8. 不在本期实施

  • 方案 A(Python 侧改造):需要改 hermes-agent 上游代码,成本高,留作长期目标
  • 错误响应的精细化重试策略:当前所有嗅探到的错误统一为 FAILED,后续可根据 FailReason 区分(限流→延迟重试,认证失败→直接失败)
  • 正则匹配模式:当前用 substring,未来如有需求可在 pattern 增加前缀区分