我们用实战,让 Bad Case 自己说出根因

37 阅读12分钟

我们用实战,让 Bad Case 自己说出根因

一次生产 RAG 系统的痛点修复实录 —— 当 3 个具体到代码的洞,撞上「Workflow + ReAct」双引擎

cover.png

写在前面:一行命令替代人工 triage

客服同事在群里丢了一条「机器人又答错了」的消息。过去我要做的事是:登录 /admin/bad_cases、找到那条 case、读 query、读故障答案、读期望答案、凭经验判根因、写 PATCH。一条 case 至少 5 分钟,一周累计几十条,月底一看大半其实只是 R1 召回不到、R5 答案编造、R7 误拒这种高频模式。

这篇文章要讲的就是:把这些高频模式写进代码,让诊断从"人工逐条 triage"变成"自动归因 + 人工只确认"。整条链路在 VM 192.168.200.128 上跑过 6 项端到端验证,3 类种子 3/3 命中,下面把架构、代码、实战、坑点一次性讲透。


🔗 完整代码已开源 → lingluo1hao/enterprise-ai 觉得这篇实战复盘有用?点个 Star ⭐ / Fork 拿去改 —— 你的 star 是我继续把踩坑写成文章的底气,评论区也欢迎一起讨论。

一、现状:三个具体到代码的洞

我们的 bad case 闭环原本是:

入口① 用户点踩     POST /api/feedback            (rag_web_server.py:1328)
入口② pipeline 异常  langgraph_rag_agent.py:1215
入口③ 评测失败      evalkit run
        │
        ▼
bad_cases 表(root_cause 可空、status=open)
        │
        ▼
人工 /admin/bad_cases 逐条看:query / 故障答案 / 期望答案 →
凭经验判根因 → PATCH 更新 → resolved

洞 1 · 点踩样本零诊断。evalkit/triage.py:classify() 是规则阈值判定,依赖 Recall@5 / bury / nDCG@5 / forbidden_hits 这些 golden 集信号。点踩进来的 case 没有 gold 标注 —— 这套规则根本跑不了,于是全部病例都落到了人工头上。

洞 2 · 根因写不进库。memory_store.update_bad_case_status()(memory_store.py:847)只支持 status / resolved_by / diagnosis / expected 四个字段,没有 root_cause 参数——连人工通过 PATCH 接口都设不了根因(rag_web_server.py:1364 同样透传不了)。GET 的 ?root_cause=R5 筛选形同虚设。

洞 3 · 诊断没有执行体。一次像样的诊断需要连续动作——复跑检索看召回、用强 judge 复判当时的答案、对比矛盾信号、按 R1~R8 归因、写出带证据的结论。这是多步、有分支、可能要临场换角度查证的 agent 任务,而我们没有承载它的模块。


二、思路:Workflow 骨架 + ReAct 局部智能

dual_engine.png

这个项目里我们把同一个问题拆给两种 Agent 形态,真实分工,不是「一个兜底另一个」:

形态干什么为什么这样选
Workflow(预定义 DAG)复跑检索 → 探查文档相关性 → judge 复判 → 规则归因 → 回写步骤固定可枚举;LLM 调用数恒定(3~4 次/条),成本可预测;失败可定位到节点;大部分 case 在这层结案
ReAct(动态决策)标准流水线低置信/信号矛盾时的开放探查:换关键词重搜、查文档是否入库、查相似历史 case任务不可预知,需要模型逐步决定下一步;复用现有 ReActAgent + SkillRegistry

这正是业界「Workflow 做骨架 + ReAct 做局部智能节点」的生产共识(Anthropic《Building effective agents》范式)。分支规则写在代码里(何时升级到 ReAct),LLM 只在节点内部干活 —— 这条纪律比任何架构图都重要。


三、架构:触发 → 运行时 → 复用

arch_overview.png

诊断运行时收口在 agentworkflow/ 包:

  • pipeline.py:诊断 DAG(7 节点 StateGraph)
  • probe.py:ReAct 探查适配器(复用 ReActAgent + SkillRegistry)
  • rules.py:归因规则表(纯 Python、零外部依赖)
  • trace.py:统一轨迹 StepRecord
  • diagnose.py / __main__.py:CLI 入口

复用层全部走现有组件:VectorStoreManager(双视角复跑)/ evalkit.JudgeLLM(复判)/ evalkit.triage._ROOT_CAUSES(R1~R8 文案库)/ llm_gateway(evalgrade/react/generate 三条链)/ memory_store(+1 个 root_cause 参数)/ audit_logger(+第 8 类 bad_case_diagnose)。

