MindTrip 与 AtlasSplit:11 项改进的工程实践

· 约 8 分钟 ·
本文目录(15 节)

开头

上一篇写完 AtlasSplit 的错误类型分布分析后,我在结尾列了 11 条改进建议。

说实话当时只是列了个清单,能不能落地、效果如何,心里没底。

这篇就是把那 11 条全部跑通之后的实践记录——5 条给 MindTrip,6 条给 AtlasSplit。

写文章最怕的就是”建议提了一堆,一个没做”。所以这次我逼自己全部实现,然后把真实数据摆出来。

先说一句总结:11 项全部落地,没有一项是”理论可行但工程做不了”的。 差别只在于有些效果好得超预期,有些效果一般但方向对。

下面按项目拆开讲。


MindTrip:5 项改进

MindTrip 是我做的海南旅游知识库 RAG 系统,覆盖万宁、陵水、三亚等地的景点、美食、交通数据。上一轮评测跑完,Recall 卡在 0.917,unexpected entity rate 还有 0.069,120 个 case 手动跑一次要半小时。

1. 评测自动化:从手动跑到 CI 回归

这是最该先做的一件事。

之前每次改完检索逻辑,我都要手动跑 120 个 case,肉眼对比 baseline。124 个 case(后来扩充的)×4 个指标,手动对比根本不现实。

问题很直接:

120 cases × 4 metrics = 480 个数字
人工对比 → 漏看、误判、而且慢

所以我写了 eval_runner.py,核心逻辑就三步:跑全部 case、和 baseline 对齐、生成 diff 报告。

# eval_runner.py 核心结构
def run_regression(eval_set: str, baseline_path: str) -> DiffReport:
    current = run_all_cases(eval_set)          # 跑当前版本
    baseline = load_baseline(baseline_path)     # 加载基线
    diff = compare(current, baseline)            # 逐 case 逐指标对比
    report = render_diff(diff)                   # 生成可视化报告
    return report

diff 报告长这样,重点高亮 pass→fail 的退化:

=== MindTrip Eval Regression Report ===
Date: 2026-08-13  Time: 1m 47s

Summary:
  Total cases: 124
  Pass: 118  Fail: 6  New failures: 2  Recovered: 0

Metric changes vs baseline:
  Context Recall:      0.917 → 0.911  (-0.006)
  Entity Precision:    0.931 → 0.931   (0.000)
  Unexpected Entity:    0.031 → 0.043  (+0.012)  ⚠️
  Faithfulness:         0.895 → 0.892  (-0.003)

--- Regressions (pass→fail) ---
[CASE-047] 万宁最适合冲浪的沙滩
  Recall: 0.83 → 0.50  Δ=-0.33
  Cause: RRF weight change dropped "日月湾" doc
  
[CASE-089] 陵水到三亚的交通方式
  Unexpected Entity: 0.04 → 0.25  Δ=+0.21
  Cause: reranker missed "清水湾" as unrelated entity

关键是 pass→fail 的 case 会被自动标出来,不用我一个个翻。

接进 CI 之后,每次 push 自动跑,2 分钟内出结果。退化超过阈值直接 block 合并。

CI pipeline:
  push → lint → unit test → eval_runner (baseline v1.2)
                                     ↓
                            diff > threshold? → block merge ✅
                            diff ≤ threshold? → update baseline ✅

结果:从”半小时手动跑+肉眼对比”变成”2 分钟自动出报告”。退化当天就能发现,不用等用户反馈。


2. Reranker:RRF 之后的精度补刀

RRF 混合检索把 Recall 干到 0.917,但 unexpected entity rate 还有 0.069。

什么意思呢?相关文档是召回了,但 top-3 里混进了一些带相似关键词但实际无关的文档。RRF 只管”多路投票”,不管精度。

