系列文章第 4 篇

上一篇:《RuleEngine:让 90% 的代码迁移不再依赖大模型》

下一篇:《SqlSugar 的 JOIN 如何优雅地迁移到 MyBatis-Plus-Join?》


一、引子:把 LLM 关进笼子里

在前三篇文章中,我们构建了一个看似完美的系统:

  • IR​ 作为统一的语义中间层;

  • RuleEngine​ 以接近 100% 的准确率完成 90% 的确定性代码生成。

但现实世界总有一团乱麻。总有一些代码,它们:

  • 充斥着复杂的业务逻辑(if-else嵌套超过 3 层);

  • 包含精巧的算法实现(排序、去重、位运算);

  • 或者涉及多步的事务编排。

这些代码,RuleEngine 无能为力。它们就像城市里狭窄的弄堂,大型卡车(规则引擎)开不进去,只能靠步行(LLM)。

但是,LLM 是危险的。它强大,但也充满了不确定性(幻觉)。如果我们放任 LLM 自由发挥,它可能为了“让代码跑起来”而引入隐蔽的 Bug。

所以,我们的核心策略是:把 LLM 关进笼子里

这个“笼子”由三部分组成:

  1. 围栏(ComplexityEvaluator):决定什么时候放 LLM 出来。

  2. 提词器(Prompt Engineering):告诉 LLM 该做什么,不该做什么。

  3. 安检机(Validator):检查 LLM 交出的作业是否合格。


二、围栏:ComplexityEvaluator

我们不能让 LLM 处理所有事情,必须设定严格的准入标准。ComplexityEvaluator就是这个标准的执行者。它基于 IR 进行分析,而不是源代码。

1. 量化复杂度

我们定义了几个简单的指标:

public boolean isComplex(IrTypeDecl ir) {
    // 1. 如果是 Service 或 Controller,默认复杂
    // 理由:通常涉及业务流程、事务、外部调用
    if (ir.name().endsWith("Service") || ir.name().endsWith("Controller")) {
        return true;
    }

    // 2. 检查调用链深度
    // 如果一个方法内部调用了太多其他方法,逻辑必然复杂
    if (hasDeepCallChain(ir, 3)) {
        return true;
    }

    // 3. 检查 IR 结构复杂度
    for (IrMethod method : ir.methods()) {
        if (method.body() == null) continue;
        
        // 统计 If/Loop 的数量
        int complexityScore = calculateComplexityScore(method.body());
        if (complexityScore > 10) { // 经验阈值
            return true;
        }
    }

    // 4. 检查是否包含动态 SQL
    // 规则引擎无法处理的字符串拼接 SQL
    if (containsDynamicSql(ir)) {
        return true;
    }

    return false;
}

2. 为什么基于 IR,而不是代码行数?

代码行数(LOC)是一个糟糕的指标。一段 5 行的算法可能比一段 50 行的赋值逻辑复杂得多。

IR 关注的是语义节点IrIfIrLoopIrMethodCall这些节点的数量和嵌套深度,才是复杂度的真实反映。

效果:通过这个围栏,我们成功地将 LLM 的使用率控制在 10% 以内,极大降低了成本和风险。


三、提词器:Prompt Engineering

ComplexityEvaluator决定放行后,LLM 会收到一份精心设计的“任务书”。这份任务书的核心是 IR + 规则骨架

1. 绝对禁止“从零开始”

我们绝不会给 LLM 这样的 Prompt:

“请把这段 C# 代码翻译成 Java。”

这会诱导 LLM 产生幻觉。

相反,我们给的是:

“这是 RuleEngine 已经生成的 Java 骨架,这是对应的 IR 语义描述。请你只填充方法体的逻辑,不要修改方法签名、注解和类名。”

2. 标准的 Prompt 结构