唯一存储层改动:update_bad_case_status() 增加 root_cause 参数(root_cause 列本就存在,零 schema 变更)。


四、诊断 DAG:分支写死在代码里

dag_flow.png

7 节点的拓扑是写死的,但每个节点内部可以调 LLM 干具体活。下面是组装代码的骨架 —— LangGraph StateGraph + 条件路由把「什么时候升级到 ReAct」也写进了图里:

# agentworkflow/pipeline.py · build_diagnosis_graph()
g = StateGraph(DiagState)
g.add_node("prepare",            traced("prepare",            node_prepare))
g.add_node("rerun_retrieval",    traced("rerun_retrieval",    node_rerun_retrieval))
g.add_node("probe_docs",         traced("probe_docs",         node_probe_docs))
g.add_node("judge_answer",       traced("judge_answer",       node_judge_answer))
g.add_node("classify_root_cause",traced("classify_root_cause",node_classify))
g.add_node("react_probe",        traced("react_probe",        node_react_probe))
g.add_node("writeback",          traced("writeback",          node_writeback))

g.add_edge(START, "prepare")
g.add_edge("prepare",            "rerun_retrieval")
g.add_edge("rerun_retrieval",    "probe_docs")
g.add_edge("probe_docs",         "judge_answer")
g.add_edge("judge_answer",       "classify_root_cause")
# ★ 分支写进代码:低置信 → react_probe;高/中置信 → writeback
g.add_conditional_edges(
    "classify_root_cause", route_after_classify,
    {"react_probe": "react_probe", "writeback": "writeback"})
g.add_edge("react_probe",  "writeback")
g.add_edge("writeback",    END)
return g.compile()

注意双视角复跑 —— node_rerun_retrieval 同时跑两路:

# 本租户 admin 视角(tenant_id expr 下推)
scoped = VectorStoreManager.search(
    db, query, k=RETRIEVE_K, filter_role="admin",
    user_id="agent-diagnosis", tenant_id=tenant)
# 全库视角(super_admin → 无 expr)
full = VectorStoreManager.search(
    db, query, k=RETRIEVE_K, filter_role="super_admin",
    user_id="agent-diagnosis", tenant_id="__global__")

这一对组合直接服务于 R8 跨租户泄漏实锤(scoped 命中其他租户)和 R4 方向提示(scoped 零召回但 full 能搜到)—— 这两个判定都是点踩样本最该看、而旧 triage 规则完全看不到的视角。


五、归因决策树:纯规则 R1~R8

rc_tree.png

classify_signals 是这次最值得贴的一段 —— 它的纪律在于:判定按优先级短路、不依赖 LLM、任何 case 都能在几行内说清它会落到哪个分支:

# agentworkflow/rules.py · classify_signals()  摘核心
def classify_signals(*, leak_hits, scoped_hits, full_hits,
                     docs_relevant, docs_gap, scores,
                     refused, source, retrieval_error) -> Dict:
    faith, rel = scores.get("faithfulness"), scores.get("relevancy")

    # 0. 复跑彻底失败 → 不硬猜
    if retrieval_error and not scoped_hits and not full_hits:
        return _result(None, f"复跑检索失败:{retrieval_error}", "低")

    # 1. 安全类 R8(最优先)
    if leak_hits:
        return _result("R8",
            f"本租户视角复跑命中其他租户文档({files}),存在越权风险。", "高")

    # 2. 召回类 R1(含 R4 方向提示的诚实边界)
    if (not scoped_hits) or (docs_relevant is False):
        if not scoped_hits and full_hits:
            # 全库能看到、本租户视角看不到 → 更像权限/租户配置问题(R4 方向)
            return _result("R1",
                f"本租户视角零召回,但全库可检索到 {len(full_hits)} 条,"
                f"疑似租户/权限过滤误杀(R4 方向),需人工确认。", "中")
        return _result("R1", f"当前检索无法支撑该问题(…)", "高")

    # 3. 拒答类 R7
    if refused:
        return _result("R7", "检索内容足以回答,但当时答案表现为拒答(误拒)。", "中")

    # 4. 生成类 R5/R6(互斥)
    if faith is not None and faith < FAITH_TH:        # 0.6
        return _result("R5", f"faithfulness={faith:.2f} 低于阈值 {FAITH_TH},"
                              f"答案不忠于上下文。", "高")
    if rel is not None and rel < REL_TH:              # 0.6
        return _result("R6", f"relevancy={rel:.2f} 低于阈值 {REL_TH},答非所问。", "中")

    # 5. 全正常但被标失败 → 升级 ReAct
    return _result(None, f"复跑与 judge 均正常(faith={faith}, rel={rel}),"
                          f"与失败标记矛盾(source={source})。",
                   "低", escalate=True)