RRF 召回的 top-10:
  ✓ 日月湾冲浪攻略        (高度相关)
  ✓ 万宁海湾冲浪指南       (高度相关)
  ✗ 三亚湾冲浪体验分享      (相似但不相关,用户问的是万宁)
  ✓ 万宁旅游交通地图        (相关上下文)
  ✗ 海南冲浪历史发展        (相似但不相关)
  ...

所以我在 RRF 之后加了一层 reranker:bge-reranker-v2-m3。

# reranker 模块
from FlagEmbedding import FlagReranker

class RerankStage:
    def __init__(self):
        self.reranker = FlagReranker(
            'BAAI/bge-reranker-v2-m3',
            use_fp16=True
        )
    
    def rerank(self, query: str, candidates: list[Doc], top_k: int = 3):
        # 只对 RRF 的 top-10 做 rerank,控制延迟
        pairs = [[query, doc.text] for doc in candidates[:10]]
        scores = self.reranker.compute_score(pairs, normalize=True)
        ranked = sorted(zip(candidates[:10], scores), 
                        key=lambda x: x[1], reverse=True)
        return [doc for doc, _ in ranked[:top_k]]

整个 pipeline 变成:

Query → BM25 + Dense + Metadata
              ↓
           RRF Merge (top-10)
              ↓
         bge-reranker-v2-m3
              ↓
           top-3 → LLM

reranker 的成本是额外 ~80ms 延迟,但精度收益很值:

                    Recall    Unexpected Entity
RRF only:           0.917      0.069
RRF + Reranker:     0.917      0.031   ← 降了一半

Recall 没动,因为 reranker 只重排不召回。但 unexpected entity rate 从 0.069 降到 0.031,基本砍半。

结论:RRF 负责”找得到”,reranker 负责”排得准”。两段式比单段式更省心。


3. 否定型查询的 Query Rewriting

这个是被一个 case 刺痛了才做的。

case 是:“万宁除了日月湾还有什么冲浪的地方?” 所有检索方法的 unexpected entity rate 都超过 0.22。

为什么?因为”日月湾”出现在 query 里,BM25 和 dense embedding 都会把它当作强信号,拼命召回日月湾相关文档。

Query: "万宁除了日月湾还有什么冲浪的地方"

BM25 召回 top-3:
  ✗ 日月湾冲浪详细攻略      ← 被排除的实体反而排第一
  ✗ 日月湾最佳冲浪时段
  ✓ 万宁其他海湾冲浪点

根本问题是检索器不懂否定语义。

所以我加了 query rewriting,在检索之前用 LLM 重写 query,提取出排除实体:

# query_rewriter.py
NEGATION_REWRITE_PROMPT = """
分析用户查询中的否定语义,提取需要排除的实体。

用户查询: {query}

输出 JSON:
{{
  "rewritten_query": "去除否定部分后的核心查询",
  "exclude_entities": ["需要排除的实体"],
  "exclude_reason": "为什么排除"
}}

注意:只处理明确的否定(除了、不要、除了X之外),
不要过度推断。
"""

def rewrite_negation(query: str) -> RewriteResult:
    result = llm.extract(NEGATION_REWRITE_PROMPT, query=query)
    # "万宁除了日月湾还有什么冲浪的地方"
    # → rewritten_query: "万宁冲浪的地方"
    # → exclude_entities: ["日月湾"]
    return result

然后 rewrite 的结果接到 metadata filter 上:

Original Query
     ↓
LLM Rewrite (extract exclude_entities)
     ↓
Rewritten Query + Metadata Filter
     ↓
BM25 + Dense (filtered)
     ↓
RRF + Reranker
     ↓
LLM Answer

filter 这步很关键——不是改 query 文本去搜,而是在检索结果里直接过滤掉包含排除实体的文档:

def apply_negation_filter(docs: list[Doc], 
                          exclude_entities: list[str]) -> list[Doc]:
    filtered = []
    for doc in docs:
        if not any(ent in doc.text for ent in exclude_entities):
            filtered.append(doc)
    return filtered if filtered else docs  # 兜底:全过滤完了就不过滤

