主线闭环:失败 → 结构化 → 反哺 Prompt/评测集/路由 → 指标提升
1. 失败案例自动捕获
自动捕获和结构化工单失败案例,是持续优化的基础。通过合理设计数据结构,实现可检索、可聚合的案例沉淀。
核心流程:
- 每次工单关闭后,自动判断是否为失败案例,并入库沉淀。
- 失败判定标准包括:
- 人工审批被拒绝(方案不可用)
- 验证节点失败(方案无效)
- 重试次数 ≥ 2(Agent 兜圈子)
- 用户7天内重复提交同类工单(首次未根治)
- LLM-Judge 评分 ≤ 2
数据结构设计要点:
- 字段围绕"可检索、可聚合"展开,便于后续分析与优化。
java
/**
* 工单失败案例评审结果
*
* @param type 案例类型:MISROUTE / TOOL_FAILURE / SOLUTION_REJECTED / RETRY_LOOP / REPEATED_ISSUE
* @param symptom 症状描述
* @param rootCause 根因
* @param resolution 处置方案
* @param toolsUsedJson 使用的工具(JSON)
* @param durationMs 总耗时(毫秒)
*/
public record CaseReview(
String type,
String symptom,
String rootCause,
String resolution,
String toolsUsedJson,
long durationMs
) {
/**
* 是否值得沉淀(命中任一失败判定即入库)
*/
public boolean worthKeeping() {
return type != null && !type.equals("OK");
}
}
java
/**
* 失败案例自动捕获
*
* <p>每次工单关闭后,判断是否需要沉淀。
* 命中任一失败判定即入库:
* <ol>
* <li>人工审批被拒绝 → 方案不可用</li>
* <li>验证节点失败 → 方案无效</li>
* <li>重试次数 >= 2 → Agent 兜圈子</li>
* <li>用户重新提交同类工单(7天内同服务同关键词)→ 首次未根治</li>
* <li>LLM-Judge 评分 <= 2</li>
* </ol>
*/
@Component
public class PostMortemRecorder {
private static final Logger log = LoggerFactory.getLogger(PostMortemRecorder.class);
@Resource
private JdbcTemplate jdbc;
/**
* 工单关闭后调用
*/
public void onWorkflowClosed(WorkflowContext ctx) {
CaseReview review = classify(ctx);
if (review.worthKeeping()) {
try {
jdbc.update("""
INSERT INTO experience_case
(case_type, symptom, root_cause, resolution, tools_used, duration_ms, trace_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())
""", review.type(), review.symptom(), review.rootCause(),
review.resolution(), review.toolsUsedJson(), review.durationMs(),
ctx.getWorkflowId());
log.info("失败案例沉淀: type={}, symptom={}, traceId={}",
review.type(), review.symptom(), ctx.getWorkflowId());
} catch (Exception e) {
log.warn("写入失败案例失败: traceId={}, err={}", ctx.getWorkflowId(), e.getMessage());
}
}
}
/**
* 判定是否值得沉淀
*/
private CaseReview classify(WorkflowContext ctx) {
List<NodeExecution> log_ = ctx.getExecutionLog();
String symptom = ctx.getString("symptom");
if (symptom == null) {
symptom = ctx.getString("description");
}
if (symptom == null) {
symptom = "未知症状";
}
// 判定1:人工审批被拒绝
boolean approvalRejected = log_.stream()
.anyMatch(e -> e.nodeId().contains("approval") && "FAILED".equals(e.status()));
if (approvalRejected) {
return new CaseReview("SOLUTION_REJECTED", symptom, "人工审批拒绝", "",
extractTools(log_), calcDuration(log_));
}
// 判定2:验证节点失败
boolean verificationFailed = log_.stream()
.anyMatch(e -> e.nodeId().contains("verification") && "FAILED".equals(e.status()));
if (verificationFailed) {
return new CaseReview("VERIFICATION_FAILED", symptom, "方案验证失败", "",
extractTools(log_), calcDuration(log_));
}
// 判定3:重试次数 >= 2(同一节点出现2次以上执行)
long maxRetries = log_.stream()
.collect(java.util.stream.Collectors.groupingBy(NodeExecution::nodeId, java.util.stream.Collectors.counting()))
.values().stream()
.max(Long::compare)
.orElse(0L);
if (maxRetries >= 2) {
return new CaseReview("RETRY_LOOP", symptom, "Agent兜圈子(重试" + maxRetries + "次)", "",
extractTools(log_), calcDuration(log_));
}
// 判定4:流程失败
if (ctx.getStatus() == WorkflowStatus.FAILED) {
return new CaseReview("WORKFLOW_FAILED", symptom, "工作流执行失败", "",
extractTools(log_), calcDuration(log_));
}
// 成功完成,不需要沉淀
return new CaseReview("OK", symptom, "", "", "", calcDuration(log_));
}
/**
* 从执行日志中提取使用的工具列表(JSON 格式)
*/
private String extractTools(List<NodeExecution> log_) {
var tools = log_.stream()
.map(NodeExecution::nodeId)
.filter(id -> id.contains("tool") || id.contains("diagnosis") || id.contains("solution"))
.distinct()
.toList();
return tools.isEmpty() ? "[]" : "[\"" + String.join("\",\"", tools) + "\"]";
}
/**
* 从执行日志计算总耗时
*/
private long calcDuration(List<NodeExecution> log_) {
if (log_ == null || log_.isEmpty()) {
return 0;
}
var first = log_.getFirst().timestamp();
var last = log_.getLast().timestamp();
return java.time.Duration.between(first, last).toMillis();
}
}
2. 从失败案例生成 Few-Shot(路由专项)
针对"路由不准"问题,自动从典型失败案例中挖掘 few-shot 样本,动态注入路由 Prompt,实现错例收敛与持续进化。
实践要点:
- 每周自动从路由错误工单中筛选典型样本,生成 few-shot 片段。
- 限定每类样本数量,防止膨胀。
- 动态挂载到路由 Prompt,自动纠正历史混淆。
java
/**
* 从失败案例生成 few-shot(路由专项)
*
* <p>应对"路由不准"最直接的解法:从路由错误的工单里挑典型样本,
* 生成 few-shot 片段注入到路由 Prompt 里。
*
* <p>效果:错例会自动收敛------同一个混淆对被纠正后不再出现,
* 新的混淆对才会冒出来。这比手工维护 few-shot 可持续得多。
*/
@Component
public class FewShotMiner {
private static final Logger log = LoggerFactory.getLogger(FewShotMiner.class);
@Resource
private JdbcTemplate jdbc;
/**
* 每类最多保留的样本数,防膨胀
*/
private static final int MAX_PER_CATEGORY = 8;
/**
* 每周执行:从路由错误的工单里挑典型样本,生成 few-shot 片段
*/
public String mineRoutingFewShots() {
try {
List<Map<String, Object>> misroutes = jdbc.queryForList("""
SELECT symptom, root_cause as expected_agent, case_type
FROM experience_case
WHERE case_type IN ('MISROUTE', 'RETRY_LOOP')
AND created_at > NOW() - INTERVAL '30 DAY'
ORDER BY created_at DESC
LIMIT 20
""");
if (misroutes.isEmpty()) {
return "";
}
// 按症状分组,每组取最高频的一条
return misroutes.stream()
.limit(MAX_PER_CATEGORY)
.map(r -> "用户:\"%s\" → %s (置信度0.90)"
.formatted(r.get("symptom"), r.get("expected_agent")))
.reduce("", (a, b) -> a + "\n" + b);
} catch (Exception e) {
log.warn("挖掘路由 few-shot 失败: {}", e.getMessage());
return "";
}
}
}
java
String dynamicExamples = fewShotMiner.mineRoutingFewShots();
// 注入到 routerAgent 的 system prompt 尾部
效果:
- 错例自动收敛,历史混淆被纠正后不再反复出现。
- 新的混淆对被及时暴露,持续优化路由准确率。
3. 工具描述的自我修复
工具调用准确率是核心指标。实践中,工具调用错误往往源于 @Tool(description=...) 描述不清晰。
自我修复机制:
- 统计每个工具的"空参调用"和"调用失败"次数。
- 空参率高或失败次数多的工具,自动生成改进建议。
优化建议举例:
- 空参率高 → 明确参数为必填,并举例取值范围。
- 失败次数多 → 检查 description 是否清晰,参数约束是否明确。
java
/**
* 工具描述的自我修复
*
* <p>统计每个工具的"空参调用"和"调用失败"次数,
* 空参率高的工具 → 自动生成改进建议。
*
* <p>实践中常见情况:工具调用错误往往源于 @Tool(description=...) 写得不够清楚,
* 而非模型能力问题。
*/
@Component
public class ToolHealthReport {
private static final Logger log = LoggerFactory.getLogger(ToolHealthReport.class);
/**
* 工具调用记录(简化版,实际从 DecisionLogger 的 TOOL_CALL 记录中提取)
*/
public record ToolCallRecord(
String toolName,
String toolArgsJson,
boolean success
) {}
/**
* 工具健康度
*/
public record ToolHealth(
String toolName,
int totalCalls,
long emptyArgsCalls,
long failedCalls,
double emptyArgsRate
) {}
/**
* 统计每个工具的"空参调用"和"调用失败"次数
*/
public Map<String, ToolHealth> toolHealthReport(List<ToolCallRecord> calls) {
return calls.stream()
.collect(Collectors.groupingBy(ToolCallRecord::toolName))
.entrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey, e -> {
var list = e.getValue();
long badArgs = list.stream()
.filter(r -> r.toolArgsJson() == null || r.toolArgsJson().equals("{}"))
.count();
long failed = list.stream()
.filter(r -> !r.success())
.count();
return new ToolHealth(
e.getKey(),
list.size(),
badArgs,
failed,
list.isEmpty() ? 0.0 : (double) badArgs / list.size()
);
}));
}
/**
* 生成工具描述改进建议
*/
public void reportAndSuggest(Map<String, ToolHealth> healthMap) {
healthMap.forEach((tool, health) -> {
if (health.emptyArgsRate() > 0.2) {
log.warn("【工具健康】{} 空参率{}% → 建议在 @ToolParam 中明确标注参数为必填,并举例取值范围",
tool, String.format("%.0f", health.emptyArgsRate() * 100));
}
if (health.failedCalls() > 3) {
log.warn("【工具健康】{} 失败{}次/{}次 → 建议检查 description 是否清晰、参数约束是否明确",
tool, health.failedCalls(), health.totalCalls());
}
});
}
}
实际收获:
- 快速定位被反复误调的工具,精准改进 description,提升整体调用成功率。
4. 评测集自动生长
保持评测集的"鲜活度",防止指标虚高但线上效果翻车。
自动生长机制:
- 持续从线上真实问题中挖掘新用例,剔除已覆盖和低价值样本。
- 候选用例经人工或 LLM 审核后晋升为正式评测用例。
- 规则过滤:短于6字或命中寒暄词的输入直接丢弃;与现有用例精确去重。
维护节奏建议:
- 每周新增 5~10 条,淘汰 5 条过时用例。
- 评测集规模建议维持在 60~80 条,重点是持续更新。
java
/**
* 评测集自增长
*
* <p>线上真实问题是评测集最宝贵的来源------手工维护的用例总会滞后于真实场景。
* 本组件从工单中挖掘新问题,剔除已覆盖和低价值样本,把候选写入 {@code eval_case_candidate} 表,
* 由人工或 LLM 筛选后晋升为正式评测用例。
*
* <p>关键约束:
* <ul>
* <li>已存在于 EvalDataset 的输入不重复收录(基于输入文本精确去重;相似去重交给 LLM 判定)</li>
* <li>短于 6 字或命中寒暄词的输入视为闲聊,直接丢弃</li>
* <li>晋升后的候选标记为 PROMOTED,避免重复晋升</li>
* </ul>
*/
@Component
public class DatasetGrower {
private static final Logger log = LoggerFactory.getLogger(DatasetGrower.class);
/**
* 寒暄关键词,命中即视为低价值
*/
private static final Set<String> CHITCHAT_KEYWORDS = Set.of(
"你好", "在吗", "谢谢", "帮我", "请问", "hello", "hi", "thanks"
);
@Resource
private JdbcTemplate jdbc;
/**
* 候选筛选器:判断是否值得加入评测集,并给出建议字段
*/
private final ChatClient curator;
public DatasetGrower(ChatClient.Builder builder) {
this.curator = builder
.defaultSystem("""
你是评测集策展人。判断一条线上工单是否值得固化为评测用例。
只返回 JSON:{"worth": true/false, "category": "routing|tool-call|rag|hallucination|workflow", "reason": "简短说明"}
""")
.build();
}
/**
* 从一条工单输入挖掘候选评测用例
*
* @param userInput 线上真实问题
* @param sourceTraceId 来源 traceId
*/
public void consider(String userInput, String sourceTraceId) {
if (userInput == null || userInput.isBlank()) {
return;
}
String trimmed = userInput.trim();
// 1. 规则过滤:闲聊与过短输入
if (trimmed.length() <= 6 || isChitchat(trimmed)) {
return;
}
// 2. 精确去重:与现有评测集重复则跳过
Set<String> existing = EvalDataset.opsCases().stream()
.map(EvalCase::input)
.map(String::trim)
.collect(Collectors.toSet());
if (existing.contains(trimmed)) {
return;
}
// 3. 与候选表去重(避免同一输入多次入库)
try {
Integer dupCount = jdbc.queryForObject(
"SELECT COUNT(*) FROM eval_case_candidate WHERE input = ?",
Integer.class, trimmed);
if (dupCount != null && dupCount > 0) {
return;
}
} catch (Exception e) {
log.debug("候选表去重查询失败,继续后续流程: {}", e.getMessage());
}
// 4. LLM 策展:判定价值并建议分类
String category = "rag"; // 兜底分类
boolean worth = true;
try {
String json = curator.prompt()
.user("""
判断以下工单是否值得固化为评测用例:
"%s"
只返回JSON:{"worth": true/false, "category": "...", "reason": "..."}
""".formatted(trimmed))
.call()
.content();
if (json != null && json.contains("\"worth\":false")) {
worth = false;
}
String parsed = extractJsonField(json, "category");
if (parsed != null && !parsed.isBlank()) {
category = parsed;
}
} catch (Exception e) {
log.debug("LLM 策展失败,按默认价值入库: {}", e.getMessage());
}
if (!worth) {
return;
}
// 5. 写入候选表,待后续晋升
try {
jdbc.update("""
INSERT INTO eval_case_candidate
(input, source_trace_id, suggested_category, status, created_at)
VALUES (?, ?, ?, 'PENDING', NOW())
""", trimmed, sourceTraceId, category);
log.info("评测候选入库: category={}, traceId={}", category, sourceTraceId);
} catch (Exception e) {
log.warn("评测候选入库失败: traceId={}, err={}", sourceTraceId, e.getMessage());
}
}
/**
* 拉取待晋升的候选(status=PENDING),供人工或 LLM 审阅
*/
public List<Candidate> pendingCandidates(int limit) {
try {
return jdbc.query("""
SELECT id, input, suggested_category, source_trace_id
FROM eval_case_candidate
WHERE status = 'PENDING'
ORDER BY created_at DESC
LIMIT ?
""", (rs, rowNum) -> new Candidate(
rs.getLong("id"),
rs.getString("input"),
rs.getString("suggested_category"),
rs.getString("source_trace_id")
), limit);
} catch (Exception e) {
log.warn("查询候选失败: {}", e.getMessage());
return List.of();
}
}
/**
* 晋升一条候选为正式评测用例(写入 EvalDataset 由人工维护,此处仅更新状态)
*/
public void promote(long candidateId) {
try {
jdbc.update("""
UPDATE eval_case_candidate
SET status = 'PROMOTED', reviewed_at = NOW()
WHERE id = ?
""", candidateId);
log.info("候选 {} 已晋升为正式评测用例", candidateId);
} catch (Exception e) {
log.warn("候选晋升失败: id={}, err={}", candidateId, e.getMessage());
}
}
/**
* 拒绝一条候选
*/
public void reject(long candidateId, String reason) {
try {
jdbc.update("""
UPDATE eval_case_candidate
SET status = 'REJECTED', reviewed_at = NOW()
WHERE id = ?
""", candidateId);
log.info("候选 {} 被拒绝:{}", candidateId, reason);
} catch (Exception e) {
log.warn("候选拒绝失败: id={}, err={}", candidateId, e.getMessage());
}
}
/**
* 候选用例
*/
public record Candidate(long id, String input, String suggestedCategory, String sourceTraceId) {}
/**
* 简易判定是否为寒暄
*/
private boolean isChitchat(String text) {
String lower = text.toLowerCase();
return CHITCHAT_KEYWORDS.stream().anyMatch(lower::contains);
}
/**
* 简易 JSON 字段提取(与 MemoryConsolidator 同风格,避免引入 JSON 库)
*/
private static final java.util.regex.Pattern FIELD_PATTERN =
java.util.regex.Pattern.compile("\"(\\w+)\"\\s*:\\s*\"([^\"]*)\"");
private String extractJsonField(String json, String field) {
if (json == null) {
return null;
}
var matcher = FIELD_PATTERN.matcher(json);
while (matcher.find()) {
if (matcher.group(1).equals(field)) {
return matcher.group(2);
}
}
return null;
}
}
5. 裁判模型校准
监控 LLM-Judge 与人工复核评分的一致性,防止评分标准随时间"漂移"。
校准方法:
- 按月抽样人工复核 LLM-Judge 评分,统计一致率趋势。
- 指标分级:
- 完全一致:人工分 = LLM 分
- 可接受偏差:|人工分 - LLM 分| ≤ 1
- 显著偏差:|人工分 - LLM 分| ≥ 2
- 告警阈值:
- 一致率 < 70% 或显著偏差 > 30% 时,提示重新校准 rubric 或更换裁判模型。
java
/**
* 裁判模型校准 ------ 监控 LLM-Judge 与人工复核的一致性
*
* <p>LLM-as-a-Judge 最怕的不是评分偏低,而是评分"漂移"------同一类用例的严格程度随时间变化。
* 本组件按月抽样人工复核 LLM-Judge 的评分,统计一致率趋势:
* <ul>
* <li>完全一致:人工分 == LLM 分</li>
* <li>偏差可接受:|人工分 - LLM 分| ≤ 1</li>
* <li>显著偏差:|人工分 - LLM 分| ≥ 2</li>
* </ul>
*
* <p>当月一致率 < 0.7 或显著偏差占比 > 30% 时告警,提示需要重新校准 rubric 或更换裁判模型。
*/
@Component
public class JudgeCalibration {
private static final Logger log = LoggerFactory.getLogger(JudgeCalibration.class);
/**
* 人工复核抽样比例:默认 20%
*/
private static final double SAMPLE_RATIO = 0.2;
/**
* 一致率告警阈值
*/
private static final double CONSISTENCY_THRESHOLD = 0.7;
/**
* 显著偏差占比告警阈值
*/
private static final double SIGNIFICANT_DEVIATION_THRESHOLD = 0.3;
@Resource
private JdbcTemplate jdbc;
/**
* 记录一次 LLM-Judge 评分,等待人工抽样复核
*
* @param caseId 用例 ID
* @param result LLM-Judge 评分结果
* @param sourceTraceId 来源 traceId
*/
public void recordJudgeResult(String caseId, JudgeResult result, String sourceTraceId) {
if (!result.valid()) {
return;
}
try {
// 按抽样比例决定是否需要人工复核
boolean needsHumanReview = Math.random() < SAMPLE_RATIO;
jdbc.update("""
INSERT INTO judge_calibration
(case_id, llm_score, llm_reason, human_score, human_reviewed, source_trace_id, created_at)
VALUES (?, ?, ?, NULL, ?, ?, NOW())
""", caseId, result.score(), result.reason(),
needsHumanReview, sourceTraceId);
} catch (Exception e) {
log.warn("记录 Judge 评分失败: caseId={}, err={}", caseId, e.getMessage());
}
}
/**
* 提交一次人工复核评分
*
* @param recordId judge_calibration.id
* @param humanScore 人工评分(0~5)
*/
public void submitHumanReview(long recordId, int humanScore) {
try {
jdbc.update("""
UPDATE judge_calibration
SET human_score = ?, human_reviewed = TRUE, reviewed_at = NOW()
WHERE id = ?
""", humanScore, recordId);
log.info("人工复核提交: recordId={}, humanScore={}", recordId, humanScore);
} catch (Exception e) {
log.warn("人工复核提交失败: recordId={}, err={}", recordId, e.getMessage());
}
}
/**
* 月度校准报告:统计本月 LLM-Judge 与人工复核的一致性
* <p>每月 1 日凌晨执行
*/
@Scheduled(cron = "0 0 2 1 * ?")
public void monthlyCalibrationReport() {
try {
Map<String, Object> stats = jdbc.queryForMap("""
SELECT
COUNT(*) AS total,
COUNT(CASE WHEN human_score = llm_score THEN 1 END) AS exact_match,
COUNT(CASE WHEN ABS(human_score - llm_score) <= 1 THEN 1 END) AS acceptable,
COUNT(CASE WHEN ABS(human_score - llm_score) >= 2 THEN 1 END) AS significant_deviation,
AVG(ABS(human_score - llm_score)) AS avg_deviation
FROM judge_calibration
WHERE human_reviewed = TRUE
AND created_at >= date_trunc('month', NOW()) - INTERVAL '1 MONTH'
AND created_at < date_trunc('month', NOW())
""");
long total = asLong(stats.get("total"));
if (total == 0) {
log.info("[月度校准] 本月无人工复核样本,跳过校准");
return;
}
long exactMatch = asLong(stats.get("exact_match"));
long acceptable = asLong(stats.get("acceptable"));
long significant = asLong(stats.get("significant_deviation"));
double avgDeviation = asDouble(stats.get("avg_deviation"));
double consistencyRate = (double) acceptable / total;
double significantRate = (double) significant / total;
log.info("[月度校准] 样本数={}, 完全一致={}, 一致率(偏差≤1)={}, 显著偏差(≥2)={}, 平均偏差={}",
total, exactMatch,
String.format("%.0f%%", consistencyRate * 100),
String.format("%.0f%%", significantRate * 100),
String.format("%.2f", avgDeviation));
if (consistencyRate < CONSISTENCY_THRESHOLD) {
log.warn("[月度校准告警] 一致率 {}% 低于阈值 {}% → 建议重新审视 rubric 或更换裁判模型",
String.format("%.0f", consistencyRate * 100),
String.format("%.0f", CONSISTENCY_THRESHOLD * 100));
}
if (significantRate > SIGNIFICANT_DEVIATION_THRESHOLD) {
log.warn("[月度校准告警] 显著偏差占比 {}% 超过阈值 {}% → 裁判模型可能存在系统性偏差",
String.format("%.0f", significantRate * 100),
String.format("%.0f", SIGNIFICANT_DEVIATION_THRESHOLD * 100));
}
} catch (Exception e) {
log.warn("月度校准报告生成失败: {}", e.getMessage());
}
}
/**
* 拉取待人工复核的样本
*/
public List<Map<String, Object>> pendingHumanReviews(int limit) {
try {
return jdbc.queryForList("""
SELECT id, case_id, llm_score, llm_reason, source_trace_id, created_at
FROM judge_calibration
WHERE human_reviewed = FALSE
ORDER BY created_at DESC
LIMIT ?
""", limit);
} catch (Exception e) {
log.warn("查询待复核样本失败: {}", e.getMessage());
return List.of();
}
}
/**
* 兜底 LlmJudge 引用,确保模块依赖 observability(避免被误删)
*/
@SuppressWarnings("unused")
private LlmJudge judgeRef;
/**
* Spring 注入 LlmJudge,保持对 observability 模块的编译期依赖
*/
@Resource
public void setJudgeRef(LlmJudge judgeRef) {
this.judgeRef = judgeRef;
}
private static long asLong(Object v) {
if (v == null) {
return 0L;
}
if (v instanceof Number n) {
return n.longValue();
}
try {
return Long.parseLong(v.toString());
} catch (NumberFormatException e) {
return 0L;
}
}
private static double asDouble(Object v) {
if (v == null) {
return 0.0;
}
if (v instanceof Number n) {
return n.doubleValue();
}
try {
return Double.parseDouble(v.toString());
} catch (NumberFormatException e) {
return 0.0;
}
}
}
总结
- 建立自动化闭环,让失败案例反哺 Prompt、评测集和路由,驱动指标持续提升。
- 通过结构化数据、自动挖掘、健康度自检和模型校准,实现体系化、可持续优化。
建议:
- 关注数据可检索、可聚合,便于分析和持续改进。
- 保持评测集和路由 few-shot 的动态更新,避免"过拟合"历史场景。
- 定期校准裁判模型,确保评测指标的客观性与权威性。