几个值得展开的设计点:

  • R4 是诚实边界,不是定论。点踩样本没有记录当时用户角色,规则只能"提示方向",写不到 R4。这点我故意没在代码里假装能定论。
  • judge 不可用时不下生成类结论。if scores.get("error") or (faith is None and rel is None) 把「检索正常 + judge 缺位」也划到 code=None 的转人工分支,不硬猜。
  • R5/R6 阈值与 harness 判分口径一致(FAITH_TH = REL_TH = 0.6),不是另起炉灶,方便后续和 golden 集评测结果对照。

六、统一轨迹:一份证据链

trace_timeline.png

诊断 DAG 产出的是「节点流转」,ReAct 探查产出的是「think → act → observe」——两种异构轨迹必须归一到同一条记录,否则页面、报表、落盘 json 三处展示都对不齐。

# agentworkflow/trace.py · StepRecord
@dataclass
class StepRecord:
    engine: str                 # "workflow" | "react"
    seq: int                    # 引擎内步骤序号(各自从 1 递增)
    step_type: str              # workflow: node_enter/node_exit;
                                # react: think/act/observe
    node_or_tool: str           # workflow 记节点名;react 记工具名
    input: str = ""             # 截断后的输入摘要
    output: str = ""            # 截断后的输出摘要
    llm_task: str = ""          # 该步走的网关任务链(非 LLM 步为空)
    tokens_in: int = 0
    tokens_out: int = 0
    latency_ms: int = 0
    ts: float = field(default_factory=time.time)

一份轨迹 · 三处消费:

  1. 落盘:agentworkflow/runs/diag-<bc_id>-<ts>.json(诊断证据链,可复查可回放)
  2. API 返回:CLI / Web 接口附 steps={workflow:N, react:M} 摘要
  3. 诊断失败排查:卡哪一步?打开 runs json → steps[] 按时间序定位节点/工具/异常串

这种归一的价值在实战里立刻显现:当 Workflow 升级到 ReAct 探查时,同一份 run json 里你能看到「7 节点 enter/exit + 6 步 think/act/observe」按时间序排列,证据链是连续的、可视的。


七、实战验证:VM 在线实测

live_results.png

这是这次最值得看的部分 —— 全部在 VM 192.168.200.128 实跑,不是模拟。