效果:

否定型查询 (12 cases):
                    Before    After
Recall:             0.72      0.89   ← +0.17
Unexpected Entity:  0.221     0.067  ← 降了 70%

这件事让我意识到:不要指望 retrieval 自己理解否定语义,在 query 层就把意图解构掉。


4. 评测集扩充:用户反馈 → Bad Case 管线

120 个 case 是我手动标的,但用户实际问的问题远不止这些。

最大的问题:评测集是静态的,跑不出来的 failure pattern 我根本不知道。

所以我搭了一个 feedback pipeline,把线上用户的”踩”反馈变成候选评测 case:

用户查询 → RAG 回答 → 用户点"踩" 👎
                           ↓
                    feedback_pipeline.py
                           ↓
                    ┌──────┴──────┐
                    ↓             ↓
              存原始 query     触发 LLM 分析
                    ↓             ↓
              对齐 ground truth  生成 draft case
                    ↓             ↓
                    └──────┬──────┘
                           ↓
                    人工 review queue
                           ↓
                    合并进 eval set

核心代码:

# feedback_pipeline.py
class FeedbackPipeline:
    def collect(self, query: str, answer: str, thumbs: str):
        if thumbs != "down":
            return
        # 存原始数据
        self.store.save(query, answer, timestamp=now())
    
    def generate_draft_case(self, raw_feedback: RawFeedback):
        # LLM 分析这个 bad case 的问题类型
        analysis = llm.analyze(
            query=raw_feedback.query,
            answer=raw_feedback.answer,
            prompt=DRAFT_CASE_PROMPT
        )
        # 生成标准格式的 eval case
        return EvalCase(
            query=raw_feedback.query,
            expected_entities=analysis.entities,
            expected_context=analysis.context,
            case_type=analysis.failure_type,
            source="user_feedback"
        )

第一个月的效果:

用户反馈收集:89 条 "踩"
→ LLM 生成 draft case:71 条
→ 人工 review 通过:43 条
→ 合并进 eval set

新发现的 query 类型:
  - 跨城市比较:"万宁和陵水哪个更适合带小孩"  (14 cases)
  - 多约束查询:"万宁有冲浪又能露营的地方"    (8 cases)
  - 时间限定:"下午3点后还能去的景点"         (6 cases)

最有价值的是发现了”跨城市比较”这个新类型——之前我的评测集完全没有覆盖。

评测集不是一次性的工作,是持续生长的。没有用户反馈管线,就是在自己画的圈里自测。


5. Judge 模型分层:14B 太贵,3B 太弱

RAGAS 评测需要 LLM 做 judge,我之前用的是 Qwen2.5-14B。

120 个 case × 4 个 metric = 480 次 LLM 调用。14B 跑一次完整评测,GPU 费用不低。

但换成 3B 呢?跑了一次,发现几个边界 case 判断不稳定,准确率和 14B 差了 ~8%。

所以做了分层策略:

           所有 case × 4 metrics
                  ↓
           3B 先全部打分 (cheap)
                  ↓
        ┌─────────┴──────────┐
        ↓                    ↓
   分数稳定               分数波动大
   (Δ < threshold          (Δ ≥ threshold
    vs baseline)            vs baseline)
        ↓                    ↓
     直接采用            14B 重新打分 (expensive)
        ↓                    ↓
        └─────────┬──────────┘
                  ↓
            最终分数
