选模型时只盯一个指标,是代码类任务最常见的翻车方式。这次从 LLM 反编译器「重编译得更多、语义保留却更少」的失真现象说起,我拆一拆可编译性、可执行性与语义保真这三个指标的坑,以及如何在 4sapi(https://4sapi.com)上建立自己的验收体系。

一个指标选模型,上线就翻车

上个月我接到一个内部工具的选型任务:在几个候选模型里挑一个做反编译辅助,把二进制函数还原成对应的 C 代码。当时的评审表只列了两列——「编译通过率」和「能跑」。两个模型竞争到最后,胜出的那个重编译率 90% 出头,落败的那个只有 60% 多。看起来结论毫无悬念。

真正上线之后问题立刻暴露:胜出模型产出的代码确实几乎都能编译,但不少函数把原来的边界检查简化掉了,有的把 switch 分支折叠成了错误的 if-else,还有的把异常路径整个吞掉。拿真实样本逐一对拍,语义不一致的比例比落败模型还高。事后复盘,问题不在模型,而在选型时的评测指标本身——只看一个数字选模型,等于把验收责任外包给了一个会骗人的排行榜。

反编译任务的特殊性:占位符与「能跑」的假象

先说说传统反编译器是什么状态。Ghidra、Hex-Rays 这类工具面对无法解析的控制流与内存引用时,习惯把内容暴露为占位符(placeholder):未识别的变量、丢失的类型、悬空的跳转目标。所以它们产出的伪代码经常不可编译、不可执行——这本来就是它们的常态,分析者也不指望直接拿去编译。

LLM 反编译器改变了这个局面:输出是干净、惯用的 C 代码,类型声明齐全,循环和分支排布得整整齐齐,一眼看过去完全像人手写的。问题在于,评测界几乎只拿「可重编译性」和「可重执行性」来衡量这种输出,而这两个指标恰恰是最容易被表面质量带偏的。

评测三兄弟:可编译性、可执行性、语义保真

代码类任务的验收指标可以粗分成三个维度。可编译性(recompilability)看输出能否被编译器接受;可执行性(re-executability)看编译产物能否运行、不崩溃;语义保真(semantic fidelity)看输出与原逻辑是否行为等价。三者层层递进,但测量成本也层层升高——前两个可以全自动测量,第三个需要人工标注或差分测试。

指标 考察内容 测量方式 主要陷阱 适用场景
可编译性 输出能否通过编译 直接交给 gcc/clang,统计通过率 语法正确不等于逻辑正确;模型会为「迎合编译」而删减边界处理 CI 冒烟、快速初筛
可执行性 编译产物能否运行、是否崩溃 运行测试程序,观察退出码与崩溃率 能跑不等于行为正确;死循环与静默错误难以暴露 粗粒度回归
语义保真 与原逻辑是否行为等价 黄金用例、差分测试、行为等价断言 标注成本高,但这是下游真正依赖的价值 漏洞检测、恶意软件分析、生产代码

为什么重编译得更多,语义却保留得更少

围绕这次评测,我观察到一条反直觉的规律:重编译率上升的同时,语义信息保留反而可能下降。原因并不神秘,可以拆成三点来看。

第一,编译通过只需要满足语法和类型约束,它不检查行为。模型只要学会「像 C 代码」的统计模式就能刷高这个指标,代价是简化。真实二进制里的复杂控制流,被模型重写成更「惯用」的形态时,往往伴随着分支合并、条件取反、变量复用——这些改写都可能悄悄改变语义。

第二,评测反馈会塑造模型行为。当「能不能编译」成为唯一被量化的奖励信号,模型自然会把 token 预算花在让输出显得完整、可编译的形态上,而不是花在保留那些难以表达的边角逻辑上。可编译性成了一个被过度优化的代理指标,而它代理的语义保真并没有被真正测量。

第三,可重执行性同样有盲区。一段能编译、能运行、不崩溃的代码,可能只是把错误路径吞掉之后假装正常返回——这在安全场景里比直接崩溃危险得多,因为它让下游的漏洞检测和恶意软件分析彻底失明。

评测失真的根源:排行榜竞赛正在扭曲目标

把问题往上一层看,失真不只发生在单个模型的训练阶段,也发生在整个评测范式里。排行榜竞赛天然偏好可自动计算的单一指标:收集一个测试集,跑一个分数,排一个名次。可编译性因为测量成本最低、自动化最彻底,成了最容易登上排行榜的指标。

但这恰恰是本末倒置。对反编译任务而言,语义保真才是下游真正消费的东西:漏洞检测要的是「这段代码到底在做什么」,恶意软件分析要的是「原始行为被还原了多少」。一个能编译但语义错乱的输出,对这两个场景的价值接近于零。

所以我的判断是:评测范式应该从「排行榜竞赛」转向「边界与保真探测」——主动构造边界用例(超大输入、重叠内存、异常路径、并发场景),逐条验证行为等价,而不是满足于一个编译通过率数字。

我的验收体系:单测通过率 + 语义抽查 + 回归集

落到自己的工程里,我把验收拆成三层,缺一不可。

第一层是单测通过率。为每类任务准备一套黄金用例:输入、预期输出、断言都提前写好,模型的输出直接编译并跑测试。这一层全自动,负责快速筛掉明显不合格的候选。

第二层是语义抽查。单测覆盖不到的地方,靠人工抽样对拍:挑 20 到 50 个代表样本,逐个比较输出与原逻辑在边界输入下的行为。抽查不必追求穷尽,但必须覆盖每个任务类型至少一个边界类别。

第三层是回归集。把历史上翻过车的样本全部沉淀下来——某个模型把异常路径吞了、把溢出检查删了、把分支顺序搞反了,都进回归集。以后每换一次模型、每升一次版本,先跑回归集,只有历史问题不复发才算通过。

三个层次的权重我通常按 4:3:3 分配,语义抽查和回归集加起来占大头。原因很简单:单测通过率再高,也补偿不了语义保真的缺失。

建立验收基线的五个步骤

具体的落地流程我固定为五步,每一步都有产出物。

  1. 收集黄金样本:从真实业务里抽取 100 到 300 个代表性样本,覆盖主要代码形态。
  2. 编写单测与断言:把每个样本的预期行为固化成可执行测试,能自动编译、自动运行。
  3. 定义语义探针:为每个任务类型列出 3 到 5 个边界类别,作为语义抽查的固定清单。
  4. 沉淀回归集:把历次翻车样本按失败模式分类归档,标注原因。
  5. 计算加权得分:三层得分按 4:3:3 汇总,作为选型与版本准入的唯一标准。

这套基线建好之后,换模型、升版本、调参数都有了统一的参照物。更重要的是,它把「能编译」从唯一的验收标准降级为众多指标之一,失真的空间被大幅压缩。

中转接入:多轮代码生成的完整流程

验收指标定好之后,剩下的事情就是把这些测试跑在真实模型上。我通过 4sapi(https://4sapi.com)统一接入多个模型:一个 base_url 对应所有上游,模型切换只改参数、不碰业务代码,这让横向对比的成本降到最低。

本地脚本
   │  POST https://4sapi.com/v1/chat/completions
   │  headers: Authorization: Bearer sk-xxxx
   │  body: model / messages / temperature / stream
   ▼
中转网关
   │  ① 鉴权:校验 key 与配额
   │  ② 路由:按 model 参数分发到对应上游
   │  ③ 负载均衡:多节点加权轮询,按延迟择优
   ▼
上游模型服务
   │  生成结果(流式 chunk 或完整 JSON)
   ▼
中转网关 ──▶ 本地脚本 ──▶ 单测执行器 ──▶ 失败反馈 ──▶ 第二轮请求

Python 接入示例:生成 + 单测 + 修正循环

代码生成任务很少一轮完成,我的做法是「生成 → 跑单测 → 反馈失败 → 修正」的多轮循环,每轮把上一轮的输出和测试失败信息都追加进 messages:

from openai import OpenAI

client = OpenAI(
    api_key="sk-xxxx",                 # 在中转控制台创建
    base_url="https://4sapi.com/v1",   # 统一接入入口
)

def call_model(client, messages, model="gpt-4o"):
    resp = client.chat.completions.create(
        model=model,
        messages=messages,
        temperature=0.2,
        stream=False,
    )
    return resp.choices[0].message.content

# 第一轮:给出需求
messages = [
    {"role": "system", "content": "输出可编译的完整 C 函数,不得省略边界处理与错误路径。"},
    {"role": "user", "content": "实现一个安全复制函数,要求正确处理 src 与 dst 重叠的情况。"},
]
code = call_model(client, messages)

# 单测执行器:编译并运行,把失败信息回灌给模型
test_feedback = run_unit_tests(code)   # 返回如 "memmove_case 失败:重叠区间复制结果错误"

# 第二轮:修正
messages.append({"role": "assistant", "content": code})
messages.append({"role": "user", "content": f"测试反馈:{test_feedback}。请修正实现。"})
fixed_code = call_model(client, messages)

第二轮开始,每次请求都要携带全部历史,prompt 的 token 量随轮数线性增长。多轮修正一般控制在 3 轮以内,超过就该回查提示词或样本本身,而不是无限重试。

多轮调用的 token 成本控制

多轮循环是 token 消耗的大户,账要提前算清楚。一次修正循环里,第 n 轮的 prompt 包含系统提示、全部历史对话和上一轮输出,总 token 近似为「固定前缀 + 每轮新增内容之和」。三轮下来,输入侧的消耗往往达到单轮的 2 到 3 倍。

我常用的控制手段有四条。其一,限制轮数:修正超过 3 轮直接放弃本轮样本,标记进回归集。其二,压缩历史:只保留最近两轮的对话与最终测试反馈,中间过程丢弃。其三,分档选模型:初稿用便宜模型批量生成,只有进入语义抽查环节的样本才换高价模型精修。其四,开启流式输出并设置合理的超时与重试,避免无效等待吃掉配额。

此外,计费口径要弄清楚。按 token 计费时,输入的 system 提示词会被每一轮重复计费,把提示词模板做成长度可控的常量、定期清理无用的历史,能省下可观的成本。这些优化都可以通过统一的计费面板核对,每轮请求的实际 token 消耗一目了然。

合规与风险提示

围绕接入这件事,我始终守住几条边界。只通过正规 API 与中转网关做合法接入,做架构设计、负载均衡与计费优化,不做任何违规代理。反编译与代码生成的场景限定在安全研究、漏洞检测、自有软件分析等合法用途,不涉及破解、脱壳或规避授权的内容。

工程侧的风险同样要防。API key 只存在服务端环境变量里,绝不进代码仓库;请求要设超时与指数退避重试,防止上游抖动拖垮任务队列;模型输出要过一遍基本的静态检查再进生产链路,LLM 输出的代码默认不可信。

上线前检查清单

每次换模型或升版本,我按下面这张清单过一遍,全部打勾才放行:

总结

这次围绕 LLM 反编译评测的失真,我看到的核心问题只有一个:可编译性是个会骗人的代理指标,重编译率上升的同时,语义保留可能正在下降。对代码生成与代码理解类任务,语义保真才是下游真正依赖的价值,验收体系应该由单测通过率、语义抽查与回归集三层构成,评测范式也该从排行榜竞赛转向边界与保真探测。接入与成本方面,多轮修正的 token 消耗要提前建模,轮数、历史长度与模型分档都是可控的优化点。这套方法我在 4sapi(https://4sapi.com)上落地之后,选型结论和线上表现终于对上了。欢迎在评论区发表想法/聊聊。