同一个模型、同一组 proposal,消费方式换一下,ARC 从 13% 跳到 40%。前两周 arXiv 挂出的 Narcissus: Program Synthesis Using Context-Aware LLM Approximations,做的就是这个。真正值得往下挖的不是那个 40% 的数字——benchmark 赢分不稀罕。它把 LLM 输出当语法树本身用,不是当规则频率表。这一下就把“LLM 生成的程序错了怎么办”换了个问法:错的语法树里还能不能抠出可用的先验。
拿 LLM 做 program synthesis 的早期玩法很直接:让模型吐几十个 proposal,配一组测试用例过滤,能过就收工。过不了就尴尬。目标语言罕见的时候,模型输出的程序连语法都常错,可恰恰是这种语言最想让它生成。于是有人用了个 common trick——统计 proposal 里每条 grammar rule 出现的次数,把这个频率表当搜索候选展开时的加权先验。极简版本我们这么写:
freq = defaultdict(float)
for p in proposals:
for node in p.ast.all_nodes():
freq[node.rule] += 1
def score_expansion(c, rule, freq):
return freq[rule]
能跑,但有个坑。freq 是全局统计。一个 stmt -> return 出现在函数尾部十次,一个 stmt -> for 出现在函数开头十次,freq 给这两条打一样的分数。搜索候选走到 stmt 准备展开,它只知道“return 跟 for 一样受欢迎”。可真实语法里这个位置性致命:return 插在 for header 里,只会出现在离谱且没用的程序里。
Narcissus 把这点挑明了:rule frequency 把语法树里的结构信息全删了。proposal 有错的时候,这种频率先验可能比没有更糟——错的 proposal 多数不是每条 rule 数量不对,而是 rule 放错了上下文。频率表恰恰抓不到这个错,还把错方案按出现次数放大。
所以他们把 proposal 保留成语法树。得分不求全局,求的是“同一个 surrounding structure 底下,这条 rule 有没有继续走下去”。如果 proposal 里相同结构的节点在这个位置走了 stmt -> for,搜索里的候选也在这得分;如果 proposal 里相同结构走的是 stmt -> return,而 search 在相同位置想走 for,分数就低。还有一个判据:看 expansion 重建出来的 fragment 在 proposal 里反复出现过没有。前者管“这个位置该长什么”,后者管“长出来的这一整块像不像 model 会写的东西”。
核心评分逻辑的最小形状可以这么写——这不是 Narcissus 的源码,是机制的骨架:
class ProposalStore:
def __init__(self, proposals):
self.cont = defaultdict(list)
for ast in proposals:
for node in ast:
ctx = (node.parent.symbol, node.child_index)
self.cont[ctx].append(node.rule)
def likelihood(self, candidate, rule):
ctx = (candidate.parent.symbol, candidate.child_index)
saw = self.cont[ctx]
return saw.count(rule) / len(saw)
分数从全局频率的分母里,切成了每个上下文单独的规范化。一个 stmt 下面该长什么,先问它在哪里、前后是谁,再去看 proposal 里同样位置的人怎么走。到这一步,context 和 proposal 结合的价值不是“提升命中率”,而是错误的 proposal 第一次变成了可定位的信号:错在哪个结构的哪个子树里,一眼能看。
但接着是个反直觉的地方。proposal 错得太狠怎么办?不能把它压到零。错得离谱的 proposal 会把某条 grammar rule 出现的上下文全盖掉,把这条 rule 压成零分。搜索就永远发现不了“这条 rule 其实是对的,只是 model 从没把它放对过地方”。所以 Narcissus 加了一个正则化项:每个 grammar rule 都保持可达。最小形式就一行:
score = alpha * store.likelihood(candidate, rule) + (1 - alpha) * uniform
这里不能当普通平滑看。prior 的坏脾气要用它控制:错误的 proposal 只能让找到解的路径变慢,不能把某扇门焊死。这是程序合成里的安全姿势——prior 是搜索的顺风还是逆风,不取决于它有没有错,取决于它错了之后底下还有没有路。
写到这层,题眼已经出来了。很多用 LLM 输出的方法,错的第一步就是马上回去 re-prompt,希望下一次采样能自己改对。Narcissus 的搜索阶段一次 LLM 调用都不做。它把 proposal 离线榨干,进入一个纯枚举的、语法合法候选空间里搜索。它报告的数据是:五个域、两个搜索后端,每个预算点都压过静态 guidance;比 re-prompt 让模型自己修 proposal 的做法更稳;到达跟 proposal 质量相仿的程序,比竞品 guidance 方法快约一个数量级。ARC 任务,原始 LLM proposal 直接跑是 13%,Narcissus 是 40%。
我不是想吹这个 40%。它说清了一件事:同一个模型,同样的 proposal,消费方式换掉,结果能跳三倍。模型没变,搜索度量变了。我一开始也以为 Narcissus 靠“生成再验证再生成”那种自修复循环跑出这个分,翻完才确认,搜索里是零 LLM 调用。这一点不是为了省 token,而是在跟 re-prompt 明确划清界限。re-prompt 问的是“你错在哪”,可一个自回归模型根本没有错在哪的概念,它只会按上下文采下一个 token。拿 LLM 的输出当先验放进搜索,等于让它用正确的方式做它擅长的事:做不出对的结果,也能给对的方向留一点信号。
想自己动手验证这个机制,可以拿一个玩具 DSL 试一遍:搭十来条产生式,写死一组“局部有点对、整体歪了”的 proposal,然后用频率和上下文两种 guidance 各跑一遍穷举搜索。频率版会在错误 proposal 塞满的分支上反复剪出重复的错路,上下文版会把这些 proposal 拆成局部条件,从错里捡出可用的部分。把搜索预算从几十步拉到几千步各看一遍,正则化项那个 alpha 在不同预算下该怎么调就有感觉了——预算小的时候多压 alpha,预算大的时候可以更信任 proposal。这是调度,不是调参。
所以说到底,Narcissus 干的事不是让 LLM 更会写,而是把 LLM 写过的东西按语法树存起来,在搜索的每个分叉口当局部先验用。错误的语法树里还藏着一套完整的、条件化的、局部大概率可信的概率分布。能不能把这份分布从错误的整体里取出来,取决于你先把它当成一棵树,还是先把它压成一张频数表。