# tiered_judge.py
class TieredJudge:
    def __init__(self):
        self.fast_model = LLM("Qwen2.5-3B")
        self.accurate_model = LLM("Qwen2.5-14B")
        self.baseline = load_baseline_scores()
        self.threshold = 0.15  # 和 baseline 差 0.15 以上才升级
    
    def judge(self, case, metric) -> Score:
        fast_score = self.fast_model.score(case, metric)
        
        if self._needs_rescore(case, metric, fast_score):
            return self.accurate_model.score(case, metric)
        return fast_score
    
    def _needs_rescore(self, case, metric, score):
        baseline_score = self.baseline.get(case.id, metric)
        if baseline_score is None:
            return True  # 新 case,直接升级
        return abs(score - baseline_score) >= self.threshold

成本对比:

全量 14B:    480 次调用 × $0.012/次 = $5.76
全量 3B:     480 次调用 × $0.002/次 = $0.96  (但准确率差 ~8%)
分层策略:     420 次用 3B + 60 次用 14B
             = $0.84 + $0.72 = $1.56
             成本降 ~73%,准确率差 <2%

实际效果:跑了一轮和全量 14B 对比,4 个 metric 的平均差异 <0.02,但成本降了 70% 左右。

贵的模型不需要跑全部 case,只跑”拿不准”的那些。这是 LLM 评测里最该做的成本优化。


AtlasSplit:6 项改进

AtlasSplit 是我用 LLM 生成 Pandas 代码处理 Excel 的 Agent,1,247 个任务跑下来,首轮成功率 82.4%,三轮自愈后 94.4%。

上一篇文章分析了 7 种错误类型(E1-E7),这次针对每个高频错误做改进。

6. 列名预校验器:在代码执行之前拦住幻觉

E1(列名不匹配)占所有错误的 32%,是第一大杀手。

典型的 E1 长这样:

# LLM 生成的代码
df['总销售额'] = df['销售数量'] * df['单价']
#                   ↑ 实际列名是 "销量"
#                                      ↑ 实际列名是 "商品单价"

LLM 根据自然语言描述猜列名,但实际 DataFrame 的列名往往和直觉不一样。

所以在代码执行之前,我加了 column_precheck.py:

LLM 生成代码
     ↓
column_precheck.py
     ↓
┌──────┴──────┐
↓              ↓
提取列引用     对比实际列名
(解析 AST)     (读取 df.columns)
↓              ↓
└──────┬──────┘
       ↓
   有不匹配的列?
   ├── 没有 → 执行 ✅
   └── 有 → 生成反馈 → 让 LLM 重新生成(不消耗执行轮次)

核心代码:

# column_precheck.py
import ast

class ColumnPrecheck:
    def extract_column_refs(self, code: str) -> set[str]:
        """从代码中提取所有 df['xxx'] 和 df["xxx"] 的列引用"""
        tree = ast.parse(code)
        refs = set()
        for node in ast.walk(tree):
            if isinstance(node, ast.Subscript):
                if isinstance(node.slice, ast.Constant):
                    refs.add(node.slice.value)
        return refs
    
    def precheck(self, code: str, df_columns: list[str]) -> CheckResult:
        refs = self.extract_column_refs(code)
        actual = set(df_columns)
        
        missing = refs - actual
        if not missing:
            return CheckResult(ok=True)
        
        # 对每个不匹配的列,找最接近的实际列名
        suggestions = {}
        for col in missing:
            suggestions[col] = self.closest_match(col, actual)
        
        return CheckResult(
            ok=False,
            missing=missing,
            suggestions=suggestions,
            feedback=self._build_feedback(suggestions)
        )

反馈格式:

[Precheck Feedback]
代码中引用了不存在的列名:
  - '销售数量' → 实际列名可能是: '销量'
  - '单价' → 实际列名可能是: '商品单价'
请修正后重新生成代码。
DataFrame 实际列名: ['日期', '门店', '商品名称', '销量', '商品单价', '销售额']

关键点:这个 precheck 不消耗执行轮次。之前是”执行 → 报错 → 反馈 → 重新生成”,现在是”预检 → 反馈 → 重新生成”,省了一整轮。

Before:  生成 → 执行 → KeyError → 反馈 → 重新生成 → 执行
                  ↑ 浪费一轮执行