/**
     * 构建 LLM Prompt
     */
    private String buildPrompt(IrTypeDecl ir, String ruleOutput) {
        return """
            你是一个 C# 到 Java 代码迁移专家。
            目标框架:Crystal Framework(Spring Boot 3.x + MyBatis Plus)

            ## IR 中间表示(%s)
            ```json
            %s
            ```
            ## RuleEngine 已生成骨架(参考)
            ```java
            %s
            ```
            ## 要求

1. 如果 IR.body 为 null,请实现完整的方法业务逻辑
2. 如果 IR.body 不为 null,请基于 IR 表达式生成 Java 代码
3. 使用 MyBatis Plus 查询(不要用 JdbcTemplate)
4. 遵循 Crystal Framework 规范(见 backend-SOUL.md)
5. 使用 R<T> 统一响应(Controller 层)
6. 使用 @Transactional(Service 更新方法)
7. 输出完整 Java 文件(含 package / import / 注解)
8. 不要输出解释文字,只输出 Java 代码
""".formatted(
                ir.kind(),
                ir,  // IrTypeDecl 的 toString()
                ruleOutput
        );
    }

3. 为什么这样有效?

  • 确定性锚点:RuleEngine 生成的骨架是“锚”。LLM 知道哪里不能动,减少了胡乱修改的风险。

  • 语义指导:IR 告诉 LLM “这是什么”,而不是让它自己去猜。

  • 负面约束:明确告诉它不能用什么(字符串 SQL),引导它用正确的 API。


四、安检机:MigrationValidator

LLM 生成了代码,交出了“作业”。但这作业能打多少分?能不能及格?这需要 MigrationValidator来评判。

Validator 是我们对抗 LLM 幻觉的最后一道防线。

1. 编译检查(第一道关卡)

这是最基础的。我们使用 javax.tools.JavaCompilerAPI 在内存中编译生成的代码。

// 简化逻辑
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
DiagnosticCollector<JavaFileObject> diagnostics = new DiagnosticCollector<>();
boolean success = compiler.getTask(...).call();

if (!success) {
    // 编译失败,直接丢弃 LLM 的结果,标记为迁移失败
    return ValidationResult.failed(diagnostics);
}

如果连编译都通不过(比如少了分号、类型不对、引用了不存在的类),这段代码直接作废。

2. AST Diff(第二道关卡)

编译通过不代表逻辑正确。我们需要进一步检查结构。

我们使用 JavaParser​ 解析生成的 Java 代码,并与 IR 进行对比。

  • 检查点

    • 方法名是否一致?

    • 参数数量是否一致?

    • 返回值类型是否匹配?

    • 是否错误地引入了 IR 中不存在的 import

这一步能发现 LLM 经常犯的“好心办坏事”的错误,比如擅自引入了一个不存在的工具类。

3. SQL Diff(第三道关卡,核心)

对于 Repository 层,这是最关键的一步。我们需要确保 LLM 生成的 MPJ 查询,在语义上等价于原来的 SqlSugar 查询。

我们在前两篇文章中提到过 Logical Plan

  • 左边:从 C# 的 SqlSugar 调用链推导出 Logical Plan A。

  • 右边:从 LLM 生成的 Java MPJ 代码推导出 Logical Plan B。

Validator 会比较 A 和 B:

  • JOIN 类型是否一致(LEFT JOIN vs INNER JOIN)?

  • ON 条件是否一致?

  • WHERE 条件是否一致?

  • SELECT 字段是否一致?

如果不一致,哪怕编译通过,我们也判定为迁移失败。

4. 规范校验(第四道关卡)

最后,检查是否符合 Crystal Framework 的规范。

  • Controller 是否返回 R<T>

  • Service 的更新方法是否有 @Transactional

  • 是否使用了被禁止的 API(如 JdbcTemplate)?


五、失败后的降级策略

即使有重重关卡,LLM 仍然可能失败。我们需要一个优雅的降级策略。

// MigrationOrchestrator 中的逻辑
FileMigrationResult result = migrateSingle(ir);

