# 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 行) **当前逻辑**: ```java if (result.getStatus() == NodeExecutionResult.Status.FAILED) { // ... failStrategy 处理 } ``` **改造后逻辑**: ```java 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` **当前问题**: ```java } catch (Exception e) { return NodeExecutionResult.success(nodeId, NodeOutputEnvelope.failure("Hermes Agent 调用失败: " + e.getMessage()).toMap()); } ``` 外层 `success` + 内层 `failure` 是语义不一致的。`WorkflowLevelExecutor` 当前只看外层,所以这种节点失败不会触发 failStrategy。 **改造**(两处对称修改): ```java } 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 也是反模式): ```java if (runResult.getFinalText() == null || runResult.getFinalText().isBlank()) { return NodeExecutionResult.failed(nodeId, "Hermes Agent 返回空结果", runResult.getLogs()); } ``` **兜底机制**:`executeOneNode` 在 `result.getStatus() == FAILED` 且 `output == null` 时自动构造 failure envelope 写入 context(已有逻辑,不变),下游节点可通过 envelope.status 感知。 **"Agent 节点未关联 Skill"、"加载 Skill 失败" 等早期失败分支** 也一并改为 `failed`。 ### 方案 B:Java Client 错误文本嗅探 + 可配置列表 #### B.1 后端:错误模式数据模型 **新增实体**:`HermesErrorPattern` ```java 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`,文件名 `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(...)` 前。 ```java 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` 的"系统管理"或底部独立分组追加: ```javascript // 在 menuItems 中追加 { key: '/hermes-errors', label: '错误模式管理', icon: WarningOutline } ``` **路由**:`frontend/src/router/index.js` 追加: ```javascript { path: '/hermes-errors', name: 'HermesErrorPatterns', component: () => import('../views/system/HermesErrorPatterns.vue'), meta: { title: '错误模式管理' } } ``` **API**:`frontend/src/api/hermesError.js` ```javascript 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 增加前缀区分