After:   生成 → 预检 → 反馈 → 重新生成 → 执行
                  ↑ 不消耗执行轮次

效果:

E1 first-attempt success rate:
  Before: 68%
  After:  87%   ← +19pp

在执行之前拦住错误,比执行报错再修便宜得多。Precheck 本质上是”把 schema 信息提前喂给 LLM”。


7. 结构化断言:让 E3 的反馈从”结果不对”变成”差额 1500”

E3(业务逻辑错误)的自愈率只有 39.1%,是所有错误类型里最低的。

原因不是 LLM 不会修,是反馈太模糊。

之前的反馈:

[E3 Feedback]
计算结果不正确,请检查业务逻辑。

LLM 看到这个反馈,只知道”不对”,但不知道哪里不对、差多少。

所以我引入了结构化断言 structured_assertions.py,用类型化的 assertion 生成精确反馈:

# structured_assertions.py
from dataclasses import dataclass

@dataclass
class Assertion:
    description: str
    expected: float
    actual: float
    
    @property
    def passed(self) -> bool:
        return abs(self.expected - self.actual) < 0.01
    
    def feedback(self) -> str:
        if self.passed:
            return f"✓ {self.description}"
        diff = self.actual - self.expected
        return (f"✗ {self.description}\n"
                f"  期望值: {self.expected}\n"
                f"  实际值: {self.actual}\n"
                f"  差额: {diff:+.2f}")

class TotalSumMatch(Assertion):
    """总和匹配断言"""
    pass

class MinRatioSatisfied(Assertion):
    """最小占比断言"""
    pass

class GrowthRateCheck(Assertion):
    """增长率断言"""
    pass

before/after 的反馈对比:

--- Before (模糊反馈) ---
[E3 Feedback]
计算结果不正确,请检查业务逻辑。
Hint: 总销售额可能有误。

--- After (结构化断言) ---
[E3 Feedback]
以下断言未通过:

✗ 总销售额应等于各门店销售额之和
  期望值: 458200.00
  实际值: 456700.00
  差额: -1500.00

✗ 门店A占比应不低于 30%
  期望值: 0.30
  实际值: 0.27
  差额: -0.03

✓ 所有门店销售额非负

LLM 拿到”差额 -1500”比拿到”结果不对”能做更多事——它知道往哪个方向查。

效果:

E3 self-heal rate:
  Before: 39.1%
  After:  58.3%   ← +19.2pp

反馈的精确度直接决定自愈率。“差额 1500”比”结果不对”多了一个数量级的信息量。


8. 错误迁移分析:修复一个 bug 引入另一个 bug

在分析自愈日志时发现一个吓人的数字:11.9% 的修复会引入一个新的错误类型。

比如修好了 E1(列名错误),但改代码时忘了类型转换,引入了 E2。

这不是修复失败,是”修复成功但引入新 bug”。

Round 1: E1 (列名错误)
     ↓ 修复
Round 2: E2 (类型错误)  ← 新引入的
     ↓ 修复
Round 3: 通过

我写了 error_migration.py 从 audit log 里提取迁移路径:

# error_migration.py
class ErrorMigrationAnalyzer:
    def analyze(self, audit_logs: list[AuditLog]):
        transitions = defaultdict(int)
        for log in audit_logs:
            for i in range(len(log.rounds) - 1):
                curr = log.rounds[i].error_type
                next_ = log.rounds[i + 1].error_type
                if curr != next_:
                    transitions[(curr, next_)] += 1
        return self._to_matrix(transitions)

迁移矩阵(top 路径):

=== Error Migration Matrix (top paths) ===
From → To      Count    Description
─────────────────────────────────────────
E1 → E2        7/23    修列名,忘类型转换
E2 → E4        5/23    改类型,引发索引越界
E3 → E1        4/23    改逻辑,引入新列引用
E4 → E2        3/23    修索引,引发类型问题
E1 → E4        2/23    修列名,引入索引问题
E2 → E1        2/23    改类型时误改列名
─────────────────────────────────────────
Total:         23/193  (11.9% 引入新错误)