if (result.isFailed()) {
    // 1. 记录失败原因
    log.error("LLM migration failed for {}: {}", ir.name(), result.errorMessage());
    
    // 2. 生成“占位符”代码
    String placeholderCode = generatePlaceholderCode(ir);
    
    // 3. 写入文件,但标记为 TODO
    writeToFile(placeholderCode);
    
    // 4. 通知前端,高亮显示该文件
    notifyFrontend(ir.name(), "MANUAL_INTERVENTION_REQUIRED");
}

生成的占位符代码可能是这样的:

@Service
public class UserService {
    @Override
    public BigDecimal calculateComplexDiscount(OrderDTO dto) {
        // TODO: LLM migration failed.
        // Reason: Complex business logic involving multi-step interest calculation.
        // Please implement manually based on original C# code.
        throw new UnsupportedOperationException("Manual migration required.");
    }
}

这样做的好处是:

  1. 不阻塞整体迁移进程:其他文件可以继续迁移。

  2. 明确告知人工介入点:开发人员一眼就能看到哪里需要手动处理。

  3. 保留上下文:注释中说明了失败原因,方便人工处理。


六、一个真实的兜底案例

场景:一个复杂的折扣计算算法。

C# 原代码

包含多层嵌套的 if判断,涉及会员等级、促销活动、商品类别、库存水位等多个维度的计算。

RuleEngine

判定为复杂逻辑,移交 LLM。

LLM 第一次尝试

生成了代码,但 SQL Diff 失败。因为它把 LEFT JOIN写成了 INNER JOIN,导致查询结果集变小。

Validator

捕获到 JOIN 类型不匹配,拒绝代码。

LLM 第二次尝试(基于修正后的 Prompt):

生成了正确的 JOIN 类型,但在计算折扣金额时,使用了整数除法 (10 / 3),导致精度丢失。

Validator

虽然编译通过了,但我们在自定义的规范校验中加入了对“金额计算必须使用 BigDecimal”的检查,再次拒绝。

最终处理

连续两次失败后,系统放弃 LLM,生成了包含 // TODO的占位符代码,并通知高级开发人员手动处理。

结论

虽然这次 LLM 没有完全成功,但 Validator 成功地拦截了两次潜在的线上 Bug。LLM 充当了“高级助手”,尝试解决问题,但最终的安全阀由规则系统牢牢把控。


七、总结

在大模型时代,很多人迷信“端到端”的魔法。但在严肃的工程领域,可控性远比智能性重要

我们的 LLM 兜底策略,本质上是建立了一套 Human-in-the-loop(人在回路中)​ 的机制:

  1. 规则先行:用确定性解决绝大多数问题。

  2. LLM 受限:只在必要时、受控范围内使用。

  3. 验证闭环:用自动化手段严格检验 LLM 的输出。

这套机制让我们敢于在生产环境中使用 LLM。它不是为了炫技,而是为了实实在在地降低成本、提高效率,同时确保系统的稳定性和可靠性。

下一篇,我们将深入一个具体的技术深水区:SqlSugar 的 JOIN 如何优雅地迁移到 MyBatis-Plus-Join?

这将是我们 IR 设计和 RuleEngine 能力的集中体现。


系列目录

  1. 从 C# 到 Java:我们是如何把老系统"无痛"迁到 Crystal Framework 的

  2. 代码迁移的中间语言:为什么我们要设计一套 IR?

  3. RuleEngine:让 90% 的代码迁移不再依赖大模型

  4. LLM 兜底:如何安全地使用大模型处理复杂逻辑(本文)

  5. [SqlSugar 的 JOIN 如何优雅地迁移到 MyBatis-Plus-Join?(待发布)]

  6. [迁移不等于结束:我们是如何验证生成代码的“正确性”的(待发布)]

  7. [给迁移平台做一个“驾驶舱”:Vue3 + ECharts 实战(待发布)]


关于 LLM 在代码迁移中的应用,你有什么看法或疑问?欢迎在评论区讨论。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