文档目的:解决两个生产反馈问题——(1) LLM 返回中文字段名未被拒绝;(2) 重试未区分"运行失败"与"格式失败",导致不必要的全量重跑。
改造范围:
backend/src/main/java/com/agent/management/engine/OutputValidator.javabackend/src/main/java/com/agent/management/engine/StructuredOutputHelper.javabackend/src/main/java/com/agent/management/engine/RetryPolicy.javabackend/src/main/java/com/agent/management/config/WorkflowProperties.javabackend/src/main/resources/application.yml.example(仅配置段)- 新增 2 个单元测试类
状态:待实施
1. 部分节点的输出,输出为 Json 格式,但与我定义的字段名不一致。我的定义:
{"status": 200/400, "message": "...", "data": {"fieldA": ...}}
它的输出:
{"状态": "成功", "信息": "XXXX成功", "数据": {"字段A": ...}}
为什么 validator 没有将这种格式认定为非法,并让该节点重试,重新生成?
2. 如果某个节点输出的格式未通过校验,不应重新运行节点逻辑。应分两种情况:
(1) 运行失败(如"模型访问量过大")→ 重新运行节点
(2) 运行成功但输出结构错误 → 把 JSON 模板 + 上次响应回喂给 LLM,仅重组 JSON
除"用户输入"节点和"条件分支"节点外,其他节点的用户定义输出均为必填,需严格满足每个字段均存在,键名严格校验。
| 问题 | 代码位置 | 根因 |
|---|---|---|
| 中文键名未拒绝 | OutputValidator.java:79-103 |
仅用英文 name 在 parsed 中 get(name),找不到就视为 null,但默认 retryOnMissingRequired=true 时虽会重试,重试 prompt 未明确告诉 LLM "你的字段名错了"——只在 instruction 末尾追加 lastError,LLM 可能仍按中文返回 |
| 中文键名被解析为 null | StructuredOutputHelper.java:213-223 |
parse() 用英文 name 从 LLM JSON 中 parsed.get(name),中文响应 → 全部 null |
| 未区分失败类型 | StructuredOutputHelper.java:94-138 |
单层 for 循环,每次都重建完整 prompt 调用 LLM,格式失败也会重新触发完整推理(浪费 token、放大限流概率) |
| RetryPolicy 单计数 | RetryPolicy.java:23-26 |
仅有 maxRetries,无 runtime/format 区分 |
所有需要结构化校验的执行器(LlmExecutor、SmartActionExecutor、HermesAgentExecutor、HermesSmartActionExecutor 等)都通过 StructuredOutputHelper.extractWithRetry 调用,因此只需改造 extractWithRetry 一处,所有节点自动受益。
userInput 节点不调用 extractWithRetry(用户直接填值),condition 节点仅输出 selectedBranch,二者天然不受影响。
| 场景 | 当前行为 | 目标行为 |
|---|---|---|
| LLM 返回英文字段名齐全 | ✅ 通过 | ✅ 通过 |
| LLM 返回中文字段名 | ❌ 解析为 null,silent 通过(retryOnMissingRequired 默认 false 时) | ❌ 拒绝,进入内层重试 |
| LLM 运行异常(限流/超时) | 整个节点重试 | 外层重试(重新完整推理) |
| LLM 运行成功但 JSON 结构错 | 整个节点重试 | 内层重试(仅重组 JSON,不重跑逻辑) |
| 内层重试耗尽 | N/A | 升级为外层重试 |
| 外层重试耗尽 | 返回失败 envelope | 返回失败 envelope(保持不变) |
| 节点类型 | 校验策略 |
|---|---|
userInput |
保持原有 required/optional 语义(不走校验器) |
condition |
仅输出 selectedBranch,不需要 envelope data 校验 |
| 其他节点(llm / agent / smartAction / hermesAgent / hermesSmartAction / knowledgeBase / output) | 所有声明字段视为必填,严格键名校验(精确匹配,区分大小写、区分分隔符) |
| 编号 | 现有行为 | 保留方式 |
|---|---|---|
| S1 | NodeOutputEnvelope 三段式结构(status/message/data) |
不动 |
| S2 | 类型自动转换(number/boolean/string) | 不动,仍由 OutputValidator 完成静默转换 |
| S3 | extractWithRetry 对外接口签名 |
保持不变(内部实现重写) |
| S4 | RetryPolicy.fromNodeData 节点级覆盖全局 |
拆分后保留覆盖语义 |
| S5 | 所有执行器调用方式(LlmExecutor/SmartActionExecutor/...) | 零改动 |
新增重载方法 validate(parsed, outputsDecl, strictAllRequired):
strictAllRequired = false:保持现有行为(按 decl.required 字段判定)strictAllRequired = true:所有声明字段视为必填,且每个字段必须在 parsed 中精确存在精确存在的定义:parsed.containsKey(name) && parsed.get(name) != null && !isBlankString(parsed.get(name))
中文同义字段(如 状态 vs status)不会通过 containsKey("status"),自动落入 missing 列表。
优点:改动聚焦、向后兼容、不引入新的字段名归一化逻辑(保持精确匹配符合用户"键名严格校验"要求)。
引入"中英文同义词典",LLM 返回 状态 时映射到 status。
否决理由:违反用户"键名严格校验"的要求;维护同义词典成本高;LLM 偶尔返回的中英文混杂会难以处理。
用 strict JSON Schema 强制 LLM 输出格式。
否决理由:依赖 Spring AI 的 entity(Class) 或 toolCalling 机制,改动面大;目前 extractWithRetry 是字符串 prompt + 解析的方式,改造成本太高;用户需要的是"出错时让 LLM 修正",而非"硬约束 LLM"。
目标:新增 strict 模式重载,对声明字段执行精确键存在校验。
改动点:
OutputValidator.java:
validate(Map<String, Object> parsed, JsonNode outputsDecl, boolean strictAllRequired)validate(parsed, outputsDecl) 内部转调 validate(parsed, outputsDecl, false)strictAllRequired = true 时:!parsed.containsKey(name) → 加入 missingRequired 并标注 "键名缺失"parsed.get(name) == null → 加入 missingRequired 并标注 "值为 null"missingRequired 元素从 "fieldName" 升级为 "fieldName (键名缺失)" 或 "fieldName (值为空)",便于 LLM 在重试时精准修正接口签名:
public static ValidationResult validate(
Map<String, Object> parsed,
JsonNode outputsDecl,
boolean strictAllRequired
);
// 旧入口保留
public static ValidationResult validate(
Map<String, Object> parsed,
JsonNode outputsDecl
);
验证方式:单元测试覆盖 4 个场景(中文字段名拒绝、英文字段名齐全通过、缺失部分字段拒绝、类型不匹配仍走转换)。
目标:将 maxRetries 拆分为 runtimeMaxRetries 和 formatMaxRetries。
改动点:
RetryPolicy.java:
int runtimeMaxRetries(默认 2):运行异常时重试次数(含首次)int formatMaxRetries(默认 2):格式错误时重试次数(仅重组 JSON)retryOnParseError、retryOnMissingRequired、retryOnTypeMismatch(仍然决定是否触发对应类型的重试)retryOnKeyNameMismatch(默认 true,受 strict 模式产生的新失败类型)@Deprecated 标记 maxRetries 字段(保留过渡期,运行期仍可用,但优先取 runtimeMaxRetries)fromNodeData 同步更新;defaultPolicy() 改为返回 runtimeMaxRetries=2, formatMaxRetries=2WorkflowProperties.Retry:
runtimeMaxRetries、formatMaxRetries、retryOnKeyNameMismatchmaxRetries(标注 @Deprecated,过渡期仍读,未配置时回退到 runtimeMaxRetries)RetryPolicy.fromNodeData 读取顺序:节点级 → 全局 → 默认验证方式:RetryPolicyTest 新增 3 个用例(默认值、节点级覆盖、节点级缺失走全局)。
目标:实现"外层运行重试 + 内层格式重试"。
核心结构:
public static ExtractionResult extractWithRetry(
ChatClient client,
String userPrompt,
String systemPrompt,
JsonNode outputsDecl,
RetryPolicy retryPolicy) {
String runtimeError = null;
// ===== 外层:运行重试(重新完整推理) =====
for (int runtimeAttempt = 1; runtimeAttempt <= retryPolicy.runtimeMaxRetries; runtimeAttempt++) {
// 1. 调用 LLM 完整推理
LlmCallOutcome call;
try {
call = invokeLlm(client, userPrompt, systemPrompt, outputsDecl, null);
} catch (Exception e) {
runtimeError = "LLM 调用异常: " + e.getMessage();
if (!retryPolicy.retryOnParseError) break;
continue; // 外层重试
}
if (call.response == null || call.response.isBlank()) {
runtimeError = "LLM 返回空结果";
if (!retryPolicy.retryOnParseError) break;
continue;
}
// 2. 内层:格式重试(仅重组 JSON,不重跑逻辑)
FormatResult fmt = reformatLoop(
client, call.response, outputsDecl, retryPolicy);
if (fmt.ok) {
return ExtractionResult.ok(fmt.data, runtimeAttempt + fmt.formatAttempts - 1);
}
// 3. 内层耗尽 → 外层重试(重新完整推理)
runtimeError = fmt.errorMessage;
if (!shouldRuntimeRetry(fmt.failReason, retryPolicy)) break;
}
return ExtractionResult.fail(runtimeError, retryPolicy.runtimeMaxRetries);
}
新增内部方法:
invokeLlm(client, userPrompt, systemPrompt, outputsDecl, previousResponse):
previousResponse == null → 构造完整 prompt(userPrompt + JSON 模板指令)previousResponse != null → 构造重组 prompt(见下方)LlmCallOutcomereformatLoop(client, originalResponse, outputsDecl, retryPolicy):
formatAttempts = 0currentResponse = originalResponse1..retryPolicy.formatMaxRetries:formatAttempts++FormatResult.ok(data)invokeLlm(..., previousResponse=currentResponse, error=...),更新 currentResponseFormatResult.fail(reason)重组 prompt 模板(关键:明确告知 LLM 字段名错了):
你之前的响应:
{原始响应}
但需要的字段名是(必须精确匹配,区分大小写):
{声明的字段列表,含 type/description/required}
检测到的问题:
{具体错误,如 "字段 'status' 键名缺失;字段 'message' 键名缺失;..."}
请仅根据你之前响应中的语义内容,**重新组织**为符合字段名要求的 JSON:
{"status": ..., "message": ..., "data": {...}}
仅输出 JSON 本身,不要任何额外文字、Markdown 标记。
关键设计:
新增内部数据类(私有静态内部类):
LlmCallOutcome(String response)FormatResult(boolean ok, Map<String, Object> data, String errorMessage, FailReason failReason)FailReason 枚举:PARSE_ERROR / MISSING_REQUIRED / TYPE_MISMATCH / KEY_NAME_MISMATCH / OTHER适配调用方:LlmExecutor、SmartActionExecutor 等执行器的调用代码完全不变(对外接口未改)。
验证方式:mock ChatClient,验证两种 prompt 的调用次数与内容。
新增测试类:
OutputValidatorStrictTest.javastrictMode_allFieldsPresent_ok — 全部字段精确存在 → 通过strictMode_chineseKeyNameRejected — LLM 返回中文键名 → missingRequired 含所有声明字段strictMode_partialMissing_fails — 缺失 1 个字段 → missingRequired 含该字段strictMode_nullValue_treatedAsMissing — parsed 中 value=null → 视为缺失strictMode_typeMismatch_stillConverts — 类型不符但可转换 → 通过(含转换后值)strictMode_extraFields_ignored — LLM 返回额外字段 → 不影响通过legacyMode_backwardCompatible — validate(parsed, decl) 等价于 validate(parsed, decl, false)StructuredOutputHelperDualLoopTest.javafirstCallOk_noRetry — LLM 首次返回合法 JSON → 直接返回,attempts=1chineseKeys_triggerFormatRetry — 首次中文,重组后英文 → ok,runtimeAttempts=1,formatAttempts=2runtimeException_triggersRuntimeRetry — 首次抛异常 → 外层重试一次成功formatRetryExhausted_upgradesToRuntime — formatMaxRetries 耗尽 → 触发外层重试allRetriesExhausted_returnsFail — 全部耗尽 → 返回 fail + errorMessagereformatPrompt_doesNotContainUserPrompt — 捕获重组 prompt,验证不含原始 userPromptreformatPrompt_listsAllMissingKeys — 验证重组 prompt 明确列出所有缺失键名改动点:
application.yml.example:
workflow:
llm:
retry:
# 运行异常重试(重新完整推理)
runtime-max-retries: 2
# 格式错误重试(仅重组 JSON)
format-max-retries: 2
# JSON 解析失败是否重试
retry-on-parse-error: true
# 必填字段缺失是否重试
retry-on-missing-required: true
# 类型不匹配是否重试
retry-on-type-mismatch: true
# 键名不匹配(如返回中文键名)是否重试
retry-on-key-name-mismatch: true
# 已弃用:被 runtime-max-retries 替代,未配置时回退
# max-retries: 2
application.yml(实际配置):同步更新
prompt.md:追加本次变更条目(用户视角)
| # | 陷阱 | 处理阶段 |
|---|---|---|
| 1 | strict 模式误伤:LLM 偶尔返回额外字段 | 忽略额外字段,只检查声明字段是否全部精确存在 |
| 2 | 类型自动转换与 strict 共存 | 类型转换逻辑在 strict 之后执行,不冲突 |
| 3 | 重组 prompt 长度爆炸(上次响应太长) | formatMaxRetries 默认 2;截断上次响应到前 2000 字(避免超 token) |
| 4 | 内层重组再失败时是否再走外层 | 是,但通过 shouldRuntimeRetry(failReason, policy) 判定,避免无限循环 |
| 5 | legacy maxRetries 字段兼容 |
@Deprecated + 过渡期映射为 runtimeMaxRetries |
| 6 | retryOnMissingRequired=false 时 strict 检测出的 missing 是否触发重试 |
新增独立 retryOnKeyNameMismatch,与 retryOnMissingRequired 解耦 |
| 7 | 重组响应解析失败(LLM 又返回非 JSON) | 内层循环继续重试,直到 formatMaxRetries 耗尽 |
reformatLoop(),单元测试单独覆盖fromNodeData 模式,仅增加字段NodeExecutor 接口NodeOutputEnvelope 三段式结构WorkflowContext 数据结构userInput / condition 节点执行器(它们不走 extractWithRetry)git revert 原子回滚max-retries 仍可读取,回滚后立即生效| 阶段 | 内容 | 估算(小时) |
|---|---|---|
| 1 | OutputValidator strict 模式 | 1.5 |
| 2 | RetryPolicy / WorkflowProperties 拆分 | 1.5 |
| 3 | StructuredOutputHelper 双循环重写 | 4 - 5 |
| 4 | 单元测试(14 用例) | 3 - 4 |
| 5 | application.yml + prompt.md | 0.5 |
| 合计 | 10.5 - 12.5 |