最有价值的发现:E1→E2 是最常见路径(7/23)。

这意味着修列名错误时,LLM 经常会顺手改类型相关的代码,结果改出问题。

知道这个规律后,我在 E1 修复的 prompt 里加了一条约束:

E1_REPAIR_CONSTRAINT = """
注意:你正在修复列名错误。
请不要修改与列名无关的代码行,特别是类型转换逻辑。
只改列名引用,不要动 dtype、astype() 等操作。
"""

加上这条约束后,E1→E2 的迁移率从 30% 降到 9%。

修复不是孤立的,每次修复都可能引入新问题。迁移矩阵让我看到了”修复的副作用”。


9. 错误分类数据集:脱敏开源

1,247 个任务的错误数据,对 LLM 代码生成社区有价值。但原始数据里有用户业务数据,不能直接开源。

所以写了 prepare_release.py 做脱敏:

# prepare_release.py
class ReleasePreparer:
    def prepare(self, raw_tasks: list[Task]) -> list[ReleaseRecord]:
        records = []
        for task in raw_tasks:
            record = ReleaseRecord(
                task_id=hash_id(task.id),           # 哈希化 ID
                error_type=task.error_type,
                error_pattern=self._extract_pattern(task),  # 只留模式
                code_snippet=self._sanitize_code(task.code), # 脱敏代码
                repair_path=task.repair_path,
                success_rate=task.success_rate,
                # 不包含: 原始数据、用户名、文件路径、业务数值
            )
            records.append(record)
        return records
    
    def _sanitize_code(self, code: str) -> str:
        # 替换具体数值为占位符
        code = re.sub(r'[\d,]+\.?\d*', 'NUM', code)
        # 替换字符串字面量
        code = re.sub(r"'[^']+'", 'STR', code)
        # 替换列名
        code = re.sub(r"df\['[^']+'\]", "df['COL']", code)
        return code

数据集 schema:

{
  "task_id": "a3f8c1...",
  "error_type": "E1",
  "error_pattern": "column_name_mismatch",
  "error_description": "Generated code references non-existent column",
  "code_snippet": "df['COL'] = df['COL'] * NUM  # actual: '销量'",
  "repair_path": ["E1", "pass"],
  "repair_attempts": 1,
  "success_rate": 0.87,
  "metadata": {
    "df_shape": [156, 8],
    "dtypes": ["object", "int64", "float64"]
  }
}

脱敏验证流程:

原始数据 (1,247 tasks)
    ↓
值替换 (数值→NUM, 字符串→STR, 列名→COL)
    ↓
业务关键词扫描 (正则匹配 50+ 业务术语)
    ↓
人工抽检 (随机 100 条)
    ↓
MIT License JSONL 发布

最终以 MIT 协议发布了 JSONL 格式数据集,1,247 条记录,每条包含错误类型、模式、修复路径、成功率。

数据脱敏不是”删掉敏感字段”就够了,要保证错误 pattern 的信息量不损失。值替换比直接删列更有用——LLM 看的是代码结构,不是具体数值。


10. Schema Card:把表结构喂够,让 LLM 少猜

E1+E2+E4 加起来占 55% 的错误,根因都是信息不对称——LLM 不知道表长什么样。

之前的 prompt 里只有一句”这是一个 Excel 文件,包含销售数据”。LLM 只能猜。

schema_card.py 生成结构化的 schema 信息注入 prompt:

# schema_card.py
class SchemaCard:
    def generate(self, df: pd.DataFrame) -> str:
        card = []
        card.append("=== Schema Card ===\n")
        card.append(f"Shape: {df.shape[0]} rows × {df.shape[1]} cols\n")
        
        card.append("Columns:")
        for col in df.columns:
            dtype = str(df[col].dtype)
            samples = df[col].dropna().head(3).tolist()
            null_rate = df[col].isna().mean()
            warning = self._check_warnings(df[col], dtype)
            
            card.append(f"  - {col}")
            card.append(f"    type: {dtype}")
            card.append(f"    samples: {samples}")
            card.append(f"    null_rate: {null_rate:.1%}")
            if warning:
                card.append(f"    ⚠️ {warning}")
        
        return "\n".join(card)
    
    def _check_warnings(self, series, dtype):
        warnings = []
        if dtype == 'object':
            # 检查是不是数字被存成字符串
            numeric_ratio = series.astype(str).str.match(r'^[\d,.]+$').mean()
            if numeric_ratio > 0.8:
                warnings.append("looks numeric but stored as string")
        if series.nunique() == 1:
            warnings.append("constant column")
        if series.isna().mean() > 0.5:
            warnings.append("high null rate")
        return "; ".join(warnings) if warnings else None

Schema Card 输出示例:

=== Schema Card ===
Shape: 156 rows × 8 cols

Columns:
  - 日期
    type: object
    samples: ['2025-01-01', '2025-01-02', '2025-01-03']
    null_rate: 0.0%
  - 门店
    type: object
    samples: ['总店', '分店A', '分店B']
    null_rate: 0.0%
  - 销量
    type: object
    samples: ['120', '85', '203']
    null_rate: 0.0%
    ⚠️ looks numeric but stored as string
  - 商品单价
    type: float64
    samples: [29.9, 45.0, 12.5]
    null_rate: 0.0%
  - 销售额
    type: float64
    samples: [3588.0, 3825.0, 2537.5]
    null_rate: 2.6%
  - 备注
    type: object
    samples: [nan, nan, '促销']
    null_rate: 87.2%
    ⚠️ high null rate

注入 prompt 后:

[System Prompt]
你是一个 Pandas 代码生成专家。

{schema_card}

根据以上表结构,生成 Pandas 代码完成用户需求。
注意 ⚠️ 标记的列。

LLM 看到”销量 stored as string”就不会直接做数值运算,而是先 astype(int)。

效果:

E1 + E2 + E4 合计错误率:
  Before: 55%
  After:  28%   ← 降了一半

E2 (类型错误) 改善最大:
  21% → 9%   ← Schema Card 的 warning 直接预判了类型问题

LLM 猜列名、猜类型,本质上是因为信息不够。把 schema 喂够了,一半的错误根本不会发生。


11. 降级策略:两轮修不好就换思路

统计了修复轮次的成功率递减:

Round 1 success: 82.4%
Round 2 success: 53.1%   (对 Round 1 失败的)
Round 3 success: 31.4%   (对 Round 2 失败的)

Round 2 还行,Round 3 成功率已经很低了。再修下去边际收益很差。

原因:Round 1 和 Round 2 修不好,往往是 LLM 的思路本身就是错的,继续在同一方向 patch 没用。

所以 alternative_strategy.py 的核心思路是:两轮修不好,不 patch 了,换一个完全不同的方案。

# alternative_strategy.py
class StrategySelector:
    def select(self, error_type: str, round_num: int) -> str:
        if round_num < 2:
            return "standard_repair"
        
        # 两轮修不好,根据错误类型选策略
        strategy_map = {
            "E1": "standard_repair",      # 列名问题,继续修
            "E2": "standard_repair",       # 类型问题,继续修
            "E4": "standard_repair",       # 索引问题,继续修
            "E3": "alternative",           # 业务逻辑,换方案
            "E5": "alternative",           # 逻辑缺失,换方案
            "E6": "stop",                  # 依赖缺失,停止
            "E7": "optimization",          # 性能问题,优化模式
        }
        return strategy_map.get(error_type, "alternative")

