LLM 兜底:如何安全地使用大模型处理复杂逻辑
系列文章第 4 篇
上一篇:《RuleEngine:让 90% 的代码迁移不再依赖大模型》
下一篇:《SqlSugar 的 JOIN 如何优雅地迁移到 MyBatis-Plus-Join?》
一、引子:把 LLM 关进笼子里
在前三篇文章中,我们构建了一个看似完美的系统:
-
IR 作为统一的语义中间层;
-
RuleEngine 以接近 100% 的准确率完成 90% 的确定性代码生成。
但现实世界总有一团乱麻。总有一些代码,它们:
-
充斥着复杂的业务逻辑(
if-else嵌套超过 3 层); -
包含精巧的算法实现(排序、去重、位运算);
-
或者涉及多步的事务编排。
这些代码,RuleEngine 无能为力。它们就像城市里狭窄的弄堂,大型卡车(规则引擎)开不进去,只能靠步行(LLM)。
但是,LLM 是危险的。它强大,但也充满了不确定性(幻觉)。如果我们放任 LLM 自由发挥,它可能为了“让代码跑起来”而引入隐蔽的 Bug。
所以,我们的核心策略是:把 LLM 关进笼子里。
这个“笼子”由三部分组成:
-
围栏(ComplexityEvaluator):决定什么时候放 LLM 出来。
-
提词器(Prompt Engineering):告诉 LLM 该做什么,不该做什么。
-
安检机(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 关注的是语义节点。IrIf、IrLoop、IrMethodCall这些节点的数量和嵌套深度,才是复杂度的真实反映。
效果:通过这个围栏,我们成功地将 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.");
}
}
这样做的好处是:
-
不阻塞整体迁移进程:其他文件可以继续迁移。
-
明确告知人工介入点:开发人员一眼就能看到哪里需要手动处理。
-
保留上下文:注释中说明了失败原因,方便人工处理。
六、一个真实的兜底案例
场景:一个复杂的折扣计算算法。
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(人在回路中) 的机制:
-
规则先行:用确定性解决绝大多数问题。
-
LLM 受限:只在必要时、受控范围内使用。
-
验证闭环:用自动化手段严格检验 LLM 的输出。
这套机制让我们敢于在生产环境中使用 LLM。它不是为了炫技,而是为了实实在在地降低成本、提高效率,同时确保系统的稳定性和可靠性。
下一篇,我们将深入一个具体的技术深水区:SqlSugar 的 JOIN 如何优雅地迁移到 MyBatis-Plus-Join?
这将是我们 IR 设计和 RuleEngine 能力的集中体现。
系列目录:
-
从 C# 到 Java:我们是如何把老系统"无痛"迁到 Crystal Framework 的
-
代码迁移的中间语言:为什么我们要设计一套 IR?
-
RuleEngine:让 90% 的代码迁移不再依赖大模型
-
LLM 兜底:如何安全地使用大模型处理复杂逻辑(本文)
-
[SqlSugar 的 JOIN 如何优雅地迁移到 MyBatis-Plus-Join?(待发布)]
-
[迁移不等于结束:我们是如何验证生成代码的“正确性”的(待发布)]
-
[给迁移平台做一个“驾驶舱”:Vue3 + ECharts 实战(待发布)]
关于 LLM 在代码迁移中的应用,你有什么看法或疑问?欢迎在评论区讨论。
更多推荐

所有评论(0)