#验证项结果
1端到端真实诊断(真实点踩 case #9,yh 租户)✅ R7 拒答错误(误拒),中置信:yh 视角复跑成功召回学生证协议 V5.0(含工作模式内容),但当时答案表现为拒答
2R1 种子(#10,财务问题)✅ R1 高置信:scoped 召回 8 条协议片段,LLM 探查正确判定"无财务数据"
3R5 种子(#14,编造停止位数值)✅ R5 高置信:faithfulness=0.00,judge 证据"停止位数值与上下文不符,且编码方式无出处"
4R6 种子(#15,答 UTF-8 不答 SOS 格式)✅ R6 中置信:faithfulness=1.00 + relevancy=0.00 的教科书信号组合
5ReAct 升级分支(#16,信号全正常的点踩)✅ 完整走通:Workflow 低置信 → 升级 ReAct → 2 步探查确认"停止位信息与系统答案一致"(点踩存疑)→ 未产出 JSON → 按设计不硬猜、携证据转人工
6Web 路由全链路✅ POST /api/admin/bad_cases/13/diagnose:200 + R1 + 审计 bad_case_diagnose 完整落盘

验收第 5 条(3 类种子 3/3 命中) ✅ — #10 R1 / #14 R5 / #15 R6 全部命中正确 R 码。

两个真实发现(比种子假设更值钱)

发现 1 · 诊断系统比种子假设更准。最初我准备用"定位方式"类 query 构造的 R5/R6 种子(#11/#12/#13)被系统判为 R1 —— 追查发现"定位方式"内容只存在于其他租户的文档(学生证协议 V5.0),jm 租户视角确实召不回。系统对"当前检索状态"忠实,种子的 ground truth 假设错了。这本身验证了双视角检索设计的价值。

发现 2 · 知识库数据质量线索。jm 协议文档的 top 召回里混有近乎空内容的页眉 chunk("个人定位终端通讯协议"重复数十字)排在有效段落之前 —— 这是潜在的召回噪声源,建议后续在 ingest 侧过滤空 chunk(与 BadCase9 报告的检索层议题同族)。


八、核心 CLI 体验

# ① 冒烟:dry-run 不落库
python -m agentworkflow --bc-id 17 --dry-run

# ② 正式诊断:自动回写 root_cause + status 推进
python -m agentworkflow --bc-id 17

# ③ 机器可读:含完整轨迹摘要(程序化取值建议直接读 runs json)
python -m agentworkflow --bc-id 17 --json

输出形态(节选,bc_id=14 的 R5 种子):

============================================================
  Bad Case #14 自动诊断
============================================================
  根因     : R5 生成幻觉
  引擎     : workflow   置信度: 高
  回写     : 已写入 bad_cases(open→in_progress)
  轨迹     : agentworkflow\runs\diag-14-….json(workflow 10 步 / react 0 步)
------------------------------------------------------------
【自动诊断·workflow】R5 生成幻觉
证据:faithfulness=0.00 低于阈值 0.6,答案不忠于上下文。
(judge:停止位数值与上下文不符,且编码方式无出处)
复跑召回(本租户视角):8 条(协议 V5.0 §3.2 …)
建议:① 强化 generate prompt 约束 ② 补充相关文档 ③ 降温度/加 cite 要求
置信度:高(机器建议,确认后请人工流转状态)

九、工程边界与诚实设计

边界设计选择
不做自动修复执行诊断产出"根因 + 建议";playbook 强化仍走既有 evolution.patch_success 流程,P2 才考虑对接
不取代人工确认resolved 仍由管理员操作;诊断建议附带置信度,页面明示"机器建议"
R4 只能提示不能定论点踩样本没有记录当时用户角色,规则只能输出方向,写不到 R4
LLM 调用 ≤6 次硬顶DAG 3~4 次 + ReAct 探查 max_steps=5 + 串行执行;批量触发等 P2
复跑检索绕过 Redis 缓存诊断要看真实召回,不是缓存命中;DocSearchSkill 的缓存路径需要单独走
限流 6/min单次诊断 30s~2min(CPU 模型下更久),Web 端按钮置 loading 态,避免被连点刷爆
零新增 LLM 路由全部 task= 走现有链(evalgrade/react/generate),Token 按触发者 actor 归因

十、收尾:怎么用、代码在哪

enterprise-ai/
└── agentworkflow/
    ├── pipeline.py          # 7 节点 StateGraph
    ├── rules.py             # 归因规则表(纯 Python、零外部依赖)
    ├── probe.py             # ReAct 探查适配
    ├── trace.py             # 统一轨迹
    ├── diagnose.py          # 编排 + 组件注入
    └── __main__.py          # CLI
tests/test_agentworkflow.py  # 25 个表驱动断言(归因规则 / 解析器 / 轨迹)

测试:

python tests/test_agentworkflow.py           # 25/25 PASS(归因规则表驱动 / 解析器 / 统一轨迹)
python tests/test_evolution_p1.py            # 16/16 PASS
python tests/test_p1_review_fixes.py         # 29/29 PASS
python tests/test_p1_coverage_gaps.py        # 36/36 PASS(覆盖率补齐)
# 合计 106/106 全绿,无外部依赖

📦 完整代码已开源:github.com/lingluo1hao…

👉 仓库里你能直接拿到:

  • agentworkflow/ 诊断运行时(本文全部代码出处)
  • evalkit/ judge + triage 规则
  • llm_gateway.py 三条链路网关
  • docs/reports/AgentWorkflow_BadCase自动诊断方案.md 完整版方案

如果这篇帮你少踩了几个坑,点个 Star ⭐ / Fork 拿去改 —— 开源不易,你的 star 是我继续写实战复盘的动力。


写在最后

写完回看,最想说的是两句话:

第一,Workflow 形态的纪律是"分支写死在代码里"。任何试图让 LLM 决定"接下来跑哪个分支"的诱惑都要拒绝 —— 一旦开了这个口子,诊断就会变成不可解释、不可回放、不可批量回归的玄学。LLM 只在节点内部干活,永远不能改变流程形状。

第二,Agent 形态的选择要服从问题结构。诊断这件事的"信号可枚举"和"信号矛盾时需临场查证"两个特征,分别对应 Workflow 和 ReAct 的甜区。硬选一种,要么写一堆 if-else 假装是智能,要么把简单 case 烧掉几十次 LLM 假装是高级。

实战里看下来,3 类种子 3/3 命中 + ReAct 升级分支不硬猜 + 真实 case #9 R7 误揪出来 —— 这套「Workflow 骨架 + ReAct 兜底」的组合,在我们这个量级(约 30 条 bad case / 周、3 个 R 码高频模式)上是稳的。至于 P2 的批量诊断 + 诊断直达自进化,等网关配额稳了再上。

有问题欢迎评论区一起讨论,看到能秒回。