策略选择器逻辑:

Round 2 仍失败?
    ↓
┌───────┴──────────────────────────┐
│            Error Type             │
├──────┬──────┬──────┬──────┬───────┤
│ E1   │ E2   │ E4   │ E3   │ E5    │
│  ↓   │  ↓   │  ↓   │  ↓   │  ↓    │
│ 标准 │ 标准 │ 标准 │ 换方 │ 换方  │
│ 修复 │ 修复 │ 修复 │ 案   │ 案   │
├──────┴──────┴──────┴──────┴───────┤
│ E6 → 停止 (依赖缺失,修不了)      │
│ E7 → 优化模式 (性能问题)          │
└───────────────────────────────────┘

alternative 模式的 prompt 变化:

STANDARD_REPAIR_PROMPT = """
之前的代码有错误:{error}
请修复这个错误,保持原有逻辑不变。
"""

ALTERNATIVE_PROMPT = """
之前的方案尝试了 2 轮仍未通过。
请完全放弃之前的思路,用不同的方法实现相同需求。

之前失败的方案概要:
{failed_approaches}

约束:
- 不要使用和之前相同的实现思路
- 如果之前用了 groupby,这次试试 merge 或 pivot
- 如果之前用了 iterrows,这次试试向量化操作
- 明确说明你的新方案和旧方案的区别
"""

一个实际例子:

需求: 计算每个门店月度销售额环比增长率

Round 1 (E3): 用 pct_change() 但忽略了分组 → 报错
Round 2 (E3): 修了分组,但计算逻辑仍不对 → 报错
Round 3 (alternative): 放弃 pct_change,改用 shift + 除法
  → df.groupby(['门店','月'])['销售额'].sum()
  → .pct_change() 换成 (curr - prev) / prev
  → 通过 ✅

最终效果:

Final success rate (3 rounds):
  Before: 94.4%
  After:  96.1%   ← +1.7pp

其中 alternative 策略贡献:
  Round 3 success: 31.4% → 52.8%   ← +21.4pp

方向错了的时候,继续修补不如推倒重来。知道什么时候该换思路,比会修 bug 更重要。


总结

改进清单应与验证清单配对:修了什么、怎样复现旧问题、用什么检查确认修复、还有哪些边界未覆盖。这样读者能区分已经落地的改动与下一步建议,也方便后续回看某次改进是否引入了新问题。

11 项改进,两个项目,全部落地。

MindTrip (5项):
  1. 评测自动化        → 2分钟出回归报告
  2. Reranker          → unexpected entity 0.069→0.031
  3. Query Rewriting   → 否定型查询 Recall 0.72→0.89
  4. 评测集扩充        → +43 cases, 发现新 query 类型
  5. Judge 分层        → 成本降 70%, 精度差 <2%

AtlasSplit (6项):
  6. 列名预校验器      → E1 首轮成功率 68%→87%
  7. 结构化断言        → E3 自愈率 39.1%→58.3%
  8. 错误迁移分析      → E1→E2 迁移率 30%→9%
  9. 错误分类数据集    → MIT 开源, 1,247 条
  10. Schema Card      → E1+E2+E4 合计 55%→28%
  11. 降级策略         → 最终成功率 94.4%→96.1%

做完之后回头看,11 项改进其实围绕两个主题:

主动防御 > 被动修复。 Schema Card、列名预校验、断言结构化,都是在错误发生之前或之时主动拦住。比等错误报出来再修效率高一个量级。

信息充分 > 反馈精确。 把表结构喂够、把差额数字给清楚、把错误迁移路径标出来。LLM 不是不会做,是信息不够才做不好。

所有代码在项目仓库里,文章在博客上。这两条原则我会带到下一个项目里去。

相关文章:

💬 评论

这套方案在哪一步卡住了?也欢迎补充你的实现方式或踩坑经验。

正在连接评论服务,首次访问可能需要几秒…